From 2c8fdfa51abcfc280db90e1e3ae50493cc756273 Mon Sep 17 00:00:00 2001 From: Amp Date: Sat, 14 Nov 2026 16:50:04 +0000 Subject: [PATCH] feat(rust): replay property/sequences, with NormalizeInlineElements and JS's no-DOM-selection deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The Rust conformance runner didn't replay `property/sequences`, the 81 random command sequences JS records on a rich-text editor with lists, check lists, links, hashtags, code and history. It does now. The suite builds the editor `sequenceEditor` builds, in the extension builder's registration order: `InitialStateExtension`'s first update included, and no `registerCodeIndentation`, since only `CodeIndentExtension` registers it. Two Rust differences from JS showed up, and both are fixed: - `NormalizeInlineElementsExtension`'s registration (`deleteEmptyInline`, which `RichTextExtension` depends on) wasn't ported, so empty links and autolinks stayed. It is now `register_normalize_inline_elements`, behind the `extension` feature. - In that editor, JS's `getDOMSelection` returns null, so `$extendSelectionForDeletion` and `modify` stop after their model step. Rust kept measuring and moved the selection into nearby text. A new `SelectionMovement::has_dom_selection()` (default true) lets an app say there is no DOM selection. ## Test plan ### Before ``` $ cargo run -q --release -p lexical-conformance | grep -E 'sequences|Total' | `property/sequences` | – | – | – | – | – | not implemented | | **Total** | 488 | 488 | 0 | 0 | 0 | | ``` ### After ``` $ cargo run -q --release -p lexical-conformance | grep -E 'sequences|Total' | `property/sequences` | 81 | 81 | 0 | 0 | 0 | pass | | **Total** | 569 | 569 | 0 | 0 | 0 | | $ cargo test --workspace --all-features 38 passed, 0 failed ``` Mutations, each restored afterwards: `delete_empty_inline` removing nothing fails 19 cases; the early return in `extend_selection_for_deletion` disabled fails 6; `FORMAT_TEXT_COMMAND` formatting nothing fails 15. `doctored_sequences_fail` checks that a vector with one `handled` and one `text` changed fails exactly those steps. `cargo fmt --check`, clippy `-D warnings`, rustdoc `-D warnings`, `cargo +1.88 check`, `--no-default-features` and Rune's clippy pass. Amp-Thread-ID: https://ampcode.com/threads/T-16d45229-bfa8-417a-a091-d3e3888d830d --- rust/DESIGN.md | 2 +- rust/PARITY.md | 32 +- rust/conformance/src/scripts.rs | 82 ++--- rust/conformance/src/suites/mod.rs | 5 + rust/conformance/src/suites/property.rs | 281 +++++++++++++++++- rust/conformance/tests/conformance.rs | 64 ++++ rust/lexical-editor/Cargo.toml | 4 +- rust/lexical-editor/src/editor.rs | 33 +- rust/lexical-editor/src/extension/mod.rs | 6 +- .../normalize_inline_elements_extension.rs | 35 +++ rust/lexical-editor/src/selection_deletion.rs | 70 +++++ 11 files changed, 555 insertions(+), 59 deletions(-) create mode 100644 rust/lexical-editor/src/extension/normalize_inline_elements_extension.rs diff --git a/rust/DESIGN.md b/rust/DESIGN.md index b7e2e3b2904..55458b43ba2 100644 --- a/rust/DESIGN.md +++ b/rust/DESIGN.md @@ -182,7 +182,7 @@ Each milestone ends with green CI and a report to the Space. | M1 | The core model: `editor.update` and the commit (dirty nodes, normalization, transforms, listeners, commands, tags), node creation and methods (`splitText`, `mergeWithSibling`, `spliceText`, `insertAfter`, `remove`, `replace`...) with UTF-16 offsets, `RangeSelection` and `NodeSelection` editing, carets, NodeState, slots, the schema facility, `DecoratorNode`, the extension API (`buildEditorFromExtensions`), the parity ledger | `scripts/*`, `fuzz/core`, `file/*` | in progress: the update cycle, `setEditorState`, commands, transforms, listeners, the node methods, carets, selection reading and `RangeSelection`/`NodeSelection` editing are done (`scripts/*` and `fuzz/core` pass), and the platform's `Selection.modify` is an editor hook (`SelectionMovement`); NodeState is done (`createState`, `$getState`, `$setState`, `$getStateChange`, the `"$"` key and flat states in node JSON, unknown keys kept, clones, `$copyNode`, splits and merges, `scripts/node-state`), except its schema builders, which come with the schema facility; `DecoratorNode` is done (its hooks, block and inline decorators in `insertNodes`, deletion, `modify`, text content and formatting, `$setBlocksType` across one, `scripts/decorator`), except `editor._decorators` and the decorator listener, which the DOM reconciler fills; next, the extension API | | P | Desktop playgrounds (`apps/`): `lexical-playground-rune` on Rune, then `lexical-playground-gpui` on GPUI, sharing `lexical-playground-core`; they grow with each milestone below | the vectors, replayed in the app; a session exported from the app replays in JS (`replay.test.ts`); in CI, a GPUI session typed in a real window replays in JS | both apps run on macOS with typing, Return, deletion, formatting, input methods and the Parity panel; they grow with each milestone | | M2 | `rich-text` (headings, quotes, and the command handlers a text surface dispatches: typing, Enter, Backspace, formatting) and `history` (undo), which the playgrounds need first; then the node packages `list`, `link`, `code`, `overflow`, `hashtag`, with `selection` and `utils` | `serialization/*`, `property/documents` (JSON and text), `entities/hashtag-*`, `scripts/history` | done (every vector it names passes): `history` is done (`scripts/history` passes); `rich-text`'s nodes and `registerRichText`'s model command handlers are done (`scripts/rich-text` and `scripts/rich-text-commands` pass, with the heading and quote cases of `property/documents`); `plain-text`'s model command handlers are done (`scripts/plain-text` passes); `list`'s nodes, `registerList` and the model part of `registerCheckList` are done (`scripts/list` and `serialization/list` pass, with the list cases of `property/documents`); `link`'s nodes, `$toggleLink` and `registerLink`'s model handlers are done (`scripts/link` and `serialization/link` pass, with the link cases of `property/documents` that need no other package), `registerAutoLink` and its matchers are done behind `autolink` (`scripts/autolink` and `entities/autolink-match` pass); `selection`'s `$setBlocksType` is done (`scripts/set-blocks-type` passes); `code`'s nodes, `FlatStructureUtils.ts`, `CodeExtension`'s transform and `registerCodeIndentation`'s model handlers are done (`scripts/code` and `serialization/code` pass, with the code cases of `property/documents` that need no other package); `overflow`'s `OverflowNode` is done (`scripts/overflow` and `serialization/overflow` pass, with the overflow cases of `property/documents` that need no other package); `hashtag`'s `HashtagNode`, its matcher (generated character tables, no `regex`) and `registerLexicalHashtag`, with `text`'s `registerLexicalTextEntity`, are done (`entities/hashtag-match`, `entities/hashtag-documents` and `entities/hashtag-typing` pass, with the last waiting cases of `property/documents`), so M2's node packages are done | -| M3 | The commands `property/sequences` dispatches | `property/sequences` | | +| M3 | The commands `property/sequences` dispatches | `property/sequences` | done (all 81 cases match JS): the suite builds `sequenceEditor`'s editor without `buildEditorFromExtensions`, from what each of its extensions registers, in the builder's order, with `InitialStateExtension`'s paragraph; the commands M2 ported needed two fixes, `NormalizeInlineElementsExtension`'s transform (`register_normalize_inline_elements`, removing empty links) and `$extendSelectionForDeletion` and `modify` stopping where the window gives no DOM selection (`SelectionMovement::has_dom_selection`) | | M4 | `html`: a DOM abstraction over an HTML parser, CSS declarations, export and import | `html/*`, `css/*`, `property/documents` (HTML) | | | M5 | `markdown` | `markdown/import-export`, `property/documents` (Markdown) | | | M6 | `yjs`: a port of `@lexical/yjs` on `yrs` (behind the feature), with new JS vectors for the binding (sevki/lexical-rs's `lexical-yjs` and jpcamara/yrby's `lexical-yjs-html` only as cross-checks) | the new collab vectors | | diff --git a/rust/PARITY.md b/rust/PARITY.md index f47f1bbef7d..9bd0e81b3a5 100644 --- a/rust/PARITY.md +++ b/rust/PARITY.md @@ -22,21 +22,21 @@ updated with each milestone. | Selection reading | `LexicalSelection.ts`: `Point.isBefore`, `isBackward` across nodes, `RangeSelection.getNodes` (`$getNodesFromCaretRangeCompat`) and `getTextContent`, `NodeSelection.getNodes` and `getTextContent`, the `_cachedNodes` cache | `selection` | `scripts/caret`; a unit test against JS's output | | Carets | `caret/LexicalCaret.ts`: sibling, child and text point carets in both directions and their methods (`getAdjacentCaret`, `getNodeAtCaret`, `getFlipped`, `getParentCaret`, `getChildCaret`, `insert`, `remove`, `replaceOrInsert`, `splice`, iteration), `$getSiblingCaret`, `$getChildCaret`, `$getTextPointCaret`, `$getTextNodeOffset`, text slices, caret ranges (`$getCaretRange`, `iterNodeCarets`, `getTextSlices`, `isCollapsed`), `$getAdjacentChildCaret`, `$getCaretInDirection`, `$extendCaretToRange`, `$comparePointCaretNext`, `$getCommonAncestor`, `makeStepwiseIterator` | `caret` | `scripts/caret` | | Caret utilities | `caret/LexicalCaretUtils.ts`: `$caretFromPoint`, `$setPointFromCaret`, `$caretRangeFromSelection`, `$setSelectionFromCaretRange`, `$updateRangeSelectionFromCaretRange`, `$rewindSiblingCaret`, `$removeTextFromCaretRange` (block merges, empty-container repair), `$normalizeCaret`, `$isExtendableTextPointCaret`, `$getCaretRangeInDirection`, `$getChildCaretAtIndex`, `$getAdjacentSiblingOrParentSiblingCaret`, `$getTextPointCaretSliceForNode`; with `$getNodeByKeyOrThrow`, `$removeFromParent`, `$restoreEmptyContainerParagraph`, `$isShadowRootNode` and `INTERNAL_$isBlock` from `LexicalUtils.ts` | `caret_utils`, `utils` | `scripts/caret` | -| Selection editing | `LexicalSelection.ts`: `insertText` (`$insertText`, `$insertTextAtPoint`), `insertRawText` (`tokenizeRawText`, `$generateNodesFromRawText`), `insertNodes` for both selection kinds (`$removeTextAndSplitBlock`, `$splitNodeAtPoint`, `$wrapInlineNodes`), `insertParagraph`, `insertLineBreak`, `removeText`, `formatText` (`$updateTextFormat`, `$setTextFormat`, `$getCharacterOffsets`), `toggleFormat`, `setFormat`, `setStyle`, `deleteCharacter`, `deleteWord`, `deleteLine` and `modify` (`forwardDeletion`, `$extendSelectionForDeletion`, `$modifySelectionAroundDecoratorsAndBlocks`, `$collapseAtStart`, `$removeSegment`, `$updateCaretSelectionForUnicodeCharacter`, `$stopLineDeletionAtLineBreak`, `$shrinkSelectionToRoot`, and the model side of `applyDOMRange`), with an editor's `SelectionMovement` standing in for `Selection.modify`, `$insertNodes`, `$selectAll`; `$splitTextPointCaretSlice` and `$isBlockFullySelected`; `ParagraphNode.insertNewAfter`, `canInsertTextBefore`/`After` | `selection_editing`, `selection_insert_text`, `selection_insert_nodes`, `selection_format`, `selection_deletion` | `scripts/selection`, `fuzz/core`; unit tests against JS's output for paths no vector covers | +| Selection editing | `LexicalSelection.ts`: `insertText` (`$insertText`, `$insertTextAtPoint`), `insertRawText` (`tokenizeRawText`, `$generateNodesFromRawText`), `insertNodes` for both selection kinds (`$removeTextAndSplitBlock`, `$splitNodeAtPoint`, `$wrapInlineNodes`), `insertParagraph`, `insertLineBreak`, `removeText`, `formatText` (`$updateTextFormat`, `$setTextFormat`, `$getCharacterOffsets`), `toggleFormat`, `setFormat`, `setStyle`, `deleteCharacter`, `deleteWord`, `deleteLine` and `modify` (`forwardDeletion`, `$extendSelectionForDeletion`, `$modifySelectionAroundDecoratorsAndBlocks`, `$collapseAtStart`, `$removeSegment`, `$updateCaretSelectionForUnicodeCharacter`, `$stopLineDeletionAtLineBreak`, `$shrinkSelectionToRoot`, and the model side of `applyDOMRange`), with an editor's `SelectionMovement` standing in for `Selection.modify`, `$insertNodes`, `$selectAll`; `$splitTextPointCaretSlice` and `$isBlockFullySelected`; `ParagraphNode.insertNewAfter`, `canInsertTextBefore`/`After` | `selection_editing`, `selection_insert_text`, `selection_insert_nodes`, `selection_format`, `selection_deletion` | `scripts/selection`, `fuzz/core`, `property/sequences`; unit tests against JS's output for paths no vector covers | | Update cycle | `LexicalUpdates.ts` (`$beginUpdate`, nested and queued updates, `$applyAllTransforms`, `$commitPendingUpdates`, `$discardPendingUpdate`, listeners, `triggerCommandListeners`, `updateEditorSync`), `LexicalNormalization.ts` (`$normalizeTextNode`), `LexicalGC.ts` (nodes) | `updates`, `normalization`, `gc` | `scripts/updates`, `scripts/listeners`, `scripts/transforms`, `scripts/commands` | | Editor state | `LexicalEditorState.ts` (`toJSON`, `read`, `isEmpty`, `_parsed`) | `editor_state` | `serialization/core` | | Parsing | `parseEditorState`, `$parseSerializedNode` (`LexicalUpdates.ts`) | `updates` | `serialization/core`, `property/documents` | | NodeState | `LexicalNodeState.ts`: `createState` (`StateConfig` with `parse`, `unparse`, `isEqual`, `defaultValue` and `resetOnCopyNode`), `$getState` (latest and direct), `$setState` with a value or an updater, `$getStateChange`, `$getWritableNodeState`, `NodeState` (`getValue`, `toJSON`, `getWritable` with `parseAndPruneNextUnknownState`, `updateFromKnown`, `updateFromUnknown`, `updateFromJSON`, `resetOnCopyNode`, the size count), `$getSharedNodeState` and `createSharedNodeState` (a class's `stateConfigs`, flat keys, key collisions), `$updateStateFromJSON` (`Object.entries` of a `"$"` that is not an object, too), `$applyFlatStates`, `$cloneNodeState`, `$copyNodeState`, `$carryNodeState`, `nodeStatesAreEquivalent`; their call sites: the `"$"` key and flat states in `exportJSON` and `$parseSerializedNode`, `$copyNode`, `splitText`, the merge of `$normalizeTextNode`, history's JSON, a segmented node made plain text (`$insertText`, `$removeTextFromCaretRange`); `QuoteNode`'s `quoteShadowRootState` as a flat state | `node_state`, `node`, `nodes::text`, `normalization`, `updates`, `rich_text`, `caret_utils`, `selection_insert_text` | `scripts/node-state`, the node state case of `serialization/core` | | DecoratorNode | `nodes/LexicalDecoratorNode.ts` (`isInline`, `isIsolated`, `isKeyboardSelectable`, `decorate` as a hook returning an app's value, `$isDecoratorNode`) as `LexicalNode` hooks (`is_decorator`, `decorate`, `is_isolated`, `is_keyboard_selectable`); where the model treats decorators: `INTERNAL_$isBlock`, `$isLeafNode` (`formatList.ts`), `$isInlineElementOrDecoratorNode`, `$needsBlockCursorBeside`, `RootNode.splice` and `errorOnInsertTextNodeOnRoot`, `RangeSelection.getTextContent`, `insertNodes` (CASE 2's `notInline`, `$extractInlineFromBlocks`, `$isInlineRunNode`), `deleteCharacter`'s decorator branch (an isolated decorator stays; one beside an empty block is selected as a node, else removed), `$modifySelectionAroundDecoratorsAndBlocks` (stepping over, stopping at an isolated one, a node selection of a keyboard-selectable one); `$isInlineFormattable` and `InlineFormattableNode` (`is_inline_formattable`, `get_inline_format`, `set_inline_format`) in `$updateTextFormat` | `node`, `lexical_utils`, `caret_tree`, `nodes::element`, `lexical_selection`, `selection_insert_nodes`, `selection_deletion`, `selection_format`, `list` | `scripts/decorator` | | Editor | `createEditor` (node registration), `update`, `read`, `getEditorState`, `setEditorState`, `registerNodeTransform`, `registerUpdateListener`, `registerTextContentListener`, `registerCommand`, `dispatchCommand`, `createCommand` | `editor` | `scripts/*` | -| History (the `history` feature) | `packages/lexical-history/src/index.ts`: `registerHistory` (with `dateNow`, `onHistoryStateChange` and `maxDepth`), `createEmptyHistoryState`, `createMergeActionGetter`, `getChangeType`, `getDirtyNodes`, `isTextNodeUnchanged`, `areNodesUnchanged`, `getInverseEntry`, `getUndoEntry`, `undo`, `redo`, `clearHistory`; the commands it handles and dispatches from `LexicalCommands.ts` (`UNDO_COMMAND`, `REDO_COMMAND`, `CAN_UNDO_COMMAND`, `CAN_REDO_COMMAND`, `CLEAR_HISTORY_COMMAND`, `CLEAR_EDITOR_COMMAND`) and its tags from `LexicalUpdateTags.ts` | `history`, `commands`, `editor` | `scripts/history`; unit tests for a history shared by two editors (upstream's #8623 test) and `maxDepth` | +| History (the `history` feature) | `packages/lexical-history/src/index.ts`: `registerHistory` (with `dateNow`, `onHistoryStateChange` and `maxDepth`), `createEmptyHistoryState`, `createMergeActionGetter`, `getChangeType`, `getDirtyNodes`, `isTextNodeUnchanged`, `areNodesUnchanged`, `getInverseEntry`, `getUndoEntry`, `undo`, `redo`, `clearHistory`; the commands it handles and dispatches from `LexicalCommands.ts` (`UNDO_COMMAND`, `REDO_COMMAND`, `CAN_UNDO_COMMAND`, `CAN_REDO_COMMAND`, `CLEAR_HISTORY_COMMAND`, `CLEAR_EDITOR_COMMAND`) and its tags from `LexicalUpdateTags.ts` | `history`, `commands`, `editor` | `scripts/history`, `property/sequences`; unit tests for a history shared by two editors (upstream's #8623 test) and `maxDepth` | | Rich text nodes (the `rich-text` feature) | `packages/lexical-rich-text/src/index.ts`: `HeadingNode` (`getTag`, `setTag`, `insertNewAfter`, `collapseAtStart`, `extractWithChild`), `QuoteNode` (`insertNewAfter`, `collapseAtStart`, `canMergeWhenEmpty`, the opt-in shadow root), `$createHeadingNode`, `$isHeadingNode`, `$createQuoteNode`, `$isQuoteNode`; their generated JSON (`LexicalRichTextGeneratedJSON.ts`); `ElementNode.collapseAtStart` as a node hook, and `ElementNode`'s own `$transform` (`$normalizeShadowRootChildren`); from `@lexical/selection` and `@lexical/utils` only `$isAtEdgeOfElement`, `$isAtStartOfNode`, `$isAtEndOfNode`, `$findMatchingParent` and `$handleIndentAndOutdent` | `rich_text`, `selection`, `utils`, `updates` | `scripts/rich-text`, the heading and quote cases of `property/documents` | -| `registerRichText` (the `rich-text` feature) | `packages/lexical-rich-text/src/index.ts` `registerRichText`: the handlers of `DELETE_CHARACTER_COMMAND` (with `NodeSelection.deleteNodes`), `DELETE_WORD_COMMAND`, `DELETE_LINE_COMMAND`, `CONTROLLED_TEXT_INSERTION_COMMAND` (string form), `REMOVE_TEXT_COMMAND`, `FORMAT_TEXT_COMMAND`, `SET_TEXT_FORMAT_COMMAND`, `FORMAT_ELEMENT_COMMAND`, `INSERT_LINE_BREAK_COMMAND`, `INSERT_PARAGRAPH_COMMAND`, `INSERT_TAB_COMMAND`, `INDENT_CONTENT_COMMAND`, `OUTDENT_CONTENT_COMMAND`, `SELECT_ALL_COMMAND`; those commands from `LexicalCommands.ts`; `ElementNode.canIndent` | `rich_text::register_rich_text`, `commands`, `selection_deletion`, `utils` | `scripts/rich-text-commands` | +| `registerRichText` (the `rich-text` feature) | `packages/lexical-rich-text/src/index.ts` `registerRichText`: the handlers of `DELETE_CHARACTER_COMMAND` (with `NodeSelection.deleteNodes`), `DELETE_WORD_COMMAND`, `DELETE_LINE_COMMAND`, `CONTROLLED_TEXT_INSERTION_COMMAND` (string form), `REMOVE_TEXT_COMMAND`, `FORMAT_TEXT_COMMAND`, `SET_TEXT_FORMAT_COMMAND`, `FORMAT_ELEMENT_COMMAND`, `INSERT_LINE_BREAK_COMMAND`, `INSERT_PARAGRAPH_COMMAND`, `INSERT_TAB_COMMAND`, `INDENT_CONTENT_COMMAND`, `OUTDENT_CONTENT_COMMAND`, `SELECT_ALL_COMMAND`; those commands from `LexicalCommands.ts`; `ElementNode.canIndent` | `rich_text::register_rich_text`, `commands`, `selection_deletion`, `utils` | `scripts/rich-text-commands`, `property/sequences` | | `registerPlainText` (the `plain-text` feature) | `packages/lexical-plain-text/src/index.ts` `registerPlainText`: the handlers of `DELETE_CHARACTER_COMMAND`, `DELETE_WORD_COMMAND`, `DELETE_LINE_COMMAND`, `CONTROLLED_TEXT_INSERTION_COMMAND` (string form), `REMOVE_TEXT_COMMAND`, `INSERT_LINE_BREAK_COMMAND`, `INSERT_PARAGRAPH_COMMAND` (a line break) and `SELECT_ALL_COMMAND` | `plain_text` | `scripts/plain-text` | | List nodes (the `list` feature) | `packages/lexical-list/src/LexicalListNode.ts` (`ListNode`: `getListType`, `setListType`, `getTag`, `getStart`, `setStart`, `canBeEmpty`, `canIndent`, `splice`, its `$transform`), `LexicalListItemNode.ts` (`ListItemNode`: `append`, `replace`, `insertAfter`, `remove`, `resetOnCopyNodeFrom`, `insertNewAfter`, `collapseAtStart`, `getValue`, `setValue`, `getChecked`, `setChecked`, `toggleChecked`, `getIndent`, `setIndent`, `canMergeWith`, `isParentRequired`, `createParentElementNode`, `canMergeWhenEmpty`, its `$transform`), `$createListNode`, `$isListNode`, `$createListItemNode`, `$isListItemNode`; their generated JSON (`LexicalListGeneratedJSON.ts`); `utils.ts`; the `LexicalNode` hooks they override (`append`, `splice`, `replace`, `insertAfter`, `remove`, `getIndent`, `setIndent`, `resetOnCopyNodeFrom`, a class's own `$transform`); from the core `$copyNode`, `getPreviousSiblings`, `getNextSiblings`, `$splitTextPointCaret`, `$splitAtPointCaretNext`, `$insertNodeToNearestRootAtCaret`; from `@lexical/utils` `$getNearestNodeOfType` | `list`, `lexical_utils`, `caret_utils`, `node`, `nodes::element`, `utils` | `serialization/list`, the list cases of `property/documents`, `scripts/list` | -| `formatList.ts`, `registerList` and `registerCheckList` (the `list` feature) | `formatList.ts` (`$insertList`, `$createListOrMerge`, `mergeLists`, `$removeList`, `updateChildrenListItemValue`, `mergeNextSiblingListIfSameType`, `$handleIndent`, `$handleOutdent`, `$outdentFromItemWithContent`, `$handleListInsertParagraph`); `registerList.ts` `registerList` (`INSERT_ORDERED_LIST_COMMAND`, `INSERT_UNORDERED_LIST_COMMAND`, `REMOVE_LIST_COMMAND`, `UPDATE_LIST_START_COMMAND`, `INSERT_PARAGRAPH_COMMAND`, and the `ListItemNode` and `TextNode` transforms), with `RegisterListOptions`; `checkList.ts` `INSERT_CHECK_LIST_COMMAND` and its handler | `list::format_list`, `list::register_list` | `scripts/list` | +| `formatList.ts`, `registerList` and `registerCheckList` (the `list` feature) | `formatList.ts` (`$insertList`, `$createListOrMerge`, `mergeLists`, `$removeList`, `updateChildrenListItemValue`, `mergeNextSiblingListIfSameType`, `$handleIndent`, `$handleOutdent`, `$outdentFromItemWithContent`, `$handleListInsertParagraph`); `registerList.ts` `registerList` (`INSERT_ORDERED_LIST_COMMAND`, `INSERT_UNORDERED_LIST_COMMAND`, `REMOVE_LIST_COMMAND`, `UPDATE_LIST_START_COMMAND`, `INSERT_PARAGRAPH_COMMAND`, and the `ListItemNode` and `TextNode` transforms), with `RegisterListOptions`; `checkList.ts` `INSERT_CHECK_LIST_COMMAND` and its handler | `list::format_list`, `list::register_list` | `scripts/list`, `property/sequences` | | Link nodes (the `link` feature) | `packages/lexical-link/src/LexicalLinkNode.ts` (`LinkNode`: `getURL`, `setURL`, `getTarget`, `setTarget`, `getRel`, `setRel`, `getTitle`, `setTitle`, `insertNewAfter`, `canInsertTextBefore`, `canInsertTextAfter`, `canBeEmpty`, `isInline`, `extractWithChild`, `isEmailURI`, `isWebSiteURI`, `shouldMergeAdjacentLink`; `AutoLinkNode`: `getIsUnlinked`, `setIsUnlinked`, its `shouldMergeAdjacentLink`), `$createLinkNode`, `$isLinkNode`, `$createAutoLinkNode`, `$isAutoLinkNode`, `$linkNodeTransform`, `formatUrl`; their generated JSON (`LexicalLinkGeneratedJSON.ts`, with `afterCloneLinkNode` and `afterCloneAutoLinkNode` as `Clone`); `ElementNode.extractWithChild` as a node hook and `isParentOf`; from the core `RangeSelection.extract` and `NodeSelection.extract` | `link`, `node`, `selection_editing` | `serialization/link`, the link cases of `property/documents`, `scripts/link` | -| `$toggleLink` and `registerLink` (the `link` feature) | `LexicalLinkNode.ts` (`TOGGLE_LINK_COMMAND`, `$toggleLink`, `$withSelectedNodes`, `$getPointNode`, `$splitLinkAtSelection`); `LexicalLinkExtension.ts` `registerLink` (the `LinkNode` transform and the `TOGGLE_LINK_COMMAND` handler, with `validateUrl` and `attributes` from `LinkConfig`) | `link::register_link`, `link::toggle_link` | `scripts/link` | +| `$toggleLink` and `registerLink` (the `link` feature) | `LexicalLinkNode.ts` (`TOGGLE_LINK_COMMAND`, `$toggleLink`, `$withSelectedNodes`, `$getPointNode`, `$splitLinkAtSelection`); `LexicalLinkExtension.ts` `registerLink` (the `LinkNode` transform and the `TOGGLE_LINK_COMMAND` handler, with `validateUrl` and `attributes` from `LinkConfig`) | `link::register_link`, `link::toggle_link` | `scripts/link`, `property/sequences` | | `registerAutoLink` (the `autolink` feature) | `packages/lexical-link/src/LexicalAutoLinkExtension.ts`: `registerAutoLink` (the `TextNode` transform, `$handleLinkCreation`, `handleLinkEdit`, `handleBadNeighbors`, `$createAutoLinkNode_`, `extractMatchingNodes`, `isContentAroundIsValid`, `isPreviousNodeValid`, `isNextNodeValid`, `startsWithTLD`, the separator checks, `getTextNodesToMatch`, `replaceWithChildren`, and the `TOGGLE_LINK_COMMAND` handler that flips `isUnlinked`), `AutoLinkConfig` and its defaults (`changeHandlers`, `excludeParents`, `matchers`, `separatorRegex`), `ChangeHandler`, `LinkMatcher`, `LinkMatcherResult`, `createLinkMatcherWithRegExp`, `autoLinkUrlMatcher` (with its parenthesis balancing) and `autoLinkEmailMatcher`, on the `regex` crate | `link::register_auto_link` | `scripts/autolink`, `entities/autolink-match` | | Code nodes (the `code` feature) | `packages/lexical-code-core/src/CodeNode.ts` (`CodeNode`: `getLanguage`, `setLanguage`, `getTheme`, `setTheme`, `getIsSyntaxHighlightSupported`, `setIsSyntaxHighlightSupported`, `insertNewAfter` (the path of an editor without `CodeExtension`), `canIndent`, `collapseAtStart`; `$createCodeNode`, `$isCodeNode`, `DEFAULT_CODE_LANGUAGE`, `getDefaultCodeLanguage`, `$exitCodeNodeOnEnter`), `CodeHighlightNode.ts` (`CodeHighlightNode`: `getHighlightType`, `setHighlightType`, `setFormat` as a no-op, `isParentRequired`, `createParentElementNode`; `$createCodeHighlightNode`, `$isCodeHighlightNode`); their generated JSON (`LexicalCodeCoreGeneratedJSON.ts`, with `afterCloneCodeNode` and `afterCloneCodeHighlightNode` as `Clone`); `TextNode.setFormat` as a node hook; from the core `getTextDirection` and `RTL_REGEX`/`LTR_REGEX` (`LexicalUtils.ts`, `LexicalConstants.ts`) | `code`, `node`, `nodes::text`, `lexical_utils`, `constants` | `serialization/code`, the code cases of `property/documents`, `scripts/code` | | `FlatStructureUtils.ts` (the `code` feature) | `$getFirstCodeNodeOfLine`, `$getLastCodeNodeOfLine`, `$getCodeLineDirection`, `$getStartOfCodeInLine` (with `findNextNonBlankInLine`), `$getEndOfCodeInLine`, `$plainifyCodeContent`, `$outdentLeadingSpaces` | `code::flat_structure_utils` | `scripts/code`; a unit test against JS's output for each function | @@ -44,8 +44,9 @@ updated with each milestone. | Overflow node (the `overflow` feature) | `packages/lexical-overflow/src/index.ts` (`OverflowNode`: `insertNewAfter` (its parent's), `excludeFromCopy`, the `$transform` of its `$config` that removes it once empty; `$createOverflowNode`, `$isOverflowNode`), with `ElementNode`'s generated JSON; `ElementNode.excludeFromCopy` as a node hook | `overflow`, `node` | `serialization/overflow`, the overflow cases of `property/documents`, `scripts/overflow` | | Hashtag node and matcher (the `hashtag` feature) | `packages/lexical-hashtag/src/LexicalHashtagNode.ts` (`HashtagNode`: `canInsertTextBefore`, `isTextEntity`; `$createHashtagNode`, `$isHashtagNode`), with `TextNode`'s generated JSON; `LexicalHashtagExtension.ts` (`getHashtagRegexStringChars`, `getHashtagRegexString` and `getHashtagMatch` as the default matcher, `HashtagConfig`, `registerLexicalHashtag`), the character classes as range tables generated from the JS source by `rust/scripts/generate-hashtag-tables.mjs`; `TextNode.isTextEntity` as a node hook | `hashtag`, `node` | `entities/hashtag-match`, `entities/hashtag-documents`, `entities/hashtag-typing`, the hashtag cases of `property/documents` | | `@lexical/extension`'s decorators (the `extension` feature) | `packages/lexical-extension/src/HorizontalRuleExtension.ts` (`HorizontalRuleNode`: a block decorator whose text is `\n`; `$createHorizontalRuleNode`, `$isHorizontalRuleNode`), `DecoratorTextExtension.ts` (`DecoratorTextNode`: inline, `__isInlineFormattable`, `getFormat`, `hasFormat`, `setFormat`, `toggleFormat`, its `format` as a flat NodeState; `$isDecoratorTextNode`), with `LexicalNode`'s generated JSON | `extension` | `scripts/decorator` | -| `registerLexicalTextEntity` (the `text` feature) | `packages/lexical-text/src/registerLexicalTextEntity.ts`: `EntityMatch`, `registerLexicalTextEntity` (the `TextNode` transform and `$reverseNodeTransform`, `$replaceWithSimpleText`, `$wasTurnedIntoText`, `getFirstMatchAfter`, `isNextTextEntity`, `isTextAfterOutsideSource`, `isEntityAfter`, `$checkNextSibling`, `$moveSelectionIntoEntity`, `$wouldMerge`, `MAX_MATCHES_PASSED`, `MAX_TURNED_INTO_TEXT`, the per-update `markedInUpdate` and `turnedIntoTextInUpdate`); from the core `INTERNAL_getActiveEditorState` | `text`, `updates` | `entities/hashtag-documents`, `entities/hashtag-typing` | -| `$setBlocksType` (the `selection` feature) | `packages/lexical-selection/src/range-selection.ts` (`$setBlocksType`, with its default `$afterCreateElement`, `$copyBlockFormatIndent`, and `$isPointAtBlockEdge`), through `LexicalNode.replace` and `ListItemNode.replace` | `selection` | `scripts/set-blocks-type`, the block-decorator case of `scripts/decorator` | +| `NormalizeInlineElementsExtension`'s register (the `extension` feature) | `packages/lexical-extension/src/NormalizeInlineElementsExtension.ts` (`deleteEmptyInline`, and the `register` that adds it to the transforms of every registered `ElementNode` class with an `isInline` of its own, without marking their nodes dirty), as `register_normalize_inline_elements`; `RichTextExtension` depends on it | `extension` | `property/sequences` | +| `registerLexicalTextEntity` (the `text` feature) | `packages/lexical-text/src/registerLexicalTextEntity.ts`: `EntityMatch`, `registerLexicalTextEntity` (the `TextNode` transform and `$reverseNodeTransform`, `$replaceWithSimpleText`, `$wasTurnedIntoText`, `getFirstMatchAfter`, `isNextTextEntity`, `isTextAfterOutsideSource`, `isEntityAfter`, `$checkNextSibling`, `$moveSelectionIntoEntity`, `$wouldMerge`, `MAX_MATCHES_PASSED`, `MAX_TURNED_INTO_TEXT`, the per-update `markedInUpdate` and `turnedIntoTextInUpdate`); from the core `INTERNAL_getActiveEditorState` | `text`, `updates` | `entities/hashtag-documents`, `entities/hashtag-typing`, `property/sequences` | +| `$setBlocksType` (the `selection` feature) | `packages/lexical-selection/src/range-selection.ts` (`$setBlocksType`, with its default `$afterCreateElement`, `$copyBlockFormatIndent`, and `$isPointAtBlockEdge`), through `LexicalNode.replace` and `ListItemNode.replace` | `selection` | `scripts/set-blocks-type`, the block-decorator case of `scripts/decorator`, `property/sequences` | | UTF-16 offsets | `String#length`, `String#slice` | `utf16` | through the text methods; unit tests against Node | | Upstream version | `meta.json` | `UPSTREAM_VERSION` | `meta` | @@ -90,7 +91,11 @@ touches: editor has a `SelectionMovement` (`Editor::set_selection_movement`), which an app implements from its text layout; the rest follows JS from where it lands (a unit test checks that against JS with a stubbed - `Selection.modify`). The vectors record the headless behavior. The DOM + `Selection.modify`). The vectors record the headless behavior, except + `property/sequences`, whose editor has a window that gives no DOM + selection (jsdom's, installed after Lexical loaded, so `CAN_USE_DOM` is + false): there `$extendSelectionForDeletion` and `modify` stop after their + model step, which a movement whose `has_dom_selection` is false does. The DOM details around the measurement (block cursor element, shadow DOM ranges, Firefox's line-affinity retry) have no counterpart. `$selectAll(selection)`'s scoping isn't ported, nor @@ -153,9 +158,11 @@ touches: `CodeNode.insertNewAfter` always takes the path JS takes for an editor without `CodeExtension` (exiting the block after two line breaks itself, as `scripts/code` records with `createEditor`), less the deprecation - warning. JS's `CodeHighlightNode` constructor keeps a `null` highlight - type it is given; nothing but a direct `new` makes one, and Rust has - none. Highlighting (`@lexical/code-prism`, `@lexical/code-shiki`) is + warning. `property/sequences`' editor has `CodeExtension`, so there JS's + `INSERT_PARAGRAPH_COMMAND` adds a line even after two line breaks; no + case of it gets there (one would fail). JS's `CodeHighlightNode` + constructor keeps a `null` highlight type it is given; nothing but a + direct `new` makes one, and Rust has none. Highlighting (`@lexical/code-prism`, `@lexical/code-shiki`) is other packages: no vector needs a tokenizer, since every recorded document carries its `highlightType`s, and `scripts/code` registers no highlighter. Of the 25 `property/documents` cases with code nodes, 4 @@ -223,7 +230,8 @@ touches: - `registerPlainText`'s handlers of DOM events (the arrow keys, Backspace, Delete, Enter, copy, cut, paste, drop and drag) and `PlainTextExtension` with its dependencies (`DragonExtension`, - `NormalizeInlineElementsExtension`, + `NormalizeInlineElementsExtension`, whose transform + `register_normalize_inline_elements` registers, `NormalizeTripleClickSelectionExtension`), which need an input layer and the extension API. `quoteShadowRootState` is a flat NodeState, as in JS diff --git a/rust/conformance/src/scripts.rs b/rust/conformance/src/scripts.rs index 6aaa5b6e334..59836b31eaf 100644 --- a/rust/conformance/src/scripts.rs +++ b/rust/conformance/src/scripts.rs @@ -173,54 +173,58 @@ impl Ctx { /// (`CORE_COMMANDS`), a list or link command (`PACKAGE_COMMANDS`), or one /// command per other name. fn command(&self, name: &str) -> ScriptCommand { - match name { - "CAN_REDO_COMMAND" => ScriptCommand::Bool(CAN_REDO_COMMAND), - "CAN_UNDO_COMMAND" => ScriptCommand::Bool(CAN_UNDO_COMMAND), - "CLEAR_EDITOR_COMMAND" => ScriptCommand::Void(CLEAR_EDITOR_COMMAND), - "CLEAR_HISTORY_COMMAND" => ScriptCommand::Void(CLEAR_HISTORY_COMMAND), - "CONTROLLED_TEXT_INSERTION_COMMAND" => { - ScriptCommand::Str(CONTROLLED_TEXT_INSERTION_COMMAND) - } - "DELETE_CHARACTER_COMMAND" => ScriptCommand::Bool(DELETE_CHARACTER_COMMAND), - "DELETE_LINE_COMMAND" => ScriptCommand::Bool(DELETE_LINE_COMMAND), - "DELETE_WORD_COMMAND" => ScriptCommand::Bool(DELETE_WORD_COMMAND), - "FORMAT_ELEMENT_COMMAND" => ScriptCommand::Str(FORMAT_ELEMENT_COMMAND), - "FORMAT_TEXT_COMMAND" => ScriptCommand::Str(FORMAT_TEXT_COMMAND), - "INDENT_CONTENT_COMMAND" => ScriptCommand::Void(INDENT_CONTENT_COMMAND), - "INSERT_LINE_BREAK_COMMAND" => ScriptCommand::Bool(INSERT_LINE_BREAK_COMMAND), - "INSERT_PARAGRAPH_COMMAND" => ScriptCommand::Void(INSERT_PARAGRAPH_COMMAND), - "INSERT_TAB_COMMAND" => ScriptCommand::Void(INSERT_TAB_COMMAND), - "OUTDENT_CONTENT_COMMAND" => ScriptCommand::Void(OUTDENT_CONTENT_COMMAND), - "REDO_COMMAND" => ScriptCommand::Void(REDO_COMMAND), - "REMOVE_TEXT_COMMAND" => ScriptCommand::Void(REMOVE_TEXT_COMMAND), - "SELECT_ALL_COMMAND" => ScriptCommand::Void(SELECT_ALL_COMMAND), - "SET_TEXT_FORMAT_COMMAND" => ScriptCommand::Formats(SET_TEXT_FORMAT_COMMAND), - "UNDO_COMMAND" => ScriptCommand::Void(UNDO_COMMAND), - "INSERT_CHECK_LIST_COMMAND" => ScriptCommand::Void(list::INSERT_CHECK_LIST_COMMAND), - "INSERT_ORDERED_LIST_COMMAND" => ScriptCommand::Void(list::INSERT_ORDERED_LIST_COMMAND), - "INSERT_UNORDERED_LIST_COMMAND" => { - ScriptCommand::Void(list::INSERT_UNORDERED_LIST_COMMAND) - } - "REMOVE_LIST_COMMAND" => ScriptCommand::Void(list::REMOVE_LIST_COMMAND), - "UPDATE_LIST_START_COMMAND" => { - ScriptCommand::ListStart(list::UPDATE_LIST_START_COMMAND) - } - "TOGGLE_LINK_COMMAND" => ScriptCommand::Link(link::TOGGLE_LINK_COMMAND), - _ => ScriptCommand::Json( + known_command(name).unwrap_or_else(|| { + ScriptCommand::Json( self.commands .borrow_mut() .entry(name.to_owned()) .or_insert_with(|| create_command(name)) .clone(), - ), - } + ) + }) } } +/// One of Lexical's own commands (`CORE_COMMANDS`) or a list or link command +/// (`PACKAGE_COMMANDS`), by the name JS exports it as. +pub(crate) fn known_command(name: &str) -> Option { + Some(match name { + "CAN_REDO_COMMAND" => ScriptCommand::Bool(CAN_REDO_COMMAND), + "CAN_UNDO_COMMAND" => ScriptCommand::Bool(CAN_UNDO_COMMAND), + "CLEAR_EDITOR_COMMAND" => ScriptCommand::Void(CLEAR_EDITOR_COMMAND), + "CLEAR_HISTORY_COMMAND" => ScriptCommand::Void(CLEAR_HISTORY_COMMAND), + "CONTROLLED_TEXT_INSERTION_COMMAND" => { + ScriptCommand::Str(CONTROLLED_TEXT_INSERTION_COMMAND) + } + "DELETE_CHARACTER_COMMAND" => ScriptCommand::Bool(DELETE_CHARACTER_COMMAND), + "DELETE_LINE_COMMAND" => ScriptCommand::Bool(DELETE_LINE_COMMAND), + "DELETE_WORD_COMMAND" => ScriptCommand::Bool(DELETE_WORD_COMMAND), + "FORMAT_ELEMENT_COMMAND" => ScriptCommand::Str(FORMAT_ELEMENT_COMMAND), + "FORMAT_TEXT_COMMAND" => ScriptCommand::Str(FORMAT_TEXT_COMMAND), + "INDENT_CONTENT_COMMAND" => ScriptCommand::Void(INDENT_CONTENT_COMMAND), + "INSERT_LINE_BREAK_COMMAND" => ScriptCommand::Bool(INSERT_LINE_BREAK_COMMAND), + "INSERT_PARAGRAPH_COMMAND" => ScriptCommand::Void(INSERT_PARAGRAPH_COMMAND), + "INSERT_TAB_COMMAND" => ScriptCommand::Void(INSERT_TAB_COMMAND), + "OUTDENT_CONTENT_COMMAND" => ScriptCommand::Void(OUTDENT_CONTENT_COMMAND), + "REDO_COMMAND" => ScriptCommand::Void(REDO_COMMAND), + "REMOVE_TEXT_COMMAND" => ScriptCommand::Void(REMOVE_TEXT_COMMAND), + "SELECT_ALL_COMMAND" => ScriptCommand::Void(SELECT_ALL_COMMAND), + "SET_TEXT_FORMAT_COMMAND" => ScriptCommand::Formats(SET_TEXT_FORMAT_COMMAND), + "UNDO_COMMAND" => ScriptCommand::Void(UNDO_COMMAND), + "INSERT_CHECK_LIST_COMMAND" => ScriptCommand::Void(list::INSERT_CHECK_LIST_COMMAND), + "INSERT_ORDERED_LIST_COMMAND" => ScriptCommand::Void(list::INSERT_ORDERED_LIST_COMMAND), + "INSERT_UNORDERED_LIST_COMMAND" => ScriptCommand::Void(list::INSERT_UNORDERED_LIST_COMMAND), + "REMOVE_LIST_COMMAND" => ScriptCommand::Void(list::REMOVE_LIST_COMMAND), + "UPDATE_LIST_START_COMMAND" => ScriptCommand::ListStart(list::UPDATE_LIST_START_COMMAND), + "TOGGLE_LINK_COMMAND" => ScriptCommand::Link(link::TOGGLE_LINK_COMMAND), + _ => return None, + }) +} + /// A command a script names, by its payload type: Lexical's own take /// nothing (`void`), a boolean, a string or a map of formats to booleans, /// and those the script makes take any JSON. -enum ScriptCommand { +pub(crate) enum ScriptCommand { Void(LexicalCommand<()>), Bool(LexicalCommand), Str(LexicalCommand), @@ -302,7 +306,7 @@ fn formats_of(payload: &Value) -> Option> { impl ScriptCommand { /// `editor.dispatchCommand(command, payload)`. A payload the command /// doesn't take is an error, which JS would pass on as it is. - fn dispatch(&self, editor: &Editor, payload: Value) -> Result { + pub(crate) fn dispatch(&self, editor: &Editor, payload: Value) -> Result { match self { ScriptCommand::Void(command) if payload.is_null() => { editor.dispatch_command(command, ()) @@ -914,7 +918,7 @@ fn run_action(action: &Value, ctx: &Ctx, editor: &Editor) -> Result<(), LexicalE } /// `$selectionSnapshot()`. -fn selection_snapshot() -> Value { +pub(crate) fn selection_snapshot() -> Value { let Some(selection) = get_selection() else { return Value::Null; }; diff --git a/rust/conformance/src/suites/mod.rs b/rust/conformance/src/suites/mod.rs index 9654c3c4621..b9226f98353 100644 --- a/rust/conformance/src/suites/mod.rs +++ b/rust/conformance/src/suites/mod.rs @@ -160,6 +160,11 @@ pub fn all() -> Vec { variant: Some("JSON and text"), check: property::check_document, }, + Suite { + file: "property/sequences", + variant: None, + check: property::check_sequence, + }, ] } diff --git a/rust/conformance/src/suites/property.rs b/rust/conformance/src/suites/property.rs index 353f2d5d4cb..159d07cca99 100644 --- a/rust/conformance/src/suites/property.rs +++ b/rust/conformance/src/suites/property.rs @@ -1,7 +1,17 @@ use super::package_of; use crate::case::{Case, Outcome, same}; -use lexical::json::{self, Value}; -use lexical::{CreateEditorArgs, create_editor, get_root}; +use crate::scripts::{known_command, selection_snapshot}; +use lexical::history::{create_empty_history_state, register_history}; +use lexical::json::{self, Object, Value}; +use lexical::rich_text::{HeadingTagType, create_heading_node, create_quote_node}; +use lexical::selection::set_blocks_type; +use lexical::{ + CreateEditorArgs, Editor, HISTORY_MERGE_TAG, LexicalError, NodeClass, NodeRef, Point, + PointType, SelectionGranularity, SelectionMovement, Unregister, UpdateOptions, Which, + create_editor, create_paragraph_node, create_range_selection, get_root, get_selection, + reset_random_key, set_selection, +}; +use std::rc::Rc; /// The first node type in a serialized document that needs a package not /// ported yet. @@ -106,3 +116,270 @@ pub fn check_document(case: &Value, _file: &Value) -> Result { same("text", text.as_str(), case.str("text")?)?; Ok(Outcome::Pass) } + +/// The jsdom window `sequenceEditor` gives its editor in property.ts. It +/// is installed after Lexical has loaded, so `CAN_USE_DOM` is false and +/// `getDOMSelection` gives no selection: the deletion commands resolve in +/// the model (Swift's runner gives its editor a movement that never lands, +/// to the same end). +struct WindowWithoutSelection; + +impl SelectionMovement for WindowWithoutSelection { + fn landed_point( + &self, + _from: &Point, + _is_backward: bool, + _granularity: SelectionGranularity, + ) -> Result, LexicalError> { + Ok(None) + } + + fn has_dom_selection(&self) -> bool { + false + } +} + +/// `sequenceEditor()` in property.ts, without `buildEditorFromExtensions`: +/// the nodes its extensions list, in their order, and what each registers, +/// in the order the builder registers them (dependencies first). +/// +/// - `RichTextExtension`: `registerRichText`. Of its dependencies only +/// `NormalizeInlineElementsExtension` touches the model, with a transform +/// that removes an empty inline element; `HeadingAnnounceExtension` +/// (a mutation listener), `DragonExtension` and +/// `NormalizeTripleClickSelectionExtension` (root listeners) do nothing +/// without a root element, and the DOM import extensions are inert. +/// - `ListExtension`: `registerList` with `restoreNumbering: false`; +/// `CheckListExtension`: `registerCheckList`. +/// - `LinkExtension`: `registerLink` with its defaults. +/// - `HashtagExtension`: `registerLexicalHashtag`. +/// - `CodeExtension`: its `TabNode` transform. It doesn't register +/// `registerCodeIndentation` (`CodeIndentExtension` does, and the editor +/// doesn't have it). +/// - `HistoryExtension` with `{delay: 0, disabled: false}`: `registerHistory` +/// with a fresh history state and `Date.now`. +/// +/// Then `InitialStateExtension`, which every built editor has, gives the +/// empty root its paragraph. +/// +/// Returns the editor and what unregisters the history, whose entries hold +/// the editor. +fn sequence_editor() -> (Editor, Unregister) { + reset_random_key(); + let editor = create_editor(CreateEditorArgs { + nodes: [ + lexical::rich_text::rich_text_nodes(), + lexical::list::list_nodes(), + vec![NodeClass::of::()], + lexical::hashtag::hashtag_nodes(), + lexical::code::code_nodes(), + vec![NodeClass::of::()], + lexical::overflow::overflow_nodes(), + ] + .concat(), + }); + // Registered for the editor's life; dropping an unregister function + // doesn't call it. + let _unregister = lexical::extension::register_normalize_inline_elements(&editor); + let _unregister = lexical::rich_text::register_rich_text(&editor); + let _unregister = + lexical::list::register_list(&editor, lexical::list::RegisterListOptions::default()); + let _unregister = lexical::list::register_check_list(&editor); + let _unregister = lexical::link::register_link(&editor, lexical::link::LinkConfig::default()); + let _unregister = lexical::hashtag::register_lexical_hashtag( + &editor, + lexical::hashtag::HashtagConfig::default(), + ); + let _unregister = lexical::code::register_code(&editor); + let unregister_history = register_history( + &editor, + &create_empty_history_state(), + 0.0, + None, + None, + None, + ); + // `InitialStateExtension`'s `afterRegistration`, with its default + // `$initialEditorState` (`$defaultInitializer`): an empty root gets a + // paragraph, in an update tagged `history-merge`. + let _ = editor.update( + || { + let root = get_root(); + if root.is_empty() { + root.append(&[create_paragraph_node()])?; + } + Ok(()) + }, + UpdateOptions { + tags: vec![HISTORY_MERGE_TAG.to_owned()], + ..UpdateOptions::default() + }, + ); + editor.set_selection_movement(Some(Rc::new(WindowWithoutSelection))); + (editor, unregister_history) +} + +/// A step's `anchor` or `focus`: `[key, offset, type]`. +fn point_spec(step: &Value, which: &str) -> Result<(String, usize, PointType), String> { + let spec = step.arr(which)?; + let bad = || { + format!( + "bad {which} {}", + step.field(which).map_or(String::new(), Value::to_string) + ) + }; + let key = spec.first().and_then(Value::as_str).ok_or_else(bad)?; + let offset = spec.get(1).and_then(Value::as_f64).ok_or_else(bad)?; + let point_type = match spec.get(2).and_then(Value::as_str) { + Some("text") => PointType::Text, + Some("element") => PointType::Element, + _ => return Err(bad()), + }; + Ok((key.to_owned(), offset as usize, point_type)) +} + +/// `$blockCreator(blocks)` in property.ts. +fn block_creator(blocks: &str) -> Box NodeRef> { + match blocks { + "quote" => Box::new(|| create_quote_node(false)), + "h1" => Box::new(|| create_heading_node(HeadingTagType::H1)), + "h2" => Box::new(|| create_heading_node(HeadingTagType::H2)), + _ => Box::new(create_paragraph_node), + } +} + +/// `runStep(editor, step)` in property.ts: selects the step's points in one +/// discrete update (running `$setBlocksType` for a `blocks` step), then +/// dispatches its command outside any update, and records the result in +/// JS's key order. +fn run_step(editor: &Editor, step: &Value) -> Result { + let anchor = point_spec(step, "anchor")?; + let focus = point_spec(step, "focus")?; + let blocks = step + .get("blocks") + .and_then(Value::as_str) + .map(str::to_owned); + let command = match step.get("command").and_then(Value::as_str) { + None => None, + Some(name) => Some(known_command(name).ok_or_else(|| format!("unknown command {name}"))?), + }; + let mut handled = None; + let outcome = editor + .update( + move || { + let selection = create_range_selection(); + selection.point_set(Which::Anchor, &anchor.0, anchor.1, anchor.2)?; + selection.point_set(Which::Focus, &focus.0, focus.1, focus.2)?; + set_selection(Some(selection)); + if let Some(blocks) = &blocks { + set_blocks_type(get_selection().as_ref(), block_creator(blocks), None)?; + } + Ok(()) + }, + UpdateOptions { + discrete: true, + ..UpdateOptions::default() + }, + ) + .and_then(|()| match &command { + None => Ok(()), + Some(command) => { + let payload = step.get("payload").cloned().unwrap_or(Value::Null); + handled = Some(command.dispatch(editor, payload)?); + Ok(()) + } + }); + // A dispatch outside an update commits at the next read. + let (selection, text) = editor + .read(|| (selection_snapshot(), get_root().get_text_content())) + .map_err(|e| format!("committing: {e}"))?; + let state = editor.get_editor_state(); + let mut result = Object::new(); + result.insert("compact", state.to_json(true).to_string().into()); + result.insert("selection", selection); + result.insert("state", state.to_json(false).to_string().into()); + result.insert("text", text.into()); + if let Some(handled) = handled { + result.insert("handled", handled.into()); + } + if let Err(error) = outcome { + result.insert("error", true.into()); + // Not a field JS records: why the step failed, for the report. + result.insert("thrown", error.to_string().into()); + } + Ok(result) +} + +/// The fields of a step's result, in the order a mismatch is reported. +const STEP_FIELDS: [&str; 6] = ["error", "handled", "text", "selection", "state", "compact"]; + +/// `got` and `want` from the first place they differ, for long strings. +fn difference(got: &str, want: &str) -> String { + const CONTEXT: usize = 60; + let common = got + .char_indices() + .zip(want.chars()) + .find(|((_, a), b)| a != b) + .map_or(got.len().min(want.len()), |((i, _), _)| i); + let start = got[..common] + .char_indices() + .rev() + .nth(CONTEXT) + .map_or(0, |(i, _)| i); + let window = |s: &str| -> String { s[start..].chars().take(3 * CONTEXT).collect() }; + format!( + "differ at byte {common}: Rust …{:?}, JS …{:?}", + window(got), + window(want) + ) +} + +/// `property/sequences.json`: `runSequence` in property.ts, from the +/// concrete steps JS recorded. After each step, whether it threw, what +/// `dispatchCommand` returned, the text, the selection and the state in both +/// forms match JS. The first step that differs is reported, with every +/// field that differs in it; later steps diverge with it. +pub fn check_sequence(case: &Value, _file: &Value) -> Result { + let document = case.str("document")?; + let steps = case.arr("steps")?; + let results = case.arr("results")?; + same("steps recorded", results.len(), steps.len())?; + let (editor, unregister_history) = sequence_editor(); + let outcome = (|| { + let state = editor + .parse_editor_state(document) + .map_err(|e| format!("parsing the document: {e}"))?; + editor + .set_editor_state(&state, Some(HISTORY_MERGE_TAG)) + .map_err(|e| format!("setting the document: {e}"))?; + for (i, (step, expected)) in steps.iter().zip(results).enumerate() { + let got = run_step(&editor, step).map_err(|e| format!("step {i}: {e}"))?; + let mismatches: Vec = STEP_FIELDS + .iter() + .filter(|field| got.get(field) != expected.get(field)) + .map(|field| { + let show = |v: Option<&Value>| v.map_or("absent".to_owned(), Value::to_string); + let detail = match (got.get(field), expected.get(field)) { + (Some(Value::String(a)), Some(Value::String(b))) + if a.len() + b.len() > 200 => + { + difference(a, b) + } + (a, b) => format!("Rust {}, JS {}", show(a), show(b)), + }; + let thrown = match (*field, got.get("thrown")) { + ("error", Some(thrown)) => format!(" (Rust threw {thrown})"), + _ => String::new(), + }; + format!("{field}: {detail}{thrown}") + }) + .collect(); + if !mismatches.is_empty() { + return Err(format!("step {i} ({step}): {}", mismatches.join("; "))); + } + } + Ok(Outcome::Pass) + })(); + unregister_history(); + outcome +} diff --git a/rust/conformance/tests/conformance.rs b/rust/conformance/tests/conformance.rs index 6223b43c650..7b6542054dc 100644 --- a/rust/conformance/tests/conformance.rs +++ b/rust/conformance/tests/conformance.rs @@ -118,3 +118,67 @@ fn doctored_scripts_fail() { report.markdown() ); } + +/// The sequence suite compares every field of every step: in a copy of +/// `property/sequences.json` where one step's `handled` and a later step's +/// `text` in another case differ from what JS recorded, those two cases +/// fail, and only those, each naming its step and field. +#[test] +fn doctored_sequences_fail() { + let Some(vectors) = vectors_or_skip() else { + return; + }; + let text = std::fs::read_to_string(vectors.join("property/sequences.json")).unwrap(); + let mut file = json::parse(&text).unwrap(); + let Value::Object(object) = &mut file else { + panic!("not an object") + }; + let Some(Value::Array(cases)) = object.get_mut("cases") else { + panic!("no cases") + }; + let mut doctor = |i: usize, step: usize, key: &str, to: Value| { + let Value::Object(case) = &mut cases[i] else { + panic!("case {i}") + }; + let Some(Value::Array(results)) = case.get_mut("results") else { + panic!("case {i} has no results") + }; + let Value::Object(result) = &mut results[step] else { + panic!("case {i} step {step}") + }; + assert!( + result.get(key).is_some(), + "case {i} step {step} has no {key}" + ); + result.insert(key, to); + }; + doctor(0, 0, "handled", false.into()); + doctor(1, 1, "text", "doctored".into()); + + let dir = std::env::temp_dir().join(format!( + "lexical-conformance-doctored-sequences-{}", + std::process::id() + )); + std::fs::create_dir_all(dir.join("property")).unwrap(); + std::fs::write(dir.join("property/sequences.json"), file.to_string()).unwrap(); + let report = lexical_conformance::run_in(&dir, Some("property/sequences")); + std::fs::remove_dir_all(&dir).unwrap(); + let report = report.unwrap(); + let f = report + .files + .iter() + .find(|f| f.file == "property/sequences") + .unwrap(); + assert_eq!(f.failed, 2, "{}", report.markdown()); + assert!( + f.failures[0].contains("#0: step 0 ") + && f.failures[0].contains("handled: Rust true, JS false"), + "{}", + f.failures[0] + ); + assert!( + f.failures[1].contains("#1: step 1 ") && f.failures[1].contains("text: Rust "), + "{}", + f.failures[1] + ); +} diff --git a/rust/lexical-editor/Cargo.toml b/rust/lexical-editor/Cargo.toml index 707a43f8018..d529fcd47d8 100644 --- a/rust/lexical-editor/Cargo.toml +++ b/rust/lexical-editor/Cargo.toml @@ -55,8 +55,8 @@ text = [] # mergeRegister, which aren't ported. hashtag = ["text"] # @lexical/extension's decorators: HorizontalRuleNode (a block decorator) and -# DecoratorTextNode (an inline one). The rest of the package, the extension -# API, isn't ported. +# DecoratorTextNode (an inline one), and NormalizeInlineElementsExtension's +# transform. The rest of the package, the extension API, isn't ported. extension = [] [dependencies] diff --git a/rust/lexical-editor/src/editor.rs b/rust/lexical-editor/src/editor.rs index 15202dd3b5f..8b610281fd4 100644 --- a/rust/lexical-editor/src/editor.rs +++ b/rust/lexical-editor/src/editor.rs @@ -32,12 +32,24 @@ pub struct NodeClass { pub(crate) import_json: fn(&Object) -> Result, /// The `stateConfigs` of the class's `$config`. pub(crate) state_configs: fn() -> Vec, + /// Whether the class is an `ElementNode` with an `isInline` of its own + /// (`klass.prototype.isInline !== ElementNode.prototype.isInline`, + /// which `NormalizeInlineElementsExtension` asks). A trait method can't + /// tell an override, so this asks a fresh node whether it is an inline + /// element; every ported class answers the same for all its nodes. + #[cfg_attr(not(feature = "extension"), allow(dead_code))] + pub(crate) is_inline_element: fn() -> bool, } fn state_configs_of() -> Vec { T::default().state_configs() } +fn is_inline_element_of() -> bool { + let node = T::default(); + node.element().is_some() && node.is_inline() +} + /// The `importJSON` a class gets when it declares none /// (`synthesizeImportJSON`): build the node, then apply the JSON. fn import_default(json: &Object) -> Result { @@ -54,10 +66,12 @@ impl NodeClass { node_type: T::default().get_type(), import_json: import_default::, state_configs: state_configs_of::, + is_inline_element: is_inline_element_of::, } } - /// A class with its own `importJSON`, which declares no NodeState. + /// A class with its own `importJSON`, which declares no NodeState and + /// is not an inline element. pub fn with_import_json( node_type: &'static str, import_json: fn(&Object) -> Result, @@ -66,6 +80,7 @@ impl NodeClass { node_type, import_json, state_configs: Vec::new, + is_inline_element: || false, } } @@ -477,6 +492,21 @@ impl Editor { &self, node_type: &'static str, transform: impl Fn(&NodeRef) -> Result<(), LexicalError> + 'static, + ) -> Unregister { + let unregister = self.add_node_transform(node_type, transform); + mark_nodes_with_types_as_dirty(self, &[node_type]); + unregister + } + + /// `registeredNode.transforms.add(transform)`: what + /// [`register_node_transform`](Self::register_node_transform) does + /// before marking the type's nodes dirty, for callers that add to a + /// class's transforms directly, as `NormalizeInlineElementsExtension` + /// does. + pub(crate) fn add_node_transform( + &self, + node_type: &'static str, + transform: impl Fn(&NodeRef) -> Result<(), LexicalError> + 'static, ) -> Unregister { let id = self.next_id(); self.0 @@ -485,7 +515,6 @@ impl Editor { .entry(node_type) .or_default() .push((id, Rc::new(transform))); - mark_nodes_with_types_as_dirty(self, &[node_type]); let weak = self.weak(); Box::new(move || { if let Some(inner) = weak.upgrade() diff --git a/rust/lexical-editor/src/extension/mod.rs b/rust/lexical-editor/src/extension/mod.rs index 38abdf1d945..261736380a3 100644 --- a/rust/lexical-editor/src/extension/mod.rs +++ b/rust/lexical-editor/src/extension/mod.rs @@ -4,7 +4,9 @@ //! inline one that carries a text format (`DecoratorTextExtension.ts`), //! with their `$create`/`$is` functions. Neither has a schema of its own, //! so their JSON is `LexicalNode`'s, with `DecoratorTextNode`'s `format` as -//! a flat NodeState. +//! a flat NodeState. Also `NormalizeInlineElementsExtension`'s register +//! ([`register_normalize_inline_elements`]), which `RichTextExtension` +//! depends on. //! //! Not ported: their `createDOM`, `updateDOM`, `exportDOM` and `importDOM`, //! which come with `html`; `applyFormatFromStyle`, which reads a CSS style @@ -16,6 +18,7 @@ mod decorator_text_extension; mod horizontal_rule_extension; +mod normalize_inline_elements_extension; pub use decorator_text_extension::{ DecoratorTextNode, create_decorator_text_node, decorator_text_format_state, @@ -24,6 +27,7 @@ pub use decorator_text_extension::{ pub use horizontal_rule_extension::{ HorizontalRuleNode, create_horizontal_rule_node, is_horizontal_rule_node, }; +pub use normalize_inline_elements_extension::register_normalize_inline_elements; use crate::NodeClass; diff --git a/rust/lexical-editor/src/extension/normalize_inline_elements_extension.rs b/rust/lexical-editor/src/extension/normalize_inline_elements_extension.rs new file mode 100644 index 00000000000..ad1e95096b1 --- /dev/null +++ b/rust/lexical-editor/src/extension/normalize_inline_elements_extension.rs @@ -0,0 +1,35 @@ +//! `NormalizeInlineElementsExtension.ts`: the transform that removes an +//! inline element once it is empty. + +use crate::{Editor, LexicalError, NodeRef, Unregister}; + +/// `deleteEmptyInline(node)`: removes `node` when it is an inline element +/// with no children. JS's `__DEV__` warning for a node whose `canBeEmpty()` +/// is true has no console here. +fn delete_empty_inline(node: &NodeRef) -> Result<(), LexicalError> { + if node.is_element() && node.is_inline() && node.is_empty() { + node.remove(false)?; + } + Ok(()) +} + +/// `NormalizeInlineElementsExtension`'s `register(editor)`, without the +/// extension and with `disabled: false`: adds `deleteEmptyInline` to the +/// transforms of every registered `ElementNode` class with an `isInline` of +/// its own (`LinkNode` and `AutoLinkNode` among the ported ones). As in JS +/// it goes straight into each class's transforms, without marking that +/// class's nodes dirty as `registerNodeTransform` does. Returns what +/// removes it again. +pub fn register_normalize_inline_elements(editor: &Editor) -> Unregister { + let unregisters: Vec = editor + .registered_nodes() + .values() + .filter(|class| (class.is_inline_element)()) + .map(|class| editor.add_node_transform(class.node_type, delete_empty_inline)) + .collect(); + Box::new(move || { + for unregister in unregisters { + unregister(); + } + }) +} diff --git a/rust/lexical-editor/src/selection_deletion.rs b/rust/lexical-editor/src/selection_deletion.rs index 3e0ed5a4d07..97248c6519a 100644 --- a/rust/lexical-editor/src/selection_deletion.rs +++ b/rust/lexical-editor/src/selection_deletion.rs @@ -80,6 +80,17 @@ pub trait SelectionMovement { is_backward: bool, granularity: SelectionGranularity, ) -> Result, LexicalError>; + + /// Whether the window gives a DOM selection to measure with + /// (`getDOMSelection(getWindow(editor))` is not null). Where it gives + /// none, as in a window installed after Lexical loaded (the upstream + /// unit tests install jsdom's that way, so `CAN_USE_DOM` is false), + /// `$extendSelectionForDeletion` and `modify` stop after their model + /// step and leave the selection as it left it, and `landed_point` is + /// never asked. True by default. + fn has_dom_selection(&self) -> bool { + true + } } /// `deleteCharacter`'s scan for a block to merge with. @@ -501,6 +512,10 @@ impl SelectionRef { } let collapse = alter == SelectionAlter::Move; let movement = selection_movement()?; + // `if (!domSelection) return;` + if !movement.has_dom_selection() { + return Ok(()); + } // Removing the DOM block cursor element: there is no DOM. When the // selection is dirty JS first syncs the DOM selection to it; the // movement always measures from the model's focus. @@ -611,6 +626,10 @@ fn extend_selection_for_deletion( return Ok(false); } let movement = selection_movement()?; + // `!domSelection`. + if !movement.has_dom_selection() { + return Ok(false); + } // Removing the DOM block cursor element: there is no DOM. let range = selection.range().expect("a range selection"); let anchor = range.anchor.clone(); @@ -2988,4 +3007,55 @@ extend>l 10 3:0:e 3:0:e | 3:0:e 3:0:e 0 | = Err("window object not found".to_owned()) ); } + + /// A window that gives no DOM selection, as jsdom's does when installed + /// after Lexical loaded (property.ts's `sequenceEditor`). + struct WindowWithoutSelection; + + impl SelectionMovement for WindowWithoutSelection { + fn landed_point( + &self, + _from: &Point, + _is_backward: bool, + _granularity: SelectionGranularity, + ) -> Result, LexicalError> { + unreachable!("nothing measures without a DOM selection") + } + + fn has_dom_selection(&self) -> bool { + false + } + } + + /// JS, with `sequenceEditor`'s window: `modify` past the model step + /// returns at `if (!domSelection)`, leaving `abc` selected at 1 for both + /// alters. + #[test] + fn modify_without_a_dom_selection_matches_js() { + for alter in [SelectionAlter::Move, SelectionAlter::Extend] { + reset_random_key(); + let editor = create_editor(CreateEditorArgs::default()); + editor.set_selection_movement(Some(Rc::new(WindowWithoutSelection))); + let result = editor.update( + move || { + let paragraph = create_paragraph_node(); + let text = crate::create_text_node("abc"); + paragraph.append(std::slice::from_ref(&text))?; + get_root().append(&[paragraph])?; + let selection = text.select(Some(1), Some(1))?; + selection.modify(alter, true, SelectionGranularity::Character) + }, + UpdateOptions { + discrete: true, + ..UpdateOptions::default() + }, + ); + assert_eq!(result.map_err(|e| e.message().to_owned()), Ok(())); + let offsets = editor.read(|| { + let range = get_selection().and_then(|s| s.range()); + range.map(|r| (r.anchor.offset, r.focus.offset)) + }); + assert_eq!(offsets.ok().flatten(), Some((1, 1))); + } + } }