smartrte-core 0.2.1 → 1.0.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +23 -0
- package/dist/command.d.ts +8 -8
- package/dist/editor.d.ts +32 -0
- package/dist/editor.js +121 -0
- package/dist/foundation/annotations/index.d.ts +2 -0
- package/dist/foundation/annotations/index.js +2 -0
- package/dist/foundation/annotations/range.d.ts +54 -0
- package/dist/foundation/annotations/range.js +158 -0
- package/dist/foundation/annotations/types.d.ts +33 -0
- package/dist/foundation/annotations/types.js +1 -0
- package/dist/foundation/atom/commands.d.ts +11 -0
- package/dist/foundation/atom/commands.js +118 -0
- package/dist/foundation/atom/composition.d.ts +8 -0
- package/dist/foundation/atom/composition.js +22 -0
- package/dist/foundation/atom/declarations.d.ts +2 -0
- package/dist/foundation/atom/declarations.js +17 -0
- package/dist/foundation/atom/formats.d.ts +24 -0
- package/dist/foundation/atom/formats.js +105 -0
- package/dist/foundation/atom/index.d.ts +10 -0
- package/dist/foundation/atom/index.js +10 -0
- package/dist/foundation/atom/lifecycle.d.ts +12 -0
- package/dist/foundation/atom/lifecycle.js +41 -0
- package/dist/foundation/atom/persistence.d.ts +3 -0
- package/dist/foundation/atom/persistence.js +20 -0
- package/dist/foundation/atom/plugin.d.ts +20 -0
- package/dist/foundation/atom/plugin.js +55 -0
- package/dist/foundation/atom/schema.d.ts +3 -0
- package/dist/foundation/atom/schema.js +29 -0
- package/dist/foundation/atom/security.d.ts +10 -0
- package/dist/foundation/atom/security.js +20 -0
- package/dist/foundation/atom/selection.d.ts +3 -0
- package/dist/foundation/atom/selection.js +4 -0
- package/dist/foundation/atom/types.d.ts +47 -0
- package/dist/foundation/atom/types.js +1 -0
- package/dist/foundation/block/commands.d.ts +17 -0
- package/dist/foundation/block/commands.js +197 -0
- package/dist/foundation/block/formats.d.ts +33 -0
- package/dist/foundation/block/formats.js +57 -0
- package/dist/foundation/block/index.d.ts +5 -0
- package/dist/foundation/block/index.js +5 -0
- package/dist/foundation/block/input.d.ts +19 -0
- package/dist/foundation/block/input.js +67 -0
- package/dist/foundation/block/plugin.d.ts +26 -0
- package/dist/foundation/block/plugin.js +79 -0
- package/dist/foundation/block/schema.d.ts +49 -0
- package/dist/foundation/block/schema.js +27 -0
- package/dist/foundation/block/types.d.ts +40 -0
- package/dist/foundation/block/types.js +1 -0
- package/dist/foundation/boundaries.d.ts +11 -0
- package/dist/foundation/boundaries.js +117 -0
- package/dist/foundation/clipboard/detection.d.ts +3 -0
- package/dist/foundation/clipboard/detection.js +29 -0
- package/dist/foundation/clipboard/diagnostics.d.ts +24 -0
- package/dist/foundation/clipboard/diagnostics.js +59 -0
- package/dist/foundation/clipboard/index.d.ts +8 -0
- package/dist/foundation/clipboard/index.js +8 -0
- package/dist/foundation/clipboard/insertion.d.ts +22 -0
- package/dist/foundation/clipboard/insertion.js +210 -0
- package/dist/foundation/clipboard/normalizers.d.ts +8 -0
- package/dist/foundation/clipboard/normalizers.js +229 -0
- package/dist/foundation/clipboard/pipeline.d.ts +13 -0
- package/dist/foundation/clipboard/pipeline.js +58 -0
- package/dist/foundation/clipboard/sanitizer.d.ts +3 -0
- package/dist/foundation/clipboard/sanitizer.js +58 -0
- package/dist/foundation/clipboard/serialization.d.ts +5 -0
- package/dist/foundation/clipboard/serialization.js +33 -0
- package/dist/foundation/clipboard/transfer.d.ts +11 -0
- package/dist/foundation/clipboard/transfer.js +108 -0
- package/dist/foundation/clipboard/types.d.ts +65 -0
- package/dist/foundation/clipboard/types.js +1 -0
- package/dist/foundation/collab/index.d.ts +3 -0
- package/dist/foundation/collab/index.js +3 -0
- package/dist/foundation/collab/rebase.d.ts +28 -0
- package/dist/foundation/collab/rebase.js +82 -0
- package/dist/foundation/collab/transform.d.ts +73 -0
- package/dist/foundation/collab/transform.js +251 -0
- package/dist/foundation/collab/types.d.ts +52 -0
- package/dist/foundation/collab/types.js +1 -0
- package/dist/foundation/comments/fromSelection.d.ts +16 -0
- package/dist/foundation/comments/fromSelection.js +24 -0
- package/dist/foundation/comments/index.d.ts +3 -0
- package/dist/foundation/comments/index.js +3 -0
- package/dist/foundation/comments/rebase.d.ts +31 -0
- package/dist/foundation/comments/rebase.js +43 -0
- package/dist/foundation/comments/types.d.ts +37 -0
- package/dist/foundation/comments/types.js +1 -0
- package/dist/foundation/diff/documentDiff.d.ts +12 -0
- package/dist/foundation/diff/documentDiff.js +148 -0
- package/dist/foundation/diff/index.d.ts +3 -0
- package/dist/foundation/diff/index.js +3 -0
- package/dist/foundation/diff/textDiff.d.ts +12 -0
- package/dist/foundation/diff/textDiff.js +114 -0
- package/dist/foundation/diff/types.d.ts +74 -0
- package/dist/foundation/diff/types.js +1 -0
- package/dist/foundation/editor.d.ts +138 -0
- package/dist/foundation/editor.js +398 -0
- package/dist/foundation/formats/codec.d.ts +45 -0
- package/dist/foundation/formats/codec.js +1 -0
- package/dist/foundation/formats/docx/export.d.ts +4 -0
- package/dist/foundation/formats/docx/export.js +285 -0
- package/dist/foundation/formats/docx/import.d.ts +9 -0
- package/dist/foundation/formats/docx/import.js +37 -0
- package/dist/foundation/formats/docx/nestingGuard.d.ts +40 -0
- package/dist/foundation/formats/docx/nestingGuard.js +66 -0
- package/dist/foundation/formats/docx/portableAtoms.d.ts +4 -0
- package/dist/foundation/formats/docx/portableAtoms.js +66 -0
- package/dist/foundation/formats/docx/styledImport.d.ts +10 -0
- package/dist/foundation/formats/docx/styledImport.js +205 -0
- package/dist/foundation/formats/featureCodecs.d.ts +37 -0
- package/dist/foundation/formats/featureCodecs.js +69 -0
- package/dist/foundation/formats/fidelity.d.ts +31 -0
- package/dist/foundation/formats/fidelity.js +123 -0
- package/dist/foundation/formats/index.d.ts +7 -0
- package/dist/foundation/formats/index.js +7 -0
- package/dist/foundation/formats/pdf/format.d.ts +29 -0
- package/dist/foundation/formats/pdf/format.js +190 -0
- package/dist/foundation/history.d.ts +20 -0
- package/dist/foundation/history.js +121 -0
- package/dist/foundation/identity.d.ts +8 -0
- package/dist/foundation/identity.js +30 -0
- package/dist/foundation/index.d.ts +30 -0
- package/dist/foundation/index.js +30 -0
- package/dist/foundation/list/commands.d.ts +28 -0
- package/dist/foundation/list/commands.js +422 -0
- package/dist/foundation/list/formats.d.ts +21 -0
- package/dist/foundation/list/formats.js +813 -0
- package/dist/foundation/list/index.d.ts +8 -0
- package/dist/foundation/list/index.js +8 -0
- package/dist/foundation/list/input.d.ts +35 -0
- package/dist/foundation/list/input.js +320 -0
- package/dist/foundation/list/plugin.d.ts +21 -0
- package/dist/foundation/list/plugin.js +71 -0
- package/dist/foundation/list/presets.d.ts +16 -0
- package/dist/foundation/list/presets.js +21 -0
- package/dist/foundation/list/schema.d.ts +35 -0
- package/dist/foundation/list/schema.js +29 -0
- package/dist/foundation/list/shadow.d.ts +54 -0
- package/dist/foundation/list/shadow.js +75 -0
- package/dist/foundation/list/shadowCorpus.d.ts +10 -0
- package/dist/foundation/list/shadowCorpus.js +41 -0
- package/dist/foundation/list/types.d.ts +50 -0
- package/dist/foundation/list/types.js +1 -0
- package/dist/foundation/mapping.d.ts +11 -0
- package/dist/foundation/mapping.js +40 -0
- package/dist/foundation/marks/canonical.d.ts +9 -0
- package/dist/foundation/marks/canonical.js +72 -0
- package/dist/foundation/marks/commands.d.ts +21 -0
- package/dist/foundation/marks/commands.js +136 -0
- package/dist/foundation/marks/dom.d.ts +2 -0
- package/dist/foundation/marks/dom.js +46 -0
- package/dist/foundation/marks/editor.d.ts +8 -0
- package/dist/foundation/marks/editor.js +50 -0
- package/dist/foundation/marks/formats.d.ts +37 -0
- package/dist/foundation/marks/formats.js +49 -0
- package/dist/foundation/marks/hardBreak.d.ts +8 -0
- package/dist/foundation/marks/hardBreak.js +80 -0
- package/dist/foundation/marks/index.d.ts +11 -0
- package/dist/foundation/marks/index.js +11 -0
- package/dist/foundation/marks/normalization.d.ts +2 -0
- package/dist/foundation/marks/normalization.js +49 -0
- package/dist/foundation/marks/plugin.d.ts +14 -0
- package/dist/foundation/marks/plugin.js +63 -0
- package/dist/foundation/marks/resolveMarkRun.d.ts +8 -0
- package/dist/foundation/marks/resolveMarkRun.js +47 -0
- package/dist/foundation/marks/schema.d.ts +167 -0
- package/dist/foundation/marks/schema.js +50 -0
- package/dist/foundation/marks/stored.d.ts +2 -0
- package/dist/foundation/marks/stored.js +31 -0
- package/dist/foundation/marks/types.d.ts +40 -0
- package/dist/foundation/marks/types.js +1 -0
- package/dist/foundation/modelDom.d.ts +38 -0
- package/dist/foundation/modelDom.js +327 -0
- package/dist/foundation/normalization.d.ts +22 -0
- package/dist/foundation/normalization.js +132 -0
- package/dist/foundation/operations.d.ts +28 -0
- package/dist/foundation/operations.js +568 -0
- package/dist/foundation/plugin/builtins.d.ts +10 -0
- package/dist/foundation/plugin/builtins.js +27 -0
- package/dist/foundation/plugin/dispatch.d.ts +33 -0
- package/dist/foundation/plugin/dispatch.js +34 -0
- package/dist/foundation/plugin/index.d.ts +4 -0
- package/dist/foundation/plugin/index.js +4 -0
- package/dist/foundation/plugin/registry.d.ts +36 -0
- package/dist/foundation/plugin/registry.js +115 -0
- package/dist/foundation/plugin/types.d.ts +141 -0
- package/dist/foundation/plugin/types.js +13 -0
- package/dist/foundation/positions.d.ts +20 -0
- package/dist/foundation/positions.js +121 -0
- package/dist/foundation/schema.d.ts +57 -0
- package/dist/foundation/schema.js +371 -0
- package/dist/foundation/schemaBuilder.d.ts +41 -0
- package/dist/foundation/schemaBuilder.js +96 -0
- package/dist/foundation/scope/index.d.ts +2 -0
- package/dist/foundation/scope/index.js +2 -0
- package/dist/foundation/scope/resolveScope.d.ts +13 -0
- package/dist/foundation/scope/resolveScope.js +860 -0
- package/dist/foundation/scope/types.d.ts +122 -0
- package/dist/foundation/scope/types.js +1 -0
- package/dist/foundation/security/urlPolicy.d.ts +21 -0
- package/dist/foundation/security/urlPolicy.js +75 -0
- package/dist/foundation/structural/contentTarget.d.ts +21 -0
- package/dist/foundation/structural/contentTarget.js +51 -0
- package/dist/foundation/structural/index.d.ts +2 -0
- package/dist/foundation/structural/index.js +2 -0
- package/dist/foundation/structural/move.d.ts +7 -0
- package/dist/foundation/structural/move.js +35 -0
- package/dist/foundation/suggestions/index.d.ts +3 -0
- package/dist/foundation/suggestions/index.js +3 -0
- package/dist/foundation/suggestions/inline.d.ts +64 -0
- package/dist/foundation/suggestions/inline.js +116 -0
- package/dist/foundation/suggestions/structural.d.ts +40 -0
- package/dist/foundation/suggestions/structural.js +47 -0
- package/dist/foundation/suggestions/types.d.ts +19 -0
- package/dist/foundation/suggestions/types.js +1 -0
- package/dist/foundation/surface/index.d.ts +3 -0
- package/dist/foundation/surface/index.js +3 -0
- package/dist/foundation/surface/input.d.ts +127 -0
- package/dist/foundation/surface/input.js +1761 -0
- package/dist/foundation/surface/renderer.d.ts +84 -0
- package/dist/foundation/surface/renderer.js +974 -0
- package/dist/foundation/surface/types.d.ts +48 -0
- package/dist/foundation/surface/types.js +1 -0
- package/dist/foundation/table/commands.d.ts +31 -0
- package/dist/foundation/table/commands.js +470 -0
- package/dist/foundation/table/formats.d.ts +27 -0
- package/dist/foundation/table/formats.js +40 -0
- package/dist/foundation/table/grid.d.ts +8 -0
- package/dist/foundation/table/grid.js +178 -0
- package/dist/foundation/table/index.d.ts +6 -0
- package/dist/foundation/table/index.js +6 -0
- package/dist/foundation/table/plugin.d.ts +23 -0
- package/dist/foundation/table/plugin.js +119 -0
- package/dist/foundation/table/schema.d.ts +2 -0
- package/dist/foundation/table/schema.js +38 -0
- package/dist/foundation/table/selection.d.ts +7 -0
- package/dist/foundation/table/selection.js +35 -0
- package/dist/foundation/table/types.d.ts +109 -0
- package/dist/foundation/table/types.js +1 -0
- package/dist/foundation/transactions.d.ts +41 -0
- package/dist/foundation/transactions.js +90 -0
- package/dist/foundation/types.d.ts +261 -0
- package/dist/foundation/types.js +1 -0
- package/dist/foundation/versioning/diffVersions.d.ts +5 -0
- package/dist/foundation/versioning/diffVersions.js +3 -0
- package/dist/foundation/versioning/index.d.ts +2 -0
- package/dist/foundation/versioning/index.js +2 -0
- package/dist/foundation/versioning/types.d.ts +17 -0
- package/dist/foundation/versioning/types.js +1 -0
- package/dist/history.d.ts +20 -0
- package/dist/history.js +165 -0
- package/dist/html/compatibility.js +7 -1
- package/dist/index.d.ts +1 -14
- package/dist/index.js +9 -14
- package/dist/legacy/index.d.ts +45 -0
- package/dist/legacy/index.js +45 -0
- package/dist/legacyCommands/alignment.d.ts +20 -0
- package/dist/{commands → legacyCommands}/alignment.js +17 -0
- package/dist/legacyCommands/blocks.d.ts +14 -0
- package/dist/legacyCommands/blocks.js +105 -0
- package/dist/legacyCommands/checklist.d.ts +11 -0
- package/dist/legacyCommands/checklist.js +45 -0
- package/dist/legacyCommands/code.d.ts +15 -0
- package/dist/{commands → legacyCommands}/code.js +25 -1
- package/dist/legacyCommands/formula.d.ts +7 -0
- package/dist/legacyCommands/formula.js +67 -0
- package/dist/legacyCommands/inlineAtoms.d.ts +7 -0
- package/dist/legacyCommands/inlineAtoms.js +58 -0
- package/dist/legacyCommands/list.d.ts +38 -0
- package/dist/legacyCommands/list.js +477 -0
- package/dist/{commands → legacyCommands}/marks.d.ts +3 -0
- package/dist/{commands → legacyCommands}/marks.js +28 -11
- package/dist/legacyCommands/media.d.ts +31 -0
- package/dist/legacyCommands/media.js +205 -0
- package/dist/legacyCommands/move.d.ts +14 -0
- package/dist/legacyCommands/move.js +91 -0
- package/dist/legacyCommands/table.d.ts +91 -0
- package/dist/legacyCommands/table.js +547 -0
- package/dist/listPresets.d.ts +12 -0
- package/dist/listPresets.js +6 -0
- package/dist/listScope.d.ts +27 -0
- package/dist/listScope.js +104 -0
- package/dist/markdown/compatibility.d.ts +2 -0
- package/dist/markdown/compatibility.js +94 -2
- package/dist/marks.d.ts +5 -5
- package/dist/model.d.ts +65 -9
- package/dist/plugin.d.ts +15 -0
- package/dist/plugin.js +31 -0
- package/dist/plugins/alignment.d.ts +3 -0
- package/dist/plugins/alignment.js +6 -0
- package/dist/plugins/basicFormatting.d.ts +9 -0
- package/dist/plugins/basicFormatting.js +24 -0
- package/dist/plugins/blockType.d.ts +3 -0
- package/dist/plugins/blockType.js +6 -0
- package/dist/plugins/blockquote.d.ts +3 -0
- package/dist/plugins/blockquote.js +6 -0
- package/dist/plugins/checklist.d.ts +3 -0
- package/dist/plugins/checklist.js +10 -0
- package/dist/plugins/codeBlock.d.ts +3 -0
- package/dist/plugins/codeBlock.js +6 -0
- package/dist/plugins/formula.d.ts +3 -0
- package/dist/plugins/formula.js +9 -0
- package/dist/plugins/list.d.ts +3 -0
- package/dist/plugins/list.js +10 -0
- package/dist/plugins/media.d.ts +3 -0
- package/dist/plugins/media.js +12 -0
- package/dist/plugins/move.d.ts +3 -0
- package/dist/plugins/move.js +9 -0
- package/dist/plugins/table.d.ts +3 -0
- package/dist/plugins/table.js +22 -0
- package/dist/preset.d.ts +12 -0
- package/dist/preset.js +40 -0
- package/dist/schema.d.ts +22 -0
- package/dist/schema.js +333 -0
- package/dist/security/urlPolicy.d.ts +1 -17
- package/dist/security/urlPolicy.js +3 -69
- package/dist/selection.d.ts +1 -1
- package/dist/selectionMapping.d.ts +4 -0
- package/dist/selectionMapping.js +222 -0
- package/dist/table.d.ts +3 -3
- package/dist/transaction.d.ts +35 -12
- package/dist/transaction.js +174 -28
- package/dist/tree.d.ts +8 -2
- package/dist/tree.js +44 -0
- package/package.json +27 -8
- package/dist/commands/alignment.d.ts +0 -18
- package/dist/commands/code.d.ts +0 -13
- package/dist/commands/list.d.ts +0 -20
- package/dist/commands/list.js +0 -52
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
import { createNodeId, isTextNode } from "../identity.js";
|
|
2
|
+
import { backspaceAtListItemStart, deleteAtListItemEnd, enterInList, listItemAt, setListChecked, } from "../list/index.js";
|
|
3
|
+
import { resolveShortcut } from "../plugin/dispatch.js";
|
|
4
|
+
import { applyOperations } from "../operations.js";
|
|
5
|
+
import { nodeAtPath, comparePos, inlineGraphemeBoundaries } from "../positions.js";
|
|
6
|
+
import { createScopeIndex } from "../scope/index.js";
|
|
7
|
+
import { TransactionBuilder } from "../editor.js";
|
|
8
|
+
import { executeMarkTool, canonicalMarkOrder, inlineToolDeclarations, insertHardBreak, markKey, marksAtInsertion, } from "../marks/index.js";
|
|
9
|
+
import { exitCodeBlock, insertCodeBlockNewline, } from "../block/index.js";
|
|
10
|
+
import { tokenizeCompositionOwner } from "../atom/index.js";
|
|
11
|
+
import { deleteClipboardSelection, insertClipboardFragment, parseClipboardPayload, reportParsedClipboard, reportRejectedClipboard, remintClipboardFragmentIds, serializeClipboardRepresentations, sliceClipboardSelection, } from "../clipboard/index.js";
|
|
12
|
+
import { cellSelectionFromIds } from "../table/selection.js";
|
|
13
|
+
import { occupancyGridFor } from "../table/grid.js";
|
|
14
|
+
import { insertTableRowCommand } from "../table/commands.js";
|
|
15
|
+
import { FoundationTransactionMap } from "../mapping.js";
|
|
16
|
+
import { resolvePrecedingContentTarget } from "../structural/contentTarget.js";
|
|
17
|
+
import { SMART_UI_ATTRIBUTE } from "../modelDom.js";
|
|
18
|
+
import { suggestDeleteRangeOperation, suggestInsertOperation } from "../suggestions/index.js";
|
|
19
|
+
const samePos = (left, right) => comparePos(left, right) === 0;
|
|
20
|
+
const normalizedRange = (selection) => comparePos(selection.anchor, selection.head) <= 0
|
|
21
|
+
? { from: selection.anchor, to: selection.head }
|
|
22
|
+
: { from: selection.head, to: selection.anchor };
|
|
23
|
+
const collapsed = (selection) => samePos(selection.anchor, selection.head);
|
|
24
|
+
const inlineText = (owner) => (owner.children || []).map((node) => isTextNode(node) ? node.text : "\uFFFC").join("");
|
|
25
|
+
const tokenUnits = (tokens) => {
|
|
26
|
+
const units = [];
|
|
27
|
+
tokens.forEach((token) => {
|
|
28
|
+
if (token.kind === "atom")
|
|
29
|
+
units.push({ char: "\uFFFC", marks: [], key: `atom:${token.nodeId}`, kind: "atom" });
|
|
30
|
+
else
|
|
31
|
+
for (let index = 0; index < token.text.length; index += 1) {
|
|
32
|
+
units.push({ char: token.text[index], marks: [...token.marks], key: token.marks.map(markKey).join("|"), kind: "text" });
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
return units;
|
|
36
|
+
};
|
|
37
|
+
const ownerAt = (editor, pos) => {
|
|
38
|
+
const node = nodeAtPath(editor.document, pos.path);
|
|
39
|
+
if (!node || isTextNode(node))
|
|
40
|
+
throw new Error("Input position must resolve to an inline owner.");
|
|
41
|
+
return node;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Returns editable inline owners in logical document order. This is used only
|
|
45
|
+
* when a deletion crosses a structural boundary; ordinary character deletion
|
|
46
|
+
* remains path-local. Structural containers (lists, quotes, cells) are
|
|
47
|
+
* intentionally transparent here, while atomic nodes remain opaque.
|
|
48
|
+
*/
|
|
49
|
+
const editableOwners = (node, path, schema, output = []) => {
|
|
50
|
+
if (isTextNode(node) || schema.nodes[node.type]?.atomic === true)
|
|
51
|
+
return output;
|
|
52
|
+
if (isInlineOwnerNode(node, schema)) {
|
|
53
|
+
output.push({ node, path: [...path] });
|
|
54
|
+
return output;
|
|
55
|
+
}
|
|
56
|
+
node.children?.forEach((child, index) => editableOwners(child, [...path, index], schema, output));
|
|
57
|
+
return output;
|
|
58
|
+
};
|
|
59
|
+
const emptyParagraph = (id) => ({ type: "paragraph", id, children: [] });
|
|
60
|
+
const isInlineOwnerNode = (node, schema) => {
|
|
61
|
+
if (isTextNode(node) || schema.nodes[node.type]?.atomic === true)
|
|
62
|
+
return false;
|
|
63
|
+
if (["paragraph", "heading", "code_block"].includes(node.type))
|
|
64
|
+
return true;
|
|
65
|
+
return /(?:^|[|(\s])(?:inline|text)(?:[+*?]|$)/.test(schema.nodes[node.type]?.content || "");
|
|
66
|
+
};
|
|
67
|
+
/** Finds the nearest editable inline owner inside a structural sibling. */
|
|
68
|
+
const editableOwnerPosition = (node, path, direction, schema) => {
|
|
69
|
+
if (isTextNode(node) || schema.nodes[node.type]?.atomic === true)
|
|
70
|
+
return null;
|
|
71
|
+
if (isInlineOwnerNode(node, schema))
|
|
72
|
+
return {
|
|
73
|
+
path: [...path],
|
|
74
|
+
offset: direction > 0 ? 0 : inlineText(node).length,
|
|
75
|
+
};
|
|
76
|
+
const children = node.children || [];
|
|
77
|
+
const indexes = Array.from({ length: children.length }, (_, index) => index);
|
|
78
|
+
if (direction < 0)
|
|
79
|
+
indexes.reverse();
|
|
80
|
+
for (const index of indexes) {
|
|
81
|
+
const found = editableOwnerPosition(children[index], [...path, index], direction, schema);
|
|
82
|
+
if (found)
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* DOM selections can legally collapse on a structural element boundary (for
|
|
89
|
+
* example, the editable root at child offset `children.length`). Structural
|
|
90
|
+
* positions are useful for operations, but they are not caret owners. Resolve
|
|
91
|
+
* those boundary points to the nearest inline owner before they enter the
|
|
92
|
+
* canonical selection state. Direct editable siblings are preferred over an
|
|
93
|
+
* owner nested inside a structural sibling so a caret between quotes lands in
|
|
94
|
+
* the boundary paragraph rather than inside the preceding quote.
|
|
95
|
+
*/
|
|
96
|
+
const editablePositionForStructuralBoundary = (editor, position, bias) => {
|
|
97
|
+
const node = nodeAtPath(editor.document, position.path);
|
|
98
|
+
if (!node || isTextNode(node) || isInlineOwnerNode(node, editor.schema))
|
|
99
|
+
return position;
|
|
100
|
+
const children = node.children || [];
|
|
101
|
+
const offset = Math.max(0, Math.min(position.offset, children.length));
|
|
102
|
+
const direct = (index, direction) => {
|
|
103
|
+
const child = children[index];
|
|
104
|
+
if (!child || isTextNode(child) || !isInlineOwnerNode(child, editor.schema))
|
|
105
|
+
return null;
|
|
106
|
+
return {
|
|
107
|
+
path: [...position.path, index],
|
|
108
|
+
offset: direction < 0 ? inlineText(child).length : 0,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
const directOrder = bias < 0 ? [offset - 1, offset] : [offset, offset - 1];
|
|
112
|
+
for (const index of directOrder) {
|
|
113
|
+
const found = direct(index, bias);
|
|
114
|
+
if (found)
|
|
115
|
+
return found;
|
|
116
|
+
}
|
|
117
|
+
const nestedOrder = bias < 0 ? [offset - 1, offset] : [offset, offset - 1];
|
|
118
|
+
for (const index of nestedOrder) {
|
|
119
|
+
const child = children[index];
|
|
120
|
+
if (!child || isTextNode(child))
|
|
121
|
+
continue;
|
|
122
|
+
const found = editableOwnerPosition(child, [...position.path, index], bias, editor.schema);
|
|
123
|
+
if (found)
|
|
124
|
+
return found;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
};
|
|
128
|
+
const textSegments = (owner) => {
|
|
129
|
+
let offset = 0;
|
|
130
|
+
return (owner.children || []).map((node, index) => {
|
|
131
|
+
const from = offset;
|
|
132
|
+
offset += isTextNode(node) ? node.text.length : 1;
|
|
133
|
+
return { node, index, from, to: offset };
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Split an inline owner without flattening its children. Enter is a block
|
|
138
|
+
* operation, but the content crossing the split still belongs to the same
|
|
139
|
+
* inline runs: marks, hard breaks, and inline atoms must survive byte-for-byte
|
|
140
|
+
* on the new block. Text children are copied only when they are cut in two;
|
|
141
|
+
* unsplit children retain their identity until the operation boundary clones
|
|
142
|
+
* the inserted node.
|
|
143
|
+
*/
|
|
144
|
+
const splitInlineChildren = (children, offset) => {
|
|
145
|
+
const before = [];
|
|
146
|
+
const after = [];
|
|
147
|
+
let consumed = 0;
|
|
148
|
+
let split = false;
|
|
149
|
+
for (const child of children) {
|
|
150
|
+
if (split) {
|
|
151
|
+
after.push(child);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const width = isTextNode(child) ? child.text.length : 1;
|
|
155
|
+
const local = offset - consumed;
|
|
156
|
+
if (local >= width) {
|
|
157
|
+
before.push(child);
|
|
158
|
+
consumed += width;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (isTextNode(child)) {
|
|
162
|
+
if (local > 0)
|
|
163
|
+
before.push({ ...child, text: child.text.slice(0, local) });
|
|
164
|
+
if (local < width)
|
|
165
|
+
after.push({ ...child, text: child.text.slice(local) });
|
|
166
|
+
}
|
|
167
|
+
else if (local === 0) {
|
|
168
|
+
after.push(child);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// Inline atoms occupy one indivisible unit. A valid position cannot be
|
|
172
|
+
// inside one; this branch is only the defensive side of that contract.
|
|
173
|
+
before.push(child);
|
|
174
|
+
}
|
|
175
|
+
split = true;
|
|
176
|
+
consumed += width;
|
|
177
|
+
}
|
|
178
|
+
return [before, after];
|
|
179
|
+
};
|
|
180
|
+
const queueInlineDeletion = (builder, ownerPath, owner, from, to) => {
|
|
181
|
+
const segments = textSegments(owner).filter((segment) => segment.from < to && segment.to > from).reverse();
|
|
182
|
+
segments.forEach((segment) => {
|
|
183
|
+
if (isTextNode(segment.node)) {
|
|
184
|
+
const localFrom = Math.max(from, segment.from) - segment.from;
|
|
185
|
+
const localTo = Math.min(to, segment.to) - segment.from;
|
|
186
|
+
const text = segment.node.text.slice(localFrom, localTo);
|
|
187
|
+
if (text)
|
|
188
|
+
builder.operations.push({
|
|
189
|
+
type: "deleteText",
|
|
190
|
+
pos: { path: [...ownerPath], offset: segment.from + localFrom },
|
|
191
|
+
text,
|
|
192
|
+
...(segment.node.marks?.length ? { marks: [...segment.node.marks] } : {}),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
else if (from <= segment.from && to >= segment.to) {
|
|
196
|
+
builder.operations.push({ type: "removeNode", pos: { path: [...ownerPath], offset: segment.index }, node: segment.node });
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
const isDocumentStart = (editor, pos) => {
|
|
201
|
+
if (pos.offset !== 0)
|
|
202
|
+
return false;
|
|
203
|
+
let node = editor.document;
|
|
204
|
+
for (const index of pos.path) {
|
|
205
|
+
if (index !== 0 || isTextNode(node) || !node.children?.[index])
|
|
206
|
+
return false;
|
|
207
|
+
node = node.children[index];
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
};
|
|
211
|
+
const isDocumentEnd = (editor, pos) => {
|
|
212
|
+
let node = editor.document;
|
|
213
|
+
if (pos.path.length === 0)
|
|
214
|
+
return pos.offset === editor.document.children.length;
|
|
215
|
+
for (const index of pos.path) {
|
|
216
|
+
if (isTextNode(node) || !node.children?.[index] || index !== node.children.length - 1)
|
|
217
|
+
return false;
|
|
218
|
+
node = node.children[index];
|
|
219
|
+
}
|
|
220
|
+
const limit = isTextNode(node) ? 0 : isInlineOwnerNode(node, editor.schema)
|
|
221
|
+
? inlineText(node).length
|
|
222
|
+
: node.children?.length || 0;
|
|
223
|
+
return pos.offset === limit;
|
|
224
|
+
};
|
|
225
|
+
const isWholeDocumentRange = (editor, range) => {
|
|
226
|
+
return isDocumentStart(editor, range.from) && isDocumentEnd(editor, range.to);
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* Clearing a whole-document selection must still leave the schema-valid
|
|
230
|
+
* editable anchor the browser expects. Keep the operation explicit rather
|
|
231
|
+
* than relying on repair(), because transaction validation deliberately runs
|
|
232
|
+
* before repair and a doc node is required to contain at least one block.
|
|
233
|
+
*/
|
|
234
|
+
const queueWholeDocumentDeletion = (editor, builder) => {
|
|
235
|
+
const children = editor.document.children || [];
|
|
236
|
+
for (let index = children.length - 1; index >= 0; index -= 1) {
|
|
237
|
+
builder.operations.push({ type: "removeNode", pos: { path: [], offset: index }, node: children[index] });
|
|
238
|
+
}
|
|
239
|
+
const paragraph = emptyParagraph(createNodeId());
|
|
240
|
+
builder.operations.push({ type: "insertNode", pos: { path: [], offset: 0 }, node: paragraph });
|
|
241
|
+
return { path: [0], offset: 0 };
|
|
242
|
+
};
|
|
243
|
+
const queueRangeDeletion = (editor, builder, range) => {
|
|
244
|
+
const fromOwner = ownerAt(editor, range.from);
|
|
245
|
+
const toOwner = ownerAt(editor, range.to);
|
|
246
|
+
if (range.from.path.length === range.to.path.length && range.from.path.every((part, index) => part === range.to.path[index])) {
|
|
247
|
+
queueInlineDeletion(builder, range.from.path, fromOwner, range.from.offset, range.to.offset);
|
|
248
|
+
return { path: [...range.from.path], offset: range.from.offset };
|
|
249
|
+
}
|
|
250
|
+
const parentPath = range.from.path.slice(0, -1);
|
|
251
|
+
const toParentPath = range.to.path.slice(0, -1);
|
|
252
|
+
if (parentPath.length !== toParentPath.length || parentPath.some((part, index) => part !== toParentPath[index])) {
|
|
253
|
+
throw new Error("Cross-parent text deletion is outside the canonical test surface contract.");
|
|
254
|
+
}
|
|
255
|
+
const fromIndex = range.from.path[range.from.path.length - 1];
|
|
256
|
+
const toIndex = range.to.path[range.to.path.length - 1];
|
|
257
|
+
if (fromOwner.type !== toOwner.type || fromIndex >= toIndex)
|
|
258
|
+
throw new Error("Cross-block deletion requires compatible forward sibling blocks.");
|
|
259
|
+
queueInlineDeletion(builder, range.to.path, toOwner, 0, range.to.offset);
|
|
260
|
+
queueInlineDeletion(builder, range.from.path, fromOwner, range.from.offset, inlineText(fromOwner).length);
|
|
261
|
+
const parent = nodeAtPath(editor.document, parentPath);
|
|
262
|
+
if (!parent || isTextNode(parent) || !parent.children)
|
|
263
|
+
throw new Error("Cross-block deletion parent is invalid.");
|
|
264
|
+
for (let index = toIndex - 1; index > fromIndex; index -= 1) {
|
|
265
|
+
builder.operations.push({ type: "removeNode", pos: { path: [...parentPath], offset: index }, node: parent.children[index] });
|
|
266
|
+
}
|
|
267
|
+
builder.operations.push({
|
|
268
|
+
type: "mergeNode",
|
|
269
|
+
pos: { path: [...parentPath], offset: fromIndex + 1 },
|
|
270
|
+
depth: 0,
|
|
271
|
+
retiredId: toOwner.id,
|
|
272
|
+
splitOffset: textSegments(fromOwner).filter((segment) => segment.from < range.from.offset).length,
|
|
273
|
+
});
|
|
274
|
+
return { path: [...range.from.path], offset: range.from.offset };
|
|
275
|
+
};
|
|
276
|
+
const samePath = (left, right) => left.length === right.length && left.every((part, index) => part === right[index]);
|
|
277
|
+
const pathStartsWith = (path, prefix) => prefix.length < path.length && prefix.every((part, index) => path[index] === part);
|
|
278
|
+
const firstEditableOwnerId = (node, schema) => {
|
|
279
|
+
if (isTextNode(node) || schema.nodes[node.type]?.atomic === true)
|
|
280
|
+
return null;
|
|
281
|
+
if (isInlineOwnerNode(node, schema))
|
|
282
|
+
return node.id;
|
|
283
|
+
for (const child of node.children || []) {
|
|
284
|
+
const found = firstEditableOwnerId(child, schema);
|
|
285
|
+
if (found)
|
|
286
|
+
return found;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
};
|
|
290
|
+
const scopeTargetIds = (scope) => {
|
|
291
|
+
if (scope.kind === "list-selection")
|
|
292
|
+
return scope.items.map((item) => item.itemId);
|
|
293
|
+
if (scope.kind === "block-range")
|
|
294
|
+
return [...scope.blockIds];
|
|
295
|
+
if (scope.kind === "container-tree")
|
|
296
|
+
return [scope.rootId];
|
|
297
|
+
if (scope.kind === "atomic-node")
|
|
298
|
+
return [scope.nodeId];
|
|
299
|
+
if (scope.kind === "mixed")
|
|
300
|
+
return scope.parts.flatMap(scopeTargetIds);
|
|
301
|
+
return [];
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Deletes a structural selection by IDs rather than by assuming both inline
|
|
305
|
+
* endpoints share one parent. The old range path was intentionally narrow
|
|
306
|
+
* for character deletion; it threw for list items and for node selections of
|
|
307
|
+
* blockquote/code nodes, which the browser quite legitimately reports as
|
|
308
|
+
* different scope kinds.
|
|
309
|
+
*/
|
|
310
|
+
const structuralDeletionPlan = (editor, selection, range) => {
|
|
311
|
+
const ids = [];
|
|
312
|
+
if (selection.type === "node" || selection.type === "cell") {
|
|
313
|
+
const parent = nodeAtPath(editor.document, range.from.path);
|
|
314
|
+
if (!parent || isTextNode(parent) || !parent.children)
|
|
315
|
+
return null;
|
|
316
|
+
for (let index = range.from.offset; index < range.to.offset; index += 1) {
|
|
317
|
+
const child = parent.children[index];
|
|
318
|
+
if (child && !isTextNode(child))
|
|
319
|
+
ids.push(child.id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
const scope = editor.resolveScope({ want: "list-selection" }, selection);
|
|
324
|
+
if ("kind" in scope)
|
|
325
|
+
ids.push(...scopeTargetIds(scope));
|
|
326
|
+
if (!ids.length) {
|
|
327
|
+
const blocks = editor.resolveScope({ want: "block-range" }, selection);
|
|
328
|
+
if ("kind" in blocks)
|
|
329
|
+
ids.push(...scopeTargetIds(blocks));
|
|
330
|
+
}
|
|
331
|
+
if (!ids.length) {
|
|
332
|
+
const containers = editor.resolveScope({ want: "container-tree" }, selection);
|
|
333
|
+
if ("kind" in containers)
|
|
334
|
+
ids.push(...scopeTargetIds(containers));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const uniqueIds = [...new Set(ids)];
|
|
338
|
+
if (!uniqueIds.length)
|
|
339
|
+
return null;
|
|
340
|
+
const located = uniqueIds.flatMap((id) => {
|
|
341
|
+
const position = editor.positions.positionOf(id);
|
|
342
|
+
if (!position)
|
|
343
|
+
return [];
|
|
344
|
+
return [{ id, position, node: position.parent.children?.[position.pos.offset] }];
|
|
345
|
+
}).filter((entry) => Boolean(entry.node && !isTextNode(entry.node)));
|
|
346
|
+
if (!located.length)
|
|
347
|
+
return null;
|
|
348
|
+
// If a parent itself is selected, descendants are already removed with it;
|
|
349
|
+
// emitting both would address stale paths in one transaction.
|
|
350
|
+
const targetPaths = located.map((entry) => ({ ...entry, path: [...entry.position.pos.path, entry.position.pos.offset] }));
|
|
351
|
+
const targets = targetPaths.filter((entry) => !targetPaths.some((candidate) => candidate !== entry && pathStartsWith(entry.path, candidate.path)));
|
|
352
|
+
const byParent = new Map();
|
|
353
|
+
targets.forEach((entry) => {
|
|
354
|
+
const key = entry.position.pos.path.join("/");
|
|
355
|
+
byParent.set(key, [...(byParent.get(key) || []), entry]);
|
|
356
|
+
});
|
|
357
|
+
// Select-all is a structural range at the document root. Keep the explicit
|
|
358
|
+
// empty-document anchor instead of leaving repair to invent one later.
|
|
359
|
+
const root = nodeAtPath(editor.document, []);
|
|
360
|
+
const rootGroup = byParent.get("");
|
|
361
|
+
if (root && !isTextNode(root) && rootGroup && rootGroup.length === (root.children || []).length
|
|
362
|
+
&& rootGroup.every((entry) => entry.position.pos.path.length === 0)) {
|
|
363
|
+
const operations = [];
|
|
364
|
+
for (let index = (root.children || []).length - 1; index >= 0; index -= 1) {
|
|
365
|
+
operations.push({ type: "removeNode", pos: { path: [], offset: index }, node: root.children[index] });
|
|
366
|
+
}
|
|
367
|
+
const paragraph = emptyParagraph(createNodeId());
|
|
368
|
+
operations.push({ type: "insertNode", pos: { path: [], offset: 0 }, node: paragraph });
|
|
369
|
+
return { operations, preferredOwnerIds: [paragraph.id] };
|
|
370
|
+
}
|
|
371
|
+
const operations = [];
|
|
372
|
+
const preferredOwnerIds = [];
|
|
373
|
+
byParent.forEach((entries) => {
|
|
374
|
+
const parent = entries[0].position.parent;
|
|
375
|
+
const children = parent.children || [];
|
|
376
|
+
const indexes = entries.map((entry) => entry.position.pos.offset).sort((left, right) => left - right);
|
|
377
|
+
const allChildren = indexes.length === children.length && indexes.every((value, index) => value === index);
|
|
378
|
+
if (allChildren && parent.type === "list") {
|
|
379
|
+
const listPosition = editor.positions.positionOf(parent.id);
|
|
380
|
+
if (listPosition) {
|
|
381
|
+
const paragraph = emptyParagraph(createNodeId());
|
|
382
|
+
operations.push({ type: "replaceNode", pos: listPosition.pos, before: parent, after: paragraph });
|
|
383
|
+
preferredOwnerIds.push(paragraph.id);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (allChildren && (parent.type === "list_item" || parent.type === "blockquote" || parent.type === "table_cell")) {
|
|
388
|
+
const parentPosition = editor.positions.positionOf(parent.id);
|
|
389
|
+
if (parentPosition) {
|
|
390
|
+
const paragraph = emptyParagraph(createNodeId());
|
|
391
|
+
operations.push({ type: "replaceNode", pos: parentPosition.pos, before: parent, after: { ...parent, children: [paragraph] } });
|
|
392
|
+
preferredOwnerIds.push(paragraph.id);
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// Descending offsets keep every remaining path stable when several
|
|
397
|
+
// siblings are deleted from the same list/table/document container.
|
|
398
|
+
[...entries].sort((left, right) => right.position.pos.offset - left.position.pos.offset).forEach((entry) => {
|
|
399
|
+
operations.push({ type: "removeNode", pos: entry.position.pos, node: entry.node });
|
|
400
|
+
});
|
|
401
|
+
const next = children[indexes[indexes.length - 1] + 1] || children[indexes[0] - 1];
|
|
402
|
+
if (next && !isTextNode(next)) {
|
|
403
|
+
const owner = firstEditableOwnerId(next, editor.schema);
|
|
404
|
+
if (owner)
|
|
405
|
+
preferredOwnerIds.push(owner);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
if (!operations.length)
|
|
409
|
+
return null;
|
|
410
|
+
return { operations, preferredOwnerIds };
|
|
411
|
+
};
|
|
412
|
+
const previousWord = (text, offset) => {
|
|
413
|
+
const prefix = text.slice(0, offset);
|
|
414
|
+
const match = prefix.match(/(?:\s+|[\p{L}\p{N}_]+|[^\p{L}\p{N}_\s]+)$/u);
|
|
415
|
+
return Math.max(0, offset - (match?.[0].length || 1));
|
|
416
|
+
};
|
|
417
|
+
const nextWord = (text, offset) => {
|
|
418
|
+
const match = text.slice(offset).match(/^(?:\s+|[\p{L}\p{N}_]+|[^\p{L}\p{N}_\s]+)/u);
|
|
419
|
+
return Math.min(text.length, offset + (match?.[0].length || 1));
|
|
420
|
+
};
|
|
421
|
+
export class FoundationInputPipeline {
|
|
422
|
+
constructor(editor, renderer, root, options = {}) {
|
|
423
|
+
this.editor = editor;
|
|
424
|
+
this.renderer = renderer;
|
|
425
|
+
this.root = root;
|
|
426
|
+
this.options = options;
|
|
427
|
+
this.composition = null;
|
|
428
|
+
this.unhandled = [];
|
|
429
|
+
this.destroyed = false;
|
|
430
|
+
this.internalDrag = null;
|
|
431
|
+
this.tableDragAnchor = null;
|
|
432
|
+
this.trackChanges = { enabled: false, authorId: "anonymous" };
|
|
433
|
+
this.beforeInputListener = (event) => this.handleBeforeInput(event);
|
|
434
|
+
this.keyDownListener = (event) => this.handleKeyDown(event);
|
|
435
|
+
this.compositionStartListener = (event) => this.handleCompositionStart(event);
|
|
436
|
+
this.compositionUpdateListener = (event) => this.handleCompositionUpdate(event);
|
|
437
|
+
this.compositionEndListener = (event) => this.handleCompositionEnd(event);
|
|
438
|
+
this.tableMouseDownListener = (event) => {
|
|
439
|
+
const target = event.target instanceof Element ? event.target.closest(`[${SMART_UI_ATTRIBUTE}="check-control"]`) : null;
|
|
440
|
+
if (target) {
|
|
441
|
+
// A projected checkbox must not move the editable selection to the
|
|
442
|
+
// button itself. The click handler below keeps the existing model
|
|
443
|
+
// selection while toggling the item.
|
|
444
|
+
event.preventDefault();
|
|
445
|
+
this.root.focus();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (this.composition)
|
|
449
|
+
return;
|
|
450
|
+
// Only the primary (left) button starts a drag-selection gesture. A
|
|
451
|
+
// right-click's mousedown/mouseup pair (button 2) also reaches this
|
|
452
|
+
// same native listener - without this guard, a real-world imprecise
|
|
453
|
+
// right-click whose mousedown and mouseup landed in different cells
|
|
454
|
+
// (easy near a shared cell border, where a human hand's natural
|
|
455
|
+
// few-pixel wobble between press and release is enough) silently
|
|
456
|
+
// formed a genuine multi-cell selection the user never intended to
|
|
457
|
+
// make, rendered as a highlight spanning well past what a single
|
|
458
|
+
// right-click should ever select.
|
|
459
|
+
if (event.button !== 0) {
|
|
460
|
+
this.tableDragAnchor = null;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const cell = this.cellFromNode(event.target);
|
|
464
|
+
this.tableDragAnchor = cell;
|
|
465
|
+
};
|
|
466
|
+
this.tableMouseUpListener = (event) => {
|
|
467
|
+
const anchor = this.tableDragAnchor;
|
|
468
|
+
this.tableDragAnchor = null;
|
|
469
|
+
if (!anchor || this.composition)
|
|
470
|
+
return;
|
|
471
|
+
const head = this.cellFromNode(event.target);
|
|
472
|
+
if (!head || head.tableId !== anchor.tableId || head.id === anchor.id)
|
|
473
|
+
return;
|
|
474
|
+
const selection = cellSelectionFromIds(anchor.id, head.id, this.editor.positions);
|
|
475
|
+
if (!selection)
|
|
476
|
+
return;
|
|
477
|
+
event.preventDefault();
|
|
478
|
+
this.editor.setSelection(selection, { source: "api" });
|
|
479
|
+
this.renderer.render(this.editor.document, selection);
|
|
480
|
+
};
|
|
481
|
+
this.clickListener = (event) => {
|
|
482
|
+
const checkControl = event.target instanceof Element
|
|
483
|
+
? event.target.closest(`[${SMART_UI_ATTRIBUTE}="check-control"]`)
|
|
484
|
+
: null;
|
|
485
|
+
if (checkControl) {
|
|
486
|
+
event.preventDefault();
|
|
487
|
+
const itemElement = checkControl.closest('[data-smart-type="list_item"]');
|
|
488
|
+
const mapped = itemElement ? this.renderer.mapping.domToNode(itemElement) : null;
|
|
489
|
+
if (!mapped || isTextNode(mapped.node) || mapped.node.type !== "list_item")
|
|
490
|
+
return;
|
|
491
|
+
const itemPosition = this.editor.positions.positionOf(mapped.nodeId);
|
|
492
|
+
const list = itemPosition?.parent;
|
|
493
|
+
if (!itemPosition || !list || list.type !== "list" || list.attrs?.checkable !== true)
|
|
494
|
+
return;
|
|
495
|
+
const ownerId = firstEditableOwnerId(mapped.node, this.editor.schema);
|
|
496
|
+
const content = ownerId ? this.editor.positions.contentRangeOf(ownerId) : null;
|
|
497
|
+
if (!content)
|
|
498
|
+
return;
|
|
499
|
+
const point = content.from;
|
|
500
|
+
const scope = this.editor.resolveScope({ want: "list-selection" }, { type: "text", anchor: point, head: point });
|
|
501
|
+
if (!("kind" in scope) || (scope.kind !== "list-selection" && scope.kind !== "mixed"))
|
|
502
|
+
return;
|
|
503
|
+
const operations = setListChecked(this.editor.document, scope, { checked: mapped.node.attrs?.checked !== true }, this.commandContext());
|
|
504
|
+
if (!operations.length)
|
|
505
|
+
return;
|
|
506
|
+
const selection = this.editor.selection;
|
|
507
|
+
this.editor.transact((builder) => {
|
|
508
|
+
builder.operations.push(...operations);
|
|
509
|
+
builder.setSelection(selection);
|
|
510
|
+
}, { source: "input", addToHistory: true });
|
|
511
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const target = event.target instanceof Element ? event.target.closest("[data-smart-atomic]") : null;
|
|
515
|
+
const mapped = target ? this.renderer.mapping.domToNode(target) : null;
|
|
516
|
+
if (!mapped || isTextNode(mapped.node) || this.editor.schema.nodes[mapped.node.type]?.selectable !== true)
|
|
517
|
+
return;
|
|
518
|
+
const range = this.editor.positions.rangeOf(mapped.nodeId);
|
|
519
|
+
if (!range)
|
|
520
|
+
return;
|
|
521
|
+
this.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "api" });
|
|
522
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
523
|
+
};
|
|
524
|
+
this.selectionChangeListener = () => this.syncSelectionFromDom();
|
|
525
|
+
this.pasteListener = (event) => this.handlePaste(event);
|
|
526
|
+
this.copyListener = (event) => this.handleCopy(event);
|
|
527
|
+
this.cutListener = (event) => this.handleCut(event);
|
|
528
|
+
this.dragStartListener = (event) => this.handleDragStart(event);
|
|
529
|
+
this.dropListener = (event) => this.handleDrop(event);
|
|
530
|
+
this.ownerDocument = root.ownerDocument;
|
|
531
|
+
root.addEventListener("beforeinput", this.beforeInputListener);
|
|
532
|
+
root.addEventListener("keydown", this.keyDownListener);
|
|
533
|
+
root.addEventListener("compositionstart", this.compositionStartListener);
|
|
534
|
+
root.addEventListener("compositionupdate", this.compositionUpdateListener);
|
|
535
|
+
root.addEventListener("compositionend", this.compositionEndListener);
|
|
536
|
+
root.addEventListener("click", this.clickListener);
|
|
537
|
+
root.addEventListener("mousedown", this.tableMouseDownListener);
|
|
538
|
+
root.addEventListener("mouseup", this.tableMouseUpListener);
|
|
539
|
+
this.ownerDocument.addEventListener("selectionchange", this.selectionChangeListener);
|
|
540
|
+
root.addEventListener("paste", this.pasteListener);
|
|
541
|
+
root.addEventListener("copy", this.copyListener);
|
|
542
|
+
root.addEventListener("cut", this.cutListener);
|
|
543
|
+
root.addEventListener("dragstart", this.dragStartListener);
|
|
544
|
+
root.addEventListener("drop", this.dropListener);
|
|
545
|
+
renderer.render(editor.document, editor.selection);
|
|
546
|
+
editor.resolveScope({ want: "describe" });
|
|
547
|
+
}
|
|
548
|
+
get unhandledInputTypes() { return this.unhandled; }
|
|
549
|
+
cellFromNode(input) {
|
|
550
|
+
const node = input instanceof Node ? input : null;
|
|
551
|
+
const element = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
|
|
552
|
+
const cell = element?.closest('[data-smart-type="table_cell"]');
|
|
553
|
+
if (!cell)
|
|
554
|
+
return null;
|
|
555
|
+
const table = cell.closest('[data-smart-type="table"]');
|
|
556
|
+
const mappedCell = this.renderer.mapping.domToNode(cell);
|
|
557
|
+
const mappedTable = table ? this.renderer.mapping.domToNode(table) : null;
|
|
558
|
+
if (!mappedCell || isTextNode(mappedCell.node) || !mappedTable || isTextNode(mappedTable.node))
|
|
559
|
+
return null;
|
|
560
|
+
return { id: mappedCell.nodeId, tableId: mappedTable.nodeId };
|
|
561
|
+
}
|
|
562
|
+
payloadFromTransfer(transfer) {
|
|
563
|
+
const representations = Object.fromEntries(Array.from(transfer.types).filter((type) => type !== "Files").map((type) => [type, transfer.getData(type)]));
|
|
564
|
+
return {
|
|
565
|
+
html: representations["text/html"], plainText: representations["text/plain"],
|
|
566
|
+
native: representations["application/x-smart-rte+json"], types: Array.from(transfer.types), representations,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
writeTransfer(transfer, document) {
|
|
570
|
+
const representations = serializeClipboardRepresentations(document);
|
|
571
|
+
Object.entries(representations).forEach(([type, value]) => transfer.setData(type, value));
|
|
572
|
+
}
|
|
573
|
+
selectionForPoint(event) {
|
|
574
|
+
const pointDocument = this.ownerDocument;
|
|
575
|
+
const caret = pointDocument.caretPositionFromPoint?.(event.clientX, event.clientY);
|
|
576
|
+
const fallback = !caret ? pointDocument.caretRangeFromPoint?.(event.clientX, event.clientY) : null;
|
|
577
|
+
const pos = caret ? this.renderer.mapping.domToPos(caret.offsetNode, caret.offset)
|
|
578
|
+
: fallback ? this.renderer.mapping.domToPos(fallback.startContainer, fallback.startOffset) : null;
|
|
579
|
+
const target = pos || this.editor.selection.head;
|
|
580
|
+
return { type: "text", anchor: target, head: target };
|
|
581
|
+
}
|
|
582
|
+
commitClipboard(result, source) {
|
|
583
|
+
if (!result.operations.length)
|
|
584
|
+
return;
|
|
585
|
+
const preview = applyOperations(this.editor.document, result.operations);
|
|
586
|
+
const lookup = createScopeIndex().positions(preview, this.editor.schema);
|
|
587
|
+
const content = lookup.contentRangeOf(result.selectionTarget.ownerId);
|
|
588
|
+
const position = lookup.positionOf(result.selectionTarget.ownerId);
|
|
589
|
+
const base = content?.from || position?.pos || this.editor.selection.head;
|
|
590
|
+
const target = content && content.from.path.length === content.to.path.length
|
|
591
|
+
&& content.from.path.every((part, index) => part === content.to.path[index])
|
|
592
|
+
? { path: [...content.from.path], offset: Math.min(result.selectionTarget.offset, content.to.offset) }
|
|
593
|
+
: { path: [...base.path], offset: base.offset };
|
|
594
|
+
this.editor.transact((builder) => {
|
|
595
|
+
builder.operations.push(...result.operations);
|
|
596
|
+
builder.setSelection({ type: "text", anchor: target, head: target });
|
|
597
|
+
}, { source, addToHistory: true });
|
|
598
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
599
|
+
}
|
|
600
|
+
handleCopy(event) {
|
|
601
|
+
if (!event.clipboardData || collapsed(this.editor.selection))
|
|
602
|
+
return;
|
|
603
|
+
event.preventDefault();
|
|
604
|
+
this.writeTransfer(event.clipboardData, sliceClipboardSelection(this.editor.document, this.editor.selection));
|
|
605
|
+
}
|
|
606
|
+
handleCut(event) {
|
|
607
|
+
if (!event.clipboardData || collapsed(this.editor.selection))
|
|
608
|
+
return;
|
|
609
|
+
event.preventDefault();
|
|
610
|
+
this.writeTransfer(event.clipboardData, sliceClipboardSelection(this.editor.document, this.editor.selection));
|
|
611
|
+
const deletion = deleteClipboardSelection(this.editor.document, this.editor.selection, this.editor.positions);
|
|
612
|
+
this.commitClipboard({ ...deletion, definingAncestorId: null }, "cut");
|
|
613
|
+
}
|
|
614
|
+
handlePaste(event) {
|
|
615
|
+
event.preventDefault();
|
|
616
|
+
if (!event.clipboardData) {
|
|
617
|
+
this.unhandled.push("paste-without-clipboard-data");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const payload = this.payloadFromTransfer(event.clipboardData);
|
|
621
|
+
try {
|
|
622
|
+
const parsed = parseClipboardPayload(payload, { ownerDocument: this.ownerDocument, schema: this.editor.schema });
|
|
623
|
+
this.options.onClipboardDiagnostic?.(reportParsedClipboard(payload, parsed));
|
|
624
|
+
const fragment = remintClipboardFragmentIds(parsed.document, createNodeId);
|
|
625
|
+
const result = insertClipboardFragment(this.editor.document, this.editor.selection, fragment, {
|
|
626
|
+
schema: this.editor.schema, positions: this.editor.positions, idFactory: createNodeId,
|
|
627
|
+
});
|
|
628
|
+
this.commitClipboard(result, "paste");
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
this.options.onClipboardDiagnostic?.(reportRejectedClipboard(payload, error));
|
|
632
|
+
this.unhandled.push("paste-rejected");
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
handleDragStart(event) {
|
|
636
|
+
if (!event.dataTransfer || collapsed(this.editor.selection))
|
|
637
|
+
return;
|
|
638
|
+
const fragment = sliceClipboardSelection(this.editor.document, this.editor.selection);
|
|
639
|
+
this.internalDrag = { selection: this.editor.selection, fragment };
|
|
640
|
+
this.writeTransfer(event.dataTransfer, fragment);
|
|
641
|
+
event.dataTransfer.effectAllowed = "move";
|
|
642
|
+
}
|
|
643
|
+
handleDrop(event) {
|
|
644
|
+
event.preventDefault();
|
|
645
|
+
if (!event.dataTransfer) {
|
|
646
|
+
this.unhandled.push("drop-without-data-transfer");
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const targetSelection = this.selectionForPoint(event);
|
|
650
|
+
const files = Array.from(event.dataTransfer.files || []);
|
|
651
|
+
if (files.length) {
|
|
652
|
+
this.options.onFiles?.(files, targetSelection);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
if (this.internalDrag) {
|
|
656
|
+
const deletion = deleteClipboardSelection(this.editor.document, this.internalDrag.selection, this.editor.positions);
|
|
657
|
+
const preview = applyOperations(this.editor.document, deletion.operations);
|
|
658
|
+
const mappedSelection = new FoundationTransactionMap(deletion.operations).mapSelection(targetSelection);
|
|
659
|
+
const lookup = createScopeIndex().positions(preview, this.editor.schema);
|
|
660
|
+
if (this.internalDrag.selection.type === "node" && this.internalDrag.fragment.children.length === 1) {
|
|
661
|
+
const moving = this.internalDrag.fragment.children[0];
|
|
662
|
+
const targetOwner = nodeAtPath(preview, mappedSelection.head.path);
|
|
663
|
+
if (targetOwner && !isTextNode(targetOwner) && mappedSelection.head.path.length) {
|
|
664
|
+
const parentPath = mappedSelection.head.path.slice(0, -1);
|
|
665
|
+
const ownerIndex = mappedSelection.head.path[mappedSelection.head.path.length - 1];
|
|
666
|
+
const insertionOffset = ownerIndex + (mappedSelection.head.offset > 0 ? 1 : 0);
|
|
667
|
+
this.internalDrag = null;
|
|
668
|
+
this.commitClipboard({
|
|
669
|
+
operations: [...deletion.operations, { type: "insertNode", pos: { path: parentPath, offset: insertionOffset }, node: moving }],
|
|
670
|
+
selectionTarget: { ownerId: isTextNode(moving) ? targetOwner.id : moving.id, offset: 0 },
|
|
671
|
+
definingAncestorId: null,
|
|
672
|
+
}, "drop");
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const insertion = insertClipboardFragment(preview, mappedSelection, this.internalDrag.fragment, {
|
|
677
|
+
schema: this.editor.schema, positions: lookup, idFactory: createNodeId,
|
|
678
|
+
});
|
|
679
|
+
this.internalDrag = null;
|
|
680
|
+
this.commitClipboard({ ...insertion, operations: [...deletion.operations, ...insertion.operations] }, "drop");
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const parsed = parseClipboardPayload(this.payloadFromTransfer(event.dataTransfer), { ownerDocument: this.ownerDocument, schema: this.editor.schema });
|
|
684
|
+
const fragment = remintClipboardFragmentIds(parsed.document, createNodeId);
|
|
685
|
+
const result = insertClipboardFragment(this.editor.document, targetSelection, fragment, {
|
|
686
|
+
schema: this.editor.schema, positions: this.editor.positions, idFactory: createNodeId,
|
|
687
|
+
});
|
|
688
|
+
this.commitClipboard(result, "drop");
|
|
689
|
+
}
|
|
690
|
+
commit(builderFn, options = {}) {
|
|
691
|
+
this.editor.transact((builder) => builder.setSelection(builderFn(builder)), {
|
|
692
|
+
source: "input",
|
|
693
|
+
addToHistory: true,
|
|
694
|
+
...(options.compositionId ? { compositionId: options.compositionId } : {}),
|
|
695
|
+
});
|
|
696
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
697
|
+
}
|
|
698
|
+
commandContext() {
|
|
699
|
+
return { schema: this.editor.schema, positions: this.editor.positions };
|
|
700
|
+
}
|
|
701
|
+
commitStructuralResult(result, source = "input") {
|
|
702
|
+
if (!result.operations.length)
|
|
703
|
+
return;
|
|
704
|
+
const preview = applyOperations(this.editor.document, result.operations);
|
|
705
|
+
const lookup = createScopeIndex().positions(preview, this.editor.schema);
|
|
706
|
+
const content = lookup.contentRangeOf(result.selectionTarget.ownerId);
|
|
707
|
+
if (!content)
|
|
708
|
+
throw new Error(`List input selection owner "${result.selectionTarget.ownerId}" was not preserved.`);
|
|
709
|
+
const target = { path: [...content.from.path], offset: result.selectionTarget.offset };
|
|
710
|
+
this.editor.transact((builder) => {
|
|
711
|
+
builder.operations.push(...result.operations);
|
|
712
|
+
builder.setSelection({ type: "text", anchor: target, head: target });
|
|
713
|
+
}, { source, addToHistory: true });
|
|
714
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
715
|
+
}
|
|
716
|
+
/** First content-bearing child of a table cell, or the cell itself if somehow childless. */
|
|
717
|
+
firstOwnerOf(cell) {
|
|
718
|
+
const first = cell.children?.[0];
|
|
719
|
+
return first && !isTextNode(first) ? first.id : cell.id;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Tab/Shift+Tab cell-to-cell navigation (Phase 11 Tier 2 - previously
|
|
723
|
+
* "Tables own Tab navigation" was just a bare `return`, a genuinely
|
|
724
|
+
* missing feature, not a missing test). `grid.anchors` is already in
|
|
725
|
+
* reading order, so stepping ±1 through it is next/previous cell;
|
|
726
|
+
* stepping past the last cell appends a row via insertTableRowCommand and
|
|
727
|
+
* lands in its first cell, matching the reference precedent Tab already
|
|
728
|
+
* uses for a list item with no legal indent - always claim the key.
|
|
729
|
+
*/
|
|
730
|
+
handleTableTab(tableId, currentCellId, backward) {
|
|
731
|
+
const resolvedTable = this.editor.positions.positionOf(tableId);
|
|
732
|
+
const tableNode = resolvedTable?.parent.children?.[resolvedTable.pos.offset];
|
|
733
|
+
if (!tableNode || isTextNode(tableNode) || tableNode.id !== tableId)
|
|
734
|
+
return;
|
|
735
|
+
const grid = occupancyGridFor(tableNode);
|
|
736
|
+
const index = grid.anchors.findIndex((cell) => cell.cellId === currentCellId);
|
|
737
|
+
if (index === -1)
|
|
738
|
+
return;
|
|
739
|
+
if (backward) {
|
|
740
|
+
if (index === 0)
|
|
741
|
+
return;
|
|
742
|
+
const target = grid.anchors[index - 1];
|
|
743
|
+
const ownerId = this.firstOwnerOf(target.node);
|
|
744
|
+
const content = this.editor.positions.contentRangeOf(ownerId);
|
|
745
|
+
if (content) {
|
|
746
|
+
this.editor.setSelection({ type: "text", anchor: content.from, head: content.from }, { source: "keyboard" });
|
|
747
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
748
|
+
}
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
if (index < grid.anchors.length - 1) {
|
|
752
|
+
const target = grid.anchors[index + 1];
|
|
753
|
+
const ownerId = this.firstOwnerOf(target.node);
|
|
754
|
+
const content = this.editor.positions.contentRangeOf(ownerId);
|
|
755
|
+
if (content) {
|
|
756
|
+
this.editor.setSelection({ type: "text", anchor: content.from, head: content.from }, { source: "keyboard" });
|
|
757
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
758
|
+
}
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
// Past the last cell: append a row (no crossing rowspans at the bottom
|
|
762
|
+
// edge, so cellIds[0]/paragraphIds[0] are guaranteed the new row's
|
|
763
|
+
// first column-0 cell/paragraph).
|
|
764
|
+
const cellIds = Array.from({ length: grid.columns }, () => createNodeId());
|
|
765
|
+
const paragraphIds = Array.from({ length: grid.columns }, () => createNodeId());
|
|
766
|
+
const tableGridScope = this.editor.resolveScope({ want: "table-grid" });
|
|
767
|
+
if (!("kind" in tableGridScope) || tableGridScope.kind !== "table-grid")
|
|
768
|
+
return;
|
|
769
|
+
const operations = insertTableRowCommand(this.editor.document, tableGridScope, { position: "after", rowId: createNodeId(), cellIds, paragraphIds }, this.commandContext());
|
|
770
|
+
if (!operations.length)
|
|
771
|
+
return;
|
|
772
|
+
const preview = applyOperations(this.editor.document, operations);
|
|
773
|
+
const lookup = createScopeIndex().positions(preview, this.editor.schema);
|
|
774
|
+
const content = lookup.contentRangeOf(paragraphIds[0]);
|
|
775
|
+
if (!content)
|
|
776
|
+
return;
|
|
777
|
+
this.editor.transact((builder) => {
|
|
778
|
+
builder.operations.push(...operations);
|
|
779
|
+
builder.setSelection({ type: "text", anchor: content.from, head: content.from });
|
|
780
|
+
}, { source: "keyboard", addToHistory: true });
|
|
781
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Every prefix of a valid SmartPos.path addresses a real ancestor node
|
|
785
|
+
* (the same property ownerAt already relies on), so walking path prefixes
|
|
786
|
+
* from longest to shortest finds the nearest enclosing table_cell/table
|
|
787
|
+
* without any DOM lookup - the model-level equivalent of cellFromNode,
|
|
788
|
+
* usable from a plain SmartPos rather than a DOM event target.
|
|
789
|
+
*/
|
|
790
|
+
tableAncestorsAt(pos) {
|
|
791
|
+
let cellId = null;
|
|
792
|
+
for (let length = pos.path.length; length >= 0; length -= 1) {
|
|
793
|
+
const node = nodeAtPath(this.editor.document, pos.path.slice(0, length));
|
|
794
|
+
if (!node || isTextNode(node))
|
|
795
|
+
continue;
|
|
796
|
+
if (!cellId && node.type === "table_cell")
|
|
797
|
+
cellId = node.id;
|
|
798
|
+
if (node.type === "table")
|
|
799
|
+
return cellId ? { tableId: node.id, cellId } : null;
|
|
800
|
+
}
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Shift+Arrow rectangular cell-selection expansion (Phase 11 Tier 2 -
|
|
805
|
+
* previously no table-grid branch existed anywhere in arrow-key handling
|
|
806
|
+
* at all). Returns false when not applicable (anchor/head aren't both in
|
|
807
|
+
* the same table), letting the caller fall through to ordinary text-range
|
|
808
|
+
* arrow handling unchanged. Steps from the head cell's own top-left
|
|
809
|
+
* anchor corner - a reasonable v1 simplification for merged-cell edges,
|
|
810
|
+
* matching the existing renderer projection's own merge-snapping
|
|
811
|
+
* (surface/renderer.ts's syncCellSelectionProjection already snaps
|
|
812
|
+
* whatever rect the model selection implies through merged cells on
|
|
813
|
+
* render, so this does not need to duplicate that snapping here).
|
|
814
|
+
*/
|
|
815
|
+
handleTableShiftArrow(rowDelta, colDelta) {
|
|
816
|
+
const selection = this.editor.selection;
|
|
817
|
+
const anchorInfo = this.tableAncestorsAt(selection.anchor);
|
|
818
|
+
const headInfo = this.tableAncestorsAt(selection.head);
|
|
819
|
+
if (!anchorInfo || !headInfo || anchorInfo.tableId !== headInfo.tableId)
|
|
820
|
+
return false;
|
|
821
|
+
const resolvedTable = this.editor.positions.positionOf(anchorInfo.tableId);
|
|
822
|
+
const tableNode = resolvedTable?.parent.children?.[resolvedTable.pos.offset];
|
|
823
|
+
if (!tableNode || isTextNode(tableNode) || tableNode.id !== anchorInfo.tableId)
|
|
824
|
+
return false;
|
|
825
|
+
const grid = occupancyGridFor(tableNode);
|
|
826
|
+
const headCell = grid.anchors.find((cell) => cell.cellId === headInfo.cellId);
|
|
827
|
+
if (!headCell)
|
|
828
|
+
return false;
|
|
829
|
+
const newRow = Math.min(grid.rows - 1, Math.max(0, headCell.top + rowDelta));
|
|
830
|
+
const newCol = Math.min(grid.columns - 1, Math.max(0, headCell.left + colDelta));
|
|
831
|
+
const target = grid.at(newRow, newCol);
|
|
832
|
+
if (!target)
|
|
833
|
+
return false;
|
|
834
|
+
const selectionResult = cellSelectionFromIds(anchorInfo.cellId, target.cellId, this.editor.positions);
|
|
835
|
+
if (!selectionResult)
|
|
836
|
+
return false;
|
|
837
|
+
this.editor.setSelection(selectionResult, { source: "keyboard" });
|
|
838
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
replaceSelection(text) {
|
|
842
|
+
const selection = this.editor.selection;
|
|
843
|
+
this.commit((builder) => {
|
|
844
|
+
if (this.trackChanges.enabled) {
|
|
845
|
+
const suggested = this.suggestReplaceSelection(builder, selection, text);
|
|
846
|
+
if (suggested)
|
|
847
|
+
return suggested;
|
|
848
|
+
}
|
|
849
|
+
const caret = collapsed(selection) ? selection.head : queueRangeDeletion(this.editor, builder, normalizedRange(selection));
|
|
850
|
+
const marks = this.editor.storedMarks || marksAtInsertion(this.editor.document, caret, this.editor.schema);
|
|
851
|
+
if (text)
|
|
852
|
+
builder.operations.push({ type: "insertText", pos: caret, text, ...(marks.length ? { marks: [...marks] } : {}) });
|
|
853
|
+
const next = { path: [...caret.path], offset: caret.offset + text.length };
|
|
854
|
+
return { type: "text", anchor: next, head: next };
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Ambient track-changes mode (Phase 12a §2.3 follow-up, 2026-08-25):
|
|
859
|
+
* typed text becomes a real, live "insert"-kind suggestion mark instead
|
|
860
|
+
* of a plain insertText, and content it replaces is marked "delete"
|
|
861
|
+
* instead of actually removed - the same reviewable-suggestion contract
|
|
862
|
+
* as the explicit Suggest deletion/Suggest insertion toolbar actions,
|
|
863
|
+
* just applied automatically to ordinary typing. Scoped to a selection
|
|
864
|
+
* that's collapsed or fully within one inline owner; returns null for a
|
|
865
|
+
* selection crossing multiple paragraphs, so the caller falls through to
|
|
866
|
+
* the real (non-suggested) path - proposing a delete that spans several
|
|
867
|
+
* paragraphs plus an insert needs per-owner handling this pass doesn't
|
|
868
|
+
* build (see docs/PHASE_ROADMAP_8B_12B.md's Phase 12a status note).
|
|
869
|
+
*/
|
|
870
|
+
suggestReplaceSelection(builder, selection, text) {
|
|
871
|
+
const range = normalizedRange(selection);
|
|
872
|
+
if (!collapsed(selection) && !samePath(range.from.path, range.to.path))
|
|
873
|
+
return null;
|
|
874
|
+
const insertAt = collapsed(selection) ? selection.head : range.to;
|
|
875
|
+
if (!collapsed(selection))
|
|
876
|
+
builder.operations.push(suggestDeleteRangeOperation(range, { authorId: this.trackChanges.authorId }));
|
|
877
|
+
const marks = this.editor.storedMarks || marksAtInsertion(this.editor.document, insertAt, this.editor.schema);
|
|
878
|
+
if (text)
|
|
879
|
+
builder.operations.push(suggestInsertOperation(insertAt, text, { authorId: this.trackChanges.authorId }, marks));
|
|
880
|
+
const next = { path: [...insertAt.path], offset: insertAt.offset + text.length };
|
|
881
|
+
return { type: "text", anchor: next, head: next };
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* True exactly when queueRangeDeletion's own same-parent forward-sibling
|
|
885
|
+
* path can handle this range (see its cross-parent/type/order checks,
|
|
886
|
+
* which this mirrors) - i.e. the two endpoints are different children of
|
|
887
|
+
* the very same parent, with compatible owner types, in forward order.
|
|
888
|
+
* Deliberately does not attempt to be more permissive than
|
|
889
|
+
* queueRangeDeletion itself; anything this returns false for keeps
|
|
890
|
+
* falling through to structuralDeletionPlan exactly as before.
|
|
891
|
+
*/
|
|
892
|
+
canMergeAsSiblings(range) {
|
|
893
|
+
const fromParent = range.from.path.slice(0, -1);
|
|
894
|
+
const toParent = range.to.path.slice(0, -1);
|
|
895
|
+
if (!samePath(fromParent, toParent))
|
|
896
|
+
return false;
|
|
897
|
+
const fromIndex = range.from.path[range.from.path.length - 1];
|
|
898
|
+
const toIndex = range.to.path[range.to.path.length - 1];
|
|
899
|
+
if (fromIndex >= toIndex)
|
|
900
|
+
return false;
|
|
901
|
+
let fromOwner;
|
|
902
|
+
let toOwner;
|
|
903
|
+
try {
|
|
904
|
+
fromOwner = ownerAt(this.editor, range.from);
|
|
905
|
+
toOwner = ownerAt(this.editor, range.to);
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
return false;
|
|
909
|
+
}
|
|
910
|
+
return fromOwner.type === toOwner.type;
|
|
911
|
+
}
|
|
912
|
+
deleteRange(range) {
|
|
913
|
+
if (samePos(range.from, range.to))
|
|
914
|
+
return;
|
|
915
|
+
this.commit((builder) => {
|
|
916
|
+
if (isWholeDocumentRange(this.editor, range)) {
|
|
917
|
+
const caret = queueWholeDocumentDeletion(this.editor, builder);
|
|
918
|
+
return { type: "text", anchor: caret, head: caret };
|
|
919
|
+
}
|
|
920
|
+
// A node/cell selection, or a text selection genuinely crossing
|
|
921
|
+
// structural parents (different list items, table cells, etc.), is
|
|
922
|
+
// not an inline range - resolve its semantic scope and remove the
|
|
923
|
+
// selected IDs; otherwise queueRangeDeletion would either silently do
|
|
924
|
+
// nothing (node selection) or throw on a legitimate nested list.
|
|
925
|
+
//
|
|
926
|
+
// A text selection whose endpoints are merely different SIBLINGS
|
|
927
|
+
// under the same parent (the ordinary "select from partway into one
|
|
928
|
+
// paragraph through partway into the next" gesture) is NOT such a
|
|
929
|
+
// case - queueRangeDeletion already handles it correctly (trims each
|
|
930
|
+
// end, removes any fully-covered siblings between them, merges the
|
|
931
|
+
// remainder). Routing it here instead was a real bug: block-range/
|
|
932
|
+
// container-tree scope resolution treats any block the selection
|
|
933
|
+
// merely touches as fully "in scope" (correct for a property-setting
|
|
934
|
+
// command like "make these headings," wrong for deletion, where
|
|
935
|
+
// partial coverage must mean trim-and-merge, not whole-block
|
|
936
|
+
// removal) - structuralDeletionPlan would then delete both siblings
|
|
937
|
+
// in their entirety, silently destroying the unselected prefix of
|
|
938
|
+
// the first and unselected suffix of the last. canMergeAsSiblings
|
|
939
|
+
// excludes exactly this case from the structural path.
|
|
940
|
+
const structural = (this.editor.selection.type !== "text"
|
|
941
|
+
|| (!samePath(range.from.path, range.to.path) && !this.canMergeAsSiblings(range)))
|
|
942
|
+
? structuralDeletionPlan(this.editor, this.editor.selection, range)
|
|
943
|
+
: null;
|
|
944
|
+
if (structural) {
|
|
945
|
+
builder.operations.push(...structural.operations);
|
|
946
|
+
const preview = applyOperations(this.editor.document, structural.operations);
|
|
947
|
+
const lookup = createScopeIndex().positions(preview, this.editor.schema);
|
|
948
|
+
const ownerId = structural.preferredOwnerIds.find((id) => lookup.exists(id))
|
|
949
|
+
|| firstEditableOwnerId(preview, this.editor.schema);
|
|
950
|
+
const content = ownerId ? lookup.contentRangeOf(ownerId) : null;
|
|
951
|
+
const caret = content?.from || { path: [], offset: 0 };
|
|
952
|
+
return { type: "text", anchor: caret, head: caret };
|
|
953
|
+
}
|
|
954
|
+
// Ambient track-changes mode: a same-owner range (the overwhelming
|
|
955
|
+
// majority of real Backspace/Delete presses - one character or word
|
|
956
|
+
// within one paragraph) is marked "delete" instead of removed. The
|
|
957
|
+
// multi-owner case above (structural) and the cross-block merge in
|
|
958
|
+
// deleteAcrossBlock are unaffected - see suggestReplaceSelection's
|
|
959
|
+
// doc comment for why that scope reduction is deliberate.
|
|
960
|
+
if (this.trackChanges.enabled && samePath(range.from.path, range.to.path)) {
|
|
961
|
+
builder.operations.push(suggestDeleteRangeOperation(range, { authorId: this.trackChanges.authorId }));
|
|
962
|
+
return { type: "text", anchor: range.from, head: range.from };
|
|
963
|
+
}
|
|
964
|
+
const caret = queueRangeDeletion(this.editor, builder, range);
|
|
965
|
+
return { type: "text", anchor: caret, head: caret };
|
|
966
|
+
});
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Block atoms have no legal internal caret. When the browser reports a
|
|
970
|
+
* structural boundary beside one (including a node selection's head), move
|
|
971
|
+
* to the nearest editable block instead of manufacturing a position inside
|
|
972
|
+
* the atom. If the atom is at a container edge, create the empty paragraph
|
|
973
|
+
* that gives the user a real line to continue typing on.
|
|
974
|
+
*/
|
|
975
|
+
moveFromStructuralBoundary(path, offset, direction) {
|
|
976
|
+
const parent = nodeAtPath(this.editor.document, path);
|
|
977
|
+
if (!parent || isTextNode(parent) || isInlineOwnerNode(parent, this.editor.schema))
|
|
978
|
+
return false;
|
|
979
|
+
const children = parent.children || [];
|
|
980
|
+
let cursor = direction < 0 ? offset - 1 : offset;
|
|
981
|
+
while (cursor >= 0 && cursor < children.length) {
|
|
982
|
+
const child = children[cursor];
|
|
983
|
+
if (!isTextNode(child) && this.editor.schema.nodes[child.type]?.atomic === true) {
|
|
984
|
+
cursor += direction;
|
|
985
|
+
continue;
|
|
986
|
+
}
|
|
987
|
+
const target = editableOwnerPosition(child, [...path, cursor], direction, this.editor.schema);
|
|
988
|
+
if (target) {
|
|
989
|
+
const model = { type: "text", anchor: target, head: target };
|
|
990
|
+
this.editor.setSelection(model, { source: "keyboard" });
|
|
991
|
+
this.renderer.render(this.editor.document, model);
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
cursor += direction;
|
|
995
|
+
}
|
|
996
|
+
// Only containers whose schema accepts blocks may receive the fallback
|
|
997
|
+
// paragraph. This keeps the operation valid inside a document, list item,
|
|
998
|
+
// quote, or table cell while refusing to put content inside an atom.
|
|
999
|
+
const content = this.editor.schema.nodes[parent.type]?.content || "";
|
|
1000
|
+
if (parent.type !== "doc" && !/(?:^|[|(\s])block(?:[+*?]|$)/.test(content))
|
|
1001
|
+
return false;
|
|
1002
|
+
const insertionIndex = direction > 0
|
|
1003
|
+
? Math.max(0, Math.min(children.length, cursor))
|
|
1004
|
+
: Math.max(0, Math.min(children.length, cursor + 1));
|
|
1005
|
+
const paragraph = { type: "paragraph", id: createNodeId(), children: [] };
|
|
1006
|
+
const target = { path: [...path, insertionIndex], offset: 0 };
|
|
1007
|
+
this.editor.transact((builder) => {
|
|
1008
|
+
builder.operations.push({ type: "insertNode", pos: { path: [...path], offset: insertionIndex }, node: paragraph });
|
|
1009
|
+
builder.setSelection({ type: "text", anchor: target, head: target });
|
|
1010
|
+
}, { source: "keyboard", addToHistory: true });
|
|
1011
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1012
|
+
return true;
|
|
1013
|
+
}
|
|
1014
|
+
insertParagraph() {
|
|
1015
|
+
const selection = this.editor.selection;
|
|
1016
|
+
if (collapsed(selection)) {
|
|
1017
|
+
const listResult = enterInList(this.editor.document, selection.head, {
|
|
1018
|
+
itemId: createNodeId(), blockId: createNodeId(), emptyBlockId: createNodeId(), splitListId: createNodeId(),
|
|
1019
|
+
}, this.commandContext());
|
|
1020
|
+
if (listResult)
|
|
1021
|
+
return this.commitStructuralResult(listResult);
|
|
1022
|
+
const codeResult = insertCodeBlockNewline(this.editor.document, selection.head, {
|
|
1023
|
+
exitOnTrailingEmptyLine: true,
|
|
1024
|
+
paragraphId: createNodeId(),
|
|
1025
|
+
});
|
|
1026
|
+
if (codeResult)
|
|
1027
|
+
return this.commitStructuralResult(codeResult);
|
|
1028
|
+
}
|
|
1029
|
+
this.commit((builder) => {
|
|
1030
|
+
const caret = collapsed(selection) ? selection.head : queueRangeDeletion(this.editor, builder, normalizedRange(selection));
|
|
1031
|
+
const owner = ownerAt(this.editor, caret);
|
|
1032
|
+
const parentPath = caret.path.slice(0, -1);
|
|
1033
|
+
const index = caret.path[caret.path.length - 1];
|
|
1034
|
+
const text = inlineText(owner);
|
|
1035
|
+
const tail = text.slice(caret.offset);
|
|
1036
|
+
const [, tailChildren] = splitInlineChildren(owner.children || [], caret.offset);
|
|
1037
|
+
const activeMarks = marksAtInsertion(this.editor.document, caret, this.editor.schema);
|
|
1038
|
+
queueInlineDeletion(builder, caret.path, owner, caret.offset, text.length);
|
|
1039
|
+
const nextNode = {
|
|
1040
|
+
type: owner.type,
|
|
1041
|
+
id: createNodeId(),
|
|
1042
|
+
...(owner.attrs ? { attrs: structuredClone(owner.attrs) } : {}),
|
|
1043
|
+
children: tail ? tailChildren : [],
|
|
1044
|
+
};
|
|
1045
|
+
builder.operations.push({ type: "insertNode", pos: { path: parentPath, offset: index + 1 }, node: nextNode });
|
|
1046
|
+
const next = { path: [...parentPath, index + 1], offset: 0 };
|
|
1047
|
+
// An empty split still needs the marks active at the caret for the next
|
|
1048
|
+
// insertion. The transaction would otherwise clear stored marks as a
|
|
1049
|
+
// side effect of its structural operations.
|
|
1050
|
+
builder.setStoredMarks(activeMarks.length ? activeMarks : undefined);
|
|
1051
|
+
return { type: "text", anchor: next, head: next };
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
insertLineBreak() {
|
|
1055
|
+
const selection = this.editor.selection;
|
|
1056
|
+
if (collapsed(selection)) {
|
|
1057
|
+
const codeResult = insertCodeBlockNewline(this.editor.document, selection.head);
|
|
1058
|
+
if (codeResult)
|
|
1059
|
+
return this.commitStructuralResult(codeResult);
|
|
1060
|
+
}
|
|
1061
|
+
const operations = [];
|
|
1062
|
+
const builder = new TransactionBuilder(selection, this.editor.storedMarks ? [...this.editor.storedMarks] : undefined);
|
|
1063
|
+
const caret = collapsed(selection) ? selection.head : queueRangeDeletion(this.editor, builder, normalizedRange(selection));
|
|
1064
|
+
operations.push(...builder.operations);
|
|
1065
|
+
const preview = operations.length ? applyOperations(this.editor.document, operations) : this.editor.document;
|
|
1066
|
+
operations.push(...insertHardBreak(preview, caret));
|
|
1067
|
+
const next = { path: [...caret.path], offset: caret.offset + 1 };
|
|
1068
|
+
this.editor.transact((transaction) => {
|
|
1069
|
+
transaction.operations.push(...operations);
|
|
1070
|
+
transaction.setSelection({ type: "text", anchor: next, head: next });
|
|
1071
|
+
}, { source: "input", addToHistory: true });
|
|
1072
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1073
|
+
}
|
|
1074
|
+
deleteByInputType(inputType) {
|
|
1075
|
+
const selection = this.editor.selection;
|
|
1076
|
+
if (!collapsed(selection))
|
|
1077
|
+
return this.deleteRange(normalizedRange(selection));
|
|
1078
|
+
const owner = ownerAt(this.editor, selection.head);
|
|
1079
|
+
const text = inlineText(owner);
|
|
1080
|
+
const offset = selection.head.offset;
|
|
1081
|
+
if (inputType === "deleteContentBackward" || inputType === "deleteContentForward") {
|
|
1082
|
+
const structural = inputType.endsWith("Backward")
|
|
1083
|
+
? backspaceAtListItemStart(this.editor.document, selection.head, this.commandContext())
|
|
1084
|
+
: deleteAtListItemEnd(this.editor.document, selection.head, this.commandContext());
|
|
1085
|
+
if (structural)
|
|
1086
|
+
return this.commitStructuralResult(structural);
|
|
1087
|
+
const boundaries = inlineGraphemeBoundaries(owner.children || []);
|
|
1088
|
+
const target = inputType.endsWith("Backward")
|
|
1089
|
+
? [...boundaries].reverse().find((boundary) => boundary < offset)
|
|
1090
|
+
: boundaries.find((boundary) => boundary > offset);
|
|
1091
|
+
if (target !== undefined)
|
|
1092
|
+
return this.deleteRange(inputType.endsWith("Backward")
|
|
1093
|
+
? { from: { ...selection.head, offset: target }, to: selection.head }
|
|
1094
|
+
: { from: selection.head, to: { ...selection.head, offset: target } });
|
|
1095
|
+
return this.deleteAcrossBlock(inputType.endsWith("Backward") ? -1 : 1);
|
|
1096
|
+
}
|
|
1097
|
+
const backward = inputType.endsWith("Backward");
|
|
1098
|
+
const target = inputType === "deleteSoftLineBackward" ? 0 : backward ? previousWord(text, offset) : nextWord(text, offset);
|
|
1099
|
+
this.deleteRange(backward
|
|
1100
|
+
? { from: { ...selection.head, offset: target }, to: selection.head }
|
|
1101
|
+
: { from: selection.head, to: { ...selection.head, offset: target } });
|
|
1102
|
+
}
|
|
1103
|
+
deleteAcrossBlock(direction) {
|
|
1104
|
+
const position = this.editor.selection.head;
|
|
1105
|
+
const current = nodeAtPath(this.editor.document, position.path);
|
|
1106
|
+
if (!current || isTextNode(current) || !isInlineOwnerNode(current, this.editor.schema))
|
|
1107
|
+
return;
|
|
1108
|
+
// Preserve the existing atom selection affordance for a directly adjacent
|
|
1109
|
+
// block atom. A block atom is selected first and deleted on the next key,
|
|
1110
|
+
// rather than being silently consumed by a cross-container merge.
|
|
1111
|
+
if (position.path.length) {
|
|
1112
|
+
const parentPath = position.path.slice(0, -1);
|
|
1113
|
+
const index = position.path[position.path.length - 1];
|
|
1114
|
+
const parent = nodeAtPath(this.editor.document, parentPath);
|
|
1115
|
+
const neighbor = parent && !isTextNode(parent) ? parent.children?.[index + direction] : undefined;
|
|
1116
|
+
if (neighbor && !isTextNode(neighbor) && this.editor.schema.nodes[neighbor.type]?.atomic === true) {
|
|
1117
|
+
const range = this.editor.positions.rangeOf(neighbor.id);
|
|
1118
|
+
if (range) {
|
|
1119
|
+
this.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "keyboard" });
|
|
1120
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1121
|
+
}
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
const owners = editableOwners(this.editor.document, [], this.editor.schema);
|
|
1126
|
+
const currentIndex = owners.findIndex((entry) => entry.node.id === current.id);
|
|
1127
|
+
const adjacent = currentIndex >= 0 ? owners[currentIndex + direction] : undefined;
|
|
1128
|
+
if (!adjacent)
|
|
1129
|
+
return;
|
|
1130
|
+
const target = direction < 0
|
|
1131
|
+
? resolvePrecedingContentTarget(this.editor.document, current.id, this.commandContext())
|
|
1132
|
+
: null;
|
|
1133
|
+
const targetOwner = direction < 0
|
|
1134
|
+
? target && nodeAtPath(this.editor.document, this.editor.positions.contentRangeOf(target.ownerId)?.from.path || [])
|
|
1135
|
+
: adjacent.node;
|
|
1136
|
+
const targetId = direction < 0 ? target?.ownerId : adjacent.node.id;
|
|
1137
|
+
if (!targetOwner || isTextNode(targetOwner) || !targetId || targetOwner.id === current.id)
|
|
1138
|
+
return;
|
|
1139
|
+
const currentPosition = this.editor.positions.positionOf(current.id);
|
|
1140
|
+
const targetPosition = this.editor.positions.positionOf(targetId);
|
|
1141
|
+
if (!currentPosition || !targetPosition)
|
|
1142
|
+
return;
|
|
1143
|
+
// A blank line at a structural boundary is removed as a block, leaving
|
|
1144
|
+
// the caret at the nearest visible content. This is the path exercised
|
|
1145
|
+
// after Enter twice exits a depth-zero list. Do not merge an empty quote
|
|
1146
|
+
// paragraph with the outside boundary paragraph: that would re-enter the
|
|
1147
|
+
// quote's content instead of exiting its scope.
|
|
1148
|
+
if (inlineText(current) === "") {
|
|
1149
|
+
const targetRange = this.editor.positions.contentRangeOf(targetId);
|
|
1150
|
+
if (!targetRange)
|
|
1151
|
+
return;
|
|
1152
|
+
const targetSelection = {
|
|
1153
|
+
type: "text",
|
|
1154
|
+
anchor: direction < 0 ? targetRange.to : targetRange.from,
|
|
1155
|
+
head: direction < 0 ? targetRange.to : targetRange.from,
|
|
1156
|
+
};
|
|
1157
|
+
const operations = [{ type: "removeNode", pos: currentPosition.pos, node: current }];
|
|
1158
|
+
const mappedSelection = new FoundationTransactionMap(operations).mapSelection(targetSelection);
|
|
1159
|
+
this.editor.transact((builder) => {
|
|
1160
|
+
builder.operations.push(...operations);
|
|
1161
|
+
builder.setSelection(mappedSelection);
|
|
1162
|
+
}, { source: "input", addToHistory: true });
|
|
1163
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
if (targetOwner.type !== current.type)
|
|
1167
|
+
return;
|
|
1168
|
+
const targetChildren = direction < 0
|
|
1169
|
+
? [...(targetOwner.children || []), ...(current.children || []).map((node) => structuredClone(node))]
|
|
1170
|
+
: [...(current.children || []), ...(targetOwner.children || []).map((node) => structuredClone(node))];
|
|
1171
|
+
// Backward deletion keeps the preceding owner; forward deletion keeps the
|
|
1172
|
+
// current owner. Keeping the owner at the caret is the identity contract
|
|
1173
|
+
// used by ordinary cross-block deletion and prevents annotations on the
|
|
1174
|
+
// active block from being retired merely because its content was joined.
|
|
1175
|
+
const survivingOwner = direction < 0 ? targetOwner : current;
|
|
1176
|
+
const removedOwner = direction < 0 ? current : targetOwner;
|
|
1177
|
+
const survivingPosition = direction < 0 ? targetPosition : currentPosition;
|
|
1178
|
+
const removedPosition = direction < 0 ? currentPosition : targetPosition;
|
|
1179
|
+
const merged = { ...survivingOwner, children: targetChildren };
|
|
1180
|
+
const survivingContent = this.editor.positions.contentRangeOf(survivingOwner.id);
|
|
1181
|
+
if (!survivingContent)
|
|
1182
|
+
return;
|
|
1183
|
+
const selectionOffset = direction < 0 ? survivingContent.to.offset : inlineText(current).length;
|
|
1184
|
+
const targetSelection = {
|
|
1185
|
+
type: "text",
|
|
1186
|
+
anchor: direction < 0
|
|
1187
|
+
? { path: [...survivingContent.to.path], offset: selectionOffset }
|
|
1188
|
+
: { path: [...survivingContent.from.path], offset: selectionOffset },
|
|
1189
|
+
head: direction < 0
|
|
1190
|
+
? { path: [...survivingContent.to.path], offset: selectionOffset }
|
|
1191
|
+
: { path: [...survivingContent.from.path], offset: selectionOffset },
|
|
1192
|
+
};
|
|
1193
|
+
// The removed owner's content was just absorbed into survivingOwner
|
|
1194
|
+
// (targetChildren, above) - mark it so an AnnotationRange anchored
|
|
1195
|
+
// inside it (e.g. a comment) snaps to the survivor instead of silently
|
|
1196
|
+
// failing to resolve. This is the real cross-block Backspace/Delete
|
|
1197
|
+
// merge path (collapsed caret at a block boundary) - distinct from
|
|
1198
|
+
// queueRangeDeletion's mergeNode operation, which only fires for a
|
|
1199
|
+
// non-collapsed selection spanning multiple blocks. Same pattern as
|
|
1200
|
+
// mergeTableCellsCommand/mergeItems.
|
|
1201
|
+
const operations = [
|
|
1202
|
+
{ type: "replaceNode", pos: survivingPosition.pos, before: survivingOwner, after: merged },
|
|
1203
|
+
{ type: "removeNode", pos: removedPosition.pos, node: removedOwner, mergedInto: survivingOwner.id },
|
|
1204
|
+
];
|
|
1205
|
+
// Forward deletion removes the current owner before the target's old
|
|
1206
|
+
// path. Map the selection through both operations instead of retaining a
|
|
1207
|
+
// stale path (the exact failure after Enter twice exits a list in a
|
|
1208
|
+
// quote). Backward deletion uses the same mapping for symmetry.
|
|
1209
|
+
const mappedSelection = new FoundationTransactionMap(operations).mapSelection(targetSelection);
|
|
1210
|
+
this.editor.transact((builder) => {
|
|
1211
|
+
builder.operations.push(...operations);
|
|
1212
|
+
builder.setSelection(mappedSelection);
|
|
1213
|
+
}, { source: "input", addToHistory: true });
|
|
1214
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1215
|
+
}
|
|
1216
|
+
handleBeforeInput(event) {
|
|
1217
|
+
if (this.destroyed)
|
|
1218
|
+
return;
|
|
1219
|
+
const type = event.inputType;
|
|
1220
|
+
// Browsers may dispatch beforeinput for a keyboard selection mutation
|
|
1221
|
+
// before the asynchronous selectionchange event reaches the document.
|
|
1222
|
+
// Refresh the model from the native selection first, otherwise Ctrl/Cmd+A
|
|
1223
|
+
// followed immediately by Backspace/Delete is mistaken for a collapsed
|
|
1224
|
+
// caret at the old location.
|
|
1225
|
+
// A canonical node selection is already the semantic selection for the
|
|
1226
|
+
// upcoming replacement/deletion. The renderer projects it as a native
|
|
1227
|
+
// range over the node's contents, which is intentionally not a text range
|
|
1228
|
+
// to be re-imported here (doing so turns a selected quote/code node into a
|
|
1229
|
+
// paragraph selection and leaves the node behind).
|
|
1230
|
+
const preserveNodeSelection = this.editor.selection.type === "node"
|
|
1231
|
+
&& (type === "deleteContentBackward" || type === "deleteContentForward"
|
|
1232
|
+
|| type === "insertText" || type === "insertReplacementText");
|
|
1233
|
+
if (!this.composition && !preserveNodeSelection)
|
|
1234
|
+
this.syncSelectionFromDom();
|
|
1235
|
+
if (this.composition && (type === "insertCompositionText" || type === "deleteCompositionText"))
|
|
1236
|
+
return;
|
|
1237
|
+
if (this.editor.selection.type === "node" && (type === "insertText" || type === "insertReplacementText"
|
|
1238
|
+
|| type === "deleteContentBackward" || type === "deleteContentForward")) {
|
|
1239
|
+
const scope = this.editor.resolveScope({ want: "atomic-node" });
|
|
1240
|
+
if ("kind" in scope && scope.kind === "atomic-node") {
|
|
1241
|
+
event.preventDefault();
|
|
1242
|
+
const resolved = this.editor.positions.positionOf(scope.nodeId);
|
|
1243
|
+
const node = resolved?.parent.children?.[resolved.pos.offset];
|
|
1244
|
+
if (!resolved || !node || isTextNode(node))
|
|
1245
|
+
return;
|
|
1246
|
+
const group = this.editor.schema.nodes[node.type]?.group;
|
|
1247
|
+
const text = type === "insertText" || type === "insertReplacementText" ? event.data || "" : "";
|
|
1248
|
+
this.editor.transact((builder) => {
|
|
1249
|
+
if (group === "inline") {
|
|
1250
|
+
builder.operations.push({ type: "removeNode", pos: resolved.pos, node });
|
|
1251
|
+
if (text)
|
|
1252
|
+
builder.operations.push({ type: "insertText", pos: scope.range.from, text });
|
|
1253
|
+
const caret = { path: [...scope.range.from.path], offset: scope.range.from.offset + text.length };
|
|
1254
|
+
builder.setSelection({ type: "text", anchor: caret, head: caret });
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
const replacement = { type: "paragraph", id: createNodeId(), children: text ? [{ type: "text", text }] : [] };
|
|
1258
|
+
builder.operations.push({ type: "replaceNode", pos: resolved.pos, before: node, after: replacement });
|
|
1259
|
+
const caret = { path: [...resolved.pos.path, resolved.pos.offset], offset: text.length };
|
|
1260
|
+
builder.setSelection({ type: "text", anchor: caret, head: caret });
|
|
1261
|
+
}
|
|
1262
|
+
}, { source: "input", addToHistory: true });
|
|
1263
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
if (type === "insertText" || type === "insertReplacementText") {
|
|
1268
|
+
event.preventDefault();
|
|
1269
|
+
this.replaceSelection(event.data || "");
|
|
1270
|
+
}
|
|
1271
|
+
else if (type === "insertParagraph") {
|
|
1272
|
+
event.preventDefault();
|
|
1273
|
+
this.insertParagraph();
|
|
1274
|
+
}
|
|
1275
|
+
else if (type === "insertLineBreak") {
|
|
1276
|
+
event.preventDefault();
|
|
1277
|
+
this.insertLineBreak();
|
|
1278
|
+
}
|
|
1279
|
+
else if (["formatBold", "formatItalic", "formatUnderline", "formatStrikeThrough"].includes(type)) {
|
|
1280
|
+
event.preventDefault();
|
|
1281
|
+
const id = type === "formatBold" ? "bold" : type === "formatItalic" ? "italic" : type === "formatUnderline" ? "underline" : "strikethrough";
|
|
1282
|
+
const declaration = inlineToolDeclarations.find((tool) => tool.id === id);
|
|
1283
|
+
executeMarkTool(this.editor, declaration, "toggle");
|
|
1284
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1285
|
+
}
|
|
1286
|
+
else if (["deleteContentBackward", "deleteContentForward", "deleteWordBackward", "deleteWordForward", "deleteSoftLineBackward"].includes(type)) {
|
|
1287
|
+
event.preventDefault();
|
|
1288
|
+
this.deleteByInputType(type);
|
|
1289
|
+
}
|
|
1290
|
+
else if (type === "historyUndo" || type === "historyRedo") {
|
|
1291
|
+
event.preventDefault();
|
|
1292
|
+
if (type === "historyUndo")
|
|
1293
|
+
this.editor.undo();
|
|
1294
|
+
else
|
|
1295
|
+
this.editor.redo();
|
|
1296
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1297
|
+
}
|
|
1298
|
+
else if (["insertFromPaste", "insertFromDrop", "deleteByCut"].includes(type)) {
|
|
1299
|
+
// Owned by paste/drop/cut events, which carry the actual transfer payload.
|
|
1300
|
+
event.preventDefault();
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
event.preventDefault();
|
|
1304
|
+
this.unhandled.push(type || "unknown");
|
|
1305
|
+
console.warn(`[Smart RTE canonical input] cancelled unsupported inputType: ${type || "unknown"}`);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
moveCaret(direction, word = false) {
|
|
1309
|
+
const selection = this.editor.selection;
|
|
1310
|
+
const active = selection.head;
|
|
1311
|
+
const activeOwner = nodeAtPath(this.editor.document, active.path);
|
|
1312
|
+
if (activeOwner && !isTextNode(activeOwner) && !isInlineOwnerNode(activeOwner, this.editor.schema)) {
|
|
1313
|
+
if (this.moveFromStructuralBoundary(active.path, active.offset, direction))
|
|
1314
|
+
return;
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
const owner = ownerAt(this.editor, active);
|
|
1318
|
+
const text = inlineText(owner);
|
|
1319
|
+
let offset = word
|
|
1320
|
+
? direction < 0 ? previousWord(text, active.offset) : nextWord(text, active.offset)
|
|
1321
|
+
: direction < 0
|
|
1322
|
+
? [...inlineGraphemeBoundaries(owner.children || [])].reverse().find((value) => value < active.offset) ?? active.offset
|
|
1323
|
+
: inlineGraphemeBoundaries(owner.children || []).find((value) => value > active.offset) ?? active.offset;
|
|
1324
|
+
let path = active.path;
|
|
1325
|
+
if (offset === active.offset) {
|
|
1326
|
+
const parentPath = active.path.slice(0, -1);
|
|
1327
|
+
const siblingIndex = active.path.length ? active.path[active.path.length - 1] + direction : -1;
|
|
1328
|
+
const parent = active.path.length ? nodeAtPath(this.editor.document, parentPath) : undefined;
|
|
1329
|
+
const neighbor = !parent || isTextNode(parent) ? undefined : parent.children?.[siblingIndex];
|
|
1330
|
+
if (neighbor && !isTextNode(neighbor)) {
|
|
1331
|
+
// Enter a structural sibling through its nearest editable owner. The
|
|
1332
|
+
// previous implementation treated the structural node itself as an
|
|
1333
|
+
// inline owner, which made quote/table edges unreachable and left the
|
|
1334
|
+
// browser selection on an address with no caret.
|
|
1335
|
+
const target = editableOwnerPosition(neighbor, [...parentPath, siblingIndex], direction, this.editor.schema);
|
|
1336
|
+
if (target) {
|
|
1337
|
+
path = target.path;
|
|
1338
|
+
offset = target.offset;
|
|
1339
|
+
}
|
|
1340
|
+
else if (this.moveFromStructuralBoundary(parentPath, direction > 0 ? siblingIndex : siblingIndex + 1, direction)) {
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
else {
|
|
1345
|
+
// A quote or table may be separated from the current owner by a
|
|
1346
|
+
// structural ancestor. Walk logical owner order rather than only the
|
|
1347
|
+
// immediate sibling array, so arrows cross nested containers and the
|
|
1348
|
+
// editable boundary paragraphs installed by the normalizer.
|
|
1349
|
+
const owners = editableOwners(this.editor.document, [], this.editor.schema);
|
|
1350
|
+
const currentIndex = owners.findIndex((entry) => entry.path.length === active.path.length
|
|
1351
|
+
&& entry.path.every((part, index) => part === active.path[index]));
|
|
1352
|
+
const adjacent = currentIndex >= 0 ? owners[currentIndex + direction] : undefined;
|
|
1353
|
+
if (adjacent) {
|
|
1354
|
+
path = adjacent.path;
|
|
1355
|
+
offset = direction < 0 ? inlineText(adjacent.node).length : 0;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const next = { path: [...path], offset };
|
|
1360
|
+
const model = { type: "text", anchor: next, head: next };
|
|
1361
|
+
this.editor.setSelection(model, { source: "keyboard" });
|
|
1362
|
+
this.renderer.render(this.editor.document, model);
|
|
1363
|
+
}
|
|
1364
|
+
handleKeyDown(event) {
|
|
1365
|
+
// Selectionchange is delivered asynchronously in Chromium. A keyboard
|
|
1366
|
+
// navigation key can therefore arrive immediately after Ctrl/Cmd+A while
|
|
1367
|
+
// the native range is document-wide but the canonical selection still
|
|
1368
|
+
// describes the previous owner. Import the current native range before
|
|
1369
|
+
// directional handling so ArrowUp/Down never operates on stale paths.
|
|
1370
|
+
// Home/End and Enter's own modifier branches below (Ctrl/Cmd+Enter,
|
|
1371
|
+
// Shift+Enter) read `this.editor.selection.head` synchronously too, and
|
|
1372
|
+
// are exposed to the exact same race - most visibly, a real click
|
|
1373
|
+
// immediately followed by End: Home/End's handler used to trust the
|
|
1374
|
+
// still-stale selection, compute "end of the stale owner", and
|
|
1375
|
+
// explicitly commit + render *that* wrong position - actively moving
|
|
1376
|
+
// the real caret away from wherever the click had just placed it (for
|
|
1377
|
+
// example out of a code block nested in a blockquote and onto an
|
|
1378
|
+
// unrelated preceding paragraph), so a subsequent Enter then genuinely
|
|
1379
|
+
// operated on the wrong position instead of merely reading it wrong.
|
|
1380
|
+
// Plain Enter is additionally covered by the beforeinput handler's own
|
|
1381
|
+
// resync, but the modifier branches here fire before that ever runs.
|
|
1382
|
+
if (!this.composition && ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "Enter"].includes(event.key)) {
|
|
1383
|
+
this.syncSelectionFromDom();
|
|
1384
|
+
}
|
|
1385
|
+
const modifier = event.metaKey || event.ctrlKey;
|
|
1386
|
+
if (event.key === "Enter" && modifier && !event.shiftKey) {
|
|
1387
|
+
const result = exitCodeBlock(this.editor.document, this.editor.selection.head, createNodeId());
|
|
1388
|
+
if (result) {
|
|
1389
|
+
event.preventDefault();
|
|
1390
|
+
this.commitStructuralResult(result, "keyboard");
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
// WebKit does not consistently surface Shift+Enter as
|
|
1395
|
+
// beforeinput(insertLineBreak). Own the intent at keydown so every browser
|
|
1396
|
+
// produces the same atomic hard_break representation.
|
|
1397
|
+
if (event.key === "Enter" && event.shiftKey && !modifier) {
|
|
1398
|
+
event.preventDefault();
|
|
1399
|
+
this.insertLineBreak();
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const shortcut = modifier ? { b: "bold", i: "italic", u: "underline" }[event.key.toLowerCase()] : undefined;
|
|
1403
|
+
if (shortcut) {
|
|
1404
|
+
event.preventDefault();
|
|
1405
|
+
executeMarkTool(this.editor, inlineToolDeclarations.find((tool) => tool.id === shortcut), "toggle");
|
|
1406
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
if (modifier && event.key.toLowerCase() === "k") {
|
|
1410
|
+
event.preventDefault();
|
|
1411
|
+
this.root.dispatchEvent(new CustomEvent("smart-link-request", { bubbles: true }));
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
if (modifier && event.key.toLowerCase() === "z") {
|
|
1415
|
+
event.preventDefault();
|
|
1416
|
+
if (event.shiftKey)
|
|
1417
|
+
this.editor.redo();
|
|
1418
|
+
else
|
|
1419
|
+
this.editor.undo();
|
|
1420
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (modifier && event.key.toLowerCase() === "y") {
|
|
1424
|
+
event.preventDefault();
|
|
1425
|
+
this.editor.redo();
|
|
1426
|
+
this.renderer.render(this.editor.document, this.editor.selection);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
if (event.key === "Tab") {
|
|
1430
|
+
const active = this.editor.selection.head;
|
|
1431
|
+
const description = this.editor.resolveScope({ want: "describe" });
|
|
1432
|
+
const inTable = "inTable" in description && Boolean(description.inTable);
|
|
1433
|
+
// Data-driven replacement for the old hardcoded code/list/table
|
|
1434
|
+
// precedence if-chain: try each registered Tab contribution (block's
|
|
1435
|
+
// code-indent, list's indent/outdent - priority 20 before 10, see
|
|
1436
|
+
// block/plugin.ts and list/plugin.ts) in order, actually resolving its
|
|
1437
|
+
// declared scope and running its real command, and only counting it as
|
|
1438
|
+
// a match if that produces real operations. Nothing is declared for
|
|
1439
|
+
// table-grid, so a table caret always falls through unmatched, same as
|
|
1440
|
+
// before. `inTable` short-circuits list contributions specifically:
|
|
1441
|
+
// a list nested inside a table cell still yields Tab to the table,
|
|
1442
|
+
// exactly as the old chain did.
|
|
1443
|
+
const resolved = resolveShortcut(this.editor.keyboardShortcuts, { key: "Tab", shiftKey: event.shiftKey }, (shortcut) => {
|
|
1444
|
+
const command = this.editor.commands.get(shortcut.commandId);
|
|
1445
|
+
if (!command)
|
|
1446
|
+
return null;
|
|
1447
|
+
if (inTable && (shortcut.commandId === "list.indent" || shortcut.commandId === "list.outdent"))
|
|
1448
|
+
return null;
|
|
1449
|
+
const wanted = shortcut.scopeKinds[0];
|
|
1450
|
+
if (wanted === "mixed" || wanted === "empty")
|
|
1451
|
+
return null;
|
|
1452
|
+
const scope = this.editor.resolveScope({ want: wanted });
|
|
1453
|
+
if (!("kind" in scope) || scope.kind !== wanted)
|
|
1454
|
+
return null;
|
|
1455
|
+
const params = shortcut.commandId === "list.indent" ? { nestedListIds: [createNodeId()] }
|
|
1456
|
+
: shortcut.commandId === "list.outdent" ? { splitListIds: [createNodeId()] }
|
|
1457
|
+
: shortcut.params;
|
|
1458
|
+
return command.run(this.editor.document, scope, params, this.commandContext());
|
|
1459
|
+
});
|
|
1460
|
+
if (resolved) {
|
|
1461
|
+
event.preventDefault();
|
|
1462
|
+
const { shortcut, operations } = resolved;
|
|
1463
|
+
const selectionTarget = shortcut.commandId === "block.code.indentTab"
|
|
1464
|
+
? { ownerId: ownerAt(this.editor, active).id, offset: active.offset + 1 }
|
|
1465
|
+
: { ownerId: ownerAt(this.editor, active).id, offset: active.offset };
|
|
1466
|
+
const intent = shortcut.commandId === "list.outdent" ? "outdent" : "indent";
|
|
1467
|
+
this.commitStructuralResult({ operations: [...operations], selectionTarget, intent }, "keyboard");
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
// Tab inside a list item is always ours to handle, even when there is
|
|
1471
|
+
// no legal indent/outdent (e.g. the first item, or already at the max
|
|
1472
|
+
// legal depth) — preventDefault unconditionally here, or the browser
|
|
1473
|
+
// falls through to its native Tab-to-next-focusable-element behavior
|
|
1474
|
+
// and keyboard focus silently leaves the editor entirely.
|
|
1475
|
+
if (!inTable && listItemAt(this.editor.document, active)) {
|
|
1476
|
+
event.preventDefault();
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
if (inTable && description.inTable) {
|
|
1480
|
+
event.preventDefault();
|
|
1481
|
+
this.handleTableTab(description.inTable.tableId, description.inTable.cellId, event.shiftKey);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
// Space is ordinary text while the caret is inside a checklist item. When
|
|
1487
|
+
// keyboard focus is actually on the projected checkbox button, preserve
|
|
1488
|
+
// native button semantics by routing Space to the same click path. The
|
|
1489
|
+
// focus check is essential: a global checklist-item shortcut makes a
|
|
1490
|
+
// sentence such as "Buy milk" toggle the item and drops the separator.
|
|
1491
|
+
if ((event.key === " " || event.key === "Spacebar")
|
|
1492
|
+
&& this.ownerDocument.activeElement instanceof Element) {
|
|
1493
|
+
const checkControl = this.ownerDocument.activeElement.closest(`[${SMART_UI_ATTRIBUTE}="check-control"]`);
|
|
1494
|
+
if (checkControl) {
|
|
1495
|
+
event.preventDefault();
|
|
1496
|
+
checkControl.click();
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
|
|
1501
|
+
const direction = event.key === "ArrowLeft" ? -1 : 1;
|
|
1502
|
+
if (event.shiftKey && this.handleTableShiftArrow(0, direction)) {
|
|
1503
|
+
event.preventDefault();
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
event.preventDefault();
|
|
1507
|
+
// Plain arrows collapse a range to its normalized endpoint. Reading
|
|
1508
|
+
// `selection.head` here makes the result depend on drag direction,
|
|
1509
|
+
// violating the anchor/head contract and reversing Left/Right for a
|
|
1510
|
+
// bottom-to-top selection.
|
|
1511
|
+
if (!event.shiftKey && this.editor.selection.type === "text" && !collapsed(this.editor.selection)) {
|
|
1512
|
+
const range = normalizedRange(this.editor.selection);
|
|
1513
|
+
const target = direction < 0 ? range.from : range.to;
|
|
1514
|
+
const model = { type: "text", anchor: target, head: target };
|
|
1515
|
+
this.editor.setSelection(model, { source: "keyboard" });
|
|
1516
|
+
this.renderer.render(this.editor.document, model);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
this.moveCaret(direction, modifier || event.altKey);
|
|
1520
|
+
}
|
|
1521
|
+
else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
|
1522
|
+
if (event.shiftKey && this.handleTableShiftArrow(event.key === "ArrowUp" ? -1 : 1, 0)) {
|
|
1523
|
+
event.preventDefault();
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
if (this.editor.selection.type === "node") {
|
|
1527
|
+
// Native vertical movement has no useful target while a block atom is
|
|
1528
|
+
// selected. Treat it as movement to the preceding/following editable
|
|
1529
|
+
// line, matching left/right atom-boundary navigation.
|
|
1530
|
+
event.preventDefault();
|
|
1531
|
+
this.moveCaret(event.key === "ArrowUp" ? -1 : 1);
|
|
1532
|
+
}
|
|
1533
|
+
else if (!event.shiftKey && this.editor.selection.type === "text" && !collapsed(this.editor.selection)
|
|
1534
|
+
&& isWholeDocumentRange(this.editor, normalizedRange(this.editor.selection))) {
|
|
1535
|
+
// Native vertical-arrow collapse of a whole-document (Ctrl/Cmd+A)
|
|
1536
|
+
// selection is unreliable once the document contains structural
|
|
1537
|
+
// content (lists, tables): the browser's own notion of where
|
|
1538
|
+
// selection "focus" lands after select-all does not consistently
|
|
1539
|
+
// resolve to the true first/last editable position once nested
|
|
1540
|
+
// containers are involved, so ArrowDown can land mid-document
|
|
1541
|
+
// instead of at the real end (and symmetrically for ArrowUp/start).
|
|
1542
|
+
// Ordinary, non-whole-document vertical movement is deliberately
|
|
1543
|
+
// left native above (real line-based caret movement isn't something
|
|
1544
|
+
// the model layer can replicate) - only this specific, well-defined
|
|
1545
|
+
// state is collapsed deterministically here, reusing the same
|
|
1546
|
+
// document-order editable-owner walk moveCaret already relies on for
|
|
1547
|
+
// cross-container navigation.
|
|
1548
|
+
event.preventDefault();
|
|
1549
|
+
const owners = editableOwners(this.editor.document, [], this.editor.schema);
|
|
1550
|
+
const target = event.key === "ArrowUp" ? owners[0] : owners[owners.length - 1];
|
|
1551
|
+
if (target) {
|
|
1552
|
+
const pos = { path: [...target.path], offset: event.key === "ArrowUp" ? 0 : inlineText(target.node).length };
|
|
1553
|
+
const model = { type: "text", anchor: pos, head: pos };
|
|
1554
|
+
this.editor.setSelection(model, { source: "keyboard" });
|
|
1555
|
+
this.renderer.render(this.editor.document, model);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
else if (event.key === "Home" || event.key === "End") {
|
|
1560
|
+
event.preventDefault();
|
|
1561
|
+
const active = this.editor.selection.head;
|
|
1562
|
+
const owner = ownerAt(this.editor, active);
|
|
1563
|
+
const next = { path: [...active.path], offset: event.key === "Home" ? 0 : inlineText(owner).length };
|
|
1564
|
+
// Shift+Home/End is the standard "select to line start/end" gesture -
|
|
1565
|
+
// this previously collapsed to `next` unconditionally, discarding
|
|
1566
|
+
// the existing anchor regardless of the Shift key, so Shift+Home/
|
|
1567
|
+
// End could never actually select anything; it only ever moved the
|
|
1568
|
+
// caret. Preserve the current anchor when extending.
|
|
1569
|
+
const anchor = event.shiftKey ? this.editor.selection.anchor : next;
|
|
1570
|
+
const model = { type: "text", anchor, head: next };
|
|
1571
|
+
this.editor.setSelection(model, { source: "keyboard" });
|
|
1572
|
+
this.renderer.render(this.editor.document, model);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
handleCompositionStart(_event) {
|
|
1576
|
+
const selection = this.editor.selection;
|
|
1577
|
+
const owner = ownerAt(this.editor, selection.head);
|
|
1578
|
+
const element = this.renderer.mapping.nodeToDom(owner.id);
|
|
1579
|
+
const observer = element && typeof MutationObserver !== "undefined" ? new MutationObserver(() => undefined) : null;
|
|
1580
|
+
observer?.observe(element, { subtree: true, childList: true, characterData: true });
|
|
1581
|
+
this.composition = {
|
|
1582
|
+
id: createNodeId(), ownerId: owner.id, ownerPath: [...selection.head.path], beforeText: inlineText(owner), selectionBefore: selection, observer,
|
|
1583
|
+
beforeTokens: tokenizeCompositionOwner(owner),
|
|
1584
|
+
activeMarks: [...(this.editor.storedMarks || marksAtInsertion(this.editor.document, selection.head, this.editor.schema))],
|
|
1585
|
+
};
|
|
1586
|
+
this.renderer.beginComposition(owner.id);
|
|
1587
|
+
}
|
|
1588
|
+
/** DOM remains authoritative; updates are intentionally observed, not rendered. */
|
|
1589
|
+
handleCompositionUpdate(_event) { }
|
|
1590
|
+
handleCompositionEnd(_event) {
|
|
1591
|
+
const state = this.composition;
|
|
1592
|
+
if (!state)
|
|
1593
|
+
return;
|
|
1594
|
+
state.observer?.disconnect();
|
|
1595
|
+
const element = this.renderer.mapping.nodeToDom(state.ownerId);
|
|
1596
|
+
const afterTokens = [];
|
|
1597
|
+
if (element) {
|
|
1598
|
+
[...element.childNodes].forEach((direct) => {
|
|
1599
|
+
const directElement = direct instanceof HTMLElement ? direct : direct.parentElement;
|
|
1600
|
+
if (directElement?.closest("[data-smart-ui]"))
|
|
1601
|
+
return;
|
|
1602
|
+
const atomic = directElement?.closest("[data-smart-atomic]");
|
|
1603
|
+
if (atomic) {
|
|
1604
|
+
const mapped = this.renderer.mapping.domToNode(atomic);
|
|
1605
|
+
if (mapped && !isTextNode(mapped.node))
|
|
1606
|
+
afterTokens.push({ kind: "atom", nodeId: mapped.node.id, atomType: mapped.node.type });
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
const text = direct.textContent || "";
|
|
1610
|
+
if (!text)
|
|
1611
|
+
return;
|
|
1612
|
+
const marks = [];
|
|
1613
|
+
const walker = element.ownerDocument.createTreeWalker(direct, NodeFilter.SHOW_TEXT);
|
|
1614
|
+
const firstText = direct.nodeType === direct.TEXT_NODE ? direct : walker.nextNode();
|
|
1615
|
+
let cursor = firstText?.parentElement || null;
|
|
1616
|
+
while (cursor && cursor !== element) {
|
|
1617
|
+
const type = cursor.getAttribute("data-smart-mark");
|
|
1618
|
+
if (type) {
|
|
1619
|
+
const raw = cursor.getAttribute("data-smart-mark-attrs");
|
|
1620
|
+
marks.push({ type, ...(raw ? { attrs: JSON.parse(raw) } : {}) });
|
|
1621
|
+
}
|
|
1622
|
+
cursor = cursor.parentElement;
|
|
1623
|
+
}
|
|
1624
|
+
afterTokens.push({ kind: "text", text, marks: canonicalMarkOrder(marks) });
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
const beforeUnits = tokenUnits(state.beforeTokens);
|
|
1628
|
+
const afterUnits = tokenUnits(afterTokens);
|
|
1629
|
+
let prefix = 0;
|
|
1630
|
+
while (prefix < beforeUnits.length && prefix < afterUnits.length
|
|
1631
|
+
&& beforeUnits[prefix].char === afterUnits[prefix].char && beforeUnits[prefix].key === afterUnits[prefix].key)
|
|
1632
|
+
prefix += 1;
|
|
1633
|
+
let suffix = 0;
|
|
1634
|
+
while (suffix < beforeUnits.length - prefix && suffix < afterUnits.length - prefix
|
|
1635
|
+
&& beforeUnits[beforeUnits.length - 1 - suffix].char === afterUnits[afterUnits.length - 1 - suffix].char
|
|
1636
|
+
&& beforeUnits[beforeUnits.length - 1 - suffix].key === afterUnits[afterUnits.length - 1 - suffix].key)
|
|
1637
|
+
suffix += 1;
|
|
1638
|
+
const removed = beforeUnits.slice(prefix, beforeUnits.length - suffix);
|
|
1639
|
+
const inserted = afterUnits.slice(prefix, afterUnits.length - suffix);
|
|
1640
|
+
const domSelection = this.ownerDocument.getSelection();
|
|
1641
|
+
const domCaret = domSelection?.focusNode ? this.renderer.mapping.domToPos(domSelection.focusNode, domSelection.focusOffset) : null;
|
|
1642
|
+
this.renderer.endComposition();
|
|
1643
|
+
this.composition = null;
|
|
1644
|
+
// Atoms are opaque composition boundaries. If a browser mutation crosses,
|
|
1645
|
+
// reject the DOM mutation and restore the unchanged canonical owner.
|
|
1646
|
+
if (removed.some((unit) => unit.kind === "atom") || inserted.some((unit) => unit.kind === "atom")) {
|
|
1647
|
+
return this.renderer.render(this.editor.document, this.editor.selection);
|
|
1648
|
+
}
|
|
1649
|
+
if (!removed.length && !inserted.length)
|
|
1650
|
+
return this.renderer.render(this.editor.document, this.editor.selection);
|
|
1651
|
+
this.commit((builder) => {
|
|
1652
|
+
if (removed.length)
|
|
1653
|
+
queueInlineDeletion(builder, state.ownerPath, ownerAt(this.editor, { path: state.ownerPath, offset: prefix }), prefix, prefix + removed.length);
|
|
1654
|
+
let insertedOffset = 0;
|
|
1655
|
+
while (insertedOffset < inserted.length) {
|
|
1656
|
+
const key = inserted[insertedOffset].key;
|
|
1657
|
+
let end = insertedOffset + 1;
|
|
1658
|
+
while (end < inserted.length && inserted[end].key === key)
|
|
1659
|
+
end += 1;
|
|
1660
|
+
const text = inserted.slice(insertedOffset, end).map((unit) => unit.char).join("");
|
|
1661
|
+
const marks = inserted[insertedOffset].marks.length ? inserted[insertedOffset].marks : state.activeMarks;
|
|
1662
|
+
builder.operations.push({ type: "insertText", pos: { path: state.ownerPath, offset: prefix + insertedOffset }, text, ...(marks.length ? { marks } : {}) });
|
|
1663
|
+
insertedOffset = end;
|
|
1664
|
+
}
|
|
1665
|
+
const caret = domCaret || { path: state.ownerPath, offset: prefix + inserted.length };
|
|
1666
|
+
return { type: "text", anchor: caret, head: caret };
|
|
1667
|
+
}, { compositionId: state.id });
|
|
1668
|
+
}
|
|
1669
|
+
setTrackChanges(enabled, authorId) {
|
|
1670
|
+
this.trackChanges = { enabled, authorId: authorId ?? this.trackChanges.authorId };
|
|
1671
|
+
}
|
|
1672
|
+
syncSelectionFromDom() {
|
|
1673
|
+
if (this.destroyed || this.composition)
|
|
1674
|
+
return;
|
|
1675
|
+
const native = this.ownerDocument.getSelection();
|
|
1676
|
+
if (!native?.anchorNode || !native.focusNode || !this.root.contains(native.anchorNode) || !this.root.contains(native.focusNode))
|
|
1677
|
+
return;
|
|
1678
|
+
const anchor = this.renderer.mapping.domToPos(native.anchorNode, native.anchorOffset);
|
|
1679
|
+
const head = this.renderer.mapping.domToPos(native.focusNode, native.focusOffset);
|
|
1680
|
+
if (!anchor || !head)
|
|
1681
|
+
return;
|
|
1682
|
+
// A selectable atom is projected as a native range spanning its parent
|
|
1683
|
+
// boundary. The browser emits `selectionchange` after that projection,
|
|
1684
|
+
// and importing the range as a text selection would immediately discard
|
|
1685
|
+
// the semantic node selection that the atom click established. Preserve
|
|
1686
|
+
// it only when the native endpoints are exactly the node range; a
|
|
1687
|
+
// genuinely different native range must still replace the node selection.
|
|
1688
|
+
const current = this.editor.selection;
|
|
1689
|
+
if (current.type === "node") {
|
|
1690
|
+
const currentRange = normalizedRange(current);
|
|
1691
|
+
if (samePos(anchor, currentRange.from) && samePos(head, currentRange.to))
|
|
1692
|
+
return;
|
|
1693
|
+
if (samePos(anchor, currentRange.to) && samePos(head, currentRange.from))
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
// A cell selection is projected as a native range spanning the selected
|
|
1697
|
+
// cell boundaries. The browser emits `selectionchange` for that
|
|
1698
|
+
// projection; importing the endpoints as a text selection would lose the
|
|
1699
|
+
// semantic cell selection (and, after a merge, make the result depend on
|
|
1700
|
+
// which render frame happened to win). Preserve it while the native range
|
|
1701
|
+
// still represents the same model endpoints. A genuinely different click
|
|
1702
|
+
// or drag maps to a new selection below.
|
|
1703
|
+
if (current.type === "cell") {
|
|
1704
|
+
if (samePos(anchor, current.anchor) && samePos(head, current.head))
|
|
1705
|
+
return;
|
|
1706
|
+
if (samePos(anchor, current.head) && samePos(head, current.anchor))
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
// A native selection may collapse on the editable root (or another
|
|
1710
|
+
// structural container) instead of an inline owner. Keep that DOM detail
|
|
1711
|
+
// out of canonical state: root offset `children.length`, in particular,
|
|
1712
|
+
// is the browser's representation of "below the final blockquote" and
|
|
1713
|
+
// would otherwise leave ownerAt() with an unusable document path.
|
|
1714
|
+
const collapsedNative = native.isCollapsed;
|
|
1715
|
+
const normalizedAnchor = editablePositionForStructuralBoundary(this.editor, anchor, collapsedNative ? (anchor.offset === 0 ? 1 : -1) : 1) || anchor;
|
|
1716
|
+
const normalizedHead = editablePositionForStructuralBoundary(this.editor, head, collapsedNative ? (head.offset === 0 ? 1 : -1) : -1) || head;
|
|
1717
|
+
let model = { type: "text", anchor: normalizedAnchor, head: normalizedHead };
|
|
1718
|
+
const anchorCell = this.cellFromNode(native.anchorNode);
|
|
1719
|
+
const headCell = this.cellFromNode(native.focusNode);
|
|
1720
|
+
if (anchorCell && headCell && anchorCell.tableId === headCell.tableId && anchorCell.id !== headCell.id) {
|
|
1721
|
+
const cellSelection = cellSelectionFromIds(anchorCell.id, headCell.id, this.editor.positions);
|
|
1722
|
+
if (cellSelection)
|
|
1723
|
+
model = cellSelection;
|
|
1724
|
+
}
|
|
1725
|
+
const description = this.editor.resolveScope({ want: "describe" }, model);
|
|
1726
|
+
if (model.type !== "cell" && "spansIsolatingBoundary" in description && description.spansIsolatingBoundary) {
|
|
1727
|
+
const isolateId = description.inTable?.tableId || description.isolatingAncestorId;
|
|
1728
|
+
const range = isolateId ? this.editor.positions.rangeOf(isolateId) : null;
|
|
1729
|
+
if (range)
|
|
1730
|
+
model = { type: "node", anchor: range.from, head: range.to };
|
|
1731
|
+
}
|
|
1732
|
+
if (current.type === model.type && samePos(current.anchor, model.anchor) && samePos(current.head, model.head))
|
|
1733
|
+
return;
|
|
1734
|
+
this.editor.setSelection(model, { source: "api" });
|
|
1735
|
+
// Re-project a normalized structural DOM point immediately. Without this
|
|
1736
|
+
// call the model would know the right owner while the browser caret stayed
|
|
1737
|
+
// on the root boundary, making the first subsequent input appear inert.
|
|
1738
|
+
this.renderer.render(this.editor.document, model);
|
|
1739
|
+
}
|
|
1740
|
+
destroy() {
|
|
1741
|
+
if (this.destroyed)
|
|
1742
|
+
return;
|
|
1743
|
+
this.destroyed = true;
|
|
1744
|
+
this.composition?.observer?.disconnect();
|
|
1745
|
+
this.root.removeEventListener("beforeinput", this.beforeInputListener);
|
|
1746
|
+
this.root.removeEventListener("keydown", this.keyDownListener);
|
|
1747
|
+
this.root.removeEventListener("compositionstart", this.compositionStartListener);
|
|
1748
|
+
this.root.removeEventListener("compositionupdate", this.compositionUpdateListener);
|
|
1749
|
+
this.root.removeEventListener("compositionend", this.compositionEndListener);
|
|
1750
|
+
this.root.removeEventListener("click", this.clickListener);
|
|
1751
|
+
this.root.removeEventListener("mousedown", this.tableMouseDownListener);
|
|
1752
|
+
this.root.removeEventListener("mouseup", this.tableMouseUpListener);
|
|
1753
|
+
this.ownerDocument.removeEventListener("selectionchange", this.selectionChangeListener);
|
|
1754
|
+
this.root.removeEventListener("paste", this.pasteListener);
|
|
1755
|
+
this.root.removeEventListener("copy", this.copyListener);
|
|
1756
|
+
this.root.removeEventListener("cut", this.cutListener);
|
|
1757
|
+
this.root.removeEventListener("dragstart", this.dragStartListener);
|
|
1758
|
+
this.root.removeEventListener("drop", this.dropListener);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
export const createInputPipeline = (editor, renderer, root, options = {}) => new FoundationInputPipeline(editor, renderer, root, options);
|