diff --git a/.changeset/vendor-slate.md b/.changeset/vendor-slate.md new file mode 100644 index 000000000..5c0cbd113 --- /dev/null +++ b/.changeset/vendor-slate.md @@ -0,0 +1,7 @@ +--- +'@portabletext/editor': minor +--- + +feat: vendor Slate into the editor + +Vendors `slate`, `slate-dom`, and `slate-react` into the package to remove external dependencies and enable direct modifications to the Slate layer. React Compiler exclusions added for the vendored code. No public API changes. diff --git a/packages/editor/eslint.config.js b/packages/editor/eslint.config.js index 9dcdb5e9f..cff0d6ce0 100644 --- a/packages/editor/eslint.config.js +++ b/packages/editor/eslint.config.js @@ -3,7 +3,15 @@ import {globalIgnores} from 'eslint/config' import tseslint from 'typescript-eslint' export default tseslint.config([ - globalIgnores(['coverage', 'dist', 'lib', '**/__tests__/**']), + globalIgnores([ + 'coverage', + 'dist', + 'lib', + '**/__tests__/**', + 'src/slate/**', + 'src/slate-dom/**', + 'src/slate-react/**', + ]), reactHooks.configs.flat.recommended, { files: ['src/**/*.{cjs,mjs,js,jsx,ts,tsx}'], diff --git a/packages/editor/package.config.ts b/packages/editor/package.config.ts index 9f67e3880..9c2f489ae 100644 --- a/packages/editor/package.config.ts +++ b/packages/editor/package.config.ts @@ -24,6 +24,12 @@ export default defineConfig({ noImplicitSideEffects: 'error', }, babel: {reactCompiler: true}, - reactCompilerOptions: {target: '19'}, + reactCompilerOptions: { + target: '19', + sources: (filename: string) => + !filename.includes('/src/slate/') && + !filename.includes('/src/slate-dom/') && + !filename.includes('/src/slate-react/'), + }, dts: 'rolldown', }) diff --git a/packages/editor/package.json b/packages/editor/package.json index 53ed041b8..fabd22908 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -84,6 +84,7 @@ "test:watch": "vitest" }, "dependencies": { + "@juggle/resize-observer": "^3.4.0", "@portabletext/block-tools": "workspace:^", "@portabletext/keyboard-shortcuts": "workspace:^", "@portabletext/markdown": "workspace:^", @@ -94,9 +95,8 @@ "@sanity/types": "^5.9.0", "@xstate/react": "^6.0.0", "debug": "^4.4.3", - "slate": "^0.120.0", - "slate-dom": "^0.119.0", - "slate-react": "^0.120.0", + "is-hotkey": "^0.2.0", + "scroll-into-view-if-needed": "^3.1.0", "xstate": "^5.25.0" }, "devDependencies": { @@ -106,6 +106,7 @@ "@sanity/pkg-utils": "^10.2.1", "@sanity/tsconfig": "^2.1.0", "@types/debug": "^4.1.12", + "@types/is-hotkey": "^0.1.10", "@types/node": "^20", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/packages/editor/src/behaviors/behavior.abstract.keyboard.ts b/packages/editor/src/behaviors/behavior.abstract.keyboard.ts index c669de6cc..fd4c7fb1e 100644 --- a/packages/editor/src/behaviors/behavior.abstract.keyboard.ts +++ b/packages/editor/src/behaviors/behavior.abstract.keyboard.ts @@ -2,6 +2,7 @@ import {createKeyboardShortcut} from '@portabletext/keyboard-shortcuts' import {isTextBlock} from '@portabletext/schema' import {defaultKeyboardShortcuts} from '../editor/default-keyboard-shortcuts' import {getFocusBlock} from '../selectors/selector.get-focus-block' +import {getFocusBlockObject} from '../selectors/selector.get-focus-block-object' import {getFocusInlineObject} from '../selectors/selector.get-focus-inline-object' import {getPreviousBlock} from '../selectors/selector.get-previous-block' import {isSelectionCollapsed} from '../selectors/selector.is-selection-collapsed' @@ -52,6 +53,32 @@ export const abstractKeyboardBehaviors = [ actions: [() => [raise({type: 'delete.forward', unit: 'character'})]], }), + /** + * When Backspace is pressed on a block object, raise a `delete.backward` + * event. With childless voids, some browsers (WebKit) don't fire + * `beforeinput` on contentEditable=false elements, so the keydown + * handler must initiate the delete. + */ + defineBehavior({ + on: 'keyboard.keydown', + guard: ({snapshot, event}) => + defaultKeyboardShortcuts.backspace.guard(event.originEvent) && + getFocusBlockObject(snapshot), + actions: [() => [raise({type: 'delete.backward', unit: 'character'})]], + }), + + /** + * When Delete is pressed on a block object, raise a `delete.forward` + * event. Same rationale as above. + */ + defineBehavior({ + on: 'keyboard.keydown', + guard: ({snapshot, event}) => + defaultKeyboardShortcuts.delete.guard(event.originEvent) && + getFocusBlockObject(snapshot), + actions: [() => [raise({type: 'delete.forward', unit: 'character'})]], + }), + defineBehavior({ on: 'keyboard.keydown', guard: ({event}) => diff --git a/packages/editor/src/editor/Editable.tsx b/packages/editor/src/editor/Editable.tsx index 281ccde29..68f588103 100644 --- a/packages/editor/src/editor/Editable.tsx +++ b/packages/editor/src/editor/Editable.tsx @@ -11,19 +11,19 @@ import { type KeyboardEvent, type TextareaHTMLAttributes, } from 'react' -import {Editor, Transforms, type Text} from 'slate' +import {debug} from '../internal-utils/debug' +import {getEventPosition} from '../internal-utils/event-position' +import {normalizeSelection} from '../internal-utils/selection' +import {slateRangeToSelection} from '../internal-utils/slate-utils' +import {toSlateRange} from '../internal-utils/to-slate-range' +import {Editor, Transforms, type Text} from '../slate' import { ReactEditor, Editable as SlateEditable, useSlate, type RenderElementProps, type RenderLeafProps, -} from 'slate-react' -import {debug} from '../internal-utils/debug' -import {getEventPosition} from '../internal-utils/event-position' -import {normalizeSelection} from '../internal-utils/selection' -import {slateRangeToSelection} from '../internal-utils/slate-utils' -import {toSlateRange} from '../internal-utils/to-slate-range' +} from '../slate-react' import type { EditorSelection, OnCopyFn, diff --git a/packages/editor/src/editor/create-editable-api.ts b/packages/editor/src/editor/create-editable-api.ts index 654e1fdda..33a26f8f1 100644 --- a/packages/editor/src/editor/create-editable-api.ts +++ b/packages/editor/src/editor/create-editable-api.ts @@ -4,8 +4,6 @@ import { type PortableTextChild, type PortableTextObject, } from '@portabletext/schema' -import {Editor, Range, Text, Transforms} from 'slate' -import {ReactEditor} from 'slate-react' import { isListItemActive, isStyleActive, @@ -18,6 +16,8 @@ import {getFocusBlock} from '../selectors/selector.get-focus-block' import {getFocusSpan} from '../selectors/selector.get-focus-span' import {getSelectedValue} from '../selectors/selector.get-selected-value' import {isActiveAnnotation} from '../selectors/selector.is-active-annotation' +import {Editor, Range, Text, Transforms} from '../slate' +import {ReactEditor} from '../slate-react' import type { EditableAPI, EditableAPIDeleteOptions, diff --git a/packages/editor/src/editor/create-slate-editor.tsx b/packages/editor/src/editor/create-slate-editor.tsx index 02c9066ce..bea201331 100644 --- a/packages/editor/src/editor/create-slate-editor.tsx +++ b/packages/editor/src/editor/create-slate-editor.tsx @@ -1,9 +1,10 @@ -import {createEditor, type Descendant} from 'slate' -import {withReact} from 'slate-react' import {buildIndexMaps} from '../internal-utils/build-index-maps' import {createPlaceholderBlock} from '../internal-utils/create-placeholder-block' import {debug} from '../internal-utils/debug' +import {createEditor, type Descendant} from '../slate' import {plugins} from '../slate-plugins/slate-plugins' +import {withReact} from '../slate-react' +import {setSpanTypeName} from '../slate/span-type-config' import type {PortableTextSlateEditor} from '../types/slate-editor' import type {EditorActor} from './editor-machine' import type {RelayActor} from './relay-machine' @@ -22,9 +23,10 @@ export type SlateEditor = { export function createSlateEditor(config: SlateEditorConfig): SlateEditor { debug.setup('creating new slate editor instance') - const placeholderBlock = createPlaceholderBlock( - config.editorActor.getSnapshot().context, - ) + const context = config.editorActor.getSnapshot().context + setSpanTypeName(context.schema.span.name) + + const placeholderBlock = createPlaceholderBlock(context) const editor = createEditor() @@ -56,7 +58,7 @@ export function createSlateEditor(config: SlateEditorConfig): SlateEditor { buildIndexMaps( { - schema: config.editorActor.getSnapshot().context.schema, + schema: context.schema, value: instance.value, }, { diff --git a/packages/editor/src/editor/editor-dom.ts b/packages/editor/src/editor/editor-dom.ts index 1473f37cb..ebcf3ef24 100644 --- a/packages/editor/src/editor/editor-dom.ts +++ b/packages/editor/src/editor/editor-dom.ts @@ -1,8 +1,8 @@ -import {Editor} from 'slate' -import {DOMEditor} from 'slate-dom' import type {BehaviorEvent} from '../behaviors/behavior.types.event' import {toSlateRange} from '../internal-utils/to-slate-range' import {getSelectionEndBlock, getSelectionStartBlock} from '../selectors' +import {Editor} from '../slate' +import {DOMEditor} from '../slate-dom' import type {PickFromUnion} from '../type-utils' import type {PortableTextSlateEditor} from '../types/slate-editor' import type {EditorSnapshot} from './editor-snapshot' diff --git a/packages/editor/src/editor/editor-machine.ts b/packages/editor/src/editor/editor-machine.ts index 89325dae5..60b7fb4c4 100644 --- a/packages/editor/src/editor/editor-machine.ts +++ b/packages/editor/src/editor/editor-machine.ts @@ -1,8 +1,5 @@ import type {Patch} from '@portabletext/patches' import type {PortableTextBlock} from '@portabletext/schema' -import {Transforms} from 'slate' -import {EDITOR_TO_PENDING_SELECTION} from 'slate-dom' -import {ReactEditor} from 'slate-react' import { assertEvent, assign, @@ -22,6 +19,9 @@ import type {Converter} from '../converters/converter.types' import {debug} from '../internal-utils/debug' import type {EventPosition} from '../internal-utils/event-position' import {sortByPriority} from '../priority/priority.sort' +import {Transforms} from '../slate' +import {EDITOR_TO_PENDING_SELECTION} from '../slate-dom' +import {ReactEditor} from '../slate-react' import type {NamespaceEvent, OmitFromUnion} from '../type-utils' import type { EditorSelection, diff --git a/packages/editor/src/editor/editor-provider.tsx b/packages/editor/src/editor/editor-provider.tsx index a63005514..ca01dd13a 100644 --- a/packages/editor/src/editor/editor-provider.tsx +++ b/packages/editor/src/editor/editor-provider.tsx @@ -1,8 +1,8 @@ import type React from 'react' import {useEffect, useState} from 'react' -import {Slate} from 'slate-react' import type {EditorConfig} from '../editor' import {stopActor} from '../internal-utils/stop-actor' +import {Slate} from '../slate-react' import {createInternalEditor} from './create-editor' import {EditorActorContext} from './editor-actor-context' import {EditorContext} from './editor-context' diff --git a/packages/editor/src/editor/mutation-machine.ts b/packages/editor/src/editor/mutation-machine.ts index d2c58b256..b6c1e30a7 100644 --- a/packages/editor/src/editor/mutation-machine.ts +++ b/packages/editor/src/editor/mutation-machine.ts @@ -1,6 +1,5 @@ import type {Patch} from '@portabletext/patches' import type {PortableTextBlock} from '@portabletext/schema' -import {Editor} from 'slate' import type {ActorRefFrom} from 'xstate' import { and, @@ -15,6 +14,7 @@ import { type AnyEventObject, } from 'xstate' import {debug} from '../internal-utils/debug' +import {Editor} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' import type {EditorSchema} from './editor-schema' import type {PatchEvent} from './relay-machine' diff --git a/packages/editor/src/editor/perform-hotkey.ts b/packages/editor/src/editor/perform-hotkey.ts index a958d519f..88ac0d577 100644 --- a/packages/editor/src/editor/perform-hotkey.ts +++ b/packages/editor/src/editor/perform-hotkey.ts @@ -1,6 +1,6 @@ import type {KeyboardEvent} from 'react' -import type {Editor} from 'slate' import {isHotkey} from '../internal-utils/is-hotkey' +import type {Editor} from '../slate' import type {HotkeyOptions} from '../types/options' import type {EditorActor} from './editor-machine' import type {PortableTextEditor} from './PortableTextEditor' diff --git a/packages/editor/src/editor/range-decorations-machine.ts b/packages/editor/src/editor/range-decorations-machine.ts index eb5226e6d..ffc3aa090 100644 --- a/packages/editor/src/editor/range-decorations-machine.ts +++ b/packages/editor/src/editor/range-decorations-machine.ts @@ -1,11 +1,3 @@ -import { - Element, - Path, - Range, - type BaseRange, - type NodeEntry, - type Operation, -} from 'slate' import { and, assign, @@ -18,6 +10,14 @@ import {isDeepEqual} from '../internal-utils/equality' import {moveRangeByOperation} from '../internal-utils/move-range-by-operation' import {slateRangeToSelection} from '../internal-utils/slate-utils' import {toSlateRange} from '../internal-utils/to-slate-range' +import { + Element, + Path, + Range, + type BaseRange, + type NodeEntry, + type Operation, +} from '../slate' import type {RangeDecoration} from '../types/editor' import type {PortableTextSlateEditor} from '../types/slate-editor' import {isEmptyTextBlock} from '../utils' @@ -393,7 +393,11 @@ function createDecorate( return [] } - if (!Element.isElement(node) || node.children.length === 0) { + if ( + !Element.isElement(node) || + !node.children || + node.children.length === 0 + ) { return [] } @@ -403,11 +407,13 @@ function createDecorate( return [] } + const children = node.children + return slateEditor.decoratedRanges.filter((decoratedRange) => { // Special case in order to only return one decoration for collapsed ranges if (Range.isCollapsed(decoratedRange)) { // Collapsed ranges should only be decorated if they are on a block child level (length 2) - return node.children.some( + return children.some( (_, childIndex) => Path.equals(decoratedRange.anchor.path, [blockIndex, childIndex]) && Path.equals(decoratedRange.focus.path, [blockIndex, childIndex]), diff --git a/packages/editor/src/editor/render.block-object.tsx b/packages/editor/src/editor/render.block-object.tsx index 4d7863ffa..f063f433c 100644 --- a/packages/editor/src/editor/render.block-object.tsx +++ b/packages/editor/src/editor/render.block-object.tsx @@ -1,8 +1,8 @@ import type {PortableTextObject} from '@portabletext/schema' import {useContext, useRef, type ReactElement} from 'react' -import type {Element as SlateElement} from 'slate' -import type {RenderElementProps} from 'slate-react' import type {DropPosition} from '../behaviors/behavior.core.drop-position' +import type {Element as SlateElement} from '../slate' +import type {RenderElementProps} from '../slate-react' import type { BlockRenderProps, PortableTextMemberSchemaTypes, diff --git a/packages/editor/src/editor/render.element.tsx b/packages/editor/src/editor/render.element.tsx index 6c3b9fbaf..45add4fce 100644 --- a/packages/editor/src/editor/render.element.tsx +++ b/packages/editor/src/editor/render.element.tsx @@ -1,9 +1,9 @@ import {isTextBlock} from '@portabletext/schema' import {useSelector} from '@xstate/react' import {useContext, type ReactElement} from 'react' -import type {Element as SlateElement} from 'slate' -import {useSlateStatic, type RenderElementProps} from 'slate-react' import type {DropPosition} from '../behaviors/behavior.core.drop-position' +import type {Element as SlateElement} from '../slate' +import {useSlateStatic, type RenderElementProps} from '../slate-react' import type { RenderBlockFunction, RenderChildFunction, @@ -34,8 +34,9 @@ export function RenderElement(props: { ) const slateStatic = useSlateStatic() - const isInline = - '__inline' in props.element && props.element.__inline === true + const isInline = schema.inlineObjects.some( + (obj) => obj.name === props.element._type, + ) if (isInline) { return ( diff --git a/packages/editor/src/editor/render.inline-object.tsx b/packages/editor/src/editor/render.inline-object.tsx index e8ac53ab2..5819de972 100644 --- a/packages/editor/src/editor/render.inline-object.tsx +++ b/packages/editor/src/editor/render.inline-object.tsx @@ -1,8 +1,8 @@ import {useContext, useRef, type ReactElement} from 'react' -import type {Element as SlateElement} from 'slate' -import {DOMEditor} from 'slate-dom' -import {useSlateStatic, type RenderElementProps} from 'slate-react' import {getPointBlock} from '../internal-utils/slate-utils' +import type {Element as SlateElement} from '../slate' +import {DOMEditor} from '../slate-dom' +import {useSlateStatic, type RenderElementProps} from '../slate-react' import type { BlockChildRenderProps, PortableTextMemberSchemaTypes, @@ -63,13 +63,8 @@ export function RenderInlineObject(props: { ? selectionState.focusedChildPath === serializedPath : false - const inlineObject = { - _key: props.element._key, - _type: props.element._type, - ...('value' in props.element && typeof props.element.value === 'object' - ? props.element.value - : {}), - } + // Strip the void-child `children` array to get the PT inline object + const {children: _voidChildren, ...inlineObject} = props.element return ( ['renderText']> diff --git a/packages/editor/src/editor/selection-state-context.tsx b/packages/editor/src/editor/selection-state-context.tsx index 64f26f436..976acc616 100644 --- a/packages/editor/src/editor/selection-state-context.tsx +++ b/packages/editor/src/editor/selection-state-context.tsx @@ -1,11 +1,11 @@ import {useSelector} from '@xstate/react' import {createContext, useContext} from 'react' -import {useSlateStatic} from 'slate-react' import {getFocusChild} from '../selectors' import {getSelectedChildren} from '../selectors/selector.get-selected-children' import {getSelectionEndPoint} from '../selectors/selector.get-selection-end-point' import {getSelectionStartPoint} from '../selectors/selector.get-selection-start-point' import {isSelectionCollapsed} from '../selectors/selector.is-selection-collapsed' +import {useSlateStatic} from '../slate-react' import {getBlockKeyFromSelectionPoint} from '../utils/util.selection-point' import {serializePath} from '../utils/util.serialize-path' import {EditorActorContext} from './editor-actor-context' diff --git a/packages/editor/src/editor/sync-machine.ts b/packages/editor/src/editor/sync-machine.ts index adc9874fd..04b9857e2 100644 --- a/packages/editor/src/editor/sync-machine.ts +++ b/packages/editor/src/editor/sync-machine.ts @@ -1,13 +1,5 @@ import type {Patch} from '@portabletext/patches' import {isSpan, type PortableTextBlock} from '@portabletext/schema' -import { - deleteText, - Editor, - Text, - Transforms, - type Descendant, - type Node, -} from 'slate' import type {ActorRefFrom} from 'xstate' import { and, @@ -28,7 +20,15 @@ import { isEqualValues, } from '../internal-utils/equality' import {validateValue} from '../internal-utils/validateValue' -import {toSlateBlock, VOID_CHILD_KEY} from '../internal-utils/values' +import {toSlateBlock} from '../internal-utils/values' +import { + deleteText, + Editor, + Text, + Transforms, + type Descendant, + type Node, +} 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' @@ -957,21 +957,6 @@ function updateBlock({ }) slateEditor.onChange() - } else if (!isSpanNode) { - // If it's a inline block, also update the void text node key - debug.syncValue( - 'Updating changed inline object child', - currentBlockChild, - ) - - Transforms.setNodes( - slateEditor, - {_key: VOID_CHILD_KEY}, - { - at: [...path, 0], - voids: true, - }, - ) } } else if (oldBlockChild) { debug.syncValue('Replacing child', currentBlockChild) diff --git a/packages/editor/src/editor/undo-step.ts b/packages/editor/src/editor/undo-step.ts index f827424f4..8fa4577d6 100644 --- a/packages/editor/src/editor/undo-step.ts +++ b/packages/editor/src/editor/undo-step.ts @@ -1,4 +1,4 @@ -import {Path, type Operation} from 'slate' +import {Path, type Operation} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' type UndoStep = { diff --git a/packages/editor/src/editor/validate-selection-machine.ts b/packages/editor/src/editor/validate-selection-machine.ts index 841822796..3a375d64a 100644 --- a/packages/editor/src/editor/validate-selection-machine.ts +++ b/packages/editor/src/editor/validate-selection-machine.ts @@ -1,7 +1,7 @@ -import {Editor, Transforms} from 'slate' -import {ReactEditor} from 'slate-react' import {setup} from 'xstate' import {debug} from '../internal-utils/debug' +import {Editor, Transforms} from '../slate' +import {ReactEditor} from '../slate-react' import type {PortableTextSlateEditor} from '../types/slate-editor' const validateSelectionSetup = setup({ diff --git a/packages/editor/src/internal-utils/__tests__/ranges.test.ts b/packages/editor/src/internal-utils/__tests__/ranges.test.ts index 2041edcd7..972decedf 100644 --- a/packages/editor/src/internal-utils/__tests__/ranges.test.ts +++ b/packages/editor/src/internal-utils/__tests__/ranges.test.ts @@ -1,5 +1,5 @@ -import type {InsertTextOperation, Range} from 'slate' import {describe, expect, it} from 'vitest' +import type {InsertTextOperation, Range} from '../../slate' import {moveRangeByOperation} from '../move-range-by-operation' describe('moveRangeByOperation', () => { diff --git a/packages/editor/src/internal-utils/__tests__/values.test.ts b/packages/editor/src/internal-utils/__tests__/values.test.ts index bf63ad045..fd38efcef 100644 --- a/packages/editor/src/internal-utils/__tests__/values.test.ts +++ b/packages/editor/src/internal-utils/__tests__/values.test.ts @@ -5,7 +5,7 @@ import {toSlateBlock} from '../values' const schemaTypes = compileSchema(defineSchema({})) describe(toSlateBlock.name, () => { - it('given type is custom with no custom properties, should include an empty text property in children and an empty value', () => { + it('given type is custom with no custom properties, should be a childless void element', () => { const result = toSlateBlock( { _type: 'image', @@ -14,15 +14,9 @@ describe(toSlateBlock.name, () => { {schemaTypes}, ) - expect(result).toMatchObject({ + expect(result).toEqual({ _key: '123', _type: 'image', - children: [ - { - text: '', - }, - ], - value: {}, }) }) @@ -49,6 +43,7 @@ describe(toSlateBlock.name, () => { _key: '1231', _type: 'span', text: '123', + marks: [], }, ], }) @@ -85,23 +80,13 @@ describe(toSlateBlock.name, () => { _key: '1231', _type: 'span', text: '123', + marks: [], }, { - __inline: true, _key: '1232', _type: 'image', - children: [ - { - _key: 'void-child', - _type: 'span', - marks: [], - text: '', - }, - ], - value: { - asset: { - _ref: 'ref-123', - }, + asset: { + _ref: 'ref-123', }, }, ], diff --git a/packages/editor/src/internal-utils/apply-operation-to-portable-text.test.ts b/packages/editor/src/internal-utils/apply-operation-to-portable-text.test.ts index 872278c11..922bf230c 100644 --- a/packages/editor/src/internal-utils/apply-operation-to-portable-text.test.ts +++ b/packages/editor/src/internal-utils/apply-operation-to-portable-text.test.ts @@ -2,7 +2,6 @@ import {compileSchema, defineSchema} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test} from 'vitest' import {applyOperationToPortableText} from './apply-operation-to-portable-text' -import {VOID_CHILD_KEY} from './values' function createContext() { const keyGenerator = createTestKeyGenerator() @@ -120,8 +119,7 @@ describe(applyOperationToPortableText.name, () => { node: { _type: 'image', _key: k2, - children: [{text: '', _key: VOID_CHILD_KEY, _type: 'span'}], - value: {src: 'https://example.com/image.jpg'}, + src: 'https://example.com/image.jpg', }, }, ), @@ -194,9 +192,7 @@ describe(applyOperationToPortableText.name, () => { node: { _type: 'stock-ticker', _key: k2, - __inline: true, - children: [{text: '', _key: VOID_CHILD_KEY, _type: 'span'}], - value: {symbol: 'AAPL'}, + symbol: 'AAPL', }, }, ), @@ -568,8 +564,6 @@ describe(applyOperationToPortableText.name, () => { const k0 = keyGenerator() const k1 = keyGenerator() const k2 = keyGenerator() - const voidChild = keyGenerator() - expect( applyOperationToPortableText( createContext(), @@ -591,8 +585,7 @@ describe(applyOperationToPortableText.name, () => { node: { _type: 'image', _key: k2, - children: [{_type: 'span', _key: voidChild, text: ''}], - value: {src: 'https://example.com/image.jpg'}, + src: 'https://example.com/image.jpg', }, }, ), @@ -611,7 +604,6 @@ describe(applyOperationToPortableText.name, () => { const k1 = keyGenerator() const k2 = keyGenerator() const k3 = keyGenerator() - const voidChild = keyGenerator() expect( applyOperationToPortableText( @@ -633,9 +625,7 @@ describe(applyOperationToPortableText.name, () => { node: { _type: 'stock-ticker', _key: k2, - __inline: true, - children: [{_type: 'span', _key: voidChild, text: ''}], - value: {symbol: 'AAPL'}, + symbol: 'AAPL', }, }, ), @@ -1289,7 +1279,7 @@ describe(applyOperationToPortableText.name, () => { path: [0], properties: {}, newProperties: { - value: {src: 'https://example.com/image.jpg'}, + src: 'https://example.com/image.jpg', }, }, ), @@ -1320,13 +1310,11 @@ describe(applyOperationToPortableText.name, () => { type: 'set_node', path: [0], properties: { - value: {src: 'https://example.com/image.jpg'}, + src: 'https://example.com/image.jpg', }, newProperties: { - value: { - src: 'https://example.com/image.jpg', - alt: 'An image', - }, + src: 'https://example.com/image.jpg', + alt: 'An image', }, }, ), @@ -1352,11 +1340,9 @@ describe(applyOperationToPortableText.name, () => { type: 'set_node', path: [0], properties: { - value: { - alt: 'An image', - }, + alt: 'An image', }, - newProperties: {value: {}}, + newProperties: {alt: undefined}, }, ), ).toEqual([{_type: 'image', _key: k0}]) @@ -1430,9 +1416,7 @@ describe(applyOperationToPortableText.name, () => { path: [0, 1], properties: {}, newProperties: { - value: { - symbol: 'AAPL', - }, + symbol: 'AAPL', }, }, ), @@ -1639,10 +1623,10 @@ describe(applyOperationToPortableText.name, () => { type: 'set_node', path: [0], properties: { - value: {text: 'h'}, + text: 'h', }, newProperties: { - value: {text: 'hello'}, + text: 'hello', }, }, ), @@ -1692,10 +1676,10 @@ describe(applyOperationToPortableText.name, () => { type: 'set_node', path: [0, 1], properties: { - value: {text: 'J'}, + text: 'J', }, newProperties: { - value: {text: 'John Doe'}, + text: 'John Doe', }, }, ), diff --git a/packages/editor/src/internal-utils/apply-operation-to-portable-text.ts b/packages/editor/src/internal-utils/apply-operation-to-portable-text.ts index b76abafa8..a81a17ec8 100644 --- a/packages/editor/src/internal-utils/apply-operation-to-portable-text.ts +++ b/packages/editor/src/internal-utils/apply-operation-to-portable-text.ts @@ -1,7 +1,7 @@ import type {PortableTextBlock} from '@portabletext/schema' -import {Element, Path, type Node, type Operation} from 'slate' import type {EditorSchema} from '../editor/editor-schema' import type {EditorContext} from '../editor/editor-snapshot' +import {Element, Path, type Node, type Operation} from '../slate' import type {OmitFromUnion} from '../type-utils' import { getBlock, @@ -9,7 +9,6 @@ import { getParent, getSpan, isEditorNode, - isObjectNode, isPartialSpanNode, isSpanNode, isTextBlockNode, @@ -62,20 +61,14 @@ function applyOperationToPortableTextImmutable( // Inserting block at the root if (isTextBlockNode(context, insertedNode)) { - // Text blocks can be inserted as is + // Text blocks: strip void-child `children` from inline objects const newBlock = { ...insertedNode, children: insertedNode.children.map((child) => { - if ('__inline' in child) { - // Except for inline object children which need to have their - // `value` spread onto the block - return { - _key: child._key, - _type: child._type, - ...('value' in child && typeof child['value'] === 'object' - ? child['value'] - : {}), - } + if (Element.isElement(child)) { + // Inline object: strip void-child `children` + const {children: _c, ...rest} = child + return rest } return child @@ -88,16 +81,9 @@ function applyOperationToPortableTextImmutable( } } - if (Element.isElement(insertedNode) && !('__inline' in insertedNode)) { - // Void blocks have to have their `value` spread onto the block - const newBlock = { - _key: insertedNode._key, - _type: insertedNode._type, - ...('value' in insertedNode && - typeof insertedNode.value === 'object' - ? insertedNode.value - : {}), - } + if (Element.isElement(insertedNode)) { + // Block object: strip void-child `children` + const {children: _c, ...newBlock} = insertedNode return { ...root, @@ -120,16 +106,10 @@ function applyOperationToPortableTextImmutable( if (isPartialSpanNode(context, insertedNode)) { // Text nodes can be inserted as is newChild = insertedNode - } else if ('__inline' in insertedNode) { - // Void children have to have their `value` spread onto the block - newChild = { - _key: insertedNode._key, - _type: insertedNode._type, - ...('value' in insertedNode && - typeof insertedNode.value === 'object' - ? insertedNode.value - : {}), - } + } else if (Element.isElement(insertedNode)) { + // Inline object: strip void-child `children` + const {children: _c, ...rest} = insertedNode + newChild = rest as ObjectNode } else { return root } @@ -362,30 +342,20 @@ function applyOperationToPortableTextImmutable( const node = getNode(context, root, path) - if (!node) { - return root - } - - if (isEditorNode(node)) { + if (!node || isEditorNode(node)) { return root } - if (isObjectNode(context, node)) { - const valueBefore = ( - 'value' in properties && typeof properties.value === 'object' - ? properties.value - : {} - ) as Partial - const valueAfter = ( - 'value' in newProperties && typeof newProperties.value === 'object' - ? newProperties.value - : {} - ) as Partial - + // Route by path length first, then by node type. + if (path.length === 1) { + // Block level: text blocks or block objects const newNode = {...node} + const skipKeys = isTextBlockNode(context, node) + ? ['children', 'text'] + : ['children'] for (const key in newProperties) { - if (key === 'value') { + if (skipKeys.includes(key)) { continue } @@ -399,7 +369,7 @@ function applyOperationToPortableTextImmutable( } for (const key in properties) { - if (key === 'value') { + if (skipKeys.includes(key)) { continue } @@ -408,44 +378,25 @@ function applyOperationToPortableTextImmutable( } } - for (const key in valueAfter) { - const value = valueAfter[key as keyof Partial] - - if (value == null) { - delete newNode[key] - } else { - newNode[key] = value - } - } - - for (const key in valueBefore) { - if (!valueAfter.hasOwnProperty(key)) { - delete newNode[key] - } - } - - if (path.length === 1) { - return { - ...root, - children: replaceChild(root.children, path[0]!, newNode), - } - } - - if (path.length === 2) { - return updateTextBlockAtIndex(context, root, path[0]!, (block) => ({ - ...block, - children: replaceChild(block.children, path[1]!, newNode), - })) + return { + ...root, + children: replaceChild( + root.children, + path[0]!, + newNode as (typeof root.children)[number], + ), } - - return root } - if (isTextBlockNode(context, node)) { + if (path.length === 2) { + // Child level: spans (have `text`) or object nodes (everything + // else, including transient _type: 'block' during normalization) const newNode = {...node} + const isSpan = isPartialSpanNode(context, node) + const skipKeys = isSpan ? ['text'] : ['children'] for (const key in newProperties) { - if (key === 'children' || key === 'text') { + if (skipKeys.includes(key)) { continue } @@ -458,38 +409,11 @@ function applyOperationToPortableTextImmutable( } } - // properties that were previously defined, but are now missing, must be deleted for (const key in properties) { - if (!newProperties.hasOwnProperty(key)) { - delete newNode[key] - } - } - - return { - ...root, - children: replaceChild(root.children, path[0]!, newNode), - } - } - - if (isPartialSpanNode(context, node)) { - const newNode = {...node} - - for (const key in newProperties) { - if (key === 'text') { + if (skipKeys.includes(key)) { continue } - const value = newProperties[key as keyof Partial] - - if (value == null) { - delete newNode[key] - } else { - newNode[key] = value - } - } - - // properties that were previously defined, but are now missing, must be deleted - for (const key in properties) { if (!newProperties.hasOwnProperty(key)) { delete newNode[key] } @@ -497,7 +421,11 @@ function applyOperationToPortableTextImmutable( return updateTextBlockAtIndex(context, root, path[0]!, (block) => ({ ...block, - children: replaceChild(block.children, path[1]!, newNode), + children: replaceChild( + block.children, + path[1]!, + newNode as (typeof block.children)[number], + ), })) } diff --git a/packages/editor/src/internal-utils/applyPatch.ts b/packages/editor/src/internal-utils/applyPatch.ts index eb2c6a580..c07c0de55 100644 --- a/packages/editor/src/internal-utils/applyPatch.ts +++ b/packages/editor/src/internal-utils/applyPatch.ts @@ -17,13 +17,48 @@ import { makeDiff, parsePatch, } from '@sanity/diff-match-patch' -import {Editor, Element, Node, Text, Transforms, type Descendant} from 'slate' import type {EditorContext} from '../editor/editor-snapshot' +import { + Editor, + Element, + Node, + Text, + Transforms, + type Descendant, +} from '../slate' import type {Path} from '../types/paths' import type {PortableTextSlateEditor} from '../types/slate-editor' import {isKeyedSegment} from '../utils/util.is-keyed-segment' +import {isDeepEqual} from './equality' import {isEqualToEmptyEditor, toSlateBlock} from './values' +/** + * Compute the new value for a property after applying a patch. + * Uses `applyAll` to handle nested paths and `setIfMissing` semantics. + * Returns `undefined` if the value didn't change (noop). + */ +function computeNewPropertyValue( + currentNode: Record, + propEntry: string, + patch: SetPatch | SetIfMissingPatch, + pathOffset: number, +): {oldValue: unknown; newValue: unknown} | undefined { + const oldValue = currentNode[propEntry] + const subPatch = { + ...patch, + path: patch.path.slice(pathOffset), + } + const updated = applyAll({[propEntry]: oldValue}, [subPatch]) + const newValue = (updated as Record)[propEntry] + + // Check if the value actually changed + if (isDeepEqual(oldValue, newValue)) { + return undefined + } + + return {oldValue, newValue} +} + /** * Creates a function that can apply a patch onto a PortableTextSlateEditor. */ @@ -203,7 +238,11 @@ function insertPatch( position === 'after' ? targetChild.index + 1 : targetChild.index const childInsertPath = [block.index, normalizedIdx] - if (childrenToInsert && Element.isElement(childrenToInsert)) { + if ( + childrenToInsert && + Element.isElement(childrenToInsert) && + childrenToInsert.children + ) { Transforms.insertNodes(editor, childrenToInsert.children, { at: childInsertPath, }) @@ -254,9 +293,11 @@ function setPatch( } // Insert the new children - Transforms.insertNodes(editor, updatedBlock.children, { - at: [block.index, 0], - }) + if (updatedBlock.children) { + Transforms.insertNodes(editor, updatedBlock.children, { + at: [block.index, 0], + }) + } // Restore the selection if (previousSelection) { @@ -277,16 +318,24 @@ function setPatch( } if (isTextBlock && patch.path[1] !== 'children') { - const updatedBlock = applyAll(block.node, [ - { - ...patch, - path: patch.path.slice(1), - }, - ]) + const propEntry = patch.path[1] + if (typeof propEntry === 'string') { + const result = computeNewPropertyValue( + block.node as Record, + propEntry, + patch, + 1, + ) - Transforms.setNodes(editor, updatedBlock as Partial, { - at: [block.index], - }) + if (result) { + editor.apply({ + type: 'set_node', + path: [block.index], + properties: {[propEntry]: result.oldValue}, + newProperties: {[propEntry]: result.newValue}, + }) + } + } return true } @@ -296,13 +345,18 @@ function setPatch( // If this is targeting a text block child if (isTextBlock && child) { if (Text.isText(child.node)) { - if (Text.isText(value)) { + if ( + typeof value === 'object' && + value !== null && + 'text' in value && + typeof (value as Record)['text'] === 'string' + ) { if (patch.type === 'setIfMissing') { return false } const oldText = child.node.text - const newText = value.text + const newText = (value as Record)['text'] as string if (oldText !== newText) { editor.apply({ type: 'remove_text', @@ -327,77 +381,136 @@ function setPatch( const propEntry = propPath.at(0) const reservedProps = ['_key', '_type', 'text'] - if (propEntry === undefined) { + if (typeof propEntry !== 'string') { return false } - if ( - typeof propEntry === 'string' && - reservedProps.includes(propEntry) - ) { + if (reservedProps.includes(propEntry)) { return false } - const newNode = applyAll(child.node, [ - { - ...patch, - path: propPath, - }, - ]) + const result = computeNewPropertyValue( + child.node as unknown as Record, + propEntry, + patch, + 3, + ) - Transforms.setNodes(editor, newNode, {at: [block.index, child.index]}) + if (result) { + editor.apply({ + type: 'set_node', + path: [block.index, child.index], + properties: {[propEntry]: result.oldValue}, + newProperties: {[propEntry]: result.newValue}, + }) + } } } else { // Setting inline object property const propPath = patch.path.slice(3) - const reservedProps = ['_key', '_type', 'children', '__inline'] + const reservedProps = ['_key', '_type', 'children'] const propEntry = propPath.at(0) - if (propEntry === undefined) { + if (typeof propEntry !== 'string') { return false } - if (typeof propEntry === 'string' && reservedProps.includes(propEntry)) { + if (reservedProps.includes(propEntry)) { return false } - // If the child is an inline object, we need to apply the patch to the - // `value` property object. - const value = - 'value' in child.node && typeof child.node.value === 'object' - ? child.node.value - : {} - - const newValue = applyAll(value, [ - { - ...patch, - path: patch.path.slice(3), - }, - ]) - - Transforms.setNodes( - editor, - {...child.node, value: newValue}, - {at: [block.index, child.index]}, + // Set the property directly on the inline object node. + const result = computeNewPropertyValue( + child.node as Record, + propEntry, + patch, + 3, ) + + if (result) { + // Slate's set_node rejects 'text' and 'children' in newProperties, + // but inline objects can have user-defined fields with those names. + // Use remove_node + insert_node to replace the entire node. + if (propEntry === 'text' || propEntry === 'children') { + const childPath = [block.index, child.index] + const newNode = { + ...(child.node as Record), + [propEntry]: result.newValue, + } + editor.apply({ + type: 'remove_node', + path: childPath, + node: child.node as Descendant, + }) + editor.apply({ + type: 'insert_node', + path: childPath, + node: newNode as unknown as Descendant, + }) + } else { + editor.apply({ + type: 'set_node', + path: [block.index, child.index], + properties: {[propEntry]: result.oldValue}, + newProperties: {[propEntry]: result.newValue}, + }) + } + } } return true - } else if (block && 'value' in block.node) { - if (patch.path.length > 1 && patch.path[1] !== 'children') { - const newVal = applyAll(block.node.value, [ - { - ...patch, - path: patch.path.slice(1), - }, - ]) - - Transforms.setNodes( - editor, - {...block.node, value: newVal}, - {at: [block.index]}, + } else if ( + block && + Element.isElement(block.node) && + !editor.isTextBlock(block.node) + ) { + // Block object: set property directly on the node. + // We use editor.apply instead of Transforms.setNodes because + // setNodes skips 'text' and 'children' properties, but block + // objects can have user-defined fields with those names. + const propEntry = patch.path[1] + if ( + patch.path.length > 1 && + typeof propEntry === 'string' && + propEntry !== '_type' && + propEntry !== '_key' + ) { + const result = computeNewPropertyValue( + block.node as Record, + propEntry, + patch, + 1, ) + + if (result) { + // Slate's set_node rejects 'text' and 'children' in newProperties, + // but block objects can have user-defined fields with those names. + if (propEntry === 'text' || propEntry === 'children') { + const blockPath = [block.index] + const newNode = { + ...(block.node as Record), + [propEntry]: result.newValue, + } + editor.apply({ + type: 'remove_node', + path: blockPath, + node: block.node as Descendant, + }) + editor.apply({ + type: 'insert_node', + path: blockPath, + node: newNode as unknown as Descendant, + }) + } else { + editor.apply({ + type: 'set_node', + path: [block.index], + properties: {[propEntry]: result.oldValue}, + newProperties: {[propEntry]: result.newValue}, + }) + } + } } else { return false } @@ -451,36 +564,37 @@ function unsetPatch(editor: PortableTextSlateEditor, patch: UnsetPatch) { const propPath = patch.path.slice(3) const propEntry = propPath.at(0) - const reservedProps = ['_key', '_type', 'children', '__inline'] + const reservedProps = ['_key', '_type', 'children'] - if (propEntry === undefined) { + if (typeof propEntry !== 'string') { return false } - if (typeof propEntry === 'string' && reservedProps.includes(propEntry)) { - // All custom properties are stored on the `value` property object. - // If you try to unset any of the other top-level properties it's a - // no-op. + if (reservedProps.includes(propEntry)) { return false } - const value = - 'value' in child.node && typeof child.node.value === 'object' - ? child.node.value - : {} - - const newValue = applyAll(value, [ - { - ...patch, - path: patch.path.slice(3), - }, + // Use applyAll to handle nested unset paths + const oldValue = (child.node as Record)[propEntry] + const newNode = applyAll({[propEntry]: oldValue}, [ + {...patch, path: propPath}, ]) + const newValue = (newNode as Record)[propEntry] - Transforms.setNodes( - editor, - {...child.node, value: newValue}, - {at: [block.index, child.index]}, - ) + if (newValue === undefined) { + // Top-level unset: remove the property entirely + Transforms.unsetNodes(editor, [propEntry], { + at: [block.index, child.index], + }) + } else { + // Nested unset: update the property value + editor.apply({ + type: 'set_node', + path: [block.index, child.index], + properties: {[propEntry]: oldValue}, + newProperties: {[propEntry]: newValue}, + }) + } return true } @@ -529,19 +643,36 @@ function unsetPatch(editor: PortableTextSlateEditor, patch: UnsetPatch) { } if (!child) { - if ('value' in block.node) { - const newVal = applyAll(block.node.value, [ - { - ...patch, - path: patch.path.slice(1), - }, - ]) - - Transforms.setNodes( - editor, - {...block.node, value: newVal}, - {at: [block.index]}, - ) + if (Element.isElement(block.node) && !editor.isTextBlock(block.node)) { + // Block object: unset properties + const propPath = patch.path.slice(1) + const propEntry = propPath.at(0) + + if ( + typeof propEntry === 'string' && + propEntry !== '_type' && + propEntry !== '_key' + ) { + // Use applyAll to handle nested unset paths (e.g., ['_map', 'foo']) + const oldValue = (block.node as Record)[propEntry] + const newNode = applyAll({[propEntry]: oldValue}, [ + {...patch, path: propPath}, + ]) + const newValue = (newNode as Record)[propEntry] + + if (newValue === undefined) { + // Top-level unset: remove the property entirely + Transforms.unsetNodes(editor, [propEntry], {at: [block.index]}) + } else { + // Nested unset: update the property value + editor.apply({ + type: 'set_node', + path: [block.index], + properties: {[propEntry]: oldValue}, + newProperties: {[propEntry]: newValue}, + }) + } + } return true } @@ -611,7 +742,7 @@ function findBlockChild( let childIndex = -1 - const child = blockNode.children.find((node, index: number) => { + const child = (blockNode.children ?? []).find((node, index: number) => { const isMatch = isKeyedSegment(path[2]) ? node._key === path[2]._key : index === path[2] diff --git a/packages/editor/src/internal-utils/equality.ts b/packages/editor/src/internal-utils/equality.ts index 628d56c86..209e7325e 100644 --- a/packages/editor/src/internal-utils/equality.ts +++ b/packages/editor/src/internal-utils/equality.ts @@ -4,7 +4,7 @@ import { type PortableTextObject, type Schema, } from '@portabletext/schema' -import type {Descendant} from 'slate' +import type {Descendant} from '../slate' export function isEqualValues( context: {schema: Schema}, diff --git a/packages/editor/src/internal-utils/event-position.ts b/packages/editor/src/internal-utils/event-position.ts index c86ea8d1f..93fee0f5c 100644 --- a/packages/editor/src/internal-utils/event-position.ts +++ b/packages/editor/src/internal-utils/event-position.ts @@ -1,7 +1,7 @@ -import {Editor, type BaseRange, type Node} from 'slate' -import {DOMEditor, isDOMNode} from 'slate-dom' import type {EditorActor} from '../editor/editor-machine' import type {EditorSchema} from '../editor/editor-schema' +import {Editor, type BaseRange, type Node} from '../slate' +import {DOMEditor, isDOMNode} from '../slate-dom' import type {EditorSelection} from '../types/editor' import type {PortableTextSlateEditor} from '../types/slate-editor' import {getBlockEndPoint} from '../utils/util.get-block-end-point' diff --git a/packages/editor/src/internal-utils/move-range-by-operation.ts b/packages/editor/src/internal-utils/move-range-by-operation.ts index b52af3a4b..0cc545f71 100644 --- a/packages/editor/src/internal-utils/move-range-by-operation.ts +++ b/packages/editor/src/internal-utils/move-range-by-operation.ts @@ -1,4 +1,4 @@ -import {Point, type Operation, type Range} from 'slate' +import {Point, type Operation, type Range} from '../slate' export function moveRangeByOperation( range: Range, diff --git a/packages/editor/src/internal-utils/operation-to-patches.test.ts b/packages/editor/src/internal-utils/operation-to-patches.test.ts index 6914d3ade..6fec2082c 100644 --- a/packages/editor/src/internal-utils/operation-to-patches.test.ts +++ b/packages/editor/src/internal-utils/operation-to-patches.test.ts @@ -5,11 +5,11 @@ import { type PortableTextBlock, type PortableTextTextBlock, } from '@portabletext/schema' -import {createEditor, type Descendant} from 'slate' import {beforeEach, describe, expect, it, test} from 'vitest' import {createActor} from 'xstate' import {editorMachine} from '../editor/editor-machine' import {relayMachine} from '../editor/relay-machine' +import {createEditor, type Descendant} from '../slate' import {plugins} from '../slate-plugins/slate-plugins' import {defaultKeyGenerator} from '../utils/key-generator' import { @@ -57,9 +57,7 @@ const createDefaultChildren = () => { _key: '773866318fa8', _type: 'someObject', - value: {title: 'The Object'}, - __inline: true, - children: [{_type: 'span', _key: 'bogus', text: '', marks: []}], + title: 'The Object', }, {_type: 'span', _key: 'fd9b4a4e6c0b', text: '', marks: []}, ], @@ -77,7 +75,7 @@ const createDefaultValue = () => { _key: '773866318fa8', _type: 'someObject', - value: {title: 'The Object'}, + title: 'The Object', }, {_type: 'span', _key: 'fd9b4a4e6c0b', text: '', marks: []}, ], @@ -93,15 +91,6 @@ describe(insertNodePatch.name, () => { { _key: 'k2', _type: 'image', - children: [ - { - _key: 'void-child', - _type: 'span', - marks: [], - text: '', - }, - ], - value: {}, }, ], { @@ -110,15 +99,6 @@ describe(insertNodePatch.name, () => { node: { _key: 'k2', _type: 'image', - children: [ - { - _key: 'void-child', - _type: 'span', - marks: [], - text: '', - }, - ], - value: {}, }, }, [], @@ -223,9 +203,7 @@ describe('operationToPatches', () => { node: { _type: 'someObject', _key: 'c130395c640c', - value: {title: 'The Object'}, - __inline: false, - children: [{_key: '1', _type: 'span', text: '', marks: []}], + title: 'The Object', }, }, createDefaultValue(), @@ -265,9 +243,6 @@ describe('operationToPatches', () => { node: { _type: 'someObject', _key: 'c130395c640c', - value: {}, - __inline: false, - children: [{_key: '1', _type: 'span', text: '', marks: []}], }, }, @@ -308,9 +283,7 @@ describe('operationToPatches', () => { node: { _type: 'someObject', _key: 'c130395c640c', - value: {title: 'The Object'}, - __inline: true, - children: [{_key: '1', _type: 'span', text: '', marks: []}], + title: 'The Object', }, }, @@ -431,9 +404,7 @@ describe('operationToPatches', () => { node: { _key: '773866318fa8', _type: 'someObject', - value: {title: 'The object'}, - __inline: true, - children: [{_type: 'span', _key: 'bogus', text: '', marks: []}], + title: 'The object', }, }, ), @@ -584,9 +555,7 @@ describe('defensive setIfMissing patches', () => { node: { _type: 'someObject', _key: 'new-object', - value: {title: 'New Object'}, - __inline: true, - children: [{_key: '1', _type: 'span', text: '', marks: []}], + title: 'New Object', }, }, createDefaultValue(), diff --git a/packages/editor/src/internal-utils/operation-to-patches.ts b/packages/editor/src/internal-utils/operation-to-patches.ts index e1ae248d8..38f6a11d4 100644 --- a/packages/editor/src/internal-utils/operation-to-patches.ts +++ b/packages/editor/src/internal-utils/operation-to-patches.ts @@ -14,6 +14,7 @@ import { type PortableTextSpan, type PortableTextTextBlock, } from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' import { Element, Text, @@ -26,8 +27,7 @@ import { type RemoveTextOperation, type SetNodeOperation, type SplitNodeOperation, -} from 'slate' -import type {EditorSchema} from '../editor/editor-schema' +} from '../slate' import type {Path} from '../types/paths' import {fromSlateBlock} from './values' @@ -137,36 +137,30 @@ export function setNodePatch( return patches } else { + // Block object: properties are directly on the operation const patches: Patch[] = [] - const _key = operation.newProperties._key - - if (_key !== undefined) { - patches.push(set(_key, [blockIndex, '_key'])) - } - - const newValue = - 'value' in operation.newProperties && - typeof operation.newProperties.value === 'object' - ? (operation.newProperties.value as Record) - : ({} satisfies Record) - - const keys = Object.keys(newValue) + for (const key of Object.keys(operation.newProperties)) { + const value = (operation.newProperties as Record)[key] - for (const key of keys) { - const value = newValue[key] + if (key === 'children') { + // Skip void-child children array + continue + } - patches.push(set(value, [{_key: block._key}, key])) + if (key === '_key') { + patches.push(set(value, [blockIndex, '_key'])) + } else { + patches.push(set(value, [{_key: block._key}, key])) + } } - const value = - 'value' in operation.properties && - typeof operation.properties.value === 'object' - ? (operation.properties.value as Record) - : ({} satisfies Record) + for (const key of Object.keys(operation.properties)) { + if (key === 'children') { + continue + } - for (const key of Object.keys(value)) { - if (!(key in newValue)) { + if (!(key in operation.newProperties)) { patches.push(unset([{_key: block._key}, key])) } } @@ -183,37 +177,36 @@ export function setNodePatch( const patches: Patch[] = [] if (Element.isElement(child)) { - // The child is an inline object. This needs to be treated - // differently since all custom properties are stored on a `value` - // object. - - const _key = operation.newProperties._key - - if (_key !== undefined) { - patches.push( - set(_key, [ - {_key: blockKey}, - 'children', - block.children.indexOf(child), - '_key', - ]), - ) - } - - const properties = - 'value' in operation.newProperties && - typeof operation.newProperties.value === 'object' - ? (operation.newProperties.value as Record) - : ({} satisfies Record) - - const keys = Object.keys(properties) - - for (const key of keys) { - const value = properties[key] - - patches.push( - set(value, [{_key: blockKey}, 'children', {_key: childKey}, key]), - ) + // Inline object: properties are directly on the operation + for (const key of Object.keys(operation.newProperties)) { + const value = (operation.newProperties as Record)[ + key + ] + + if (key === 'children') { + // Skip void-child children array + continue + } + + if (key === '_key') { + patches.push( + set(value, [ + {_key: blockKey}, + 'children', + block.children.indexOf(child), + '_key', + ]), + ) + } else { + patches.push( + set(value, [ + {_key: blockKey}, + 'children', + {_key: childKey}, + key, + ]), + ) + } } return patches @@ -326,27 +319,13 @@ export function insertNodePatch( return [setIfMissingPatch, insert([operation.node], position, path)] } - const _type = operation.node._type - const _key = operation.node._key - const value = - 'value' in operation.node && typeof operation.node.value === 'object' - ? operation.node.value - : ({} satisfies Record) + // Inline object: strip void-child `children`, keep all other props + const {children: _c, ...inlineObjectProps} = operation.node as Record< + string, + unknown + > - return [ - setIfMissingPatch, - insert( - [ - { - _type, - _key, - ...value, - }, - ], - position, - path, - ), - ] + return [setIfMissingPatch, insert([inlineObjectProps], position, path)] } return [] diff --git a/packages/editor/src/internal-utils/sibling-utils.ts b/packages/editor/src/internal-utils/sibling-utils.ts index 3fbe1363e..ca93fa2f7 100644 --- a/packages/editor/src/internal-utils/sibling-utils.ts +++ b/packages/editor/src/internal-utils/sibling-utils.ts @@ -1,5 +1,5 @@ import type {PortableTextSpan} from '@portabletext/schema' -import {Node, Path} from 'slate' +import {Node, Path} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' export function getPreviousSpan({ diff --git a/packages/editor/src/internal-utils/slate-utils.ts b/packages/editor/src/internal-utils/slate-utils.ts index 4a86319a5..f1afdfd9e 100644 --- a/packages/editor/src/internal-utils/slate-utils.ts +++ b/packages/editor/src/internal-utils/slate-utils.ts @@ -1,4 +1,5 @@ import type {PortableTextSpan} from '@portabletext/schema' +import type {EditorSchema} from '../editor/editor-schema' import { Editor, Element, @@ -6,8 +7,7 @@ import { Range, type Point, type Path as SlatePath, -} from 'slate' -import type {EditorSchema} from '../editor/editor-schema' +} from '../slate' import type {EditorSelection, EditorSelectionPoint} from '../types/editor' import type {PortableTextSlateEditor} from '../types/slate-editor' import {fromSlateBlock} from './values' @@ -271,7 +271,7 @@ export function getNodeBlock({ at: [], match: (n) => isBlockElement({editor, schema}, n) && - n.children.some((child) => child._key === node._key), + (n.children ?? []).some((child) => child._key === node._key), }), ) .at(0) diff --git a/packages/editor/src/internal-utils/to-slate-range.test.ts b/packages/editor/src/internal-utils/to-slate-range.test.ts index 2ee412871..317ff8e10 100644 --- a/packages/editor/src/internal-utils/to-slate-range.test.ts +++ b/packages/editor/src/internal-utils/to-slate-range.test.ts @@ -141,8 +141,8 @@ describe(toSlateRange.name, () => { }) expect(range).toEqual({ - anchor: {path: [0, 0], offset: 0}, - focus: {path: [0, 0], offset: 0}, + anchor: {path: [0], offset: 0}, + focus: {path: [0], offset: 0}, }) }) @@ -271,8 +271,8 @@ describe(toSlateRange.name, () => { }) expect(range).toEqual({ - anchor: {path: [0, 1, 0], offset: 0}, - focus: {path: [0, 1, 0], offset: 0}, + anchor: {path: [0, 1], offset: 0}, + focus: {path: [0, 1], offset: 0}, }) }) }) diff --git a/packages/editor/src/internal-utils/to-slate-range.ts b/packages/editor/src/internal-utils/to-slate-range.ts index 92ec04ad8..92a30e4b3 100644 --- a/packages/editor/src/internal-utils/to-slate-range.ts +++ b/packages/editor/src/internal-utils/to-slate-range.ts @@ -4,8 +4,8 @@ import { type PortableTextObject, type PortableTextSpan, } from '@portabletext/schema' -import type {Path, Range} from 'slate' import type {EditorContext, EditorSnapshot} from '../editor/editor-snapshot' +import type {Path, Range} from '../slate' import type {EditorSelectionPoint} from '../types/editor' import {blockOffsetToSpanSelectionPoint} from '../utils/util.block-offset' import {isEqualSelectionPoints} from '../utils/util.is-equal-selection-points' @@ -98,7 +98,7 @@ export function toSlateSelectionPoint( if (!isTextBlock(snapshot.context, block)) { return { - path: [blockIndex, 0], + path: [blockIndex], offset: 0, } } @@ -149,7 +149,7 @@ export function toSlateSelectionPoint( if (isSpan(snapshot.context, child)) { childPath = [childIndex] } else { - childPath = [childIndex, 0] + childPath = [childIndex] offset = 0 } break diff --git a/packages/editor/src/internal-utils/transform-operation.ts b/packages/editor/src/internal-utils/transform-operation.ts index 1915f0f79..fc578e9ef 100644 --- a/packages/editor/src/internal-utils/transform-operation.ts +++ b/packages/editor/src/internal-utils/transform-operation.ts @@ -6,7 +6,7 @@ import { DIFF_INSERT, parsePatch, } from '@sanity/diff-match-patch' -import type {Descendant, Operation} from 'slate' +import type {Descendant, Operation} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' import {isKeyedSegment} from '../utils' import {debug} from './debug' diff --git a/packages/editor/src/internal-utils/values.test.ts b/packages/editor/src/internal-utils/values.test.ts index 15c21ebf1..841339e3e 100644 --- a/packages/editor/src/internal-utils/values.test.ts +++ b/packages/editor/src/internal-utils/values.test.ts @@ -1,7 +1,7 @@ import {compileSchema, defineSchema} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test} from 'vitest' -import {toSlateBlock, VOID_CHILD_KEY} from './values' +import {toSlateBlock} from './values' describe(toSlateBlock.name, () => { describe('text block', () => { @@ -30,6 +30,7 @@ describe(toSlateBlock.name, () => { _key: spanKey, _type: 'span', text: 'foo', + marks: [], }, ], }) @@ -59,6 +60,7 @@ describe(toSlateBlock.name, () => { _key: spanKey, _type: 'span', text: 'foo', + marks: [], }, ], }) @@ -87,16 +89,7 @@ describe(toSlateBlock.name, () => { { _key: spanKey, _type: 'stock-ticker', - children: [ - { - _key: VOID_CHILD_KEY, - _type: 'span', - text: '', - marks: [], - }, - ], - value: {text: 'foo'}, - __inline: true, + text: 'foo', }, ], }) @@ -129,20 +122,9 @@ describe(toSlateBlock.name, () => { _key: blockKey, children: [ { - __inline: true, _key: stockTickerKey, _type: 'stock-ticker', - children: [ - { - _key: VOID_CHILD_KEY, - _type: 'span', - text: '', - marks: [], - }, - ], - value: { - symbol: 'AAPL', - }, + symbol: 'AAPL', }, ], }) @@ -171,20 +153,9 @@ describe(toSlateBlock.name, () => { _key: blockKey, children: [ { - __inline: true, _key: stockTickerKey, _type: 'stock-ticker', - children: [ - { - _key: VOID_CHILD_KEY, - _type: 'span', - text: '', - marks: [], - }, - ], - value: { - symbol: 'AAPL', - }, + symbol: 'AAPL', }, ], }) @@ -217,20 +188,9 @@ describe(toSlateBlock.name, () => { _key: blockKey, children: [ { - __inline: true, _key: stockTickerKey, _type: 'stock-ticker', - children: [ - { - _key: VOID_CHILD_KEY, - _type: 'span', - text: '', - marks: [], - }, - ], - value: { - text: 'foo', - }, + text: 'foo', }, ], }) @@ -259,20 +219,9 @@ describe(toSlateBlock.name, () => { _key: blockKey, children: [ { - __inline: true, _key: stockTickerKey, _type: 'stock-ticker', - children: [ - { - _key: VOID_CHILD_KEY, - _type: 'span', - text: '', - marks: [], - }, - ], - value: { - text: 'foo', - }, + text: 'foo', }, ], }) diff --git a/packages/editor/src/internal-utils/values.ts b/packages/editor/src/internal-utils/values.ts index adce19d93..be9823ad5 100644 --- a/packages/editor/src/internal-utils/values.ts +++ b/packages/editor/src/internal-utils/values.ts @@ -3,8 +3,8 @@ import type { PortableTextObject, PortableTextTextBlock, } from '@portabletext/schema' -import {Element, Text, type Descendant} from 'slate' import type {EditorSchema} from '../editor/editor-schema' +import {Element, Text, type Descendant} from '../slate' import {isEqualValues} from './equality' export const EMPTY_MARKDEFS: PortableTextObject[] = [] @@ -34,35 +34,27 @@ export function toSlateBlock( _key: childKey, _type: schemaTypes.span.name, text: childProps.text, + marks: [], } } } if (childType !== schemaTypes.span.name) { - // Return 'slate' version of inline object where the actual - // value is stored in the `value` property. - // In slate, inline objects are represented as regular - // children with actual text node in order to be able to - // be selected the same way as the rest of the (text) content. + // Inline objects are childless void elements. hasInlines = true return { _type: childType, _key: childKey, - children: [ - { - _key: VOID_CHILD_KEY, - _type: schemaTypes.span.name, - text: '', - marks: [], - }, - ], - value: childProps, - __inline: true, + ...childProps, } } - // Original child object (span) + // Span: ensure marks is always present + if (!Array.isArray(child.marks)) { + return {...child, marks: child.marks ?? []} + } + return child }) @@ -80,18 +72,11 @@ export function toSlateBlock( return {_type, _key, ...rest, children} as Descendant } + // Block objects (images, etc.) are childless void elements. return { _type, _key, - children: [ - { - _key: VOID_CHILD_KEY, - _type: 'span', - text: '', - marks: [], - }, - ], - value: rest, + ...rest, } as Descendant } @@ -106,34 +91,11 @@ export function fromSlateBlock(block: Descendant, textBlockType: string) { Array.isArray(block.children) && _key ) { - let hasInlines = false - const children = block.children.map((child) => { - const {_type: _cType} = child - if ('value' in child && _cType !== 'span') { - hasInlines = true - const { - value: v, - _key: k, - _type: t, - __inline: _i, - children: _c, - ...rest - } = child - return {...rest, ...v, _key: k as string, _type: t as string} - } - return child - }) - if (!hasInlines) { - return block as PortableTextBlock // Original object - } - return {...block, children, _key, _type} as PortableTextBlock + // Text block: inline objects are already childless, return as-is + return block as PortableTextBlock } - const blockValue = 'value' in block && block.value - return { - _key, - _type, - ...(typeof blockValue === 'object' ? blockValue : {}), - } as PortableTextBlock + // Block object: already childless, return as-is + return block as unknown as PortableTextBlock } export function isEqualToEmptyEditor( diff --git a/packages/editor/src/operations/operation.annotation.add.ts b/packages/editor/src/operations/operation.annotation.add.ts index 561c010f0..a72b4b4ed 100644 --- a/packages/editor/src/operations/operation.annotation.add.ts +++ b/packages/editor/src/operations/operation.annotation.add.ts @@ -1,5 +1,5 @@ -import {Editor, Node, Range, Text, Transforms} from 'slate' import {toSlateRange} from '../internal-utils/to-slate-range' +import {Editor, Node, Range, Text, Transforms} from '../slate' import {parseAnnotation} from '../utils/parse-blocks' import type {OperationImplementation} from './operation.types' diff --git a/packages/editor/src/operations/operation.annotation.remove.ts b/packages/editor/src/operations/operation.annotation.remove.ts index 4422c8b15..13637d036 100644 --- a/packages/editor/src/operations/operation.annotation.remove.ts +++ b/packages/editor/src/operations/operation.annotation.remove.ts @@ -1,6 +1,6 @@ import type {PortableTextSpan} from '@portabletext/schema' -import {Editor, Node, Path, Range, Transforms} from 'slate' import {toSlateRange} from '../internal-utils/to-slate-range' +import {Editor, Node, Path, Range, Transforms} from '../slate' import type {OperationImplementation} from './operation.types' export const removeAnnotationOperationImplementation: OperationImplementation< diff --git a/packages/editor/src/operations/operation.block.set.ts b/packages/editor/src/operations/operation.block.set.ts index 001323a34..408bfc809 100644 --- a/packages/editor/src/operations/operation.block.set.ts +++ b/packages/editor/src/operations/operation.block.set.ts @@ -1,6 +1,5 @@ -import {applyAll, set} from '@portabletext/patches' import {isTextBlock} from '@portabletext/schema' -import {Transforms, type Node} from 'slate' +import {Transforms, type Descendant} from '../slate' import {parseMarkDefs} from '../utils/parse-blocks' import type {OperationImplementation} from './operation.types' @@ -93,12 +92,38 @@ export const blockSetOperationImplementation: OperationImplementation< } } - const patches = Object.entries(filteredProps).map(([key, value]) => - key === '_key' ? set(value, ['_key']) : set(value, ['value', key]), - ) + // Slate's set_node rejects 'text' and 'children' in newProperties, + // but block objects can have user-defined fields with those names. + const safeProps: Record = {} + const unsafeProps: Record = {} - const updatedSlateBlock = applyAll(slateBlock, patches) as Partial + for (const key in filteredProps) { + if (key === 'text' || key === 'children') { + unsafeProps[key] = filteredProps[key] + } else { + safeProps[key] = filteredProps[key] + } + } - Transforms.setNodes(operation.editor, updatedSlateBlock, {at: [blockIndex]}) + if (Object.keys(safeProps).length > 0) { + Transforms.setNodes(operation.editor, safeProps, {at: [blockIndex]}) + } + + if (Object.keys(unsafeProps).length > 0) { + const newNode = { + ...(slateBlock as Record), + ...unsafeProps, + } + operation.editor.apply({ + type: 'remove_node', + path: [blockIndex], + node: slateBlock as Descendant, + }) + operation.editor.apply({ + type: 'insert_node', + path: [blockIndex], + node: newNode as unknown as Descendant, + }) + } } } diff --git a/packages/editor/src/operations/operation.block.unset.ts b/packages/editor/src/operations/operation.block.unset.ts index 35643eb01..abb694f7c 100644 --- a/packages/editor/src/operations/operation.block.unset.ts +++ b/packages/editor/src/operations/operation.block.unset.ts @@ -1,6 +1,5 @@ -import {applyAll, set, unset} from '@portabletext/patches' import {isTextBlock} from '@portabletext/schema' -import {Transforms, type Node} from 'slate' +import {Transforms} from '../slate' import type {OperationImplementation} from './operation.types' export const blockUnsetOperationImplementation: OperationImplementation< @@ -40,15 +39,17 @@ export const blockUnsetOperationImplementation: OperationImplementation< return } - const patches = operation.props.flatMap((key) => - key === '_type' - ? [] - : key === '_key' - ? set(context.keyGenerator(), ['_key']) - : unset(['value', key]), + const propsToRemove = operation.props.filter( + (prop) => prop !== '_type' && prop !== '_key', ) - const updatedSlateBlock = applyAll(slateBlock, patches) as Partial + Transforms.unsetNodes(operation.editor, propsToRemove, {at: [blockIndex]}) - Transforms.setNodes(operation.editor, updatedSlateBlock, {at: [blockIndex]}) + if (operation.props.includes('_key')) { + Transforms.setNodes( + operation.editor, + {_key: context.keyGenerator()}, + {at: [blockIndex]}, + ) + } } diff --git a/packages/editor/src/operations/operation.child.set.ts b/packages/editor/src/operations/operation.child.set.ts index 6a875bf15..030f26acc 100644 --- a/packages/editor/src/operations/operation.child.set.ts +++ b/packages/editor/src/operations/operation.child.set.ts @@ -1,5 +1,5 @@ -import {Editor, Element, Transforms} from 'slate' import {toSlateRange} from '../internal-utils/to-slate-range' +import {Editor, Element, Transforms, type Descendant} from '../slate' import type {OperationImplementation} from './operation.types' export const childSetOperationImplementation: OperationImplementation< @@ -75,28 +75,51 @@ export const childSetOperationImplementation: OperationImplementation< ) } - const value = - 'value' in child && typeof child.value === 'object' ? child.value : {} const {_type, _key, ...rest} = operation.props + const filteredProps: Record = {} + + if (typeof _key === 'string') { + filteredProps['_key'] = _key + } for (const prop in rest) { - if (!definition.fields.some((field) => field.name === prop)) { - delete rest[prop] + if (definition.fields.some((field) => field.name === prop)) { + filteredProps[prop] = rest[prop] } } - Transforms.setNodes( - operation.editor, - { - ...child, - _key: typeof _key === 'string' ? _key : child._key, - value: { - ...value, - ...rest, - }, - }, - {at: childPath}, - ) + // Slate's set_node rejects 'text' and 'children' in newProperties, + // but inline objects can have user-defined fields with those names. + // Split into set_node-safe props and text/children props. + const safeProps: Record = {} + const unsafeProps: Record = {} + + for (const key in filteredProps) { + if (key === 'text' || key === 'children') { + unsafeProps[key] = filteredProps[key] + } else { + safeProps[key] = filteredProps[key] + } + } + + if (Object.keys(safeProps).length > 0) { + Transforms.setNodes(operation.editor, safeProps, {at: childPath}) + } + + if (Object.keys(unsafeProps).length > 0) { + // Use remove_node + insert_node to replace the entire node + const newNode = {...(child as Record), ...unsafeProps} + operation.editor.apply({ + type: 'remove_node', + path: childPath, + node: child as Descendant, + }) + operation.editor.apply({ + type: 'insert_node', + path: childPath, + node: newNode as unknown as Descendant, + }) + } return } diff --git a/packages/editor/src/operations/operation.child.unset.ts b/packages/editor/src/operations/operation.child.unset.ts index 690ae0d75..fdc3c4f5d 100644 --- a/packages/editor/src/operations/operation.child.unset.ts +++ b/packages/editor/src/operations/operation.child.unset.ts @@ -1,6 +1,5 @@ -import {applyAll} from '@portabletext/patches' import {isTextBlock} from '@portabletext/schema' -import {Editor, Element, Transforms} from 'slate' +import {Editor, Element, Transforms} from '../slate' import type {OperationImplementation} from './operation.types' export const childUnsetOperationImplementation: OperationImplementation< @@ -69,6 +68,12 @@ export const childUnsetOperationImplementation: OperationImplementation< continue } + if (prop === 'marks') { + // Unsetting marks resets to empty array (marks is structural) + newNode['marks'] = [] + continue + } + newNode[prop] = null } @@ -87,26 +92,20 @@ export const childUnsetOperationImplementation: OperationImplementation< } if (Element.isElement(child)) { - const value = - 'value' in child && typeof child.value === 'object' ? child.value : {} - const patches = operation.props.map((prop) => ({ - type: 'unset' as const, - path: [prop], - })) - const newValue = applyAll(value, patches) - - Transforms.setNodes( - operation.editor, - { - ...child, - _key: operation.props.includes('_key') - ? context.keyGenerator() - : child._key, - value: newValue, - }, - {at: childPath}, + const propsToRemove = operation.props.filter( + (prop) => prop !== '_type' && prop !== '_key', ) + Transforms.unsetNodes(operation.editor, propsToRemove, {at: childPath}) + + if (operation.props.includes('_key')) { + Transforms.setNodes( + operation.editor, + {_key: context.keyGenerator()}, + {at: childPath}, + ) + } + return } diff --git a/packages/editor/src/operations/operation.decorator.add.ts b/packages/editor/src/operations/operation.decorator.add.ts index 6bad350a2..428f3c681 100644 --- a/packages/editor/src/operations/operation.decorator.add.ts +++ b/packages/editor/src/operations/operation.decorator.add.ts @@ -1,5 +1,5 @@ -import {Editor, Range, Text, Transforms} from 'slate' import {toSlateRange} from '../internal-utils/to-slate-range' +import {Editor, Range, Text, Transforms} from '../slate' import type {OperationImplementation} from './operation.types' export const decoratorAddOperationImplementation: OperationImplementation< diff --git a/packages/editor/src/operations/operation.decorator.remove.ts b/packages/editor/src/operations/operation.decorator.remove.ts index 3a093a037..7ba8ba5b7 100644 --- a/packages/editor/src/operations/operation.decorator.remove.ts +++ b/packages/editor/src/operations/operation.decorator.remove.ts @@ -1,5 +1,5 @@ -import {Editor, Element, Range, Text, Transforms} from 'slate' import {toSlateRange} from '../internal-utils/to-slate-range' +import {Editor, Element, Range, Text, Transforms} from '../slate' import type {OperationImplementation} from './operation.types' export const decoratorRemoveOperationImplementation: OperationImplementation< @@ -43,7 +43,7 @@ export const decoratorRemoveOperationImplementation: OperationImplementation< ] splitTextNodes.forEach(([node, path]) => { const block = editor.children[path[0]!] - if (Element.isElement(block) && block.children.includes(node)) { + if (Element.isElement(block) && block.children?.includes(node)) { Transforms.setNodes( editor, { diff --git a/packages/editor/src/operations/operation.delete.ts b/packages/editor/src/operations/operation.delete.ts index 1f822ea68..0ff6afdb3 100644 --- a/packages/editor/src/operations/operation.delete.ts +++ b/packages/editor/src/operations/operation.delete.ts @@ -1,17 +1,18 @@ import {isSpan, isTextBlock} from '@portabletext/schema' +import {toSlateRange} from '../internal-utils/to-slate-range' import { deleteText, Editor, Element, + Node, Path, Point, Range, + Text, Transforms, type NodeEntry, -} from 'slate' -import {DOMEditor} from 'slate-dom' -import {toSlateRange} from '../internal-utils/to-slate-range' -import {VOID_CHILD_KEY} from '../internal-utils/values' +} from '../slate' +import {DOMEditor} from '../slate-dom' import type {PortableTextSlateEditor} from '../types/slate-editor' import type {OperationImplementation} from './operation.types' @@ -58,8 +59,8 @@ export const deleteOperationImplementation: OperationImplementation< Transforms.removeNodes(operation.editor, { at, match: (node) => - (isSpan(context, node) && node._key !== VOID_CHILD_KEY) || - ('__inline' in node && node.__inline === true), + isSpan(context, node) || + (Element.isElement(node) && operation.editor.isInline(node as Element)), }) return @@ -163,9 +164,9 @@ export const deleteOperationImplementation: OperationImplementation< ) { if (!startNonEditable) { const point = startRef.current! - const [node] = Editor.leaf(operation.editor, point) + const node = Node.get(operation.editor, point.path) - if (node.text.length > 0) { + if (Text.isText(node) && node.text.length > 0) { operation.editor.apply({ type: 'remove_text', path: point.path, @@ -185,13 +186,16 @@ export const deleteOperationImplementation: OperationImplementation< if (!endNonEditable) { const point = endRef.current! - const [node] = Editor.leaf(operation.editor, point) - const {path} = point - const offset = 0 - const text = node.text.slice(offset, end.offset) + const node = Node.get(operation.editor, point.path) + + if (Text.isText(node)) { + const {path} = point + const offset = 0 + const text = node.text.slice(offset, end.offset) - if (text.length > 0) { - operation.editor.apply({type: 'remove_text', path, offset, text}) + if (text.length > 0) { + operation.editor.apply({type: 'remove_text', path, offset, text}) + } } } @@ -291,7 +295,7 @@ function findCurrentLineRange( return Editor.range(editor, positions[left]!, parentRangeBoundary) } -function rangesAreOnSameLine(editor: DOMEditor, range1: Range, range2: Range) { +function rangesAreOnSameLine(editor: Editor, range1: Range, range2: Range) { const rect1 = DOMEditor.toDOMRange(editor, range1).getBoundingClientRect() const rect2 = DOMEditor.toDOMRange(editor, range2).getBoundingClientRect() diff --git a/packages/editor/src/operations/operation.history.redo.ts b/packages/editor/src/operations/operation.history.redo.ts index 68466d35a..2991069ac 100644 --- a/packages/editor/src/operations/operation.history.redo.ts +++ b/packages/editor/src/operations/operation.history.redo.ts @@ -1,5 +1,5 @@ -import {Editor, Transforms} from 'slate' import {transformOperation} from '../internal-utils/transform-operation' +import {Editor, Transforms} from '../slate' import {pluginRedoing} from '../slate-plugins/slate-plugin.redoing' import {pluginWithoutHistory} from '../slate-plugins/slate-plugin.without-history' import type {OperationImplementation} from './operation.types' diff --git a/packages/editor/src/operations/operation.history.undo.ts b/packages/editor/src/operations/operation.history.undo.ts index 41139ec55..d16971d35 100644 --- a/packages/editor/src/operations/operation.history.undo.ts +++ b/packages/editor/src/operations/operation.history.undo.ts @@ -1,5 +1,5 @@ -import {Editor, Operation, Transforms} from 'slate' import {transformOperation} from '../internal-utils/transform-operation' +import {Editor, Operation, Transforms} from '../slate' import {pluginUndoing} from '../slate-plugins/slate-plugin.undoing' import {pluginWithoutHistory} from '../slate-plugins/slate-plugin.without-history' import type {OperationImplementation} from './operation.types' diff --git a/packages/editor/src/operations/operation.insert.block.ts b/packages/editor/src/operations/operation.insert.block.ts index 193025aa2..055edd761 100644 --- a/packages/editor/src/operations/operation.insert.block.ts +++ b/packages/editor/src/operations/operation.insert.block.ts @@ -1,4 +1,8 @@ import {isSpan} from '@portabletext/schema' +import {isEqualChildren, isEqualMarks} from '../internal-utils/equality' +import {getFocusChild} from '../internal-utils/slate-utils' +import {toSlateRange} from '../internal-utils/to-slate-range' +import {toSlateBlock} from '../internal-utils/values' import { Editor, Element, @@ -8,11 +12,7 @@ import { Range, Text, type Descendant, -} from 'slate' -import {isEqualChildren, isEqualMarks} from '../internal-utils/equality' -import {getFocusChild} from '../internal-utils/slate-utils' -import {toSlateRange} from '../internal-utils/to-slate-range' -import {toSlateBlock} from '../internal-utils/values' +} from '../slate' import type {EditorSelection} from '../types/editor' import type {PortableTextSlateEditor} from '../types/slate-editor' import {parseBlock} from '../utils/parse-blocks' @@ -549,7 +549,7 @@ export function insertBlock(options: { // Remove nodes after start const blockNode = Node.get(editor, endBlockPath) as Element - for (let i = blockNode.children.length - 1; i > start.path[1]!; i--) { + for (let i = blockNode.children!.length - 1; i > start.path[1]!; i--) { removeNodeAt(editor, [...endBlockPath, i]) } @@ -637,7 +637,7 @@ export function insertBlock(options: { const blockToSplit = Node.get(editor, blockPath) if ( - splitAtIndex < (blockToSplit as Element).children.length && + splitAtIndex < ((blockToSplit as Element).children?.length ?? 0) && Element.isElement(blockToSplit) ) { // Get the properties to preserve in the split @@ -892,7 +892,7 @@ function deleteCrossBlockRange( // Remove remaining nodes in start block const startBlock = Node.get(editor, startBlockPath) as Element - for (let i = startBlock.children.length - 1; i > start.path[1]!; i--) { + for (let i = startBlock.children!.length - 1; i > start.path[1]!; i--) { removeNodeAt(editor, [...startBlockPath, i]) } } diff --git a/packages/editor/src/operations/operation.insert.child.ts b/packages/editor/src/operations/operation.insert.child.ts index 1c0a681c6..204cd018a 100644 --- a/packages/editor/src/operations/operation.insert.child.ts +++ b/packages/editor/src/operations/operation.insert.child.ts @@ -1,8 +1,7 @@ import {isTextBlock} from '@portabletext/schema' -import {Transforms} from 'slate' -import {EDITOR_TO_PENDING_SELECTION} from 'slate-dom' import {getFocusBlock, getFocusSpan} from '../internal-utils/slate-utils' -import {VOID_CHILD_KEY} from '../internal-utils/values' +import {Transforms} from '../slate' +import {EDITOR_TO_PENDING_SELECTION} from '../slate-dom' import {parseInlineObject, parseSpan} from '../utils/parse-blocks' import type {OperationImplementation} from './operation.types' @@ -72,54 +71,22 @@ export const insertChildOperationImplementation: OperationImplementation< }) if (inlineObject) { - const {_key, _type, ...rest} = inlineObject - const [focusSpan] = getFocusSpan({editor: operation.editor}) + const slateInlineObject = { + ...inlineObject, + } + if (focusSpan) { - Transforms.insertNodes( - operation.editor, - { - _key, - _type, - children: [ - { - _key: VOID_CHILD_KEY, - _type: 'span', - text: '', - marks: [], - }, - ], - value: rest, - __inline: true, - }, - { - at: focus, - select: true, - }, - ) + Transforms.insertNodes(operation.editor, slateInlineObject, { + at: focus, + select: true, + }) } else { - Transforms.insertNodes( - operation.editor, - { - _key, - _type, - children: [ - { - _key: VOID_CHILD_KEY, - _type: 'span', - text: '', - marks: [], - }, - ], - value: rest, - __inline: true, - }, - { - at: [focusBlockIndex, focusChildIndex + 1], - select: true, - }, - ) + Transforms.insertNodes(operation.editor, slateInlineObject, { + at: [focusBlockIndex, focusChildIndex + 1], + select: true, + }) } return diff --git a/packages/editor/src/operations/operation.insert.text.ts b/packages/editor/src/operations/operation.insert.text.ts index 2ed4f338f..4f5911c88 100644 --- a/packages/editor/src/operations/operation.insert.text.ts +++ b/packages/editor/src/operations/operation.insert.text.ts @@ -1,4 +1,4 @@ -import {Transforms} from 'slate' +import {Transforms} from '../slate' import type {OperationImplementation} from './operation.types' export const insertTextOperationImplementation: OperationImplementation< diff --git a/packages/editor/src/operations/operation.move.backward.ts b/packages/editor/src/operations/operation.move.backward.ts index c30fe00b2..aeb213db4 100644 --- a/packages/editor/src/operations/operation.move.backward.ts +++ b/packages/editor/src/operations/operation.move.backward.ts @@ -1,4 +1,4 @@ -import {Transforms} from 'slate' +import {Transforms} from '../slate' import type {OperationImplementation} from './operation.types' export const moveBackwardOperationImplementation: OperationImplementation< diff --git a/packages/editor/src/operations/operation.move.block.ts b/packages/editor/src/operations/operation.move.block.ts index 0c400b32c..bd73a4ab0 100644 --- a/packages/editor/src/operations/operation.move.block.ts +++ b/packages/editor/src/operations/operation.move.block.ts @@ -1,4 +1,4 @@ -import {Transforms} from 'slate' +import {Transforms} from '../slate' import {getBlockKeyFromSelectionPoint} from '../utils/util.selection-point' import type {OperationImplementation} from './operation.types' diff --git a/packages/editor/src/operations/operation.move.forward.ts b/packages/editor/src/operations/operation.move.forward.ts index db621dceb..63317f993 100644 --- a/packages/editor/src/operations/operation.move.forward.ts +++ b/packages/editor/src/operations/operation.move.forward.ts @@ -1,4 +1,4 @@ -import {Transforms} from 'slate' +import {Transforms} from '../slate' import type {OperationImplementation} from './operation.types' export const moveForwardOperationImplementation: OperationImplementation< diff --git a/packages/editor/src/operations/operation.perform.ts b/packages/editor/src/operations/operation.perform.ts index 1ead5f9bb..e0b36771b 100644 --- a/packages/editor/src/operations/operation.perform.ts +++ b/packages/editor/src/operations/operation.perform.ts @@ -1,4 +1,4 @@ -import {Editor} from 'slate' +import {Editor} from '../slate' import {addAnnotationOperationImplementation} from './operation.annotation.add' import {removeAnnotationOperationImplementation} from './operation.annotation.remove' import {blockSetOperationImplementation} from './operation.block.set' diff --git a/packages/editor/src/operations/operation.select.ts b/packages/editor/src/operations/operation.select.ts index a8d563bf7..acad5360e 100644 --- a/packages/editor/src/operations/operation.select.ts +++ b/packages/editor/src/operations/operation.select.ts @@ -1,6 +1,6 @@ -import {Transforms} from 'slate' -import {IS_FOCUSED, IS_READ_ONLY} from 'slate-dom' import {toSlateRange} from '../internal-utils/to-slate-range' +import {Transforms} from '../slate' +import {IS_FOCUSED, IS_READ_ONLY} from '../slate-dom' import type {OperationImplementation} from './operation.types' export const selectOperationImplementation: OperationImplementation< diff --git a/packages/editor/src/plugins/plugin.internal.slate-editor-ref.tsx b/packages/editor/src/plugins/plugin.internal.slate-editor-ref.tsx index 8e31d26bd..8b26ede9b 100644 --- a/packages/editor/src/plugins/plugin.internal.slate-editor-ref.tsx +++ b/packages/editor/src/plugins/plugin.internal.slate-editor-ref.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {useSlateStatic} from 'slate-react' +import {useSlateStatic} from '../slate-react' import type {PortableTextSlateEditor} from '../types/slate-editor' export const InternalSlateEditorRefPlugin = diff --git a/packages/editor/src/slate-dom/custom-types.ts b/packages/editor/src/slate-dom/custom-types.ts new file mode 100644 index 000000000..0ac38cdf8 --- /dev/null +++ b/packages/editor/src/slate-dom/custom-types.ts @@ -0,0 +1,25 @@ +// CustomTypes augmentation removed — PTE's types/slate.ts is the source of truth. +// Slate-dom's placeholder/resize props are handled through PTE's own type system. + +declare global { + interface Window { + MSStream: boolean + } + interface DocumentOrShadowRoot { + getSelection(): Selection | null + } + + interface CaretPosition { + readonly offsetNode: Node + readonly offset: number + getClientRect(): DOMRect | null + } + + interface Document { + caretPositionFromPoint(x: number, y: number): CaretPosition | null + } + + interface Node { + getRootNode(options?: GetRootNodeOptions): Document | ShadowRoot + } +} diff --git a/packages/editor/src/slate-dom/index.ts b/packages/editor/src/slate-dom/index.ts new file mode 100644 index 000000000..2e7523c13 --- /dev/null +++ b/packages/editor/src/slate-dom/index.ts @@ -0,0 +1,92 @@ +// Plugin +export {DOMEditor, type DOMEditorInterface} from './plugin/dom-editor' +export {withDOM} from './plugin/with-dom' + +// Utils +export {TRIPLE_CLICK} from './utils/constants' + +export { + applyStringDiff, + mergeStringDiffs, + normalizePoint, + normalizeRange, + normalizeStringDiff, + type StringDiff, + targetRange, + type TextDiff, + verifyDiffState, +} from './utils/diff-text' + +export { + closestShadowAware, + containsShadowAware, + type DOMElement, + type DOMNode, + type DOMPoint, + type DOMRange, + type DOMSelection, + type DOMStaticRange, + DOMText, + getActiveElement, + getDefaultView, + getSelection, + hasShadowRoot, + isAfter, + isBefore, + isDOMElement, + isDOMNode, + isDOMSelection, + isPlainTextOnlyPaste, + isTrackedMutation, + normalizeDOMPoint, +} from './utils/dom' + +export { + CAN_USE_DOM, + HAS_BEFORE_INPUT_SUPPORT, + IS_ANDROID, + IS_CHROME, + IS_FIREFOX, + IS_FIREFOX_LEGACY, + IS_IOS, + IS_WEBKIT, + IS_UC_MOBILE, + IS_WECHATBROWSER, +} from './utils/environment' + +export {default as Hotkeys} from './utils/hotkeys' + +export {Key} from './utils/key' + +export { + isElementDecorationsEqual, + isTextDecorationsEqual, + splitDecorationsByChild, +} from './utils/range-list' + +export { + EDITOR_TO_ELEMENT, + EDITOR_TO_FORCE_RENDER, + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_ON_CHANGE, + EDITOR_TO_PENDING_ACTION, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_PENDING_SELECTION, + EDITOR_TO_PLACEHOLDER_ELEMENT, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_USER_MARKS, + EDITOR_TO_USER_SELECTION, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + IS_COMPOSING, + IS_FOCUSED, + IS_NODE_MAP_DIRTY, + IS_READ_ONLY, + MARK_PLACEHOLDER_SYMBOL, + NODE_TO_ELEMENT, + NODE_TO_INDEX, + NODE_TO_KEY, + NODE_TO_PARENT, + PLACEHOLDER_SYMBOL, +} from './utils/weak-maps' diff --git a/packages/editor/src/slate-dom/plugin/dom-editor.ts b/packages/editor/src/slate-dom/plugin/dom-editor.ts new file mode 100644 index 000000000..f6fc51f0e --- /dev/null +++ b/packages/editor/src/slate-dom/plugin/dom-editor.ts @@ -0,0 +1,1103 @@ +import { + Editor, + Element, + Node, + Range, + Scrubber, + Transforms, + type BaseEditor, + type Path, + type Point, +} from '../../slate' +import type {TextDiff} from '../utils/diff-text' +import { + closestShadowAware, + containsShadowAware, + DOMText, + getSelection, + hasShadowRoot, + isAfter, + isBefore, + isDOMElement, + isDOMNode, + isDOMSelection, + normalizeDOMPoint, + type DOMElement, + type DOMNode, + type DOMPoint, + type DOMRange, + type DOMSelection, + type DOMStaticRange, +} from '../utils/dom' +import {IS_ANDROID, IS_CHROME, IS_FIREFOX} from '../utils/environment' +import {Key} from '../utils/key' +import { + EDITOR_TO_ELEMENT, + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + IS_COMPOSING, + IS_FOCUSED, + IS_READ_ONLY, + NODE_TO_INDEX, + NODE_TO_KEY, + NODE_TO_PARENT, +} from '../utils/weak-maps' + +/** + * A DOM-specific version of the `Editor` interface. + */ + +export interface DOMEditor extends BaseEditor { + hasEditableTarget: ( + editor: Editor, + target: EventTarget | null, + ) => target is DOMNode + hasRange: (editor: Editor, range: Range) => boolean + hasSelectableTarget: (editor: Editor, target: EventTarget | null) => boolean + hasTarget: (editor: Editor, target: EventTarget | null) => target is DOMNode + insertData: (data: DataTransfer) => void + insertFragmentData: (data: DataTransfer) => boolean + insertTextData: (data: DataTransfer) => boolean + isTargetInsideNonReadonlyVoid: ( + editor: Editor, + target: EventTarget | null, + ) => boolean + setFragmentData: ( + data: DataTransfer, + originEvent?: 'drag' | 'copy' | 'cut', + ) => void +} + +export interface DOMEditorInterface { + /** + * Experimental and android specific: Get pending diffs + */ + androidPendingDiffs: (editor: Editor) => TextDiff[] | undefined + + /** + * Experimental and android specific: Flush all pending diffs and cancel composition at the next possible time. + */ + androidScheduleFlush: (editor: Editor) => void + + /** + * Blur the editor. + */ + blur: (editor: Editor) => void + + /** + * Deselect the editor. + */ + deselect: (editor: Editor) => void + + /** + * Find the DOM node that implements DocumentOrShadowRoot for the editor. + */ + findDocumentOrShadowRoot: (editor: Editor) => Document | ShadowRoot + + /** + * Get the target range from a DOM `event`. + */ + findEventRange: (editor: Editor, event: any) => Range + + /** + * Find a key for a Slate node. + */ + findKey: (editor: Editor, node: Node) => Key + + /** + * Find the path of Slate node. + */ + findPath: (editor: Editor, node: Node) => Path + + /** + * Focus the editor. + */ + focus: (editor: Editor, options?: {retries: number}) => void + + /** + * Return the host window of the current editor. + */ + getWindow: (editor: Editor) => Window + + /** + * Check if a DOM node is within the editor. + */ + hasDOMNode: ( + editor: Editor, + target: DOMNode, + options?: {editable?: boolean}, + ) => boolean + + /** + * Check if the target is editable and in the editor. + */ + hasEditableTarget: ( + editor: Editor, + target: EventTarget | null, + ) => target is DOMNode + + /** + * + */ + hasRange: (editor: Editor, range: Range) => boolean + + /** + * Check if the target can be selectable + */ + hasSelectableTarget: (editor: Editor, target: EventTarget | null) => boolean + + /** + * Check if the target is in the editor. + */ + hasTarget: (editor: Editor, target: EventTarget | null) => target is DOMNode + + /** + * Insert data from a `DataTransfer` into the editor. + */ + insertData: (editor: Editor, data: DataTransfer) => void + + /** + * Insert fragment data from a `DataTransfer` into the editor. + */ + insertFragmentData: (editor: Editor, data: DataTransfer) => boolean + + /** + * Insert text data from a `DataTransfer` into the editor. + */ + insertTextData: (editor: Editor, data: DataTransfer) => boolean + + /** + * Check if the user is currently composing inside the editor. + */ + isComposing: (editor: Editor) => boolean + + /** + * Check if the editor is focused. + */ + isFocused: (editor: Editor) => boolean + + /** + * Check if the editor is in read-only mode. + */ + isReadOnly: (editor: Editor) => boolean + + /** + * Check if the target is inside void and in an non-readonly editor. + */ + isTargetInsideNonReadonlyVoid: ( + editor: Editor, + target: EventTarget | null, + ) => boolean + + /** + * Sets data from the currently selected fragment on a `DataTransfer`. + */ + setFragmentData: ( + editor: Editor, + data: DataTransfer, + originEvent?: 'drag' | 'copy' | 'cut', + ) => void + + /** + * Find the native DOM element from a Slate node. + */ + toDOMNode: (editor: Editor, node: Node) => HTMLElement + + /** + * Find a native DOM selection point from a Slate point. + */ + toDOMPoint: (editor: Editor, point: Point) => DOMPoint + + /** + * Find a native DOM range from a Slate `range`. + * + * Notice: the returned range will always be ordinal regardless of the direction of Slate `range` due to DOM API limit. + * + * there is no way to create a reverse DOM Range using Range.setStart/setEnd + * according to https://dom.spec.whatwg.org/#concept-range-bp-set. + */ + toDOMRange: (editor: Editor, range: Range) => DOMRange + + /** + * Find a Slate node from a native DOM `element`. + */ + toSlateNode: (editor: Editor, domNode: DOMNode) => Node + + /** + * Find a Slate point from a DOM selection's `domNode` and `domOffset`. + */ + toSlatePoint: ( + editor: Editor, + domPoint: DOMPoint, + options: { + exactMatch: boolean + suppressThrow: T + /** + * The direction to search for Slate leaf nodes if `domPoint` is + * non-editable and non-void. + */ + searchDirection?: 'forward' | 'backward' + }, + ) => T extends true ? Point | null : Point + + /** + * Find a Slate range from a DOM range or selection. + */ + toSlateRange: ( + editor: Editor, + domRange: DOMRange | DOMStaticRange | DOMSelection, + options: { + exactMatch: boolean + suppressThrow: T + }, + ) => T extends true ? Range | null : Range +} + +// eslint-disable-next-line no-redeclare +export const DOMEditor: DOMEditorInterface = { + androidPendingDiffs: (editor) => EDITOR_TO_PENDING_DIFFS.get(editor), + + androidScheduleFlush: (editor) => { + EDITOR_TO_SCHEDULE_FLUSH.get(editor)?.() + }, + + blur: (editor) => { + const el = DOMEditor.toDOMNode(editor, editor) + const root = DOMEditor.findDocumentOrShadowRoot(editor) + IS_FOCUSED.set(editor, false) + + if (root.activeElement === el) { + el.blur() + } + }, + + deselect: (editor) => { + const {selection} = editor + const root = DOMEditor.findDocumentOrShadowRoot(editor) + const domSelection = getSelection(root) + + if (domSelection && domSelection.rangeCount > 0) { + domSelection.removeAllRanges() + } + + if (selection) { + Transforms.deselect(editor) + } + }, + + findDocumentOrShadowRoot: (editor) => { + const el = DOMEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (root instanceof Document || root instanceof ShadowRoot) { + return root + } + + return el.ownerDocument + }, + + findEventRange: (editor, event) => { + if ('nativeEvent' in event) { + event = event.nativeEvent + } + + const {clientX: x, clientY: y, target} = event + + if (x == null || y == null) { + throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`) + } + + const node = DOMEditor.toSlateNode(editor, event.target) + const path = DOMEditor.findPath(editor, node) + + // If the drop target is inside a void node, move it into either the + // next or previous node, depending on which side the `x` and `y` + // coordinates are closest to. + if (Element.isElement(node) && Editor.isVoid(editor, node)) { + const rect = target.getBoundingClientRect() + const isPrev = editor.isInline(node) + ? x - rect.left < rect.left + rect.width - x + : y - rect.top < rect.top + rect.height - y + + const edge = Editor.point(editor, path, { + edge: isPrev ? 'start' : 'end', + }) + const point = isPrev + ? Editor.before(editor, edge) + : Editor.after(editor, edge) + + if (point) { + const range = Editor.range(editor, point) + return range + } + } + + // Else resolve a range from the caret position where the drop occured. + let domRange: globalThis.Range | null = null + const {document} = DOMEditor.getWindow(editor) + + // COMPAT: In Firefox, `caretRangeFromPoint` doesn't exist. (2016/07/25) + if (document.caretRangeFromPoint) { + domRange = document.caretRangeFromPoint(x, y) + } else { + const position = document.caretPositionFromPoint(x, y) + + if (position) { + domRange = document.createRange() + domRange.setStart(position.offsetNode, position.offset) + domRange.setEnd(position.offsetNode, position.offset) + } + } + + if (!domRange) { + throw new Error(`Cannot resolve a Slate range from a DOM event: ${event}`) + } + + // Resolve a Slate range from the DOM range. + const range = DOMEditor.toSlateRange(editor, domRange, { + exactMatch: false, + suppressThrow: false, + }) + return range + }, + + findKey: (_editor, node) => { + let key = NODE_TO_KEY.get(node) + + if (!key) { + key = new Key() + NODE_TO_KEY.set(node, key) + } + + return key + }, + + findPath: (_editor, node) => { + const path: Path = [] + let child = node + + while (true) { + const parent = NODE_TO_PARENT.get(child) + + if (parent == null) { + if (Editor.isEditor(child)) { + return path + } else { + break + } + } + + const i = NODE_TO_INDEX.get(child) + + if (i == null) { + break + } + + path.unshift(i) + child = parent + } + + throw new Error( + `Unable to find the path for Slate node: ${Scrubber.stringify(node)}`, + ) + }, + + focus: (editor, options = {retries: 5}) => { + // Return if already focused + if (IS_FOCUSED.get(editor)) { + return + } + + // Return if no dom node is associated with the editor, which means the editor is not yet mounted + // or has been unmounted. This can happen especially, while retrying to focus the editor. + if (!EDITOR_TO_ELEMENT.get(editor)) { + return + } + + // Retry setting focus if the editor has pending operations. + // The DOM (selection) is unstable while changes are applied. + // Retry until retries are exhausted or editor is focused. + if (options.retries <= 0) { + throw new Error( + 'Could not set focus, editor seems stuck with pending operations', + ) + } + if (editor.operations.length > 0) { + setTimeout(() => { + DOMEditor.focus(editor, {retries: options.retries - 1}) + }, 10) + return + } + + const el = DOMEditor.toDOMNode(editor, editor) + const root = DOMEditor.findDocumentOrShadowRoot(editor) + if (root.activeElement !== el) { + // Ensure that the DOM selection state is set to the editor's selection + if (editor.selection && root instanceof Document) { + const domSelection = getSelection(root) + const domRange = DOMEditor.toDOMRange(editor, editor.selection) + domSelection?.removeAllRanges() + domSelection?.addRange(domRange) + } + // Create a new selection in the top of the document if missing + if (!editor.selection) { + Transforms.select(editor, Editor.start(editor, [])) + } + // IS_FOCUSED should be set before calling el.focus() to ensure that + // FocusedContext is updated to the correct value + IS_FOCUSED.set(editor, true) + el.focus({preventScroll: true}) + } + }, + + getWindow: (editor) => { + const window = EDITOR_TO_WINDOW.get(editor) + if (!window) { + throw new Error('Unable to find a host window element for this editor') + } + return window + }, + + hasDOMNode: (editor, target, options = {}) => { + const {editable = false} = options + const editorEl = DOMEditor.toDOMNode(editor, editor) + let targetEl: HTMLElement | null = null + + // COMPAT: In Firefox, reading `target.nodeType` will throw an error if + // target is originating from an internal "restricted" element (e.g. a + // stepper arrow on a number input). (2018/05/04) + // https://github.com/ianstormtaylor/slate/issues/1819 + try { + targetEl = ( + isDOMElement(target) ? target : target.parentElement + ) as HTMLElement + } catch (err) { + if ( + err instanceof Error && + !err.message.includes('Permission denied to access property "nodeType"') + ) { + throw err + } + } + + if (!targetEl) { + return false + } + + return ( + closestShadowAware(targetEl, `[data-slate-editor]`) === editorEl && + (!editable || targetEl.isContentEditable + ? true + : (typeof targetEl.isContentEditable === 'boolean' && // isContentEditable exists only on HTMLElement, and on other nodes it will be undefined + // this is the core logic that lets you know you got the right editor.selection instead of null when editor is contenteditable="false"(readOnly) + closestShadowAware(targetEl, '[contenteditable="false"]') === + editorEl) || + !!targetEl.getAttribute('data-slate-zero-width')) + ) + }, + + hasEditableTarget: (editor, target): target is DOMNode => + isDOMNode(target) && DOMEditor.hasDOMNode(editor, target, {editable: true}), + + hasRange: (editor, range) => { + const {anchor, focus} = range + return ( + Editor.hasPath(editor, anchor.path) && Editor.hasPath(editor, focus.path) + ) + }, + + hasSelectableTarget: (editor, target) => + DOMEditor.hasEditableTarget(editor, target) || + DOMEditor.isTargetInsideNonReadonlyVoid(editor, target), + + hasTarget: (editor, target): target is DOMNode => + isDOMNode(target) && DOMEditor.hasDOMNode(editor, target), + + insertData: (editor, data) => { + editor.insertData(data) + }, + + insertFragmentData: (editor, data) => editor.insertFragmentData(data), + + insertTextData: (editor, data) => editor.insertTextData(data), + + isComposing: (editor) => { + return !!IS_COMPOSING.get(editor) + }, + + isFocused: (editor) => !!IS_FOCUSED.get(editor), + + isReadOnly: (editor) => !!IS_READ_ONLY.get(editor), + + isTargetInsideNonReadonlyVoid: (editor, target) => { + if (IS_READ_ONLY.get(editor)) { + return false + } + + const slateNode = + DOMEditor.hasTarget(editor, target) && + DOMEditor.toSlateNode(editor, target) + return Element.isElement(slateNode) && Editor.isVoid(editor, slateNode) + }, + + setFragmentData: (editor, data, originEvent) => + editor.setFragmentData(data, originEvent), + + toDOMNode: (editor, node) => { + const KEY_TO_ELEMENT = EDITOR_TO_KEY_TO_ELEMENT.get(editor) + const domNode = Editor.isEditor(node) + ? EDITOR_TO_ELEMENT.get(editor) + : KEY_TO_ELEMENT?.get(DOMEditor.findKey(editor, node)) + + if (!domNode) { + throw new Error( + `Cannot resolve a DOM node from Slate node: ${Scrubber.stringify(node)}`, + ) + } + + return domNode + }, + + toDOMPoint: (editor, point) => { + const [node] = Editor.node(editor, point.path) + const el = DOMEditor.toDOMNode(editor, node) + let domPoint: DOMPoint | undefined + + // If we're inside a void node, force the offset to 0, otherwise the zero + // width spacing character will result in an incorrect offset of 1 + if (Editor.void(editor, {at: point})) { + point = {path: point.path, offset: 0} + } + + // For each leaf, we need to isolate its content, which means filtering + // to its direct text and zero-width spans. (We have to filter out any + // other siblings that may have been rendered alongside them.) + const selector = `[data-slate-string], [data-slate-zero-width]` + const texts = Array.from(el.querySelectorAll(selector)) + let start = 0 + + for (let i = 0; i < texts.length; i++) { + const text = texts[i]! + const domNode = text.childNodes[0] as HTMLElement + + if (domNode == null || domNode.textContent == null) { + continue + } + + const {length} = domNode.textContent + const attr = text.getAttribute('data-slate-length') + const trueLength = attr == null ? length : parseInt(attr, 10) + const end = start + trueLength + + // Prefer putting the selection inside the mark placeholder to ensure + // composed text is displayed with the correct marks. + const nextText = texts[i + 1] + if ( + point.offset === end && + nextText?.hasAttribute('data-slate-mark-placeholder') + ) { + const domText = nextText.childNodes[0] + + domPoint = [ + // COMPAT: If we don't explicity set the dom point to be on the actual + // dom text element, chrome will put the selection behind the actual dom + // text element, causing domRange.getBoundingClientRect() calls on a collapsed + // selection to return incorrect zero values (https://bugs.chromium.org/p/chromium/issues/detail?id=435438) + // which will cause issues when scrolling to it. + domText instanceof DOMText ? domText : nextText, + nextText.textContent?.startsWith('\uFEFF') ? 1 : 0, + ] + break + } + + if (point.offset <= end) { + const offset = Math.min(length, Math.max(0, point.offset - start)) + domPoint = [domNode, offset] + break + } + + start = end + } + + if (!domPoint) { + throw new Error( + `Cannot resolve a DOM point from Slate point: ${Scrubber.stringify( + point, + )}`, + ) + } + + return domPoint + }, + + toDOMRange: (editor, range) => { + const {anchor, focus} = range + const isBackward = Range.isBackward(range) + const domAnchor = DOMEditor.toDOMPoint(editor, anchor) + const domFocus = Range.isCollapsed(range) + ? domAnchor + : DOMEditor.toDOMPoint(editor, focus) + + const window = DOMEditor.getWindow(editor) + const domRange = window.document.createRange() + const [startNode, startOffset] = isBackward ? domFocus : domAnchor + const [endNode, endOffset] = isBackward ? domAnchor : domFocus + + // A slate Point at zero-width Leaf always has an offset of 0 but a native DOM selection at + // zero-width node has an offset of 1 so we have to check if we are in a zero-width node and + // adjust the offset accordingly. + const startEl = ( + isDOMElement(startNode) ? startNode : startNode.parentElement + ) as HTMLElement + const isStartAtZeroWidth = !!startEl.getAttribute('data-slate-zero-width') + const endEl = ( + isDOMElement(endNode) ? endNode : endNode.parentElement + ) as HTMLElement + const isEndAtZeroWidth = !!endEl.getAttribute('data-slate-zero-width') + + domRange.setStart(startNode, isStartAtZeroWidth ? 1 : startOffset) + domRange.setEnd(endNode, isEndAtZeroWidth ? 1 : endOffset) + return domRange + }, + + toSlateNode: (_editor, domNode) => { + let domEl = isDOMElement(domNode) ? domNode : domNode.parentElement + + if (domEl && !domEl.hasAttribute('data-slate-node')) { + domEl = domEl.closest(`[data-slate-node]`) + } + + const node = domEl ? ELEMENT_TO_NODE.get(domEl as HTMLElement) : null + + if (!node) { + throw new Error(`Cannot resolve a Slate node from DOM node: ${domEl}`) + } + + return node + }, + + toSlatePoint: ( + editor: Editor, + domPoint: DOMPoint, + options: { + exactMatch: boolean + suppressThrow: T + searchDirection?: 'forward' | 'backward' + }, + ): T extends true ? Point | null : Point => { + const {exactMatch, suppressThrow, searchDirection} = options + const [nearestNode, nearestOffset] = exactMatch + ? domPoint + : normalizeDOMPoint(domPoint) + const parentNode = nearestNode.parentNode as DOMElement + let textNode: DOMElement | null = null + let offset = 0 + + if (parentNode) { + const editorEl = DOMEditor.toDOMNode(editor, editor) + const potentialVoidNode = parentNode.closest('[data-slate-void="true"]') + // Need to ensure that the closest void node is actually a void node + // within this editor, and not a void node within some parent editor. This can happen + // if this editor is within a void node of another editor ("nested editors", like in + // the "Editable Voids" example on the docs site). + const voidNode = + potentialVoidNode && containsShadowAware(editorEl, potentialVoidNode) + ? potentialVoidNode + : null + const potentialNonEditableNode = parentNode.closest( + '[contenteditable="false"]', + ) + const nonEditableNode = + potentialNonEditableNode && + containsShadowAware(editorEl, potentialNonEditableNode) + ? potentialNonEditableNode + : null + let leafNode = parentNode.closest('[data-slate-leaf]') + let domNode: DOMElement | null = null + + // Calculate how far into the text node the `nearestNode` is, so that we + // can determine what the offset relative to the text node is. + if (leafNode) { + textNode = leafNode.closest('[data-slate-node="text"]') + + if (textNode) { + const window = DOMEditor.getWindow(editor) + const range = window.document.createRange() + range.setStart(textNode, 0) + range.setEnd(nearestNode, nearestOffset) + + const contents = range.cloneContents() + const removals = [ + ...Array.prototype.slice.call( + contents.querySelectorAll('[data-slate-zero-width]'), + ), + ...Array.prototype.slice.call( + contents.querySelectorAll('[contenteditable=false]'), + ), + ] + + removals.forEach((el) => { + // COMPAT: While composing at the start of a text node, some keyboards put + // the text content inside the zero width space. + if ( + IS_ANDROID && + !exactMatch && + el.hasAttribute('data-slate-zero-width') && + el.textContent.length > 0 && + el.textContext !== '\uFEFF' + ) { + if (el.textContent.startsWith('\uFEFF')) { + el.textContent = el.textContent.slice(1) + } + + return + } + + el!.parentNode!.removeChild(el) + }) + + // COMPAT: Edge has a bug where Range.prototype.toString() will + // convert \n into \r\n. The bug causes a loop when slate-dom + // attempts to reposition its cursor to match the native position. Use + // textContent.length instead. + // https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/10291116/ + offset = contents.textContent!.length + domNode = textNode + } + } else if (voidNode) { + // For void nodes, the element with the offset key will be a cousin, not an + // ancestor, so find it by going down from the nearest void parent and taking the + // first one that isn't inside a nested editor. + const leafNodes = voidNode.querySelectorAll('[data-slate-leaf]') + for (let index = 0; index < leafNodes.length; index++) { + const current = leafNodes[index]! + if (DOMEditor.hasDOMNode(editor, current)) { + leafNode = current + break + } + } + + // COMPAT: In read-only editors the leaf is not rendered. + if (!leafNode) { + offset = 1 + } else { + textNode = leafNode.closest('[data-slate-node="text"]')! + domNode = leafNode + offset = domNode.textContent!.length + domNode.querySelectorAll('[data-slate-zero-width]').forEach((el) => { + offset -= el.textContent!.length + }) + } + } else if (nonEditableNode) { + // Find the edge of the nearest leaf in `searchDirection` + const getLeafNodes = (node: DOMElement | null | undefined) => + node + ? node.querySelectorAll( + // Exclude leaf nodes in nested editors + '[data-slate-leaf]:not(:scope [data-slate-editor] [data-slate-leaf])', + ) + : [] + const elementNode = nonEditableNode.closest( + '[data-slate-node="element"]', + ) + + if (searchDirection === 'backward' || !searchDirection) { + const leafNodes = [ + ...getLeafNodes(elementNode?.previousElementSibling), + ...getLeafNodes(elementNode), + ] + + leafNode = + leafNodes.findLast((leaf) => isBefore(nonEditableNode, leaf)) ?? + null + + if (leafNode) { + searchDirection === 'backward' + } + } + + if (searchDirection === 'forward' || !searchDirection) { + const leafNodes = [ + ...getLeafNodes(elementNode), + ...getLeafNodes(elementNode?.nextElementSibling), + ] + + leafNode = + leafNodes.find((leaf) => isAfter(nonEditableNode, leaf)) ?? null + + if (leafNode) { + searchDirection === 'forward' + } + } + + if (leafNode) { + textNode = leafNode.closest('[data-slate-node="text"]')! + domNode = leafNode + if (searchDirection === 'forward') { + offset = 0 + } else { + offset = domNode.textContent!.length + domNode + .querySelectorAll('[data-slate-zero-width]') + .forEach((el) => { + offset -= el.textContent!.length + }) + } + } + } + + if ( + domNode && + offset === domNode.textContent!.length && + // COMPAT: Android IMEs might remove the zero width space while composing, + // and we don't add it for line-breaks. + IS_ANDROID && + domNode.getAttribute('data-slate-zero-width') === 'z' && + domNode.textContent?.startsWith('\uFEFF') && + // COMPAT: If the parent node is a Slate zero-width space, editor is + // because the text node should have no characters. However, during IME + // composition the ASCII characters will be prepended to the zero-width + // space, so subtract 1 from the offset to account for the zero-width + // space character. + (parentNode.hasAttribute('data-slate-zero-width') || + // COMPAT: In Firefox, `range.cloneContents()` returns an extra trailing '\n' + // when the document ends with a new-line character. This results in the offset + // length being off by one, so we need to subtract one to account for this. + (IS_FIREFOX && domNode.textContent?.endsWith('\n\n'))) + ) { + offset-- + } + } + + if (IS_ANDROID && !textNode && !exactMatch) { + const node = parentNode.hasAttribute('data-slate-node') + ? parentNode + : parentNode.closest('[data-slate-node]') + + if (node && DOMEditor.hasDOMNode(editor, node, {editable: true})) { + const slateNode = DOMEditor.toSlateNode(editor, node) + let {path, offset} = Editor.start( + editor, + DOMEditor.findPath(editor, slateNode), + ) + + if (!node.querySelector('[data-slate-leaf]')) { + offset = nearestOffset + } + + return {path, offset} as T extends true ? Point | null : Point + } + } + + if (!textNode) { + if (suppressThrow) { + return null as T extends true ? Point | null : Point + } + throw new Error( + `Cannot resolve a Slate point from DOM point: ${domPoint}`, + ) + } + + // COMPAT: If someone is clicking from one Slate editor into another, + // the select event fires twice, once for the old editor's `element` + // first, and then afterwards for the correct `element`. (2017/03/03) + const slateNode = DOMEditor.toSlateNode(editor, textNode!) + const path = DOMEditor.findPath(editor, slateNode) + + // For childless void elements, the virtual text node produces a path + // like [blockIndex, 0] but the Slate tree has no child there. Truncate + // to the void element's path. + if (path.length > 1) { + const parentPath = path.slice(0, -1) + const parentNode = Node.get(editor, parentPath) + if (Element.isElement(parentNode) && editor.isVoid(parentNode)) { + return {path: parentPath, offset: 0} as T extends true + ? Point | null + : Point + } + } + + return {path, offset} as T extends true ? Point | null : Point + }, + + toSlateRange: ( + editor: Editor, + domRange: DOMRange | DOMStaticRange | DOMSelection, + options: { + exactMatch: boolean + suppressThrow: T + }, + ): T extends true ? Range | null : Range => { + const {exactMatch, suppressThrow} = options + const el = isDOMSelection(domRange) + ? domRange.anchorNode + : domRange.startContainer + let anchorNode: globalThis.Node | null = null + let anchorOffset: number = 0 + let focusNode: globalThis.Node | null = null + let focusOffset: number = 0 + let isCollapsed: boolean = false + + if (el) { + if (isDOMSelection(domRange)) { + // COMPAT: In firefox the normal seletion way does not work + // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223) + if (IS_FIREFOX && domRange.rangeCount > 1) { + focusNode = domRange.focusNode // Focus node works fine + const firstRange = domRange.getRangeAt(0) + const lastRange = domRange.getRangeAt(domRange.rangeCount - 1) + + // Here we are in the contenteditable mode of a table in firefox + if ( + focusNode instanceof HTMLTableRowElement && + firstRange.startContainer instanceof HTMLTableRowElement && + lastRange.startContainer instanceof HTMLTableRowElement + ) { + // HTMLElement, becouse Element is a slate element + function getLastChildren(element: HTMLElement): HTMLElement { + if (element.childElementCount > 0) { + return getLastChildren(element.children[0] as HTMLElement) + } else { + return element + } + } + + const firstNodeRow = + firstRange.startContainer as HTMLTableRowElement + const lastNodeRow = lastRange.startContainer as HTMLTableRowElement + + // This should never fail as "The HTMLElement interface represents any HTML element." + const firstNode = getLastChildren( + firstNodeRow.children[firstRange.startOffset] as HTMLElement, + ) + const lastNode = getLastChildren( + lastNodeRow.children[lastRange.startOffset] as HTMLElement, + ) + + // Zero, as we allways take the right one as the anchor point + focusOffset = 0 + + if (lastNode.childNodes.length > 0) { + anchorNode = lastNode.childNodes[0] ?? null + } else { + anchorNode = lastNode + } + + if (firstNode.childNodes.length > 0) { + focusNode = firstNode.childNodes[0] ?? null + } else { + focusNode = firstNode + } + + if (lastNode instanceof HTMLElement) { + anchorOffset = (lastNode as HTMLElement).innerHTML.length + } else { + // Fallback option + anchorOffset = 0 + } + } else { + // This is the read only mode of a firefox table + // Right to left + if (firstRange.startContainer === focusNode) { + anchorNode = lastRange.endContainer + anchorOffset = lastRange.endOffset + focusOffset = firstRange.startOffset + } else { + // Left to right + anchorNode = firstRange.startContainer + anchorOffset = firstRange.endOffset + focusOffset = lastRange.startOffset + } + } + } else { + anchorNode = domRange.anchorNode + anchorOffset = domRange.anchorOffset + focusNode = domRange.focusNode + focusOffset = domRange.focusOffset + } + + // COMPAT: There's a bug in chrome that always returns `true` for + // `isCollapsed` for a Selection that comes from a ShadowRoot. + // (2020/08/08) + // https://bugs.chromium.org/p/chromium/issues/detail?id=447523 + // IsCollapsed might not work in firefox, but this will + if ((IS_CHROME && hasShadowRoot(anchorNode)) || IS_FIREFOX) { + isCollapsed = + domRange.anchorNode === domRange.focusNode && + domRange.anchorOffset === domRange.focusOffset + } else { + isCollapsed = domRange.isCollapsed + } + } else { + anchorNode = domRange.startContainer + anchorOffset = domRange.startOffset + focusNode = domRange.endContainer + focusOffset = domRange.endOffset + isCollapsed = domRange.collapsed + } + } + + if ( + anchorNode == null || + focusNode == null || + anchorOffset == null || + focusOffset == null + ) { + throw new Error( + `Cannot resolve a Slate range from DOM range: ${domRange}`, + ) + } + + // COMPAT: Firefox sometimes includes an extra \n (rendered by TextString + // when isTrailing is true) in the focusOffset, resulting in an invalid + // Slate point. (2023/11/01) + if ( + IS_FIREFOX && + focusNode.textContent?.endsWith('\n\n') && + focusOffset === focusNode.textContent.length + ) { + focusOffset-- + } + + const anchor = DOMEditor.toSlatePoint(editor, [anchorNode, anchorOffset], { + exactMatch, + suppressThrow, + }) + if (!anchor) { + return null as T extends true ? Range | null : Range + } + + const focusBeforeAnchor = + isBefore(anchorNode, focusNode) || + (anchorNode === focusNode && focusOffset < anchorOffset) + const focus = isCollapsed + ? anchor + : DOMEditor.toSlatePoint(editor, [focusNode, focusOffset], { + exactMatch, + suppressThrow, + searchDirection: focusBeforeAnchor ? 'forward' : 'backward', + }) + if (!focus) { + return null as T extends true ? Range | null : Range + } + + let range: Range = {anchor: anchor as Point, focus: focus as Point} + // if the selection is a hanging range that ends in a void + // and the DOM focus is an Element + // (meaning that the selection ends before the element) + // unhang the range to avoid mistakenly including the void + if ( + Range.isExpanded(range) && + Range.isForward(range) && + isDOMElement(focusNode) && + Editor.void(editor, {at: range.focus, mode: 'highest'}) + ) { + range = Editor.unhangRange(editor, range, {voids: true}) + } + + return range as unknown as T extends true ? Range | null : Range + }, +} diff --git a/packages/editor/src/slate-dom/plugin/with-dom.ts b/packages/editor/src/slate-dom/plugin/with-dom.ts new file mode 100644 index 000000000..62bfbac86 --- /dev/null +++ b/packages/editor/src/slate-dom/plugin/with-dom.ts @@ -0,0 +1,382 @@ +import { + Editor, + Element, + Node, + Path, + Point, + Range, + Transforms, + type Operation, + type PathRef, +} from '../../slate' +import { + transformPendingPoint, + transformPendingRange, + transformTextDiff, + type TextDiff, +} from '../utils/diff-text' +import {getPlainText, getSlateFragmentAttribute, isDOMText} from '../utils/dom' +import type {Key} from '../utils/key' +import {findCurrentLineRange} from '../utils/lines' +import { + EDITOR_TO_KEY_TO_ELEMENT, + EDITOR_TO_ON_CHANGE, + EDITOR_TO_PENDING_ACTION, + EDITOR_TO_PENDING_DIFFS, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_PENDING_SELECTION, + EDITOR_TO_SCHEDULE_FLUSH, + EDITOR_TO_USER_MARKS, + EDITOR_TO_USER_SELECTION, + IS_NODE_MAP_DIRTY, + NODE_TO_KEY, +} from '../utils/weak-maps' +import {DOMEditor} from './dom-editor' + +/** + * `withDOM` adds DOM specific behaviors to the editor. + * + * If you are using TypeScript, you must extend Slate's CustomTypes to use + * this plugin. + * + * See https://docs.slatejs.org/concepts/11-typescript to learn how. + */ + +export const withDOM = ( + editor: T, + clipboardFormatKey = 'x-slate-fragment', +): T & DOMEditor => { + const e = editor as T & DOMEditor + const {apply, onChange, deleteBackward, addMark, removeMark} = e + + // The WeakMap which maps a key to a specific HTMLElement must be scoped to the editor instance to + // avoid collisions between editors in the DOM that share the same value. + EDITOR_TO_KEY_TO_ELEMENT.set(e, new WeakMap()) + + e.addMark = (key, value) => { + EDITOR_TO_SCHEDULE_FLUSH.get(e)?.() + + if ( + !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && + EDITOR_TO_PENDING_DIFFS.get(e)?.length + ) { + // Ensure the current pending diffs originating from changes before the addMark + // are applied with the current formatting + EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) + } + + EDITOR_TO_USER_MARKS.delete(e) + + addMark(key, value) + } + + e.removeMark = (key) => { + if ( + !EDITOR_TO_PENDING_INSERTION_MARKS.get(e) && + EDITOR_TO_PENDING_DIFFS.get(e)?.length + ) { + // Ensure the current pending diffs originating from changes before the addMark + // are applied with the current formatting + EDITOR_TO_PENDING_INSERTION_MARKS.set(e, null) + } + + EDITOR_TO_USER_MARKS.delete(e) + + removeMark(key) + } + + e.deleteBackward = (unit) => { + if (unit !== 'line') { + return deleteBackward(unit) + } + + if (e.selection && Range.isCollapsed(e.selection)) { + const parentBlockEntry = Editor.above(e, { + match: (n) => Element.isElement(n) && Editor.isBlock(e, n), + at: e.selection, + }) + + if (parentBlockEntry) { + const [, parentBlockPath] = parentBlockEntry + const parentElementRange = Editor.range( + e, + parentBlockPath, + e.selection.anchor, + ) + + const currentLineRange = findCurrentLineRange(e, parentElementRange) + + if (!Range.isCollapsed(currentLineRange)) { + Transforms.delete(e, {at: currentLineRange}) + } + } + } + } + + // This attempts to reset the NODE_TO_KEY entry to the correct value + // as apply() changes the object reference and hence invalidates the NODE_TO_KEY entry + e.apply = (op: Operation) => { + const matches: [Path, Key][] = [] + const pathRefMatches: [PathRef, Key][] = [] + + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(e) + if (pendingDiffs?.length) { + const transformed = pendingDiffs + .map((textDiff) => transformTextDiff(textDiff, op)) + .filter(Boolean) as TextDiff[] + + EDITOR_TO_PENDING_DIFFS.set(e, transformed) + } + + const pendingSelection = EDITOR_TO_PENDING_SELECTION.get(e) + if (pendingSelection) { + EDITOR_TO_PENDING_SELECTION.set( + e, + transformPendingRange(e, pendingSelection, op), + ) + } + + const pendingAction = EDITOR_TO_PENDING_ACTION.get(e) + if (pendingAction?.at) { + const at = Point.isPoint(pendingAction?.at) + ? transformPendingPoint(e, pendingAction.at, op) + : transformPendingRange(e, pendingAction.at, op) + + EDITOR_TO_PENDING_ACTION.set(e, at ? {...pendingAction, at} : null) + } + + switch (op.type) { + case 'insert_text': + case 'remove_text': + case 'set_node': + case 'split_node': { + matches.push(...getMatches(e, op.path)) + break + } + + case 'set_selection': { + // Selection was manually set, don't restore the user selection after the change. + EDITOR_TO_USER_SELECTION.get(e)?.unref() + EDITOR_TO_USER_SELECTION.delete(e) + break + } + + case 'insert_node': + case 'remove_node': { + matches.push(...getMatches(e, Path.parent(op.path))) + break + } + + case 'merge_node': { + const prevPath = Path.previous(op.path) + matches.push(...getMatches(e, prevPath)) + break + } + + case 'move_node': { + const commonPath = Path.common( + Path.parent(op.path), + Path.parent(op.newPath), + ) + matches.push(...getMatches(e, commonPath)) + + let changedPath: Path + if (Path.isBefore(op.path, op.newPath)) { + matches.push(...getMatches(e, Path.parent(op.path))) + changedPath = op.newPath + } else { + matches.push(...getMatches(e, Path.parent(op.newPath))) + changedPath = op.path + } + + const changedNode = Node.get(editor, Path.parent(changedPath)) + const changedNodeKey = DOMEditor.findKey(e, changedNode) + const changedPathRef = Editor.pathRef(e, Path.parent(changedPath)) + pathRefMatches.push([changedPathRef, changedNodeKey]) + + break + } + } + + apply(op) + + switch (op.type) { + case 'insert_node': + case 'remove_node': + case 'merge_node': + case 'move_node': + case 'split_node': + case 'insert_text': + case 'remove_text': + case 'set_selection': { + // FIXME: Rename to something like IS_DOM_EDITOR_DESYNCED + // to better reflect reality, see #5792 + IS_NODE_MAP_DIRTY.set(e, true) + } + } + + for (const [path, key] of matches) { + const [node] = Editor.node(e, path) + NODE_TO_KEY.set(node, key) + } + + for (const [pathRef, key] of pathRefMatches) { + if (pathRef.current) { + const [node] = Editor.node(e, pathRef.current) + NODE_TO_KEY.set(node, key) + } + + pathRef.unref() + } + } + + e.setFragmentData = (data: Pick) => { + const {selection} = e + + if (!selection) { + return + } + + const [start, end] = Range.edges(selection) + const startVoid = Editor.void(e, {at: start.path}) + const endVoid = Editor.void(e, {at: end.path}) + + if (Range.isCollapsed(selection) && !startVoid) { + return + } + + // Create a fake selection so that we can add a Base64-encoded copy of the + // fragment to the HTML, to decode on future pastes. + const domRange = DOMEditor.toDOMRange(e, selection) + let contents = domRange.cloneContents() + let attach = contents.childNodes[0] as HTMLElement + + // Make sure attach is non-empty, since empty nodes will not get copied. + contents.childNodes.forEach((node) => { + if (node.textContent && node.textContent.trim() !== '') { + attach = node as HTMLElement + } + }) + + // COMPAT: If the end node is a void node, we need to move the end of the + // range from the void node's spacer span, to the end of the void node's + // content, since the spacer is before void's content in the DOM. + if (endVoid) { + const [voidNode] = endVoid + const r = domRange.cloneRange() + const domNode = DOMEditor.toDOMNode(e, voidNode) + r.setEndAfter(domNode) + contents = r.cloneContents() + } + + // COMPAT: If the start node is a void node, we need to attach the encoded + // fragment to the void node's content node instead of the spacer, because + // attaching it to empty `
/` nodes will end up having it erased by + // most browsers. (2018/04/27) + if (startVoid) { + attach = contents.querySelector('[data-slate-spacer]')! as HTMLElement + } + + // Remove any zero-width space spans from the cloned DOM so that they don't + // show up elsewhere when pasted. + Array.from(contents.querySelectorAll('[data-slate-zero-width]')).forEach( + (zw) => { + const isNewline = zw.getAttribute('data-slate-zero-width') === 'n' + zw.textContent = isNewline ? '\n' : '' + }, + ) + + // Set a `data-slate-fragment` attribute on a non-empty node, so it shows up + // in the HTML, and can be used for intra-Slate pasting. If it's a text + // node, wrap it in a `` so we have something to set an attribute on. + if (isDOMText(attach)) { + const span = attach.ownerDocument.createElement('span') + // COMPAT: In Chrome and Safari, if we don't add the `white-space` style + // then leading and trailing spaces will be ignored. (2017/09/21) + span.style.whiteSpace = 'pre' + span.appendChild(attach) + contents.appendChild(span) + attach = span + } + + const fragment = e.getFragment() + const string = JSON.stringify(fragment) + const encoded = window.btoa(encodeURIComponent(string)) + attach.setAttribute('data-slate-fragment', encoded) + data.setData(`application/${clipboardFormatKey}`, encoded) + + // Add the content to a
so that we can get its inner HTML. + const div = contents.ownerDocument.createElement('div') + div.appendChild(contents) + div.setAttribute('hidden', 'true') + contents.ownerDocument.body.appendChild(div) + data.setData('text/html', div.innerHTML) + data.setData('text/plain', getPlainText(div)) + contents.ownerDocument.body.removeChild(div) + return data + } + + e.insertData = (data: DataTransfer) => { + if (!e.insertFragmentData(data)) { + e.insertTextData(data) + } + } + + e.insertFragmentData = (data: DataTransfer): boolean => { + /** + * Checking copied fragment from application/x-slate-fragment or data-slate-fragment + */ + const fragment = + data.getData(`application/${clipboardFormatKey}`) || + getSlateFragmentAttribute(data) + + if (fragment) { + const decoded = decodeURIComponent(window.atob(fragment)) + const parsed = JSON.parse(decoded) as Node[] + e.insertFragment(parsed) + return true + } + return false + } + + e.insertTextData = (data: DataTransfer): boolean => { + const text = data.getData('text/plain') + + if (text) { + const lines = text.split(/\r\n|\r|\n/) + let split = false + + for (const line of lines) { + if (split) { + Transforms.splitNodes(e, {always: true}) + } + + e.insertText(line) + split = true + } + return true + } + return false + } + + e.onChange = (options) => { + const onContextChange = EDITOR_TO_ON_CHANGE.get(e) + + if (onContextChange) { + onContextChange(options) + } + + onChange(options) + } + + return e +} + +const getMatches = (e: Editor, path: Path) => { + const matches: [Path, Key][] = [] + for (const [n, p] of Editor.levels(e, {at: path})) { + const key = DOMEditor.findKey(e, n) + matches.push([p, key]) + } + return matches +} diff --git a/packages/editor/src/slate-dom/utils/constants.ts b/packages/editor/src/slate-dom/utils/constants.ts new file mode 100644 index 000000000..a8a13e355 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/constants.ts @@ -0,0 +1 @@ +export const TRIPLE_CLICK = 3 diff --git a/packages/editor/src/slate-dom/utils/diff-text.ts b/packages/editor/src/slate-dom/utils/diff-text.ts new file mode 100644 index 000000000..8b68fbf6d --- /dev/null +++ b/packages/editor/src/slate-dom/utils/diff-text.ts @@ -0,0 +1,424 @@ +import { + Editor, + Element, + Node, + Path, + Point, + Range, + Text, + type Operation, +} from '../../slate' +import {EDITOR_TO_PENDING_DIFFS} from './weak-maps' + +export type StringDiff = { + start: number + end: number + text: string +} + +export type TextDiff = { + id: number + path: Path + diff: StringDiff +} + +/** + * Check whether a text diff was applied in a way we can perform the pending action on / + * recover the pending selection. + */ +export function verifyDiffState(editor: Editor, textDiff: TextDiff): boolean { + const {path, diff} = textDiff + if (!Editor.hasPath(editor, path)) { + return false + } + + const node = Node.get(editor, path) + if (!Text.isText(node)) { + return false + } + + if (diff.start !== node.text.length || diff.text.length === 0) { + return ( + node.text.slice(diff.start, diff.start + diff.text.length) === diff.text + ) + } + + const nextPath = Path.next(path) + if (!Editor.hasPath(editor, nextPath)) { + return false + } + + const nextNode = Node.get(editor, nextPath) + return Text.isText(nextNode) && nextNode.text.startsWith(diff.text) +} + +export function applyStringDiff(text: string, ...diffs: StringDiff[]) { + return diffs.reduce( + (text, diff) => + text.slice(0, diff.start) + diff.text + text.slice(diff.end), + text, + ) +} + +function longestCommonPrefixLength(str: string, another: string) { + const length = Math.min(str.length, another.length) + + for (let i = 0; i < length; i++) { + if (str.charAt(i) !== another.charAt(i)) { + return i + } + } + + return length +} + +function longestCommonSuffixLength( + str: string, + another: string, + max: number, +): number { + const length = Math.min(str.length, another.length, max) + + for (let i = 0; i < length; i++) { + if ( + str.charAt(str.length - i - 1) !== another.charAt(another.length - i - 1) + ) { + return i + } + } + + return length +} + +/** + * Remove redundant changes from the diff so that it spans the minimal possible range + */ +export function normalizeStringDiff(targetText: string, diff: StringDiff) { + const {start, end, text} = diff + const removedText = targetText.slice(start, end) + + const prefixLength = longestCommonPrefixLength(removedText, text) + const max = Math.min( + removedText.length - prefixLength, + text.length - prefixLength, + ) + const suffixLength = longestCommonSuffixLength(removedText, text, max) + + const normalized: StringDiff = { + start: start + prefixLength, + end: end - suffixLength, + text: text.slice(prefixLength, text.length - suffixLength), + } + + if (normalized.start === normalized.end && normalized.text.length === 0) { + return null + } + + return normalized +} + +/** + * Return a string diff that is equivalent to applying b after a spanning the range of + * both changes + */ +export function mergeStringDiffs( + targetText: string, + a: StringDiff, + b: StringDiff, +): StringDiff | null { + const start = Math.min(a.start, b.start) + const overlap = Math.max( + 0, + Math.min(a.start + a.text.length, b.end) - b.start, + ) + + const applied = applyStringDiff(targetText, a, b) + const sliceEnd = Math.max( + b.start + b.text.length, + a.start + + a.text.length + + (a.start + a.text.length > b.start ? b.text.length : 0) - + overlap, + ) + + const text = applied.slice(start, sliceEnd) + const end = Math.max(a.end, b.end - a.text.length + (a.end - a.start)) + return normalizeStringDiff(targetText, {start, end, text}) +} + +/** + * Get the slate range the text diff spans. + */ +export function targetRange(textDiff: TextDiff): Range { + const {path, diff} = textDiff + return { + anchor: {path, offset: diff.start}, + focus: {path, offset: diff.end}, + } +} + +/** + * Normalize a 'pending point' a.k.a a point based on the dom state before applying + * the pending diffs. Since the pending diffs might have been inserted with different + * marks we have to 'walk' the offset from the starting position to ensure we still + * have a valid point inside the document + */ +export function normalizePoint(editor: Editor, point: Point): Point | null { + let {path, offset} = point + if (!Editor.hasPath(editor, path)) { + return null + } + + let leaf = Node.get(editor, path) + if (!Text.isText(leaf)) { + return null + } + + const parentBlock = Editor.above(editor, { + match: (n) => Element.isElement(n) && Editor.isBlock(editor, n), + at: path, + }) + + if (!parentBlock) { + return null + } + + while (offset > leaf.text.length) { + const entry = Editor.next(editor, {at: path, match: Text.isText}) + if (!entry || !Path.isDescendant(entry[1], parentBlock[1])) { + return null + } + + offset -= leaf.text.length + leaf = entry[0] + path = entry[1] + } + + return {path, offset} +} + +/** + * Normalize a 'pending selection' to ensure it's valid in the current document state. + */ +export function normalizeRange(editor: Editor, range: Range): Range | null { + const anchor = normalizePoint(editor, range.anchor) + if (!anchor) { + return null + } + + if (Range.isCollapsed(range)) { + return {anchor, focus: anchor} + } + + const focus = normalizePoint(editor, range.focus) + if (!focus) { + return null + } + + return {anchor, focus} +} + +export function transformPendingPoint( + editor: Editor, + point: Point, + op: Operation, +): Point | null { + const pendingDiffs = EDITOR_TO_PENDING_DIFFS.get(editor) + const textDiff = pendingDiffs?.find(({path}) => Path.equals(path, point.path)) + + if (!textDiff || point.offset <= textDiff.diff.start) { + return Point.transform(point, op, {affinity: 'backward'}) + } + + const {diff} = textDiff + // Point references location inside the diff => transform the point based on the location + // the diff will be applied to and add the offset inside the diff. + if (point.offset <= diff.start + diff.text.length) { + const anchor = {path: point.path, offset: diff.start} + const transformed = Point.transform(anchor, op, { + affinity: 'backward', + }) + + if (!transformed) { + return null + } + + return { + path: transformed.path, + offset: transformed.offset + point.offset - diff.start, + } + } + + // Point references location after the diff + const anchor = { + path: point.path, + offset: point.offset - diff.text.length + diff.end - diff.start, + } + const transformed = Point.transform(anchor, op, { + affinity: 'backward', + }) + if (!transformed) { + return null + } + + if ( + op.type === 'split_node' && + Path.equals(op.path, point.path) && + anchor.offset < op.position && + diff.start < op.position + ) { + return transformed + } + + return { + path: transformed.path, + offset: transformed.offset + diff.text.length - diff.end + diff.start, + } +} + +export function transformPendingRange( + editor: Editor, + range: Range, + op: Operation, +): Range | null { + const anchor = transformPendingPoint(editor, range.anchor, op) + if (!anchor) { + return null + } + + if (Range.isCollapsed(range)) { + return {anchor, focus: anchor} + } + + const focus = transformPendingPoint(editor, range.focus, op) + if (!focus) { + return null + } + + return {anchor, focus} +} + +export function transformTextDiff( + textDiff: TextDiff, + op: Operation, +): TextDiff | null { + const {path, diff, id} = textDiff + + switch (op.type) { + case 'insert_text': { + if (!Path.equals(op.path, path) || op.offset >= diff.end) { + return textDiff + } + + if (op.offset <= diff.start) { + return { + diff: { + start: op.text.length + diff.start, + end: op.text.length + diff.end, + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start, + end: diff.end + op.text.length, + text: diff.text, + }, + id, + path, + } + } + case 'remove_text': { + if (!Path.equals(op.path, path) || op.offset >= diff.end) { + return textDiff + } + + if (op.offset + op.text.length <= diff.start) { + return { + diff: { + start: diff.start - op.text.length, + end: diff.end - op.text.length, + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start, + end: diff.end - op.text.length, + text: diff.text, + }, + id, + path, + } + } + case 'split_node': { + if (!Path.equals(op.path, path) || op.position >= diff.end) { + return { + diff, + id, + path: Path.transform(path, op, {affinity: 'backward'})!, + } + } + + if (op.position > diff.start) { + return { + diff: { + start: diff.start, + end: Math.min(op.position, diff.end), + text: diff.text, + }, + id, + path, + } + } + + return { + diff: { + start: diff.start - op.position, + end: diff.end - op.position, + text: diff.text, + }, + id, + path: Path.transform(path, op, {affinity: 'forward'})!, + } + } + case 'merge_node': { + if (!Path.equals(op.path, path)) { + return { + diff, + id, + path: Path.transform(path, op)!, + } + } + + return { + diff: { + start: diff.start + op.position, + end: diff.end + op.position, + text: diff.text, + }, + id, + path: Path.transform(path, op)!, + } + } + } + + const newPath = Path.transform(path, op) + if (!newPath) { + return null + } + + return { + diff, + path: newPath, + id, + } +} diff --git a/packages/editor/src/slate-dom/utils/dom.ts b/packages/editor/src/slate-dom/utils/dom.ts new file mode 100644 index 000000000..89a08eecd --- /dev/null +++ b/packages/editor/src/slate-dom/utils/dom.ts @@ -0,0 +1,430 @@ +import type {Editor} from '../../slate' +import {DOMEditor} from '../plugin/dom-editor' + +/** + * Types. + */ + +// COMPAT: This is required to prevent TypeScript aliases from doing some very +// weird things for Slate's types with the same name as globals. (2019/11/27) +// https://github.com/microsoft/TypeScript/issues/35002 +type DOMNode = globalThis.Node +type DOMComment = globalThis.Comment +type DOMElement = globalThis.Element +// DOMText is used as a value (instanceof) in dom-editor.ts, so we need both +// the type alias and a const reference to the global constructor. +type DOMText = globalThis.Text +const DOMText = globalThis.Text +type DOMRange = globalThis.Range +type DOMSelection = globalThis.Selection +type DOMStaticRange = globalThis.StaticRange + +export { + type DOMNode, + type DOMComment, + type DOMElement, + DOMText, + type DOMRange, + type DOMSelection, + type DOMStaticRange, +} + +declare global { + interface Window { + Selection: (typeof Selection)['constructor'] + DataTransfer: (typeof DataTransfer)['constructor'] + Node: (typeof Node)['constructor'] + } +} + +export type DOMPoint = [Node, number] + +/** + * Returns the host window of a DOM node + */ + +export const getDefaultView = (value: any): Window | null => { + return ( + (value && value.ownerDocument && value.ownerDocument.defaultView) || null + ) +} + +/** + * Check if a DOM node is a comment node. + */ + +export const isDOMComment = (value: any): value is DOMComment => { + return isDOMNode(value) && value.nodeType === 8 +} + +/** + * Check if a DOM node is an element node. + */ + +export const isDOMElement = (value: any): value is DOMElement => { + return isDOMNode(value) && value.nodeType === 1 +} + +/** + * Check if a value is a DOM node. + */ + +export const isDOMNode = (value: any): value is DOMNode => { + const window = getDefaultView(value) + return !!window && value instanceof window.Node +} + +/** + * Check if a value is a DOM selection. + */ + +export const isDOMSelection = (value: any): value is DOMSelection => { + const window = value && value.anchorNode && getDefaultView(value.anchorNode) + return !!window && value instanceof window.Selection +} + +/** + * Check if a DOM node is an element node. + */ + +export const isDOMText = (value: any): value is DOMText => { + return isDOMNode(value) && value.nodeType === 3 +} + +/** + * Checks whether a paste event is a plaintext-only event. + */ + +export const isPlainTextOnlyPaste = (event: ClipboardEvent) => { + return ( + event.clipboardData && + event.clipboardData.getData('text/plain') !== '' && + event.clipboardData.types.length === 1 + ) +} + +/** + * Normalize a DOM point so that it always refers to a text node. + */ + +export const normalizeDOMPoint = (domPoint: DOMPoint): DOMPoint => { + let [node, offset] = domPoint + + // If it's an element node, its offset refers to the index of its children + // including comment nodes, so try to find the right text child node. + if (isDOMElement(node) && node.childNodes.length) { + let isLast = offset === node.childNodes.length + let index = isLast ? offset - 1 : offset + ;[node, index] = getEditableChildAndIndex( + node, + index, + isLast ? 'backward' : 'forward', + ) + // If the editable child found is in front of input offset, we instead seek to its end + isLast = index < offset + + // If the node has children, traverse until we have a leaf node. Leaf nodes + // can be either text nodes, or other void DOM nodes. + while (isDOMElement(node) && node.childNodes.length) { + const i = isLast ? node.childNodes.length - 1 : 0 + node = getEditableChild(node, i, isLast ? 'backward' : 'forward') + } + + // Determine the new offset inside the text node. + offset = isLast && node.textContent != null ? node.textContent.length : 0 + } + + // Return the node and offset. + return [node, offset] +} + +/** + * Determines whether the active element is nested within a shadowRoot + */ + +export const hasShadowRoot = (node: Node | null) => { + let parent = node && node.parentNode + while (parent) { + if (parent.toString() === '[object ShadowRoot]') { + return true + } + parent = parent.parentNode + } + return false +} + +/** + * Get the nearest editable child and index at `index` in a `parent`, preferring + * `direction`. + */ + +export const getEditableChildAndIndex = ( + parent: DOMElement, + index: number, + direction: 'forward' | 'backward', +): [DOMNode, number] => { + const {childNodes} = parent + let child: ChildNode | undefined = childNodes[index] + let i = index + let triedForward = false + let triedBackward = false + + // While the child is a comment node, or an element node with no children, + // keep iterating to find a sibling non-void, non-comment node. + while ( + isDOMComment(child) || + (isDOMElement(child) && child.childNodes.length === 0) || + (isDOMElement(child) && child.getAttribute('contenteditable') === 'false') + ) { + if (triedForward && triedBackward) { + break + } + + if (i >= childNodes.length) { + triedForward = true + i = index - 1 + direction = 'backward' + continue + } + + if (i < 0) { + triedBackward = true + i = index + 1 + direction = 'forward' + continue + } + + child = childNodes[i] + index = i + i += direction === 'forward' ? 1 : -1 + } + + return [child!, index] +} + +/** + * Get the nearest editable child at `index` in a `parent`, preferring + * `direction`. + */ + +export const getEditableChild = ( + parent: DOMElement, + index: number, + direction: 'forward' | 'backward', +): DOMNode => { + const [child] = getEditableChildAndIndex(parent, index, direction) + return child +} + +/** + * Get a plaintext representation of the content of a node, accounting for block + * elements which get a newline appended. + * + * The domNode must be attached to the DOM. + */ + +export const getPlainText = (domNode: DOMNode) => { + let text = '' + + if (isDOMText(domNode) && domNode.nodeValue) { + return domNode.nodeValue + } + + if (isDOMElement(domNode)) { + for (const childNode of Array.from(domNode.childNodes)) { + text += getPlainText(childNode) + } + + const display = getComputedStyle(domNode).getPropertyValue('display') + + if (display === 'block' || display === 'list' || domNode.tagName === 'BR') { + text += '\n' + } + } + + return text +} + +/** + * Get x-slate-fragment attribute from data-slate-fragment + */ +const catchSlateFragment = /data-slate-fragment="(.+?)"/m +export const getSlateFragmentAttribute = ( + dataTransfer: DataTransfer, +): string | void => { + const htmlData = dataTransfer.getData('text/html') + const [, fragment] = htmlData.match(catchSlateFragment) || [] + return fragment +} + +/** + * Get the x-slate-fragment attribute that exist in text/html data + * and append it to the DataTransfer object + */ +export const getClipboardData = ( + dataTransfer: DataTransfer, + clipboardFormatKey = 'x-slate-fragment', +): DataTransfer => { + if (!dataTransfer.getData(`application/${clipboardFormatKey}`)) { + const fragment = getSlateFragmentAttribute(dataTransfer) + if (fragment) { + const clipboardData = new DataTransfer() + dataTransfer.types.forEach((type) => { + clipboardData.setData(type, dataTransfer.getData(type)) + }) + clipboardData.setData(`application/${clipboardFormatKey}`, fragment) + return clipboardData + } + } + return dataTransfer +} + +/** + * Get the dom selection from Shadow Root if possible, otherwise from the document + */ +export const getSelection = (root: Document | ShadowRoot): Selection | null => { + if ('getSelection' in root && typeof root.getSelection === 'function') { + return root.getSelection() + } + return document.getSelection() +} + +/** + * Check whether a mutation originates from a editable element inside the editor. + */ + +export const isTrackedMutation = ( + editor: Editor, + mutation: MutationRecord, + batch: MutationRecord[], +): boolean => { + const {target} = mutation + if (isDOMElement(target) && target.matches('[contentEditable="false"]')) { + return false + } + + const {document} = DOMEditor.getWindow(editor) + if (containsShadowAware(document, target)) { + return DOMEditor.hasDOMNode(editor, target, {editable: true}) + } + + const parentMutation = batch.find(({addedNodes, removedNodes}) => { + for (const node of addedNodes) { + if (node === target || containsShadowAware(node, target)) { + return true + } + } + + for (const node of removedNodes) { + if (node === target || containsShadowAware(node, target)) { + return true + } + } + + return false + }) + + if (!parentMutation || parentMutation === mutation) { + return false + } + + // Target add/remove is tracked. Track the mutation if we track the parent mutation. + return isTrackedMutation(editor, parentMutation, batch) +} + +/** + * Retrieves the deepest active element in the DOM, considering nested shadow DOMs. + */ +export const getActiveElement = () => { + let activeElement = document.activeElement + + while (activeElement?.shadowRoot && activeElement.shadowRoot?.activeElement) { + activeElement = activeElement?.shadowRoot?.activeElement + } + + return activeElement +} + +/** + * @returns `true` if `otherNode` is before `node` in the document; otherwise, `false`. + */ +export const isBefore = (node: DOMNode, otherNode: DOMNode): boolean => + Boolean( + node.compareDocumentPosition(otherNode) & Node.DOCUMENT_POSITION_PRECEDING, + ) + +/** + * @returns `true` if `otherNode` is after `node` in the document; otherwise, `false`. + */ +export const isAfter = (node: DOMNode, otherNode: DOMNode): boolean => + Boolean( + node.compareDocumentPosition(otherNode) & Node.DOCUMENT_POSITION_FOLLOWING, + ) + +/** + * Shadow DOM-aware version of Element.closest() + * Traverses up the DOM tree, crossing shadow DOM boundaries + */ +export const closestShadowAware = ( + element: DOMElement | null | undefined, + selector: string, +): DOMElement | null => { + if (!element) { + return null + } + + let current: DOMElement | null = element + + while (current) { + if (current.matches && current.matches(selector)) { + return current + } + + if (current.parentElement) { + current = current.parentElement + } else if (current.parentNode && 'host' in current.parentNode) { + current = (current.parentNode as ShadowRoot).host as DOMElement + } else { + return null + } + } + + return null +} + +/** + * Shadow DOM-aware version of Node.contains() + * Checks if a node contains another node, crossing shadow DOM boundaries + */ +export const containsShadowAware = ( + parent: DOMNode | null | undefined, + child: DOMNode | null | undefined, +): boolean => { + if (!parent || !child) { + return false + } + + if (parent.contains(child)) { + return true + } + + let current: DOMNode | null = child + + while (current) { + if (current === parent) { + return true + } + + if (current.parentNode) { + if ('host' in current.parentNode) { + current = (current.parentNode as ShadowRoot).host + } else { + current = current.parentNode + } + } else { + return false + } + } + + return false +} diff --git a/packages/editor/src/slate-dom/utils/environment.ts b/packages/editor/src/slate-dom/utils/environment.ts new file mode 100644 index 000000000..0560b6e2d --- /dev/null +++ b/packages/editor/src/slate-dom/utils/environment.ts @@ -0,0 +1,83 @@ +export const IS_IOS = + typeof navigator !== 'undefined' && + typeof window !== 'undefined' && + /iPad|iPhone|iPod/.test(navigator.userAgent) && + !(window as any).MSStream + +export const IS_APPLE = + typeof navigator !== 'undefined' && /Mac OS X/.test(navigator.userAgent) + +export const IS_ANDROID = + typeof navigator !== 'undefined' && /Android/.test(navigator.userAgent) + +export const IS_FIREFOX = + typeof navigator !== 'undefined' && + /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent) + +export const IS_WEBKIT = + typeof navigator !== 'undefined' && + /AppleWebKit(?!.*Chrome)/i.test(navigator.userAgent) + +// "modern" Edge was released at 79.x +export const IS_EDGE_LEGACY = + typeof navigator !== 'undefined' && + /Edge?\/(?:[0-6][0-9]|[0-7][0-8])(?:\.)/i.test(navigator.userAgent) + +export const IS_CHROME = + typeof navigator !== 'undefined' && /Chrome/i.test(navigator.userAgent) + +// Native `beforeInput` events don't work well with react on Chrome 75 +// and older, Chrome 76+ can use `beforeInput` though. +export const IS_CHROME_LEGACY = + typeof navigator !== 'undefined' && + /Chrome?\/(?:[0-7][0-5]|[0-6][0-9])(?:\.)/i.test(navigator.userAgent) + +export const IS_ANDROID_CHROME_LEGACY = + IS_ANDROID && + typeof navigator !== 'undefined' && + /Chrome?\/(?:[0-5]?\d)(?:\.)/i.test(navigator.userAgent) + +// Firefox did not support `beforeInput` until `v87`. +export const IS_FIREFOX_LEGACY = + typeof navigator !== 'undefined' && + /^(?!.*Seamonkey)(?=.*Firefox\/(?:[0-7][0-9]|[0-8][0-6])(?:\.)).*/i.test( + navigator.userAgent, + ) + +// UC mobile browser +export const IS_UC_MOBILE = + typeof navigator !== 'undefined' && /.*UCBrowser/.test(navigator.userAgent) + +// Wechat browser (not including mac wechat) +export const IS_WECHATBROWSER = + typeof navigator !== 'undefined' && + /.*Wechat/.test(navigator.userAgent) && + !/.*MacWechat/.test(navigator.userAgent) && // avoid lookbehind (buggy in safari < 16.4) + (!IS_CHROME || IS_CHROME_LEGACY) // wechat and low chrome is real wechat +// Check if DOM is available as React does internally. +// https://github.com/facebook/react/blob/master/packages/shared/ExecutionEnvironment.js +export const CAN_USE_DOM = !!( + typeof window !== 'undefined' && + typeof window.document !== 'undefined' && + typeof window.document.createElement !== 'undefined' +) + +// Check if the browser is Safari and older than 17 +export const IS_SAFARI_LEGACY = + typeof navigator !== 'undefined' && + /Safari/.test(navigator.userAgent) && + /Version\/(\\d+)/.test(navigator.userAgent) && + (navigator.userAgent.match(/Version\/(\\d+)/)?.[1] + ? // biome-ignore lint/suspicious/noNonNullAssertedOptionalChain: Slate upstream — regex match guaranteed by outer test + parseInt(navigator.userAgent.match(/Version\/(\\d+)/)?.[1]!, 10) < 17 + : false) + +// COMPAT: Firefox/Edge Legacy don't support the `beforeinput` event +// Chrome Legacy doesn't support `beforeinput` correctly +export const HAS_BEFORE_INPUT_SUPPORT = + (!IS_CHROME_LEGACY || !IS_ANDROID_CHROME_LEGACY) && + !IS_EDGE_LEGACY && + // globalThis is undefined in older browsers + typeof globalThis !== 'undefined' && + globalThis.InputEvent && + typeof globalThis.InputEvent.prototype.getTargetRanges === 'function' diff --git a/packages/editor/src/slate-dom/utils/hotkeys.ts b/packages/editor/src/slate-dom/utils/hotkeys.ts new file mode 100644 index 000000000..26e5bd939 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/hotkeys.ts @@ -0,0 +1,103 @@ +import {isHotkey} from 'is-hotkey' +import {IS_APPLE} from './environment' + +/** + * Hotkey mappings for each platform. + */ + +const HOTKEYS = { + bold: 'mod+b', + compose: ['down', 'left', 'right', 'up', 'backspace', 'enter'], + moveBackward: 'left', + moveForward: 'right', + moveWordBackward: 'ctrl+left', + moveWordForward: 'ctrl+right', + deleteBackward: 'shift?+backspace', + deleteForward: 'shift?+delete', + extendBackward: 'shift+left', + extendForward: 'shift+right', + italic: 'mod+i', + insertSoftBreak: 'shift+enter', + splitBlock: 'enter', + undo: 'mod+z', +} + +const APPLE_HOTKEYS = { + moveLineBackward: 'opt+up', + moveLineForward: 'opt+down', + moveWordBackward: 'opt+left', + moveWordForward: 'opt+right', + deleteBackward: ['ctrl+backspace', 'ctrl+h'], + deleteForward: ['ctrl+delete', 'ctrl+d'], + deleteLineBackward: 'cmd+shift?+backspace', + deleteLineForward: ['cmd+shift?+delete', 'ctrl+k'], + deleteWordBackward: 'opt+shift?+backspace', + deleteWordForward: 'opt+shift?+delete', + extendLineBackward: 'opt+shift+up', + extendLineForward: 'opt+shift+down', + redo: 'cmd+shift+z', + transposeCharacter: 'ctrl+t', +} + +const WINDOWS_HOTKEYS = { + deleteWordBackward: 'ctrl+shift?+backspace', + deleteWordForward: 'ctrl+shift?+delete', + redo: ['ctrl+y', 'ctrl+shift+z'], +} + +/** + * Create a platform-aware hotkey checker. + */ + +const create = (key: string) => { + const generic = HOTKEYS[key as keyof typeof HOTKEYS] + const apple = APPLE_HOTKEYS[key as keyof typeof APPLE_HOTKEYS] + const windows = WINDOWS_HOTKEYS[key as keyof typeof WINDOWS_HOTKEYS] + const isGeneric = generic && isHotkey(generic) + const isApple = apple && isHotkey(apple) + const isWindows = windows && isHotkey(windows) + + return (event: KeyboardEvent) => { + if (isGeneric && isGeneric(event)) { + return true + } + if (IS_APPLE && isApple && isApple(event)) { + return true + } + if (!IS_APPLE && isWindows && isWindows(event)) { + return true + } + return false + } +} + +/** + * Hotkeys. + */ + +export default { + isBold: create('bold'), + isCompose: create('compose'), + isMoveBackward: create('moveBackward'), + isMoveForward: create('moveForward'), + isDeleteBackward: create('deleteBackward'), + isDeleteForward: create('deleteForward'), + isDeleteLineBackward: create('deleteLineBackward'), + isDeleteLineForward: create('deleteLineForward'), + isDeleteWordBackward: create('deleteWordBackward'), + isDeleteWordForward: create('deleteWordForward'), + isExtendBackward: create('extendBackward'), + isExtendForward: create('extendForward'), + isExtendLineBackward: create('extendLineBackward'), + isExtendLineForward: create('extendLineForward'), + isItalic: create('italic'), + isMoveLineBackward: create('moveLineBackward'), + isMoveLineForward: create('moveLineForward'), + isMoveWordBackward: create('moveWordBackward'), + isMoveWordForward: create('moveWordForward'), + isRedo: create('redo'), + isSoftBreak: create('insertSoftBreak'), + isSplitBlock: create('splitBlock'), + isTransposeCharacter: create('transposeCharacter'), + isUndo: create('undo'), +} diff --git a/packages/editor/src/slate-dom/utils/key.ts b/packages/editor/src/slate-dom/utils/key.ts new file mode 100644 index 000000000..0c8773312 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/key.ts @@ -0,0 +1,18 @@ +/** + * An auto-incrementing identifier for keys. + */ + +let n = 0 + +/** + * A class that keeps track of a key string. We use a full class here because we + * want to be able to use them as keys in `WeakMap` objects. + */ + +export class Key { + id: string + + constructor() { + this.id = `${n++}` + } +} diff --git a/packages/editor/src/slate-dom/utils/lines.ts b/packages/editor/src/slate-dom/utils/lines.ts new file mode 100644 index 000000000..90b834f9c --- /dev/null +++ b/packages/editor/src/slate-dom/utils/lines.ts @@ -0,0 +1,75 @@ +/** + * Utilities for single-line deletion + */ + +import {Editor, Range} from '../../slate' +import {DOMEditor} from '../plugin/dom-editor' + +const doRectsIntersect = (rect: DOMRect, compareRect: DOMRect) => { + const middle = (compareRect.top + compareRect.bottom) / 2 + + return rect.top <= middle && rect.bottom >= middle +} + +const areRangesSameLine = (editor: Editor, range1: Range, range2: Range) => { + const rect1 = DOMEditor.toDOMRange(editor, range1).getBoundingClientRect() + const rect2 = DOMEditor.toDOMRange(editor, range2).getBoundingClientRect() + + return doRectsIntersect(rect1, rect2) && doRectsIntersect(rect2, rect1) +} + +/** + * A helper utility that returns the end portion of a `Range` + * which is located on a single line. + * + * @param {Editor} editor The editor object to compare against + * @param {Range} parentRange The parent range to compare against + * @returns {Range} A valid portion of the parentRange which is one a single line + */ +export const findCurrentLineRange = ( + editor: Editor, + parentRange: Range, +): Range => { + const parentRangeBoundary = Editor.range(editor, Range.end(parentRange)) + const positions = Array.from(Editor.positions(editor, {at: parentRange})) + + let left = 0 + let right = positions.length + let middle = Math.floor(right / 2) + + if ( + areRangesSameLine( + editor, + Editor.range(editor, positions[left]!), + parentRangeBoundary, + ) + ) { + return Editor.range(editor, positions[left]!, parentRangeBoundary) + } + + if (positions.length < 2) { + return Editor.range( + editor, + positions[positions.length - 1]!, + parentRangeBoundary, + ) + } + + while (middle !== positions.length && middle !== left) { + if ( + areRangesSameLine( + editor, + Editor.range(editor, positions[middle]!), + parentRangeBoundary, + ) + ) { + right = middle + } else { + left = middle + } + + middle = Math.floor((left + right) / 2) + } + + return Editor.range(editor, positions[left]!, parentRangeBoundary) +} diff --git a/packages/editor/src/slate-dom/utils/range-list.ts b/packages/editor/src/slate-dom/utils/range-list.ts new file mode 100644 index 000000000..18a3081d1 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/range-list.ts @@ -0,0 +1,167 @@ +import {Editor, Range, type Ancestor, type DecoratedRange} from '../../slate' +import {DOMEditor} from '../plugin/dom-editor' +import {PLACEHOLDER_SYMBOL} from './weak-maps' + +export const shallowCompare = ( + obj1: {[key: string]: unknown}, + obj2: {[key: string]: unknown}, +) => + Object.keys(obj1).length === Object.keys(obj2).length && + Object.keys(obj1).every( + (key) => obj2.hasOwnProperty(key) && obj1[key] === obj2[key], + ) + +const isDecorationFlagsEqual = (range: Range, other: Range) => { + const {anchor: _rangeAnchor, focus: _rangeFocus, ...rangeOwnProps} = range + const {anchor: _otherAnchor, focus: _otherFocus, ...otherOwnProps} = other + + return ( + (range as any)[PLACEHOLDER_SYMBOL] === (other as any)[PLACEHOLDER_SYMBOL] && + shallowCompare(rangeOwnProps, otherOwnProps) + ) +} + +/** + * Check if a list of decorator ranges are equal to another. + * + * PERF: this requires the two lists to also have the ranges inside them in the + * same order, but this is an okay constraint for us since decorations are + * kept in order, and the odd case where they aren't is okay to re-render for. + */ + +export const isElementDecorationsEqual = ( + list: Range[] | null, + another: Range[] | null, +): boolean => { + if (list === another) { + return true + } + + if (!list || !another) { + return false + } + + if (list.length !== another.length) { + return false + } + + for (let i = 0; i < list.length; i++) { + const range = list[i]! + const other = another[i]! + + if (!Range.equals(range, other) || !isDecorationFlagsEqual(range, other)) { + return false + } + } + + return true +} + +/** + * Check if a list of decorator ranges are equal to another. + * + * PERF: this requires the two lists to also have the ranges inside them in the + * same order, but this is an okay constraint for us since decorations are + * kept in order, and the odd case where they aren't is okay to re-render for. + */ + +export const isTextDecorationsEqual = ( + list: Range[] | null, + another: Range[] | null, +): boolean => { + if (list === another) { + return true + } + + if (!list || !another) { + return false + } + + if (list.length !== another.length) { + return false + } + + for (let i = 0; i < list.length; i++) { + const range = list[i]! + const other = another[i]! + + // compare only offsets because paths doesn't matter for text + if ( + range.anchor.offset !== other.anchor.offset || + range.focus.offset !== other.focus.offset || + !isDecorationFlagsEqual(range, other) + ) { + return false + } + } + + return true +} + +/** + * Split and group decorations by each child of a node. + * + * @returns An array with length equal to that of `node.children`. Each index + * corresponds to a child of `node`, and the value is an array of decorations + * for that child. + */ + +export const splitDecorationsByChild = ( + editor: Editor, + node: Ancestor, + decorations: DecoratedRange[], +): DecoratedRange[][] => { + const children = node.children ?? [] + const decorationsByChild = Array.from(children, (): DecoratedRange[] => []) + + if (decorations.length === 0) { + return decorationsByChild + } + + const path = DOMEditor.findPath(editor, node) + const level = path.length + const ancestorRange = Editor.range(editor, path) + + const cachedChildRanges = new Array(children.length) + + const getChildRange = (index: number) => { + const cachedRange = cachedChildRanges[index] + if (cachedRange) { + return cachedRange + } + const childRange = Editor.range(editor, [...path, index]) + cachedChildRanges[index] = childRange + return childRange + } + + for (const decoration of decorations) { + const decorationRange = Range.intersection(ancestorRange, decoration) + if (!decorationRange) { + continue + } + + const [startPoint, endPoint] = Range.edges(decorationRange) + const startIndex = startPoint.path[level]! + const endIndex = endPoint.path[level]! + + for (let i = startIndex; i <= endIndex; i++) { + const ds = decorationsByChild[i] + if (!ds) { + continue + } + + const childRange = getChildRange(i) + const childDecorationRange = Range.intersection(childRange, decoration) + if (!childDecorationRange) { + continue + } + + ds.push({ + ...decoration, + ...childDecorationRange, + }) + } + } + + return decorationsByChild +} diff --git a/packages/editor/src/slate-dom/utils/types.ts b/packages/editor/src/slate-dom/utils/types.ts new file mode 100644 index 000000000..c4d025c36 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/types.ts @@ -0,0 +1,3 @@ +export type OmitFirstArg = F extends (x: any, ...args: infer P) => infer R + ? (...args: P) => R + : never diff --git a/packages/editor/src/slate-dom/utils/weak-maps.ts b/packages/editor/src/slate-dom/utils/weak-maps.ts new file mode 100644 index 000000000..cc8504ea3 --- /dev/null +++ b/packages/editor/src/slate-dom/utils/weak-maps.ts @@ -0,0 +1,98 @@ +import type { + Ancestor, + Editor, + Node, + Operation, + Point, + Range, + RangeRef, + Text, +} from '../../slate' +import type {TextDiff} from './diff-text' +import type {Key} from './key' + +export type Action = {at?: Point | Range; run: () => void} + +/** + * Two weak maps that allow us rebuild a path given a node. They are populated + * at render time such that after a render occurs we can always backtrack. + */ +export const IS_NODE_MAP_DIRTY: WeakMap = new WeakMap() +export const NODE_TO_INDEX: WeakMap = new WeakMap() +export const NODE_TO_PARENT: WeakMap = new WeakMap() + +/** + * Weak maps that allow us to go between Slate nodes and DOM nodes. These + * are used to resolve DOM event-related logic into Slate actions. + */ +export const EDITOR_TO_WINDOW: WeakMap = new WeakMap() +export const EDITOR_TO_ELEMENT: WeakMap = new WeakMap() +export const EDITOR_TO_PLACEHOLDER: WeakMap = new WeakMap() +export const EDITOR_TO_PLACEHOLDER_ELEMENT: WeakMap = + new WeakMap() +export const ELEMENT_TO_NODE: WeakMap = new WeakMap() +export const NODE_TO_ELEMENT: WeakMap = new WeakMap() +export const NODE_TO_KEY: WeakMap = new WeakMap() +export const EDITOR_TO_KEY_TO_ELEMENT: WeakMap< + Editor, + WeakMap +> = new WeakMap() + +/** + * Weak maps for storing editor-related state. + */ + +export const IS_READ_ONLY: WeakMap = new WeakMap() +export const IS_FOCUSED: WeakMap = new WeakMap() +export const IS_COMPOSING: WeakMap = new WeakMap() + +export const EDITOR_TO_USER_SELECTION: WeakMap = + new WeakMap() + +/** + * Weak map for associating the context `onChange` context with the plugin. + */ + +export const EDITOR_TO_ON_CHANGE = new WeakMap< + Editor, + (options?: {operation?: Operation}) => void +>() + +/** + * Weak maps for saving pending state on composition stage. + */ + +export const EDITOR_TO_SCHEDULE_FLUSH: WeakMap void> = + new WeakMap() + +export const EDITOR_TO_PENDING_INSERTION_MARKS: WeakMap< + Editor, + Partial | null +> = new WeakMap() + +export const EDITOR_TO_USER_MARKS: WeakMap | null> = + new WeakMap() + +/** + * Android input handling specific weak-maps + */ + +export const EDITOR_TO_PENDING_DIFFS: WeakMap = + new WeakMap() + +export const EDITOR_TO_PENDING_ACTION: WeakMap = + new WeakMap() + +export const EDITOR_TO_PENDING_SELECTION: WeakMap = + new WeakMap() + +export const EDITOR_TO_FORCE_RENDER: WeakMap void> = new WeakMap() + +/** + * Symbols. + */ + +export const PLACEHOLDER_SYMBOL = Symbol('placeholder') as unknown as string +export const MARK_PLACEHOLDER_SYMBOL = Symbol( + 'mark-placeholder', +) as unknown as string diff --git a/packages/editor/src/slate-plugins/slate-plugin.behavior-api.ts b/packages/editor/src/slate-plugins/slate-plugin.behavior-api.ts index 1e03e7ff6..3c8d0a691 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.behavior-api.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.behavior-api.ts @@ -1,9 +1,9 @@ -import {Editor, Node, Point, Range, Text} from 'slate' import type {EditorActor} from '../editor/editor-machine' import { slatePointToSelectionPoint, slateRangeToSelection, } from '../internal-utils/slate-utils' +import {Editor, Point, Range, Text, type Node} from '../slate' export function createBehaviorApiPlugin(editorActor: EditorActor) { return function behaviorApiPlugin(editor: Editor) { @@ -118,25 +118,46 @@ export function createBehaviorApiPlugin(editorActor: EditorActor) { editor.insertNodes = (nodes, options) => { if (editor.isNormalizingNode) { - const normalizedNodes = (Node.isNode(nodes) ? [nodes] : nodes).map( - (node) => { - if (!Text.isText(node)) { - return node - } - - if (typeof node._type !== 'string') { - return { + // Slate's normalization creates bare text nodes ({text: ''}) + // without _type or marks. Promote them to proper spans before + // they reach Transforms.insertNodes, which checks Node.isNode. + const promoted: Array = [] + + for (const node of Array.isArray(nodes) ? nodes : [nodes]) { + if (Text.isText(node)) { + const record = node as unknown as Record + const needsType = typeof record['_type'] !== 'string' + + if (needsType) { + promoted.push({ ...(node as Node), _type: editorActor.getSnapshot().context.schema.span.name, - } + }) + continue } + } else { + const record = node as Record + if ( + typeof record['text'] === 'string' && + !Array.isArray(record['marks']) + ) { + // Bare text node without marks — promote to span + promoted.push({ + ...record, + _type: + typeof record['_type'] === 'string' + ? record['_type'] + : editorActor.getSnapshot().context.schema.span.name, + marks: [], + } as unknown as Node) + continue + } + } - return node - }, - ) as Array - - insertNodes(normalizedNodes, options) + promoted.push(node) + } + insertNodes(promoted, options) return } diff --git a/packages/editor/src/slate-plugins/slate-plugin.history.ts b/packages/editor/src/slate-plugins/slate-plugin.history.ts index f8e9aead2..38ff78436 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.history.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.history.ts @@ -4,9 +4,9 @@ */ import type {PortableTextBlock} from '@portabletext/schema' -import type {Operation} from 'slate' import type {EditorActor} from '../editor/editor-machine' import {createUndoSteps} from '../editor/undo-step' +import type {Operation} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' const UNDO_STEP_LIMIT = 1000 diff --git a/packages/editor/src/slate-plugins/slate-plugin.normalization.ts b/packages/editor/src/slate-plugins/slate-plugin.normalization.ts index 23f78d853..7d1395ceb 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.normalization.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.normalization.ts @@ -1,9 +1,9 @@ import type {PortableTextObject, PortableTextSpan} from '@portabletext/schema' -import {Editor, Node, Path, Range, Text, Transforms} from 'slate' import type {EditorActor} from '../editor/editor-machine' import {createPlaceholderBlock} from '../internal-utils/create-placeholder-block' import {debug} from '../internal-utils/debug' import {isEqualMarkDefs} from '../internal-utils/equality' +import {Editor, Node, Path, Range, Text, Transforms} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' import {withNormalizeNode} from './slate-plugin.normalize-node' import {withoutPatching} from './slate-plugin.without-patching' diff --git a/packages/editor/src/slate-plugins/slate-plugin.patches.ts b/packages/editor/src/slate-plugins/slate-plugin.patches.ts index c9d26522c..7c58b5e36 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.patches.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.patches.ts @@ -1,6 +1,5 @@ import {insert, setIfMissing, unset, type Patch} from '@portabletext/patches' import type {PortableTextBlock} from '@portabletext/schema' -import {Editor, type Operation} from 'slate' import type {EditorActor} from '../editor/editor-machine' import type {RelayActor} from '../editor/relay-machine' import {createApplyPatch} from '../internal-utils/applyPatch' @@ -16,6 +15,7 @@ import { splitNodePatch, } from '../internal-utils/operation-to-patches' import {isEqualToEmptyEditor} from '../internal-utils/values' +import {Editor, type Operation} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' import {withRemoteChanges} from './slate-plugin.remote-changes' import {pluginWithoutHistory} from './slate-plugin.without-history' diff --git a/packages/editor/src/slate-plugins/slate-plugin.schema.ts b/packages/editor/src/slate-plugins/slate-plugin.schema.ts index 868223af8..fbd4d6974 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.schema.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.schema.ts @@ -5,9 +5,9 @@ import { type PortableTextSpan, type PortableTextTextBlock, } from '@portabletext/schema' -import {Editor, Transforms, type Element} from 'slate' import type {EditorActor} from '../editor/editor-machine' import {debug} from '../internal-utils/debug' +import {Editor, Transforms, type Element} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' import {isListBlock} from '../utils/parse-blocks' import {withNormalizeNode} from './slate-plugin.normalize-node' @@ -66,11 +66,20 @@ export function createSchemaPlugin({editorActor}: {editorActor: EditorActor}) { const inlineSchemaTypes = editorActor .getSnapshot() .context.schema.inlineObjects.map((obj) => obj.name) - return ( - inlineSchemaTypes.includes(element._type) && - '__inline' in element && - element.__inline === true - ) + if (!inlineSchemaTypes.includes(element._type)) { + return false + } + // If the type is also a block object, we need to check position. + // Block-level elements are direct children of the editor. + // Inline elements are children of text blocks (grandchildren of editor). + const blockSchemaTypes = editorActor + .getSnapshot() + .context.schema.blockObjects.map((obj) => obj.name) + if (blockSchemaTypes.includes(element._type)) { + // Dual-type: only inline if NOT a direct child of the editor + return !editor.children.includes(element as any) + } + return true } // Extend Slate's default normalization diff --git a/packages/editor/src/slate-plugins/slate-plugin.unique-keys.ts b/packages/editor/src/slate-plugins/slate-plugin.unique-keys.ts index 956fb3c74..d4075baea 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.unique-keys.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.unique-keys.ts @@ -1,8 +1,8 @@ import {isSpan, isTextBlock} from '@portabletext/schema' -import {Editor, Element, Node, Path, Transforms} from 'slate' import type {EditorActor} from '../editor/editor-machine' import type {EditorContext, EditorSnapshot} from '../editor/editor-snapshot' import {isEqualMarks} from '../internal-utils/equality' +import {Editor, Element, Node, Path, Transforms} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' import {withNormalizeNode} from './slate-plugin.normalize-node' diff --git a/packages/editor/src/slate-plugins/slate-plugin.update-value.ts b/packages/editor/src/slate-plugins/slate-plugin.update-value.ts index 7b4b8fb18..7e343a747 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.update-value.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.update-value.ts @@ -1,7 +1,8 @@ +import type {PortableTextBlock} from '@portabletext/schema' import type {EditorContext} from '../editor/editor-snapshot' -import {applyOperationToPortableText} from '../internal-utils/apply-operation-to-portable-text' import {buildIndexMaps} from '../internal-utils/build-index-maps' import {debug} from '../internal-utils/debug' +import {Element} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' export function updateValuePlugin( @@ -22,16 +23,42 @@ export function updateValuePlugin( return } - editor.value = applyOperationToPortableText( - context, - editor.value, - operation, - ) + // Slate's normalization creates bare text nodes ({text: ''}) without + // marks or _type. Promote them to proper spans before they enter the + // tree. This catches all insert_node operations regardless of source + // (Transforms.insertNodes, editor.insertNodes, normalization). + if (operation.type === 'insert_node') { + const record = operation.node as Record + if ( + typeof record['text'] === 'string' && + !Element.isElement(operation.node) + ) { + const needsType = typeof record['_type'] !== 'string' + const needsMarks = !Array.isArray(record['marks']) + + if (needsType || needsMarks) { + operation = { + ...operation, + node: { + ...record, + _type: needsType ? context.schema.span.name : record['_type'], + marks: needsMarks ? [] : record['marks'], + } as unknown as typeof operation.node, + } + } + } + } + + // Apply the Slate operation first, then derive the PT value from + // editor.children. With childless void elements, the Slate tree and + // PT tree are structurally identical. + apply(operation) + + editor.value = editor.children as Array if (operation.type === 'insert_text' || operation.type === 'remove_text') { // Inserting and removing text has no effect on index maps so there is // no need to rebuild those. - apply(operation) return } @@ -45,8 +72,6 @@ export function updateValuePlugin( listIndexMap: editor.listIndexMap, }, ) - - apply(operation) } return editor diff --git a/packages/editor/src/slate-plugins/slate-plugin.without-normalizing-conditional.ts b/packages/editor/src/slate-plugins/slate-plugin.without-normalizing-conditional.ts index 510f5930a..578a86392 100644 --- a/packages/editor/src/slate-plugins/slate-plugin.without-normalizing-conditional.ts +++ b/packages/editor/src/slate-plugins/slate-plugin.without-normalizing-conditional.ts @@ -1,4 +1,4 @@ -import {Editor} from 'slate' +import {Editor} from '../slate' export function withoutNormalizingConditional( editor: Editor, diff --git a/packages/editor/src/slate-plugins/slate-plugins.ts b/packages/editor/src/slate-plugins/slate-plugins.ts index 8d48a5451..19d63b9c3 100644 --- a/packages/editor/src/slate-plugins/slate-plugins.ts +++ b/packages/editor/src/slate-plugins/slate-plugins.ts @@ -1,6 +1,6 @@ -import type {BaseOperation, Editor, Node, NodeEntry} from 'slate' import type {EditorActor} from '../editor/editor-machine' import type {RelayActor} from '../editor/relay-machine' +import type {BaseOperation, Editor, Node, NodeEntry} from '../slate' import type {PortableTextSlateEditor} from '../types/slate-editor' import {createBehaviorApiPlugin} from './slate-plugin.behavior-api' import {createHistoryPlugin} from './slate-plugin.history' diff --git a/packages/editor/src/slate-react/@types/direction.d.ts b/packages/editor/src/slate-react/@types/direction.d.ts new file mode 100644 index 000000000..fc1d26dd4 --- /dev/null +++ b/packages/editor/src/slate-react/@types/direction.d.ts @@ -0,0 +1,4 @@ +declare module 'direction' { + function direction(text: string): 'neutral' | 'ltr' | 'rtl' + export default direction +} diff --git a/packages/editor/src/slate-react/chunking/children-helper.ts b/packages/editor/src/slate-react/chunking/children-helper.ts new file mode 100644 index 000000000..4300ac449 --- /dev/null +++ b/packages/editor/src/slate-react/chunking/children-helper.ts @@ -0,0 +1,128 @@ +import type {Descendant, Editor} from '../../slate' +import type {Key} from '../../slate-dom' +import {ReactEditor} from '../plugin/react-editor' +import type {ChunkLeaf} from './types' + +/** + * Traverse an array of children, providing helpers useful for reconciling the + * children array with a chunk tree + */ +export class ChildrenHelper { + private editor: Editor + private children: Descendant[] + + /** + * Sparse array of Slate node keys, each index corresponding to an index in + * the children array + * + * Fetching the key for a Slate node is expensive, so we cache them here. + */ + private cachedKeys: Array + + /** + * The index of the next node to be read in the children array + */ + public pointerIndex: number + + constructor(editor: Editor, children: Descendant[]) { + this.editor = editor + this.children = children + this.cachedKeys = new Array(children.length) + this.pointerIndex = 0 + } + + /** + * Read a given number of nodes, advancing the pointer by that amount + */ + public read(n: number): Descendant[] { + // PERF: If only one child was requested (the most common case), use array + // indexing instead of slice + if (n === 1) { + return [this.children[this.pointerIndex++]!] + } + + const slicedChildren = this.remaining(n) + this.pointerIndex += n + + return slicedChildren + } + + /** + * Get the remaining children without advancing the pointer + * + * @param [maxChildren] Limit the number of children returned. + */ + public remaining(maxChildren?: number): Descendant[] { + if (maxChildren === undefined) { + return this.children.slice(this.pointerIndex) + } + + return this.children.slice( + this.pointerIndex, + this.pointerIndex + maxChildren, + ) + } + + /** + * Whether all children have been read + */ + public get reachedEnd() { + return this.pointerIndex >= this.children.length + } + + /** + * Determine whether a node with a given key appears in the unread part of the + * children array, and return its index relative to the current pointer if so + * + * Searching for the node object itself using indexOf is most efficient, but + * will fail to locate nodes that have been modified. In this case, nodes + * should be identified by their keys instead. + * + * Searching an array of keys using indexOf is very inefficient since fetching + * the keys for all children in advance is very slow. Insead, if the node + * search fails to return a value, fetch the keys of each remaining child one + * by one and compare it to the known key. + */ + public lookAhead(node: Descendant, key: Key) { + const elementResult = this.children.indexOf(node, this.pointerIndex) + if (elementResult > -1) { + return elementResult - this.pointerIndex + } + + for (let i = this.pointerIndex; i < this.children.length; i++) { + const candidateNode = this.children[i]! + const candidateKey = this.findKey(candidateNode, i) + if (candidateKey === key) { + return i - this.pointerIndex + } + } + + return -1 + } + + /** + * Convert an array of Slate nodes to an array of chunk leaves, each + * containing the node and its key + */ + public toChunkLeaves(nodes: Descendant[], startIndex: number): ChunkLeaf[] { + return nodes.map((node, i) => ({ + type: 'leaf', + node, + key: this.findKey(node, startIndex + i), + index: startIndex + i, + })) + } + + /** + * Get the key for a Slate node, cached using the node's index + */ + private findKey(node: Descendant, index: number): Key { + const cachedKey = this.cachedKeys[index] + if (cachedKey) { + return cachedKey + } + const key = ReactEditor.findKey(this.editor, node) + this.cachedKeys[index] = key + return key + } +} diff --git a/packages/editor/src/slate-react/chunking/chunk-tree-helper.ts b/packages/editor/src/slate-react/chunking/chunk-tree-helper.ts new file mode 100644 index 000000000..dc3e3cee9 --- /dev/null +++ b/packages/editor/src/slate-react/chunking/chunk-tree-helper.ts @@ -0,0 +1,592 @@ +import {Path} from '../../slate' +import {Key} from '../../slate-dom' +import type { + Chunk, + ChunkAncestor, + ChunkDescendant, + ChunkLeaf, + ChunkTree, +} from './types' + +type SavedPointer = + | 'start' + | { + chunk: ChunkAncestor + node: ChunkDescendant + } + +export interface ChunkTreeHelperOptions { + chunkSize: number + debug?: boolean +} + +/** + * Traverse and modify a chunk tree + */ +export class ChunkTreeHelper { + /** + * The root of the chunk tree + */ + private root: ChunkTree + + /** + * The ideal size of a chunk + */ + private chunkSize: number + + /** + * Whether debug mode is enabled + * + * If enabled, the pointer state will be checked for internal consistency + * after each mutating operation. + */ + private debug: boolean + + /** + * Whether the traversal has reached the end of the chunk tree + * + * When this is true, the pointerChunk and pointerIndex point to the last + * top-level node in the chunk tree, although pointerNode returns null. + */ + private reachedEnd: boolean + + /** + * The chunk containing the current node + */ + private pointerChunk: ChunkAncestor + + /** + * The index of the current node within pointerChunk + * + * Can be -1 to indicate that the pointer is before the start of the tree. + */ + private pointerIndex: number + + /** + * Similar to a Slate path; tracks the path of pointerChunk relative to the + * root. + * + * Used to move the pointer from the current chunk to the parent chunk more + * efficiently. + */ + private pointerIndexStack: number[] + + /** + * Indexing the current chunk's children has a slight time cost, which adds up + * when traversing very large trees, so the current node is cached. + * + * A value of undefined means that the current node is not cached. This + * property must be set to undefined whenever the pointer is moved, unless + * the pointer is guaranteed to point to the same node that it did previously. + */ + private cachedPointerNode: ChunkDescendant | null | undefined + + constructor( + chunkTree: ChunkTree, + {chunkSize, debug}: ChunkTreeHelperOptions, + ) { + this.root = chunkTree + this.chunkSize = chunkSize + // istanbul ignore next + this.debug = debug ?? false + this.pointerChunk = chunkTree + this.pointerIndex = -1 + this.pointerIndexStack = [] + this.reachedEnd = false + this.validateState() + } + + /** + * Move the pointer to the next leaf in the chunk tree + */ + public readLeaf(): ChunkLeaf | null { + // istanbul ignore next + if (this.reachedEnd) { + return null + } + + // Get the next sibling or aunt node + while (true) { + if (this.pointerIndex + 1 < this.pointerSiblings.length) { + this.pointerIndex++ + this.cachedPointerNode = undefined + break + } else if (this.pointerChunk.type === 'root') { + this.reachedEnd = true + return null + } else { + this.exitChunk() + } + } + + this.validateState() + + // If the next sibling or aunt is a chunk, descend into it + this.enterChunkUntilLeaf(false) + + return this.pointerNode as ChunkLeaf + } + + /** + * Move the pointer to the previous leaf in the chunk tree + */ + public returnToPreviousLeaf() { + // If we were at the end of the tree, descend into the end of the last + // chunk in the tree + if (this.reachedEnd) { + this.reachedEnd = false + this.enterChunkUntilLeaf(true) + return + } + + // Get the previous sibling or aunt node + while (true) { + if (this.pointerIndex >= 1) { + this.pointerIndex-- + this.cachedPointerNode = undefined + break + } else if (this.pointerChunk.type === 'root') { + this.pointerIndex = -1 + return + } else { + this.exitChunk() + } + } + + this.validateState() + + // If the previous sibling or aunt is a chunk, descend into it + this.enterChunkUntilLeaf(true) + } + + /** + * Insert leaves before the current leaf, leaving the pointer unchanged + */ + public insertBefore(leaves: ChunkLeaf[]) { + this.returnToPreviousLeaf() + this.insertAfter(leaves) + this.readLeaf() + } + + /** + * Insert leaves after the current leaf, leaving the pointer on the last + * inserted leaf + * + * The insertion algorithm first checks for any chunk we're currently at the + * end of that can receive additional leaves. Next, it tries to insert leaves + * at the starts of any subsequent chunks. + * + * Any remaining leaves are passed to rawInsertAfter to be chunked and + * inserted at the highest possible level. + */ + public insertAfter(leaves: ChunkLeaf[]) { + // istanbul ignore next + if (leaves.length === 0) { + return + } + + let beforeDepth = 0 + let afterDepth = 0 + + // While at the end of a chunk, insert any leaves that will fit, and then + // exit the chunk + while ( + this.pointerChunk.type === 'chunk' && + this.pointerIndex === this.pointerSiblings.length - 1 + ) { + const remainingCapacity = this.chunkSize - this.pointerSiblings.length + const toInsertCount = Math.min(remainingCapacity, leaves.length) + + if (toInsertCount > 0) { + const leavesToInsert = leaves.splice(0, toInsertCount) + this.rawInsertAfter(leavesToInsert, beforeDepth) + } + + this.exitChunk() + beforeDepth++ + } + + if (leaves.length === 0) { + return + } + + // Save the pointer so that we can come back here after inserting leaves + // into the starts of subsequent blocks + const rawInsertPointer = this.savePointer() + + // If leaves are inserted into the start of a subsequent block, then we + // eventually need to restore the pointer to the last such inserted leaf + let finalPointer: SavedPointer | null = null + + // Move the pointer into the chunk containing the next leaf, if it exists + if (this.readLeaf()) { + // While at the start of a chunk, insert any leaves that will fit, and + // then exit the chunk + while (this.pointerChunk.type === 'chunk' && this.pointerIndex === 0) { + const remainingCapacity = this.chunkSize - this.pointerSiblings.length + const toInsertCount = Math.min(remainingCapacity, leaves.length) + + if (toInsertCount > 0) { + const leavesToInsert = leaves.splice(-toInsertCount, toInsertCount) + + // Insert the leaves at the start of the chunk + this.pointerIndex = -1 + this.cachedPointerNode = undefined + this.rawInsertAfter(leavesToInsert, afterDepth) + + // If this is the first batch of insertions at the start of a + // subsequent chunk, set the final pointer to the last inserted leaf + if (!finalPointer) { + finalPointer = this.savePointer() + } + } + + this.exitChunk() + afterDepth++ + } + } + + this.restorePointer(rawInsertPointer) + + // If there are leaves left to insert, insert them between the end of the + // previous chunk and the start of the first subsequent chunk, or wherever + // the pointer ended up after the first batch of insertions + const minDepth = Math.max(beforeDepth, afterDepth) + this.rawInsertAfter(leaves, minDepth) + + if (finalPointer) { + this.restorePointer(finalPointer) + } + + this.validateState() + } + + /** + * Remove the current node and decrement the pointer, deleting any ancestor + * chunk that becomes empty as a result + */ + public remove() { + this.pointerSiblings.splice(this.pointerIndex--, 1) + this.cachedPointerNode = undefined + + if ( + this.pointerSiblings.length === 0 && + this.pointerChunk.type === 'chunk' + ) { + this.exitChunk() + this.remove() + } else { + this.invalidateChunk() + } + + this.validateState() + } + + /** + * Add the current chunk and all ancestor chunks to the list of modified + * chunks + */ + public invalidateChunk() { + for (let c = this.pointerChunk; c.type === 'chunk'; c = c.parent) { + this.root.modifiedChunks.add(c) + } + } + + /** + * Whether the pointer is at the start of the tree + */ + private get atStart() { + return this.pointerChunk.type === 'root' && this.pointerIndex === -1 + } + + /** + * The siblings of the current node + */ + private get pointerSiblings(): ChunkDescendant[] { + return this.pointerChunk.children + } + + /** + * Get the current node (uncached) + * + * If the pointer is at the start or end of the document, returns null. + * + * Usually, the current node is a chunk leaf, although it can be a chunk + * while insertions are in progress. + */ + private getPointerNode(): ChunkDescendant | null { + if (this.reachedEnd || this.pointerIndex === -1) { + return null + } + + return this.pointerSiblings[this.pointerIndex] ?? null + } + + /** + * Cached getter for the current node + */ + private get pointerNode(): ChunkDescendant | null { + if (this.cachedPointerNode !== undefined) { + return this.cachedPointerNode + } + const pointerNode = this.getPointerNode() + this.cachedPointerNode = pointerNode + return pointerNode + } + + /** + * Get the path of a chunk relative to the root, returning null if the chunk + * is not connected to the root + */ + private getChunkPath(chunk: ChunkAncestor): number[] | null { + const path: number[] = [] + + for (let c = chunk; c.type === 'chunk'; c = c.parent) { + const index = c.parent.children.indexOf(c) + + // istanbul ignore next + if (index === -1) { + return null + } + + path.unshift(index) + } + + return path + } + + /** + * Save the current pointer to be restored later + */ + private savePointer(): SavedPointer { + if (this.atStart) { + return 'start' + } + + // istanbul ignore next + if (!this.pointerNode) { + throw new Error('Cannot save pointer when pointerNode is null') + } + + return { + chunk: this.pointerChunk, + node: this.pointerNode, + } + } + + /** + * Restore the pointer to a previous state + */ + private restorePointer(savedPointer: SavedPointer) { + if (savedPointer === 'start') { + this.pointerChunk = this.root + this.pointerIndex = -1 + this.pointerIndexStack = [] + this.reachedEnd = false + this.cachedPointerNode = undefined + return + } + + // Since nodes may have been inserted or removed prior to the saved + // pointer since it was saved, the index and index stack must be + // recomputed. This is slow, but this is fine since restoring a pointer is + // not a frequent operation. + + const {chunk, node} = savedPointer + const index = chunk.children.indexOf(node) + + // istanbul ignore next + if (index === -1) { + throw new Error( + 'Cannot restore point because saved node is no longer in saved chunk', + ) + } + + const indexStack = this.getChunkPath(chunk) + + // istanbul ignore next + if (!indexStack) { + throw new Error( + 'Cannot restore point because saved chunk is no longer connected to root', + ) + } + + this.pointerChunk = chunk + this.pointerIndex = index + this.pointerIndexStack = indexStack + this.reachedEnd = false + this.cachedPointerNode = node + this.validateState() + } + + /** + * Assuming the current node is a chunk, move the pointer into that chunk + * + * @param end If true, place the pointer on the last node of the chunk. + * Otherwise, place the pointer on the first node. + */ + private enterChunk(end: boolean) { + // istanbul ignore next + if (this.pointerNode?.type !== 'chunk') { + throw new Error('Cannot enter non-chunk') + } + + this.pointerIndexStack.push(this.pointerIndex) + this.pointerChunk = this.pointerNode + this.pointerIndex = end ? this.pointerSiblings.length - 1 : 0 + this.cachedPointerNode = undefined + this.validateState() + + // istanbul ignore next + if (this.pointerChunk.children.length === 0) { + throw new Error('Cannot enter empty chunk') + } + } + + /** + * Assuming the current node is a chunk, move the pointer into that chunk + * repeatedly until the current node is a leaf + * + * @param end If true, place the pointer on the last node of the chunk. + * Otherwise, place the pointer on the first node. + */ + private enterChunkUntilLeaf(end: boolean) { + while (this.pointerNode?.type === 'chunk') { + this.enterChunk(end) + } + } + + /** + * Move the pointer to the parent chunk + */ + private exitChunk() { + // istanbul ignore next + if (this.pointerChunk.type === 'root') { + throw new Error('Cannot exit root') + } + + const previousPointerChunk = this.pointerChunk + this.pointerChunk = previousPointerChunk.parent + this.pointerIndex = this.pointerIndexStack.pop()! + this.cachedPointerNode = undefined + this.validateState() + } + + /** + * Insert leaves immediately after the current node, leaving the pointer on + * the last inserted leaf + * + * Leaves are chunked according to the number of nodes already in the parent + * plus the number of nodes being inserted, or the minimum depth if larger + */ + private rawInsertAfter(leaves: ChunkLeaf[], minDepth: number) { + if (leaves.length === 0) { + return + } + + const groupIntoChunks = ( + leaves: ChunkLeaf[], + parent: ChunkAncestor, + perChunk: number, + ): ChunkDescendant[] => { + if (perChunk === 1) { + return leaves + } + const chunks: Chunk[] = [] + + for (let i = 0; i < this.chunkSize; i++) { + const chunkNodes = leaves.slice(i * perChunk, (i + 1) * perChunk) + if (chunkNodes.length === 0) { + break + } + + const chunk: Chunk = { + type: 'chunk', + key: new Key(), + parent, + children: [], + } + + chunk.children = groupIntoChunks( + chunkNodes, + chunk, + perChunk / this.chunkSize, + ) + chunks.push(chunk) + } + + return chunks + } + + // Determine the chunking depth based on the number of existing nodes in + // the chunk and the number of nodes being inserted + const newTotal = this.pointerSiblings.length + leaves.length + let depthForTotal = 0 + + for (let i = this.chunkSize; i < newTotal; i *= this.chunkSize) { + depthForTotal++ + } + + // A depth of 0 means no chunking + const depth = Math.max(depthForTotal, minDepth) + const perTopLevelChunk = this.chunkSize ** depth + + const chunks = groupIntoChunks(leaves, this.pointerChunk, perTopLevelChunk) + this.pointerSiblings.splice(this.pointerIndex + 1, 0, ...chunks) + this.pointerIndex += chunks.length + this.cachedPointerNode = undefined + this.invalidateChunk() + this.validateState() + } + + /** + * If debug mode is enabled, ensure that the state is internally consistent + */ + // istanbul ignore next + private validateState() { + if (!this.debug) { + return + } + + const validateDescendant = (node: ChunkDescendant) => { + if (node.type === 'chunk') { + const {parent, children} = node + + if (!parent.children.includes(node)) { + throw new Error( + `Debug: Chunk ${node.key.id} has an incorrect parent property`, + ) + } + + children.forEach(validateDescendant) + } + } + + this.root.children.forEach(validateDescendant) + + if ( + this.cachedPointerNode !== undefined && + this.cachedPointerNode !== this.getPointerNode() + ) { + throw new Error( + 'Debug: The cached pointer is incorrect and has not been invalidated', + ) + } + + const actualIndexStack = this.getChunkPath(this.pointerChunk) + + if (!actualIndexStack) { + throw new Error('Debug: The pointer chunk is not connected to the root') + } + + if (!Path.equals(this.pointerIndexStack, actualIndexStack)) { + throw new Error( + `Debug: The cached index stack [${this.pointerIndexStack.join( + ', ', + )}] does not match the path of the pointer chunk [${actualIndexStack.join( + ', ', + )}]`, + ) + } + } +} diff --git a/packages/editor/src/slate-react/chunking/get-chunk-tree-for-node.ts b/packages/editor/src/slate-react/chunking/get-chunk-tree-for-node.ts new file mode 100644 index 000000000..bfa6a65ca --- /dev/null +++ b/packages/editor/src/slate-react/chunking/get-chunk-tree-for-node.ts @@ -0,0 +1,47 @@ +import type {Ancestor, Editor} from '../../slate' +import type {Key} from '../../slate-dom' +import {ReactEditor} from '../plugin/react-editor' +import {reconcileChildren, type ReconcileOptions} from './reconcile-children' +import type {ChunkTree} from './types' + +export const KEY_TO_CHUNK_TREE = new WeakMap() + +/** + * Get or create the chunk tree for a Slate node + * + * If the reconcile option is provided, the chunk tree will be updated to + * match the current children of the node. The children are chunked + * automatically using the given chunk size. + */ +export const getChunkTreeForNode = ( + editor: Editor, + node: Ancestor, + // istanbul ignore next + options: { + reconcile?: Omit | false + } = {}, +) => { + const key = ReactEditor.findKey(editor, node) + let chunkTree = KEY_TO_CHUNK_TREE.get(key) + + if (!chunkTree) { + chunkTree = { + type: 'root', + movedNodeKeys: new Set(), + modifiedChunks: new Set(), + children: [], + } + + KEY_TO_CHUNK_TREE.set(key, chunkTree) + } + + if (options.reconcile) { + reconcileChildren(editor, { + chunkTree, + children: node.children ?? [], + ...options.reconcile, + }) + } + + return chunkTree +} diff --git a/packages/editor/src/slate-react/chunking/index.ts b/packages/editor/src/slate-react/chunking/index.ts new file mode 100644 index 000000000..ee8ea2a2d --- /dev/null +++ b/packages/editor/src/slate-react/chunking/index.ts @@ -0,0 +1,2 @@ +export * from './get-chunk-tree-for-node' +export * from './types' diff --git a/packages/editor/src/slate-react/chunking/reconcile-children.ts b/packages/editor/src/slate-react/chunking/reconcile-children.ts new file mode 100644 index 000000000..9674cf09b --- /dev/null +++ b/packages/editor/src/slate-react/chunking/reconcile-children.ts @@ -0,0 +1,128 @@ +import type {Descendant, Editor} from '../../slate' +import {ChildrenHelper} from './children-helper' +import {ChunkTreeHelper, type ChunkTreeHelperOptions} from './chunk-tree-helper' +import type {ChunkLeaf, ChunkTree} from './types' + +export interface ReconcileOptions extends ChunkTreeHelperOptions { + chunkTree: ChunkTree + children: Descendant[] + chunkSize: number + rerenderChildren?: number[] + onInsert?: (node: Descendant, index: number) => void + onUpdate?: (node: Descendant, index: number) => void + onIndexChange?: (node: Descendant, index: number) => void + debug?: boolean +} + +/** + * Update the chunk tree to match the children array, inserting, removing and + * updating differing nodes + */ +export const reconcileChildren = ( + editor: Editor, + { + chunkTree, + children, + chunkSize, + rerenderChildren = [], + onInsert, + onUpdate, + onIndexChange, + debug, + }: ReconcileOptions, +) => { + chunkTree.modifiedChunks.clear() + + const chunkTreeHelper = new ChunkTreeHelper(chunkTree, {chunkSize, debug}) + const childrenHelper = new ChildrenHelper(editor, children) + + let treeLeaf: ChunkLeaf | null + + // Read leaves from the tree one by one, each one representing a single Slate + // node. Each leaf from the tree is compared to the current node in the + // children array to determine whether nodes have been inserted, removed or + // updated. + // biome-ignore lint/suspicious/noAssignInExpressions: Slate upstream pattern — assignment in while condition + while ((treeLeaf = chunkTreeHelper.readLeaf())) { + // Check where the tree node appears in the children array. In the most + // common case (where no insertions or removals have occurred), this will be + // 0. If the node has been removed, this will be -1. If new nodes have been + // inserted before the node, or if the node has been moved to a later + // position in the same children array, this will be a positive number. + const lookAhead = childrenHelper.lookAhead(treeLeaf.node, treeLeaf.key) + + // If the node was moved, we want to remove it and insert it later, rather + // then re-inserting all intermediate nodes before it. + const wasMoved = lookAhead > 0 && chunkTree.movedNodeKeys.has(treeLeaf.key) + + // If the tree leaf was moved or removed, remove it + if (lookAhead === -1 || wasMoved) { + chunkTreeHelper.remove() + continue + } + + // Get the matching Slate node and any nodes that may have been inserted + // prior to it. Insert these into the chunk tree. + const insertedChildrenStartIndex = childrenHelper.pointerIndex + const insertedChildren = childrenHelper.read(lookAhead + 1) + const matchingChild = insertedChildren.pop()! + + if (insertedChildren.length) { + const leavesToInsert = childrenHelper.toChunkLeaves( + insertedChildren, + insertedChildrenStartIndex, + ) + + chunkTreeHelper.insertBefore(leavesToInsert) + + insertedChildren.forEach((node, relativeIndex) => { + onInsert?.(node, insertedChildrenStartIndex + relativeIndex) + }) + } + + const matchingChildIndex = childrenHelper.pointerIndex - 1 + + // Make sure the chunk tree contains the most recent version of the Slate + // node + if (treeLeaf.node !== matchingChild) { + treeLeaf.node = matchingChild + chunkTreeHelper.invalidateChunk() + onUpdate?.(matchingChild, matchingChildIndex) + } + + // Update the index if it has changed + if (treeLeaf.index !== matchingChildIndex) { + treeLeaf.index = matchingChildIndex + onIndexChange?.(matchingChild, matchingChildIndex) + } + + // Manually invalidate chunks containing specific children that we want to + // re-render + if (rerenderChildren.includes(matchingChildIndex)) { + chunkTreeHelper.invalidateChunk() + } + } + + // If there are still Slate nodes remaining from the children array that were + // not matched to nodes in the tree, insert them at the end of the tree + if (!childrenHelper.reachedEnd) { + const remainingChildren = childrenHelper.remaining() + + const leavesToInsert = childrenHelper.toChunkLeaves( + remainingChildren, + childrenHelper.pointerIndex, + ) + + // Move the pointer back to the final leaf in the tree, or the start of the + // tree if the tree is currently empty + chunkTreeHelper.returnToPreviousLeaf() + + chunkTreeHelper.insertAfter(leavesToInsert) + + remainingChildren.forEach((node, relativeIndex) => { + onInsert?.(node, childrenHelper.pointerIndex + relativeIndex) + }) + } + + chunkTree.movedNodeKeys.clear() +} diff --git a/packages/editor/src/slate-react/chunking/types.ts b/packages/editor/src/slate-react/chunking/types.ts new file mode 100644 index 000000000..25d680813 --- /dev/null +++ b/packages/editor/src/slate-react/chunking/types.ts @@ -0,0 +1,52 @@ +import type {Descendant} from '../../slate' +import type {Key} from '../../slate-dom' + +export interface ChunkTree { + type: 'root' + children: ChunkDescendant[] + + /** + * The keys of any Slate nodes that have been moved using move_node since the + * last render + * + * Detecting when a node has been moved to a different position in the + * children array is impossible to do efficiently while reconciling the chunk + * tree. This interferes with the reconciliation logic since it is treated as + * if the intermediate nodes were inserted and removed, causing them to be + * re-chunked unnecessarily. + * + * This set is used to detect when a node has been moved so that this case + * can be handled correctly and efficiently. + */ + movedNodeKeys: Set + + /** + * The chunks whose descendants have been modified during the most recent + * reconciliation + * + * Used to determine when the otherwise memoized React components for each + * chunk should be re-rendered. + */ + modifiedChunks: Set +} + +export interface Chunk { + type: 'chunk' + key: Key + parent: ChunkAncestor + children: ChunkDescendant[] +} + +// A chunk leaf is unrelated to a Slate leaf; it is a leaf of the chunk tree, +// containing a single element that is a child of the Slate node the chunk tree +// belongs to. +export interface ChunkLeaf { + type: 'leaf' + key: Key + node: Descendant + index: number +} + +export type ChunkAncestor = ChunkTree | Chunk +export type ChunkDescendant = Chunk | ChunkLeaf +export type ChunkNode = ChunkTree | Chunk | ChunkLeaf diff --git a/packages/editor/src/slate-react/components/chunk-tree.tsx b/packages/editor/src/slate-react/components/chunk-tree.tsx new file mode 100644 index 000000000..abc543196 --- /dev/null +++ b/packages/editor/src/slate-react/components/chunk-tree.tsx @@ -0,0 +1,65 @@ +import React, {Fragment, type JSX} from 'react' +import type {Element} from '../../slate' +import type {Key} from '../../slate-dom' +import type { + Chunk as TChunk, + ChunkAncestor as TChunkAncestor, + ChunkTree as TChunkTree, +} from '../chunking' +import type {RenderChunkProps} from './editable' + +const defaultRenderChunk = ({children}: RenderChunkProps) => children + +const ChunkAncestor = (props: { + root: TChunkTree + ancestor: C + renderElement: (node: Element, index: number, key: Key) => JSX.Element + renderChunk?: (props: RenderChunkProps) => JSX.Element +}) => { + const { + root, + ancestor, + renderElement, + renderChunk = defaultRenderChunk, + } = props + + return ancestor.children.map((chunkNode) => { + if (chunkNode.type === 'chunk') { + const key = chunkNode.key.id + + const renderedChunk = renderChunk({ + highest: ancestor === root, + lowest: chunkNode.children.some((c) => c.type === 'leaf'), + attributes: {'data-slate-chunk': true}, + children: ( + + ), + }) + + return {renderedChunk} + } + + // Only blocks containing no inlines are chunked + const element = chunkNode.node as Element + + return renderElement(element, chunkNode.index, chunkNode.key) + }) +} + +const ChunkTree = ChunkAncestor + +const MemoizedChunk = React.memo( + ChunkAncestor, + (prev, next) => + prev.root === next.root && + prev.renderElement === next.renderElement && + prev.renderChunk === next.renderChunk && + !next.root.modifiedChunks.has(next.ancestor), +) + +export default ChunkTree diff --git a/packages/editor/src/slate-react/components/editable.tsx b/packages/editor/src/slate-react/components/editable.tsx new file mode 100644 index 000000000..3e3883291 --- /dev/null +++ b/packages/editor/src/slate-react/components/editable.tsx @@ -0,0 +1,2067 @@ +import React, { + forwardRef, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, + type ForwardedRef, + type JSX, +} from 'react' +import scrollIntoView from 'scroll-into-view-if-needed' +import { + Editor, + Element, + Node, + Path, + Range, + Text, + Transforms, + type DecoratedRange, + type LeafPosition, + type NodeEntry, +} from '../../slate' +import { + CAN_USE_DOM, + containsShadowAware, + EDITOR_TO_ELEMENT, + EDITOR_TO_FORCE_RENDER, + EDITOR_TO_PENDING_INSERTION_MARKS, + EDITOR_TO_USER_MARKS, + EDITOR_TO_USER_SELECTION, + EDITOR_TO_WINDOW, + ELEMENT_TO_NODE, + getActiveElement, + getDefaultView, + getSelection, + HAS_BEFORE_INPUT_SUPPORT, + Hotkeys, + IS_ANDROID, + IS_CHROME, + IS_COMPOSING, + IS_FIREFOX, + IS_FIREFOX_LEGACY, + IS_FOCUSED, + IS_IOS, + IS_NODE_MAP_DIRTY, + IS_READ_ONLY, + IS_UC_MOBILE, + IS_WEBKIT, + IS_WECHATBROWSER, + isDOMElement, + isDOMNode, + isPlainTextOnlyPaste, + MARK_PLACEHOLDER_SYMBOL, + NODE_TO_ELEMENT, + PLACEHOLDER_SYMBOL, + TRIPLE_CLICK, + type DOMElement, + type DOMRange, + type DOMText, +} from '../../slate-dom' +import type {AndroidInputManager} from '../hooks/android-input-manager/android-input-manager' +import {useAndroidInputManager} from '../hooks/android-input-manager/use-android-input-manager' +import useChildren from '../hooks/use-children' +import {ComposingContext} from '../hooks/use-composing' +import {DecorateContext, useDecorateContext} from '../hooks/use-decorations' +import {useIsomorphicLayoutEffect} from '../hooks/use-isomorphic-layout-effect' +import {ReadOnlyContext} from '../hooks/use-read-only' +import {useSlate} from '../hooks/use-slate' +import {useFlushDeferredSelectorsOnRender} from '../hooks/use-slate-selector' +import {useTrackUserInput} from '../hooks/use-track-user-input' +import {ReactEditor} from '../plugin/react-editor' +import {debounce, throttle} from '../utils/debounce' +import getDirection from '../utils/direction' +import {RestoreDOM} from './restore-dom/restore-dom' + +type DeferredOperation = () => void + +const Children = (props: Parameters[0]) => ( + {useChildren(props)} +) + +/** + * `RenderElementProps` are passed to the `renderElement` handler. + */ + +export interface RenderElementProps { + children: any + element: Element + attributes: { + 'data-slate-node': 'element' + 'data-slate-inline'?: true + 'data-slate-void'?: true + 'dir'?: 'rtl' + 'ref': any + } +} + +/** + * `RenderChunkProps` are passed to the `renderChunk` handler + */ +export interface RenderChunkProps { + highest: boolean + lowest: boolean + children: any + attributes: { + 'data-slate-chunk': true + } +} + +/** + * `RenderLeafProps` are passed to the `renderLeaf` handler. + */ + +export interface RenderLeafProps { + children: any + /** + * The leaf node with any applied decorations. + * If no decorations are applied, it will be identical to the `text` property. + */ + leaf: Text + text: Text + attributes: { + 'data-slate-leaf': true + } + /** + * The position of the leaf within the Text node, only present when the text node is split by decorations. + */ + leafPosition?: LeafPosition +} + +/** + * `RenderTextProps` are passed to the `renderText` handler. + */ +export interface RenderTextProps { + text: Text + children: any + attributes: { + 'data-slate-node': 'text' + 'ref': any + } +} + +/** + * `EditableProps` are passed to the `` component. + */ + +export type EditableProps = { + decorate?: (entry: NodeEntry) => DecoratedRange[] + onDOMBeforeInput?: (event: InputEvent) => void + placeholder?: string + readOnly?: boolean + role?: string + style?: React.CSSProperties + renderElement?: (props: RenderElementProps) => JSX.Element + renderChunk?: (props: RenderChunkProps) => JSX.Element + renderLeaf?: (props: RenderLeafProps) => JSX.Element + renderText?: (props: RenderTextProps) => JSX.Element + renderPlaceholder?: (props: RenderPlaceholderProps) => JSX.Element + scrollSelectionIntoView?: (editor: ReactEditor, domRange: DOMRange) => void + as?: React.ElementType + disableDefaultStyles?: boolean +} & React.TextareaHTMLAttributes + +/** + * Editable. + */ + +export const Editable = forwardRef( + (props: EditableProps, forwardedRef: ForwardedRef) => { + const defaultRenderPlaceholder = useCallback( + (props: RenderPlaceholderProps) => , + [], + ) + const { + autoFocus, + decorate = defaultDecorate, + onDOMBeforeInput: propsOnDOMBeforeInput, + placeholder, + readOnly = false, + renderElement, + renderChunk, + renderLeaf, + renderText, + renderPlaceholder = defaultRenderPlaceholder, + scrollSelectionIntoView = defaultScrollSelectionIntoView, + style: userStyle = {}, + as: Component = 'div', + disableDefaultStyles = false, + ...attributes + } = props + const editor = useSlate() + // Rerender editor when composition status changed + const [isComposing, setIsComposing] = useState(false) + const ref = useRef(null) + const deferredOperations = useRef([]) + const [placeholderHeight, setPlaceholderHeight] = useState< + number | undefined + >() + const processing = useRef(false) + + const {onUserInput, receivedUserInput} = useTrackUserInput() + + const [, forceRender] = useReducer((s) => s + 1, 0) + EDITOR_TO_FORCE_RENDER.set(editor, forceRender) + + // Update internal state on each render. + IS_READ_ONLY.set(editor, readOnly) + + // Keep track of some state for the event handler logic. + const state = useMemo( + () => ({ + isDraggingInternally: false, + isUpdatingSelection: false, + latestElement: null as DOMElement | null, + hasMarkPlaceholder: false, + }), + [], + ) + + // The autoFocus TextareaHTMLAttribute doesn't do anything on a div, so it + // needs to be manually focused. + // + // If this stops working in Firefox, make sure nothing is causing this + // component to re-render during the initial mount. If the DOM selection is + // set by `useIsomorphicLayoutEffect` before `onDOMSelectionChange` updates + // `editor.selection`, the DOM selection can be removed accidentally. + useEffect(() => { + if (ref.current && autoFocus) { + ref.current.focus() + } + }, [autoFocus]) + + /** + * The AndroidInputManager object has a cyclical dependency on onDOMSelectionChange + * + * It is defined as a reference to simplify hook dependencies and clarify that + * it needs to be initialized. + */ + const androidInputManagerRef = useRef< + AndroidInputManager | null | undefined + >(undefined) + + // Listen on the native `selectionchange` event to be able to update any time + // the selection changes. This is required because React's `onSelect` is leaky + // and non-standard so it doesn't fire until after a selection has been + // released. This causes issues in situations where another change happens + // while a selection is being dragged. + const onDOMSelectionChange = useMemo( + () => + throttle(() => { + if (IS_NODE_MAP_DIRTY.get(editor)) { + onDOMSelectionChange() + return + } + + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (!processing.current && IS_WEBKIT && root instanceof ShadowRoot) { + processing.current = true + + const active = getActiveElement() + + if (active) { + document.execCommand('indent') + } else { + Transforms.deselect(editor) + } + + processing.current = false + return + } + + const androidInputManager = androidInputManagerRef.current + if ( + (IS_ANDROID || !ReactEditor.isComposing(editor)) && + (!state.isUpdatingSelection || androidInputManager?.isFlushing()) && + !state.isDraggingInternally + ) { + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const {activeElement} = root + const el = ReactEditor.toDOMNode(editor, editor) + const domSelection = getSelection(root) + + if (activeElement === el) { + state.latestElement = activeElement + IS_FOCUSED.set(editor, true) + } else { + IS_FOCUSED.delete(editor) + } + + if (!domSelection) { + return Transforms.deselect(editor) + } + + const {anchorNode, focusNode} = domSelection + + const anchorNodeSelectable = + ReactEditor.hasEditableTarget(editor, anchorNode) || + ReactEditor.isTargetInsideNonReadonlyVoid(editor, anchorNode) + + const focusNodeInEditor = ReactEditor.hasTarget(editor, focusNode) + + if (anchorNodeSelectable && focusNodeInEditor) { + const range = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + suppressThrow: true, + }) + + if (range) { + if ( + !ReactEditor.isComposing(editor) && + !androidInputManager?.hasPendingChanges() && + !androidInputManager?.isFlushing() + ) { + Transforms.select(editor, range) + } else { + androidInputManager?.handleUserSelect(range) + } + } + } + + // Deselect the editor if the dom selection is not selectable in readonly mode + if (readOnly && (!anchorNodeSelectable || !focusNodeInEditor)) { + Transforms.deselect(editor) + } + } + }, 100), + [editor, readOnly, state], + ) + + const scheduleOnDOMSelectionChange = useMemo( + () => debounce(onDOMSelectionChange, 0), + [onDOMSelectionChange], + ) + + androidInputManagerRef.current = useAndroidInputManager({ + node: ref as React.RefObject, + onDOMSelectionChange, + scheduleOnDOMSelectionChange, + }) + + useIsomorphicLayoutEffect(() => { + // Update element-related weak maps with the DOM element ref. + let window: Window | null = null + // biome-ignore lint/suspicious/noAssignInExpressions: Slate upstream pattern — assignment in condition + if (ref.current && (window = getDefaultView(ref.current))) { + EDITOR_TO_WINDOW.set(editor, window) + EDITOR_TO_ELEMENT.set(editor, ref.current) + NODE_TO_ELEMENT.set(editor, ref.current) + ELEMENT_TO_NODE.set(ref.current, editor) + } else { + NODE_TO_ELEMENT.delete(editor) + } + + // Make sure the DOM selection state is in sync. + const {selection} = editor + const root = ReactEditor.findDocumentOrShadowRoot(editor) + const domSelection = getSelection(root) + + if ( + !domSelection || + !ReactEditor.isFocused(editor) || + androidInputManagerRef.current?.hasPendingAction() + ) { + return + } + + const setDomSelection = (forceChange?: boolean) => { + const hasDomSelection = domSelection.type !== 'None' + + // If the DOM selection is properly unset, we're done. + if (!selection && !hasDomSelection) { + return + } + + // Get anchorNode and focusNode + const focusNode = domSelection.focusNode + let anchorNode: globalThis.Node | null = null + + // COMPAT: In firefox the normal selection way does not work + // (https://github.com/ianstormtaylor/slate/pull/5486#issue-1820720223) + if (IS_FIREFOX && domSelection.rangeCount > 1) { + const firstRange = domSelection.getRangeAt(0) + const lastRange = domSelection.getRangeAt(domSelection.rangeCount - 1) + + // Right to left + if (firstRange.startContainer === focusNode) { + anchorNode = lastRange.endContainer + } else { + // Left to right + anchorNode = firstRange.startContainer + } + } else { + anchorNode = domSelection.anchorNode + } + + // verify that the dom selection is in the editor + const editorElement = EDITOR_TO_ELEMENT.get(editor)! + let hasDomSelectionInEditor = false + if ( + containsShadowAware(editorElement, anchorNode) && + containsShadowAware(editorElement, focusNode) + ) { + hasDomSelectionInEditor = true + } + + // If the DOM selection is in the editor and the editor selection is already correct, we're done. + if ( + hasDomSelection && + hasDomSelectionInEditor && + selection && + !forceChange + ) { + const slateRange = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: true, + + // domSelection is not necessarily a valid Slate range + // (e.g. when clicking on contentEditable:false element) + suppressThrow: true, + }) + + if (slateRange && Range.equals(slateRange, selection)) { + if (!state.hasMarkPlaceholder) { + return + } + + // Ensure selection is inside the mark placeholder + if ( + anchorNode?.parentElement?.hasAttribute( + 'data-slate-mark-placeholder', + ) + ) { + return + } + } + } + + // when is being controlled through external value + // then its children might just change - DOM responds to it on its own + // but Slate's value is not being updated through any operation + // and thus it doesn't transform selection on its own + if (selection && !ReactEditor.hasRange(editor, selection)) { + editor.selection = ReactEditor.toSlateRange(editor, domSelection, { + exactMatch: false, + suppressThrow: true, + }) + return + } + + // Otherwise the DOM selection is out of sync, so update it. + state.isUpdatingSelection = true + + let newDomRange: DOMRange | null = null + + try { + newDomRange = selection && ReactEditor.toDOMRange(editor, selection) + } catch (_e) { + // Ignore, dom and state might be out of sync + } + + if (newDomRange) { + if (ReactEditor.isComposing(editor) && !IS_ANDROID) { + domSelection.collapseToEnd() + } else if (Range.isBackward(selection!)) { + domSelection.setBaseAndExtent( + newDomRange.endContainer, + newDomRange.endOffset, + newDomRange.startContainer, + newDomRange.startOffset, + ) + } else { + domSelection.setBaseAndExtent( + newDomRange.startContainer, + newDomRange.startOffset, + newDomRange.endContainer, + newDomRange.endOffset, + ) + } + scrollSelectionIntoView(editor, newDomRange) + } else { + domSelection.removeAllRanges() + } + + return newDomRange + } + + // In firefox if there is more then 1 range and we call setDomSelection we remove the ability to select more cells in a table + if (domSelection.rangeCount <= 1) { + setDomSelection() + } + + const ensureSelection = + androidInputManagerRef.current?.isFlushing() === 'action' + + if (!IS_ANDROID || !ensureSelection) { + setTimeout(() => { + state.isUpdatingSelection = false + }) + return + } + + let timeoutId: ReturnType | null = null + const animationFrameId = requestAnimationFrame(() => { + if (ensureSelection) { + const ensureDomSelection = (forceChange?: boolean) => { + try { + const el = ReactEditor.toDOMNode(editor, editor) + el.focus() + + setDomSelection(forceChange) + } catch (_e) { + // Ignore, dom and state might be out of sync + } + } + + // Compat: Android IMEs try to force their selection by manually re-applying it even after we set it. + // This essentially would make setting the slate selection during an update meaningless, so we force it + // again here. We can't only do it in the setTimeout after the animation frame since that would cause a + // visible flicker. + ensureDomSelection() + + timeoutId = setTimeout(() => { + // COMPAT: While setting the selection in an animation frame visually correctly sets the selection, + // it doesn't update GBoards spellchecker state. We have to manually trigger a selection change after + // the animation frame to ensure it displays the correct state. + ensureDomSelection(true) + state.isUpdatingSelection = false + }) + } + }) + + return () => { + cancelAnimationFrame(animationFrameId) + if (timeoutId) { + clearTimeout(timeoutId) + } + } + }) + + // Listen on the native `beforeinput` event to get real "Level 2" events. This + // is required because React's `beforeinput` is fake and never really attaches + // to the real event sadly. (2019/11/01) + // https://github.com/facebook/react/issues/11211 + const onDOMBeforeInput = useCallback( + (event: InputEvent) => { + handleNativeHistoryEvents(editor, event) + const el = ReactEditor.toDOMNode(editor, editor) + const root = el.getRootNode() + + if (processing?.current && IS_WEBKIT && root instanceof ShadowRoot) { + const ranges = event.getTargetRanges() + const range = ranges[0]! + + const newRange = new window.Range() + + newRange.setStart(range.startContainer, range.startOffset) + newRange.setEnd(range.endContainer, range.endOffset) + + // Translate the DOM Range into a Slate Range + const slateRange = ReactEditor.toSlateRange(editor, newRange, { + exactMatch: false, + suppressThrow: false, + }) + + Transforms.select(editor, slateRange) + + event.preventDefault() + event.stopImmediatePropagation() + return + } + onUserInput() + + if ( + !readOnly && + ReactEditor.hasSelectableTarget(editor, event.target) && + !isDOMEventHandled(event, propsOnDOMBeforeInput) + ) { + // COMPAT: BeforeInput events aren't cancelable on android, so we have to handle them differently using the android input manager. + if (androidInputManagerRef.current) { + return androidInputManagerRef.current.handleDOMBeforeInput(event) + } + + // Some IMEs/Chrome extensions like e.g. Grammarly set the selection immediately before + // triggering a `beforeinput` expecting the change to be applied to the immediately before + // set selection. + scheduleOnDOMSelectionChange.flush() + onDOMSelectionChange.flush() + + const {selection} = editor + const {inputType: type} = event + const data = (event as any).dataTransfer || event.data || undefined + + const isCompositionChange = + type === 'insertCompositionText' || type === 'deleteCompositionText' + + // COMPAT: use composition change events as a hint to where we should insert + // composition text if we aren't composing to work around https://github.com/ianstormtaylor/slate/issues/5038 + if (isCompositionChange && ReactEditor.isComposing(editor)) { + return + } + + let native = false + if ( + type === 'insertText' && + selection && + Range.isCollapsed(selection) && + // Only use native character insertion for single characters a-z or space for now. + // Long-press events (hold a + press 4 = ä) to choose a special character otherwise + // causes duplicate inserts. + event.data && + event.data.length === 1 && + /[a-z ]/i.test(event.data) && + // Chrome has issues correctly editing the start of nodes: https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 + // When there is an inline element, e.g. a link, and you select + // right after it (the start of the next node). + selection.anchor.offset !== 0 + ) { + native = true + + // Skip native if there are marks, as + // `insertText` will insert a node, not just text. + if (editor.marks) { + native = false + } + + // If the NODE_MAP is dirty, we can't trust the selection anchor (eg ReactEditor.toDOMPoint) + if (!IS_NODE_MAP_DIRTY.get(editor)) { + // Chrome also has issues correctly editing the end of anchor elements: https://bugs.chromium.org/p/chromium/issues/detail?id=1259100 + // Therefore we don't allow native events to insert text at the end of anchor nodes. + const {anchor} = selection + + const [node, offset] = ReactEditor.toDOMPoint(editor, anchor) + const anchorNode = node.parentElement?.closest('a') + + const window = ReactEditor.getWindow(editor) + + if ( + native && + anchorNode && + ReactEditor.hasDOMNode(editor, anchorNode) + ) { + // Find the last text node inside the anchor. + const lastText = window?.document + .createTreeWalker(anchorNode, NodeFilter.SHOW_TEXT) + .lastChild() as DOMText | null + + if ( + lastText === node && + lastText.textContent?.length === offset + ) { + native = false + } + } + + // Chrome has issues with the presence of tab characters inside elements with whiteSpace = 'pre' + // causing abnormal insert behavior: https://bugs.chromium.org/p/chromium/issues/detail?id=1219139 + if ( + native && + node.parentElement && + window?.getComputedStyle(node.parentElement)?.whiteSpace === + 'pre' + ) { + const block = Editor.above(editor, { + at: anchor.path, + match: (n) => + Element.isElement(n) && Editor.isBlock(editor, n), + }) + + if (block && Node.string(block[0]).includes('\t')) { + native = false + } + } + } + } + // COMPAT: For the deleting forward/backward input types we don't want + // to change the selection because it is the range that will be deleted, + // and those commands determine that for themselves. + // If the NODE_MAP is dirty, we can't trust the selection anchor (eg ReactEditor.toDOMPoint via ReactEditor.toSlateRange) + if ( + (!type.startsWith('delete') || type.startsWith('deleteBy')) && + !IS_NODE_MAP_DIRTY.get(editor) + ) { + const [targetRange] = (event as any).getTargetRanges() + + if (targetRange) { + const range = ReactEditor.toSlateRange(editor, targetRange, { + exactMatch: false, + suppressThrow: false, + }) + + if (!selection || !Range.equals(selection, range)) { + native = false + + const selectionRef = + !isCompositionChange && + editor.selection && + Editor.rangeRef(editor, editor.selection) + + Transforms.select(editor, range) + + if (selectionRef) { + EDITOR_TO_USER_SELECTION.set(editor, selectionRef) + } + } + } + } + + // Composition change types occur while a user is composing text and can't be + // cancelled. Let them through and wait for the composition to end. + if (isCompositionChange) { + return + } + + if (!native) { + event.preventDefault() + } + + // COMPAT: If the selection is expanded, even if the command seems like + // a delete forward/backward command it should delete the selection. + if ( + selection && + Range.isExpanded(selection) && + type.startsWith('delete') + ) { + const direction = type.endsWith('Backward') ? 'backward' : 'forward' + Editor.deleteFragment(editor, {direction}) + return + } + + switch (type) { + case 'deleteByComposition': + case 'deleteByCut': + case 'deleteByDrag': { + Editor.deleteFragment(editor) + break + } + + case 'deleteContent': + case 'deleteContentForward': { + Editor.deleteForward(editor) + break + } + + case 'deleteContentBackward': { + Editor.deleteBackward(editor) + break + } + + case 'deleteEntireSoftLine': { + Editor.deleteBackward(editor, {unit: 'line'}) + Editor.deleteForward(editor, {unit: 'line'}) + break + } + + case 'deleteHardLineBackward': { + Editor.deleteBackward(editor, {unit: 'block'}) + break + } + + case 'deleteSoftLineBackward': { + Editor.deleteBackward(editor, {unit: 'line'}) + break + } + + case 'deleteHardLineForward': { + Editor.deleteForward(editor, {unit: 'block'}) + break + } + + case 'deleteSoftLineForward': { + Editor.deleteForward(editor, {unit: 'line'}) + break + } + + case 'deleteWordBackward': { + Editor.deleteBackward(editor, {unit: 'word'}) + break + } + + case 'deleteWordForward': { + Editor.deleteForward(editor, {unit: 'word'}) + break + } + + case 'insertLineBreak': + Editor.insertSoftBreak(editor) + break + + case 'insertParagraph': { + Editor.insertBreak(editor) + break + } + + case 'insertFromComposition': + case 'insertFromDrop': + case 'insertFromPaste': + case 'insertFromYank': + case 'insertReplacementText': + case 'insertText': { + if (type === 'insertFromComposition') { + // COMPAT: in Safari, `compositionend` is dispatched after the + // `beforeinput` for "insertFromComposition". But if we wait for it + // then we will abort because we're still composing and the selection + // won't be updated properly. + // https://www.w3.org/TR/input-events-2/ + if (ReactEditor.isComposing(editor)) { + setIsComposing(false) + IS_COMPOSING.set(editor, false) + } + } + + // use a weak comparison instead of 'instanceof' to allow + // programmatic access of paste events coming from external windows + // like cypress where cy.window does not work realibly + if (data?.constructor.name === 'DataTransfer') { + ReactEditor.insertData(editor, data) + } else if (typeof data === 'string') { + // Only insertText operations use the native functionality, for now. + // Potentially expand to single character deletes, as well. + if (native) { + deferredOperations.current.push(() => + Editor.insertText(editor, data), + ) + } else { + Editor.insertText(editor, data) + } + } + + break + } + } + + // Restore the actual user section if nothing manually set it. + const toRestore = EDITOR_TO_USER_SELECTION.get(editor)?.unref() + EDITOR_TO_USER_SELECTION.delete(editor) + + if ( + toRestore && + (!editor.selection || !Range.equals(editor.selection, toRestore)) + ) { + Transforms.select(editor, toRestore) + } + } + }, + [ + editor, + onDOMSelectionChange, + onUserInput, + propsOnDOMBeforeInput, + readOnly, + scheduleOnDOMSelectionChange, + ], + ) + + const callbackRef = useCallback( + (node: HTMLDivElement | null) => { + if (node == null) { + onDOMSelectionChange.cancel() + scheduleOnDOMSelectionChange.cancel() + + EDITOR_TO_ELEMENT.delete(editor) + NODE_TO_ELEMENT.delete(editor) + + if (ref.current && HAS_BEFORE_INPUT_SUPPORT) { + ref.current.removeEventListener('beforeinput', onDOMBeforeInput) + } + } else { + // Attach a native DOM event handler for `beforeinput` events, because React's + // built-in `onBeforeInput` is actually a leaky polyfill that doesn't expose + // real `beforeinput` events sadly... (2019/11/04) + // https://github.com/facebook/react/issues/11211 + if (HAS_BEFORE_INPUT_SUPPORT) { + node.addEventListener('beforeinput', onDOMBeforeInput) + } + } + + ref.current = node + if (typeof forwardedRef === 'function') { + forwardedRef(node) + } else if (forwardedRef) { + forwardedRef.current = node + } + }, + [ + onDOMSelectionChange, + scheduleOnDOMSelectionChange, + editor, + onDOMBeforeInput, + forwardedRef, + ], + ) + + useIsomorphicLayoutEffect(() => { + const window = ReactEditor.getWindow(editor) + + // COMPAT: In Chrome, `selectionchange` events can fire when and + //