From 1fb814396cc6a867418b00e07751f493d202d58b Mon Sep 17 00:00:00 2001 From: Rupert Dunk Date: Mon, 11 Nov 2024 16:29:18 +0000 Subject: [PATCH] feat: add shared state (#2120) * feat: add shared state * refactor(presentation): improve preview header component types --- .../components/overlays/OverlayHighlight.tsx | 25 ++++ .../src/components/overlays/resolver.ts | 34 +++-- apps/studio/presentation/CustomHeader.tsx | 41 ++++++ apps/studio/sanity.config.ts | 4 + .../presentation/src/PresentationTool.tsx | 110 ++++++++-------- packages/presentation/src/index.ts | 7 + .../src/overlays/SharedStateContext.tsx | 9 ++ .../src/overlays/SharedStateProvider.tsx | 50 ++++++++ .../src/overlays/useSharedState.ts | 25 ++++ .../src/preview/PreviewHeader.tsx | 15 ++- packages/presentation/src/types.ts | 6 +- .../src/types/comlink.ts | 31 +++++ packages/visual-editing/src/index.ts | 1 + packages/visual-editing/src/ui/Overlays.tsx | 121 +++++++++--------- .../src/ui/shared-state/SharedStateContext.ts | 10 ++ .../ui/shared-state/SharedStateProvider.tsx | 67 ++++++++++ .../src/ui/shared-state/sharedStateStore.ts | 27 ++++ .../src/ui/shared-state/useSharedState.ts | 20 +++ 18 files changed, 473 insertions(+), 130 deletions(-) create mode 100644 apps/page-builder-demo/src/components/overlays/OverlayHighlight.tsx create mode 100644 apps/studio/presentation/CustomHeader.tsx create mode 100644 packages/presentation/src/overlays/SharedStateContext.tsx create mode 100644 packages/presentation/src/overlays/SharedStateProvider.tsx create mode 100644 packages/presentation/src/overlays/useSharedState.ts create mode 100644 packages/visual-editing/src/ui/shared-state/SharedStateContext.ts create mode 100644 packages/visual-editing/src/ui/shared-state/SharedStateProvider.tsx create mode 100644 packages/visual-editing/src/ui/shared-state/sharedStateStore.ts create mode 100644 packages/visual-editing/src/ui/shared-state/useSharedState.ts diff --git a/apps/page-builder-demo/src/components/overlays/OverlayHighlight.tsx b/apps/page-builder-demo/src/components/overlays/OverlayHighlight.tsx new file mode 100644 index 000000000..92ec966f5 --- /dev/null +++ b/apps/page-builder-demo/src/components/overlays/OverlayHighlight.tsx @@ -0,0 +1,25 @@ +import {useSharedState} from '@sanity/visual-editing' +import {FunctionComponent} from 'react' + +export const OverlayHighlight: FunctionComponent = () => { + const overlayEnabled = useSharedState('overlay-enabled') + + if (!overlayEnabled) { + return null + } + + return ( +
+ ) +} diff --git a/apps/page-builder-demo/src/components/overlays/resolver.ts b/apps/page-builder-demo/src/components/overlays/resolver.ts index 4db113afa..c38aabfa7 100644 --- a/apps/page-builder-demo/src/components/overlays/resolver.ts +++ b/apps/page-builder-demo/src/components/overlays/resolver.ts @@ -1,29 +1,47 @@ 'use client' -import {OverlayComponentResolver} from '@sanity/visual-editing' +import {OverlayComponent, OverlayComponentResolver, useSharedState} from '@sanity/visual-editing' import { defineOverlayComponent, UnionInsertMenuOverlay, } from '@sanity/visual-editing/unstable_overlay-components' import {ExcitingTitleControl} from './ExcitingTitleControl' +import {OverlayHighlight} from './OverlayHighlight' import {ProductModelRotationControl} from './ProductModelRotationControl' export const components: OverlayComponentResolver = (props) => { - const {type, node, parent} = props + const {element, type, node, parent} = props + + const components: Array< + | OverlayComponent, any> + | { + component: OverlayComponent, any> + props?: Record + } + > = [OverlayHighlight] if (type === 'string' && node.path === 'title') { - return ExcitingTitleControl + components.push(ExcitingTitleControl) } if (type === 'object' && node.path.endsWith('rotations')) { - return defineOverlayComponent(ProductModelRotationControl) + components.push(ProductModelRotationControl) } if (parent?.type === 'union') { - return defineOverlayComponent(UnionInsertMenuOverlay, { - direction: 'vertical', - }) + const parentDataset = element.parentElement?.dataset || {} + + const direction = (parentDataset.direction ?? 'vertical') as 'vertical' | 'horizontal' + + const hoverAreaExtent = parentDataset.hoverExtent || 48 + + components.push( + defineOverlayComponent(UnionInsertMenuOverlay, { + direction, + hoverAreaExtent, + }), + ) } - return undefined + return components } diff --git a/apps/studio/presentation/CustomHeader.tsx b/apps/studio/presentation/CustomHeader.tsx new file mode 100644 index 000000000..747276a1b --- /dev/null +++ b/apps/studio/presentation/CustomHeader.tsx @@ -0,0 +1,41 @@ +import {CheckmarkIcon, CloseIcon, EllipsisVerticalIcon} from '@sanity/icons' +import {useSharedState} from '@sanity/presentation' +import type {PreviewHeaderProps} from '@sanity/presentation' +import {Button, Menu, MenuButton, MenuItem} from '@sanity/ui' +import {useState, type FunctionComponent} from 'react' + +export const CustomHeader: FunctionComponent = (props) => { + const [enabled, setEnabled] = useState(false) + + useSharedState('overlay-enabled', enabled) + + return ( + <> + {props.renderDefault(props)} + + } + id="custom-menu" + menu={ + + setEnabled((enabled) => !enabled)} + padding={3} + tone={enabled ? 'caution' : 'positive'} + text={enabled ? 'Disable Highlighting' : 'Enable Highlighting'} + /> + + } + popover={{ + animate: true, + constrainSize: true, + placement: 'bottom', + portal: true, + }} + /> + + ) +} diff --git a/apps/studio/sanity.config.ts b/apps/studio/sanity.config.ts index 0f36a1eab..0d814ee63 100644 --- a/apps/studio/sanity.config.ts +++ b/apps/studio/sanity.config.ts @@ -16,6 +16,7 @@ import { import {debugSecrets} from '@sanity/preview-url-secret/sanity-plugin-debug-secrets' import {visionTool} from '@sanity/vision' import {defineConfig, definePlugin, type PluginOptions} from 'sanity' +import {CustomHeader} from './presentation/CustomHeader' import {CustomNavigator} from './presentation/CustomNavigator' import {StegaDebugger} from './presentation/DebugStega' @@ -138,6 +139,9 @@ export default defineConfig([ workspaces['page-builder-demo'].tool, ), components: { + unstable_header: { + component: CustomHeader, + }, unstable_navigator: { minWidth: 120, maxWidth: 240, diff --git a/packages/presentation/src/PresentationTool.tsx b/packages/presentation/src/PresentationTool.tsx index 5ee4a4dc1..bd0ed3a6f 100644 --- a/packages/presentation/src/PresentationTool.tsx +++ b/packages/presentation/src/PresentationTool.tsx @@ -36,6 +36,7 @@ import { } from './constants' import {useUnique, useWorkspace, type CommentIntentGetter} from './internals' import {debounce} from './lib/debounce' +import {SharedStateProvider} from './overlays/SharedStateProvider' import {Panel} from './panels/Panel' import {Panels} from './panels/Panels' import {PresentationContent} from './PresentationContent' @@ -92,7 +93,7 @@ export default function PresentationTool(props: { const components = tool.options?.components const _previewUrl = tool.options?.previewUrl const name = tool.name || DEFAULT_TOOL_NAME - const {unstable_navigator} = components || {} + const {unstable_navigator, unstable_header} = components || {} const {navigate: routerNavigate, state: routerState} = useRouter() as RouterContextValue & { state: PresentationStateParams @@ -516,58 +517,61 @@ export default function PresentationTool(props: { > - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index cfdfcd09a..9c1f3536c 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -27,6 +27,7 @@ export { usePresentationNavigate, } from './usePresentationNavigate' export {usePresentationParams} from './usePresentationParams' +export {useSharedState} from './overlays/useSharedState' export type {PreviewHeaderProps} from './preview/PreviewHeader' export type {PreviewProps} from './preview/Preview' export { @@ -42,3 +43,9 @@ export { type PresentationState, type VisualEditingOverlaysToggleAction, } from './reducers/presentationReducer' +export type { + Serializable, + SerializableArray, + SerializableObject, + SerializablePrimitive, +} from '@repo/visual-editing-helpers' diff --git a/packages/presentation/src/overlays/SharedStateContext.tsx b/packages/presentation/src/overlays/SharedStateContext.tsx new file mode 100644 index 000000000..10513af8e --- /dev/null +++ b/packages/presentation/src/overlays/SharedStateContext.tsx @@ -0,0 +1,9 @@ +import type {Serializable} from '@repo/visual-editing-helpers' +import {createContext} from 'react' + +export interface SharedStateContextValue { + removeValue: (key: string) => void + setValue: (key: string, value: Serializable) => void +} + +export const SharedStateContext = createContext(null) diff --git a/packages/presentation/src/overlays/SharedStateProvider.tsx b/packages/presentation/src/overlays/SharedStateProvider.tsx new file mode 100644 index 000000000..4793e2534 --- /dev/null +++ b/packages/presentation/src/overlays/SharedStateProvider.tsx @@ -0,0 +1,50 @@ +import type {Serializable, SerializableObject} from '@repo/visual-editing-helpers' +import { + useCallback, + useEffect, + useMemo, + useRef, + type FunctionComponent, + type PropsWithChildren, +} from 'react' +import type {VisualEditingConnection} from '../types' +import {SharedStateContext, type SharedStateContextValue} from './SharedStateContext' + +export const SharedStateProvider: FunctionComponent< + PropsWithChildren<{ + comlink: VisualEditingConnection | null + }> +> = function (props) { + const {comlink, children} = props + + const sharedState = useRef({}) + + useEffect(() => { + return comlink?.on('visual-editing/shared-state', () => { + return {state: sharedState.current} + }) + }, [comlink]) + + const setValue = useCallback( + (key: string, value: Serializable) => { + sharedState.current[key] = value + comlink?.post({type: 'presentation/shared-state', data: {key, value}}) + }, + [comlink], + ) + + const removeValue = useCallback( + (key: string) => { + comlink?.post({type: 'presentation/shared-state', data: {key}}) + delete sharedState.current[key] + }, + [comlink], + ) + + const context = useMemo( + () => ({removeValue, setValue}), + [removeValue, setValue], + ) + + return {children} +} diff --git a/packages/presentation/src/overlays/useSharedState.ts b/packages/presentation/src/overlays/useSharedState.ts new file mode 100644 index 000000000..ec7d590d0 --- /dev/null +++ b/packages/presentation/src/overlays/useSharedState.ts @@ -0,0 +1,25 @@ +import type {Serializable} from '@repo/visual-editing-helpers' +import {useContext, useEffect} from 'react' +import {SharedStateContext} from './SharedStateContext' + +export const useSharedState = (key: string, value: Serializable): undefined => { + const context = useContext(SharedStateContext) + + if (!context) { + throw new Error('Preview Snapshots context is missing') + } + + const {removeValue, setValue} = context + + useEffect(() => { + setValue(key, value) + }, [key, value, setValue]) + + useEffect(() => { + return () => { + removeValue(key) + } + }, [key, removeValue]) + + return undefined +} diff --git a/packages/presentation/src/preview/PreviewHeader.tsx b/packages/presentation/src/preview/PreviewHeader.tsx index 3cc15da1f..c5252a778 100644 --- a/packages/presentation/src/preview/PreviewHeader.tsx +++ b/packages/presentation/src/preview/PreviewHeader.tsx @@ -59,9 +59,12 @@ const PERSPECTIVE_ICONS: Record = { export interface PreviewHeaderProps extends PreviewProps { iframeRef: RefObject + renderDefault: (props: PreviewHeaderProps) => ReactNode } -const PreviewHeaderDefault: FunctionComponent = (props) => { +const PreviewHeaderDefault: FunctionComponent> = ( + props, +) => { const { canSharePreviewAccess, canToggleSharePreviewAccess, @@ -375,10 +378,10 @@ const PreviewHeaderDefault: FunctionComponent = (props) => { ) } -const PreviewHeader: FunctionComponent = ( - props, -) => { - const renderDefault = useCallback((props: PreviewHeaderProps) => { +const PreviewHeader: FunctionComponent< + Omit & {options?: HeaderOptions} +> = (props) => { + const renderDefault = useCallback((props: Omit) => { return createElement(PreviewHeaderDefault, props) }, []) @@ -397,7 +400,7 @@ const PreviewHeader: FunctionComponent & {options?: HeaderOptions}, ): () => ReactNode { const Component = useCallback(() => { return diff --git a/packages/presentation/src/types.ts b/packages/presentation/src/types.ts index a0d5863ad..f0b859b4c 100644 --- a/packages/presentation/src/types.ts +++ b/packages/presentation/src/types.ts @@ -10,7 +10,7 @@ import type { PreviewUrlResolver, PreviewUrlResolverOptions, } from '@sanity/preview-url-secret/define-preview-url' -import type {ComponentType, ReactNode} from 'react' +import type {ComponentType} from 'react' import type {Observable} from 'rxjs' import type {SanityClient} from 'sanity' import type {DocumentStore} from './internals' @@ -71,9 +71,7 @@ export interface NavigatorOptions { } export interface HeaderOptions { - component: ComponentType< - PreviewHeaderProps & {renderDefault: (props: PreviewHeaderProps) => ReactNode} - > + component: ComponentType } export type PreviewUrlOption = string | PreviewUrlResolver | PreviewUrlResolverOptions diff --git a/packages/visual-editing-helpers/src/types/comlink.ts b/packages/visual-editing-helpers/src/types/comlink.ts index f8b56f661..80d49a973 100644 --- a/packages/visual-editing-helpers/src/types/comlink.ts +++ b/packages/visual-editing-helpers/src/types/comlink.ts @@ -139,6 +139,13 @@ export type VisualEditingControllerMsg = event: ReconnectEvent | WelcomeEvent | MutationEvent } } + | { + type: 'presentation/shared-state' + data: { + key: string + value?: Serializable + } + } /** * @public @@ -258,6 +265,13 @@ export type VisualEditingNodeMsg = features: Record } } + | { + type: 'visual-editing/shared-state' + data: undefined + response: { + state: SerializableObject + } + } /** * @public @@ -345,3 +359,20 @@ export type PreviewKitNodeMsg = { documents: ContentSourceMapDocuments } } + +/** + * @public + */ +export type SerializablePrimitive = string | number | boolean | null | undefined +/** + * @public + */ +export type SerializableObject = {[key: string]: Serializable} +/** + * @public + */ +export type SerializableArray = Serializable[] +/** + * @public + */ +export type Serializable = SerializablePrimitive | SerializableObject | SerializableArray diff --git a/packages/visual-editing/src/index.ts b/packages/visual-editing/src/index.ts index 1317f44c7..0b50dcba4 100644 --- a/packages/visual-editing/src/index.ts +++ b/packages/visual-editing/src/index.ts @@ -64,6 +64,7 @@ export { useDocuments, useOptimistic, } from './ui/optimistic-state' +export {useSharedState} from './ui/shared-state/useSharedState' export { type CreateDataAttribute, type CreateDataAttributeProps, diff --git a/packages/visual-editing/src/ui/Overlays.tsx b/packages/visual-editing/src/ui/Overlays.tsx index c33fe2c9e..6724ff98d 100644 --- a/packages/visual-editing/src/ui/Overlays.tsx +++ b/packages/visual-editing/src/ui/Overlays.tsx @@ -39,6 +39,7 @@ import {OverlayMinimapPrompt} from './OverlayMinimapPrompt' import {overlayStateReducer} from './overlayStateReducer' import {PreviewSnapshotsProvider} from './preview/PreviewSnapshotsProvider' import {SchemaProvider} from './schema/SchemaProvider' +import {SharedStateProvider} from './shared-state/SharedStateProvider.tsx' import {useController} from './useController' import {usePerspectiveSync} from './usePerspectiveSync' import {useReportDocuments} from './useReportDocuments' @@ -338,68 +339,70 @@ export const Overlays: FunctionComponent<{ - - - - {contextMenu && } - {!isDragging && - elementsToRender.map( - ({id, element, focused, hovered, rect, sanity, dragDisabled}) => { - const draggable = - !dragDisabled && - !!element.getAttribute('data-sanity') && - optimisticActorReady && - elements.some((e) => - 'id' in e.sanity && 'id' in sanity - ? sanityNodesExistInSameArray(e.sanity, sanity) && - e.sanity.path !== sanity.path - : false, + + + + + {contextMenu && } + {!isDragging && + elementsToRender.map( + ({id, element, focused, hovered, rect, sanity, dragDisabled}) => { + const draggable = + !dragDisabled && + !!element.getAttribute('data-sanity') && + optimisticActorReady && + elements.some((e) => + 'id' in e.sanity && 'id' in sanity + ? sanityNodesExistInSameArray(e.sanity, sanity) && + e.sanity.path !== sanity.path + : false, + ) + + return ( + ) + }, + )} - return ( - - ) - }, + {isDragging && !dragMinimapTransition && ( + <> + {dragInsertPosition && ( + + )} + {dragShowMinimapPrompt && } + {dragGroupRect && } + )} - - {isDragging && !dragMinimapTransition && ( - <> - {dragInsertPosition && ( - - )} - {dragShowMinimapPrompt && } - {dragGroupRect && } - - )} - {isDragging && dragSkeleton && } - + {isDragging && dragSkeleton && } + + diff --git a/packages/visual-editing/src/ui/shared-state/SharedStateContext.ts b/packages/visual-editing/src/ui/shared-state/SharedStateContext.ts new file mode 100644 index 000000000..5f6234775 --- /dev/null +++ b/packages/visual-editing/src/ui/shared-state/SharedStateContext.ts @@ -0,0 +1,10 @@ +import {createContext} from 'react' +import type {VisualEditingNode} from '../../types' +import type {SharedStateStore} from './sharedStateStore' + +export interface SharedStateContextValue { + comlink?: VisualEditingNode + store: SharedStateStore +} + +export const SharedStateContext = createContext(null) diff --git a/packages/visual-editing/src/ui/shared-state/SharedStateProvider.tsx b/packages/visual-editing/src/ui/shared-state/SharedStateProvider.tsx new file mode 100644 index 000000000..23f3e61c7 --- /dev/null +++ b/packages/visual-editing/src/ui/shared-state/SharedStateProvider.tsx @@ -0,0 +1,67 @@ +import type {SerializableObject} from '@repo/visual-editing-helpers' +import {useEffect, useMemo, type FunctionComponent, type PropsWithChildren} from 'react' +import type {VisualEditingNode} from '../../types' +import {SharedStateContext} from './SharedStateContext' + +const createStore = (initialState: SerializableObject) => { + let state = initialState + const getState = () => state + const listeners = new Set<() => void>() + const setState = (fn: (state: SerializableObject) => SerializableObject) => { + state = fn(state) + listeners.forEach((l) => l()) + } + const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + return {getState, setState, subscribe} +} + +const store = createStore({}) + +export const SharedStateProvider: FunctionComponent< + PropsWithChildren<{ + comlink?: VisualEditingNode + }> +> = (props) => { + const {comlink, children} = props + + useEffect(() => { + return comlink?.on('presentation/shared-state', (data) => { + if ('value' in data) { + store.setState((prev) => ({...prev, [data.key]: data.value})) + } else { + store.setState((prev) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const {[data.key]: _removed, ...rest} = prev + return rest + }) + } + }) + }, [comlink]) + + useEffect(() => { + const fetch = async () => { + try { + const value = await comlink?.fetch( + {type: 'visual-editing/shared-state', data: undefined}, + {suppressWarnings: true}, + ) + if (value) { + store.setState(() => value.state) + } + } catch { + // eslint-disable-next-line no-console + console.warn( + '[@sanity/visual-editing]: Failed to fetch shared state. Check your version of `@sanity/presentation` is up-to-date', + ) + } + } + fetch() + }, [comlink]) + + const value = useMemo(() => ({comlink, store}), [comlink]) + + return {children} +} diff --git a/packages/visual-editing/src/ui/shared-state/sharedStateStore.ts b/packages/visual-editing/src/ui/shared-state/sharedStateStore.ts new file mode 100644 index 000000000..b1671c7aa --- /dev/null +++ b/packages/visual-editing/src/ui/shared-state/sharedStateStore.ts @@ -0,0 +1,27 @@ +import type {SerializableObject} from '@repo/visual-editing-helpers' + +export interface SharedStateStore { + getState: () => T + setState: (fn: (state: T) => T) => void + subscribe: (listener: () => void) => () => void +} + +const createStore = (initialState: T): SharedStateStore => { + let state = initialState + const listeners = new Set<() => void>() + + const getState = () => state + const setState = (fn: (state: T) => T) => { + state = fn(state) + listeners.forEach((l) => l()) + } + + const subscribe = (listener: () => void) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + + return {getState, setState, subscribe} +} + +export const store = createStore({}) diff --git a/packages/visual-editing/src/ui/shared-state/useSharedState.ts b/packages/visual-editing/src/ui/shared-state/useSharedState.ts new file mode 100644 index 000000000..735d1346e --- /dev/null +++ b/packages/visual-editing/src/ui/shared-state/useSharedState.ts @@ -0,0 +1,20 @@ +import {useCallback, useContext, useSyncExternalStore} from 'react' +import {SharedStateContext} from './SharedStateContext' + +export function useSharedState< + T extends boolean | null | number | object | string | undefined | unknown = unknown, +>(key: string): T { + const context = useContext(SharedStateContext) + if (!context) { + throw new Error('useSharedState must be used within a SharedStateProvider') + } + + const {store} = context + + const value = useSyncExternalStore( + store.subscribe, + useCallback(() => store.getState()[key] as T, [key, store]), + ) + + return value +}