import {createEditUrl, studioPath} from '@sanity/client/csm' import {DocumentIcon, DragHandleIcon, EllipsisVerticalIcon, PlugIcon} from '@sanity/icons' import {MenuButton, MenuDivider} from '@sanity/ui' import {Box, Button, Card, Flex, Menu, MenuItem, Stack, Text} from '@sanity/ui/_visual-editing' import {pathToUrlString} from '@sanity/visual-editing-csm' import { Fragment, isValidElement, memo, startTransition, useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type FunctionComponent, type MouseEventHandler, type ReactElement, } from 'react' import scrollIntoView from 'scroll-into-view-if-needed' import {styled} from 'styled-components' import {v4 as uuid} from 'uuid' import type { ElementChildTarget, ElementFocusedState, ElementNode, OverlayComponent, OverlayComponentResolver, OverlayComponentResolverContext, OverlayPluginDefinition, OverlayPluginExclusiveDefinition, OverlayPluginHudDefinition, OverlayRect, SanityNode, SanityStegaNode, VisualEditingNode, } from '../types' import {PointerEvents} from '../overlay-components/components/PointerEvents' import {getLinkHref} from '../util/getLinkHref' import {PopoverBackground} from './PopoverPortal' import {usePreviewSnapshots} from './preview/usePreviewSnapshots' import {useSchema} from './schema/useSchema' const isReactElementOverlayComponent = ( component: | OverlayComponent | {component: OverlayComponent; props?: Record} | Array}> | ReactElement, ): component is React.JSX.Element => { return isValidElement(component) } export interface ElementOverlayProps { inFrame: boolean id: string comlink?: VisualEditingNode componentResolver?: OverlayComponentResolver plugins?: OverlayPluginDefinition[] draggable: boolean element: ElementNode focused: ElementFocusedState hovered: boolean isDragging: boolean node: SanityNode | SanityStegaNode rect: OverlayRect showActions: boolean wasMaybeCollapsed: boolean enableScrollIntoView: boolean targets: ElementChildTarget[] elementType: 'element' | 'group' onActivateExclusivePlugin?: ( plugin: OverlayPluginExclusiveDefinition, context: OverlayComponentResolverContext, ) => void onMenuOpenChange: (open: boolean) => void } const Root = styled(Card)` background-color: var(--overlay-bg); border-radius: 3px; pointer-events: none; position: absolute; will-change: transform; box-shadow: var(--overlay-box-shadow); transition: none; --overlay-bg: transparent; --overlay-box-shadow: inset 0 0 0 1px transparent; [data-overlays] & { --overlay-bg: color-mix(in srgb, transparent 95%, var(--card-focus-ring-color)); --overlay-box-shadow: inset 0 0 0 2px color-mix(in srgb, transparent 50%, var(--card-focus-ring-color)); } [data-fading-out] & { transition: box-shadow 1550ms, background-color 1550ms; --overlay-bg: rgba(0, 0, 255, 0); --overlay-box-shadow: inset 0 0 0 1px transparent; } &[data-focused] { --overlay-box-shadow: inset 0 0 0 1px var(--card-focus-ring-color); } &[data-hovered]:not([data-focused]) { transition: none; --overlay-box-shadow: inset 0 0 0 2px var(--card-focus-ring-color); } /* [data-unmounted] & { --overlay-box-shadow: inset 0 0 0 1px var(--card-focus-ring-color); } */ :link { text-decoration: none; } ` const Actions = styled(Flex)` bottom: 100%; cursor: pointer; pointer-events: none; position: absolute; right: 0; [data-hovered]:not([data-menu-open]) & { pointer-events: all; } [data-flipped] & { bottom: auto; top: 100%; } ` const HUD = styled(Flex)` top: 100%; cursor: pointer; pointer-events: none; position: absolute; left: 0; gap: 4px; padding: 4px 0; flex-wrap: wrap; [data-hovered]:not([data-menu-open]) & { pointer-events: all; } [data-flipped] & { top: calc(100% + 2rem); } ` const MenuWrapper = styled(Flex)` margin: -0.5rem; [data-hovered]:not([data-menu-open]) & { pointer-events: all; } ` const Tab = styled(Flex)` bottom: 100%; cursor: pointer; pointer-events: none; position: absolute; left: 0; [data-hovered]:not([data-menu-open]) & { pointer-events: all; } [data-flipped] & { bottom: auto; top: 100%; } ` const ActionOpen = styled(Card)` cursor: pointer; background-color: var(--card-focus-ring-color); right: 0; border-radius: 3px; & [data-ui='Text'] { color: #fff; white-space: nowrap; } ` const Labels = styled(Flex)` display: flex; align-items: center; background-color: var(--card-focus-ring-color); right: 0; border-radius: 3px; & [data-ui='Text'], & [data-sanity-icon] { color: #fff; white-space: nowrap; } ` const ExclusivePluginContainer = styled.div` position: absolute; inset: 0; pointer-events: all; ` function createIntentLink(node: SanityNode) { const {id, type, path, baseUrl, tool, workspace, perspective} = node const [url, search] = createEditUrl({ baseUrl, workspace, tool, type: type!, id, path: path ? pathToUrlString(studioPath.fromString(path)) : [], }).split('?') const searchParams = new URLSearchParams(search) const resolvedPerspective = perspective || searchParams.get('perspective') if (resolvedPerspective === 'drafts') { // 'drafts' is not a valid search param in the studio URL, having no `perspective` is the same as 'drafts' searchParams.delete('perspective') } else if (resolvedPerspective) { searchParams.set('perspective', resolvedPerspective) } return `${url}?${searchParams}` } const ElementOverlayInner: FunctionComponent = (props) => { const { id, element, focused, componentResolver, node, showActions, draggable, targets, elementType, comlink, onActivateExclusivePlugin, onMenuOpenChange, inFrame, } = props const {getField, getType} = useSchema() const schemaType = getType(node) const href = 'path' in node ? createIntentLink(node) : node.href const previewSnapshots = usePreviewSnapshots() const title = useMemo(() => { if (!('path' in node)) return undefined return previewSnapshots.find((snapshot) => snapshot._id === node.id)?.title }, [node, previewSnapshots]) const resolverContexts = useMemo<{ legacyComponentContext: OverlayComponentResolverContext | undefined pluginContexts: OverlayComponentResolverContext[] }>(() => { function getContext( node: SanityNode | SanityStegaNode, nodeElement?: ElementNode, ): OverlayComponentResolverContext | undefined { const schemaType = getType(node) const {field, parent} = getField(node) if (!('id' in node)) return undefined if (!field || !schemaType) return undefined const type = field.value.type return { document: schemaType, element, targetElement: nodeElement || element, field, focused: !!focused, node, parent, type, } } return { legacyComponentContext: elementType === 'element' ? getContext(node) : undefined, pluginContexts: targets .map((target) => getContext(target.sanity, target.element)) .filter((ctx) => ctx !== undefined), } }, [elementType, node, targets, getType, getField, element, focused]) const customComponents = useCustomComponents( resolverContexts.legacyComponentContext, componentResolver, ) const nodePluginCollections = useResolvedNodePlugins( resolverContexts.pluginContexts, props.plugins, ) const icon = schemaType?.icon ? (
) : ( ) const menuId = useId() const hasMenuitems = nodePluginCollections?.some( (nodePluginCollection) => nodePluginCollection.exclusive.length > 0, ) const showMenu = hasMenuitems || nodePluginCollections?.length > 1 const handleLabelClick = useCallback(() => { window.dispatchEvent(new CustomEvent('sanity-overlay/label-click', {detail: {id}})) }, [id]) return ( <> {showActions ? ( ) : null} {(title || showMenu) && ( {draggable && ( )} {icon} {title && ( {title} )} {showMenu && ( { // Do not propagate and click the label too if clicking menu button e.stopPropagation() }} > { onMenuOpenChange?.(true) }} onClose={() => { onMenuOpenChange?.(false) }} button={