import type { DocumentSchema, ResolvedSchemaTypeMap, SanityNode, SanityStegaNode, SchemaType, TypeSchema, UnresolvedPath, } from '@sanity/presentation-comlink' import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type FunctionComponent, type PropsWithChildren, } from 'react' import type { ElementState, OverlayElementField, OverlayElementParent, VisualEditingNode, } from '../../types' import {SchemaContext, type SchemaContextValue} from './SchemaContext' function isSanityNode(node: SanityNode | SanityStegaNode): node is SanityNode { return 'path' in node } function isDocumentSchemaType(type: SchemaType): type is DocumentSchema { return type.type === 'document' } function isTypeSchemaType(type: SchemaType): type is TypeSchema { return type.type === 'type' } function popUnkeyedPathSegments(path: string): string { return path .split('.') .toReversed() .reduce((acc, part) => { if (acc.length) return [part, ...acc] if (part.includes('[_key==')) return [part] return [] }, [] as string[]) .join('.') } function getPathsWithUnresolvedTypes(elements: ElementState[]): {id: string; path: string}[] { return elements.reduce((acc, element) => { const {sanity} = element if (!('id' in sanity)) return acc if (!sanity.path.includes('[_key==')) return acc const path = popUnkeyedPathSegments(sanity.path) if (!acc.find((item) => item.id === sanity.id && item.path === path)) { acc.push({id: sanity.id, path}) } return acc }, [] as UnresolvedPath[]) } export const SchemaProvider: FunctionComponent< PropsWithChildren<{ comlink?: VisualEditingNode elements: ElementState[] }> > = function (props) { const {comlink, children, elements} = props const [resolvedTypes, setResolvedTypes] = useState(new Map()) const [schema, setSchema] = useState(null) useEffect(() => { if (!comlink) return async function fetchSchema(signal: AbortSignal) { if (!comlink) return try { const response = await comlink.fetch('visual-editing/schema', undefined, { signal, suppressWarnings: true, }) setSchema(response.schema) } catch { // Fail silently as the app may be communicating with a version of // Presentation that does not support this feature } } const schemaFetch = new AbortController() const unsub = comlink.onStatus(() => { fetchSchema(schemaFetch.signal) }, 'connected') return () => { schemaFetch.abort() unsub() } }, [comlink]) const reportedPathsRef = useRef([]) const paths = getPathsWithUnresolvedTypes(useDeferredValue(elements)) const controllerRef = useRef(null) useEffect(() => { if (!paths.length || !comlink) return // We report a list of paths that reference array items using a _key. We need // to resolve the types of each of these items so we can map them to the // correct schema types. One day CSM might include this data for us. const reportPaths = async (paths: UnresolvedPath[], signal: AbortSignal) => { const initialReportedPaths = reportedPathsRef.current try { reportedPathsRef.current = paths const response = await comlink.fetch( 'visual-editing/schema-union-types', {paths}, {signal, suppressWarnings: true}, ) if (signal.aborted) return setResolvedTypes(response.types) controllerRef.current = null } catch { // Fail silently as the app may be communicating with a version of // Presentation that does not support this feature } if (signal.aborted) { reportedPathsRef.current = initialReportedPaths } } if ( paths.some( (p) => !reportedPathsRef.current.find(({id, path}) => id === p.id && path === p.path), ) ) { // Abort any previous requests controllerRef.current?.abort() controllerRef.current = new AbortController() reportPaths(paths, controllerRef.current.signal) } }, [comlink, paths]) const getType = useCallback( ( node: SanityNode | SanityStegaNode | string, _type?: T, ): T extends 'document' ? DocumentSchema | undefined : TypeSchema | undefined => { const type = _type || 'document' if ( !schema || (typeof node !== 'string' && (!isSanityNode(node) || !Array.isArray(schema))) ) { return undefined } const name = typeof node === 'string' ? node : node.type const filter = type === 'document' ? isDocumentSchemaType : isTypeSchemaType return schema .filter(filter) .find((schemaType) => schemaType.name === name) as T extends 'document' ? DocumentSchema | undefined : TypeSchema | undefined }, [schema], ) const getField = useCallback( ( node: SanityNode | SanityStegaNode, ): { field: OverlayElementField parent: OverlayElementParent } => { if (!isSanityNode(node)) { return { field: undefined, parent: undefined, } } const schemaType = getType(node) if (!schemaType) { return { field: undefined, parent: undefined, } } function fieldFromPath( schemaType: OverlayElementParent, path: string[], parent: OverlayElementParent, prevPath: string[] = [], ): { field: OverlayElementField parent: OverlayElementParent } { if (!schemaType) { return {field: undefined, parent: undefined} } const [next, ...rest] = path if ('fields' in schemaType) { const objectField = schemaType.fields[next] if (!objectField && 'rest' in schemaType) { return fieldFromPath(schemaType.rest, path, schemaType, prevPath) } if (!rest.length) { return {field: objectField, parent} } if (!objectField) { throw new Error( `[@sanity/visual-editing] No field could be resolved at path: "${[...prevPath, ...path].join('.')}"`, ) } return fieldFromPath(objectField.value, rest, schemaType, [...prevPath, next]) } else if (schemaType.type === 'array') { return fieldFromPath(schemaType.of, path, schemaType, prevPath) } else if (schemaType.type === 'arrayItem') { if (!rest.length) return {field: schemaType, parent} return fieldFromPath(schemaType.value, rest, schemaType, [...prevPath, next]) } else if (schemaType.type === 'union') { const name = next.startsWith('[_key==') ? resolvedTypes ?.get((node as SanityNode).id) ?.get([prevPath.join('.'), next].filter(Boolean).join('')) : next return fieldFromPath( schemaType.of.find((item) => (item.type === 'unionOption' ? item.name === name : item)), rest, schemaType, [...prevPath, next], ) } else if (schemaType.type === 'unionOption') { if (!next) return {field: schemaType, parent} return fieldFromPath(schemaType.value, path, schemaType, prevPath) } else if (schemaType.type === 'inline') { const type = getType(schemaType.name, 'type') return fieldFromPath((type as TypeSchema).value, path, schemaType, prevPath) } throw new Error( `[@sanity/visual-editing] No field could be resolved at path: "${[...prevPath, ...path].join('.')}"`, ) } const nodePath = node.path.split('.').flatMap((part) => { if (part.includes('[')) { return part.split(/(\[.+\])/, 2) } return [part] }) try { return fieldFromPath(schemaType, nodePath, undefined) } catch (e) { // eslint-disable-next-line no-console if (e instanceof Error) console.warn(e.message) return {field: undefined, parent: undefined} } }, [getType, resolvedTypes], ) const context = useMemo(() => ({getField, getType}), [getField, getType]) return {children} }