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://

251 lines (210 loc) 7.88 kB
import {decodeSanityNodeData} from '@repo/visual-editing-helpers/csm' import type { ElementNode, OverlayElement, ResolvedElement, SanityNode, SanityStegaNode, } from '../types' import {findNonInlineElement} from './elements' import {testAndDecodeStega} 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.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 objects, each containing an HTML element and decoded sanity data * @internal */ export function findSanityNodes( el: ElementNode | ChildNode | {childNodes: Array<ElementNode>}, ): ResolvedElement[] { const elements: ResolvedElement[] = [] function addElement(element: ElementNode, data: SanityStegaNode | string) { 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 } elements.push({ elements: { element, measureElement, }, sanity, }) } if (el) { for (const node of el.childNodes) { 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).map(({sanity}) => sanity) // If there are inconsistent node types, continue if (!nodesInTarget.map((n) => isSanityNode(n)).every((n, _i, arr) => n === arr[0])) { continue } const commonData = findCommonSanityData(nodesInTarget) if (commonData) { elements.push({ 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) continue addElement(parentElement, data) } // 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') { continue } // Prefer elements with explicit data attributes if (node.dataset?.['sanity']) { addElement(node, node.dataset['sanity']) } // Look for legacy sanity data attributes else if (node.dataset?.['sanityEditInfo']) { addElement(node, node.dataset['sanityEditInfo']) } else if (isImgElement(node)) { const data = testAndDecodeStega(node.alt, true) if (!data) continue addElement(node, data) // No need to recurse for img elements continue } else if (isTimeElement(node)) { const data = testAndDecodeStega(node.dateTime, true) if (!data) continue addElement(node, data) } else if (isSvgRootElement(node)) { if (!node.ariaLabel) continue const data = testAndDecodeStega(node.ariaLabel, true) if (!data) continue addElement(node, data) } elements.push(...findSanityNodes(node)) } } } return elements } 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 && !elDragDisabled && isSanityNode(elData.sanity) && sanityNodesExistInSameArray(sanity, elData.sanity) && sharedDragGroup && elHasSanityAttribution ) { acc.push(elData) } return acc }, []) if (group.length <= 1) return null return group }