import type { SanityNode, SchemaArrayItem, SchemaNode, SchemaUnionNode, SchemaUnionOption, } from '@sanity/presentation-comlink' import type {SchemaType} from '@sanity/types' import { ArrowDownIcon, ArrowUpIcon, CopyIcon, InsertAboveIcon, InsertBelowIcon, PublishIcon, RemoveIcon, SortIcon, UnpublishIcon, } from '@sanity/icons' import {MenuGroup} from '@sanity/ui/_visual-editing' import {type FunctionComponent} from 'react' import type {OptimisticDocument} from '../../optimistic' import type {ContextMenuNode, OverlayElementField, OverlayElementParent} from '../../types' import {InsertMenu} from '../../overlay-components/components/InsertMenu' import {getNodeIcon} from '../../util/getNodeIcon' import { getArrayDuplicatePatches, getArrayInsertPatches, getArrayMovePatches, getArrayRemovePatches, } from '../../util/mutations' export function getArrayRemoveAction(node: SanityNode, doc: OptimisticDocument): () => void { if (!node.type) throw new Error('Node type is missing') return () => doc.patch(async ({getSnapshot}) => getArrayRemovePatches(node, (await getSnapshot())!)) } function getArrayInsertAction( node: SanityNode, doc: OptimisticDocument, insertType: string, position: 'before' | 'after', ): () => void { if (!node.type) throw new Error('Node type is missing') return () => doc.patch(() => getArrayInsertPatches(node, insertType, position)) } function getDuplicateAction(node: SanityNode, doc: OptimisticDocument): () => void { if (!node.type) throw new Error('Node type is missing') return () => doc.patch(async ({getSnapshot}) => getArrayDuplicatePatches(node, (await getSnapshot())!)) } export function getContextMenuItems(context: { doc: OptimisticDocument field: OverlayElementField node: SanityNode parent: OverlayElementParent }): Promise { const {node, field, parent, doc} = context if (field?.type === 'arrayItem') { return getContextMenuArrayItems({node, field, doc}) } if (parent?.type === 'union') { return getContextMenuUnionItems({node, parent, doc}) } return Promise.resolve([]) } function getDuplicateItem(context: {doc: OptimisticDocument; node: SanityNode}) { const {node, doc} = context if (!doc) return [] return [ { type: 'action' as const, label: 'Duplicate', icon: CopyIcon, action: getDuplicateAction(node, doc), telemetryEvent: 'Visual Editing Context Menu Item Duplicated' as const, }, ] } function getRemoveItems(context: {doc: OptimisticDocument; node: SanityNode}) { const {node, doc} = context if (!doc) return [] return [ { type: 'action' as const, label: 'Remove', icon: RemoveIcon, action: getArrayRemoveAction(node, doc), telemetryEvent: 'Visual Editing Context Menu Item Removed' as const, }, ] } async function getMoveItems( context: { doc: OptimisticDocument node: SanityNode }, withDivider = true, ) { const {node, doc} = context if (!doc) return [] const items: ContextMenuNode[] = [] const groupItems: ContextMenuNode[] = [] const [moveUpPatches, moveDownPatches, moveFirstPatches, moveLastPatches] = await Promise.all([ getArrayMovePatches(node, doc, 'previous'), getArrayMovePatches(node, doc, 'next'), getArrayMovePatches(node, doc, 'first'), getArrayMovePatches(node, doc, 'last'), ]) if (moveFirstPatches.length) { groupItems.push({ type: 'action', label: 'To top', icon: PublishIcon, action: () => doc.patch(moveFirstPatches), telemetryEvent: 'Visual Editing Context Menu Item Moved', }) } if (moveUpPatches.length) { groupItems.push({ type: 'action', label: 'Up', icon: ArrowUpIcon, action: () => doc.patch(moveUpPatches), telemetryEvent: 'Visual Editing Context Menu Item Moved', }) } if (moveDownPatches.length) { groupItems.push({ type: 'action', label: 'Down', icon: ArrowDownIcon, action: () => doc.patch(moveDownPatches), telemetryEvent: 'Visual Editing Context Menu Item Moved', }) } if (moveLastPatches.length) { groupItems.push({ type: 'action', label: 'To bottom', icon: UnpublishIcon, action: () => doc.patch(moveLastPatches), telemetryEvent: 'Visual Editing Context Menu Item Moved', }) } if (groupItems.length) { items.push({ type: 'group', label: 'Move', icon: SortIcon, items: groupItems, }) if (withDivider) { items.push({type: 'divider'}) } } return items } async function getContextMenuArrayItems(context: { doc: OptimisticDocument field: SchemaArrayItem node: SanityNode }): Promise { const {node, field, doc} = context const items: ContextMenuNode[] = [] items.push(...getDuplicateItem(context)) items.push(...getRemoveItems(context)) items.push(...(await getMoveItems(context))) items.push({ type: 'action', label: 'Insert before', icon: InsertAboveIcon, action: getArrayInsertAction(node, doc, field.name, 'before'), telemetryEvent: 'Visual Editing Context Menu Item Inserted', }) items.push({ type: 'action', label: 'Insert after', icon: InsertBelowIcon, action: getArrayInsertAction(node, doc, field.name, 'after'), telemetryEvent: 'Visual Editing Context Menu Item Inserted', }) return items } const InsertMenuWrapper: FunctionComponent<{ label: string onSelect: (schemaType: SchemaType) => void parent: SchemaUnionNode width: number | undefined boundaryElement: HTMLDivElement | null }> = (props) => { const {label, parent, width, onSelect, boundaryElement} = props return ( ) } async function getContextMenuUnionItems(context: { doc: OptimisticDocument node: SanityNode parent: SchemaUnionNode }): Promise { const {doc, node, parent} = context const items: ContextMenuNode[] = [] items.push(...getDuplicateItem(context)) items.push(...getRemoveItems(context)) items.push(...(await getMoveItems(context))) if (parent.options?.insertMenu) { const insertMenuOptions = parent.options.insertMenu || {} const width = insertMenuOptions.views?.some((view) => view.name === 'grid') ? 0 : undefined items.push({ type: 'custom', component: ({boundaryElement, sendTelemetry}) => { const onSelect = (schemaType: SchemaType) => { const action = getArrayInsertAction(node, doc, schemaType.name, 'before') action() sendTelemetry('Visual Editing Context Menu Item Inserted', null) } return ( ) }, }) items.push({ type: 'custom', component: ({boundaryElement, sendTelemetry}) => { const onSelect = (schemaType: SchemaType) => { const action = getArrayInsertAction(node, doc, schemaType.name, 'after') action() sendTelemetry('Visual Editing Context Menu Item Inserted', null) } return ( ) }, }) } else { items.push({ type: 'group', label: 'Insert before', icon: InsertAboveIcon, items: ( parent.of.filter((item) => item.type === 'unionOption') as SchemaUnionOption[] ).map((t) => { return { type: 'action' as const, icon: getNodeIcon(t), label: t.name === 'block' ? 'Paragraph' : t.title || t.name, action: getArrayInsertAction(node, doc, t.name, 'before'), telemetryEvent: 'Visual Editing Context Menu Item Inserted', } }), }) items.push({ type: 'group', label: 'Insert after', icon: InsertBelowIcon, items: ( parent.of.filter((item) => item.type === 'unionOption') as SchemaUnionOption[] ).map((t) => { return { type: 'action' as const, label: t.name === 'block' ? 'Paragraph' : t.title || t.name, icon: getNodeIcon(t), action: getArrayInsertAction(node, doc, t.name, 'after'), telemetryEvent: 'Visual Editing Context Menu Item Inserted', } }), }) } return items }