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

1 lines 147 kB
{"version":3,"file":"index.cjs","sources":["../../src/ui/History.tsx","../../src/ui/Meta.ts","../../src/util/useDragEvents.ts","../../src/ui/schema/SchemaContext.tsx","../../src/ui/schema/useSchema.ts","../../src/ui/context-menu/contextMenuItems.tsx","../../src/ui/context-menu/ContextMenu.tsx","../../src/util/getLinkHref.ts","../../src/ui/preview/PreviewSnapshotsContext.tsx","../../src/ui/ElementOverlay.tsx","../../src/ui/preview/usePreviewSnapshots.ts","../../src/ui/OverlayDragGroupRect.tsx","../../src/ui/OverlayDragInsertMarker.tsx","../../src/ui/OverlayDragPreview.tsx","../../src/ui/OverlayMinimapPrompt.tsx","../../src/ui/elementsReducer.ts","../../src/ui/overlayStateReducer.ts","../../src/ui/preview/PreviewSnapshotsProvider.tsx","../../src/ui/schema/SchemaProvider.tsx","../../src/ui/shared-state/SharedStateProvider.tsx","../../src/ui/telemetry/sendTelemetry.ts","../../src/ui/usePerspectiveSync.tsx","../../src/ui/useReportDocuments.ts","../../src/ui/Overlays.tsx","../../src/ui/useController.tsx","../../src/ui/Refresh.tsx","../../src/ui/useDatasetMutator.ts","../../src/optimistic/state/createSharedListener.ts","../../src/ui/VisualEditing.tsx","../../src/ui/useComlink.tsx"],"sourcesContent":["import {useEffect, type FunctionComponent} from 'react'\nimport type {HistoryAdapter, VisualEditingNode} from '../types'\n\n/**\n * @internal\n */\nexport const History: FunctionComponent<{\n comlink: VisualEditingNode\n history?: HistoryAdapter\n}> = (props) => {\n const {comlink, history} = props\n\n useEffect(() => {\n return comlink?.on('presentation/navigate', (data) => {\n history?.update(data)\n })\n }, [comlink, history])\n\n useEffect(() => {\n if (history) {\n return history.subscribe((update) => {\n update.title = update.title || document.title\n comlink?.post('visual-editing/navigate', update)\n })\n }\n return\n }, [comlink, history])\n\n return null\n}\n","import {useEffect, type FunctionComponent} from 'react'\nimport type {VisualEditingNode} from '../types'\n\n/**\n * @internal\n */\nexport const Meta: FunctionComponent<{\n comlink: VisualEditingNode\n}> = (props) => {\n const {comlink} = props\n\n useEffect(() => {\n const sendMeta = () => {\n comlink.post('visual-editing/meta', {title: document.title})\n }\n\n const observer = new MutationObserver(([mutation]) => {\n if (mutation.target.nodeName === 'TITLE') {\n sendMeta()\n }\n })\n\n observer.observe(document.head, {\n subtree: true,\n characterData: true,\n childList: true,\n })\n\n sendMeta()\n\n return () => observer.disconnect()\n }, [comlink])\n\n return null\n}\n","import {at, insert, remove} from '@sanity/mutate'\nimport {get as getFromPath} from '@sanity/util/paths'\nimport {useCallback, useEffect} from 'react'\nimport {useDocuments} from '../react/useDocuments'\nimport type {DragEndEvent, DragInsertPosition} from '../types'\nimport {getArrayItemKeyAndParentPath} from './mutations'\n\n// Finds the node that the drag end event was relative to, and the relative\n// position the new element should be inserted in. If the reference node was\n// \"top\" or \"left\", we insert \"after\". If it was \"bottom\" or \"right\", we insert\n// \"before\".\nfunction getReferenceNodeAndInsertPosition(position: DragInsertPosition) {\n if (position) {\n const {top, right, bottom, left} = position\n if (left || top) {\n return {node: (left ?? top)!.sanity, position: 'after' as const}\n } else if (right || bottom) {\n return {node: (right ?? bottom)!.sanity, position: 'before' as const}\n }\n }\n return undefined\n}\n\nexport function useDragEndEvents(): {\n dispatchDragEndEvent: (event: DragEndEvent) => void\n} {\n const {getDocument} = useDocuments()\n\n useEffect(() => {\n const handler = (e: CustomEvent<DragEndEvent>) => {\n const {insertPosition, target, preventInsertDefault} = e.detail\n\n if (preventInsertDefault) return\n\n const reference = getReferenceNodeAndInsertPosition(insertPosition)\n if (reference) {\n const doc = getDocument(target.id)\n // We must have access to the document actor in order to perform the\n // necessary mutations. If this is undefined, something went wrong when\n // resolving the currently in use documents\n const {node, position} = reference\n // Get the key of the element that was dragged\n const {key: targetKey, hasExplicitKey} = getArrayItemKeyAndParentPath(target)\n // Get the key of the reference element, and path to the parent array\n const {path: arrayPath, key: referenceItemKey} = getArrayItemKeyAndParentPath(node)\n // Don't patch if the keys match, as this means the item was only\n // dragged to its existing position, i.e. not moved\n if (arrayPath && referenceItemKey && referenceItemKey !== targetKey) {\n doc.patch(async ({getSnapshot}) => {\n const snapshot = await getSnapshot()\n // Get the current value of the element we dragged, as we will need\n // to clone this into the new position\n const elementValue = getFromPath(snapshot, target.path)\n\n if (hasExplicitKey) {\n return [\n // Remove the original dragged item\n at(arrayPath, remove({_key: targetKey})),\n // Insert the cloned dragged item into its new position\n at(arrayPath, insert(elementValue, position, {_key: referenceItemKey})),\n ]\n } else {\n // handle reordering for primitives\n return [\n // Remove the original dragged item\n at(arrayPath, remove(~~targetKey)),\n // Insert the cloned dragged item into its new position\n at(\n arrayPath,\n insert(\n elementValue,\n position,\n // if target key is < reference, each item in the array's index will be one less due to the previous removal\n referenceItemKey > targetKey ? ~~referenceItemKey - 1 : ~~referenceItemKey,\n ),\n ),\n ]\n }\n })\n }\n }\n }\n window.addEventListener('sanity/dragEnd', handler as EventListener)\n return () => {\n window.removeEventListener('sanity/dragEnd', handler as EventListener)\n }\n }, [getDocument])\n\n const dispatchDragEndEvent = useCallback((event: DragEndEvent) => {\n const customEvent = new CustomEvent<DragEndEvent>('sanity/dragEnd', {\n detail: event,\n cancelable: true,\n })\n window.dispatchEvent(customEvent)\n }, [])\n\n return {dispatchDragEndEvent}\n}\n","import type {\n DocumentSchema,\n ResolvedSchemaTypeMap,\n SanityNode,\n SanityStegaNode,\n SchemaType,\n TypeSchema,\n} from '@repo/visual-editing-helpers'\nimport {createContext} from 'react'\nimport type {OverlayElementField, OverlayElementParent} from '../../types'\n\nexport interface SchemaContextValue {\n getField: (node: SanityNode | SanityStegaNode) => {\n field: OverlayElementField\n parent: OverlayElementParent\n }\n getType: <T extends 'document' | 'type' = 'document'>(\n node: SanityNode | SanityStegaNode | string,\n type?: T,\n ) => T extends 'document' ? DocumentSchema | undefined : TypeSchema | undefined\n resolvedTypes: ResolvedSchemaTypeMap\n schema: SchemaType[]\n}\n\nexport const SchemaContext = createContext<SchemaContextValue | null>(null)\n","import {useContext} from 'react'\nimport {SchemaContext, type SchemaContextValue} from './SchemaContext'\n\nexport function useSchema(): SchemaContextValue {\n const context = useContext(SchemaContext)\n\n if (!context) {\n throw new Error('Schema context is missing')\n }\n\n return context\n}\n","import type {\n SanityNode,\n SchemaArrayItem,\n SchemaNode,\n SchemaUnionNode,\n SchemaUnionOption,\n} from '@repo/visual-editing-helpers'\nimport {\n ArrowDownIcon,\n ArrowUpIcon,\n CopyIcon,\n InsertAboveIcon,\n InsertBelowIcon,\n PublishIcon,\n RemoveIcon,\n SortIcon,\n UnpublishIcon,\n} from '@sanity/icons'\nimport type {SchemaType} from '@sanity/types'\nimport {MenuGroup} from '@sanity/ui'\nimport {type FunctionComponent} from 'react'\nimport type {OptimisticDocument} from '../../optimistic'\nimport {InsertMenu} from '../../overlay-components/components/InsertMenu'\nimport type {ContextMenuNode, OverlayElementField, OverlayElementParent} from '../../types'\nimport {getNodeIcon} from '../../util/getNodeIcon'\nimport {\n getArrayDuplicatePatches,\n getArrayInsertPatches,\n getArrayMovePatches,\n getArrayRemovePatches,\n} from '../../util/mutations'\n\nexport function getArrayRemoveAction(node: SanityNode, doc: OptimisticDocument): () => void {\n if (!node.type) throw new Error('Node type is missing')\n return () =>\n doc.patch(async ({getSnapshot}) => getArrayRemovePatches(node, (await getSnapshot())!))\n}\n\nfunction getArrayInsertAction(\n node: SanityNode,\n doc: OptimisticDocument,\n insertType: string,\n position: 'before' | 'after',\n): () => void {\n if (!node.type) throw new Error('Node type is missing')\n return () => doc.patch(() => getArrayInsertPatches(node, insertType, position))\n}\n\nfunction getDuplicateAction(node: SanityNode, doc: OptimisticDocument): () => void {\n if (!node.type) throw new Error('Node type is missing')\n return () =>\n doc.patch(async ({getSnapshot}) => getArrayDuplicatePatches(node, (await getSnapshot())!))\n}\n\nexport function getContextMenuItems(context: {\n doc: OptimisticDocument\n field: OverlayElementField\n node: SanityNode\n parent: OverlayElementParent\n}): Promise<ContextMenuNode[]> {\n const {node, field, parent, doc} = context\n if (field?.type === 'arrayItem') {\n return getContextMenuArrayItems({node, field, doc})\n }\n if (parent?.type === 'union') {\n return getContextMenuUnionItems({node, parent, doc})\n }\n\n return Promise.resolve([])\n}\n\nfunction getDuplicateItem(context: {doc: OptimisticDocument; node: SanityNode}) {\n const {node, doc} = context\n if (!doc) return []\n return [\n {\n type: 'action' as const,\n label: 'Duplicate',\n icon: CopyIcon,\n action: getDuplicateAction(node, doc),\n },\n ]\n}\n\nfunction getRemoveItems(context: {doc: OptimisticDocument; node: SanityNode}) {\n const {node, doc} = context\n if (!doc) return []\n return [\n {\n type: 'action' as const,\n label: 'Remove',\n icon: RemoveIcon,\n action: getArrayRemoveAction(node, doc),\n },\n ]\n}\n\nasync function getMoveItems(\n context: {\n doc: OptimisticDocument\n node: SanityNode\n },\n withDivider = true,\n) {\n const {node, doc} = context\n if (!doc) return []\n\n const items: ContextMenuNode[] = []\n const groupItems: ContextMenuNode[] = []\n\n const [moveUpPatches, moveDownPatches, moveFirstPatches, moveLastPatches] = await Promise.all([\n getArrayMovePatches(node, doc, 'previous'),\n getArrayMovePatches(node, doc, 'next'),\n getArrayMovePatches(node, doc, 'first'),\n getArrayMovePatches(node, doc, 'last'),\n ])\n\n if (moveFirstPatches.length) {\n groupItems.push({\n type: 'action',\n label: 'To top',\n icon: PublishIcon,\n action: () => doc.patch(moveFirstPatches),\n })\n }\n if (moveUpPatches.length) {\n groupItems.push({\n type: 'action',\n label: 'Up',\n icon: ArrowUpIcon,\n action: () => doc.patch(moveUpPatches),\n })\n }\n if (moveDownPatches.length) {\n groupItems.push({\n type: 'action',\n label: 'Down',\n icon: ArrowDownIcon,\n action: () => doc.patch(moveDownPatches),\n })\n }\n if (moveLastPatches.length) {\n groupItems.push({\n type: 'action',\n label: 'To bottom',\n icon: UnpublishIcon,\n action: () => doc.patch(moveLastPatches),\n })\n }\n\n if (groupItems.length) {\n items.push({\n type: 'group',\n label: 'Move',\n icon: SortIcon,\n items: groupItems,\n })\n if (withDivider) {\n items.push({type: 'divider'})\n }\n }\n\n return items\n}\n\nasync function getContextMenuArrayItems(context: {\n doc: OptimisticDocument\n field: SchemaArrayItem\n node: SanityNode\n}): Promise<ContextMenuNode[]> {\n const {node, field, doc} = context\n const items: ContextMenuNode[] = []\n items.push(...getDuplicateItem(context))\n items.push(...getRemoveItems(context))\n items.push(...(await getMoveItems(context)))\n\n items.push({\n type: 'action',\n label: 'Insert before',\n icon: InsertAboveIcon,\n action: getArrayInsertAction(node, doc, field.name, 'before'),\n })\n items.push({\n type: 'action',\n label: 'Insert after',\n icon: InsertBelowIcon,\n action: getArrayInsertAction(node, doc, field.name, 'after'),\n })\n\n return items\n}\n\nconst InsertMenuWrapper: FunctionComponent<{\n label: string\n onSelect: (schemaType: SchemaType) => void\n parent: SchemaUnionNode<SchemaNode>\n width: number | undefined\n boundaryElement: HTMLDivElement | null\n}> = (props) => {\n const {label, parent, width, onSelect, boundaryElement} = props\n\n return (\n <MenuGroup\n fontSize={1}\n icon={InsertBelowIcon}\n padding={2}\n popover={{\n arrow: false,\n constrainSize: true,\n floatingBoundary: boundaryElement,\n padding: 0,\n placement: 'right-start',\n fallbackPlacements: [\n 'left-start',\n 'right',\n 'left',\n 'right-end',\n 'left-end',\n 'bottom',\n 'top',\n ],\n preventOverflow: true,\n width,\n __unstable_margins: [4, 4, 4, 4],\n }}\n space={2}\n text={label}\n >\n <InsertMenu node={parent} onSelect={onSelect} />\n </MenuGroup>\n )\n}\n\nasync function getContextMenuUnionItems(context: {\n doc: OptimisticDocument\n node: SanityNode\n parent: SchemaUnionNode<SchemaNode>\n}): Promise<ContextMenuNode[]> {\n const {doc, node, parent} = context\n const items: ContextMenuNode[] = []\n items.push(...getDuplicateItem(context))\n items.push(...getRemoveItems(context))\n items.push(...(await getMoveItems(context)))\n\n if (parent.options?.insertMenu) {\n const insertMenuOptions = parent.options.insertMenu || {}\n const width = insertMenuOptions.views?.some((view) => view.name === 'grid') ? 0 : undefined\n\n items.push({\n type: 'custom',\n component: ({boundaryElement}) => {\n const onSelect = (schemaType: SchemaType) => {\n const action = getArrayInsertAction(node, doc, schemaType.name, 'before')\n action()\n }\n return (\n <InsertMenuWrapper\n label=\"Insert before\"\n onSelect={onSelect}\n parent={parent}\n width={width}\n boundaryElement={boundaryElement}\n />\n )\n },\n })\n\n items.push({\n type: 'custom',\n component: ({boundaryElement}) => {\n const onSelect = (schemaType: SchemaType) => {\n const action = getArrayInsertAction(node, doc, schemaType.name, 'after')\n action()\n }\n return (\n <InsertMenuWrapper\n label=\"Insert after\"\n onSelect={onSelect}\n parent={parent}\n width={width}\n boundaryElement={boundaryElement}\n />\n )\n },\n })\n } else {\n items.push({\n type: 'group',\n label: 'Insert before',\n icon: InsertAboveIcon,\n items: (\n parent.of.filter((item) => item.type === 'unionOption') as SchemaUnionOption<SchemaNode>[]\n ).map((t) => {\n return {\n type: 'action' as const,\n icon: getNodeIcon(t),\n label: t.name === 'block' ? 'Paragraph' : t.title || t.name,\n action: getArrayInsertAction(node, doc, t.name, 'before'),\n }\n }),\n })\n items.push({\n type: 'group',\n label: 'Insert after',\n icon: InsertBelowIcon,\n items: (\n parent.of.filter((item) => item.type === 'unionOption') as SchemaUnionOption<SchemaNode>[]\n ).map((t) => {\n return {\n type: 'action' as const,\n label: t.name === 'block' ? 'Paragraph' : t.title || t.name,\n icon: getNodeIcon(t),\n action: getArrayInsertAction(node, doc, t.name, 'after'),\n }\n }),\n })\n }\n\n return items\n}\n","import {\n Box,\n Flex,\n Menu,\n MenuDivider,\n MenuGroup,\n MenuItem,\n Popover,\n Spinner,\n Stack,\n Text,\n type PopoverMargins,\n} from '@sanity/ui'\nimport {useCallback, useEffect, useMemo, useState, type FunctionComponent} from 'react'\nimport {useDocuments} from '../../react/useDocuments'\nimport type {ContextMenuNode, ContextMenuProps} from '../../types'\nimport {getNodeIcon} from '../../util/getNodeIcon'\nimport {PopoverPortal} from '../PopoverPortal'\nimport {useSchema} from '../schema/useSchema'\nimport {getContextMenuItems} from './contextMenuItems'\n\nconst POPOVER_MARGINS: PopoverMargins = [-4, 4, -4, 4]\n\nfunction ContextMenuItem(props: {\n node: ContextMenuNode\n onDismiss?: () => void\n boundaryElement: HTMLDivElement | null\n}) {\n const {node, onDismiss, boundaryElement} = props\n\n const onClick = useCallback(() => {\n if (node.type === 'action') {\n node.action?.()\n onDismiss?.()\n }\n }, [node, onDismiss])\n\n if (node.type === 'divider') {\n return <MenuDivider />\n }\n\n if (node.type === 'action') {\n return (\n <MenuItem\n fontSize={1}\n hotkeys={node.hotkeys}\n icon={node.icon}\n padding={2}\n space={2}\n text={node.label}\n disabled={!node.action}\n onClick={onClick}\n />\n )\n }\n\n if (node.type === 'group') {\n return (\n <MenuGroup\n fontSize={1}\n icon={node.icon}\n padding={2}\n // @todo when this PR lands https://github.com/sanity-io/ui/pull/1454\n // menu={{\n // padding: 0,\n // }}\n popover={{\n arrow: false,\n constrainSize: true,\n placement: 'right-start',\n fallbackPlacements: [\n 'left-start',\n 'right',\n 'left',\n 'right-end',\n 'left-end',\n 'bottom',\n 'top',\n ],\n preventOverflow: true,\n __unstable_margins: POPOVER_MARGINS,\n }}\n space={2}\n text={node.label}\n >\n {node.items.map((item, itemIndex) => (\n <ContextMenuItem\n key={itemIndex}\n node={item}\n onDismiss={onDismiss}\n boundaryElement={boundaryElement}\n />\n ))}\n </MenuGroup>\n )\n }\n\n if (node.type === 'custom') {\n const {component: Component} = node\n return <Component boundaryElement={boundaryElement} />\n }\n\n return null\n}\n\nexport const ContextMenu: FunctionComponent<ContextMenuProps> = (props) => {\n const {\n node,\n onDismiss,\n position: {x, y},\n } = props\n\n const [boundaryElement, setBoundaryElement] = useState<HTMLDivElement | null>(null)\n\n const {getField} = useSchema()\n const {getDocument} = useDocuments()\n\n const {field, parent} = getField(node)\n\n const title = useMemo(() => {\n return field?.title || field?.name || 'Unknown type'\n }, [field])\n\n const [items, setItems] = useState<ContextMenuNode[] | undefined>(undefined)\n\n useEffect(() => {\n const fetchContextMenuItems = async () => {\n const doc = getDocument(node.id)\n if (!doc) return\n const items = await getContextMenuItems({node, field, parent, doc})\n setItems(items)\n }\n fetchContextMenuItems()\n }, [field, node, parent, getDocument])\n\n const contextMenuReferenceElement = useMemo(() => {\n return {\n getBoundingClientRect: () => ({\n bottom: y,\n left: x,\n right: x,\n top: y,\n width: 0,\n height: 0,\n }),\n } as HTMLElement\n }, [x, y])\n\n const icon = useMemo(() => {\n return getNodeIcon(field)\n }, [field])\n\n return (\n <PopoverPortal setBoundaryElement={setBoundaryElement} onDismiss={onDismiss}>\n <Popover\n __unstable_margins={POPOVER_MARGINS}\n arrow={false}\n open\n placement=\"right-start\"\n referenceElement={contextMenuReferenceElement}\n content={\n <Menu style={{minWidth: 120, maxWidth: 160}}>\n <Flex gap={2} padding={2}>\n <Box flex=\"none\">{items ? <Text size={1}>{icon}</Text> : <Spinner size={1} />}</Box>\n\n <Stack flex={1} space={2}>\n <Text size={1} weight=\"semibold\">\n {items ? title : 'Loading...'}\n </Text>\n </Stack>\n </Flex>\n\n {items && (\n <>\n <MenuDivider />\n {items.map((item, i) => (\n <ContextMenuItem\n key={i}\n node={item}\n onDismiss={onDismiss}\n boundaryElement={boundaryElement}\n />\n ))}\n </>\n )}\n </Menu>\n }\n >\n <div\n key={`${x}-${y}`}\n style={{\n position: 'absolute',\n left: x,\n top: y,\n }}\n />\n </Popover>\n </PopoverPortal>\n )\n}\n","export function getLinkHref(href: string, referer: string): string {\n try {\n const parsed = new URL(href, typeof location === 'undefined' ? undefined : location.origin)\n if (parsed.hash) {\n const hash = new URL(getLinkHref(parsed.hash.slice(1), referer))\n return `${parsed.origin}${parsed.pathname}${parsed.search}#${hash.pathname}${hash.search}`\n }\n parsed.searchParams.set('preview', referer)\n return parsed.toString()\n } catch {\n return href\n }\n}\n","import type {PreviewSnapshot} from '@repo/visual-editing-helpers'\nimport {createContext} from 'react'\n\nexport type PreviewSnapshotsContextValue = PreviewSnapshot[]\n\nexport const PreviewSnapshotsContext = createContext<PreviewSnapshotsContextValue | null>(null)\n","import {pathToUrlString} from '@repo/visual-editing-helpers'\nimport {createEditUrl, studioPath} from '@sanity/client/csm'\nimport {DocumentIcon, DragHandleIcon} from '@sanity/icons'\nimport {Box, Card, Flex, Text} from '@sanity/ui'\nimport {\n isValidElement,\n memo,\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useSyncExternalStore,\n type CSSProperties,\n type FunctionComponent,\n type ReactElement,\n} from 'react'\nimport scrollIntoView from 'scroll-into-view-if-needed'\nimport {styled} from 'styled-components'\nimport {PointerEvents} from '../overlay-components/components/PointerEvents'\nimport type {\n ElementFocusedState,\n ElementNode,\n OverlayComponent,\n OverlayComponentResolver,\n OverlayComponentResolverContext,\n OverlayRect,\n SanityNode,\n SanityStegaNode,\n} from '../types'\nimport {getLinkHref} from '../util/getLinkHref'\nimport {usePreviewSnapshots} from './preview/usePreviewSnapshots'\nimport {useSchema} from './schema/useSchema'\n\nconst isReactElementOverlayComponent = (\n component:\n | OverlayComponent\n | {component: OverlayComponent; props?: Record<string, unknown>}\n | Array<OverlayComponent | {component: OverlayComponent; props?: Record<string, unknown>}>\n | ReactElement,\n): component is ReactElement => {\n return isValidElement(component)\n}\n\nexport interface ElementOverlayProps {\n componentResolver?: OverlayComponentResolver\n draggable: boolean\n element: ElementNode\n focused: ElementFocusedState\n hovered: boolean\n isDragging: boolean\n node: SanityNode | SanityStegaNode\n rect: OverlayRect\n showActions: boolean\n wasMaybeCollapsed: boolean\n enableScrollIntoView: boolean\n}\n\nconst Root = styled(Card)`\n background-color: var(--overlay-bg);\n border-radius: 3px;\n pointer-events: none;\n position: absolute;\n will-change: transform;\n box-shadow: var(--overlay-box-shadow);\n transition: none;\n\n --overlay-bg: transparent;\n --overlay-box-shadow: inset 0 0 0 1px transparent;\n\n [data-overlays] & {\n --overlay-bg: color-mix(in srgb, transparent 95%, var(--card-focus-ring-color));\n --overlay-box-shadow: inset 0 0 0 2px\n color-mix(in srgb, transparent 50%, var(--card-focus-ring-color));\n }\n\n [data-fading-out] & {\n transition:\n box-shadow 1550ms,\n background-color 1550ms;\n\n --overlay-bg: rgba(0, 0, 255, 0);\n --overlay-box-shadow: inset 0 0 0 1px transparent;\n }\n\n &[data-focused] {\n --overlay-box-shadow: inset 0 0 0 1px var(--card-focus-ring-color);\n }\n\n &[data-hovered]:not([data-focused]) {\n transition: none;\n --overlay-box-shadow: inset 0 0 0 2px var(--card-focus-ring-color);\n }\n\n /* [data-unmounted] & {\n --overlay-box-shadow: inset 0 0 0 1px var(--card-focus-ring-color);\n } */\n\n :link {\n text-decoration: none;\n }\n`\n\nconst Actions = styled(Flex)`\n bottom: 100%;\n cursor: pointer;\n pointer-events: none;\n position: absolute;\n right: 0;\n\n [data-hovered] & {\n pointer-events: all;\n }\n`\n\nconst ActionOpen = styled(Card)`\n background-color: var(--card-focus-ring-color);\n right: 0;\n border-radius: 3px;\n\n & [data-ui='Text'] {\n color: #fff;\n white-space: nowrap;\n }\n`\n\nconst Tab = styled(Flex)`\n bottom: 100%;\n cursor: pointer;\n pointer-events: none;\n position: absolute;\n left: 0;\n`\n\nconst Labels = styled(Flex)`\n display: flex;\n align-items: center;\n background-color: var(--card-focus-ring-color);\n right: 0;\n border-radius: 3px;\n & [data-ui='Text'],\n & [data-sanity-icon] {\n color: #fff;\n white-space: nowrap;\n }\n`\n\nfunction createIntentLink(node: SanityNode) {\n const {id, type, path, baseUrl, tool, workspace} = node\n\n return createEditUrl({\n baseUrl,\n workspace,\n tool,\n type: type!,\n id,\n path: pathToUrlString(studioPath.fromString(path)),\n })\n}\n\nconst ElementOverlayInner: FunctionComponent<ElementOverlayProps> = (props) => {\n const {element, focused, componentResolver, node, showActions, draggable} = props\n\n const {getField, getType} = useSchema()\n const schemaType = getType(node)\n const {field, parent} = getField(node)\n\n const href = 'path' in node ? createIntentLink(node) : node.href\n\n const previewSnapshots = usePreviewSnapshots()\n\n const title = useMemo(() => {\n if (!('path' in node)) return undefined\n return previewSnapshots.find((snapshot) => snapshot._id === node.id)?.title\n }, [node, previewSnapshots])\n\n const componentContext = useMemo<OverlayComponentResolverContext | undefined>(() => {\n if (!('path' in node)) return undefined\n if (!field || !schemaType) return undefined\n const type = field.value.type\n\n return {\n document: schemaType,\n element,\n field,\n focused: !!focused,\n node,\n parent,\n type,\n }\n }, [schemaType, element, field, focused, node, parent])\n\n const customComponents = useCustomComponents(componentContext, componentResolver)\n\n const icon = schemaType?.icon ? (\n <div dangerouslySetInnerHTML={{__html: schemaType.icon}} />\n ) : (\n <DocumentIcon />\n )\n\n return (\n <>\n {showActions ? (\n <Actions gap={1} paddingBottom={1} data-sanity-overlay-element>\n <Link href={href} />\n </Actions>\n ) : null}\n\n {title && (\n <Tab gap={1} paddingBottom={1}>\n <Labels gap={2} padding={2}>\n {draggable && (\n <Box marginRight={1}>\n <Text className=\"drag-handle\" size={0}>\n <DragHandleIcon />\n </Text>\n </Box>\n )}\n <Text size={0}>{icon}</Text>\n <Text size={1} weight=\"medium\">\n {title}\n </Text>\n </Labels>\n </Tab>\n )}\n\n {Array.isArray(customComponents)\n ? customComponents.map(({component: Component, props}, i) => {\n return (\n <Component key={i} PointerEvents={PointerEvents} {...componentContext!} {...props} />\n )\n })\n : customComponents}\n </>\n )\n}\n\nexport const ElementOverlay = memo(function ElementOverlay(props: ElementOverlayProps) {\n const {focused, hovered, rect, wasMaybeCollapsed, enableScrollIntoView} = props\n\n const ref = useRef<HTMLDivElement>(null)\n\n const scrolledIntoViewRef = useRef(false)\n\n const style = useMemo<CSSProperties>(\n () => ({\n width: `${rect.w}px`,\n height: `${rect.h}px`,\n transform: `translate(${rect.x}px, ${rect.y}px)`,\n }),\n [rect],\n )\n\n useEffect(() => {\n if (\n !scrolledIntoViewRef.current &&\n !wasMaybeCollapsed &&\n focused === true &&\n ref.current &&\n enableScrollIntoView\n ) {\n const target = ref.current\n scrollIntoView(ref.current, {\n // Workaround issue with scroll-into-view-if-needed struggling with iframes\n behavior: (actions) => {\n if (actions.length === 0) {\n // An empty actions list equals scrolling isn't needed\n return\n }\n // Uses native scrollIntoView to ensure iframes behave correctly\n target.scrollIntoView({\n behavior: 'smooth',\n block: 'center',\n inline: 'nearest',\n })\n },\n scrollMode: 'if-needed',\n block: 'center',\n inline: 'nearest',\n })\n }\n\n scrolledIntoViewRef.current = focused === true\n }, [focused, wasMaybeCollapsed, enableScrollIntoView])\n\n return (\n <Root\n data-focused={focused ? '' : undefined}\n data-hovered={hovered ? '' : undefined}\n ref={ref}\n style={style}\n >\n {hovered && <ElementOverlayInner {...props} />}\n </Root>\n )\n})\n\nfunction useCustomComponents(\n componentContext: OverlayComponentResolverContext | undefined,\n componentResolver: OverlayComponentResolver | undefined,\n) {\n return useMemo(() => {\n if (!componentContext) return undefined\n const resolved = componentResolver?.(componentContext)\n if (!resolved) return undefined\n\n if (isReactElementOverlayComponent(resolved)) {\n return resolved\n }\n\n return (Array.isArray(resolved) ? resolved : [resolved]).map((component) => {\n if (typeof component === 'object' && 'component' in component) {\n return component\n }\n return {component, props: {}}\n })\n }, [componentResolver, componentContext])\n}\n\nconst Link = memo(function Link(props: {href: string}) {\n const referer = useSyncExternalStore(\n useCallback((onStoreChange) => {\n const handlePopState = () => onStoreChange()\n window.addEventListener('popstate', handlePopState)\n return () => window.removeEventListener('popstate', handlePopState)\n }, []),\n () => window.location.href,\n )\n const href = useMemo(() => getLinkHref(props.href, referer), [props.href, referer])\n\n return (\n <Box as=\"a\" href={href} target=\"_blank\" rel=\"noopener noreferrer\">\n <ActionOpen padding={2}>\n <Text size={1} weight=\"medium\">\n Open in Studio\n </Text>\n </ActionOpen>\n </Box>\n )\n})\n","import {useContext} from 'react'\nimport {PreviewSnapshotsContext, type PreviewSnapshotsContextValue} from './PreviewSnapshotsContext'\n\nexport function usePreviewSnapshots(): PreviewSnapshotsContextValue {\n const context = useContext(PreviewSnapshotsContext)\n\n if (!context) {\n throw new Error('Preview Snapshots context is missing')\n }\n\n return context\n}\n","import type {FunctionComponent} from 'react'\nimport type {OverlayRect} from '../types'\n\nexport const OverlayDragGroupRect: FunctionComponent<{\n dragGroupRect: OverlayRect\n}> = ({dragGroupRect}) => {\n return (\n <div\n style={{\n position: 'absolute',\n top: `${dragGroupRect.y}px`,\n left: `${dragGroupRect.x}px`,\n width: `${dragGroupRect.w - 1}px`,\n height: `${dragGroupRect.h - 1}px`,\n border: '1px dashed #f0709b',\n pointerEvents: 'none',\n }}\n ></div>\n )\n}\n","import type {FunctionComponent} from 'react'\nimport type {DragInsertPosition} from '../types'\n\nconst markerThickness = 6\n\nfunction lerp(v0: number, v1: number, t: number) {\n return v0 * (1 - t) + v1 * t\n}\n\nexport const OverlayDragInsertMarker: FunctionComponent<{\n dragInsertPosition: DragInsertPosition\n}> = ({dragInsertPosition}) => {\n if (dragInsertPosition === null) return\n\n const flow = dragInsertPosition?.left || dragInsertPosition?.right ? 'horizontal' : 'vertical'\n\n let x = 0\n let y = 0\n let width = 0\n let height = 0\n const offsetMultiplier = 0.0125\n\n if (flow === 'horizontal') {\n const {left, right} = dragInsertPosition\n\n width = markerThickness\n\n if (right && left) {\n const startX = left.rect.x + left.rect.w\n const endX = right.rect.x\n const targetHeight = Math.min(right.rect.h, left.rect.h)\n const offset = targetHeight * offsetMultiplier\n\n x = lerp(startX, endX, 0.5) - markerThickness / 2\n y = left.rect.y + offset\n\n height = Math.min(right.rect.h, left.rect.h) - offset * 2\n } else if (right && !left) {\n const targetHeight = right.rect.h\n const offset = targetHeight * offsetMultiplier\n\n x = right.rect.x - markerThickness / 2\n y = right.rect.y + offset\n height = right.rect.h - offset * 2\n } else if (left && !right) {\n const targetHeight = left.rect.h\n const offset = targetHeight * offsetMultiplier\n\n x = left.rect.x + left.rect.w - markerThickness / 2\n y = left.rect.y + offset\n height = left.rect.h - offset * 2\n }\n } else {\n const {bottom, top} = dragInsertPosition\n\n if (bottom && top) {\n const startX = Math.min(top.rect.x, bottom.rect.x)\n const startY = top.rect.y + top.rect.h\n const endY = bottom.rect.y\n const targetWidth = Math.min(bottom.rect.w, top.rect.w)\n const offset = targetWidth * offsetMultiplier\n\n height = markerThickness\n\n x = startX + offset\n y = lerp(startY, endY, 0.5) - markerThickness / 2\n width = Math.max(bottom.rect.w, top.rect.w) - offset * 2\n } else if (bottom && !top) {\n const targetWidth = bottom.rect.w\n const offset = targetWidth * offsetMultiplier\n\n x = bottom.rect.x + offset\n y = bottom.rect.y - markerThickness / 2\n width = bottom.rect.w - offset * 2\n height = markerThickness\n } else if (top && !bottom) {\n const targetWidth = top.rect.w\n const offset = targetWidth * offsetMultiplier\n\n x = top.rect.x + offset\n y = top.rect.y + top.rect.h - markerThickness / 2\n width = top.rect.w - offset * 2\n height = markerThickness\n }\n }\n\n return (\n <div\n style={{\n position: 'absolute',\n width: `${width}px`,\n height: `${height}px`,\n transform: `translate(${x}px, ${y}px)`,\n background: '#556bfc',\n border: '2px solid white',\n borderRadius: '999px',\n zIndex: '999999',\n }}\n ></div>\n )\n}\n","import {Card, usePrefersDark, useTheme_v2} from '@sanity/ui'\nimport type {FunctionComponent} from 'react'\nimport {styled} from 'styled-components'\nimport type {DragSkeleton} from '../types'\n\nconst Root = styled.div<{\n $width: number\n $height: number\n $offsetX: number\n $offsetY: number\n $scaleFactor: number\n}>`\n --drag-preview-opacity: 0.98;\n --drag-preview-skeleton-stroke: #ffffff;\n\n @media (prefers-color-scheme: dark) {\n --drag-preview-skeleton-stroke: #383d51;\n }\n\n position: fixed;\n display: grid;\n transform: ${({$scaleFactor, $width, $height}) =>\n `translate(calc(var(--drag-preview-x) - ${$width / 2}px), calc(var(--drag-preview-y) - ${$height / 2}px)) scale(${$scaleFactor})`};\n width: ${({$width}) => `${$width}px`};\n height: ${({$height}) => `${$height}px`};\n z-index: 9999999;\n opacity: var(--drag-preview-opacity);\n cursor: move;\n\n .drag-preview-content-wrapper {\n position: relative;\n width: 100%;\n height: 100%;\n container-type: inline-size;\n }\n\n [data-ui='card'] {\n position: relative;\n width: 100%;\n height: 100%;\n }\n\n .drag-preview-skeleton {\n position: absolute;\n inset: 0;\n\n rect {\n stroke: var(--drag-preview-skeleton-stroke);\n }\n }\n\n .drag-preview-handle {\n position: absolute;\n top: 4cqmin;\n left: 4cqmin;\n width: 6cqmin;\n fill: var(--drag-preview-handle-fill);\n }\n`\n\nfunction clamp(number: number, min: number, max: number): number {\n return number < min ? min : number > max ? max : number\n}\n\nfunction map(number: number, inMin: number, inMax: number, outMin: number, outMax: number): number {\n const mapped: number = ((number - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin\n return clamp(mapped, outMin, outMax)\n}\n\nexport const OverlayDragPreview: FunctionComponent<{skeleton: DragSkeleton}> = ({skeleton}) => {\n const maxSkeletonWidth = Math.min(skeleton.maxWidth, window.innerWidth / 2)\n const scaleFactor = skeleton.w > maxSkeletonWidth ? maxSkeletonWidth / skeleton.w : 1\n\n const offsetX = skeleton.offsetX * scaleFactor\n const offsetY = skeleton.offsetY * scaleFactor\n\n const prefersDark = usePrefersDark()\n const theme = useTheme_v2()\n\n const radius = theme.radius[~~map(skeleton.w, 0, 1920, 1, theme.radius.length - 2)]\n\n const imageRects = skeleton.childRects.filter((r) => r.tagName === 'IMG')\n const textRects = skeleton.childRects.filter((r) => r.tagName !== 'IMG')\n\n return (\n <Root\n $width={skeleton.w}\n $height={skeleton.h}\n $offsetX={offsetX}\n $offsetY={offsetY}\n $scaleFactor={scaleFactor}\n >\n <Card\n radius={radius}\n shadow={4}\n overflow=\"hidden\"\n tone=\"transparent\"\n scheme={prefersDark ? 'dark' : 'light'}\n >\n <div className=\"drag-preview-content-wrapper\">\n <svg className=\"drag-preview-skeleton\" viewBox={`0 0 ${skeleton.w} ${skeleton.h}`}>\n {/* render image rects first to account for background images overlapping text */}\n {[...imageRects, ...textRects].map((r, i) => (\n <rect\n key={i}\n x={r.x}\n y={r.y}\n width={r.w}\n height={r.h}\n fill={theme.color.skeleton.from}\n ></rect>\n ))}\n </svg>\n </div>\n </Card>\n </Root>\n )\n}\n","import {ExpandIcon} from '@sanity/icons'\nimport {Card, Flex, Hotkeys, Text} from '@sanity/ui'\nimport type {FunctionComponent} from 'react'\nimport {styled} from 'styled-components'\n\nconst Root = styled(Card)`\n position: fixed;\n bottom: 2rem;\n left: 2rem;\n`\n\nexport const OverlayMinimapPrompt: FunctionComponent = () => {\n return (\n <Root padding={2} shadow={2} radius={2} style={{zIndex: '999999'}}>\n <Flex align=\"center\" gap={2}>\n <Hotkeys keys={['Shift']} />\n <Text size={1}>Zoom Out</Text>\n <ExpandIcon />\n </Flex>\n </Root>\n )\n}\n","import type {VisualEditingControllerMsg} from '@repo/visual-editing-helpers'\nimport type {ElementState, OverlayMsg} from '../types'\n\n/**\n * Reducer for managing element state from received channel messages\n * @internal\n */\nexport const elementsReducer = (\n elements: ElementState[],\n message: OverlayMsg | VisualEditingControllerMsg,\n): ElementState[] => {\n const {type} = message\n switch (type) {\n case 'element/register': {\n const elementExists = !!elements.find((e) => e.id === message.id)\n if (elementExists) return elements\n\n return [\n ...elements,\n {\n id: message.id,\n activated: false,\n element: message.element,\n focused: false,\n hovered: false,\n rect: message.rect,\n sanity: message.sanity,\n dragDisabled: message.dragDisabled,\n },\n ]\n }\n case 'element/activate':\n return elements.map((e) => {\n if (e.id === message.id) {\n return {...e, activated: true}\n }\n return e\n })\n case 'element/update': {\n return elements.map((e) => {\n if (e.id === message.id) {\n return {...e, sanity: message.sanity, rect: message.rect}\n }\n return e\n })\n }\n case 'element/unregister':\n return elements.filter((e) => e.id !== message.id)\n case 'element/deactivate':\n return elements.map((e) => {\n if (e.id === message.id) {\n return {...e, activated: false, hovered: false}\n }\n return e\n })\n case 'element/mouseenter':\n return elements.map((e) => {\n if (e.id === message.id) {\n return {...e, rect: message.rect, hovered: true}\n }\n return {...e, hovered: false}\n })\n case 'element/mouseleave':\n return elements.map((element) => {\n if (element.id === message.id) {\n return {...element, hovered: false}\n }\n return element\n })\n case 'element/updateRect':\n return elements.map((element) => {\n if (element.id === message.id) {\n return {...element, rect: message.rect}\n }\n return element\n })\n case 'element/click':\n return elements.map((e) => {\n return {...e, focused: e.id === message.id && 'clicked'}\n })\n case 'overlay/blur':\n return elements.map((e) => {\n return {...e, focused: false}\n })\n case 'presentation/blur':\n return elements.map((e) => {\n return {...e, focused: false}\n })\n case 'presentation/focus': {\n // Before setting the focus state of each element, check to see if any\n // element has gained focus from an `element/click` message. Presentation\n // tool \"reflects\" these back as a `presentation/focus` message.\n const clickedElement = elements.find((e) => e.focused === 'clicked')\n return elements.map((e) => {\n // We want to focus any element which matches the received id and path\n const focused =\n 'path' in e.sanity &&\n e.sanity.id === message.data.id &&\n e.sanity.path === message.data.path\n\n // If we have a 'clicked' element, and that element matches, it is a\n // reflection, so we maintain the focus state\n if (clickedElement && e === clickedElement && focused) {\n return e\n }\n\n return {\n ...e,\n // Mark as a dupe if another matching item has been clicked to prevent\n // scrolling, otherwise just set focus as a boolean\n focused: focused && clickedElement ? 'duplicate' : focused,\n }\n })\n }\n default:\n return elements\n }\n}\n","import type {SanityNode, VisualEditingControllerMsg} from '@repo/visual-editing-helpers'\nimport type {ClientPerspective} from '@sanity/client'\nimport type {\n DragInsertPosition,\n DragSkeleton,\n ElementState,\n OverlayMsg,\n OverlayRect,\n} from '../types'\nimport {elementsReducer} from './elementsReducer'\n\nexport interface OverlayState {\n contextMenu: {\n node: SanityNode\n position: {\n x: number\n y: number\n }\n } | null\n focusPath: string\n elements: ElementState[]\n wasMaybeCollapsed: boolean\n perspective: ClientPerspective\n isDragging: boolean\n dragInsertPosition: DragInsertPosition\n dragSkeleton: DragSkeleton | null\n dragShowMinimap: boolean\n dragShowMinimapPrompt: boolean\n dragMinimapTransition: boolean\n dragGroupRect: OverlayRect | null\n}\n\nexport function overlayStateReducer(\n state: OverlayState,\n message: OverlayMsg | VisualEditingControllerMsg,\n): OverlayState {\n const {type} = message\n let {\n contextMenu,\n focusPath,\n perspective,\n isDragging,\n dragInsertPosition,\n dragShowMinimap,\n dragShowMinimapPrompt,\n dragSkeleton,\n dragMinimapTransition,\n dragGroupRect,\n } = state\n let wasMaybeCollapsed = false\n\n if (type === 'presentation/focus') {\n const prevFocusPath = state.focusPath\n\n focusPath = message.data.path\n\n if (prevFocusPath !== focusPath) {\n wasMaybeCollapsed = prevFocusPath.slice(focusPath.length).startsWith('[')\n }\n }\n\n if (type === 'presentation/perspective') {\n perspective = message.data.perspective\n }\n\n if (type === 'element/contextmenu') {\n if ('sanity' in message) {\n contextMenu = {node: message.sanity, position: message.position}\n } else {\n contextMenu = null\n }\n }\n\n if (\n type === 'element/click' ||\n type === 'element/mouseleave' ||\n type === 'overlay/blur' ||\n type === 'presentation/blur' ||\n type === 'presentation/focus'\n ) {\n contextMenu = null\n }\n\n if (type === 'overlay/dragUpdateInsertPosition') {\n dragInsertPosition = message.insertPosition\n }\n\n if (type === 'overlay/dragStart') {\n isDragging = true\n }\n\n if (message.type === 'overlay/dragUpdateSkeleton') {\n dragSkeleton = message.skeleton\n }\n\n if (type === 'overlay/dragEnd') {\n isDragging = false\n }\n\n if (message.type === 'overlay/dragToggleMinimapPrompt') {\n dragShowMinimapPrompt = message.display\n }\n\n if (type === 'overlay/dragStartMinimapTransition') {\n dragMinimapTransition = true\n }\n\n if (type === 'overlay/dragEndMinimapTransition') {\n dragMinimapTransition = false\n }\n\n if (type === 'overlay/dragUpdateGroupRect') {\n dragGroupRect = message.groupRect\n }\n\n if (type === 'overlay/dragToggleMinimap') {\n dragShowMinimap = message.display\n }\n\n return {\n ...state,\n contextMenu,\n elements: elementsReducer(state.elements, message),\n dragInsertPosition,\n dragSkeleton,\n dragGroupRect,\n isDragging,\n focusPath,\n perspective,\n wasMaybeCollapsed,\n dragShowMinimap,\n dragShowMinimapPrompt,\n dragMinimapTransition,\n }\n}\n","import {useEffect, useMemo, useState, type FunctionComponent, type PropsWithChildren} from 'react'\nimport type {VisualEditingNode} from '../../types'\nimport {PreviewSnapshotsContext, type PreviewSnapshotsContextValue} from './PreviewSnapshotsContext'\n\nexport const PreviewSnapshotsProvider: FunctionComponent<\n PropsWithChildren<{\n comlink?: VisualEditingNode\n }>\n> = function (props) {\n const {comlink, children} = props\n\n const [previewSnapshots, setPreviewSnapshots] = useState<PreviewSnapshotsContextValue>([])\n\n useEffect(() => {\n return comlink?.on('presentation/preview-snapshots', (data) => {\n setPreviewSnapshots(data.snapshots)\n })\n }, [comlink])\n\n const context = useMemo<PreviewSnapshotsContextValue>(() => previewSnapshots, [previewSnapshots])\n return (\n <PreviewSnapshotsContext.Provider value={context}>{children}</PreviewSnapshotsContext.Provider>\n )\n}\n","import type {\n DocumentSchema,\n ResolvedSchemaTypeMap,\n SanityNode,\n SanityStegaNode,\n SchemaType,\n TypeSchema,\n UnresolvedPath,\n} from '@repo/visual-editing-helpers'\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n type FunctionComponent,\n type PropsWithChildren,\n} from 'react'\nimport type {\n ElementState,\n OverlayElementField,\n OverlayElementParent,\n VisualEditingNode,\n} from '../../types'\nimport {SchemaContext, type SchemaContextValue} from './SchemaContext'\n\nfunction isSanityNode(node: SanityNode | SanityStegaNode): node is SanityNode {\n return 'path' in node\n}\n\nfunction isDocumentSchemaType(type: SchemaType): type is DocumentSchema {\n return type.type === 'document'\n}\n\nfunction isTypeSchemaType(type: SchemaType): type is TypeSchema {\n return type.type === 'type'\n}\n\nfunction popUnkeyedPathSegments(path: string): string {\n return path\n .split('.')\n .toReversed()\n .reduce((acc, part) => {\n if (acc.length) return [part, ...acc]\n if (part.includes('[_key==')) return [part]\n return []\n }, [] as string[])\n .join('.')\n}\n\nfunction getPathsWithUnresolvedTypes(elements: ElementState[]): {id: string; path: string}[] {\n return elements.reduce((acc, element) => {\n const {sanity} = element\n if (!('id' in sanity)) return acc\n if (!sanity.path.includes('[_key==')) return acc\n const path = popUnkeyedPathSegments(sanity.path)\n if (!acc.find((item) => item.id === sanity.id && item.path === path)) {\n acc.push({id: sanity.id, path})\n }\n return acc\n }, [] as UnresolvedPath[])\n}\n\nexport const SchemaProvider: FunctionComponent<\n PropsWithChildren<{\n comlink?: VisualEditingNode\n elements: ElementState[]\n }>\n> = function (props) {\n const {comlink, children, elements} = props\n\n const [resolvedTypes, setResolvedTypes] = useState<ResolvedSchemaTypeMap>(new Map())\n\n const [schema, setSchema] = useState<SchemaType[] | null>(null)\n\n const fetchSchema = useCallback(\n async (signal: AbortSignal) => {\n if (!comlink) return\n try {\n const response = await comlink.fetch('visual-editing/schema', undefined, {\n