diff --git a/.eslintignore b/.eslintignore index 9220f74..8214920 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,6 @@ /dist _unstable_apply.js +_unstable_machine.js _unstable_store.js path.js .eslintrc.cjs diff --git a/.github/renovate.json b/.github/renovate.json index 59b43dd..dd88efd 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,4 +1,12 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": ["local>sanity-io/renovate-config"] + "extends": ["local>sanity-io/renovate-config"], + "packageRules": [ + { + "matchDepNames": ["xstate"], + "matchDepTypes": ["devDependencies", "peerDependencies"], + "rangeStrategy": "bump", + "semanticCommitType": "fix" + } + ] } diff --git a/.gitignore b/.gitignore index 3e91e3b..d8c42d5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ coverage # legacy exports _unstable_apply.js +_unstable_machine.js _unstable_store.js path.js tsconfig.tsbuildinfo diff --git a/examples/visual-editing/.env.example b/examples/visual-editing/.env.example new file mode 100644 index 0000000..c19fa1a --- /dev/null +++ b/examples/visual-editing/.env.example @@ -0,0 +1,3 @@ +VITE_SANITY_API_TOKEN= +VITE_SANITY_API_PROJECT_ID= +VITE_SANITY_API_DATASET= diff --git a/examples/visual-editing/.eslintrc.cjs b/examples/visual-editing/.eslintrc.cjs new file mode 100644 index 0000000..ce1c34c --- /dev/null +++ b/examples/visual-editing/.eslintrc.cjs @@ -0,0 +1,53 @@ +module.exports = { + extends: '../../.eslintrc.cjs', + settings: { + react: { + version: 'detect', + }, + }, + overrides: [ + { + files: ['**/*.ts', '**/*.tsx'], + parser: '@typescript-eslint/parser', + parserOptions: { + project: true, + tsconfigRootDir: `${__dirname}`, + }, + extends: [ + 'eslint:recommended', + 'plugin:prettier/recommended', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + 'plugin:react/jsx-runtime', + ], + plugins: ['import', '@typescript-eslint', 'prettier', 'react-compiler'], + rules: { + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/member-delimiter-style': 'off', + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/no-unused-vars': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'error', + {prefer: 'type-imports'}, + ], + '@typescript-eslint/no-dupe-class-members': ['error'], + '@typescript-eslint/no-shadow': ['error'], + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': ['warn'], + 'import/no-duplicates': ['error', {'prefer-inline': true}], + 'import/first': 'error', + 'import/newline-after-import': 'error', + 'import/consistent-type-specifier-style': ['error', 'prefer-inline'], + 'import/order': 'off', // handled by simple-import-sort + 'sort-imports': 'off', // handled by simple-import-sort + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + 'react-compiler/react-compiler': 'error', + }, + }, + ], +} diff --git a/examples/visual-editing/.gitignore b/examples/visual-editing/.gitignore new file mode 100644 index 0000000..9488683 --- /dev/null +++ b/examples/visual-editing/.gitignore @@ -0,0 +1,3 @@ +.env +.vercel +.env*.local diff --git a/examples/visual-editing/index.html b/examples/visual-editing/index.html new file mode 100644 index 0000000..2fa3862 --- /dev/null +++ b/examples/visual-editing/index.html @@ -0,0 +1,12 @@ + + + + + + Sanity Visual Editing example + + +
+ + + diff --git a/examples/visual-editing/main.tsx b/examples/visual-editing/main.tsx new file mode 100644 index 0000000..e4d8191 --- /dev/null +++ b/examples/visual-editing/main.tsx @@ -0,0 +1,81 @@ +import {studioTheme, ThemeProvider} from '@sanity/ui' +import {lazy, Suspense, useState, useSyncExternalStore} from 'react' +import {createRoot} from 'react-dom/client' +import {createGlobalStyle} from 'styled-components' + +const Studio = lazy(() => import('./studio')) +const Preview = lazy(() => import('./preview')) + +const SCROLLBAR_SIZE = 12 // px +const SCROLLBAR_BORDER_SIZE = 4 // px + +const GlobalStyle = createGlobalStyle` + html, body { + margin: 0; + padding: 0; + overscroll-behavior: none; + } + + body { + scrollbar-gutter: stable; + } + + ::-webkit-scrollbar { + width: ${SCROLLBAR_SIZE}px; + height: ${SCROLLBAR_SIZE}px; + } + + ::-webkit-scrollbar-corner { + background-color: transparent; + } + + ::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: var(--card-border-color, ${({theme}) => theme.sanity.color.border}); + border: ${SCROLLBAR_BORDER_SIZE}px solid transparent; + border-radius: ${SCROLLBAR_SIZE * 2}px; + } + + ::-webkit-scrollbar-thumb:hover { + background-color: var(--card-muted-fg-color, ${({theme}) => theme.sanity.color.muted.fg}); + } + + ::-webkit-scrollbar-track { + background: transparent; + } +` + +createRoot(document.getElementById('root') as HTMLElement).render( + + + + , +) + +function Router() { + const [isPreview] = useState(() => { + const {searchParams} = new URL(location.href) + return searchParams.has('preview') + }) + const [isDebug] = useState(() => { + const {searchParams} = new URL(location.href) + return searchParams.has('debug') + }) + // If we're being inside an iframe, or spawned from another window, render the preview + const isStudio = useSyncExternalStore( + subscribe, + () => window.self === window.top && !window.opener, + ) + + return ( + + {!isPreview && isStudio ? ( + + ) : ( + + )} + + ) +} + +const subscribe = () => () => {} diff --git a/examples/visual-editing/package.json b/examples/visual-editing/package.json new file mode 100644 index 0000000..0b7ebb7 --- /dev/null +++ b/examples/visual-editing/package.json @@ -0,0 +1,62 @@ +{ + "name": "example-visual-editing", + "version": "1.0.0", + "private": true, + "license": "MIT", + "main": "package.json", + "scripts": { + "dev": "vite dev", + "start": "vite dev", + "test": "vitest" + }, + "dependencies": { + "@react-spring/three": "^9.7.5", + "@react-three/drei": "^9.117.3", + "@react-three/fiber": "^8.17.10", + "@sanity/client": "^6.22.5", + "@sanity/comlink": "^2.0.0", + "@sanity/icons": "^3.5.0", + "@sanity/mutate": "workspace:*", + "@sanity/sanitype": "^0.6.2", + "@sanity/ui": "^2.8.9", + "@statelyai/inspect": "^0.4.0", + "@types/three": "^0.169.0", + "@xstate/react": "^4.1.3", + "comlink": "^4.4.1", + "groq-js": "^1.13.0", + "mendoza": "^3.0.7", + "nice-color-palettes": "^4.0.0", + "polished": "^4.3.1", + "react": "^18.3.1", + "react-colorful": "^5.6.1", + "react-compiler-runtime": "beta", + "react-dom": "^18.3.1", + "react-jason": "^1.1.2", + "rxjs": "^7.8.1", + "styled-components": "^6.1.13", + "three": "^0.169.0", + "xstate": "^5.18.2" + }, + "devDependencies": { + "@types/node": "catalog:", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "@typescript-eslint/eslint-plugin": "catalog:", + "@typescript-eslint/parser": "catalog:", + "@vitejs/plugin-react": "^4.3.2", + "babel-plugin-react-compiler": "beta", + "eslint": "catalog:", + "eslint-config-prettier": "catalog:", + "eslint-config-sanity": "catalog:", + "eslint-plugin-import": "catalog:", + "eslint-plugin-prettier": "catalog:", + "eslint-plugin-react": "catalog:", + "eslint-plugin-react-compiler": "beta", + "eslint-plugin-react-hooks": "catalog:", + "eslint-plugin-simple-import-sort": "catalog:", + "eslint-plugin-unused-imports": "catalog:", + "typescript": "catalog:", + "vite": "^5.4.9", + "vitest": "^2.1.3" + } +} diff --git a/examples/visual-editing/preview/App.tsx b/examples/visual-editing/preview/App.tsx new file mode 100644 index 0000000..91e972f --- /dev/null +++ b/examples/visual-editing/preview/App.tsx @@ -0,0 +1,94 @@ +import {Box, Card, Grid} from '@sanity/ui' +import {useRef, useState} from 'react' + +import {DOCUMENT_IDS} from '../shared/constants' +import {useVisualEditingDocumentSnapshot} from './context' +import EditableShoeComponent from './EditableShoe' +import PreviewShoeComponent from './PreviewShoe' + +export default function App() { + const [editing, setEditing] = useState(DOCUMENT_IDS[0]!) + const scrollRef = useRef(0) + + return ( + <> + { + scrollRef.current = e.currentTarget.scrollLeft / 320 + }} + > + + {DOCUMENT_IDS.map((id, index) => ( + + ))} + + + + + ) +} + +function EditableShoe(props: {documentId: string}) { + const {documentId} = props + const snapshot = useVisualEditingDocumentSnapshot(documentId) + + return ( + + ) +} + +function PreviewShoe(props: { + documentId: string + selected: boolean + setEditing: React.Dispatch> + scrollRef: React.MutableRefObject + index: number +}) { + const {documentId, selected, setEditing, scrollRef, index} = props + + const snapshot = useVisualEditingDocumentSnapshot(documentId) || ({} as any) + const {name, model} = snapshot + + return ( + setEditing(documentId)} + border={true} + > + + + ) +} diff --git a/examples/visual-editing/preview/EditableShoe.tsx b/examples/visual-editing/preview/EditableShoe.tsx new file mode 100644 index 0000000..1d0b988 --- /dev/null +++ b/examples/visual-editing/preview/EditableShoe.tsx @@ -0,0 +1,227 @@ +import { + ContactShadows, + Environment, + OrbitControls, + View, +} from '@react-three/drei' +import {useFrame} from '@react-three/fiber' +import {type Infer} from '@sanity/sanitype' +import {Box, Card, Heading, Inline} from '@sanity/ui' +import {useEffect, useRef, useState} from 'react' +import {HexColorPicker} from 'react-colorful' +import {styled} from 'styled-components' +import {type Group, type Object3DEventMap} from 'three' + +import {type airmax, type dunklow, type ultraboost} from '../studio/schema/shoe' +import {AirmaxModel, DunklowModel, UltraboostModel} from './Shoes' + +const StyledView = styled(View)` + height: 100%; + width: 100%; +` + +export default function EditableShoe(props: { + documentId: string + title: string | undefined + model: Infer +}) { + const {title, documentId} = props + const ref = useRef(null) + const [selectedColor, setSelectedColor] = useState(null) + + const [model, mutateModel] = useState(() => props.model as any) + + const [syncedId, setSyncedId] = useState(() => props.documentId) + useEffect(() => { + if (documentId !== syncedId) { + mutateModel(props.model as any) + setSyncedId(documentId) + } + }, [documentId, props.model, syncedId]) + + const [hovered, setHovered] = useState(null) + useEffect(() => { + const el = ref.current + if (!hovered || !el) return + + const cursor = `${hovered}` + const auto = `` + el.style.cursor = `url('data:image/svg+xml;base64,${btoa(cursor)}'), auto` + return () => { + el.style.cursor = `url('data:image/svg+xml;base64,${btoa(auto)}'), auto` + } + }, [hovered, model]) + + return ( + + + + {props.model ? title || 'Untitled' : 'Loading…'} + + + + + + ( + e.stopPropagation(), setHovered(e.object.material.name) + )} + onPointerOut={e => e.intersections.length === 0 && setHovered(null)} + onPointerMissed={() => setSelectedColor(null)} + onClick={e => ( + e.stopPropagation(), setSelectedColor(e.object.material.name) + )} + > + {model._type === 'airmax' ? ( + + ) : model._type === 'dunklow' ? ( + + ) : ( + + )} + + + + + + + + ) +} + +function EditableAirmaxShoe(props: {model: Infer}) { + const {model} = props + const ref = useRef>(null) + + useFrame(state => { + if (!ref.current) return + const t = state.clock.getElapsedTime() + ref.current.rotation.set( + Math.cos(t / 4) / 8, + -5 + Math.sin(t / 4) / 8, + (3 + Math.sin(t / 1.5)) / 20, + ) + ref.current.position.y = (1 + Math.sin(t / 1.5)) / 10 - 0.5 + }) + + return +} + +function EditableDunklowShoe(props: {model: Infer}) { + const {model} = props + const ref = useRef>(null) + + useFrame(state => { + if (!ref.current) return + const t = state.clock.getElapsedTime() + ref.current.rotation.set( + Math.cos(t / 4) / 8, + 3 + Math.sin(t / 4) / 8, + 0.2 - (1 + Math.sin(t / 1.5)) / 20, + ) + ref.current.position.y = (1 + Math.sin(t / 1.5)) / 10 - 0.5 + }) + + return +} + +function EditableUltraboostShoe(props: {model: Infer}) { + const {model} = props + const ref = useRef>(null) + + useFrame(state => { + if (!ref.current) return + const t = state.clock.getElapsedTime() + ref.current.rotation.set( + Math.cos(t / 4) / 8, + Math.sin(t / 4) / 8, + -0.2 - (1 + Math.sin(t / 1.5)) / 20, + ) + ref.current.position.y = (1 + Math.sin(t / 1.5)) / 10 + }) + + return +} + +function Picker({ + model, + selectedColor, + mutateModel, +}: { + model: Infer + selectedColor: string | null + mutateModel: React.Dispatch< + React.SetStateAction< + Infer + > + > +}) { + return ( + + + mutateModel(prev => ({...prev, [selectedColor!]: color})) + } + /> + + {selectedColor} + + + ) +} diff --git a/examples/visual-editing/preview/PreviewShoe.tsx b/examples/visual-editing/preview/PreviewShoe.tsx new file mode 100644 index 0000000..bc4419b --- /dev/null +++ b/examples/visual-editing/preview/PreviewShoe.tsx @@ -0,0 +1,160 @@ +import { + ContactShadows, + Environment, + PerspectiveCamera, + View, +} from '@react-three/drei' +import {useFrame} from '@react-three/fiber' +import {type Infer} from '@sanity/sanitype' +import {Box, Heading} from '@sanity/ui' +import {useRef} from 'react' +import {styled} from 'styled-components' +import {type Group, type Object3DEventMap} from 'three' + +import {type airmax, type dunklow, type ultraboost} from '../studio/schema/shoe' +import {AirmaxModel, DunklowModel, UltraboostModel} from './Shoes' + +const StyledView = styled(View)` + height: 100%; + width: 100%; +` + +export default function PreviewShoe( + props: { + title: string | undefined + } & Pick< + | React.ComponentProps + | React.ComponentProps + | React.ComponentProps, + 'model' | 'scrollRef' | 'offsetLeft' + >, +) { + const {title, scrollRef, model, offsetLeft} = props + + return ( + <> + + + {title || 'Untitled'} + + + + + + {model._type === 'airmax' ? ( + + ) : model._type === 'dunklow' ? ( + + ) : ( + + )} + + + + + + + ) +} + +function PreviewAirmaxShoe(props: { + model: Infer + scrollRef: React.MutableRefObject + offsetLeft: number +}) { + const {offsetLeft, scrollRef, model} = props + const ref = useRef>(null) + + useFrame(state => { + if (!ref.current) return + const t = state.clock.getElapsedTime() + ref.current.rotation.set( + Math.cos(t / 4) / 8, + -5 + Math.sin(t / 4) / 8 + (offsetLeft + scrollRef.current * 3 - 1000), + (3 + Math.sin(t / 1.5)) / 20, + ) + ref.current.position.y = (1 + Math.sin((t + offsetLeft) / 1.5)) / 10 - 0.5 + }) + + return +} + +function PreviewDunklowShoe(props: { + model: Infer + scrollRef: React.MutableRefObject + offsetLeft: number +}) { + const {offsetLeft, scrollRef, model} = props + const ref = useRef>(null) + useFrame(state => { + if (!ref.current) return + const t = state.clock.getElapsedTime() + ref.current.rotation.set( + Math.cos(t / 4) / 8, + 3 + Math.sin(t / 4) / 8 + (offsetLeft + scrollRef.current * 3 - 1000), + 0.2 - (1 + Math.sin(t / 1.5)) / 20, + ) + ref.current.position.y = (1 + Math.sin((t + offsetLeft) / 1.5)) / 10 - 0.5 + }) + return +} + +function PreviewUltraboostShoe(props: { + model: Infer + scrollRef: React.MutableRefObject + offsetLeft: number +}) { + const {offsetLeft, scrollRef, model} = props + const ref = useRef>(null) + + useFrame(state => { + if (!ref.current) return + const t = state.clock.getElapsedTime() + ref.current.rotation.set( + Math.cos(t / 4) / 8, + Math.sin(t / 4) / 8 + (offsetLeft + scrollRef.current * 3 - 1000), + -0.2 - (1 + Math.sin(t / 1.5)) / 20, + ) + ref.current.position.y = (1 + Math.sin((t + offsetLeft) / 1.5)) / 10 + }) + + return +} diff --git a/examples/visual-editing/preview/Shoes.tsx b/examples/visual-editing/preview/Shoes.tsx new file mode 100644 index 0000000..c40d536 --- /dev/null +++ b/examples/visual-editing/preview/Shoes.tsx @@ -0,0 +1,281 @@ +import {useGLTF} from '@react-three/drei' +import {type Infer} from '@sanity/sanitype' +import {forwardRef} from 'react' +import {type Group, type Object3DEventMap} from 'three' + +import {type airmax, type dunklow, type ultraboost} from '../studio/schema/shoe' + +export const AirmaxModel = forwardRef< + Group, + {model: Infer} +>(function AirmaxModel(props, forwardedRef) { + const {model} = props + const {nodes, materials} = useGLTF('/shoe-airmax.glb') + + const color = materials.ASSET_MAT_MR?.clone() + const gel = materials.ASSET_MAT_MR?.clone() + + return ( + + + + + ) +}) + +export const DunklowModel = forwardRef< + Group, + {model: Infer} +>(function DunklowModel(props, forwardedRef) { + const {model} = props + const {nodes, materials} = useGLTF('/shoe-dunklow.glb') + + const towel = materials['Fluffy White Towel']?.clone() + const neck = materials['Material.011']?.clone() + const soleTop = materials['Material.012']?.clone() + const soleBottom = materials['Material.013']?.clone() + const nikeLogo = materials['Material.001']?.clone() + const coatFront = materials['Material.002']?.clone() + const coatMiddle = materials['Material.002']?.clone() + const coatBack = materials['Material.002']?.clone() + const patch = materials['Material.006']?.clone() + const laces = materials['Material.003']?.clone() + const nikeText = materials.Material?.clone() + const inner = nodes.desighn_00?.material?.clone() + + return ( + + + + + + + + + + + + + + + + + + + ) +}) + +export const UltraboostModel = forwardRef< + Group, + {model: Infer} +>(function UltraboostModel(props, forwardedRef) { + const {model} = props + const {nodes, materials} = useGLTF('/shoe-ultraboost.glb') + + const laces = materials.laces?.clone() + const mesh = materials.mesh?.clone() + const caps = materials.caps?.clone() + const inner = materials.inner?.clone() + const sole = materials.sole?.clone() + const stripes = materials.stripes?.clone() + const band = materials.band?.clone() + const patch = materials.patch?.clone() + + return ( + + + + + + + + + + + + ) +}) diff --git a/examples/visual-editing/preview/context.ts b/examples/visual-editing/preview/context.ts new file mode 100644 index 0000000..c38ccae --- /dev/null +++ b/examples/visual-editing/preview/context.ts @@ -0,0 +1,76 @@ +import {createClient} from '@sanity/client' +import {createActorContext, useSelector} from '@xstate/react' +import {useEffect} from 'react' +import {createEmptyActor} from 'xstate' + +import {defineLocalDocumentMachine} from '../shared/state/local-document-machine' +import {defineRemoteDocumentMachine} from '../shared/state/remote-document-machine' +import { + createSharedListener, + defineGetDocument, + defineRemoteEvents, +} from '../shared/state/remote-events-machine' +import {defineVisualEditingMachine} from '../shared/state/visual-editor-machine' + +const sanityClient = createClient({ + projectId: import.meta.env.VITE_SANITY_API_PROJECT_ID, + dataset: import.meta.env.VITE_SANITY_API_DATASET, + apiVersion: '2023-10-27', + useCdn: false, + token: import.meta.env.VITE_SANITY_API_TOKEN, +}) + +/** + * How long the `client.listen()` EventSource is kept alive after the last subscriber unsubscribes, until it's possibly resubscribed + */ +const keepAlive = 1_000 + +/** + * Dependencies for the Visual Editing machine + */ +const listener = createSharedListener(sanityClient) +const remoteEvents = defineRemoteEvents({listener, keepAlive}) +const getDocument = defineGetDocument({client: sanityClient}) +const remoteDocumentMachine = defineRemoteDocumentMachine({ + getDocument, + remoteEvents, +}) +const localDocumentMachine = defineLocalDocumentMachine({remoteDocumentMachine}) + +export const visualEditingMachine = defineVisualEditingMachine({ + localDocumentMachine, +}) + +export const { + Provider: VisualEditingProvider, + useActorRef: useVisualEditingActorRef, + useSelector: useVisualEditingSelector, +} = createActorContext(visualEditingMachine) + +// Used to handle cases where an actor isn't set yet +const emptyLocalDocumentActor = createEmptyActor() + +export function useVisualEditingDocumentSnapshot(documentId: string) { + const visualEditingActorRef = useVisualEditingActorRef() + + // Always load up whichever document a snapshot is being requested for + useEffect(() => { + // Due to React StrictMode, we schedule it to the next tick, and cancel it on unmount + const raf = requestAnimationFrame(() => { + visualEditingActorRef.send({type: 'listen', documentId}) + }) + return () => cancelAnimationFrame(raf) + }, [documentId, visualEditingActorRef]) + + // Get a ref to the local document machine + const localDocumentActorRef = useVisualEditingSelector( + snapshot => + snapshot.context.documents[documentId] || emptyLocalDocumentActor, + ) + + return useSelector( + localDocumentActorRef, + // @ts-expect-error figure out how to infer types correctly when using nullable actor refs + state => state?.context?.remoteSnapshot, + ) +} diff --git a/examples/visual-editing/preview/index.tsx b/examples/visual-editing/preview/index.tsx new file mode 100644 index 0000000..f7f3b9d --- /dev/null +++ b/examples/visual-editing/preview/index.tsx @@ -0,0 +1,90 @@ +import {useGLTF, View} from '@react-three/drei' +import {Canvas} from '@react-three/fiber' +import {Card, Grid} from '@sanity/ui' +import {useEffect, useMemo, useRef, useState} from 'react' +import {styled} from 'styled-components' + +// import * as Comlink from 'comlink' +import { + globalSymbol, + type InspectorInspectContextValue, +} from '../shared/inspector' +import App from './App' +import {VisualEditingProvider} from './context' + +useGLTF.preload([ + '/shoe-airmax.glb', + '/shoe-dunklow.glb', + '/shoe-ultraboost.glb', +]) + +export default function Preview(props: {debug: boolean}) { + const {debug} = props + const eventSourceRef = useRef(null) + const [mounted, setMounted] = useState(!debug) + const [inspect, setInspect] = + useState(undefined) + + // useEffect(() => { + // function send(event: unknown) { + // console.log('send', event) + // } + // Comlink.expose(send, Comlink.windowEndpoint(self.parent)) + // }, []) + + useEffect(() => { + if (mounted) return + + const raf = requestAnimationFrame(() => { + setMounted(true) + // @ts-expect-error this is fine + const inspector = parent.window[globalSymbol] + if (inspector) setInspect(inspector.inspect) + }) + return () => cancelAnimationFrame(raf) + }, [mounted]) + + const visualEditingOptions = useMemo(() => ({inspect}), [inspect]) + + return ( + <> + + {mounted && ( + + + + + + )} + + + + + + ) +} + +const Container = styled(Grid)` + box-sizing: border-box; + height: 100vh; + max-height: 100dvh; + overflow: clip; + overscroll-behavior: none; + grid-auto-rows: min-content 1fr; +` + +const StyledCanvas = styled(Canvas)` + pointer-events: none; + position: absolute !important; + top: 0.75rem; + left: 0.75rem; + bottom: 0.75rem; + right: 0.75rem; + height: auto !important; + width: auto !important; +` diff --git a/examples/visual-editing/public/shoe-airmax.glb b/examples/visual-editing/public/shoe-airmax.glb new file mode 100644 index 0000000..c87d00d Binary files /dev/null and b/examples/visual-editing/public/shoe-airmax.glb differ diff --git a/examples/visual-editing/public/shoe-dunklow.glb b/examples/visual-editing/public/shoe-dunklow.glb new file mode 100644 index 0000000..66f5487 Binary files /dev/null and b/examples/visual-editing/public/shoe-dunklow.glb differ diff --git a/examples/visual-editing/public/shoe-ultraboost.glb b/examples/visual-editing/public/shoe-ultraboost.glb new file mode 100644 index 0000000..c600339 Binary files /dev/null and b/examples/visual-editing/public/shoe-ultraboost.glb differ diff --git a/examples/visual-editing/shared/DocumentView.tsx b/examples/visual-editing/shared/DocumentView.tsx new file mode 100644 index 0000000..5e9698c --- /dev/null +++ b/examples/visual-editing/shared/DocumentView.tsx @@ -0,0 +1,84 @@ +import {type SanityDocumentBase} from '@sanity/mutate' +import { + Card, + Flex, + Heading, + Stack, + Tab, + TabList, + TabPanel, + Text, +} from '@sanity/ui' +import {useState} from 'react' + +import {JsonView} from './json-view/JsonView' + +interface DocumentViewProps { + local: Doc | undefined + remote: Doc | undefined +} + +const TABS = [{id: 'current', title: 'Current document'}] as const + +export function DocumentView( + props: DocumentViewProps, +) { + const {local, remote} = props + const [tabId, setTab] = useState<(typeof TABS)[number]['id']>(TABS[0].id) + const tabs = { + current: () => ( + + {local && ( + + + Local + + + + + + )} + {remote && ( + + + Remote + + + + + + )} + + ), + } + return ( + + + {TABS.map(tab => ( + { + setTab(tab.id) + }} + selected={tab.id === tabId} + /> + ))} + + {TABS.map(tab => ( + + ))} + + ) +} diff --git a/examples/visual-editing/shared/constants.ts b/examples/visual-editing/shared/constants.ts new file mode 100644 index 0000000..7adc9d2 --- /dev/null +++ b/examples/visual-editing/shared/constants.ts @@ -0,0 +1,10 @@ +export const DOCUMENT_IDS = [ + 'shoe-a', + 'shoe-b', + 'shoe-c', + 'shoe-d', + 'shoe-e', + 'shoe-f', + // 'shoe-g', + // 'shoe-h', +] diff --git a/examples/visual-editing/shared/inspector.ts b/examples/visual-editing/shared/inspector.ts new file mode 100644 index 0000000..bb06f31 --- /dev/null +++ b/examples/visual-editing/shared/inspector.ts @@ -0,0 +1,10 @@ +import {type InspectionEvent, type Observer} from 'xstate' + +export const globalSymbol: symbol = Symbol.for( + '@statelyai/inspect/example-visual-editing', +) + +export type InspectorInspectContextValue = + | Observer + | ((inspectionEvent: InspectionEvent) => void) + | undefined diff --git a/examples/visual-editing/shared/json-view/JsonView.tsx b/examples/visual-editing/shared/json-view/JsonView.tsx new file mode 100644 index 0000000..4b0aa2b --- /dev/null +++ b/examples/visual-editing/shared/json-view/JsonView.tsx @@ -0,0 +1,41 @@ +import {type CSSProperties} from 'react' +import {ReactJason} from 'react-jason' +import styled from 'styled-components' + +export const sharedRoot: CSSProperties = { + fontFeatureSettings: '"liga" 0, "calt" 0', + whiteSpace: 'pre', + margin: 0, +} + +const theme = { + styles: { + root: sharedRoot, + attribute: {color: '#b8860b'}, + unquotedAttribute: {color: '#b8860b'}, + string: {color: '#008000'}, + nil: {color: '#806600'}, + number: {color: 'blue'}, + boolean: {color: '#008080'}, + punctuation: {color: '#888'}, + }, +} + +const OnelineWrapper = styled.span` + pre { + white-space: normal !important; + display: inline; + } +` + +export function JsonView(props: {value: any; oneline?: boolean}) { + const jason = ( + + ) + + return props.oneline ? {jason} : jason +} diff --git a/examples/visual-editing/shared/state/dataset-lru-cache-machine.ts b/examples/visual-editing/shared/state/dataset-lru-cache-machine.ts new file mode 100644 index 0000000..1579d73 --- /dev/null +++ b/examples/visual-editing/shared/state/dataset-lru-cache-machine.ts @@ -0,0 +1,10 @@ +/** + * Synchronize a LRU Cache of a dataset with mendoza mutations. + * It uses a long lived subscription to a `client.listen()` instance, and will apply patches to it as they come in. + * If the LRU Cache starts evicting entries, then mendoza events will be ignored. + * Special considerations: + * - If a document exists initially, but then a mendonza event comes in that deletes it, then the cache entry for the document will be `null`. + * - A document that initially exists, is deleted, might then be created again, so documents that are `null` need to handle document creations. + * - Other machines might be writing to the same cache, so it's important to verify that the revision for the mendoza event isn't already applied before applying. + * - When checking incoming mendoza events to see if they should be applied it's important to use cache methods that don't increase the LRU Cache's `used` count, as this machine is supposed to sync documents in the background and its activities are not an indication of wether the document is recently used or not. + */ diff --git a/examples/visual-editing/shared/state/document-editor-machine.ts b/examples/visual-editing/shared/state/document-editor-machine.ts new file mode 100644 index 0000000..5b135ac --- /dev/null +++ b/examples/visual-editing/shared/state/document-editor-machine.ts @@ -0,0 +1,439 @@ +/* eslint-disable no-console */ +import {type MutationEvent, type SanityDocument} from '@sanity/client' +import {type Mutation, type SanityDocumentBase} from '@sanity/mutate' +import { + applyMutations, + commit, + type MutationGroup, + rebase, + squashDMPStrings, + squashMutationGroups, + toTransactions, +} from '@sanity/mutate/_unstable_machine' +import {applyPatch, type RawPatch} from 'mendoza' +import {assertEvent, assign, setup} from 'xstate' + +import { + type GetDocumentMachine, + type RemoteEventsMachine, + type RemoteSnapshotEvents, +} from './remote-events-machine' +import {type SubmitTransactionsMachine} from './submit-transactions-machine' + +/** + * This machine is responsible for keeping a snapshot of a document in sync with any remote changes, as well as applying optimistic mutations, reconciling local and remote updates, and optimizing mutations before sending them in a transaction. + * It will setup a long lived subscription to a `client.listen()` instance. + * It's recommended that the `remoteEvents` machine is using a RxJS observer that can ensure that a single EventSource + * is established and its events multicast to all listeners, instead of setting up multiple EventSources to the same dataset as that would be a waste of resources and network bandwidth, + * not to mention there's a technical limit to how many concurrent `client.listen()` instances that a dataset allows: https://www.sanity.io/docs/technical-limits#c973bb88d2be + * The connection is built on mendoza events which are efficient and low bandwidth. + * If a document doesn't exist yet, the `getDocument` actor won't return a result, + * but once a document is created it'll emit a mendoza event that will create the document without needing to refetch it. + * If the `getDocument` fetch itself fails, then it has automatic retries with exponential backoff, as well as the ability to manually fire a `retry` event. + * + * All in all there's a lot of edge cases and logic to consider when one wishes to have a snapshot of a remote document that is then kept in sync with remote changes. + * This machine handles all of those concerns, so that other machines only have to care about wether we have a snapshot of the remote or not, and to handle that accordingly. + */ +export function defineDocumentEditorMachine< + const DocumentType extends SanityDocumentBase = SanityDocumentBase, +>({ + getDocument, + remoteEvents, + submitTransaction, +}: { + getDocument: GetDocumentMachine + remoteEvents: RemoteEventsMachine + submitTransaction: SubmitTransactionsMachine +}) { + return setup({ + types: {} as { + children: { + remoteEvents: 'remoteEvents' + getDocument: 'getDocument' + submitTransaction: 'submitTransaction' + } + context: { + /* The document id, matches `_id` on the document if it exists, and is immutable. If a different document should be edited, then another machine should be spawned for that document */ + documentId: string + /* Preferrably a LRU cache map that is compatible with an ES6 Map, and have documents that allow unique ids to a particular dataset */ + cache: Map | null> + /* The remote snapshot of what the document looks like in Content Lake, kept in sync by applying Mendoza patches in real time. undefined means it's unknown if it exists yet, null means its known that it doesn't exist. */ + remote: SanityDocument | null | undefined + /* Local snapshot, that is rebased to the remote snapshot whenever that snapshot changes, and allows optimistic local mutations. undefined means it's unknown if the document exists in content lake yet, if both `remote` and `local` is `null` it means it's known that it doesn't exist. If `remote` is defined, and `local` is `null` it means it's optimistically deleted. If `remote` is `null` and `local` defined then it's optimistically created. */ + local: SanityDocument | null | undefined + /* Remote mendoza mutation events, needs a better name to differentiate from optimistic mutations */ + mutationEvents: MutationEvent[] + /* Track staged mutations that can be submitted */ + stagedChanges: MutationGroup[] + /* Queue mutations mutations that should be staged after an ongoing submission settles */ + stashedChanges: MutationGroup[] + error: unknown + attempts: number + } + events: + | RemoteSnapshotEvents + | {type: 'retry'} + | {type: 'submit'} + | {type: 'mutate'; mutations: Mutation[]} + | {type: 'success'} + // Rebases the local snapshot with the remote snapshot + | {type: 'rebase local to remote'} + // Build up list over changes, mutations and transactions we wish to submit later + | {type: 'stage changes'} + // Applies the event changes to the local document snapshot + | {type: 'optimistically mutate'} + | {type: 'stash changes'} + | {type: 'restore stashed changes'} + input: { + documentId: string + cache?: Map | null> + } + }, + actors: { + getDocument, + remoteEvents, + submitTransaction, + }, + actions: { + 'stage changes': assign({ + stagedChanges: ({context, event}) => { + assertEvent(event, 'mutate') + const {mutations} = event + + return [...context.stagedChanges, {transaction: false, mutations}] + }, + }), + 'optimistically mutate': assign({ + local: ({context, event}) => { + assertEvent(event, 'mutate') + const {mutations} = event + + // @TODO perhaps separate utils to be lower level and operate on single documents at a time instead of expecting a local dataset + const localDataset = new Map() + localDataset.set(context.documentId, context.local) + // Apply mutations to local dataset (note: this is immutable, and doesn't change the dataset) + const results = applyMutations(mutations, localDataset) + // Write the updated results back to the "local" dataset + commit(results, localDataset) + return localDataset.get(context.documentId) || null + }, + }), + 'stash changes': assign({ + stashedChanges: ({context, event}) => { + assertEvent(event, 'mutate') + const {mutations} = event + + return [...context.stashedChanges, {transaction: false, mutations}] + }, + }), + 'restore changes': assign({ + stagedChanges: ({context}) => { + return context.stashedChanges + }, + stashedChanges: [], + }), + }, + delays: { + // Exponential backoff delay function + timeout: ({context}) => Math.pow(2, context.attempts) * 1000, + }, + guards: { + isCached: ({context}) => { + return context.cache.has(context.documentId) + }, + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QQPYGMCuBbMA7ALgLSQCW+KATgMQVhoq6534DaADALqKgAOKsZEg24gAHogAsAJgA0IAJ6IAjADYArADoAnAGZVEiTolqpOgBxszAXytzUmHAWIQylDSQgAbMFQDuYT3ocdi4kED4BfCFcEXEEKSkzDTYjFKU2NTYtMzNTOUUEFSkVDSUAdh0dKSULMyLjGzt0bDwiUnIKdy8ff0CUYKVQ3n5BYTC46Q1qqS0JFS0tYp0ylPzETM0tFUs1JRMdLK0MxpB7Fqd2t08UAEMXXCgqVCZ3XAA3FABrMA0YfAARZqOVicEQRUYxcaIMwSJSlKQSbISMqw6pZNbxBYaZYWMozVQqQkqMonM7A5yuTrXO4kB5UMAUChuHieG74ABmlCwvzAAKBrRCYJGUTGoDiVSkGjKZl2KiUiKkajUyIxCKSC22ZRqc3UyNJ-IuLg6Gmp90eWAw+DZ0UFYXBIshYsQeIk2LYczxCKUaK0GKUWw06j26VhRMJJNspwNbSNblo9EYzFpj16QTAtuGkWisXWOi0GjMKyk7syZTUBwkfvU2j0KgMRn2tX1DlaFON7JuJE8GFoNF5FHkGfCwuzUIQexKxj0WRR3vSswxejhiSqbBUMMba+b5xjlI0Ha7PZ8olgVvwPxu7PPFAAFFEcChLQBKJ7RttuA-d2hD+2jp3xTQLA9bIVm2ZUlUXHRNAkDIVCghYpAqMwtDUbdyUuKlbggSAqAtM8bVBO0R1FMRlDqOE2BWKDcjKODiVkBRlESDRdgWNj2IWaxIzJVsMJNLDIA0HgKBIU9aR8PC2XTQjMwhHNx3KJIjGWFCylonR6IxExXXURJvVQ7i3z401BJcCh8HkKhYAwAAjLAyB-YjHVI8c1HmFjdDKVT1M0xiEDcsoCyVTyDKaFtDT3EyIA0MyLNwy0pMcrMSLiJRlh0bR1y8tQ1LovEMQMNhtERLLQqjcLd2NKKNGsuyyCiOlnh+WkPm+GrbPs-AABUKBuXBYBuNAHSSuSxwnfM1E87y8oYgoZnVCp9LQ3jY0wu5BNqzqGseBkmU6Fk2U5ChuU2sger6gahoIoZh2S5y4mqddkjmRZKJ8sotOrd1tlDMNCWWiKqoE6LTvwbb4rPaSbt-FKyK8gt4LMKp3tmyR-WKrZCzKnjAauYH2rq-AADFOy-CSEvPEaHXk0wYWxSbpDevKCrlAtCSg36wwByq8fWkGOrIEnD17Wh8AHKm-xchItDhPRFWMXKNI+vzdldZUfoJP6bEjXAUGw+AwhxnmKCFO75MIGXtCVCxtiRrVjDMDELZrNQzBqFDVAJJRuffToPG8U3Rv-QhDCt1210sZZ5VdrTy2SBINb+iMwp3X3+JpB5A+psdMldWi5ml-04JVPyYKKmYZZnGW9BRbGjNWjR4wYJgrszoizbHYs4WRFCDloywkaKP0ERYusajYNh-UmyopB9vjPyPLPJYmVH4gkJJDH0QxjFpyw54bqKl9h8dJ5KUwDGLRXfLmpRu8WhEOM4-fIvx4TRIasAj-u5Q11dc-GavvlFWcc2AJ3dJrcMz8gZ8xiiQcyBRZLZ3-PKRCUpii20QjNT6SlgrLDrhVNO1VQbbS-vJcoiwpiZBLIA1eypApQWnmWKBvNsL80JkLMmpDO4wU0GWCeCt3paWQlMPEictbayAA */ + id: 'document-editor', + context: ({input}) => ({ + documentId: input.documentId, + // @TODO provide an LRU Cache here that is synced with `dataset-lru-cache-machine` + cache: + input.cache || new Map | null>(), + remote: undefined, + local: undefined, + mutationEvents: [], + stagedChanges: [], + stashedChanges: [], + error: undefined, + attempts: 0, + }), + + invoke: { + src: 'remoteEvents', + // id: 'remoteEvents', + input: ({context}) => ({documentId: context.documentId}), + }, + + on: { + // Reconnect failures are even worse than regular failures + reconnect: '.reconnecting', + }, + + states: { + idle: { + on: { + welcome: [ + { + guard: 'isCached', + target: 'loaded', + actions: assign({ + remote: ({context}) => context.cache.get(context.documentId), + }), + }, + {target: 'loading'}, + ], + }, + }, + + loading: { + on: { + // While the document is loading, buffer up mutation events so we can replay them after the document is fetched + mutation: { + actions: assign({ + mutationEvents: ({event, context}) => [ + ...context.mutationEvents, + event, + ], + error: undefined, + }), + }, + }, + invoke: { + src: 'getDocument', + id: 'getDocument', + input: ({context}) => ({documentId: context.documentId}), + + onDone: { + target: 'loaded', + actions: assign(({event, context}) => { + const previousRemote = context.remote + let nextRemote = event.output + + /** + * We assume all patches that happen while we're waiting for the document to resolve are already applied. + * But if we do see a patch that has the same revision as the document we just fetched, we should apply any patches following it + */ + let seenCurrentRev = false + for (const patch of context.mutationEvents) { + if (!patch.effects?.apply || !patch.previousRev) continue + if (!seenCurrentRev && patch.previousRev === nextRemote?._rev) { + seenCurrentRev = true + } + if (seenCurrentRev) { + nextRemote = applyMendozaPatch( + // @ts-expect-error handle later + nextRemote, + patch.effects.apply, + patch.resultRev, + ) + } + } + + if ( + // If the shared cache don't have the document already we can just set it + !context.cache.has(context.documentId) || + // But when it's in the cache, make sure it's necessary to update it + context.cache.get(context.documentId)!._rev !== nextRemote?._rev + ) { + context.cache.set( + context.documentId, + nextRemote as unknown as any, + ) + } + + const [stagedChanges, local] = rebase( + context.documentId, + // It's annoying to convert between null and undefined, reach consensus + previousRemote === null ? undefined : previousRemote, + nextRemote === null + ? undefined + : (nextRemote as unknown as any), + context.stagedChanges, + ) + + return { + remote: nextRemote as unknown as any, + local: local as unknown as any, + stagedChanges, + // Since the snapshot handler applies all the patches they are no longer needed, allow GC + mutationEvents: [], + } + }), + }, + + onError: { + target: 'failure', + actions: assign({ + error: ({event}) => event.error, + // If the document fetch fails then we can assume any mendoza patches we buffered up will no longer be needed + mutationEvents: [], + }), + }, + }, + }, + + reconnecting: { + on: { + welcome: 'loading', + }, + // A reconnection event is considered to be a catastrophic failure, and we have to reset remote state + // We don't reset local state, such as stagedChanges, the local snapshot, as optimistic mutations are still valid and some edits should be fine to do until we're back online + entry: [ + assign({ + remote: null, + mutationEvents: [], + }), + ], + }, + + failure: { + after: { + timeout: { + actions: assign({attempts: ({context}) => context.attempts + 1}), + target: 'loading', + }, + }, + on: { + // We can also manually retry + retry: 'loading', + }, + }, + + loaded: { + on: { + // After the document has loaded, apply the mendoza patches directly + mutation: { + actions: assign(({context, event}) => { + const previousRemote = context.remote + + // @TODO read from shared cache and check if it's necessary to apply mendoza + const nextRemote = event.effects?.apply + ? applyMendozaPatch( + // @ts-expect-error handle later + context.remote, + event.effects.apply, + event.resultRev, + ) + : context.remote + + const [stagedChanges, local] = rebase( + context.documentId, + // It's annoying to convert between null and undefined, reach consensus + previousRemote === null ? undefined : previousRemote, + nextRemote === null + ? undefined + : (nextRemote as unknown as any), + context.stagedChanges, + ) + + return { + remote: nextRemote as unknown as any, + local: local as unknown as any, + stagedChanges, + } + }), + }, + }, + + initial: 'pristine', + states: { + pristine: { + on: { + mutate: { + actions: ['optimistically mutate', 'stage changes'], + target: 'dirty', + }, + }, + }, + dirty: { + on: { + submit: 'submitting', + mutate: { + actions: ['optimistically mutate', 'stage changes'], + }, + }, + }, + submitting: { + invoke: { + src: 'submitTransaction', + id: 'submitTransaction', + input: ({context}) => { + // @TODO perhaps separate utils to be lower level and operate on single documents at a time instead of expecting a local dataset + const remoteDataset = new Map() + remoteDataset.set(context.documentId, context.remote) + return { + transactions: toTransactions( + // Squashing DMP strings is the last thing we do before submitting + squashDMPStrings( + remoteDataset, + squashMutationGroups(context.stagedChanges), + ), + ), + } + }, + + onDone: { + target: 'pristine', + actions: ['restore changes'], + }, + + onError: { + target: 'submitFailure', + actions: assign({ + error: ({event}) => event.error, + // @TODO handle restoring stuff? + }), + }, + }, + + on: { + mutate: { + actions: ['optimistically mutate', 'stash changes'], + }, + }, + }, + submitFailure: { + on: { + mutate: { + actions: ['optimistically mutate', 'stash changes'], + }, + retry: 'submitting', + }, + }, + }, + }, + }, + + initial: 'idle', + }) +} + +export type DocumentEditorMachine< + DocumentType extends SanityDocumentBase = SanityDocumentBase, +> = ReturnType> + +export function applyMendozaPatch< + const DocumentType extends SanityDocumentBase, +>( + document: DocumentType | undefined, + patch: RawPatch, + nextRevision: string | undefined, +) { + const next = applyPatch(omitRev(document), patch) + if (!next) { + return null + } + return Object.assign(next, {_rev: nextRevision}) +} + +function omitRev( + document: DocumentType | undefined, +) { + if (!document) { + return null + } + // eslint-disable-next-line unused-imports/no-unused-vars + const {_rev, ...doc} = document + return doc +} diff --git a/examples/visual-editing/shared/state/local-document-machine.ts b/examples/visual-editing/shared/state/local-document-machine.ts new file mode 100644 index 0000000..6733d45 --- /dev/null +++ b/examples/visual-editing/shared/state/local-document-machine.ts @@ -0,0 +1,76 @@ +import {type SanityDocument} from '@sanity/client' +import {type SanityDocumentBase} from '@sanity/mutate' +import {assign, setup} from 'xstate' + +import {type RemoteDocumentMachine} from './remote-document-machine' + +/** + * This machine handles the tricky implementation details of applying patches optimistically, + * optimizing patches to create smaller deltas and payloads, and such. + */ +export function defineLocalDocumentMachine< + const DocumentType extends SanityDocumentBase = SanityDocumentBase, +>({ + remoteDocumentMachine, +}: { + remoteDocumentMachine: RemoteDocumentMachine +}) { + return setup({ + types: {} as { + children: { + remoteDocumentMachine: 'remoteDocumentMachine' + } + context: { + documentId: string + localSnapshot: SanityDocument | null + remoteSnapshot: SanityDocument | null + } + events: {type: 'mutate'} | {type: 'commit'} + input: { + documentId: string + } + }, + actors: { + remoteDocumentMachine, + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QBsD2BjAhsgIhgrgLZgB2ALgMTICWsZpA2gAwC6ioADqrNWdaiXYgAHogC0ADgBMAOgkA2ACyKArBICcTdfKXSANCACeiFQHYZpgMxWV800yYqp6gIyn5AXw8G0WXAWJyCnwSGjpGViEuHj4BIVEEKUt5OSlFU1smFwkFW0sDY0T1GRUXRXkXKVN7HSYJJkUvHwxsPHQiUjIZDgAnWj4SMApCfDJMemY2JBBo3n5BaYSXeVlbKRUsiXdFSxcXfKNERSqZTbr1zVMJNyaQX1aAzpkIah6yQwp0VEJCXkmo7hzOKLRDLFwyFxaSzXKTOKQudSKCQFRDOczqCSWJjwjbHCQqRFebwgEioCBwIT3fztQJkAExebxcQuFEIZaWGRIrF7dSXKTyFSNYlUtodcjdPp0aiDelAhagBLpVmWAmc46mNwSZTyLQuFS3EWPcUvN6FTiA2LykSoq6c6wbCqVUzparKioyRFVfkq1zqNSWIkeIA */ + id: 'localDocument', + context: ({input}) => ({ + documentId: input.documentId, + localSnapshot: null, + remoteSnapshot: null, + }), + + invoke: { + src: 'remoteDocumentMachine', + id: 'remoteDocumentMachine', + input: ({context}) => ({documentId: context.documentId}), + onSnapshot: { + actions: assign({ + remoteSnapshot: ({event}) => event.snapshot.context.snapshot, + }), + }, + }, + + initial: 'pristine', + + states: { + pristine: { + on: { + mutate: 'dirty', + }, + }, + + dirty: { + on: { + commit: 'pristine', + }, + }, + }, + }) +} + +export type LocalDocumentMachine< + DocumentType extends SanityDocumentBase = SanityDocumentBase, +> = ReturnType> diff --git a/examples/visual-editing/shared/state/remote-document-machine.ts b/examples/visual-editing/shared/state/remote-document-machine.ts new file mode 100644 index 0000000..aedfca7 --- /dev/null +++ b/examples/visual-editing/shared/state/remote-document-machine.ts @@ -0,0 +1,228 @@ +/* eslint-disable no-console */ +import {type MutationEvent, type SanityDocument} from '@sanity/client' +import {type SanityDocumentBase} from '@sanity/mutate' +import {applyPatch, type RawPatch} from 'mendoza' +import {assign, setup} from 'xstate' + +import { + type GetDocumentMachine, + type RemoteEventsMachine, + type RemoteSnapshotEvents, +} from './remote-events-machine' + +/** + * This machine is responsible for keeping a snapshot of a document in sync with any remote changes. + * It will setup a long lived subscription to a `client.listen()` instance. + * It's recommended that the `remoteEvents` machine is using a RxJS observer that can ensure that a single EventSource + * is established and its events multicast to all listeners, instead of setting up multiple EventSources to the same dataset as that would be a waste of resources and network bandwidth, + * not to mention there's a technical limit to how many concurrent `client.listen()` instances that a dataset allows: https://www.sanity.io/docs/technical-limits#c973bb88d2be + * The connection is built on mendoza events which are efficient and low bandwidth. + * If a document doesn't exist yet, the `getDocument` actor won't return a result, + * but once a document is created it'll emit a mendoza event that will create the document without needing to refetch it. + * If the `getDocument` fetch itself fails, then it has automatic retries with exponential backoff, as well as the ability to manually fire a `retry` event. + * + * All in all there's a lot of edge cases and logic to consider when one wishes to have a snapshot of a remote document that is then kept in sync with remote changes. + * This machine handles all of those concerns, so that other machines only have to care about wether we have a snapshot of the remote or not, and to handle that accordingly. + */ +export function defineRemoteDocumentMachine< + const DocumentType extends SanityDocumentBase = SanityDocumentBase, +>({ + getDocument, + remoteEvents, +}: { + getDocument: GetDocumentMachine + remoteEvents: RemoteEventsMachine +}) { + return setup({ + types: {} as { + children: { + remoteEvents: 'remoteEvents' + getDocument: 'getDocument' + } + context: { + documentId: string + snapshot: SanityDocument | null + mutationEvents: MutationEvent[] + error: unknown + attempts: number + } + events: RemoteSnapshotEvents | {type: 'retry'} + input: { + documentId: string + } + }, + actors: { + getDocument, + remoteEvents, + }, + delays: { + // Exponential backoff delay function + timeout: ({context}) => Math.pow(2, context.attempts) * 1000, + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QCcwFsD2AXMARDAxgK5pgB2WAxKgRmWWAVgNoAMAuoqAA4awCWWfnS4gAHogAsAJgA0IAJ6IAjAA4A7ADoAnLt3T1kgKxHtANiMBmAL7X5qTDnzFSFTfwgAbMJQDuYT1pSNk4kEF4BIREwiQRLA01WVVNldVTLVWldM3klBGVpVU0jVlLWSWVJdXLWbVt7dGw8QhJyLE1PDABDCH4yKEoIOjB3MgA3DABrEZgsZ1aKENEIwWEyUVjkosttZOVWSwsNHMVESwrEgoNleJ2k1TN6kAcm+dd2zp6+gbBkZAxkJpuJ4ulgAGYAtCaWZvNpLMIrKLrGJnBJJFJpdQZLLmXKIaSWLSSPTmSQHHYVJ4vJwtd4dbq9fqUNBELCgtbwnh8VbRUCxMwyTQE1hGaSsZRmK5mbR4hAE7SJSxGNKWVL7GSqGx2Z6NGkuNr0r5Mmh0BhMTnhblIjaIMyEzRpWradSinapWWVSTFTJGMzSZRqaQGB5U3XNfVuE30RhCJn+QIYYIcZZWtY2uJo5LaNVYzLZD0PHR6VSa5XqdQWUOOcMLdpgrr8TxEVB+AJBMAWxFplEZrTo7OY7H504ZoraKy1ZJkmR+quvWkG+uN5s+VBYZAKTup3niVF9rM5oe4keEhXGVWsI5mcqEud62uaWBEAgEOCwZms9l0LeRbt8lTlAq4rmJeFZmBY8SytIZhaOoyRmJqaTSuo2hKneNZ0k+L5vtQjCmjGP48si-75OUXolKUqgVFk5yWJYHppJodHSBRIqHDIGS2NqZAYBAcCiNSGFtCmv47rEAC0Jx5JJ6Gwm4HjeCJRHpgSRRGFml4IeYqisOoHpZIkZQGLsyjaEGqiyQubifIyUBKdaPZGJIZiaHsvqkjclhJLKdFFJIJaqBKJhktokiWRG7RRmasZ2Qi27Ebu+QVq5hT7BooUsVUekjmoRLFqW5YVk54UPkuTaoPZf6JUG0iaAKIrJGKeaoZIPnQToE67E55TQdIJWYc+r6wPAcWiQlsT7FYTGhaYzk6UkkEntKQoZIFzqTaKYVcUAA */ + id: 'remoteDocument', + context: ({input}) => ({ + documentId: input.documentId, + snapshot: null, + mutationEvents: [] as MutationEvent[], + error: undefined, + attempts: 0, + }), + + invoke: { + src: 'remoteEvents', + id: 'remoteEvents', + input: ({context}) => ({documentId: context.documentId}), + }, + + on: { + // If a reconnect event happens, then we should go to a `reconnecting` state that guarantees that we don't attempt retrying fetching the doc until after the EventSource is established and other scenarios + reconnect: '.reconnecting', + }, + + states: { + idle: { + on: { + welcome: 'loading', + }, + }, + + loading: { + on: { + // While the document is loading, buffer up mutation events so we can replay them after the document is fetched + mutation: { + actions: assign({ + mutationEvents: ({event, context}) => [ + ...context.mutationEvents, + event, + ], + error: undefined, + }), + }, + reconnect: { + actions: assign({mutationEvents: []}), + }, + }, + invoke: { + src: 'getDocument', + id: 'getDocument', + input: ({context}) => ({documentId: context.documentId}), + + onDone: { + target: 'success', + actions: assign({ + // @ts-expect-error @TODO figure out this later and when to use SanityDocument, SanityDocumentBase and all this nonsense + snapshot: ({event, context}) => { + let document = event.output + + /** + * We assume all patches that happen while we're waiting for the document to resolve are already applied. + * But if we do see a patch that has the same revision as the document we just fetched, we should apply any patches following it + */ + let seenCurrentRev = false + for (const patch of context.mutationEvents) { + if (!patch.effects?.apply || !patch.previousRev) continue + if (!seenCurrentRev && patch.previousRev === document?._rev) { + seenCurrentRev = true + } + if (seenCurrentRev) { + document = applyMendozaPatch( + // @ts-expect-error handle later + document, + patch.effects.apply, + patch.resultRev, + ) + } + } + + return document + }, + // Since the snapshot handler applies all the patches they are no longer needed, allow GC + mutationEvents: [], + }), + }, + + onError: { + target: 'failure', + actions: assign({ + error: ({event}) => event.error, + // If the document fetch fails then we can assume any mendoza patches we buffered up will no longer be needed + mutationEvents: [], + }), + }, + }, + }, + + reconnecting: { + on: { + welcome: 'loading', + }, + }, + + failure: { + after: { + timeout: { + actions: assign({attempts: ({context}) => context.attempts + 1}), + target: 'loading', + }, + }, + on: { + // We can also manually retry + retry: 'loading', + }, + }, + + success: { + on: { + // After the document has loaded, apply the mendoza patches directly + mutation: { + actions: assign({ + snapshot: ({event, context}) => + event.effects?.apply + ? applyMendozaPatch( + // @ts-expect-error handle later + context.snapshot, + event.effects.apply, + event.resultRev, + ) + : context.snapshot, + }), + }, + reconnect: { + actions: assign({snapshot: null}), + }, + }, + }, + }, + + initial: 'idle', + }) +} + +export type RemoteDocumentMachine< + DocumentType extends SanityDocumentBase = SanityDocumentBase, +> = ReturnType> + +function applyMendozaPatch( + document: DocumentType | undefined, + patch: RawPatch, + nextRevision: string | undefined, +) { + const next = applyPatch(omitRev(document), patch) + if (!next) { + return null + } + return Object.assign(next, {_rev: nextRevision}) +} + +function omitRev( + document: DocumentType | undefined, +) { + if (!document) { + return null + } + // eslint-disable-next-line unused-imports/no-unused-vars + const {_rev, ...doc} = document + return doc +} diff --git a/examples/visual-editing/shared/state/remote-events-machine.ts b/examples/visual-editing/shared/state/remote-events-machine.ts new file mode 100644 index 0000000..99c6a25 --- /dev/null +++ b/examples/visual-editing/shared/state/remote-events-machine.ts @@ -0,0 +1,146 @@ +import { + type MutationEvent, + type ReconnectEvent, + type SanityClient, + type WelcomeEvent, +} from '@sanity/client' +import {type SanityDocumentBase} from '@sanity/mutate' +import { + asapScheduler, + defer, + filter, + merge, + type ObservedValueOf, + observeOn, + share, + shareReplay, + timer, +} from 'rxjs' +import {fromEventObservable, fromPromise} from 'xstate' + +/** + * Creates a single, shared, listener EventSource that strems remote mutations, and notifies when it's online (welcome), offline (reconnect). + */ +export function createSharedListener(client: SanityClient) { + const allEvents$ = client + .listen( + '*[!(_id in path("_.**"))]', + {}, + { + events: [ + 'welcome', + 'mutation', + 'reconnect', + // @ts-expect-error - @TODO add this to the client typings + 'error', + // @ts-expect-error - @TODO add this to the client typings + 'channelError', + ], + includeResult: false, + includePreviousRevision: false, + visibility: 'transaction', + effectFormat: 'mendoza', + includeMutations: false, + }, + ) + .pipe(share({resetOnRefCountZero: true})) + + // Reconnect events emitted in case the connection is lost + const reconnect = allEvents$.pipe( + filter((event): event is ReconnectEvent => event.type === 'reconnect'), + ) + + // Welcome events are emitted when the listener is (re)connected + const welcome = allEvents$.pipe( + filter((event): event is WelcomeEvent => event.type === 'welcome'), + ) + + // Mutation events coming from the listener + const mutations = allEvents$.pipe( + filter((event): event is MutationEvent => event.type === 'mutation'), + ) + + // Replay the latest connection event that was emitted either when the connection was disconnected ('reconnect'), established or re-established ('welcome') + const connectionEvent = merge(welcome, reconnect).pipe( + shareReplay({bufferSize: 1, refCount: true}), + ) + + // Emit the welcome event if the latest connection event was the 'welcome' event. + // Downstream subscribers will typically map the welcome event to an initial fetch + const replayWelcome = connectionEvent.pipe( + filter(latestConnectionEvent => latestConnectionEvent.type === 'welcome'), + ) + + // Combine into a single stream + return merge(replayWelcome, mutations, reconnect) +} + +/** + * Wraps a shared listener in a new observer that have a delayed unsubscribe, allowing the shared listener to + * stay active and connected, useful when it's expected that transitions between observers are frequent and agressive setup and teardown of EventSource is expensive or inefficient. + */ +export function sharedListenerWithKeepAlive( + sharedListener: ReturnType, + keepAlive: number = 1_000, +) { + return defer(() => sharedListener).pipe( + share({resetOnRefCountZero: () => timer(keepAlive, asapScheduler)}), + ) +} + +/** Creates a machine that taps into a shared observable with a delayed unsubscribe */ +export function defineRemoteEvents({ + listener, + keepAlive, +}: { + listener: ReturnType + keepAlive: number +}) { + // @TODO verify that it can recover from being offline for a long time and come back online + return fromEventObservable(({input}: {input: {documentId: string}}) => + sharedListenerWithKeepAlive(listener, keepAlive).pipe( + filter( + event => + event.type === 'welcome' || + event.type === 'reconnect' || + (event.type === 'mutation' && event.documentId === input.documentId), + ), + // This is necessary to avoid sync emitted events from `shareReplay` from happening before the actor is ready to receive them + observeOn(asapScheduler), + ), + ) +} +export type RemoteEventsMachine = ReturnType + +/** Creates a machine that is responsible for fetching documents to be kept in sync with mendoza patches */ +export function defineGetDocument< + const DocumentType extends SanityDocumentBase = SanityDocumentBase, +>({client}: {client: SanityClient}) { + return fromPromise( + async ({ + input, + signal, + }: { + input: {documentId: string} + signal: AbortSignal + }) => { + const document = await client + .getDocument(input.documentId, { + signal, + }) + .catch(e => { + if (e instanceof Error && e.name === 'AbortError') return + throw e + }) + + return document + }, + ) +} +export type GetDocumentMachine< + DocumentType extends SanityDocumentBase = SanityDocumentBase, +> = ReturnType> + +export type RemoteSnapshotEvents = ObservedValueOf< + ReturnType +> diff --git a/examples/visual-editing/shared/state/submit-transactions-machine.ts b/examples/visual-editing/shared/state/submit-transactions-machine.ts new file mode 100644 index 0000000..8f3e4ab --- /dev/null +++ b/examples/visual-editing/shared/state/submit-transactions-machine.ts @@ -0,0 +1,32 @@ +import {type SanityClient} from '@sanity/client' +import {SanityEncoder, type Transaction} from '@sanity/mutate' +import {fromPromise} from 'xstate' + +export function defineSubmitTransactions({client}: {client: SanityClient}) { + return fromPromise( + async ({ + input, + signal, + }: { + input: {transactions: Transaction[]} + signal: AbortSignal + }) => { + for (const transaction of input.transactions) { + if (signal.aborted) return + await client + .dataRequest('mutate', SanityEncoder.encodeTransaction(transaction), { + visibility: 'async', + returnDocuments: false, + signal, + }) + .catch(e => { + if (e instanceof Error && e.name === 'AbortError') return + throw e + }) + } + }, + ) +} +export type SubmitTransactionsMachine = ReturnType< + typeof defineSubmitTransactions +> diff --git a/examples/visual-editing/shared/state/visual-editor-machine.ts b/examples/visual-editing/shared/state/visual-editor-machine.ts new file mode 100644 index 0000000..ff26764 --- /dev/null +++ b/examples/visual-editing/shared/state/visual-editor-machine.ts @@ -0,0 +1,89 @@ +/** + * The logic here is intended to live inside a preview iframe, and listen to events from the parent frame. + * It also supports running in a "detached" mode, where it has to setup authenticated EventSource conenctions and perform data fetching itself. + */ + +import {type SanityDocumentBase} from '@sanity/mutate' +import {type ActorRefFrom, assertEvent, assign, setup, stopChild} from 'xstate' + +import {type LocalDocumentMachine} from './local-document-machine' + +export function defineVisualEditingMachine< + const DocumentType extends SanityDocumentBase = SanityDocumentBase, +>({ + localDocumentMachine, +}: { + localDocumentMachine: LocalDocumentMachine +}) { + type LocalDocumentActorRef = ActorRefFrom + + return setup({ + types: {} as { + context: { + documents: Record + } + events: + | {type: 'listen'; documentId: string} + | {type: 'unlisten'; documentId: string} + | {type: 'add document actor'; documentId: string} + | {type: 'stop document actor'; documentId: string} + | {type: 'remove document actor from context'; documentId: string} + }, + actions: { + 'add document actor': assign({ + documents: ({context, event, spawn}) => { + assertEvent(event, 'listen') + // Adding the same documentId multiple times is a no-op + if (context.documents[event.documentId]) return context.documents + return { + ...context.documents, + [event.documentId]: spawn('localDocumentMachine', { + input: {documentId: event.documentId}, + id: event.documentId, + }), + } + }, + }), + 'stop remote snapshot': stopChild(({context, event}) => { + assertEvent(event, 'unlisten') + return context.documents[event.documentId]! + }), + 'remove remote snapshot from context': assign({ + documents: ({context, event}) => { + assertEvent(event, 'unlisten') + // Removing a non-existing documentId is a no-op + if (!context.documents[event.documentId]) return context.documents + return {...context.documents, [event.documentId]: null} + }, + }), + }, + actors: { + localDocumentMachine, + }, + }).createMachine({ + /** @xstate-layout N4IgpgJg5mDOIC5QBsD2BjAhsgIhgrgLZgB2ALgMTICWsZpA2gAwC6ioADqrNWdaiXYgAHogC0ADgBMAOgkA2ACyKArBICcTdfKXSANCACeiFQHYZpgMxWV800yYqp6gIyn5AXw8G0WXAWJyCnwSGjpGViEuHj4BIVEEKUt5OSlFU1smFwkFW0sDY0T1GRUXRXkXKVN7HSYJJkUvHwxsPHQiUjIZDgAnWj4SMApCfDJMemY2JBBo3n5BaYSXeVlbKRUsiXdFSxcXfKNERSqZTbr1zVMJNyaQX1aAzpkIah6yQwp0VEJCXkmo7hzOKLRDLFwyFxaSzXKTOKQudSKCQFRDOczqCSWJjwjbHCQqRFebwgEioCBwIT3fztQJkAExebxcQuFEIZaWGRIrF7dSXKTyFSNYlUtodcjdPp0aiDelAhagBLpVmWAmc46mNwSZTyLQuFS3EWPcUvN6FTiA2LykSoq6c6wbCqVUzparKioyRFVfkq1zqNSWIkeIA */ + id: 'visual-editor', + context: () => ({ + documents: {}, + }), + + on: { + listen: { + actions: ['add document actor'], + }, + + unlisten: { + actions: [ + 'stop remote snapshot', + 'remove remote snapshot from context', + ], + }, + }, + + initial: 'success', + + states: { + success: {}, + }, + }) +} diff --git a/examples/visual-editing/studio/App.tsx b/examples/visual-editing/studio/App.tsx new file mode 100644 index 0000000..9655f26 --- /dev/null +++ b/examples/visual-editing/studio/App.tsx @@ -0,0 +1,616 @@ +import {createClient} from '@sanity/client' +import { + createIfNotExists, + del, + type Mutation, + SanityEncoder, +} from '@sanity/mutate' +import { + type documentMutatorMachine, + type DocumentMutatorMachineParentEvent, +} from '@sanity/mutate/_unstable_machine' +import { + createDocumentEventListener, + createDocumentLoader, + createOptimisticStore, + createSharedListener, + type MutationGroup, +} from '@sanity/mutate/_unstable_store' +import { + draft, + type Infer, + isBooleanSchema, + isDocumentSchema, + isObjectSchema, + isObjectUnionSchema, + isOptionalSchema, + isPrimitiveUnionSchema, + isStringSchema, + type SanityAny, + type SanityBoolean, + type SanityObject, + type SanityObjectUnion, + type SanityOptional, + type SanityPrimitiveUnion, + type SanityString, +} from '@sanity/sanitype' +import { + Box, + Button, + Card, + Checkbox, + Flex, + Grid, + Heading, + Stack, + Tab, + TabList, + TabPanel, + Text, +} from '@sanity/ui' +import {useActorRef, useSelector} from '@xstate/react' +import { + Fragment, + type ReactNode, + useCallback, + useDeferredValue, + useEffect, + useState, +} from 'react' +import {concatMap, from, tap} from 'rxjs' +import styled from 'styled-components' +import {type ActorRefFrom} from 'xstate' + +import {DOCUMENT_IDS} from '../shared/constants' +import {DocumentView} from '../shared/DocumentView' +import {JsonView} from '../shared/json-view/JsonView' +import {datasetMutatorMachine} from './datasetMutatorMachine' +import {shoeForm} from './forms/shoe' +import { + BooleanInput, + DocumentInput, + type DocumentInputProps, + type InputProps, + type MutationEvent, + ObjectInput, + StringInput, + UnionInput, +} from './lib/form' +import {FormNode} from './lib/form/FormNode' +import {ColorInput} from './lib/form/inputs/ColorInput' +import {PrimitiveUnionInput} from './lib/form/inputs/PrimitiveUnionInput' +import {FormatMutation} from './lib/mutate-formatter/react' +import {shoe} from './schema/shoe' +import {type InspectType} from './types' + +function Unresolved(props: InputProps) { + return Unresolved input for type {props.schema.typeName} +} + +function OptionalInput>( + props: InputProps, +) { + return props.renderInput({ + ...props, + schema: props.schema.type, + }) +} + +function isColorInputProps( + props: InputProps, +): props is InputProps { + return ( + isStringInputProps(props) && + // @ts-expect-error this is fine + props.form?.color + ) +} +function isStringInputProps( + props: InputProps, +): props is InputProps { + return isStringSchema(props.schema) +} + +function isOptionalInputProps( + props: InputProps, +): props is InputProps> { + return isOptionalSchema(props.schema) +} + +function isObjectInputProps( + props: InputProps, +): props is InputProps { + return isObjectSchema(props.schema) +} + +function isDocumentInputProps( + props: InputProps | DocumentInputProps, +): props is DocumentInputProps { + return isDocumentSchema(props.schema) +} + +function isObjectUnionInputProps( + props: InputProps, +): props is InputProps { + return isObjectUnionSchema(props.schema) +} +function isPrimitiveUnionInputProps( + props: InputProps, +): props is InputProps { + return isPrimitiveUnionSchema(props.schema) +} +function isBooleanInputProps( + props: InputProps, +): props is InputProps { + return isBooleanSchema(props.schema) +} + +function renderInput>( + props: Props, +): ReactNode { + if (isColorInputProps(props)) { + return + } + if (isStringInputProps(props)) { + return + } + if (isOptionalInputProps(props)) { + return + } + if (isObjectInputProps(props)) { + return + } + if (isDocumentInputProps(props)) { + return + } + if (isObjectUnionInputProps(props)) { + return + } + if (isPrimitiveUnionInputProps(props)) { + return + } + if (isBooleanInputProps(props)) { + return + } + return +} + +const shoeDraft = draft(shoe) +type ShoeDraft = Infer + +const sanityClient = createClient({ + projectId: import.meta.env.VITE_SANITY_API_PROJECT_ID, + dataset: import.meta.env.VITE_SANITY_API_DATASET, + apiVersion: '2024-02-28', + useCdn: false, + token: import.meta.env.VITE_SANITY_API_TOKEN, +}) + +const sharedListener = createSharedListener({client: sanityClient}) + +const loadDocument = createDocumentLoader({client: sanityClient}) + +const listenDocument = createDocumentEventListener({ + loadDocument, + listenerEvents: sharedListener, +}) + +const datastore = createOptimisticStore({ + listen: listenDocument, + submit: transactions => { + return from(transactions).pipe( + concatMap(transaction => + sanityClient.dataRequest( + 'mutate', + SanityEncoder.encodeTransaction(transaction), + {visibility: 'async', returnDocuments: false}, + ), + ), + ) + }, +}) + +function App(props: {inspect: InspectType}) { + const {inspect} = props + const datasetMutatorActorRef = useActorRef(datasetMutatorMachine, { + input: {client: sanityClient, sharedListener}, + inspect, + }) + + // Open the observed ids on startup + useEffect(() => { + for (const id of DOCUMENT_IDS) { + datasetMutatorActorRef.send({type: 'observe', documentId: id}) + } + return () => { + for (const id of DOCUMENT_IDS) { + datasetMutatorActorRef.send({type: 'unobserve', documentId: id}) + } + } + }, [datasetMutatorActorRef]) + + const [documentId, setDocumentId] = useState(DOCUMENT_IDS[0]!) + const [documentState, setDocumentState] = useState<{ + local?: ShoeDraft + remote?: ShoeDraft + }>({}) + + const [staged, setStaged] = useState([]) + const [autoOptimize, setAutoOptimize] = useState(true) + + const [remoteLogEntries, setRemoteLogEntries] = useState< + DocumentMutatorMachineParentEvent[] + >([]) + + // const cody = useSelector(datasetMutatorActorRef, state => { + // const documents = Object.keys(state.context.documents) + // const staged: MutationGroup[] = [] + // for (const id of documents) { + // const document = state.context.documents[id]?.getSnapshot() + // if (document && document.context.stagedChanges.length > 0) { + // staged.push(...document.context.stagedChanges) + // } + // } + // return staged + // }) + // console.log({cody}) + + useEffect(() => { + const sub = datasetMutatorActorRef.on('*', event => { + switch (event.type) { + case 'mutation': + case 'sync': + return setRemoteLogEntries(e => [...e, event].slice(0, 100)) + } + }) + return () => sub.unsubscribe() + }, [datasetMutatorActorRef]) + useEffect(() => { + const staged$ = datastore.meta.stage.pipe(tap(next => setStaged(next))) + const sub = staged$.subscribe() + return () => sub.unsubscribe() + }, []) + useEffect(() => { + if (!documentId) return + const sub = datastore + .listenEvents(documentId) + .pipe( + tap(event => { + setDocumentState(current => { + return ( + event.type === 'optimistic' + ? {...current, local: event.after} + : event.after + ) as {remote: ShoeDraft; local: ShoeDraft} + }) + }), + ) + .subscribe() + return () => sub.unsubscribe() + }, [documentId]) + + const handleMutate = useCallback( + (mutations: Mutation[]) => { + datastore.mutate(mutations) + if (autoOptimize) datastore.optimize() + }, + [autoOptimize], + ) + const deferredStaged = useDeferredValue(staged) + + return ( + <> + + + + {DOCUMENT_IDS.map(id => ( + { + setDocumentId(id) + }} + selected={id === documentId} + /> + ))} + + {DOCUMENT_IDS.map(id => ( + + + + + + ) +} + +function DocumentTabPanelProvider(props: { + id: string + hidden: boolean + datasetMutatorActorRef: ActorRefFrom + inspect: InspectType +}) { + const {id, hidden, datasetMutatorActorRef} = props + const documentRef = useSelector( + datasetMutatorActorRef, + state => state.context.documents[id], + ) + const ready = useSelector(documentRef, state => state?.hasTag('ready')) + + return ( + + ) +} + +function DocumentTabPanelContent(props: { + id: string + hidden: boolean + documentRef: ActorRefFrom +}) { + const {id, hidden, documentRef} = props + const local = useSelector(documentRef, state => state.context.local) + const deferredLocal = useDeferredValue(local) + const dirty = useSelector(documentRef, state => + state.matches({connected: {loaded: 'dirty'}}), + ) + const canDelete = useSelector( + documentRef, + state => state.hasTag('ready') && !!state.context.local, + ) + documentRef.on('error', () => {}) + + const handleMutation = (event: MutationEvent) => { + documentRef.send({ + type: 'mutate', + mutations: [ + createIfNotExists({_id: id, _type: shoe.shape._type.value}), + ...event.mutations, + ], + }) + } + const handleDelete = () => { + documentRef.send({ + type: 'mutate', + mutations: [del(id)], + }) + } + + return ( + + + + + +