@sanity/visual-editing
Version:
[](https://npm-stat.com/charts.html?package=@sanity/visual-editing) [](https://
1 lines • 190 kB
Source Map (JSON)
{"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/preview/usePreviewSnapshots.ts","../../src/ui/ElementOverlay.tsx","../../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/TelemetryProvider.tsx","../../src/ui/useController.tsx","../../src/ui/usePerspectiveSync.tsx","../../src/ui/useReportDocuments.ts","../../src/ui/Overlays.tsx","../../src/ui/Refresh.tsx","../../src/ui/useComlink.tsx","../../src/optimistic/state/createSharedListener.ts","../../src/ui/useDatasetMutator.ts","../../src/ui/VisualEditing.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 SanityNode,\n SanityStegaNode,\n TypeSchema,\n} from '@sanity/presentation-comlink'\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}\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 {\n ArrowDownIcon,\n ArrowUpIcon,\n CopyIcon,\n InsertAboveIcon,\n InsertBelowIcon,\n PublishIcon,\n RemoveIcon,\n SortIcon,\n UnpublishIcon,\n} from '@sanity/icons'\nimport type {\n SanityNode,\n SchemaArrayItem,\n SchemaNode,\n SchemaUnionNode,\n SchemaUnionOption,\n} from '@sanity/presentation-comlink'\nimport type {SchemaType} from '@sanity/types'\nimport {MenuGroup} from '@sanity/ui/_visual-editing'\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 telemetryEvent: 'Visual Editing Context Menu Item Duplicated' as const,\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 telemetryEvent: 'Visual Editing Context Menu Item Removed' as const,\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 telemetryEvent: 'Visual Editing Context Menu Item Moved',\n })\n }\n if (moveUpPatches.length) {\n groupItems.push({\n type: 'action',\n label: 'Up',\n icon: ArrowUpIcon,\n action: () => doc.patch(moveUpPatches),\n telemetryEvent: 'Visual Editing Context Menu Item Moved',\n })\n }\n if (moveDownPatches.length) {\n groupItems.push({\n type: 'action',\n label: 'Down',\n icon: ArrowDownIcon,\n action: () => doc.patch(moveDownPatches),\n telemetryEvent: 'Visual Editing Context Menu Item Moved',\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 telemetryEvent: 'Visual Editing Context Menu Item Moved',\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 telemetryEvent: 'Visual Editing Context Menu Item Inserted',\n })\n items.push({\n type: 'action',\n label: 'Insert after',\n icon: InsertBelowIcon,\n action: getArrayInsertAction(node, doc, field.name, 'after'),\n telemetryEvent: 'Visual Editing Context Menu Item Inserted',\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, sendTelemetry}) => {\n const onSelect = (schemaType: SchemaType) => {\n const action = getArrayInsertAction(node, doc, schemaType.name, 'before')\n action()\n\n sendTelemetry('Visual Editing Context Menu Item Inserted', null)\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, sendTelemetry}) => {\n const onSelect = (schemaType: SchemaType) => {\n const action = getArrayInsertAction(node, doc, schemaType.name, 'after')\n action()\n\n sendTelemetry('Visual Editing Context Menu Item Inserted', null)\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 telemetryEvent: 'Visual Editing Context Menu Item Inserted',\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 telemetryEvent: 'Visual Editing Context Menu Item Inserted',\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/_visual-editing'\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 {useTelemetry} from '../telemetry/useTelemetry'\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 const sendTelemetry = useTelemetry()\n\n const onClick = useCallback(() => {\n if (node.type === 'action') {\n node.action?.()\n onDismiss?.()\n\n if (node.telemetryEvent) {\n sendTelemetry(node.telemetryEvent, null)\n }\n }\n }, [node, onDismiss, sendTelemetry])\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} sendTelemetry={sendTelemetry} />\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 '@sanity/presentation-comlink'\nimport {createContext} from 'react'\n\nexport type PreviewSnapshotsContextValue = PreviewSnapshot[]\n\nexport const PreviewSnapshotsContext = createContext<PreviewSnapshotsContextValue | null>(null)\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 {createEditUrl, studioPath} from '@sanity/client/csm'\nimport {DocumentIcon, DragHandleIcon, EllipsisVerticalIcon, PlugIcon} from '@sanity/icons'\nimport {MenuButton, MenuDivider} from '@sanity/ui'\nimport {Box, Button, Card, Flex, Menu, MenuItem, Stack, Text} from '@sanity/ui/_visual-editing'\nimport {pathToUrlString} from '@sanity/visual-editing-csm'\nimport {\n Fragment,\n isValidElement,\n memo,\n useCallback,\n useEffect,\n useId,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type CSSProperties,\n type FunctionComponent,\n type MouseEventHandler,\n type ReactElement,\n} from 'react'\nimport scrollIntoView from 'scroll-into-view-if-needed'\nimport {styled} from 'styled-components'\nimport {v4 as uuid} from 'uuid'\nimport {PointerEvents} from '../overlay-components/components/PointerEvents'\nimport type {\n ElementChildTarget,\n ElementFocusedState,\n ElementNode,\n OverlayComponent,\n OverlayComponentResolver,\n OverlayComponentResolverContext,\n OverlayPluginDefinition,\n OverlayPluginExclusiveDefinition,\n OverlayPluginHudDefinition,\n OverlayRect,\n SanityNode,\n SanityStegaNode,\n VisualEditingNode,\n} from '../types'\nimport {getLinkHref} from '../util/getLinkHref'\nimport {PopoverBackground} from './PopoverPortal'\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 React.JSX.Element => {\n return isValidElement(component)\n}\n\nexport interface ElementOverlayProps {\n id: string\n comlink?: VisualEditingNode\n componentResolver?: OverlayComponentResolver\n plugins?: OverlayPluginDefinition[]\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 targets: ElementChildTarget[]\n elementType: 'element' | 'group'\n onActivateExclusivePlugin?: (\n plugin: OverlayPluginExclusiveDefinition,\n context: OverlayComponentResolverContext,\n ) => void\n onMenuOpenChange: (open: boolean) => void\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]:not([data-menu-open]) & {\n pointer-events: all;\n }\n\n [data-flipped] & {\n bottom: auto;\n top: 100%;\n }\n`\n\nconst HUD = styled(Flex)`\n top: 100%;\n cursor: pointer;\n pointer-events: none;\n position: absolute;\n left: 0;\n\n gap: 4px;\n padding: 4px 0;\n flex-wrap: wrap;\n\n [data-hovered]:not([data-menu-open]) & {\n pointer-events: all;\n }\n\n [data-flipped] & {\n top: calc(100% + 2rem);\n }\n`\n\nconst MenuWrapper = styled(Flex)`\n margin: -0.5rem;\n\n [data-hovered]:not([data-menu-open]) & {\n pointer-events: all;\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 [data-hovered]:not([data-menu-open]) & {\n pointer-events: all;\n }\n\n [data-flipped] & {\n bottom: auto;\n top: 100%;\n }\n`\n\nconst ActionOpen = styled(Card)`\n cursor: pointer;\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 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\nconst ExclusivePluginContainer = styled.div`\n position: absolute;\n inset: 0;\n pointer-events: all;\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: path ? pathToUrlString(studioPath.fromString(path)) : [],\n })\n}\n\nconst ElementOverlayInner: FunctionComponent<ElementOverlayProps> = (props) => {\n const {\n id,\n element,\n focused,\n componentResolver,\n node,\n showActions,\n draggable,\n targets,\n elementType,\n comlink,\n onActivateExclusivePlugin,\n onMenuOpenChange,\n } = props\n\n const {getField, getType} = useSchema()\n const schemaType = getType(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 resolverContexts = useMemo<{\n legacyComponentContext: OverlayComponentResolverContext | undefined\n pluginContexts: OverlayComponentResolverContext[]\n }>(() => {\n function getContext(\n node: SanityNode | SanityStegaNode,\n nodeElement?: ElementNode,\n ): OverlayComponentResolverContext | undefined {\n const schemaType = getType(node)\n const {field, parent} = getField(node)\n if (!('id' in node)) return undefined\n if (!field || !schemaType) return undefined\n const type = field.value.type\n return {\n document: schemaType,\n element,\n targetElement: nodeElement || element,\n field,\n focused: !!focused,\n node,\n parent,\n type,\n }\n }\n return {\n legacyComponentContext: elementType === 'element' ? getContext(node) : undefined,\n pluginContexts: targets\n .map((target) => getContext(target.sanity, target.element))\n .filter((ctx) => ctx !== undefined),\n }\n }, [elementType, node, targets, getType, getField, element, focused])\n\n const customComponents = useCustomComponents(\n resolverContexts.legacyComponentContext,\n componentResolver,\n )\n\n const nodePluginCollections = useResolvedNodePlugins(\n resolverContexts.pluginContexts,\n props.plugins,\n )\n\n const icon = schemaType?.icon ? (\n <div dangerouslySetInnerHTML={{__html: schemaType.icon}} />\n ) : (\n <DocumentIcon />\n )\n\n const menuId = useId()\n\n const hasMenuitems = nodePluginCollections?.some(\n (nodePluginCollection) => nodePluginCollection.exclusive.length > 0,\n )\n const showMenu = hasMenuitems || nodePluginCollections?.length > 1\n\n const handleLabelClick = useCallback(() => {\n window.dispatchEvent(new CustomEvent('sanity-overlay/label-click', {detail: {id}}))\n }, [id])\n\n return (\n <>\n <PointerEvents>\n {showActions ? (\n <Actions gap={1} paddingY={1}>\n <Link href={href} />\n </Actions>\n ) : null}\n {(title || showMenu) && (\n <Tab gap={1} paddingY={1} onClick={handleLabelClick}>\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\n {title && (\n <Text size={1} weight=\"medium\">\n {title}\n </Text>\n )}\n\n {showMenu && (\n <Box\n paddingLeft={2}\n onClick={(e) => {\n // Do not propagate and click the label too if clicking menu button\n e.stopPropagation()\n }}\n >\n <MenuWrapper>\n <MenuButton\n id={menuId}\n popover={{\n animate: true,\n placement: 'bottom-start',\n constrainSize: true,\n tone: 'default',\n }}\n onOpen={() => {\n onMenuOpenChange?.(true)\n }}\n onClose={() => {\n onMenuOpenChange?.(false)\n }}\n button={<Button icon={EllipsisVerticalIcon} tone=\"primary\" padding={2} />}\n menu={\n <Menu paddingY={0}>\n <PointerEvents>\n {nodePluginCollections?.map((nodePluginCollection, index) => (\n <Fragment key={nodePluginCollection.id}>\n <Stack role=\"group\" paddingY={1} space={0}>\n <MenuItem\n paddingY={2}\n text={\n <Box paddingY={2}>\n <Text muted size={1} style={{textTransform: 'capitalize'}}>\n {`${nodePluginCollection.context.document.name}: ${nodePluginCollection.context.field?.name}`}\n </Text>\n </Box>\n }\n onClick={() => {\n if (nodePluginCollection.context.node) {\n comlink?.post(\n 'visual-editing/focus',\n nodePluginCollection.context.node,\n )\n }\n }}\n />\n {nodePluginCollection.exclusive.map((exclusive) => {\n const Component = exclusive.component\n if (!Component) return null\n return (\n <MenuItem\n paddingY={2}\n key={exclusive.name}\n icon={exclusive.icon || <PlugIcon />}\n text={\n <Box paddingY={2}>\n <Text size={1}>\n {exclusive.title || exclusive.name}\n </Text>\n </Box>\n }\n onClick={() =>\n onActivateExclusivePlugin?.(\n exclusive,\n nodePluginCollection.context,\n )\n }\n />\n )\n })}\n </Stack>\n {index < nodePluginCollections.length - 1 && <MenuDivider />}\n </Fragment>\n ))}\n </PointerEvents>\n </Menu>\n }\n />\n </MenuWrapper>\n </Box>\n )}\n </Labels>\n </Tab>\n )}\n\n <HUD>\n {nodePluginCollections?.map((nodePluginCollection) => (\n <Fragment key={nodePluginCollection.id}>\n {nodePluginCollection.hud.map((hud) => {\n const Component = hud.component\n if (!Component) return null\n return <Component key={hud.name} {...nodePluginCollection.context} />\n })}\n </Fragment>\n ))}\n </HUD>\n </PointerEvents>\n\n {Array.isArray(customComponents)\n ? customComponents.map(({component: Component, props}, i) => {\n return (\n <Component\n key={i}\n PointerEvents={PointerEvents}\n {...resolverContexts.legacyComponentContext!}\n {...props}\n />\n )\n })\n : customComponents}\n </>\n )\n}\n\nexport const ElementOverlay = memo(function ElementOverlay(\n props: Omit<ElementOverlayProps, 'setActiveExclusivePlugin' | 'onMenuOpenChange'>,\n) {\n const {draggable, 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 const [isNearTop, setIsNearTop] = useState(false)\n useEffect(() => {\n if (!ref.current || !hovered) return undefined\n\n const io = new IntersectionObserver(\n ([intersection]) => {\n setIsNearTop(intersection.boundingClientRect.top < 0)\n },\n {threshold: 1},\n )\n io.observe(ref.current)\n return () => io.disconnect()\n }, [hovered, isNearTop])\n\n const [activeExclusivePlugin, setActiveExclusivePlugin] = useState<{\n plugin: OverlayPluginExclusiveDefinition\n context: OverlayComponentResolverContext\n } | null>(null)\n\n const closeExclusivePluginView = useCallback(() => {\n setActiveExclusivePlugin(null)\n window.dispatchEvent(new CustomEvent('sanity-overlay/exclusive-plugin-closed'))\n }, [])\n\n const onActivateExclusivePlugin = useCallback(\n (plugin: OverlayPluginExclusiveDefinition, context: OverlayComponentResolverContext) => {\n setActiveExclusivePlugin({plugin, context})\n },\n [],\n )\n\n const handleExclusivePluginClick: MouseEventHandler<HTMLDivElement> = (event) => {\n event.stopPropagation()\n }\n\n const ExclusivePluginComponent = activeExclusivePlugin?.plugin.component\n\n const [menuOpen, setMenuOpen] = useState(false)\n\n useEffect(() => {\n setMenuOpen(false)\n }, [hovered])\n\n return (\n <>\n {menuOpen || ExclusivePluginComponent ? (\n <PopoverBackground onDismiss={closeExclusivePluginView} blockScroll={menuOpen} />\n ) : null}\n <Root\n data-focused={focused ? '' : undefined}\n data-hovered={hovered ? '' : undefined}\n data-flipped={isNearTop ? '' : undefined}\n data-draggable={draggable ? '' : undefined}\n data-menu-open={menuOpen ? '' : undefined}\n ref={ref}\n style={style}\n >\n {ExclusivePluginComponent ? (\n <ExclusivePluginContainer\n data-sanity-overlay-element\n onClick={handleExclusivePluginClick}\n >\n <ExclusivePluginComponent\n {...activeExclusivePlugin.context}\n closeExclusiveView={closeExclusivePluginView}\n />\n </ExclusivePluginContainer>\n ) : hovered ? (\n <ElementOverlayInner\n {...props}\n onActivateExclusivePlugin={onActivateExclusivePlugin}\n onMenuOpenChange={setMenuOpen}\n />\n ) : null}\n </Root>\n </>\n )\n})\n\ninterface NodePluginCollection {\n id: string\n context: OverlayComponentResolverContext\n hud: OverlayPluginHudDefinition[]\n exclusive: OverlayPluginExclusiveDefinition[]\n}\n\nfunction useResolvedNodePlugins(\n componentContexts: OverlayComponentResolverContext[],\n plugins?: OverlayPluginDefinition[],\n) {\n return useMemo(\n () =>\n componentContexts.map((componentContext) => {\n const instance: NodePluginCollection = {\n id: uuid(),\n context: componentContext,\n hud: [],\n exclusive: [],\n }\n\n plugins?.forEach((plugin) => {\n if (!plugin.guard?.(componentContext)) return\n if (plugin.type === 'hud') instance.hud.push(plugin)\n if (plugin.type === 'exclusive') instance.exclusive.push(plugin)\n })\n\n return instance\n }),\n [componentContexts, plugins],\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 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/_visual-editing'\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 * scaleFacto