UNPKG

@sanity/visual-editing

Version:

[![npm stat](https://img.shields.io/npm/dm/@sanity/visual-editing.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@sanity/visual-editing) [![npm version](https://img.shields.io/npm/v/@sanity/visual-editing.svg?style=flat-square)](https://

346 lines (296 loc) 10.4 kB
import {decodeSanityNodeData} from '@sanity/visual-editing-csm' import type { ElementNode, OverlayElement, ResolvedElement, ResolvedElementReason, ResolvedElementTarget, ResolvingElement, SanityNode, SanityStegaNode, } from '../types' import {findNonInlineElement} from './elements' import {testAndDecodeStega, testVercelStegaRegex} from './stega' const isElementNode = (node: ChildNode): node is ElementNode => node.nodeType === Node.ELEMENT_NODE const isImgElement = (el: ElementNode): el is HTMLImageElement => el.tagName === 'IMG' const isTimeElement = (el: ElementNode): el is HTMLTimeElement => el.tagName === 'TIME' const isSvgRootElement = (el: ElementNode): el is SVGSVGElement => el.tagName.toUpperCase() === 'SVG' export function isSanityNode(node: SanityNode | SanityStegaNode): node is SanityNode { return 'path' in node } /** * Finds commonality between two document paths strings * @param first First path to compare * @param second Second path to compare * @returns A common path */ export function findCommonPath(first: string, second: string): string { let firstParts = first.split('.') let secondParts = second.split('.') const maxLength = Math.min(firstParts.length, secondParts.length) firstParts = firstParts.slice(0, maxLength).reverse() secondParts = secondParts.slice(0, maxLength).reverse() return firstParts .reduce((parts, part, i) => (part === secondParts[i] ? [...parts, part] : []), [] as string[]) .reverse() .join('.') } /** * Returns common Sanity node data from multiple nodes * If document paths are present, tries to resolve a common path * @param nodes An array of Sanity nodes * @returns A single sanity node or undefined * @internal */ export function findCommonSanityData( nodes: (SanityNode | SanityStegaNode)[], ): SanityNode | SanityStegaNode | undefined { // If there are no nodes, or inconsistent node types if (!nodes.length || !nodes.map((n) => isSanityNode(n)).every((n, _i, arr) => n === arr[0])) { return undefined } // If legacy nodes, return first match (no common pathfinding) if (!isSanityNode(nodes[0])) return nodes[0] const sanityNodes = nodes.filter(isSanityNode) let common: SanityNode | undefined = nodes[0] const consistentValueKeys: Array<keyof SanityNode> = [ 'projectId', 'dataset', 'id', 'baseUrl', 'workspace', 'tool', ] for (let i = 1; i < sanityNodes.length; i++) { const node = sanityNodes[i] if (consistentValueKeys.some((key) => node[key] !== common?.[key])) { common = undefined break } common = {...common, path: findCommonPath(common.path, node.path)} } return common } /** * Finds nodes containing sanity specific data * @param el - A parent element to traverse * @returns An array of overlay targets * @internal */ export function findSanityNodes( el: ElementNode | ChildNode | {childNodes: Array<ElementNode>}, ): ResolvedElement[] { const mainResults: Omit<ResolvedElement, 'commonSanity'>[] = [] function createResolvedElement( element: ElementNode, data: SanityStegaNode | string, reason: ResolvedElementReason, preventGrouping?: boolean, ): ResolvingElement | undefined { const sanity = decodeSanityNodeData(data) if (!sanity) { return } // resize observer does not fire for non-replaced inline elements https://drafts.csswg.org/resize-observer/#intro const measureElement = findNonInlineElement(element) if (!measureElement) { return } return { elements: { element, measureElement, }, sanity, reason, preventGrouping, } } function resolveNode(node: ChildNode): ResolvingElement | undefined { const {nodeType, parentElement, textContent} = node // If an edit target is found, find common paths if (isElementNode(node) && node.dataset?.['sanityEditTarget'] !== undefined) { const nodesInTarget = findSanityNodes(node) const commonData = findCommonSanityData( nodesInTarget .map((node) => (node.type === 'element' ? node.commonSanity : undefined)) .filter((n) => n !== undefined), ) if (commonData) { return { reason: 'edit-target', elements: { element: node, measureElement: node, }, sanity: commonData, } } // Check non-empty, child-only text nodes for stega strings } else if (nodeType === Node.TEXT_NODE && parentElement && textContent) { const data = testAndDecodeStega(textContent) if (!data) return return createResolvedElement(parentElement, data, 'stega-text', true) } // Check element nodes for data attributes, alt tags, etc else if (isElementNode(node)) { // Do not traverse script tags // Do not traverse the visual editing overlay if (node.tagName === 'SCRIPT' || node.tagName === 'SANITY-VISUAL-EDITING') { return } // Prefer elements with explicit data attributes if (node.dataset?.['sanity']) { return createResolvedElement( node, node.dataset['sanity'], 'data-attribute', Boolean(node.textContent && testVercelStegaRegex(node.textContent)), ) } // Look for legacy sanity data attributes else if (node.dataset?.['sanityEditInfo']) { return createResolvedElement( node, node.dataset['sanityEditInfo'], 'data-attribute', Boolean(node.textContent && testVercelStegaRegex(node.textContent)), ) } else if (isImgElement(node)) { const data = testAndDecodeStega(node.alt, true) if (!data) return return createResolvedElement(node, data, 'stega-attribute') } else if (isTimeElement(node)) { const data = testAndDecodeStega(node.dateTime, true) if (!data) return return createResolvedElement(node, data, 'stega-attribute') } else if (isSvgRootElement(node)) { if (!node.ariaLabel) return const data = testAndDecodeStega(node.ariaLabel, true) if (!data) return return createResolvedElement(node, data, 'stega-attribute') } } return } function processNode( node: ChildNode, _parentGroup: Omit<ResolvedElement, 'commonSanity'> | undefined, ): void { const resolvedElement = resolveNode(node) let parentGroup: Omit<ResolvedElement, 'commonSanity'> | undefined = _parentGroup if (isElementNode(node) && node.dataset?.['sanityEditGroup'] !== undefined) { parentGroup = { type: 'group', elements: { element: node, measureElement: node, }, targets: [], } mainResults.push(parentGroup) } if (resolvedElement) { const target: ResolvedElementTarget = { elements: resolvedElement.elements, sanity: resolvedElement.sanity, reason: resolvedElement.reason, } if (parentGroup && !resolvedElement.preventGrouping) { parentGroup.targets.push(target) } else { mainResults.push({ elements: resolvedElement.elements, type: 'element', targets: [target], }) } } const shouldTraverseNode = isElementNode(node) && !isImgElement(node) && !(node.tagName === 'SCRIPT' || node.tagName === 'SANITY-VISUAL-EDITING') if (shouldTraverseNode) { for (const childNode of node.childNodes) { processNode(childNode, parentGroup) } } } if (el) { for (const node of el.childNodes) { processNode(node, undefined) } } return mainResults .map((node) => { if (node.targets.length === 0 && node.type === 'group') { // Always return empty groups so the controller can unregister them return { ...node, commonSanity: undefined, } } const commonSanity = node.targets.length === 1 ? node.targets[0].sanity : findCommonSanityData( node.targets.map(({sanity}) => sanity).filter((n) => n !== undefined), ) || node.targets[0].sanity if (!commonSanity) return null return { ...node, commonSanity, } }) .filter((node) => node !== null) } export function isSanityArrayPath(path: string): boolean { const lastDotIndex = path.lastIndexOf('.') const lastPathItem = path.substring(lastDotIndex, path.length) return lastPathItem.includes('[') } export function getSanityNodeArrayPath(path: string): string | null { if (!isSanityArrayPath(path)) return null const split = path.split('.') split[split.length - 1] = split[split.length - 1].replace(/\[.*?\]/g, '[]') return split.join('.') } export function sanityNodesExistInSameArray( sanityNode1: SanityNode, sanityNode2: SanityNode, ): boolean { if (!isSanityArrayPath(sanityNode1.path) || !isSanityArrayPath(sanityNode2.path)) return false return getSanityNodeArrayPath(sanityNode1.path) === getSanityNodeArrayPath(sanityNode2.path) } export function resolveDragAndDropGroup( element: ElementNode, sanity: SanityNode | SanityStegaNode, elementSet: Set<ElementNode>, elementsMap: WeakMap<ElementNode, OverlayElement>, ): null | OverlayElement[] { if (!element.getAttribute('data-sanity')) return null if (element.getAttribute('data-sanity-drag-disable')) return null if (!sanity || !isSanityNode(sanity) || !isSanityArrayPath(sanity.path)) return null const targetDragGroup = element.getAttribute('data-sanity-drag-group') const group = [...elementSet].reduce<OverlayElement[]>((acc, el) => { const elData = elementsMap.get(el) const elDragDisabled = el.getAttribute('data-sanity-drag-disable') const elDragGroup = el.getAttribute('data-sanity-drag-group') const elHasSanityAttribution = el.getAttribute('data-sanity') !== null const sharedDragGroup = targetDragGroup !== null ? targetDragGroup === elDragGroup : true if ( elData?.sanity && !elDragDisabled && isSanityNode(elData.sanity) && sanityNodesExistInSameArray(sanity, elData.sanity) && sharedDragGroup && elHasSanityAttribution ) { acc.push(elData) } return acc }, []) if (group.length <= 1) return null return group }