diff --git a/apps/playground/package.json b/apps/playground/package.json index ebe9a1ca4..66cab3336 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -59,6 +59,7 @@ "tailwindcss-react-aria-components": "^2.0.1", "typescript": "catalog:", "typescript-eslint": "^8.48.0", - "vite": "^7.3.1" + "vite": "^7.3.1", + "yjs": "^13.6.29" } } diff --git a/apps/playground/src/editor.tsx b/apps/playground/src/editor.tsx index 8217f25a3..e5f197a9c 100644 --- a/apps/playground/src/editor.tsx +++ b/apps/playground/src/editor.tsx @@ -36,6 +36,8 @@ import { PencilIcon, PencilOffIcon, SeparatorHorizontalIcon, + WifiIcon, + WifiOffIcon, XIcon, } from 'lucide-react' import {useContext, useEffect, useState, type JSX} from 'react' @@ -75,9 +77,12 @@ import {Tooltip} from './primitives/tooltip' import {RangeDecorationButton} from './range-decoration-button' import {SlashCommandPickerPlugin} from './slash-command-picker' import {PortableTextToolbar} from './toolbar/portable-text-toolbar' +import {useEditorOffline} from './yjs-latency-provider' +import {PlaygroundYjsPlugin} from './yjs-plugin' export function Editor(props: { editorRef: EditorActorRef + editorIndex: number rangeDecorations: RangeDecoration[] }) { const value = useSelector(props.editorRef, (s) => s.context.value) @@ -108,6 +113,10 @@ export function Editor(props: { schemaDefinition: playgroundSchemaDefinition, }} > + {loading ? : null} - + @@ -550,8 +563,13 @@ const styleMap: Map JSX.Element> = ], ]) -function EditorFooter(props: {editorRef: EditorActorRef; readOnly: boolean}) { +function EditorFooter(props: { + editorRef: EditorActorRef + editorIndex: number + readOnly: boolean +}) { const editor = useEditor() + const playgroundFeatureFlags = useContext(PlaygroundFeatureFlagsContext) const patchesActive = useSelector(props.editorRef, (s) => s.matches({'patch subscription': 'active'}), ) @@ -562,6 +580,8 @@ function EditorFooter(props: {editorRef: EditorActorRef; readOnly: boolean}) { const value = useEditorSelector(editor, (s) => s.context.value) const [showSelection, setShowSelection] = useState(false) const [showValue, setShowValue] = useState(false) + const {setOffline} = useEditorOffline() + const [offline, setOfflineState] = useState(false) const isExpanded = showSelection || showValue @@ -652,6 +672,30 @@ function EditorFooter(props: {editorRef: EditorActorRef; readOnly: boolean}) { : 'Not receiving value updates'} + {playgroundFeatureFlags.yjsMode ? ( + + { + setOfflineState(selected) + setOffline(props.editorIndex, selected) + }} + > + {offline ? ( + + ) : ( + + )} + + + {offline + ? 'Offline (click to reconnect)' + : 'Online (click to go offline)'} + + + ) : null} + Clear log + + +
+ {events.length === 0 ? ( +
+ No events yet +
+ ) : ( +
+ {events.map((event) => ( + + ))} +
+ )} +
+ + ) +} + +function EventEntry({event}: {event: CrdtEvent}) { + const isSend = event.type === 'send' + const isConcurrentSend = isSend && event.latencyMs > 0 + + return ( +
+ + {formatTime(event.timestamp)} + + + {isSend ? 'send' : 'deliver'} + + + Ed {event.sourceEditor} {'\u2192'} Ed {event.targetEditor} + + {isSend && event.latencyMs > 0 ? ( + + {'\u23F3'} {event.latencyMs}ms + + ) : null} + {event.type === 'deliver' ? ( + + {'\u2713'} + + ) : null} +
+ ) +} + +function formatTime(timestamp: number): string { + const date = new Date(timestamp) + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 1, + } as Intl.DateTimeFormatOptions) +} diff --git a/apps/playground/src/yjs-latency-provider.tsx b/apps/playground/src/yjs-latency-provider.tsx new file mode 100644 index 000000000..13be6a255 --- /dev/null +++ b/apps/playground/src/yjs-latency-provider.tsx @@ -0,0 +1,309 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react' +import * as Y from 'yjs' + +type LatencyDoc = { + doc: Y.Doc + sharedRoot: Y.XmlText +} + +export type InFlightUpdate = { + id: number + sourceEditor: number + targetEditor: number + sentAt: number + deliverAt: number +} + +export type CrdtEvent = { + id: number + type: 'send' | 'deliver' + timestamp: number + sourceEditor: number + targetEditor: number + latencyMs: number +} + +type CrdtEventCallback = (event: CrdtEvent) => void + +type LatencyContextValue = { + getSharedRoot: (editorIndex: number) => Y.XmlText + subscribeToCrdtEvents: (callback: CrdtEventCallback) => () => void + getInFlightUpdates: () => InFlightUpdate[] + setOffline: (editorIndex: number, offline: boolean) => void + isOffline: (editorIndex: number) => boolean +} + +const LatencyContext = createContext({ + getSharedRoot: () => { + throw new Error('LatencyYjsProvider not mounted') + }, + subscribeToCrdtEvents: () => () => {}, + getInFlightUpdates: () => [], + setOffline: () => {}, + isOffline: () => false, +}) + +export function useLatencySharedRoot(editorIndex: number): Y.XmlText { + const {getSharedRoot} = useContext(LatencyContext) + return getSharedRoot(editorIndex) +} + +export function useCrdtEvents() { + const {subscribeToCrdtEvents, getInFlightUpdates} = useContext(LatencyContext) + return {subscribeToCrdtEvents, getInFlightUpdates} +} + +export function useEditorOffline() { + const {setOffline, isOffline} = useContext(LatencyContext) + return {setOffline, isOffline} +} + +function createDocs(count: number): LatencyDoc[] { + return Array.from({length: count}, () => { + const doc = new Y.Doc() + const sharedRoot = doc.get('content', Y.XmlText) as Y.XmlText + return {doc, sharedRoot} + }) +} + +let nextUpdateId = 0 +let nextEventId = 0 + +export function LatencyYjsProvider({ + editorCount, + latencyMs, + children, +}: { + editorCount: number + latencyMs: number + children: React.ReactNode +}) { + const latencyMsRef = useRef(latencyMs) + latencyMsRef.current = latencyMs + + const timersRef = useRef[]>([]) + const inFlightRef = useRef([]) + const subscribersRef = useRef>(new Set()) + const offlineEditorsRef = useRef>(new Set()) + + // Use a ref for docs so they survive React strict mode's unmount/remount + // cycle. useState preserves the value across remount, but useEffect cleanup + // would destroy the Y.Docs, leaving the state with dead references. + const docsRef = useRef(createDocs(editorCount)) + if (docsRef.current.length !== editorCount) { + for (const {doc} of docsRef.current) { + doc.destroy() + } + docsRef.current = createDocs(editorCount) + } + + const notifySubscribers = useCallback((event: CrdtEvent) => { + for (const callback of subscribersRef.current) { + callback(event) + } + }, []) + + // Register cross-doc sync handlers. When any Y.Doc emits an update, + // forward it to all other docs with the configured latency delay. + useEffect(() => { + const docs = docsRef.current + const handlers: Array<{ + doc: Y.Doc + handler: (update: Uint8Array, origin: unknown) => void + }> = [] + + for (let sourceIndex = 0; sourceIndex < docs.length; sourceIndex++) { + const sourceDoc = docs[sourceIndex]!.doc + const handler = (update: Uint8Array, origin: unknown) => { + if (origin === 'remote') { + return + } + + const delay = latencyMsRef.current + const now = Date.now() + + for (let targetIndex = 0; targetIndex < docs.length; targetIndex++) { + if (targetIndex === sourceIndex) { + continue + } + + if ( + offlineEditorsRef.current.has(sourceIndex) || + offlineEditorsRef.current.has(targetIndex) + ) { + continue + } + + const targetDoc = docs[targetIndex]!.doc + + if (delay === 0) { + Y.applyUpdate(targetDoc, update, 'remote') + + const eventId = nextEventId++ + notifySubscribers({ + id: eventId, + type: 'send', + timestamp: now, + sourceEditor: sourceIndex, + targetEditor: targetIndex, + latencyMs: 0, + }) + notifySubscribers({ + id: nextEventId++, + type: 'deliver', + timestamp: now, + sourceEditor: sourceIndex, + targetEditor: targetIndex, + latencyMs: 0, + }) + } else { + const updateId = nextUpdateId++ + const inFlight: InFlightUpdate = { + id: updateId, + sourceEditor: sourceIndex, + targetEditor: targetIndex, + sentAt: now, + deliverAt: now + delay, + } + inFlightRef.current.push(inFlight) + + notifySubscribers({ + id: nextEventId++, + type: 'send', + timestamp: now, + sourceEditor: sourceIndex, + targetEditor: targetIndex, + latencyMs: delay, + }) + + const timer = setTimeout(() => { + Y.applyUpdate(targetDoc, update, 'remote') + + inFlightRef.current = inFlightRef.current.filter( + (entry) => entry.id !== updateId, + ) + + notifySubscribers({ + id: nextEventId++, + type: 'deliver', + timestamp: Date.now(), + sourceEditor: sourceIndex, + targetEditor: targetIndex, + latencyMs: delay, + }) + }, delay) + timersRef.current.push(timer) + } + } + } + + sourceDoc.on('update', handler) + handlers.push({doc: sourceDoc, handler}) + } + + return () => { + for (const {doc, handler} of handlers) { + doc.off('update', handler) + } + for (const timer of timersRef.current) { + clearTimeout(timer) + } + timersRef.current = [] + inFlightRef.current = [] + } + }, [editorCount, notifySubscribers]) + + const getSharedRoot = useMemo( + () => (editorIndex: number) => { + const entry = docsRef.current[editorIndex] + if (!entry) { + throw new Error(`No latency doc for editor index ${editorIndex}`) + } + return entry.sharedRoot + }, + [], + ) + + const setOffline = useCallback((editorIndex: number, offline: boolean) => { + if (offline) { + offlineEditorsRef.current.add(editorIndex) + } else { + offlineEditorsRef.current.delete(editorIndex) + + // Sync diverged state: exchange missing updates between the + // reconnecting editor and all other online editors. + const docs = docsRef.current + const reconnectedDoc = docs[editorIndex]?.doc + if (!reconnectedDoc) return + + for (let other = 0; other < docs.length; other++) { + if (other === editorIndex || offlineEditorsRef.current.has(other)) { + continue + } + const otherDoc = docs[other]?.doc + if (!otherDoc) continue + + // Send updates the other doc is missing from the reconnected doc + const otherStateVector = Y.encodeStateVector(otherDoc) + const missingOnOther = Y.encodeStateAsUpdate( + reconnectedDoc, + otherStateVector, + ) + Y.applyUpdate(otherDoc, missingOnOther, 'remote') + + // Send updates the reconnected doc is missing from the other doc + const reconnectedStateVector = Y.encodeStateVector(reconnectedDoc) + const missingOnReconnected = Y.encodeStateAsUpdate( + otherDoc, + reconnectedStateVector, + ) + Y.applyUpdate(reconnectedDoc, missingOnReconnected, 'remote') + } + } + }, []) + + const isOffline = useCallback((editorIndex: number) => { + return offlineEditorsRef.current.has(editorIndex) + }, []) + + const subscribeToCrdtEvents = useCallback((callback: CrdtEventCallback) => { + subscribersRef.current.add(callback) + return () => { + subscribersRef.current.delete(callback) + } + }, []) + + const getInFlightUpdates = useCallback(() => { + return inFlightRef.current + }, []) + + const contextValue = useMemo( + () => ({ + getSharedRoot, + subscribeToCrdtEvents, + getInFlightUpdates, + setOffline, + isOffline, + }), + [ + getSharedRoot, + subscribeToCrdtEvents, + getInFlightUpdates, + setOffline, + isOffline, + ], + ) + + return ( + + {children} + + ) +} diff --git a/apps/playground/src/yjs-operation-log.tsx b/apps/playground/src/yjs-operation-log.tsx new file mode 100644 index 000000000..96dc97f17 --- /dev/null +++ b/apps/playground/src/yjs-operation-log.tsx @@ -0,0 +1,166 @@ +import type {YjsOperationEntry} from '@portabletext/editor/yjs' +import {TrashIcon} from 'lucide-react' +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react' +import {TooltipTrigger} from 'react-aria-components' +import {Button} from './primitives/button' +import {Tooltip} from './primitives/tooltip' + +const MAX_ENTRIES = 200 + +type LogEntry = YjsOperationEntry & {id: number} + +type YjsOperationLogContextValue = { + entries: LogEntry[] + addEntry: (entry: YjsOperationEntry) => void + clear: () => void +} + +const YjsOperationLogContext = createContext({ + entries: [], + addEntry: () => {}, + clear: () => {}, +}) + +let nextId = 0 + +export function YjsOperationLogProvider({ + children, +}: { + children: React.ReactNode +}) { + const [entries, setEntries] = useState([]) + + const addEntry = useCallback((entry: YjsOperationEntry) => { + setEntries((prev) => { + const next = [{...entry, id: nextId++}, ...prev] + if (next.length > MAX_ENTRIES) { + return next.slice(0, MAX_ENTRIES) + } + return next + }) + }, []) + + const clear = useCallback(() => setEntries([]), []) + + return ( + + {children} + + ) +} + +export function useYjsOperationLog() { + return useContext(YjsOperationLogContext) +} + +export function YjsOperationLog() { + const {entries, clear} = useYjsOperationLog() + const scrollRef = useRef(null) + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = 0 + } + }, [entries.length]) + + return ( +
+
+ + {entries.length} operation{entries.length !== 1 ? 's' : ''} + + + + Clear log + +
+
+ {entries.length === 0 ? ( +
+ No operations yet +
+ ) : ( +
+ {entries.map((entry) => ( + + ))} +
+ )} +
+
+ ) +} + +function OperationEntryView({entry}: {entry: LogEntry}) { + const [expanded, setExpanded] = useState(false) + const isLocal = entry.direction === 'local-to-yjs' + + return ( +
+ + {expanded && ( +
+ {entry.operations.map((op, index) => ( +
+ + {op.type} + + {'path' in op ? ( + + [{(op.path as number[]).join(', ')}] + + ) : null} + {'text' in op && typeof op.text === 'string' ? ( + + "{op.text}" + + ) : null} +
+ ))} +
+ )} +
+ ) +} + +function formatTime(timestamp: number): string { + const date = new Date(timestamp) + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + fractionalSecondDigits: 1, + } as Intl.DateTimeFormatOptions) +} diff --git a/apps/playground/src/yjs-plugin.tsx b/apps/playground/src/yjs-plugin.tsx new file mode 100644 index 000000000..d3f6dea4d --- /dev/null +++ b/apps/playground/src/yjs-plugin.tsx @@ -0,0 +1,65 @@ +import {useEditor} from '@portabletext/editor' +import { + withYjs, + type YjsEditor, + type YjsOperationEntry, +} from '@portabletext/editor/yjs' +import {useEffect, useRef} from 'react' +import {useLatencySharedRoot} from './yjs-latency-provider' +import {useYjsOperationLog} from './yjs-operation-log' + +function getSlateEditor( + editor: ReturnType, +): Parameters[0] | null { + // Access the internal Slate editor instance. This is not part of the public + // API but is needed for the Yjs spike to apply the `withYjs` plugin. + const internal = (editor as Record)._internal as + | {slateEditor?: {instance?: Parameters[0]}} + | undefined + return internal?.slateEditor?.instance ?? null +} + +export function PlaygroundYjsPlugin({ + enabled, + editorIndex, +}: { + enabled: boolean + editorIndex: number +}) { + const editor = useEditor() + const sharedRoot = useLatencySharedRoot(editorIndex) + const yjsEditorRef = useRef(null) + const {addEntry} = useYjsOperationLog() + const addEntryRef = useRef(addEntry) + addEntryRef.current = addEntry + + useEffect(() => { + const slateEditor = getSlateEditor(editor) + if (!slateEditor) return + + if (!yjsEditorRef.current) { + const onOperation = (entry: YjsOperationEntry) => { + addEntryRef.current(entry) + } + + const yjsEditor = withYjs(slateEditor, { + sharedRoot, + localOrigin: Symbol('playground-local'), + onOperation, + }) as unknown as YjsEditor + yjsEditorRef.current = yjsEditor + } + + if (enabled) { + yjsEditorRef.current.connect() + } else { + yjsEditorRef.current.disconnect() + } + + return () => { + yjsEditorRef.current?.disconnect() + } + }, [editor, sharedRoot, enabled]) + + return null +} diff --git a/apps/playground/src/yjs-tree-viewer.tsx b/apps/playground/src/yjs-tree-viewer.tsx new file mode 100644 index 000000000..86a058db1 --- /dev/null +++ b/apps/playground/src/yjs-tree-viewer.tsx @@ -0,0 +1,167 @@ +import {useCallback, useEffect, useState} from 'react' +import * as Y from 'yjs' +import {useLatencySharedRoot} from './yjs-latency-provider' + +type TreeNode = + | { + type: 'block' + key: string + attrs: Record + children: TreeNode[] + } + | {type: 'text'; text: string; attrs: Record} + +function buildTree(yText: Y.XmlText): TreeNode[] { + const delta = yText.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + return delta.map((entry) => { + if (typeof entry.insert === 'string') { + return { + type: 'text' as const, + text: entry.insert, + attrs: entry.attributes ?? {}, + } + } + + const xmlText = entry.insert + const attributes = xmlText.getAttributes() + const key = (attributes._key as string) ?? '?' + + return { + type: 'block' as const, + key, + attrs: attributes, + children: buildTree(xmlText), + } + }) +} + +export function YjsTreeViewer() { + const sharedRoot = useLatencySharedRoot(0) + const [tree, setTree] = useState([]) + + const updateTree = useCallback(() => { + if (sharedRoot) { + setTree(buildTree(sharedRoot)) + } + }, [sharedRoot]) + + useEffect(() => { + if (!sharedRoot) return + + updateTree() + + const handler = () => updateTree() + sharedRoot.observeDeep(handler) + return () => sharedRoot.unobserveDeep(handler) + }, [sharedRoot, updateTree]) + + if (!sharedRoot) { + return ( +
+ Yjs not active +
+ ) + } + + if (tree.length === 0) { + return ( +
+ Empty document +
+ ) + } + + return ( +
+
Y.XmlText root
+ {tree.map((node, index) => ( + + ))} +
+ ) +} + +function TreeNodeView({node, depth}: {node: TreeNode; depth: number}) { + const [collapsed, setCollapsed] = useState(false) + const indent = depth * 16 + + if (node.type === 'text') { + return ( +
+ + "{node.text}" + + +
+ ) + } + + const {_type, _key, ...restAttrs} = node.attrs + + return ( +
+ + {!collapsed && ( +
+ {node.children.map((child, index) => ( + + ))} +
+ )} +
+ ) +} + +function BlockAttrs({attrs}: {attrs: Record}) { + const entries = Object.entries(attrs).filter(([key]) => key !== 'children') + if (entries.length === 0) return null + + return ( + + {entries.map(([key, value]) => ( + + {key} + = + + {typeof value === 'string' ? value : JSON.stringify(value)} + + + ))} + + ) +} + +function TextAttrs({attrs}: {attrs: Record}) { + const entries = Object.entries(attrs) + if (entries.length === 0) return null + + return ( + + {entries.map(([key, value]) => ( + + {key} + = + {value} + + ))} + + ) +} diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts index 423cdf08f..753816496 100644 --- a/apps/playground/vite.config.ts +++ b/apps/playground/vite.config.ts @@ -43,6 +43,10 @@ export default defineConfig({ __dirname, '../../packages/patches/src', ), + '@portabletext/plugin-character-pair-decorator': path.resolve( + __dirname, + '../../packages/plugin-character-pair-decorator/src', + ), '@portabletext/plugin-emoji-picker': path.resolve( __dirname, '../../packages/plugin-emoji-picker/src', diff --git a/packages/editor/gherkin-spec/yjs-collaboration.feature b/packages/editor/gherkin-spec/yjs-collaboration.feature new file mode 100644 index 000000000..c8c43a24a --- /dev/null +++ b/packages/editor/gherkin-spec/yjs-collaboration.feature @@ -0,0 +1,52 @@ +Feature: Yjs Collaboration + + Background: + Given two editors with Yjs sync + And a global keymap + + Scenario: Basic text sync from A to B + When the editor is focused + And "hello" is typed + Then the text is "hello" + + Scenario: Basic text sync from B to A + When Editor B is focused + And "world" is typed in Editor B + Then the text is "world" + + Scenario: Enter key syncs (split_node) + When the editor is focused + And "foobar" is typed + And the caret is put after "foo" + And "{Enter}" is pressed + Then the text is "foo|bar" + + Scenario: Backspace at block boundary syncs (merge_node) + Given the text "foo|bar" + When the caret is put before "bar" + And "{Backspace}" is pressed + Then the text is "foobar" + + Scenario: Concurrent typing in same block + Given the text "hello world" + When the caret is put after "hello" + And "A" is typed + And the caret is put after "world" in Editor B + And "B" is typed in Editor B + Then the text is "helloA worldB" + + Scenario: Concurrent typing in different blocks + Given the text "foo|bar" + When the caret is put after "foo" + And "A" is typed + And the caret is put after "bar" in Editor B + And "B" is typed in Editor B + Then the text is "fooA|barB" + + Scenario: One editor types while the other presses Enter + Given the text "hello world" + When the caret is put after "hello" + And "{Enter}" is pressed + And the caret is put after "world" in Editor B + And "!" is typed in Editor B + Then the text is "hello|world!" diff --git a/packages/editor/gherkin-tests/yjs-collaboration.test.ts b/packages/editor/gherkin-tests/yjs-collaboration.test.ts new file mode 100644 index 000000000..7cdb7460d --- /dev/null +++ b/packages/editor/gherkin-tests/yjs-collaboration.test.ts @@ -0,0 +1,11 @@ +import {Feature} from 'racejar/vitest' +import yjsCollaborationFeature from '../gherkin-spec/yjs-collaboration.feature?raw' +import {parameterTypes} from '../src/test' +import {stepDefinitions} from '../src/test/vitest' +import {yjsStepDefinitions} from '../src/yjs/step-definitions' + +Feature({ + featureText: yjsCollaborationFeature, + stepDefinitions: [...yjsStepDefinitions, ...stepDefinitions], + parameterTypes, +}) diff --git a/packages/editor/package.json b/packages/editor/package.json index 8913bf45c..b579bde4a 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -53,6 +53,10 @@ "source": "./src/utils/_exports/index.ts", "default": "./lib/utils/index.js" }, + "./yjs": { + "source": "./src/yjs/index.ts", + "default": "./src/yjs/index.ts" + }, "./package.json": "./package.json" }, "main": "./lib/index.js", @@ -121,7 +125,8 @@ "typescript-eslint": "^8.48.0", "vite": "^7.3.1", "vitest": "^4.0.18", - "vitest-browser-react": "^2.0.5" + "vitest-browser-react": "^2.0.5", + "yjs": "^13.6.29" }, "peerDependencies": { "@portabletext/sanity-bridge": "workspace:^2.0.2", diff --git a/packages/editor/src/yjs/apply-to-slate.ts b/packages/editor/src/yjs/apply-to-slate.ts new file mode 100644 index 000000000..31dc920ae --- /dev/null +++ b/packages/editor/src/yjs/apply-to-slate.ts @@ -0,0 +1,428 @@ +import * as Y from 'yjs' +import {Editor, Element, Node, Text, type Descendant} from '../slate' +import type {PortableTextSlateEditor} from '../types/slate-editor' +import {deltaInsertToSlateNode, yTextToSlateElement} from './convert' + +/** + * Checks if a node has children (either an Element or the Editor root). + * Slate's `Element.isElement()` returns false for the editor root, but + * the editor root has `children` just like elements do. + */ +function hasChildren(node: Node): node is Element { + return Element.isElement(node) || Editor.isEditor(node) +} + +/** + * Translates Yjs `observeDeep` events into Slate operations and applies + * them to the editor. + * + * Events are processed in reverse order to avoid path invalidation + * (deleting child at index 3 before child at index 1 would shift indices). + * + * All operations are applied without normalizing, patching, or history + * recording — those wrappers are applied by the caller (`withYjs`). + */ +export function applyYjsEvents( + sharedRoot: Y.XmlText, + editor: PortableTextSlateEditor, + events: Y.YEvent[], +): void { + // Process events in reverse order to maintain path validity + const sortedEvents = [...events].reverse() + + for (const event of sortedEvents) { + if (!(event instanceof Y.YTextEvent)) { + continue + } + + const yText = event.target as Y.XmlText + const slatePath = yTextToSlatePath(sharedRoot, yText) + + if (!slatePath) { + continue + } + + if (event.keysChanged.size > 0) { + applyAttributeChanges(editor, slatePath, yText, event) + } + + if (event.delta.length > 0) { + applyDeltaChanges(editor, slatePath, yText, event) + } + } +} + +/** + * Resolves a Y.XmlText instance back to a Slate path by walking up the + * Y.XmlText tree until we reach the shared root. + */ +function yTextToSlatePath( + sharedRoot: Y.XmlText, + yText: Y.XmlText, +): number[] | null { + if (yText === sharedRoot) { + return [] + } + + const path: number[] = [] + let current: Y.XmlText = yText + + while (current !== sharedRoot) { + const parent = current._item?.parent + if (!(parent instanceof Y.XmlText)) { + return null + } + + const index = getChildIndex(parent, current) + if (index === null) { + return null + } + + path.unshift(index) + current = parent + } + + return path +} + +/** + * Finds the Slate child index of a Y.XmlText within its parent's delta. + */ +function getChildIndex(parent: Y.XmlText, child: Y.XmlText): number | null { + const delta = parent.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + let index = 0 + for (const entry of delta) { + if (entry.insert === child) { + return index + } + index++ + } + + return null +} + +/** + * Applies Y.XmlText attribute changes as Slate `set_node` operations. + */ +function applyAttributeChanges( + editor: PortableTextSlateEditor, + slatePath: number[], + yText: Y.XmlText, + event: Y.YTextEvent, +): void { + const newProperties: Record = {} + + for (const key of event.keysChanged) { + const value = yText.getAttribute(key) + if (key === 'markDefs') { + newProperties[key] = + value !== undefined ? JSON.parse(value as string) : [] + } else if (key === '__inline') { + newProperties[key] = + value !== undefined ? JSON.parse(value as string) : undefined + } else if (key === 'value') { + newProperties[key] = + value !== undefined ? JSON.parse(value as string) : undefined + } else if (key === 'level') { + newProperties[key] = + value !== undefined ? JSON.parse(value as string) : undefined + } else { + newProperties[key] = value + } + } + + const existingNode = Node.has(editor, slatePath) + ? Node.get(editor, slatePath) + : undefined + + if (!existingNode) { + return + } + + const properties: Record = {} + for (const key of Object.keys(newProperties)) { + if (key in existingNode) { + properties[key] = (existingNode as Record)[key] + } + } + + editor.apply({ + type: 'set_node', + path: slatePath, + properties: properties as Partial, + newProperties: newProperties as Partial, + }) +} + +/** + * Applies Y.XmlText delta changes (insert/delete/retain with attribute changes) + * as Slate operations. + */ +function applyDeltaChanges( + editor: PortableTextSlateEditor, + slatePath: number[], + _yText: Y.XmlText, + event: Y.YTextEvent, +): void { + // The delta describes sequential changes from the perspective of the Y.XmlText + let offset = 0 + + // Get the parent node to understand context + const parentExists = Node.has(editor, slatePath) + if (!parentExists) { + return + } + const parentNode = Node.get(editor, slatePath) + + for (const delta of event.delta) { + if ('retain' in delta) { + const retainLength = delta.retain as number + + if (delta.attributes) { + // Attribute changes on retained content → format changes on text + applyRetainFormatChanges( + editor, + slatePath, + parentNode, + offset, + retainLength, + delta.attributes as Record, + ) + } + + offset += retainLength + } else if ('delete' in delta) { + const deleteLength = delta.delete as number + applyDeltaDelete(editor, slatePath, parentNode, offset, deleteLength) + } else if ('insert' in delta) { + const inserts = Array.isArray(delta.insert) + ? delta.insert + : [delta.insert] + const attributes = (delta.attributes ?? {}) as Record + + for (const insert of inserts) { + if (typeof insert === 'string') { + applyTextInsert( + editor, + slatePath, + parentNode, + offset, + insert, + attributes, + ) + offset += insert.length + } else if (insert instanceof Y.XmlText) { + applyElementInsert(editor, slatePath, parentNode, offset, insert) + offset += 1 + } + } + } + } +} + +/** + * Applies a text insertion at a given offset within the element at `slatePath`. + */ +function applyTextInsert( + editor: PortableTextSlateEditor, + slatePath: number[], + parentNode: Node, + offset: number, + text: string, + attributes: Record, +): void { + if (!hasChildren(parentNode)) { + return + } + + const {childIndex, childOffset} = resolveChildOffset(parentNode, offset) + + const child = parentNode.children[childIndex] + + if (child && Text.isText(child)) { + // Insert into existing text node + editor.apply({ + type: 'insert_text', + path: [...slatePath, childIndex], + offset: childOffset, + text, + }) + } else { + // Insert a new text node + const node = deltaInsertToSlateNode({insert: text, attributes}) + editor.apply({ + type: 'insert_node', + path: [...slatePath, childIndex], + node, + }) + } +} + +/** + * Applies an element (Y.XmlText embed) insertion. + */ +function applyElementInsert( + editor: PortableTextSlateEditor, + slatePath: number[], + parentNode: Node, + offset: number, + yText: Y.XmlText, +): void { + if (!hasChildren(parentNode)) { + return + } + + const {childIndex} = resolveChildOffset(parentNode, offset) + const node = yTextToSlateElement(yText) as Descendant + + editor.apply({ + type: 'insert_node', + path: [...slatePath, childIndex], + node, + }) +} + +/** + * Applies a deletion at a given offset. + */ +function applyDeltaDelete( + editor: PortableTextSlateEditor, + slatePath: number[], + parentNode: Node, + offset: number, + deleteLength: number, +): void { + if (!hasChildren(parentNode)) { + return + } + + let remaining = deleteLength + let currentOffset = offset + + while (remaining > 0) { + const {childIndex, childOffset} = resolveChildOffset( + parentNode, + currentOffset, + ) + const child = parentNode.children[childIndex] + + if (!child) { + break + } + + if (Text.isText(child)) { + const textLength = child.text.length - childOffset + const deleteAmount = Math.min(remaining, textLength) + + if (deleteAmount === child.text.length && childOffset === 0) { + // Remove the entire text node + editor.apply({ + type: 'remove_node', + path: [...slatePath, childIndex], + node: child, + }) + } else { + editor.apply({ + type: 'remove_text', + path: [...slatePath, childIndex], + offset: childOffset, + text: child.text.slice(childOffset, childOffset + deleteAmount), + }) + } + remaining -= deleteAmount + currentOffset += deleteAmount + } else if (Element.isElement(child)) { + // Remove the entire element + editor.apply({ + type: 'remove_node', + path: [...slatePath, childIndex], + node: child, + }) + remaining -= 1 + currentOffset += 1 + } else { + break + } + } +} + +/** + * Applies attribute changes from a `retain` delta entry. + */ +function applyRetainFormatChanges( + editor: PortableTextSlateEditor, + slatePath: number[], + parentNode: Node, + offset: number, + _retainLength: number, + attributes: Record, +): void { + if (!hasChildren(parentNode)) { + return + } + + const {childIndex} = resolveChildOffset(parentNode, offset) + const child = parentNode.children[childIndex] + + if (!child || !Text.isText(child)) { + return + } + + const newProperties: Record = {} + for (const [key, value] of Object.entries(attributes)) { + newProperties[key] = + key === 'marks' && value !== null ? JSON.parse(value) : value + } + + const properties: Record = {} + const childRecord = child as unknown as Record + for (const key of Object.keys(newProperties)) { + if (key in childRecord) { + properties[key] = childRecord[key] + } + } + + editor.apply({ + type: 'set_node', + path: [...slatePath, childIndex], + properties: properties as Partial, + newProperties: newProperties as Partial, + }) +} + +/** + * Resolves a Y-offset within a Slate element to a child index and + * offset within that child. + * + * Walks through the element's children, accumulating lengths: + * - Text nodes contribute their text length + * - Element nodes contribute 1 + */ +function resolveChildOffset( + element: {children: Descendant[]}, + offset: number, +): {childIndex: number; childOffset: number} { + let remaining = offset + let index = 0 + + for (const child of element.children) { + if (Text.isText(child)) { + if (remaining <= child.text.length) { + return {childIndex: index, childOffset: remaining} + } + remaining -= child.text.length + } else { + if (remaining === 0) { + return {childIndex: index, childOffset: 0} + } + remaining -= 1 + } + index++ + } + + return {childIndex: index, childOffset: 0} +} diff --git a/packages/editor/src/yjs/apply-to-yjs.test.ts b/packages/editor/src/yjs/apply-to-yjs.test.ts new file mode 100644 index 000000000..88e97f7b1 --- /dev/null +++ b/packages/editor/src/yjs/apply-to-yjs.test.ts @@ -0,0 +1,495 @@ +import {describe, expect, test} from 'vitest' +import * as Y from 'yjs' +import type {Descendant, Node} from '../slate' +import {applySlateOp} from './apply-to-yjs' +import {getSharedRoot, slateNodesToYDoc, yTextToSlateElement} from './convert' + +function createDocWithBlock(text: string) { + const blocks: Descendant[] = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text, marks: []}], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + + return {yDoc, sharedRoot, blocks} +} + +function getFirstBlockText(sharedRoot: Y.XmlText): string { + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockYText = delta[0]!.insert + const blockDelta = blockYText.toDelta() as Array<{insert: string}> + return blockDelta.map((d) => d.insert).join('') +} + +describe('insert_text', () => { + test('inserts text at offset', () => { + const {sharedRoot, blocks} = createDocWithBlock('hello') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'insert_text', + path: [0, 0], + offset: 5, + text: ' world', + }) + + expect(getFirstBlockText(sharedRoot)).toBe('hello world') + }) + + test('inserts text at beginning', () => { + const {sharedRoot, blocks} = createDocWithBlock('world') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'insert_text', + path: [0, 0], + offset: 0, + text: 'hello ', + }) + + expect(getFirstBlockText(sharedRoot)).toBe('hello world') + }) +}) + +describe('remove_text', () => { + test('removes text at offset', () => { + const {sharedRoot, blocks} = createDocWithBlock('hello world') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'remove_text', + path: [0, 0], + offset: 5, + text: ' world', + }) + + expect(getFirstBlockText(sharedRoot)).toBe('hello') + }) +}) + +describe('set_node', () => { + test('changes element attributes', () => { + const {sharedRoot, blocks} = createDocWithBlock('hello') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'set_node', + path: [0], + properties: {style: 'normal'}, + newProperties: {style: 'h1'}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockElement = yTextToSlateElement(delta[0]!.insert) + + expect((blockElement as Record).style).toBe('h1') + }) + + test('changes style from h1 to normal', () => { + const blocks: Descendant[] = [ + { + _type: 'block', + _key: 'b1', + style: 'h1', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'heading', marks: []}], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'set_node', + path: [0], + properties: {style: 'h1'}, + newProperties: {style: 'normal'}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockElement = yTextToSlateElement(delta[0]!.insert) + expect((blockElement as Record).style).toBe('normal') + }) + + test('changes style to blockquote', () => { + const {sharedRoot, blocks} = createDocWithBlock('quote text') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'set_node', + path: [0], + properties: {style: 'normal'}, + newProperties: {style: 'blockquote'}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockElement = yTextToSlateElement(delta[0]!.insert) + expect((blockElement as Record).style).toBe('blockquote') + }) + + test('adds listItem and level', () => { + const {sharedRoot, blocks} = createDocWithBlock('list item') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'set_node', + path: [0], + properties: {}, + newProperties: {listItem: 'bullet', level: 1}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockElement = yTextToSlateElement(delta[0]!.insert) + expect((blockElement as Record).listItem).toBe('bullet') + expect((blockElement as Record).level).toBe(1) + }) + + test('removes listItem', () => { + const blocks: Descendant[] = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + listItem: 'bullet', + level: 1, + children: [{_key: 's1', _type: 'span', text: 'item', marks: []}], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'set_node', + path: [0], + properties: {listItem: 'bullet', level: 1}, + newProperties: {listItem: undefined, level: undefined}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockElement = yTextToSlateElement(delta[0]!.insert) + expect((blockElement as Record).listItem).toBeUndefined() + expect((blockElement as Record).level).toBeUndefined() + }) + + test('adds marks to text node', () => { + const {sharedRoot, blocks} = createDocWithBlock('hello') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'set_node', + path: [0, 0], + properties: {marks: []}, + newProperties: {marks: ['strong']}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockElement = yTextToSlateElement(delta[0]!.insert) + const span = blockElement.children[0] as Record + + expect(span.marks).toEqual(['strong']) + }) + + test('removes marks from text node', () => { + const blocks: Descendant[] = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'hello', marks: ['strong']}, + ], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'set_node', + path: [0, 0], + properties: {marks: ['strong']}, + newProperties: {marks: []}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockElement = yTextToSlateElement(delta[0]!.insert) + const span = blockElement.children[0] as Record + + expect(span.marks).toEqual([]) + }) + + test('sets multiple marks on text node', () => { + const {sharedRoot, blocks} = createDocWithBlock('hello') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'set_node', + path: [0, 0], + properties: {marks: []}, + newProperties: {marks: ['strong', 'em']}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const blockElement = yTextToSlateElement(delta[0]!.insert) + const span = blockElement.children[0] as Record + + expect(span.marks).toEqual(['strong', 'em']) + }) +}) + +describe('split_node (element)', () => { + test('splits a block at a position', () => { + const {sharedRoot, blocks} = createDocWithBlock('foobar') + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'split_node', + path: [0], + position: 3, + properties: { + _type: 'block', + _key: 'b2', + style: 'normal', + markDefs: [], + }, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + expect(delta).toHaveLength(2) + + const block1 = yTextToSlateElement(delta[0]!.insert) + const block2 = yTextToSlateElement(delta[1]!.insert) + + const block1Text = block1.children.map((child: any) => child.text).join('') + const block2Text = block2.children.map((child: any) => child.text).join('') + + expect(block1Text).toBe('foo') + expect(block2Text).toBe('bar') + }) +}) + +describe('merge_node (element)', () => { + test('merges two blocks', () => { + const blocks: Descendant[] = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'b2', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'bar', marks: []}], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'merge_node', + path: [1], + position: 3, + properties: {}, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + expect(delta).toHaveLength(1) + + const merged = yTextToSlateElement(delta[0]!.insert) + const mergedText = merged.children.map((child: any) => child.text).join('') + + expect(mergedText).toBe('foobar') + }) +}) + +describe('remove_node', () => { + test('removes a block', () => { + const blocks: Descendant[] = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'b2', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'bar', marks: []}], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'remove_node', + path: [1], + node: blocks[1]!, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + expect(delta).toHaveLength(1) + }) +}) + +describe('insert_node', () => { + test('inserts a new block', () => { + const {sharedRoot, blocks} = createDocWithBlock('hello') + const slateDoc = {children: blocks} as unknown as Node + + const newBlock = { + _type: 'block', + _key: 'b2', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'world', marks: []}], + } as unknown as Descendant + + applySlateOp(sharedRoot, slateDoc, { + type: 'insert_node', + path: [1], + node: newBlock, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + expect(delta).toHaveLength(2) + + const block2 = yTextToSlateElement(delta[1]!.insert) + const block2Text = block2.children.map((child: any) => child.text).join('') + expect(block2Text).toBe('world') + }) + + test('inserts inline element inside a block', () => { + const blocks: Descendant[] = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'before', marks: []}, + {_key: 's2', _type: 'span', text: 'after', marks: []}, + ], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const slateDoc = {children: blocks} as unknown as Node + + const inlineObj = { + _type: 'stock-ticker', + _key: 'st1', + __inline: true, + value: {symbol: 'AAPL'}, + children: [{_key: 'void-child', _type: 'span', text: '', marks: []}], + } as unknown as Descendant + + applySlateOp(sharedRoot, slateDoc, { + type: 'insert_node', + path: [0, 1], + node: inlineObj, + }) + + const rootDelta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const block = yTextToSlateElement(rootDelta[0]!.insert) + + expect(block.children).toHaveLength(3) + expect((block.children[0] as any).text).toBe('before') + expect((block.children[1] as any)._type).toBe('stock-ticker') + expect((block.children[1] as any).value).toEqual({symbol: 'AAPL'}) + expect((block.children[2] as any).text).toBe('after') + }) + + test('inserts image block at root', () => { + const {sharedRoot, blocks} = createDocWithBlock('hello') + const slateDoc = {children: blocks} as unknown as Node + + const imageBlock = { + _type: 'image', + _key: 'img1', + __inline: false, + value: {src: 'test.png'}, + children: [{_key: 'void-child', _type: 'span', text: '', marks: []}], + } as unknown as Descendant + + applySlateOp(sharedRoot, slateDoc, { + type: 'insert_node', + path: [1], + node: imageBlock, + }) + + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + expect(delta).toHaveLength(2) + + const img = yTextToSlateElement(delta[1]!.insert) + expect(img._type).toBe('image') + expect((img as any).value).toEqual({src: 'test.png'}) + }) +}) + +describe('remove_node (inline)', () => { + test('removes inline element from block', () => { + const blocks: Descendant[] = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'before', marks: []}, + { + _type: 'stock-ticker', + _key: 'st1', + __inline: true, + value: {symbol: 'AAPL'}, + children: [ + {_key: 'void-child', _type: 'span', text: '', marks: []}, + ], + }, + {_key: 's2', _type: 'span', text: 'after', marks: []}, + ], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const slateDoc = {children: blocks} as unknown as Node + + applySlateOp(sharedRoot, slateDoc, { + type: 'remove_node', + path: [0, 1], + node: (blocks[0] as any).children[1], + }) + + const rootDelta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const block = yTextToSlateElement(rootDelta[0]!.insert) + + expect(block.children).toHaveLength(2) + expect((block.children[0] as any).text).toBe('before') + expect((block.children[1] as any).text).toBe('after') + }) +}) diff --git a/packages/editor/src/yjs/apply-to-yjs.ts b/packages/editor/src/yjs/apply-to-yjs.ts new file mode 100644 index 000000000..6eeb95cb3 --- /dev/null +++ b/packages/editor/src/yjs/apply-to-yjs.ts @@ -0,0 +1,539 @@ +import * as Y from 'yjs' +import {Element, Text, type Node, type Operation} from '../slate' +import {slateNodesToInsertDelta} from './convert' +import {getYTarget} from './utils' + +/** + * Translates a single Slate operation into the equivalent Yjs mutation + * on the shared Y.XmlText root. + * + * This is called for each local Slate operation so that the Y.Doc + * stays in sync with the local Slate document. + */ +export function applySlateOp( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Operation, +): void { + switch (op.type) { + case 'insert_text': { + return applyInsertText(sharedRoot, slateDoc, op) + } + case 'remove_text': { + return applyRemoveText(sharedRoot, slateDoc, op) + } + case 'insert_node': { + return applyInsertNode(sharedRoot, slateDoc, op) + } + case 'remove_node': { + return applyRemoveNode(sharedRoot, slateDoc, op) + } + case 'set_node': { + return applySetNode(sharedRoot, slateDoc, op) + } + case 'split_node': { + return applySplitNode(sharedRoot, slateDoc, op) + } + case 'merge_node': { + return applyMergeNode(sharedRoot, slateDoc, op) + } + case 'move_node': { + return applyMoveNode(sharedRoot, slateDoc, op) + } + case 'set_selection': { + // No-op: selections are local + return + } + } +} + +function applyInsertText( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Extract, +): void { + const {path, offset, text} = op + const target = getYTarget(sharedRoot, slateDoc, path) + + // Get existing attributes at this position to preserve _key and marks + const delta = target.yParent.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + const attributes = getAttributesAtOffset( + delta, + target.textRange.start + offset, + ) + target.yParent.insert(target.textRange.start + offset, text, attributes) +} + +function applyRemoveText( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Extract, +): void { + const {path, offset, text} = op + const target = getYTarget(sharedRoot, slateDoc, path) + target.yParent.delete(target.textRange.start + offset, text.length) +} + +function applyInsertNode( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Extract, +): void { + const {path, node} = op + + // Get parent path and child index + const parentPath = path.slice(0, -1) + const index = path[path.length - 1] + if (index === undefined) { + return + } + + const parentTarget = getYTarget(sharedRoot, slateDoc, parentPath) + const parent = parentTarget.yTarget ?? parentTarget.yParent + + // Calculate the offset for the new child + const offset = getInsertOffset(parent, slateDoc, parentPath, index) + + if (Text.isText(node)) { + const attributes: Record = {} + const {text, ...rest} = node + for (const [key, value] of Object.entries(rest)) { + attributes[key] = + typeof value === 'string' ? value : JSON.stringify(value) + } + parent.insert(offset, text, attributes) + } else if (Element.isElement(node)) { + // Embed an empty Y.XmlText first so it's attached to the Y.Doc, + // then populate it with attributes and children. Operations on a + // standalone (unattached) Y.XmlText don't survive CRDT integration. + const yText = new Y.XmlText() + parent.insertEmbed(offset, yText) + + const {children, ...props} = node + for (const [key, value] of Object.entries(props)) { + if (value === undefined) { + continue + } + if (key === 'markDefs' || typeof value !== 'string') { + yText.setAttribute(key, JSON.stringify(value)) + } else { + yText.setAttribute(key, value) + } + } + + const delta = slateNodesToInsertDelta(children) + if (delta.length > 0) { + yText.applyDelta(delta) + } + } +} + +function applyRemoveNode( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Extract, +): void { + const {path} = op + const target = getYTarget(sharedRoot, slateDoc, path) + const length = target.textRange.end - target.textRange.start + target.yParent.delete(target.textRange.start, length) +} + +function applySetNode( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Extract, +): void { + const {path, newProperties} = op + const target = getYTarget(sharedRoot, slateDoc, path) + + if (target.yTarget) { + // Element node: set attributes on the Y.XmlText + for (const [key, value] of Object.entries(newProperties)) { + if (key === 'children') { + continue + } + if (value === undefined) { + target.yTarget.removeAttribute(key) + } else if (key === 'markDefs') { + target.yTarget.setAttribute(key, JSON.stringify(value)) + } else if (typeof value === 'string') { + target.yTarget.setAttribute(key, value) + } else { + target.yTarget.setAttribute(key, JSON.stringify(value)) + } + } + } else { + // Text node: format the text range with new attributes + const attributes: Record = {} + for (const [key, value] of Object.entries(newProperties)) { + if (key === 'text') { + continue + } + attributes[key] = + typeof value === 'string' ? value : JSON.stringify(value) + } + const length = target.textRange.end - target.textRange.start + if (length > 0) { + target.yParent.format(target.textRange.start, length, attributes) + } + } +} + +function applySplitNode( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Extract, +): void { + const {path, position, properties} = op + const target = getYTarget(sharedRoot, slateDoc, path) + + if (target.yTarget) { + // Splitting an element node: create a new Y.XmlText with the trailing content + const yTarget = target.yTarget + const delta = yTarget.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + // Find split point in the delta + const {trailingDelta, deleteLength} = splitDeltaAtPosition(delta, position) + + // Delete trailing content from original first + if (deleteLength > 0) { + const totalLength = yTextContentLengthFromDelta(delta) + yTarget.delete(position, totalLength - position) + } + + // Embed an empty Y.XmlText first so it's attached to the Y.Doc, + // then populate it. Operations on standalone Y.XmlText don't survive + // CRDT integration. + const parentTarget = getYTarget(sharedRoot, slateDoc, path.slice(0, -1)) + const parent = parentTarget.yTarget ?? parentTarget.yParent + const newYText = new Y.XmlText() + parent.insertEmbed(target.textRange.end, newYText) + + // Copy attributes from the original, then override with split properties + const originalAttrs = yTarget.getAttributes() + for (const [key, value] of Object.entries(originalAttrs)) { + newYText.setAttribute(key, value as string) + } + for (const [key, value] of Object.entries(properties)) { + if (key === 'children') { + continue + } + if (value === undefined) { + newYText.removeAttribute(key) + } else if (key === 'markDefs') { + newYText.setAttribute(key, JSON.stringify(value)) + } else if (typeof value === 'string') { + newYText.setAttribute(key, value) + } else { + newYText.setAttribute(key, JSON.stringify(value)) + } + } + + // Apply trailing content to the now-attached Y.XmlText + if (trailingDelta.length > 0) { + newYText.applyDelta(trailingDelta) + } + } else { + // Splitting a text node: split the text at position + const parentPath = path.slice(0, -1) + const parentTarget = getYTarget(sharedRoot, slateDoc, parentPath) + const parent = parentTarget.yTarget ?? parentTarget.yParent + + const textStart = target.textRange.start + const splitOffset = textStart + position + + // Get the content after split point + const delta = parent.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + const trailingText = getTextAtRange( + delta, + splitOffset, + target.textRange.end, + ) + + // Build attributes for the new text span + const newAttributes: Record = {} + for (const [key, value] of Object.entries(properties)) { + if (key === 'text') { + continue + } + newAttributes[key] = + typeof value === 'string' ? value : JSON.stringify(value) + } + + // Delete the trailing text from its current position + if (target.textRange.end - splitOffset > 0) { + parent.delete(splitOffset, target.textRange.end - splitOffset) + } + + // Insert the trailing text as a new span right after split point + if (trailingText.length > 0) { + parent.insert(splitOffset, trailingText, newAttributes) + } + } +} + +function applyMergeNode( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Extract, +): void { + const {path, position} = op + const target = getYTarget(sharedRoot, slateDoc, path) + + // The node at `path` merges into the node at `path - 1` + const lastIndex = path[path.length - 1] + if (lastIndex === undefined) { + return + } + const previousPath = [...path.slice(0, -1), lastIndex - 1] + + if (target.yTarget) { + // Merging element nodes: copy all content from target into previous, + // then delete the target + const previousTarget = getYTarget(sharedRoot, slateDoc, previousPath) + const previousYText = previousTarget.yTarget + + if (!previousYText) { + throw new Error( + 'Expected previous target to be a Y.XmlText for element merge', + ) + } + + const delta = target.yTarget.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + // Insert content from source at the end of target (at `position`) + if (delta.length > 0) { + previousYText.applyDelta([{retain: position}, ...delta]) + } + + // Remove the source element from the parent + const parentTarget = getYTarget(sharedRoot, slateDoc, path.slice(0, -1)) + const parent = parentTarget.yTarget ?? parentTarget.yParent + parent.delete(target.textRange.start, 1) + } else { + // Merging text nodes: in Yjs's flat text model, the text content + // stays in place. We re-format the merged span's text to have the + // same attributes as the preceding span, so Yjs merges them. + const length = target.textRange.end - target.textRange.start + if (length > 0) { + // Get the attributes of the preceding text span + const previousTarget = getYTarget(sharedRoot, slateDoc, previousPath) + const delta = target.yParent.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + const prevAttributes = getAttributesAtOffset( + delta, + previousTarget.textRange.start, + ) + target.yParent.format(target.textRange.start, length, prevAttributes) + } else { + // Empty text span — just delete it since there's no content to preserve + // The span contributes no characters to the Yjs text, but check for + // zero-length formatted ranges that may need cleanup + } + } +} + +function applyMoveNode( + sharedRoot: Y.XmlText, + slateDoc: Node, + op: Extract, +): void { + const {path, newPath} = op + + // Read the node content first + const target = getYTarget(sharedRoot, slateDoc, path) + + if (target.yTarget) { + // Moving an element: clone it, remove from old position, insert at new + const delta = target.yTarget.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + const attributes = target.yTarget.getAttributes() + + // Remove from old position + target.yParent.delete(target.textRange.start, 1) + + // Insert at new position + const newParentPath = newPath.slice(0, -1) + const newIndex = newPath[newPath.length - 1] + if (newIndex === undefined) { + return + } + const newParentTarget = getYTarget(sharedRoot, slateDoc, newParentPath) + const newParent = newParentTarget.yTarget ?? newParentTarget.yParent + + const newOffset = getInsertOffset( + newParent, + slateDoc, + newParentPath, + newIndex, + ) + + // Embed an empty Y.XmlText first so it's attached to the Y.Doc, + // then populate it. Operations on standalone Y.XmlText don't survive + // CRDT integration. + const newYText = new Y.XmlText() + newParent.insertEmbed(newOffset, newYText) + + for (const [key, value] of Object.entries(attributes)) { + newYText.setAttribute(key, value as string) + } + if (delta.length > 0) { + newYText.applyDelta(delta) + } + } +} + +function getInsertOffset( + parent: Y.XmlText, + _slateDoc: Node, + _parentPath: number[], + index: number, +): number { + const delta = parent.toDelta() as Array<{ + insert: string | Y.XmlText + }> + + let offset = 0 + let childIndex = 0 + + for (const entry of delta) { + if (childIndex >= index) { + break + } + + if (typeof entry.insert === 'string') { + // Text entries might span multiple Slate text nodes if they have the + // same attributes. For our mapping, each distinct text span in the + // delta corresponds to one Slate text child. + offset += entry.insert.length + childIndex++ + } else { + // Embedded Y.XmlText = 1 Slate element child + offset += 1 + childIndex++ + } + } + + return offset +} + +function getAttributesAtOffset( + delta: Array<{ + insert: string | Y.XmlText + attributes?: Record + }>, + offset: number, +): Record { + let currentOffset = 0 + for (const entry of delta) { + const length = typeof entry.insert === 'string' ? entry.insert.length : 1 + + if (currentOffset + length > offset) { + return entry.attributes ? {...entry.attributes} : {} + } + currentOffset += length + } + return {} +} + +function splitDeltaAtPosition( + delta: Array<{ + insert: string | Y.XmlText + attributes?: Record + }>, + position: number, +): {trailingDelta: typeof delta; deleteLength: number} { + const trailingDelta: typeof delta = [] + let currentOffset = 0 + let deleteLength = 0 + + for (const entry of delta) { + const length = typeof entry.insert === 'string' ? entry.insert.length : 1 + + if (currentOffset >= position) { + // Entirely after split point + trailingDelta.push(entry) + deleteLength += length + } else if (currentOffset + length > position) { + // Straddles the split point (text only) + if (typeof entry.insert === 'string') { + const splitAt = position - currentOffset + const trailing = entry.insert.slice(splitAt) + if (trailing.length > 0) { + trailingDelta.push({insert: trailing, attributes: entry.attributes}) + deleteLength += trailing.length + } + } + } + + currentOffset += length + } + + return {trailingDelta, deleteLength} +} + +function yTextContentLengthFromDelta( + delta: Array<{insert: string | Y.XmlText}>, +): number { + let length = 0 + for (const entry of delta) { + length += typeof entry.insert === 'string' ? entry.insert.length : 1 + } + return length +} + +function getTextAtRange( + delta: Array<{ + insert: string | Y.XmlText + attributes?: Record + }>, + start: number, + end: number, +): string { + let result = '' + let currentOffset = 0 + + for (const entry of delta) { + if (typeof entry.insert !== 'string') { + currentOffset += 1 + continue + } + + const entryStart = currentOffset + const entryEnd = currentOffset + entry.insert.length + + if (entryEnd <= start) { + currentOffset = entryEnd + continue + } + if (entryStart >= end) { + break + } + + const sliceStart = Math.max(0, start - entryStart) + const sliceEnd = Math.min(entry.insert.length, end - entryStart) + result += entry.insert.slice(sliceStart, sliceEnd) + currentOffset = entryEnd + } + + return result +} diff --git a/packages/editor/src/yjs/convert.test.ts b/packages/editor/src/yjs/convert.test.ts new file mode 100644 index 000000000..170deaa79 --- /dev/null +++ b/packages/editor/src/yjs/convert.test.ts @@ -0,0 +1,315 @@ +import {describe, expect, test} from 'vitest' +import * as Y from 'yjs' +import type {Descendant} from '../slate' +import { + deltaInsertToSlateNode, + getSharedRoot, + slateNodesToInsertDelta, + slateNodesToYDoc, + yTextToSlateElement, +} from './convert' + +describe('slateNodesToInsertDelta', () => { + test('converts text nodes to string inserts with attributes', () => { + const nodes: Descendant[] = [ + {_key: 's1', _type: 'span', text: 'hello', marks: []}, + ] + + const delta = slateNodesToInsertDelta(nodes) + + expect(delta).toEqual([ + { + insert: 'hello', + attributes: {_key: 's1', _type: 'span', marks: '[]'}, + }, + ]) + }) + + test('converts text nodes with marks', () => { + const nodes: Descendant[] = [ + {_key: 's1', _type: 'span', text: 'bold', marks: ['strong']}, + ] + + const delta = slateNodesToInsertDelta(nodes) + + expect(delta).toEqual([ + { + insert: 'bold', + attributes: {_key: 's1', _type: 'span', marks: '["strong"]'}, + }, + ]) + }) + + test('converts element nodes to Y.XmlText embeds', () => { + const element = { + _type: 'block', + _key: 'b1', + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + markDefs: [], + style: 'normal', + } as unknown as Descendant + + const delta = slateNodesToInsertDelta([element]) + + expect(delta).toHaveLength(1) + expect(delta[0]!.insert).toBeInstanceOf(Y.XmlText) + }) +}) + +describe('slateElementToYText / yTextToSlateElement roundtrip', () => { + // Roundtrip tests must go through a Y.Doc so that Y.XmlText operations + // (setAttribute, applyDelta) actually persist. We use slateNodesToYDoc + // which handles this correctly. + + test('simple text block roundtrips', () => { + const block = { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + } as unknown as Descendant + + const yDoc = slateNodesToYDoc([block]) + const sharedRoot = getSharedRoot(yDoc) + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const result = yTextToSlateElement(delta[0]!.insert) + + expect(result).toEqual(block) + }) + + test('block with multiple spans roundtrips', () => { + const block = { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'hello ', marks: []}, + {_key: 's2', _type: 'span', text: 'world', marks: ['strong']}, + ], + } as unknown as Descendant + + const yDoc = slateNodesToYDoc([block]) + const sharedRoot = getSharedRoot(yDoc) + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const result = yTextToSlateElement(delta[0]!.insert) + + expect(result).toEqual(block) + }) + + test('block with markDefs roundtrips', () => { + const block = { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [{_type: 'link', _key: 'link1', href: 'https://example.com'}], + children: [ + {_key: 's1', _type: 'span', text: 'click ', marks: []}, + {_key: 's2', _type: 'span', text: 'here', marks: ['link1']}, + ], + } as unknown as Descendant + + const yDoc = slateNodesToYDoc([block]) + const sharedRoot = getSharedRoot(yDoc) + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const result = yTextToSlateElement(delta[0]!.insert) + + expect(result).toEqual(block) + }) + + test('block object (void element) preserves attributes', () => { + const block = { + _type: 'image', + _key: 'img1', + __inline: true, + value: {src: 'image.png'}, + children: [{_key: 'void-child', _type: 'span', text: '', marks: []}], + } as unknown as Descendant + + const yDoc = slateNodesToYDoc([block]) + const sharedRoot = getSharedRoot(yDoc) + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const result = yTextToSlateElement(delta[0]!.insert) + + expect(result._type).toBe('image') + expect(result._key).toBe('img1') + expect((result as any).__inline).toBe(true) + expect((result as any).value).toEqual({src: 'image.png'}) + }) + + test('block with mixed text and inline object roundtrips', () => { + const block = { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [ + {_key: 's1', _type: 'span', text: 'before ', marks: []}, + { + _type: 'stock-ticker', + _key: 'st1', + __inline: true, + value: {symbol: 'AAPL'}, + children: [{_key: 'void-child', _type: 'span', text: '', marks: []}], + }, + {_key: 's2', _type: 'span', text: ' after', marks: []}, + ], + } as unknown as Descendant + + const yDoc = slateNodesToYDoc([block]) + const sharedRoot = getSharedRoot(yDoc) + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const result = yTextToSlateElement(delta[0]!.insert) + + expect(result.children).toHaveLength(3) + expect(result.children[0]).toEqual({ + _key: 's1', + _type: 'span', + text: 'before ', + marks: [], + }) + const inlineChild = result.children[1] as any + expect(inlineChild._type).toBe('stock-ticker') + expect(inlineChild._key).toBe('st1') + expect(inlineChild.__inline).toBe(true) + expect(inlineChild.value).toEqual({symbol: 'AAPL'}) + // Yjs ignores zero-length text inserts, so the void child loses its + // attributes and comes back as a plain `{text: ''}` placeholder + expect(inlineChild.children).toEqual([{text: ''}]) + expect(result.children[2]).toEqual({ + _key: 's2', + _type: 'span', + text: ' after', + marks: [], + }) + }) + + test('list block with listItem and level roundtrips', () => { + const block = { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + listItem: 'bullet', + level: 1, + children: [{_key: 's1', _type: 'span', text: 'list item', marks: []}], + } as unknown as Descendant + + const yDoc = slateNodesToYDoc([block]) + const sharedRoot = getSharedRoot(yDoc) + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + const result = yTextToSlateElement(delta[0]!.insert) + + expect(result).toEqual(block) + }) + + test('empty Y.XmlText gets a child', () => { + const yDoc = new Y.Doc() + const root = yDoc.get('test', Y.XmlText) as Y.XmlText + + const yText = new Y.XmlText() + yText.setAttribute('_type', 'block') + yText.setAttribute('_key', 'b1') + root.insertEmbed(0, yText) + + const delta = root.toDelta() as Array<{insert: Y.XmlText}> + const embedded = delta[0]!.insert + const result = yTextToSlateElement(embedded) + + expect(result.children).toHaveLength(1) + expect(result.children[0]).toEqual({text: ''}) + }) +}) + +describe('deltaInsertToSlateNode', () => { + test('converts string insert to text node', () => { + const result = deltaInsertToSlateNode({ + insert: 'hello', + attributes: {_key: 's1', _type: 'span', marks: '[]'}, + }) + + expect(result).toEqual({ + text: 'hello', + _key: 's1', + _type: 'span', + marks: [], + }) + }) + + test('converts Y.XmlText insert to element node', () => { + // Y.XmlText operations only persist when attached to a Y.Doc + const yDoc = new Y.Doc() + const root = yDoc.get('test', Y.XmlText) as Y.XmlText + + const yText = new Y.XmlText() + yText.setAttribute('_type', 'block') + yText.setAttribute('_key', 'b1') + yText.setAttribute('style', 'normal') + yText.setAttribute('markDefs', '[]') + yText.insert(0, 'hello', {_key: 's1', _type: 'span', marks: '[]'}) + + root.insertEmbed(0, yText) + + const delta = root.toDelta() as Array<{insert: Y.XmlText}> + const embedded = delta[0]!.insert + const result = deltaInsertToSlateNode({insert: embedded}) + + expect(result).toEqual({ + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{text: 'hello', _key: 's1', _type: 'span', marks: []}], + }) + }) +}) + +describe('slateNodesToYDoc', () => { + test('creates Y.Doc with shared root containing blocks', () => { + const blocks = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + { + _type: 'block', + _key: 'b2', + style: 'normal', + markDefs: [], + children: [{_key: 's2', _type: 'span', text: 'world', marks: []}], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const delta = sharedRoot.toDelta() + + expect(delta).toHaveLength(2) + expect(delta[0].insert).toBeInstanceOf(Y.XmlText) + expect(delta[1].insert).toBeInstanceOf(Y.XmlText) + }) + + test('roundtrips through Y.Doc', () => { + const blocks = [ + { + _type: 'block', + _key: 'b1', + style: 'normal', + markDefs: [], + children: [{_key: 's1', _type: 'span', text: 'hello', marks: []}], + }, + ] as unknown as Descendant[] + + const yDoc = slateNodesToYDoc(blocks) + const sharedRoot = getSharedRoot(yDoc) + const delta = sharedRoot.toDelta() as Array<{insert: Y.XmlText}> + + const result = yTextToSlateElement(delta[0]!.insert) + + expect(result).toEqual(blocks[0]) + }) +}) diff --git a/packages/editor/src/yjs/convert.ts b/packages/editor/src/yjs/convert.ts new file mode 100644 index 000000000..730d0be78 --- /dev/null +++ b/packages/editor/src/yjs/convert.ts @@ -0,0 +1,151 @@ +import * as Y from 'yjs' +import {Element, Text, type Descendant, type Node} from '../slate' + +type InsertDelta = Array<{ + insert: string | Y.XmlText + attributes?: Record +}> + +/** + * Converts Slate children to a Y.XmlText insert delta. + * + * Text nodes become string inserts with `_key` and `marks` attributes. + * Element nodes become embedded Y.XmlText inserts. + */ +export function slateNodesToInsertDelta(nodes: Node[]): InsertDelta { + return nodes.map((node) => { + if (Text.isText(node)) { + const attributes: Record = {} + const {text, ...rest} = node + for (const [key, value] of Object.entries(rest)) { + attributes[key] = + typeof value === 'string' ? value : JSON.stringify(value) + } + return {insert: text, attributes} + } + + if (Element.isElement(node)) { + return {insert: slateElementToYText(node)} + } + + return {insert: ''} + }) +} + +/** + * Converts a Slate element (block or inline) to a Y.XmlText node. + * + * Element properties (excluding `children`) are stored as Y.XmlText + * attributes. The element's children are converted to a delta and + * applied to the Y.XmlText content. + */ +export function slateElementToYText(element: Element): Y.XmlText { + const yText = new Y.XmlText() + + const {children, ...props} = element + + for (const [key, value] of Object.entries(props)) { + if (key === 'markDefs') { + yText.setAttribute(key, JSON.stringify(value)) + } else if (typeof value === 'string') { + yText.setAttribute(key, value) + } else if (typeof value === 'boolean') { + yText.setAttribute(key, JSON.stringify(value)) + } else if (typeof value === 'number') { + yText.setAttribute(key, JSON.stringify(value)) + } else if (value !== undefined) { + yText.setAttribute(key, JSON.stringify(value)) + } + } + + const delta = slateNodesToInsertDelta(children) + yText.applyDelta(delta) + + return yText +} + +/** + * Converts a Y.XmlText node back to a Slate element. + * + * Reads attributes for element properties and converts the + * Y.XmlText delta back to Slate children. + */ +export function yTextToSlateElement(yText: Y.XmlText): Element { + const attributes = yText.getAttributes() + const props: Record = {} + + for (const [key, value] of Object.entries(attributes)) { + if (key === 'markDefs') { + props[key] = JSON.parse(value as string) + } else if (key === '__inline') { + props[key] = JSON.parse(value as string) + } else if (key === 'value') { + props[key] = JSON.parse(value as string) + } else if (key === 'level') { + props[key] = JSON.parse(value as string) + } else { + props[key] = value + } + } + + const delta = yText.toDelta() as Array<{ + insert: string | Y.XmlText + attributes?: Record + }> + + const children = delta.map(deltaInsertToSlateNode) + + if (children.length === 0) { + // Slate requires at least one child + children.push({text: ''} as unknown as Descendant) + } + + return {...props, children} as Element +} + +/** + * Converts a single Y.XmlText delta entry to a Slate node. + * + * String inserts become Text nodes. Y.XmlText embeds become + * Element nodes (via recursive `yTextToSlateElement`). + */ +export function deltaInsertToSlateNode(insert: { + insert: string | Y.XmlText + attributes?: Record +}): Descendant { + if (insert.insert instanceof Y.XmlText) { + return yTextToSlateElement(insert.insert) + } + + const node: Record = {text: insert.insert} + const attributes = insert.attributes ?? {} + + for (const [key, value] of Object.entries(attributes)) { + node[key] = key === 'marks' ? JSON.parse(value) : value + } + + return node as unknown as Descendant +} + +/** + * Creates a Y.Doc populated with the given Slate nodes as a shared root. + * + * The shared root is a Y.XmlText stored under the key `"content"` in the + * Y.Doc. Each top-level Slate element becomes an embedded Y.XmlText child. + */ +export function slateNodesToYDoc(nodes: Node[]): Y.Doc { + const yDoc = new Y.Doc() + const sharedRoot = yDoc.get('content', Y.XmlText) as Y.XmlText + + const delta = slateNodesToInsertDelta(nodes) + sharedRoot.applyDelta(delta) + + return yDoc +} + +/** + * Returns the shared root Y.XmlText from a Y.Doc. + */ +export function getSharedRoot(yDoc: Y.Doc): Y.XmlText { + return yDoc.get('content', Y.XmlText) as Y.XmlText +} diff --git a/packages/editor/src/yjs/index.ts b/packages/editor/src/yjs/index.ts new file mode 100644 index 000000000..cdcc8effe --- /dev/null +++ b/packages/editor/src/yjs/index.ts @@ -0,0 +1,15 @@ +export { + deltaInsertToSlateNode, + getSharedRoot, + slateElementToYText, + slateNodesToInsertDelta, + slateNodesToYDoc, + yTextToSlateElement, +} from './convert' +export type { + YjsEditor, + YjsEditorConfig, + YjsOperationDirection, + YjsOperationEntry, +} from './types' +export {withYjs} from './with-yjs' diff --git a/packages/editor/src/yjs/step-definitions.tsx b/packages/editor/src/yjs/step-definitions.tsx new file mode 100644 index 000000000..23129eb3e --- /dev/null +++ b/packages/editor/src/yjs/step-definitions.tsx @@ -0,0 +1,37 @@ +import {defineSchema} from '@portabletext/schema' +import {Given} from 'racejar' +import type {Context} from '../test/vitest/step-context' +import {createTestEditorsWithYjs} from './test-editor-with-yjs' + +const schemaDefinition = defineSchema({ + annotations: [{name: 'comment'}, {name: 'link'}], + decorators: [{name: 'em'}, {name: 'strong'}], + blockObjects: [{name: 'image'}, {name: 'break'}], + inlineObjects: [{name: 'stock-ticker'}], + lists: [{name: 'bullet'}, {name: 'number'}], + styles: [ + {name: 'normal'}, + {name: 'h1'}, + {name: 'h2'}, + {name: 'h3'}, + {name: 'h4'}, + {name: 'h5'}, + {name: 'h6'}, + {name: 'blockquote'}, + ], +}) + +export const yjsStepDefinitions = [ + Given('two editors with Yjs sync', async (context: Context) => { + const {editor, locator, editorB, locatorB} = await createTestEditorsWithYjs( + { + schemaDefinition, + }, + ) + + context.locator = locator + context.editor = editor + context.locatorB = locatorB + context.editorB = editorB + }), +] diff --git a/packages/editor/src/yjs/test-editor-with-yjs.tsx b/packages/editor/src/yjs/test-editor-with-yjs.tsx new file mode 100644 index 000000000..a9b32c453 --- /dev/null +++ b/packages/editor/src/yjs/test-editor-with-yjs.tsx @@ -0,0 +1,136 @@ +import {defineSchema, type SchemaDefinition} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import React, {useRef} from 'react' +import {expect, vi} from 'vitest' +import {render} from 'vitest-browser-react' +import {page} from 'vitest/browser' +import * as Y from 'yjs' +import type {Editor} from '../editor' +import type {InternalEditor} from '../editor/create-editor' +import { + PortableTextEditable, + type PortableTextEditableProps, +} from '../editor/Editable' +import {EditorProvider} from '../editor/editor-provider' +import {EditorRefPlugin} from '../plugins/plugin.editor-ref' +import type {Context} from '../test/vitest/step-context' +import type {PortableTextSlateEditor} from '../types/slate-editor' +import type {YjsEditor} from './types' +import {withYjs} from './with-yjs' + +type Options = { + schemaDefinition?: SchemaDefinition + editableProps?: PortableTextEditableProps +} + +function YjsPlugin({ + sharedRoot, + onSlateEditor, +}: { + sharedRoot: Y.XmlText + onSlateEditor: (editor: PortableTextSlateEditor) => void +}) { + const applied = useRef(false) + + return ( + { + if (!editor || applied.current) { + return + } + + applied.current = true + + const slateEditor = (editor as unknown as InternalEditor)._internal + .slateEditor.instance + + if (!slateEditor) { + return + } + + const yjsEditor = withYjs(slateEditor, { + sharedRoot, + localOrigin: Symbol('local'), + }) as unknown as YjsEditor + + yjsEditor.connect() + onSlateEditor(slateEditor) + }} + /> + ) +} + +/** + * Creates two test editors synced through a shared Y.Doc instead of + * patch forwarding. + */ +export async function createTestEditorsWithYjs( + options: Options = {}, +): Promise> { + const editorRef = React.createRef() + const editorBRef = React.createRef() + + const keyGenerator = createTestKeyGenerator('ea-') + const keyGeneratorB = createTestKeyGenerator('eb-') + const schemaDefinition = options.schemaDefinition ?? defineSchema({}) + + const yDoc = new Y.Doc() + const sharedRoot = yDoc.get('content', Y.XmlText) as Y.XmlText + + const slateEditorA = {current: null as PortableTextSlateEditor | null} + const slateEditorB = {current: null as PortableTextSlateEditor | null} + + render( + <> + + + { + slateEditorA.current = editor + }} + /> + + + + + { + slateEditorB.current = editor + }} + /> + + + , + ) + + const locator = page.getByTestId('editor-a') + const locatorB = page.getByTestId('editor-b') + + await vi.waitFor(() => expect.element(locator).toBeInTheDocument()) + await vi.waitFor(() => expect.element(locatorB).toBeInTheDocument()) + + return { + editor: editorRef.current!, + locator, + editorB: editorBRef.current!, + locatorB, + } +} diff --git a/packages/editor/src/yjs/types.ts b/packages/editor/src/yjs/types.ts new file mode 100644 index 000000000..d964e80cb --- /dev/null +++ b/packages/editor/src/yjs/types.ts @@ -0,0 +1,25 @@ +import type * as Y from 'yjs' +import type {Operation} from '../slate' +import type {PortableTextSlateEditor} from '../types/slate-editor' + +export type YjsOperationDirection = 'local-to-yjs' | 'yjs-to-slate' + +export interface YjsOperationEntry { + direction: YjsOperationDirection + operations: Array + timestamp: number +} + +export interface YjsEditorConfig { + sharedRoot: Y.XmlText + localOrigin: unknown + onOperation?: (entry: YjsOperationEntry) => void +} + +export interface YjsEditor extends PortableTextSlateEditor { + sharedRoot: Y.XmlText + localOrigin: unknown + isYjsConnected: boolean + connect: () => void + disconnect: () => void +} diff --git a/packages/editor/src/yjs/utils.ts b/packages/editor/src/yjs/utils.ts new file mode 100644 index 000000000..bc4414143 --- /dev/null +++ b/packages/editor/src/yjs/utils.ts @@ -0,0 +1,165 @@ +import * as Y from 'yjs' +import {Element, Text, type Node} from '../slate' + +export interface YTarget { + yParent: Y.XmlText + textRange: {start: number; end: number} + yTarget?: Y.XmlText +} + +/** + * Given a Slate path, navigates the Y.XmlText tree to find the + * corresponding Y.XmlText target and its offset range within the parent. + * + * For a path like `[2, 1]`: + * - First finds the 3rd child (index 2) in the shared root + * - Then finds the 2nd child (index 1) within that element + * + * Text nodes have a Y-length equal to their text length. + * Element nodes (embeds) have a Y-length of 1. + */ +export function getYTarget( + sharedRoot: Y.XmlText, + slateRoot: Node, + path: number[], +): YTarget { + if (path.length === 0) { + return { + yParent: sharedRoot, + textRange: {start: 0, end: yTextContentLength(sharedRoot)}, + yTarget: sharedRoot, + } + } + + let currentYText = sharedRoot + let currentSlateNode: Node = slateRoot + + for (let depth = 0; depth < path.length; depth++) { + const index = path[depth]! + const isLast = depth === path.length - 1 + + if (!Element.isElement(currentSlateNode)) { + throw new Error( + `Cannot descend into a text node at path [${path.join(', ')}]`, + ) + } + + const children = currentSlateNode.children + + const {offset, length} = getYOffsetAndLength(children, index) + + if (isLast) { + const child = children[index] + if (child && Element.isElement(child)) { + const delta = currentYText.toDelta() as Array<{ + insert: string | Y.XmlText + }> + const yTarget = findEmbedAtOffset(delta, offset) + return { + yParent: currentYText, + textRange: {start: offset, end: offset + length}, + yTarget, + } + } + + return { + yParent: currentYText, + textRange: {start: offset, end: offset + length}, + } + } + + // Descend into nested element + const delta = currentYText.toDelta() as Array<{ + insert: string | Y.XmlText + }> + const yTarget = findEmbedAtOffset(delta, offset) + if (!yTarget) { + throw new Error( + `Expected Y.XmlText embed at offset ${offset} for path [${path.join(', ')}]`, + ) + } + + currentYText = yTarget + const nextSlateNode = children[index] + if (!nextSlateNode) { + throw new Error( + `No child at index ${index} for path [${path.join(', ')}]`, + ) + } + currentSlateNode = nextSlateNode + } + + return { + yParent: currentYText, + textRange: {start: 0, end: yTextContentLength(currentYText)}, + yTarget: currentYText, + } +} + +/** + * Calculates the Y offset for a child at the given Slate index, + * plus the Y-length of that child. + */ +function getYOffsetAndLength( + children: Node[], + index: number, +): {offset: number; length: number} { + let offset = 0 + + for (let i = 0; i < index; i++) { + const child = children[i] + if (child) { + offset += yNodeLength(child) + } + } + + const child = children[index] + const length = child ? yNodeLength(child) : 0 + + return {offset, length} +} + +/** + * Returns the Y-length of a Slate node. + * Text nodes: their text string length. + * Element nodes (embeds): 1. + */ +function yNodeLength(node: Node): number { + if (Text.isText(node)) { + return node.text.length + } + return 1 +} + +/** + * Finds the Y.XmlText embed at a given offset in a delta array. + */ +function findEmbedAtOffset( + delta: Array<{insert: string | Y.XmlText}>, + targetOffset: number, +): Y.XmlText | undefined { + let offset = 0 + for (const entry of delta) { + const length = typeof entry.insert === 'string' ? entry.insert.length : 1 + if (offset === targetOffset && entry.insert instanceof Y.XmlText) { + return entry.insert + } + offset += length + if (offset > targetOffset) { + break + } + } + return undefined +} + +/** + * Returns the total content length of a Y.XmlText (text chars + embeds). + */ +export function yTextContentLength(yText: Y.XmlText): number { + const delta = yText.toDelta() as Array<{insert: string | Y.XmlText}> + let length = 0 + for (const entry of delta) { + length += typeof entry.insert === 'string' ? entry.insert.length : 1 + } + return length +} diff --git a/packages/editor/src/yjs/with-yjs.ts b/packages/editor/src/yjs/with-yjs.ts new file mode 100644 index 000000000..a04fba71b --- /dev/null +++ b/packages/editor/src/yjs/with-yjs.ts @@ -0,0 +1,216 @@ +import * as Y from 'yjs' +import {Editor, type Node, type Operation} from '../slate' +import {withRemoteChanges} from '../slate-plugins/slate-plugin.remote-changes' +import {pluginWithoutHistory} from '../slate-plugins/slate-plugin.without-history' +import {withoutPatching} from '../slate-plugins/slate-plugin.without-patching' +import type {PortableTextSlateEditor} from '../types/slate-editor' +import {applyYjsEvents} from './apply-to-slate' +import {applySlateOp} from './apply-to-yjs' +import {slateNodesToInsertDelta, yTextToSlateElement} from './convert' +import type {YjsEditor, YjsEditorConfig} from './types' + +interface BufferedOperation { + op: Operation + doc: Node[] +} + +/** + * Wraps a `PortableTextSlateEditor` with Yjs collaboration support. + * + * **Local flow**: Slate operations are buffered during `apply`. On `onChange`, + * all buffered ops are flushed to the shared Y.Doc in a single transaction + * tagged with `localOrigin`. + * + * **Remote flow**: The `observeDeep` handler receives Yjs events for changes + * made by other peers. These are translated to Slate operations and applied + * wrapped in `withRemoteChanges`, `withoutPatching`, and `pluginWithoutHistory` + * so they bypass PT patch generation, history, and are correctly flagged as + * remote. + */ +export function withYjs( + editor: PortableTextSlateEditor, + config: YjsEditorConfig, +): PortableTextSlateEditor { + const yjsEditor = editor as unknown as YjsEditor + const {sharedRoot, localOrigin} = config + + yjsEditor.sharedRoot = sharedRoot + yjsEditor.localOrigin = localOrigin + + let bufferedOps: BufferedOperation[] = [] + let isApplyingRemoteChanges = false + // Local to this closure — each `withYjs` wrapper tracks its own connection + // state independently, so stale wrappers don't buffer ops to the wrong Y.Doc. + let isConnected = false + + const {apply, onChange} = editor + + function flushBufferedOps() { + if (bufferedOps.length === 0) { + return + } + + const ops = bufferedOps + bufferedOps = [] + + const yDoc = sharedRoot.doc + if (!yDoc) { + return + } + + yDoc.transact(() => { + for (const {op, doc} of ops) { + applySlateOp(sharedRoot, {children: doc} as Node, op) + } + }, localOrigin) + + if (config.onOperation) { + config.onOperation({ + direction: 'local-to-yjs', + operations: ops.map(({op}) => op), + timestamp: Date.now(), + }) + } + } + + function handleYjsObserve( + events: Y.YEvent[], + transaction: Y.Transaction, + ) { + if (transaction.origin === localOrigin) { + // Skip events from our own transactions + return + } + + if (!isConnected) { + return + } + + // Flush any pending local ops first + flushBufferedOps() + + isApplyingRemoteChanges = true + const remoteOps: Operation[] = [] + const originalApply = editor.apply + editor.apply = (op: Operation) => { + remoteOps.push(op) + originalApply(op) + } + + withRemoteChanges(editor, () => { + withoutPatching(editor, () => { + pluginWithoutHistory(editor, () => { + Editor.withoutNormalizing(editor, () => { + applyYjsEvents(sharedRoot, editor, events as Y.YEvent[]) + }) + editor.normalize() + }) + }) + }) + + editor.apply = originalApply + isApplyingRemoteChanges = false + + if (config.onOperation && remoteOps.length > 0) { + config.onOperation({ + direction: 'yjs-to-slate', + operations: remoteOps, + timestamp: Date.now(), + }) + } + + editor.onChange() + } + + yjsEditor.connect = () => { + if (isConnected) { + return + } + + isConnected = true + yjsEditor.isYjsConnected = true + + // Sync initial state: if Y.Doc has content, apply it to Slate. + // If Y.Doc is empty, push Slate state to Y.Doc. + const yDoc = sharedRoot.doc + const yDelta = sharedRoot.toDelta() + + if (yDelta.length > 0) { + // Y.Doc has content — apply it to Slate. + // Set `isApplyingRemoteChanges` so these Slate operations don't get + // buffered and sent back to the Y.Doc. + const element = yTextToSlateElement(sharedRoot) + isApplyingRemoteChanges = true + withRemoteChanges(editor, () => { + withoutPatching(editor, () => { + pluginWithoutHistory(editor, () => { + Editor.withoutNormalizing(editor, () => { + // Replace all Slate children with Y.Doc content + for (let i = editor.children.length - 1; i >= 0; i--) { + const child = editor.children[i] + if (child) { + editor.apply({ + type: 'remove_node', + path: [i], + node: child, + }) + } + } + for (let i = 0; i < element.children.length; i++) { + const child = element.children[i] + if (child) { + editor.apply({ + type: 'insert_node', + path: [i], + node: child, + }) + } + } + }) + editor.normalize() + }) + }) + }) + isApplyingRemoteChanges = false + } else if (editor.children.length > 0 && yDoc) { + // Slate has content but Y.Doc is empty — push to Y.Doc + yDoc.transact(() => { + const delta = slateNodesToInsertDelta(editor.children) + sharedRoot.applyDelta(delta) + }, localOrigin) + } + + sharedRoot.observeDeep(handleYjsObserve) + } + + yjsEditor.disconnect = () => { + if (!isConnected) { + return + } + + flushBufferedOps() + sharedRoot.unobserveDeep(handleYjsObserve) + isConnected = false + yjsEditor.isYjsConnected = false + } + + editor.apply = (op: Operation) => { + if (isConnected && !isApplyingRemoteChanges) { + if (op.type !== 'set_selection') { + bufferedOps.push({op, doc: [...editor.children]}) + } + } + + apply(op) + } + + editor.onChange = () => { + if (isConnected && !isApplyingRemoteChanges) { + flushBufferedOps() + } + + onChange() + } + + return editor +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8454eca8a..3d0591db5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: vite: specifier: ^7.3.1 version: 7.3.1(@types/node@20.19.25)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + yjs: + specifier: ^13.6.29 + version: 13.6.29 examples/basic: dependencies: @@ -526,6 +529,9 @@ importers: vitest-browser-react: specifier: ^2.0.5 version: 2.0.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.0.18) + yjs: + specifier: ^13.6.29 + version: 13.6.29 packages/keyboard-shortcuts: devDependencies: @@ -5553,7 +5559,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + deprecated: Glob versions prior to v9 are no longer supported globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -5876,6 +5882,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic.js@0.2.5: + resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -6015,6 +6024,11 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lib0@0.2.117: + resolution: {integrity: sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==} + engines: {node: '>=16'} + hasBin: true + lightningcss-android-arm64@1.30.2: resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} engines: {node: '>= 12.0.0'} @@ -8082,6 +8096,10 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yjs@13.6.29: + resolution: {integrity: sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -13758,6 +13776,8 @@ snapshots: isexe@2.0.0: {} + isomorphic.js@0.2.5: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-instrument@6.0.3: @@ -13960,6 +13980,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lib0@0.2.117: + dependencies: + isomorphic.js: 0.2.5 + lightningcss-android-arm64@1.30.2: optional: true @@ -16302,6 +16326,10 @@ snapshots: yargs-parser@21.1.1: {} + yjs@13.6.29: + dependencies: + lib0: 0.2.117 + yocto-queue@0.1.0: {} yocto-queue@1.2.2: {}