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,974 @@
|
|
|
1
|
+
import katex from "katex";
|
|
2
|
+
// Registers \ce{...} for chemistry-equation notation (e.g. the formula
|
|
3
|
+
// library's "2 H2 + O2 -> 2 H2O" entry) - a side-effect import, required
|
|
4
|
+
// once per katex module instance for \ce to parse instead of throwing.
|
|
5
|
+
import "katex/contrib/mhchem";
|
|
6
|
+
import { isTextNode } from "../identity.js";
|
|
7
|
+
import { FoundationModelDomMapping, SMART_EMPTY_LINE_ATTRIBUTE, SMART_NODE_ID_ATTRIBUTE, SMART_PROJECTION_ATTRIBUTE, SMART_UI_ATTRIBUTE, } from "../modelDom.js";
|
|
8
|
+
import { renderMarkedText, stableValue } from "../marks/index.js";
|
|
9
|
+
import { occupancyGridFor, snapTableCellRect } from "../table/index.js";
|
|
10
|
+
import { sanitizeAtomSource } from "../atom/security.js";
|
|
11
|
+
/**
|
|
12
|
+
* Security-hardened per the retired legacy KaTeX config this replaces:
|
|
13
|
+
* trust:false blocks commands like \includegraphics/\href with adverse
|
|
14
|
+
* side effects; strict:"error" rejects non-standard LaTeX outright rather
|
|
15
|
+
* than silently tolerating it. Neither is relaxed here. `output` is left
|
|
16
|
+
* at KaTeX's own default (`htmlAndMathml`) deliberately - that default
|
|
17
|
+
* already emits MathML alongside the visual HTML, which is what makes
|
|
18
|
+
* this accessible to screen readers beyond the aria-label below.
|
|
19
|
+
*/
|
|
20
|
+
const KATEX_OPTIONS = { trust: false, strict: "error" };
|
|
21
|
+
/** Genuinely invalid/incomplete LaTeX (a real possibility mid-typing) must not crash the whole document render. */
|
|
22
|
+
const renderFormulaInto = (element, source) => {
|
|
23
|
+
try {
|
|
24
|
+
katex.render(source, element, KATEX_OPTIONS);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
element.textContent = source;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const atomTypes = new Set(["image", "block_image", "formula", "block_formula", "video", "audio", "divider"]);
|
|
31
|
+
/**
|
|
32
|
+
* atom/lifecycle.ts writes a specific upload-failure reason to
|
|
33
|
+
* attrs.error (the host MediaProvider's own message, or a generic
|
|
34
|
+
* fallback if none) - previously written but never read by this
|
|
35
|
+
* renderer (docs/bugs/atom-upload-error-reason-not-rendered.md), so
|
|
36
|
+
* every failed upload showed only this function's own generic fallback
|
|
37
|
+
* regardless of what the model actually recorded.
|
|
38
|
+
*/
|
|
39
|
+
const atomErrorTitle = (node, fallback) => typeof node.attrs?.error === "string" && node.attrs.error ? node.attrs.error : fallback;
|
|
40
|
+
const emptyLineOwnerTypes = new Set(["paragraph", "heading", "code_block"]);
|
|
41
|
+
const tagForNode = (node) => {
|
|
42
|
+
if (node.type === "paragraph")
|
|
43
|
+
return "p";
|
|
44
|
+
if (node.type === "heading")
|
|
45
|
+
return `h${String(node.attrs?.level || 1)}`;
|
|
46
|
+
if (node.type === "blockquote")
|
|
47
|
+
return "blockquote";
|
|
48
|
+
if (node.type === "code_block")
|
|
49
|
+
return "pre";
|
|
50
|
+
if (node.type === "list") {
|
|
51
|
+
const marker = String(node.attrs?.style || node.attrs?.preset || "");
|
|
52
|
+
return /^(?:decimal|lower-|upper-|ordered)/.test(marker) || node.attrs?.start !== undefined ? "ol" : "ul";
|
|
53
|
+
}
|
|
54
|
+
if (node.type === "list_item")
|
|
55
|
+
return "li";
|
|
56
|
+
if (node.type === "hard_break")
|
|
57
|
+
return "br";
|
|
58
|
+
if (node.type === "table")
|
|
59
|
+
return "table";
|
|
60
|
+
if (node.type === "table_row")
|
|
61
|
+
return "tr";
|
|
62
|
+
if (node.type === "table_cell")
|
|
63
|
+
return node.attrs?.header === true ? "th" : "td";
|
|
64
|
+
if (node.type === "image" || node.type === "block_image")
|
|
65
|
+
return "img";
|
|
66
|
+
if (node.type === "formula")
|
|
67
|
+
return "span";
|
|
68
|
+
if (node.type === "block_formula")
|
|
69
|
+
return "div";
|
|
70
|
+
if (node.type === "video" || node.type === "audio")
|
|
71
|
+
return node.type;
|
|
72
|
+
if (node.type === "divider")
|
|
73
|
+
return "hr";
|
|
74
|
+
return "div";
|
|
75
|
+
};
|
|
76
|
+
export class FoundationSubtreeRenderer {
|
|
77
|
+
constructor(root, options = {}) {
|
|
78
|
+
this.root = root;
|
|
79
|
+
this.options = options;
|
|
80
|
+
this.mapping = new FoundationModelDomMapping();
|
|
81
|
+
this.current = null;
|
|
82
|
+
this.modelById = new Map();
|
|
83
|
+
this.compositionOwner = null;
|
|
84
|
+
this.writes = 0;
|
|
85
|
+
this.compositionWrites = 0;
|
|
86
|
+
this.liveRegion = null;
|
|
87
|
+
root.contentEditable = "true";
|
|
88
|
+
root.tabIndex = -1;
|
|
89
|
+
root.setAttribute("data-smart-canonical-surface", "true");
|
|
90
|
+
}
|
|
91
|
+
get composingNodeId() { return this.compositionOwner; }
|
|
92
|
+
get domWriteCount() { return this.writes; }
|
|
93
|
+
get composingDomWriteCount() { return this.compositionWrites; }
|
|
94
|
+
recordWrite(ownerId) {
|
|
95
|
+
this.writes += 1;
|
|
96
|
+
if (this.compositionOwner && ownerId === this.compositionOwner)
|
|
97
|
+
this.compositionWrites += 1;
|
|
98
|
+
}
|
|
99
|
+
setAttribute(element, name, value, ownerId) {
|
|
100
|
+
if (element.getAttribute(name) === value)
|
|
101
|
+
return;
|
|
102
|
+
element.setAttribute(name, value);
|
|
103
|
+
this.recordWrite(ownerId);
|
|
104
|
+
}
|
|
105
|
+
removeAttribute(element, name, ownerId) {
|
|
106
|
+
if (!element.hasAttribute(name))
|
|
107
|
+
return;
|
|
108
|
+
element.removeAttribute(name);
|
|
109
|
+
this.recordWrite(ownerId);
|
|
110
|
+
}
|
|
111
|
+
syncNodeAttributes(element, node, listDepth = 0) {
|
|
112
|
+
if (node.attrs?.align)
|
|
113
|
+
element.style.textAlign = String(node.attrs.align);
|
|
114
|
+
else if (element.style.textAlign)
|
|
115
|
+
element.style.removeProperty("text-align");
|
|
116
|
+
if (node.attrs?.indentLevel)
|
|
117
|
+
element.style.marginInlineStart = `${Number(node.attrs.indentLevel) * 2}em`;
|
|
118
|
+
else if (element.style.marginInlineStart)
|
|
119
|
+
element.style.removeProperty("margin-inline-start");
|
|
120
|
+
if (node.type === "code_block") {
|
|
121
|
+
const language = typeof node.attrs?.language === "string" && node.attrs.language.trim()
|
|
122
|
+
? node.attrs.language.trim()
|
|
123
|
+
: null;
|
|
124
|
+
if (language)
|
|
125
|
+
this.setAttribute(element, "data-smart-language", language, node.id);
|
|
126
|
+
else
|
|
127
|
+
this.removeAttribute(element, "data-smart-language", node.id);
|
|
128
|
+
this.setAttribute(element, "aria-label", language ? `Code block, ${language}` : "Code block", node.id);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
this.removeAttribute(element, "data-smart-language", node.id);
|
|
132
|
+
}
|
|
133
|
+
if (node.type === "list") {
|
|
134
|
+
const preset = typeof node.attrs?.preset === "string" ? node.attrs.preset : null;
|
|
135
|
+
const style = typeof node.attrs?.style === "string" ? node.attrs.style : null;
|
|
136
|
+
if (preset) {
|
|
137
|
+
this.setAttribute(element, "data-smart-list-preset", preset, node.id);
|
|
138
|
+
this.setAttribute(element, "data-srte-list-preset", preset, node.id);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
this.removeAttribute(element, "data-smart-list-preset", node.id);
|
|
142
|
+
this.removeAttribute(element, "data-srte-list-preset", node.id);
|
|
143
|
+
}
|
|
144
|
+
if (style) {
|
|
145
|
+
this.setAttribute(element, "data-smart-list-style", style, node.id);
|
|
146
|
+
this.setAttribute(element, "data-srte-list-style", style, node.id);
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
this.removeAttribute(element, "data-smart-list-style", node.id);
|
|
150
|
+
this.removeAttribute(element, "data-srte-list-style", node.id);
|
|
151
|
+
}
|
|
152
|
+
// The preset marker rules are depth-specific. The format serializer
|
|
153
|
+
// already emits this value; the live renderer must project it too or
|
|
154
|
+
// every non-default preset falls back to the browser's plain disc/
|
|
155
|
+
// decimal marker.
|
|
156
|
+
this.setAttribute(element, "data-smart-list-depth", String(listDepth), node.id);
|
|
157
|
+
this.setAttribute(element, "data-srte-list-depth", String(listDepth), node.id);
|
|
158
|
+
if (style && element.style.listStyleType !== style) {
|
|
159
|
+
element.style.listStyleType = style;
|
|
160
|
+
this.recordWrite(node.id);
|
|
161
|
+
}
|
|
162
|
+
else if (!style && element.style.listStyleType) {
|
|
163
|
+
element.style.removeProperty("list-style-type");
|
|
164
|
+
this.recordWrite(node.id);
|
|
165
|
+
}
|
|
166
|
+
if (node.attrs?.start !== undefined)
|
|
167
|
+
this.setAttribute(element, "start", String(node.attrs.start), node.id);
|
|
168
|
+
else
|
|
169
|
+
this.removeAttribute(element, "start", node.id);
|
|
170
|
+
this.setAttribute(element, "data-smart-checkable", node.attrs?.checkable === true ? "true" : "false", node.id);
|
|
171
|
+
if (node.attrs?.checkable === true)
|
|
172
|
+
this.setAttribute(element, "data-srte-checklist", "true", node.id);
|
|
173
|
+
else
|
|
174
|
+
this.removeAttribute(element, "data-srte-checklist", node.id);
|
|
175
|
+
}
|
|
176
|
+
else if (node.type === "list_item") {
|
|
177
|
+
const parent = element.parentElement;
|
|
178
|
+
const checkable = parent?.getAttribute("data-smart-checkable") === "true";
|
|
179
|
+
let control = element.querySelector(`:scope > [${SMART_UI_ATTRIBUTE}="check-control"]`);
|
|
180
|
+
if (checkable) {
|
|
181
|
+
if (!control) {
|
|
182
|
+
control = element.ownerDocument.createElement("button");
|
|
183
|
+
control.setAttribute("type", "button");
|
|
184
|
+
control.setAttribute(SMART_UI_ATTRIBUTE, "check-control");
|
|
185
|
+
control.setAttribute("data-srte-check", "true");
|
|
186
|
+
control.setAttribute("role", "checkbox");
|
|
187
|
+
control.setAttribute("aria-label", "Toggle checklist item");
|
|
188
|
+
control.contentEditable = "false";
|
|
189
|
+
element.prepend(control);
|
|
190
|
+
this.recordWrite(node.id);
|
|
191
|
+
}
|
|
192
|
+
const checked = node.attrs?.checked === true;
|
|
193
|
+
this.setAttribute(control, "aria-checked", checked ? "true" : "false", node.id);
|
|
194
|
+
this.setAttribute(control, "data-checked", checked ? "true" : "false", node.id);
|
|
195
|
+
this.setAttribute(control, "aria-label", checked ? "Mark incomplete" : "Mark complete", node.id);
|
|
196
|
+
this.setAttribute(element, "data-srte-checked", node.attrs?.checked === true ? "true" : "false", node.id);
|
|
197
|
+
this.removeAttribute(element, "role", node.id);
|
|
198
|
+
this.removeAttribute(element, "aria-checked", node.id);
|
|
199
|
+
this.removeAttribute(element, "tabindex", node.id);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
if (control) {
|
|
203
|
+
control.remove();
|
|
204
|
+
this.recordWrite(node.id);
|
|
205
|
+
}
|
|
206
|
+
this.removeAttribute(element, "data-srte-checked", node.id);
|
|
207
|
+
this.removeAttribute(element, "role", node.id);
|
|
208
|
+
this.removeAttribute(element, "aria-checked", node.id);
|
|
209
|
+
this.removeAttribute(element, "tabindex", node.id);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else if (node.type === "table") {
|
|
213
|
+
if (node.attrs?.layout) {
|
|
214
|
+
const layout = String(node.attrs.layout);
|
|
215
|
+
this.setAttribute(element, "data-smart-layout", layout, node.id);
|
|
216
|
+
if (element.style.tableLayout !== layout) {
|
|
217
|
+
element.style.tableLayout = layout;
|
|
218
|
+
this.recordWrite(node.id);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const caption = typeof node.attrs?.caption === "string" ? node.attrs.caption : "";
|
|
222
|
+
let captionElement = element.querySelector(`:scope > [${SMART_PROJECTION_ATTRIBUTE}="table-caption"]`);
|
|
223
|
+
if (caption) {
|
|
224
|
+
if (!captionElement) {
|
|
225
|
+
captionElement = element.ownerDocument.createElement("caption");
|
|
226
|
+
captionElement.setAttribute(SMART_PROJECTION_ATTRIBUTE, "table-caption");
|
|
227
|
+
captionElement.contentEditable = "false";
|
|
228
|
+
element.prepend(captionElement);
|
|
229
|
+
this.recordWrite(node.id);
|
|
230
|
+
}
|
|
231
|
+
if (captionElement.textContent !== caption) {
|
|
232
|
+
captionElement.textContent = caption;
|
|
233
|
+
this.recordWrite(node.id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else if (captionElement) {
|
|
237
|
+
captionElement.remove();
|
|
238
|
+
this.recordWrite(node.id);
|
|
239
|
+
}
|
|
240
|
+
// table.attrs.columnWidths was, until this pass, written by
|
|
241
|
+
// setTableColumnWidthCommand but never read by this renderer at all -
|
|
242
|
+
// a real, until-now-invisible gap (docs/bugs/
|
|
243
|
+
// table-column-width-not-rendered.md). A <colgroup> is the standard
|
|
244
|
+
// HTML sizing mechanism, one <col> per grid column, positioned after
|
|
245
|
+
// <caption> (self-correcting on every render regardless of prior
|
|
246
|
+
// insertion order) and before row content, matching the legacy DOM
|
|
247
|
+
// bridge's existing colgroup approach for the same attribute.
|
|
248
|
+
const widths = Array.isArray(node.attrs?.columnWidths) ? node.attrs.columnWidths : null;
|
|
249
|
+
let colgroup = element.querySelector(`:scope > colgroup[${SMART_PROJECTION_ATTRIBUTE}="table-colgroup"]`);
|
|
250
|
+
if (widths && widths.length) {
|
|
251
|
+
const grid = occupancyGridFor(node);
|
|
252
|
+
if (!colgroup) {
|
|
253
|
+
colgroup = element.ownerDocument.createElement("colgroup");
|
|
254
|
+
colgroup.setAttribute(SMART_PROJECTION_ATTRIBUTE, "table-colgroup");
|
|
255
|
+
const afterCaption = element.querySelector(":scope > caption");
|
|
256
|
+
if (afterCaption)
|
|
257
|
+
afterCaption.after(colgroup);
|
|
258
|
+
else
|
|
259
|
+
element.prepend(colgroup);
|
|
260
|
+
this.recordWrite(node.id);
|
|
261
|
+
}
|
|
262
|
+
for (let index = 0; index < grid.columns; index += 1) {
|
|
263
|
+
let col = colgroup.children[index];
|
|
264
|
+
if (!col) {
|
|
265
|
+
col = element.ownerDocument.createElement("col");
|
|
266
|
+
colgroup.appendChild(col);
|
|
267
|
+
this.recordWrite(node.id);
|
|
268
|
+
}
|
|
269
|
+
const width = widths[index];
|
|
270
|
+
const value = Number.isFinite(width) && width > 0 ? `${width}px` : "";
|
|
271
|
+
if (col.style.width !== value) {
|
|
272
|
+
col.style.width = value;
|
|
273
|
+
this.recordWrite(node.id);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
while (colgroup.children.length > grid.columns) {
|
|
277
|
+
colgroup.lastElementChild?.remove();
|
|
278
|
+
this.recordWrite(node.id);
|
|
279
|
+
}
|
|
280
|
+
// The stylesheet's default `table { width: 100% }` combined with
|
|
281
|
+
// `table-layout: fixed` makes every <col> width a *proportion* of
|
|
282
|
+
// the table's rendered width, not a literal pixel value - growing
|
|
283
|
+
// one column via colgroup shifts everyone else's proportional
|
|
284
|
+
// share too, moving columns the resize never touched (docs/bugs/
|
|
285
|
+
// table-resize-moves-unrelated-columns.md). Pinning the table's
|
|
286
|
+
// own inline width to the literal sum of columnWidths overrides
|
|
287
|
+
// that default so table-layout: fixed gives each column exactly
|
|
288
|
+
// its specified pixel width, and resizing one column changes the
|
|
289
|
+
// table's overall width instead of everyone else's.
|
|
290
|
+
const totalWidth = widths.slice(0, grid.columns).reduce((sum, width) => sum + (Number.isFinite(width) && width > 0 ? width : 0), 0);
|
|
291
|
+
const totalValue = totalWidth > 0 ? `${totalWidth}px` : "";
|
|
292
|
+
if (element.style.width !== totalValue) {
|
|
293
|
+
element.style.width = totalValue;
|
|
294
|
+
this.recordWrite(node.id);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
else if (colgroup) {
|
|
298
|
+
colgroup.remove();
|
|
299
|
+
if (element.style.width) {
|
|
300
|
+
element.style.removeProperty("width");
|
|
301
|
+
this.recordWrite(node.id);
|
|
302
|
+
}
|
|
303
|
+
this.recordWrite(node.id);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
else if (node.type === "table_row") {
|
|
307
|
+
const height = Number(node.attrs?.height);
|
|
308
|
+
if (Number.isFinite(height) && height > 0)
|
|
309
|
+
element.style.height = `${height}px`;
|
|
310
|
+
else
|
|
311
|
+
element.style.removeProperty("height");
|
|
312
|
+
}
|
|
313
|
+
else if (node.type === "table_cell") {
|
|
314
|
+
// Row height is canonical state on table_row. A cell-level height can
|
|
315
|
+
// survive a DOM subtree reuse (for example after a legacy resize or a
|
|
316
|
+
// merge) and makes the browser count the same row height once per
|
|
317
|
+
// covered cell. Clear that stale presentation at the renderer
|
|
318
|
+
// boundary; it is not part of the cell model and must never win over
|
|
319
|
+
// the row's height.
|
|
320
|
+
["height", "min-height", "max-height"].forEach((property) => {
|
|
321
|
+
if (element.style.getPropertyValue(property)) {
|
|
322
|
+
element.style.removeProperty(property);
|
|
323
|
+
this.recordWrite(node.id);
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
this.removeAttribute(element, "height", node.id);
|
|
327
|
+
const rowspan = Math.max(1, Number(node.attrs?.rowspan) || 1);
|
|
328
|
+
const colspan = Math.max(1, Number(node.attrs?.colspan) || 1);
|
|
329
|
+
if (rowspan > 1)
|
|
330
|
+
this.setAttribute(element, "rowspan", String(rowspan), node.id);
|
|
331
|
+
else
|
|
332
|
+
this.removeAttribute(element, "rowspan", node.id);
|
|
333
|
+
if (colspan > 1)
|
|
334
|
+
this.setAttribute(element, "colspan", String(colspan), node.id);
|
|
335
|
+
else
|
|
336
|
+
this.removeAttribute(element, "colspan", node.id);
|
|
337
|
+
if (node.attrs?.background)
|
|
338
|
+
element.style.background = String(node.attrs.background);
|
|
339
|
+
else
|
|
340
|
+
element.style.removeProperty("background");
|
|
341
|
+
// Uniform border first (all 4 sides, or removed) - then any per-side
|
|
342
|
+
// override (borderTop/Right/Bottom/Left, the "which sides" control -
|
|
343
|
+
// docs/bugs/table-cell-border-color-width-no-ui.md's addendum) wins
|
|
344
|
+
// for that one side only, since setting a longhand like border-top
|
|
345
|
+
// after the border shorthand overrides just that side, leaving the
|
|
346
|
+
// shorthand's effect on the other three untouched.
|
|
347
|
+
if (node.attrs?.borders)
|
|
348
|
+
element.style.border = String(node.attrs.borders);
|
|
349
|
+
else
|
|
350
|
+
element.style.removeProperty("border");
|
|
351
|
+
["Top", "Right", "Bottom", "Left"].forEach((side) => {
|
|
352
|
+
const value = node.attrs?.[`border${side}`];
|
|
353
|
+
if (value)
|
|
354
|
+
element.style.setProperty(`border-${side.toLowerCase()}`, String(value));
|
|
355
|
+
else
|
|
356
|
+
element.style.removeProperty(`border-${side.toLowerCase()}`);
|
|
357
|
+
});
|
|
358
|
+
if (node.attrs?.verticalAlign)
|
|
359
|
+
element.style.verticalAlign = String(node.attrs.verticalAlign);
|
|
360
|
+
else
|
|
361
|
+
element.style.removeProperty("vertical-align");
|
|
362
|
+
// table_cell.attrs.textColor was parsed from HTML import and settable
|
|
363
|
+
// via table.setCellAttributes, but never read by this renderer at all
|
|
364
|
+
// - the same "written, never rendered" gap columnWidths had (docs/
|
|
365
|
+
// bugs/table-column-width-not-rendered.md), found wiring the new
|
|
366
|
+
// "Cell text color" context menu item, which would otherwise have
|
|
367
|
+
// applied a value with zero visible effect.
|
|
368
|
+
if (node.attrs?.textColor)
|
|
369
|
+
element.style.color = String(node.attrs.textColor);
|
|
370
|
+
else
|
|
371
|
+
element.style.removeProperty("color");
|
|
372
|
+
}
|
|
373
|
+
else if (node.type === "image" || node.type === "block_image") {
|
|
374
|
+
const source = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: "image", allowBlobPreview: node.attrs?.status === "pending" });
|
|
375
|
+
if (source)
|
|
376
|
+
this.setAttribute(element, "src", source, node.id);
|
|
377
|
+
else
|
|
378
|
+
this.removeAttribute(element, "src", node.id);
|
|
379
|
+
this.setAttribute(element, "alt", typeof node.attrs?.alt === "string" ? node.attrs.alt : "", node.id);
|
|
380
|
+
if (node.attrs?.decorative === true)
|
|
381
|
+
this.setAttribute(element, "data-smart-decorative", "true", node.id);
|
|
382
|
+
else
|
|
383
|
+
this.removeAttribute(element, "data-smart-decorative", node.id);
|
|
384
|
+
if (node.attrs?.width)
|
|
385
|
+
this.setAttribute(element, "width", String(node.attrs.width), node.id);
|
|
386
|
+
else
|
|
387
|
+
this.removeAttribute(element, "width", node.id);
|
|
388
|
+
if (node.attrs?.height)
|
|
389
|
+
this.setAttribute(element, "height", String(node.attrs.height), node.id);
|
|
390
|
+
else
|
|
391
|
+
this.removeAttribute(element, "height", node.id);
|
|
392
|
+
this.setAttribute(element, "data-smart-status", String(node.attrs?.status || "ready"), node.id);
|
|
393
|
+
if (node.attrs?.status === "error")
|
|
394
|
+
this.setAttribute(element, "title", atomErrorTitle(node, "Image could not be loaded"), node.id);
|
|
395
|
+
else
|
|
396
|
+
this.removeAttribute(element, "title", node.id);
|
|
397
|
+
const imageAlign = node.attrs?.align;
|
|
398
|
+
if (imageAlign === "center") {
|
|
399
|
+
element.style.display = "block";
|
|
400
|
+
element.style.margin = "0 auto";
|
|
401
|
+
element.style.float = "none";
|
|
402
|
+
}
|
|
403
|
+
else if (imageAlign === "left" || imageAlign === "right") {
|
|
404
|
+
element.style.display = "inline";
|
|
405
|
+
element.style.float = imageAlign;
|
|
406
|
+
element.style.margin = imageAlign === "left" ? "0 8px 8px 0" : "0 0 8px 8px";
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
else if (node.type === "formula" || node.type === "block_formula") {
|
|
410
|
+
const source = String(node.attrs?.source || "");
|
|
411
|
+
const previousSource = element.getAttribute("data-smart-formula");
|
|
412
|
+
this.setAttribute(element, "role", "math", node.id);
|
|
413
|
+
this.setAttribute(element, "aria-label", `Mathematical formula: ${source}`, node.id);
|
|
414
|
+
this.setAttribute(element, "data-smart-formula", source, node.id);
|
|
415
|
+
// KaTeX owns the element's children once rendered (real HTML+MathML,
|
|
416
|
+
// not plain text); the dirty check moves to the source attribute
|
|
417
|
+
// since textContent no longer equals the raw source after rendering.
|
|
418
|
+
if (previousSource !== source || !element.hasChildNodes()) {
|
|
419
|
+
renderFormulaInto(element, source);
|
|
420
|
+
this.recordWrite(node.id);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else if (node.type === "video" || node.type === "audio") {
|
|
424
|
+
const source = sanitizeAtomSource(String(node.attrs?.src || ""), { kind: node.type });
|
|
425
|
+
if (source)
|
|
426
|
+
this.setAttribute(element, "src", source, node.id);
|
|
427
|
+
else
|
|
428
|
+
this.removeAttribute(element, "src", node.id);
|
|
429
|
+
this.setAttribute(element, "controls", "", node.id);
|
|
430
|
+
this.setAttribute(element, "preload", "metadata", node.id);
|
|
431
|
+
if (node.type === "video")
|
|
432
|
+
this.setAttribute(element, "playsinline", "", node.id);
|
|
433
|
+
else
|
|
434
|
+
this.removeAttribute(element, "playsinline", node.id);
|
|
435
|
+
if (node.attrs?.width)
|
|
436
|
+
this.setAttribute(element, "width", String(node.attrs.width), node.id);
|
|
437
|
+
else
|
|
438
|
+
this.removeAttribute(element, "width", node.id);
|
|
439
|
+
if (node.attrs?.height)
|
|
440
|
+
this.setAttribute(element, "height", String(node.attrs.height), node.id);
|
|
441
|
+
else
|
|
442
|
+
this.removeAttribute(element, "height", node.id);
|
|
443
|
+
const extension = source?.match(/\.([a-z0-9]+)(?:[?#]|$)/i)?.[1]?.toLowerCase();
|
|
444
|
+
const mediaType = node.type === "audio"
|
|
445
|
+
? extension === "mp3" ? "audio/mpeg" : extension === "wav" ? "audio/wav" : extension === "ogg" ? "audio/ogg" : extension === "webm" ? "audio/webm" : null
|
|
446
|
+
: extension === "mp4" ? "video/mp4" : extension === "webm" ? "video/webm" : extension === "ogv" ? "video/ogg" : null;
|
|
447
|
+
if (mediaType)
|
|
448
|
+
this.setAttribute(element, "type", mediaType, node.id);
|
|
449
|
+
else
|
|
450
|
+
this.removeAttribute(element, "type", node.id);
|
|
451
|
+
this.setAttribute(element, "aria-label", node.type === "video" ? "Video player" : "Audio player", node.id);
|
|
452
|
+
if (node.attrs?.status === "error") {
|
|
453
|
+
this.setAttribute(element, "title", atomErrorTitle(node, `${node.type === "video" ? "Video" : "Audio"} could not be loaded`), node.id);
|
|
454
|
+
}
|
|
455
|
+
else
|
|
456
|
+
this.removeAttribute(element, "title", node.id);
|
|
457
|
+
if (node.type === "video" && node.attrs?.poster) {
|
|
458
|
+
const poster = sanitizeAtomSource(String(node.attrs.poster), { kind: "image" });
|
|
459
|
+
if (poster)
|
|
460
|
+
this.setAttribute(element, "poster", poster, node.id);
|
|
461
|
+
else
|
|
462
|
+
this.removeAttribute(element, "poster", node.id);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Keep failed remote resources visible as model-backed atoms instead of
|
|
468
|
+
* leaving a silent broken-image/media control. The event only updates
|
|
469
|
+
* renderer diagnostics; it never mutates the canonical document. A later
|
|
470
|
+
* attribute transaction (retry, replacement, or upload completion) clears
|
|
471
|
+
* the state through the normal diff path.
|
|
472
|
+
*/
|
|
473
|
+
installMediaDiagnostics(element, node) {
|
|
474
|
+
if (!atomTypes.has(node.type) || node.type === "formula" || node.type === "block_formula" || node.type === "divider")
|
|
475
|
+
return;
|
|
476
|
+
if (element.hasAttribute("data-smart-media-events"))
|
|
477
|
+
return;
|
|
478
|
+
element.setAttribute("data-smart-media-events", "true");
|
|
479
|
+
const clear = () => {
|
|
480
|
+
element.setAttribute("data-smart-media-state", "ready");
|
|
481
|
+
element.removeAttribute("title");
|
|
482
|
+
};
|
|
483
|
+
const failed = () => {
|
|
484
|
+
element.setAttribute("data-smart-media-state", "error");
|
|
485
|
+
element.setAttribute("title", atomErrorTitle(node, node.type === "block_image" || node.type === "image"
|
|
486
|
+
? "Image could not be loaded"
|
|
487
|
+
: `${node.type === "video" ? "Video" : "Audio"} could not be loaded`));
|
|
488
|
+
};
|
|
489
|
+
element.addEventListener("load", clear);
|
|
490
|
+
element.addEventListener("canplay", clear);
|
|
491
|
+
element.addEventListener("error", failed);
|
|
492
|
+
}
|
|
493
|
+
syncTableAccessibility() {
|
|
494
|
+
this.root.querySelectorAll('[data-smart-type="table"]').forEach((tableElement) => {
|
|
495
|
+
const mapped = this.mapping.domToNode(tableElement)?.node;
|
|
496
|
+
if (!mapped || isTextNode(mapped) || mapped.type !== "table")
|
|
497
|
+
return;
|
|
498
|
+
const grid = occupancyGridFor(mapped);
|
|
499
|
+
let headerRows = 0;
|
|
500
|
+
while (headerRows < grid.rows && Array.from({ length: grid.columns }, (_, column) => grid.at(headerRows, column)?.node.attrs?.header === true).every(Boolean))
|
|
501
|
+
headerRows += 1;
|
|
502
|
+
let headerColumns = 0;
|
|
503
|
+
while (headerColumns < grid.columns && Array.from({ length: grid.rows }, (_, row) => grid.at(row, headerColumns)?.node.attrs?.header === true).every(Boolean))
|
|
504
|
+
headerColumns += 1;
|
|
505
|
+
const headerIds = new Map();
|
|
506
|
+
grid.anchors.filter((cell) => cell.node.attrs?.header === true).forEach((cell) => headerIds.set(cell.cellId, `smart-header-${cell.cellId}`));
|
|
507
|
+
grid.anchors.forEach((cell) => {
|
|
508
|
+
const element = this.mapping.nodeToDom(cell.cellId);
|
|
509
|
+
if (!(element instanceof HTMLElement))
|
|
510
|
+
return;
|
|
511
|
+
const id = headerIds.get(cell.cellId);
|
|
512
|
+
if (id) {
|
|
513
|
+
element.id = id;
|
|
514
|
+
element.setAttribute("scope", cell.left < headerColumns && cell.top >= headerRows ? "row" : "col");
|
|
515
|
+
element.removeAttribute("headers");
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
element.removeAttribute("scope");
|
|
519
|
+
const related = grid.anchors.filter((header) => header.node.attrs?.header === true && (header.top < headerRows && header.left <= cell.left && header.right > cell.left
|
|
520
|
+
|| header.left < headerColumns && header.top <= cell.top && header.bottom > cell.top))
|
|
521
|
+
.map((header) => headerIds.get(header.cellId)).filter((value) => Boolean(value));
|
|
522
|
+
if (related.length)
|
|
523
|
+
element.setAttribute("headers", related.join(" "));
|
|
524
|
+
else
|
|
525
|
+
element.removeAttribute("headers");
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
createNode(node, path, listDepth = 0) {
|
|
531
|
+
if (isTextNode(node)) {
|
|
532
|
+
this.recordWrite();
|
|
533
|
+
return renderMarkedText(node, this.root.ownerDocument);
|
|
534
|
+
}
|
|
535
|
+
const element = this.root.ownerDocument.createElement(tagForNode(node));
|
|
536
|
+
this.recordWrite(node.id);
|
|
537
|
+
element.setAttribute(SMART_NODE_ID_ATTRIBUTE, node.id);
|
|
538
|
+
element.setAttribute("data-smart-type", node.type);
|
|
539
|
+
this.syncNodeAttributes(element, node, listDepth);
|
|
540
|
+
this.installMediaDiagnostics(element, node);
|
|
541
|
+
if (node.type === "hard_break") {
|
|
542
|
+
element.setAttribute("data-smart-atomic", "true");
|
|
543
|
+
}
|
|
544
|
+
else if (node.type === "unknown" || node.attrs?.atomic === true || atomTypes.has(node.type)) {
|
|
545
|
+
element.contentEditable = "false";
|
|
546
|
+
element.setAttribute("data-smart-atomic", "true");
|
|
547
|
+
if (node.type === "unknown")
|
|
548
|
+
element.textContent = `[Unsupported: ${String(node.attrs?.originalType || "unknown")}]`;
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
const childListDepth = node.type === "list" ? listDepth + 1 : listDepth;
|
|
552
|
+
node.children?.forEach((child, index) => element.appendChild(this.createNode(child, [...path, index], childListDepth)));
|
|
553
|
+
if (node.type === "list")
|
|
554
|
+
(node.children || []).forEach((child, index) => {
|
|
555
|
+
if (!isTextNode(child)) {
|
|
556
|
+
const childElement = this.modelChildren(element)[index];
|
|
557
|
+
if (childElement instanceof HTMLElement)
|
|
558
|
+
this.syncNodeAttributes(childElement, child);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
this.syncEmptyLineProjection(element, node);
|
|
562
|
+
}
|
|
563
|
+
this.modelById.set(node.id, node);
|
|
564
|
+
this.mapping.track(node, path, element);
|
|
565
|
+
return element;
|
|
566
|
+
}
|
|
567
|
+
forget(node) {
|
|
568
|
+
if (isTextNode(node))
|
|
569
|
+
return;
|
|
570
|
+
this.modelById.delete(node.id);
|
|
571
|
+
this.mapping.forget(node.id);
|
|
572
|
+
node.children?.forEach((child) => this.forget(child));
|
|
573
|
+
}
|
|
574
|
+
modelChildren(element) {
|
|
575
|
+
return [...element.childNodes].filter((node) => {
|
|
576
|
+
const candidate = node.nodeType === node.ELEMENT_NODE ? node : node.parentElement;
|
|
577
|
+
return !candidate?.closest(`[${SMART_UI_ATTRIBUTE}]`) && !candidate?.closest(`[${SMART_PROJECTION_ATTRIBUTE}]`);
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Empty inline owners need a browser line box for an immediately visible
|
|
582
|
+
* caret. This <br> is renderer UI, not canonical document content.
|
|
583
|
+
*/
|
|
584
|
+
syncEmptyLineProjection(element, node) {
|
|
585
|
+
const existing = element.querySelector(`:scope > [${SMART_EMPTY_LINE_ATTRIBUTE}]`);
|
|
586
|
+
const needsProjection = emptyLineOwnerTypes.has(node.type) && (node.children?.length || 0) === 0;
|
|
587
|
+
if (needsProjection && !existing) {
|
|
588
|
+
const line = element.ownerDocument.createElement("br");
|
|
589
|
+
line.setAttribute(SMART_EMPTY_LINE_ATTRIBUTE, "true");
|
|
590
|
+
line.setAttribute(SMART_UI_ATTRIBUTE, "empty-line");
|
|
591
|
+
element.setAttribute("data-srte-caret-boundary", "true");
|
|
592
|
+
element.appendChild(line);
|
|
593
|
+
this.recordWrite(node.id);
|
|
594
|
+
}
|
|
595
|
+
else if (!needsProjection && existing) {
|
|
596
|
+
existing.remove();
|
|
597
|
+
element.removeAttribute("data-srte-caret-boundary");
|
|
598
|
+
this.recordWrite(node.id);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
diffElement(element, before, after, path, listDepth = 0) {
|
|
602
|
+
this.modelById.set(after.id, after);
|
|
603
|
+
this.mapping.track(after, path, element);
|
|
604
|
+
if (after.id === this.compositionOwner)
|
|
605
|
+
return false;
|
|
606
|
+
this.setAttribute(element, SMART_NODE_ID_ATTRIBUTE, after.id, after.id);
|
|
607
|
+
this.setAttribute(element, "data-smart-type", after.type, after.id);
|
|
608
|
+
this.syncNodeAttributes(element, after, listDepth);
|
|
609
|
+
this.installMediaDiagnostics(element, after);
|
|
610
|
+
// Atomic nodes render their payload as presentation (for example, a
|
|
611
|
+
// formula's accessible source text), not as model children. Once a
|
|
612
|
+
// stable-ID sibling is reused, recursing into that DOM subtree would
|
|
613
|
+
// mistake the presentation text for an unmodelled child and remove it.
|
|
614
|
+
// Keep the atom opaque after syncing its attributes; its model lifecycle
|
|
615
|
+
// is handled by the parent diff and its dedicated attribute renderer.
|
|
616
|
+
if (after.type === "unknown" || after.attrs?.atomic === true || atomTypes.has(after.type))
|
|
617
|
+
return false;
|
|
618
|
+
const beforeChildren = before.children || [];
|
|
619
|
+
const afterChildren = after.children || [];
|
|
620
|
+
// Remove the visual-only empty line before inserting real model children.
|
|
621
|
+
if (afterChildren.length)
|
|
622
|
+
this.syncEmptyLineProjection(element, after);
|
|
623
|
+
let structural = beforeChildren.length !== afterChildren.length
|
|
624
|
+
|| after.type === "table_cell" && ["rowspan", "colspan", "header"].some((attribute) => before.attrs?.[attribute] !== after.attrs?.[attribute]);
|
|
625
|
+
for (let index = 0; index < afterChildren.length; index += 1) {
|
|
626
|
+
const next = afterChildren[index];
|
|
627
|
+
const previous = beforeChildren[index];
|
|
628
|
+
if (next === previous) {
|
|
629
|
+
if (!isTextNode(next) && atomTypes.has(next.type)) {
|
|
630
|
+
const atomElement = this.modelChildren(element)[index];
|
|
631
|
+
if (atomElement instanceof HTMLElement)
|
|
632
|
+
this.syncNodeAttributes(atomElement, next);
|
|
633
|
+
}
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const domChildren = this.modelChildren(element);
|
|
637
|
+
let dom = domChildren[index] || null;
|
|
638
|
+
let old = previous;
|
|
639
|
+
// A sibling move can leave the DOM node already at this target index
|
|
640
|
+
// even though `previous` describes the node that occupied the index in
|
|
641
|
+
// the old model. Reconcile by stable node ID in that case. Treating the
|
|
642
|
+
// old positional node as the identity here recreates the moved element,
|
|
643
|
+
// drops its native caret, and can make the browser selection point at a
|
|
644
|
+
// detached range while the model has already moved on.
|
|
645
|
+
if (!isTextNode(next) && dom instanceof HTMLElement
|
|
646
|
+
&& dom.getAttribute(SMART_NODE_ID_ATTRIBUTE) === next.id) {
|
|
647
|
+
// When the same ID remains at the same logical index, `previous` is
|
|
648
|
+
// the immediately-rendered version and is fresher than the historical
|
|
649
|
+
// ID cache. The cache is only needed for a node whose positional
|
|
650
|
+
// predecessor has a different ID (the actual sibling-move case).
|
|
651
|
+
old = Boolean(previous && !isTextNode(previous) && previous.id === next.id)
|
|
652
|
+
? previous
|
|
653
|
+
: this.modelById.get(next.id) || next;
|
|
654
|
+
}
|
|
655
|
+
if (!isTextNode(next)) {
|
|
656
|
+
const existing = this.mapping.nodeToDom(next.id);
|
|
657
|
+
if (existing && existing.parentNode === element && existing !== dom) {
|
|
658
|
+
element.insertBefore(existing, dom);
|
|
659
|
+
this.recordWrite(after.id);
|
|
660
|
+
dom = existing;
|
|
661
|
+
old = this.modelById.get(next.id) || previous;
|
|
662
|
+
structural = true;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (isTextNode(next)) {
|
|
666
|
+
const sameMarks = !old || isTextNode(old) && stableValue(old.marks || []) === stableValue(next.marks || []);
|
|
667
|
+
const textDom = dom?.nodeType === 3 ? dom : dom?.textContent !== undefined ? (() => {
|
|
668
|
+
const walker = element.ownerDocument.createTreeWalker(dom, NodeFilter.SHOW_TEXT);
|
|
669
|
+
return walker.nextNode();
|
|
670
|
+
})() : null;
|
|
671
|
+
if (textDom?.nodeType === 3 && sameMarks) {
|
|
672
|
+
if (textDom.nodeValue !== next.text) {
|
|
673
|
+
textDom.nodeValue = next.text;
|
|
674
|
+
this.recordWrite(after.id);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
else {
|
|
678
|
+
const childListDepth = after.type === "list" ? listDepth + 1 : listDepth;
|
|
679
|
+
const replacement = this.createNode(next, [...path, index], childListDepth);
|
|
680
|
+
if (dom)
|
|
681
|
+
element.replaceChild(replacement, dom);
|
|
682
|
+
else
|
|
683
|
+
element.appendChild(replacement);
|
|
684
|
+
this.recordWrite(after.id);
|
|
685
|
+
structural = true;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
else if (dom?.nodeType === 1 && old && !isTextNode(old) && old.id === next.id && dom.tagName.toLowerCase() === tagForNode(next)) {
|
|
689
|
+
const childListDepth = after.type === "list" ? listDepth + 1 : listDepth;
|
|
690
|
+
structural = this.diffElement(dom, old, next, [...path, index], childListDepth) || structural;
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
const childListDepth = after.type === "list" ? listDepth + 1 : listDepth;
|
|
694
|
+
const replacement = this.createNode(next, [...path, index], childListDepth);
|
|
695
|
+
const previousSurvives = Boolean(previous && afterChildren.slice(index + 1).includes(previous));
|
|
696
|
+
if (dom && previousSurvives)
|
|
697
|
+
element.insertBefore(replacement, dom);
|
|
698
|
+
else if (dom)
|
|
699
|
+
element.replaceChild(replacement, dom);
|
|
700
|
+
else
|
|
701
|
+
element.appendChild(replacement);
|
|
702
|
+
this.recordWrite(after.id);
|
|
703
|
+
if (old && !previousSurvives)
|
|
704
|
+
this.forget(old);
|
|
705
|
+
structural = true;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
while (this.modelChildren(element).length > afterChildren.length) {
|
|
709
|
+
const domChildren = this.modelChildren(element);
|
|
710
|
+
const index = domChildren.length - 1;
|
|
711
|
+
const removed = beforeChildren[index];
|
|
712
|
+
element.removeChild(domChildren[index]);
|
|
713
|
+
this.recordWrite(after.id);
|
|
714
|
+
if (removed)
|
|
715
|
+
this.forget(removed);
|
|
716
|
+
structural = true;
|
|
717
|
+
}
|
|
718
|
+
this.syncEmptyLineProjection(element, after);
|
|
719
|
+
return structural;
|
|
720
|
+
}
|
|
721
|
+
selectionMatches(selection, anchor, head) {
|
|
722
|
+
return selection.anchorNode === anchor.node && selection.anchorOffset === anchor.offset
|
|
723
|
+
&& selection.focusNode === head.node && selection.focusOffset === head.offset;
|
|
724
|
+
}
|
|
725
|
+
restoreSelection(model) {
|
|
726
|
+
const selection = this.root.ownerDocument.getSelection();
|
|
727
|
+
if (!selection)
|
|
728
|
+
return;
|
|
729
|
+
if (model.type === "none") {
|
|
730
|
+
if (selection.rangeCount)
|
|
731
|
+
selection.removeAllRanges();
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const anchor = this.mapping.posToDom(model.anchor);
|
|
735
|
+
const head = this.mapping.posToDom(model.head);
|
|
736
|
+
if (!anchor || !head)
|
|
737
|
+
return;
|
|
738
|
+
if (!this.selectionMatches(selection, anchor, head)) {
|
|
739
|
+
if (typeof selection.setBaseAndExtent === "function") {
|
|
740
|
+
selection.setBaseAndExtent(anchor.node, anchor.offset, head.node, head.offset);
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
const range = this.root.ownerDocument.createRange();
|
|
744
|
+
range.setStart(anchor.node, anchor.offset);
|
|
745
|
+
range.setEnd(head.node, head.offset);
|
|
746
|
+
selection.removeAllRanges();
|
|
747
|
+
selection.addRange(range);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
this.revealSelectionHead(head.node);
|
|
751
|
+
}
|
|
752
|
+
/** Cell selections are projected as a DOM-only highlight; cells remain model nodes. */
|
|
753
|
+
syncCellSelectionProjection(selection) {
|
|
754
|
+
this.root.querySelectorAll("[data-smart-cell-selected]").forEach((cell) => cell.removeAttribute("data-smart-cell-selected"));
|
|
755
|
+
// restoreSelection still needs a real, non-collapsed native Range spanning
|
|
756
|
+
// anchor-to-head so Ctrl+C/native copy commands have something to act on
|
|
757
|
+
// (handleCopy overrides the actual clipboard payload from the model, so
|
|
758
|
+
// the Range's exact span never affects copied content) and so a later
|
|
759
|
+
// selectionchange still matches this cell selection's own anchor/head
|
|
760
|
+
// (see syncSelectionFromDom's "cell" preservation guard). But that Range
|
|
761
|
+
// necessarily passes through any cell that sits between the anchor and
|
|
762
|
+
// head cells in DOM order, and the browser highlights all of it - not
|
|
763
|
+
// just the two boundary cells - producing a native grey highlight on
|
|
764
|
+
// cells the [data-smart-cell-selected] rectangle above correctly leaves
|
|
765
|
+
// unmarked. Suppress the native highlight's rendering entirely while a
|
|
766
|
+
// cell selection is active; the attribute above is the real indicator.
|
|
767
|
+
if (selection.type === "cell")
|
|
768
|
+
this.root.setAttribute("data-smart-cell-selection-active", "true");
|
|
769
|
+
else
|
|
770
|
+
this.root.removeAttribute("data-smart-cell-selection-active");
|
|
771
|
+
if (selection.type !== "cell")
|
|
772
|
+
return;
|
|
773
|
+
const anchor = this.mapping.posToDom(selection.anchor)?.node;
|
|
774
|
+
const head = this.mapping.posToDom(selection.head)?.node;
|
|
775
|
+
const anchorCell = anchor instanceof Element ? anchor.closest('[data-smart-type="table_cell"]') : anchor?.parentElement?.closest('[data-smart-type="table_cell"]');
|
|
776
|
+
const headCell = head instanceof Element ? head.closest('[data-smart-type="table_cell"]') : head?.parentElement?.closest('[data-smart-type="table_cell"]');
|
|
777
|
+
if (!anchorCell || !headCell)
|
|
778
|
+
return;
|
|
779
|
+
const tableElement = anchorCell.closest('[data-smart-type="table"]');
|
|
780
|
+
const mappedTable = tableElement ? this.mapping.domToNode(tableElement) : null;
|
|
781
|
+
const mappedAnchor = this.mapping.domToNode(anchorCell);
|
|
782
|
+
const mappedHead = this.mapping.domToNode(headCell);
|
|
783
|
+
if (!mappedTable || !mappedAnchor || !mappedHead || isTextNode(mappedTable.node)
|
|
784
|
+
|| isTextNode(mappedAnchor.node) || isTextNode(mappedHead.node))
|
|
785
|
+
return;
|
|
786
|
+
const grid = occupancyGridFor(mappedTable.node);
|
|
787
|
+
const anchorGridCell = grid.anchors.find((cell) => cell.cellId === mappedAnchor.nodeId);
|
|
788
|
+
const headGridCell = grid.anchors.find((cell) => cell.cellId === mappedHead.nodeId);
|
|
789
|
+
if (!anchorGridCell || !headGridCell)
|
|
790
|
+
return;
|
|
791
|
+
// Cell selections are logical rectangles, not DOM-order ranges. DOM order
|
|
792
|
+
// is row-major, so slicing it would select the cells between two vertical
|
|
793
|
+
// endpoints (for example three cells in a 2x2 table) rather than the two
|
|
794
|
+
// cells in the requested column. Snap through merged cells as the model
|
|
795
|
+
// resolver does, then project exactly the resulting anchor IDs.
|
|
796
|
+
const snapped = snapTableCellRect(mappedTable.node, {
|
|
797
|
+
top: Math.min(anchorGridCell.top, headGridCell.top),
|
|
798
|
+
left: Math.min(anchorGridCell.left, headGridCell.left),
|
|
799
|
+
bottom: Math.max(anchorGridCell.bottom, headGridCell.bottom),
|
|
800
|
+
right: Math.max(anchorGridCell.right, headGridCell.right),
|
|
801
|
+
});
|
|
802
|
+
const selected = new Set(snapped.cellIds);
|
|
803
|
+
this.root.querySelectorAll('[data-smart-type="table_cell"]').forEach((cell) => {
|
|
804
|
+
const mapped = this.mapping.domToNode(cell);
|
|
805
|
+
if (mapped && selected.has(mapped.nodeId))
|
|
806
|
+
cell.setAttribute("data-smart-cell-selected", "true");
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
/** Keep the active line visible without scrolling the surrounding page. */
|
|
810
|
+
revealSelectionHead(node) {
|
|
811
|
+
const target = node.nodeType === node.ELEMENT_NODE ? node : node.parentElement;
|
|
812
|
+
if (!target || target === this.root || !this.root.contains(target))
|
|
813
|
+
return;
|
|
814
|
+
const rootRect = this.root.getBoundingClientRect();
|
|
815
|
+
const targetRect = target.getBoundingClientRect();
|
|
816
|
+
if (targetRect.bottom > rootRect.bottom) {
|
|
817
|
+
this.root.scrollTop += targetRect.bottom - rootRect.bottom + 8;
|
|
818
|
+
}
|
|
819
|
+
else if (targetRect.top < rootRect.top) {
|
|
820
|
+
this.root.scrollTop -= rootRect.top - targetRect.top + 8;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
listItemDepths(document) {
|
|
824
|
+
const output = new Map();
|
|
825
|
+
const visit = (node, path, depth) => {
|
|
826
|
+
if (isTextNode(node))
|
|
827
|
+
return;
|
|
828
|
+
if (node.type === "list_item")
|
|
829
|
+
output.set(node.id, { depth: Math.max(0, depth - 1), path });
|
|
830
|
+
const nextDepth = node.type === "list" ? depth + 1 : depth;
|
|
831
|
+
node.children?.forEach((child, index) => visit(child, [...path, index], nextDepth));
|
|
832
|
+
};
|
|
833
|
+
visit(document, [], 0);
|
|
834
|
+
return output;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Checklist controls are renderer projections whose state depends on both
|
|
838
|
+
* the list parent and the item attributes. A list-type transition can keep
|
|
839
|
+
* the item object reference unchanged, so the normal subtree identity skip
|
|
840
|
+
* must not leave a stale checkbox projection behind. Reconcile these
|
|
841
|
+
* projections from the current model after every render; attribute writes
|
|
842
|
+
* remain idempotent.
|
|
843
|
+
*/
|
|
844
|
+
syncListProjections(document) {
|
|
845
|
+
const visit = (node, listDepth = 0) => {
|
|
846
|
+
if (isTextNode(node))
|
|
847
|
+
return;
|
|
848
|
+
if (node.type === "list" || node.type === "list_item") {
|
|
849
|
+
const element = this.mapping.nodeToDom(node.id);
|
|
850
|
+
if (element)
|
|
851
|
+
this.syncNodeAttributes(element, node, listDepth);
|
|
852
|
+
}
|
|
853
|
+
const childListDepth = node.type === "list" ? listDepth + 1 : listDepth;
|
|
854
|
+
node.children?.forEach((child) => visit(child, childListDepth));
|
|
855
|
+
};
|
|
856
|
+
visit(document);
|
|
857
|
+
}
|
|
858
|
+
announceSelectedLevel(before, after, selection) {
|
|
859
|
+
const previous = this.listItemDepths(before);
|
|
860
|
+
const next = this.listItemDepths(after);
|
|
861
|
+
const active = [...next.entries()].filter(([, value]) => value.path.length <= selection.head.path.length
|
|
862
|
+
&& value.path.every((part, index) => selection.head.path[index] === part))
|
|
863
|
+
.sort((left, right) => right[1].path.length - left[1].path.length)[0];
|
|
864
|
+
if (!active || previous.get(active[0])?.depth === active[1].depth)
|
|
865
|
+
return;
|
|
866
|
+
if (!this.liveRegion) {
|
|
867
|
+
const region = this.root.ownerDocument.createElement("div");
|
|
868
|
+
region.setAttribute(SMART_UI_ATTRIBUTE, "list-level-announcement");
|
|
869
|
+
region.setAttribute("aria-live", "polite");
|
|
870
|
+
region.setAttribute("aria-atomic", "true");
|
|
871
|
+
region.contentEditable = "false";
|
|
872
|
+
region.style.cssText = "position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap";
|
|
873
|
+
// Append outside the contenteditable root, not inside it. A
|
|
874
|
+
// contenteditable="false" island living as a child of a
|
|
875
|
+
// contenteditable="true" root - even a visually hidden one - makes
|
|
876
|
+
// Chromium and WebKit's native Ctrl/Cmd+A (select all) silently
|
|
877
|
+
// collapse to nothing instead of selecting the document, as soon as
|
|
878
|
+
// any list item's depth changes (creating the very first list item
|
|
879
|
+
// already qualifies). Firefox is unaffected, which is why this only
|
|
880
|
+
// surfaces in Chromium/WebKit-family browsers. The announcement has
|
|
881
|
+
// no accessibility reason to live inside the editable subtree - an
|
|
882
|
+
// aria-live region only needs to be present in the document, not
|
|
883
|
+
// physically nested under the content it describes.
|
|
884
|
+
(this.root.parentElement || this.root).appendChild(region);
|
|
885
|
+
this.liveRegion = region;
|
|
886
|
+
}
|
|
887
|
+
this.liveRegion.textContent = `List level ${active[1].depth + 1}`;
|
|
888
|
+
}
|
|
889
|
+
render(document, selection) {
|
|
890
|
+
if (!this.current) {
|
|
891
|
+
this.mapping.beginUpdate(document);
|
|
892
|
+
document.children.forEach((node, index) => this.root.appendChild(this.createNode(node, [index])));
|
|
893
|
+
this.current = document;
|
|
894
|
+
this.mapping.rebuild(this.root, document);
|
|
895
|
+
this.syncListProjections(document);
|
|
896
|
+
this.syncTableAccessibility();
|
|
897
|
+
this.modelById.set(document.id, document);
|
|
898
|
+
this.restoreSelection(selection);
|
|
899
|
+
this.syncCellSelectionProjection(selection);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
if (this.current === document) {
|
|
903
|
+
this.restoreSelection(selection);
|
|
904
|
+
this.syncCellSelectionProjection(selection);
|
|
905
|
+
this.syncContentVisibility(document, document, selection);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const before = this.current;
|
|
909
|
+
this.mapping.beginUpdate(document);
|
|
910
|
+
const structural = this.diffElement(this.root, before, document, []);
|
|
911
|
+
this.modelById.set(document.id, document);
|
|
912
|
+
this.current = document;
|
|
913
|
+
if (structural)
|
|
914
|
+
this.mapping.rebuild(this.root, document);
|
|
915
|
+
this.syncListProjections(document);
|
|
916
|
+
if (structural)
|
|
917
|
+
this.syncTableAccessibility();
|
|
918
|
+
this.restoreSelection(selection);
|
|
919
|
+
this.syncCellSelectionProjection(selection);
|
|
920
|
+
this.syncContentVisibility(before, document, selection);
|
|
921
|
+
this.announceSelectedLevel(before, document, selection);
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Renderer-integrated content-visibility (Phase 11 Tier 3): the naive
|
|
925
|
+
* per-block experiment (stamping content-visibility:auto on every
|
|
926
|
+
* top-level block unconditionally, including the one actively being
|
|
927
|
+
* typed into) measured 55-699ms against a 41-46ms baseline and was
|
|
928
|
+
* disproven - the active block's own containment-recalculation overhead
|
|
929
|
+
* on every keystroke outweighs any off-screen skip benefit. This keys off
|
|
930
|
+
* the same reference-identity check diffElement already computes
|
|
931
|
+
* (`next === previous`, line ~421): a top-level block only gets
|
|
932
|
+
* content-visibility when it was untouched THIS render pass AND isn't
|
|
933
|
+
* the selection's own block, so the actively-edited block never carries
|
|
934
|
+
* the extra containment cost. Opt-in via the `contentVisibility`
|
|
935
|
+
* constructor option - default behavior for every existing caller and
|
|
936
|
+
* test is completely unchanged.
|
|
937
|
+
*/
|
|
938
|
+
syncContentVisibility(before, after, selection) {
|
|
939
|
+
if (!this.options.contentVisibility)
|
|
940
|
+
return;
|
|
941
|
+
const activeIndex = selection.head.path[0];
|
|
942
|
+
const domChildren = this.modelChildren(this.root);
|
|
943
|
+
after.children.forEach((child, index) => {
|
|
944
|
+
const dom = domChildren[index];
|
|
945
|
+
if (!(dom instanceof HTMLElement))
|
|
946
|
+
return;
|
|
947
|
+
const unchanged = before?.children[index] === child;
|
|
948
|
+
const eligible = unchanged && index !== activeIndex;
|
|
949
|
+
if (eligible) {
|
|
950
|
+
if (dom.style.contentVisibility !== "auto")
|
|
951
|
+
dom.style.contentVisibility = "auto";
|
|
952
|
+
if (!dom.style.containIntrinsicBlockSize)
|
|
953
|
+
dom.style.containIntrinsicBlockSize = "24px";
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
if (dom.style.contentVisibility)
|
|
957
|
+
dom.style.removeProperty("content-visibility");
|
|
958
|
+
if (dom.style.containIntrinsicBlockSize)
|
|
959
|
+
dom.style.removeProperty("contain-intrinsic-block-size");
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
beginComposition(nodeId) { this.compositionOwner = nodeId; }
|
|
964
|
+
endComposition() { this.compositionOwner = null; }
|
|
965
|
+
resetWriteCounters() { this.writes = 0; this.compositionWrites = 0; }
|
|
966
|
+
destroy() {
|
|
967
|
+
this.compositionOwner = null;
|
|
968
|
+
this.current = null;
|
|
969
|
+
this.modelById.clear();
|
|
970
|
+
this.liveRegion?.remove();
|
|
971
|
+
this.liveRegion = null;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
export const createSubtreeRenderer = (root, options) => new FoundationSubtreeRenderer(root, options);
|