smartrte-react 0.3.5 → 1.0.0-beta.1

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.
Files changed (141) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +106 -14
  3. package/dist/adapters/domChecklistCommandBridge.js +1 -1
  4. package/dist/adapters/domCommandBridge.d.ts +2 -2
  5. package/dist/adapters/domCommandBridge.js +2 -2
  6. package/dist/adapters/domInlineAtomCommandBridge.d.ts +10 -9
  7. package/dist/adapters/domInlineAtomCommandBridge.js +154 -50
  8. package/dist/adapters/domInlineImageCommandBridge.d.ts +7 -7
  9. package/dist/adapters/domInlineImageCommandBridge.js +6 -72
  10. package/dist/adapters/domMoveCommandBridge.js +1 -1
  11. package/dist/adapters/domSelectionBridge.d.ts +3 -3
  12. package/dist/adapters/domSmartDocument.d.ts +4 -4
  13. package/dist/adapters/domSmartDocument.js +2 -2
  14. package/dist/adapters/domTableCommandBridge.d.ts +42 -16
  15. package/dist/adapters/domTableCommandBridge.js +203 -173
  16. package/dist/adapters/internalFlags.d.ts +0 -8
  17. package/dist/adapters/internalFlags.js +0 -24
  18. package/dist/adapters/legacyListShadowComparator.d.ts +1 -1
  19. package/dist/adapters/legacyListShadowComparator.js +1 -1
  20. package/dist/adapters/pdfPrint.d.ts +8 -0
  21. package/dist/adapters/pdfPrint.js +22 -0
  22. package/dist/adapters/shadowMode.d.ts +3 -3
  23. package/dist/adapters/shadowMode.js +1 -1
  24. package/dist/canonicalEditorRuntime.d.ts +53 -1
  25. package/dist/canonicalEditorRuntime.js +61 -4
  26. package/dist/capabilityPresets.d.ts +27 -0
  27. package/dist/capabilityPresets.js +21 -0
  28. package/dist/collabTransport.d.ts +66 -0
  29. package/dist/collabTransport.js +1 -0
  30. package/dist/commentProvider.d.ts +24 -0
  31. package/dist/commentProvider.js +1 -0
  32. package/dist/components/CanonicalAuthorityEditor.d.ts +35 -1
  33. package/dist/components/CanonicalAuthorityEditor.js +1086 -112
  34. package/dist/components/ClassicEditorAuthority.d.ts +93 -22
  35. package/dist/components/ClassicEditorAuthority.js +11 -43
  36. package/dist/components/ColorPickerPopover.d.ts +39 -0
  37. package/dist/components/ColorPickerPopover.js +285 -0
  38. package/dist/components/CommentMarkers.d.ts +25 -0
  39. package/dist/components/CommentMarkers.js +105 -0
  40. package/dist/components/CommentThreadPanel.d.ts +26 -0
  41. package/dist/components/CommentThreadPanel.js +47 -0
  42. package/dist/components/ContextMenu.d.ts +23 -0
  43. package/dist/components/ContextMenu.js +141 -0
  44. package/dist/components/FormulaLibraryPopover.d.ts +16 -0
  45. package/dist/components/FormulaLibraryPopover.js +107 -0
  46. package/dist/components/LinkEditorPopover.d.ts +3 -1
  47. package/dist/components/LinkEditorPopover.js +22 -6
  48. package/dist/components/MediaManager.d.ts +4 -20
  49. package/dist/components/MediaManager.js +35 -6
  50. package/dist/components/MediaOverlay.d.ts +36 -0
  51. package/dist/components/MediaOverlay.js +163 -0
  52. package/dist/components/SpecialCharacterPopover.d.ts +18 -0
  53. package/dist/components/SpecialCharacterPopover.js +99 -0
  54. package/dist/components/StructuralSuggestionMarkers.d.ts +20 -0
  55. package/dist/components/StructuralSuggestionMarkers.js +56 -0
  56. package/dist/components/SuggestionPanel.d.ts +26 -0
  57. package/dist/components/SuggestionPanel.js +33 -0
  58. package/dist/components/TableBorderPopover.d.ts +59 -0
  59. package/dist/components/TableBorderPopover.js +147 -0
  60. package/dist/components/TableResizeHandles.d.ts +25 -0
  61. package/dist/components/TableResizeHandles.js +301 -0
  62. package/dist/components/TableSizePickerPopover.d.ts +16 -0
  63. package/dist/components/TableSizePickerPopover.js +126 -0
  64. package/dist/components/ToolbarPrimitives.d.ts +129 -0
  65. package/dist/components/ToolbarPrimitives.js +146 -0
  66. package/dist/components/VersionHistoryPanel.d.ts +9 -0
  67. package/dist/components/VersionHistoryPanel.js +157 -0
  68. package/dist/formulaLibrary.d.ts +31 -0
  69. package/dist/formulaLibrary.js +90 -0
  70. package/dist/index.d.ts +22 -24
  71. package/dist/index.js +14 -11
  72. package/dist/mediaManagerAdapter.d.ts +12 -0
  73. package/dist/mediaManagerAdapter.js +34 -0
  74. package/dist/mediaProvider.d.ts +21 -1
  75. package/dist/specialCharacters.d.ts +22 -0
  76. package/dist/specialCharacters.js +86 -0
  77. package/dist/standalone/classic-editor-embed.js +19 -12
  78. package/dist/suggestionProvider.d.ts +17 -0
  79. package/dist/suggestionProvider.js +1 -0
  80. package/dist/theme.d.ts +1 -1
  81. package/dist/theme.js +133 -1
  82. package/dist/versionProvider.d.ts +29 -0
  83. package/dist/versionProvider.js +1 -0
  84. package/package.json +28 -14
  85. package/dist/adapters/canonicalInlineCommandBridge.d.ts +0 -15
  86. package/dist/adapters/canonicalInlineCommandBridge.js +0 -269
  87. package/dist/adapters/canonicalListCommandBridge.d.ts +0 -26
  88. package/dist/adapters/canonicalListCommandBridge.js +0 -430
  89. package/dist/adapters/coreBold.d.ts +0 -5
  90. package/dist/adapters/coreBold.js +0 -3
  91. package/dist/adapters/documentFormats.d.ts +0 -26
  92. package/dist/adapters/documentFormats.js +0 -49
  93. package/dist/adapters/docxFormat.d.ts +0 -5
  94. package/dist/adapters/docxFormat.js +0 -272
  95. package/dist/adapters/domBlockCommandBridge.d.ts +0 -19
  96. package/dist/adapters/domBlockCommandBridge.js +0 -79
  97. package/dist/adapters/inlineMarkCoreExecution.d.ts +0 -9
  98. package/dist/adapters/inlineMarkCoreExecution.js +0 -25
  99. package/dist/adapters/pdfFormat.d.ts +0 -23
  100. package/dist/adapters/pdfFormat.js +0 -204
  101. package/dist/adapters/portableDocxAtoms.d.ts +0 -4
  102. package/dist/adapters/portableDocxAtoms.js +0 -58
  103. package/dist/adapters/styledDocxFormat.d.ts +0 -8
  104. package/dist/adapters/styledDocxFormat.js +0 -190
  105. package/dist/builtInFormatDefinitions.d.ts +0 -5
  106. package/dist/builtInFormatDefinitions.js +0 -82
  107. package/dist/canonicalAuthorityFlag.d.ts +0 -32
  108. package/dist/canonicalAuthorityFlag.js +0 -49
  109. package/dist/canonicalClipboardRuntime.d.ts +0 -15
  110. package/dist/canonicalClipboardRuntime.js +0 -76
  111. package/dist/components/ClassicEditor.d.ts +0 -76
  112. package/dist/components/ClassicEditor.js +0 -6729
  113. package/dist/editorController.d.ts +0 -78
  114. package/dist/editorController.js +0 -298
  115. package/dist/formatFidelity.d.ts +0 -14
  116. package/dist/formatFidelity.js +0 -107
  117. package/dist/formatRuntime.d.ts +0 -50
  118. package/dist/formatRuntime.js +0 -20
  119. package/dist/pluginRuntime.d.ts +0 -68
  120. package/dist/pluginRuntime.js +0 -78
  121. package/dist/standalone/editor.js +0 -1068
  122. package/dist/test-harness/atomShadowComparator.d.ts +0 -14
  123. package/dist/test-harness/atomShadowComparator.js +0 -63
  124. package/dist/test-harness/blockShadowComparator.d.ts +0 -30
  125. package/dist/test-harness/blockShadowComparator.js +0 -140
  126. package/dist/test-harness/clipboardShadowComparator.d.ts +0 -12
  127. package/dist/test-harness/clipboardShadowComparator.js +0 -46
  128. package/dist/test-harness/inlineShadowComparator.d.ts +0 -32
  129. package/dist/test-harness/inlineShadowComparator.js +0 -152
  130. package/dist/test-harness/legacyAtomEngine.d.ts +0 -10
  131. package/dist/test-harness/legacyAtomEngine.js +0 -15
  132. package/dist/test-harness/legacyBlockEngine.d.ts +0 -31
  133. package/dist/test-harness/legacyBlockEngine.js +0 -31
  134. package/dist/test-harness/legacyClipboardEngine.d.ts +0 -14
  135. package/dist/test-harness/legacyClipboardEngine.js +0 -67
  136. package/dist/test-harness/legacyInlineEngine.d.ts +0 -19
  137. package/dist/test-harness/legacyInlineEngine.js +0 -49
  138. package/dist/test-harness/legacyTableEngine.d.ts +0 -12
  139. package/dist/test-harness/legacyTableEngine.js +0 -25
  140. package/dist/test-harness/tableShadowComparator.d.ts +0 -29
  141. package/dist/test-harness/tableShadowComparator.js +0 -101
@@ -1,20 +1,29 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
3
- import { atomDeclarations, continueListNumbering, createList, createNodeId, deleteAtom, executeMarkTool, indentBlockCommand, indentList, inlineToolDeclarations, isTextNode, insertAtom, insertTableColumnCommand, insertTableCommand, insertTableRowCommand, mergeTableCellsCommand, moveListItems, moveTableColumnCommand, moveTableRowCommand, parseCanonicalListHtml, parseCanonicalListMarkdown, removeTableColumnCommand, removeTableCommand, removeTableRowCommand, resizeAtom, runAtomUpload, restartListNumbering, serializeCanonicalListHtml, serializeCanonicalListMarkdown, setListChecked, setListPreset, setListStyle, setTableHeaderCommand, setBlockAttributes, setBlockTypeCommand, moveBlockCommand, outdentBlockCommand, splitTableCellCommand, unwrapBlocks, wrapBlocks, unwrapList, updateAtom, outdentList, } from "smartrte-core/foundation";
4
- import { SMART_LIST_PRESETS } from "smartrte-core";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
3
+ import { atomDeclarations, continueListNumbering, createList, createNodeId, deleteAtom, executeMarkTool, indentBlockCommand, indentList, inlineToolDeclarations, isTextNode, insertAtom, insertTableColumnCommand, insertTableCommand, insertTableRowCommand, mergeTableCellsCommand, moveListItems, moveTableColumnCommand, moveTableRowCommand, setTableCellAttributesCommand, setTableColumnWidthCommand, setTableRowHeightCommand, parseCanonicalListHtml, parseCanonicalListMarkdown, removeTableColumnCommand, removeTableCommand, removeTableRowCommand, resizeAtom, runAtomUpload, restartListNumbering, serializeCanonicalListHtml, serializeCanonicalListMarkdown, setListChecked, setListPreset, setListStyle, setTableHeaderCommand, setBlockAttributes, setBlockTypeCommand, moveBlockCommand, outdentBlockCommand, splitTableCellCommand, unwrapBlocks, wrapBlocks, unwrapList, updateAtom, outdentList, commentRangeFromSelection, rebaseCommentThreadsThroughTransaction, foundationSchema, resolvePos, suggestDeleteCommand, suggestInsertOperation, acceptSuggestionCommand, rejectSuggestionCommand, structuralSuggestionFromNode, acceptStructuralSuggestionCommand, rebaseStructuralSuggestionsThroughTransaction, } from "smartrte-core/foundation";
4
+ import { FOUNDATION_SMART_LIST_PRESETS as SMART_LIST_PRESETS } from "smartrte-core/foundation";
5
5
  import { ensureStyleSheet } from "../theme.js";
6
+ import { VersionHistoryPanel } from "./VersionHistoryPanel.js";
7
+ import { CommentMarkers } from "./CommentMarkers.js";
8
+ import { CommentThreadPanel } from "./CommentThreadPanel.js";
9
+ import { StructuralSuggestionMarkers } from "./StructuralSuggestionMarkers.js";
10
+ import { SuggestionPanel } from "./SuggestionPanel.js";
6
11
  import { DefaultMediaPicker } from "./MediaPicker.js";
7
- import { serializeSmartDocument, smartDocumentFromHtml } from "../adapters/domSmartDocument.js";
8
- import { exportDocxDocument } from "../adapters/docxFormat.js";
9
- import { importStyledDocxDocument } from "../adapters/styledDocxFormat.js";
10
- import { importPdfDocument, printSmartDocumentAsPdf } from "../adapters/pdfFormat.js";
12
+ import { MediaManager } from "./MediaManager.js";
13
+ import { mediaManagerAdapterFrom } from "../mediaManagerAdapter.js";
14
+ import { LinkEditorPopover } from "./LinkEditorPopover.js";
15
+ import { ColorPickerPopover } from "./ColorPickerPopover.js";
16
+ import { TableSizePickerPopover } from "./TableSizePickerPopover.js";
17
+ import { FormulaLibraryPopover } from "./FormulaLibraryPopover.js";
18
+ import { SpecialCharacterPopover } from "./SpecialCharacterPopover.js";
19
+ import { TableBorderPopover, BORDER_WIDTH_PRESETS } from "./TableBorderPopover.js";
20
+ import { TableResizeHandles } from "./TableResizeHandles.js";
21
+ import { MediaOverlay } from "./MediaOverlay.js";
22
+ import { ContextMenu } from "./ContextMenu.js";
23
+ import { ToolbarButton, ToolbarDropdown, ToolbarGroup, ToolbarMenuItem, MobileMoreMenu } from "./ToolbarPrimitives.js";
24
+ import { exportDocxDocument, importStyledDocxDocument, importPdfDocument } from "smartrte-core/foundation";
25
+ import { printSmartDocumentAsPdf } from "../adapters/pdfPrint.js";
11
26
  import { CanonicalEditorRuntime, } from "../canonicalEditorRuntime.js";
12
- const labels = {
13
- bold: "Bold", italic: "Italic", underline: "Underline", strike: "Strikethrough", strikethrough: "Strikethrough",
14
- inlineCode: "Inline code", superscript: "Superscript", subscript: "Subscript",
15
- textColor: "Text colour", backgroundColor: "Background colour",
16
- fontSize: "Font size", fontFamily: "Font family",
17
- };
18
27
  const listSelectionParts = (scope) => {
19
28
  if (scope.kind === "list-selection")
20
29
  return [scope];
@@ -50,15 +59,95 @@ const downloadBlob = (ownerDocument, name, blob) => {
50
59
  anchor.click();
51
60
  URL.revokeObjectURL(url);
52
61
  };
53
- export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEditor({ defaultValue, onChange, onHtmlChange, onClipboardDiagnostic, mediaProvider, mediaPicker: MediaPicker = DefaultMediaPicker, placeholder = "Type here…", minHeight = 200, maxHeight = 500, readOnly = false, className, onRuntime, }, forwardedRef) {
62
+ const CONTEXT_MENU_DANGER_COMMANDS = new Set(["table.remove", "table.removeRow", "table.removeColumn", "atom.delete"]);
63
+ /**
64
+ * Recently-used colors are bucketed by "text-like" vs "background-like"
65
+ * rather than by the 4 exact target shapes (mark.textColor, mark.
66
+ * backgroundColor, cell.textColor, cell.background) - a user picking "our
67
+ * brand navy" almost always means the same thing whether it's landing on a
68
+ * text selection or a table cell, and a combined-across-everything list
69
+ * would mix genuinely different intents (a text color next to a page
70
+ * background color) into one row. In-memory only, for the current editor
71
+ * instance's lifetime: this project has no existing pattern for persisting
72
+ * light UI preference state across sessions (no localStorage/sessionStorage
73
+ * usage anywhere in the codebase), and building new persistence
74
+ * infrastructure for this one feature would be disproportionate.
75
+ */
76
+ const RECENT_COLOR_LIMIT = 4;
77
+ const colorBucketFor = (target) => target.kind === "mark" ? (target.markId === "textColor" ? "text" : "background")
78
+ : target.attr === "textColor" ? "text" : "background";
79
+ /**
80
+ * table_cell's border attrs (schema.ts: legacy uniform `borders`, plus
81
+ * per-side `borderTop`/`borderRight`/`borderBottom`/`borderLeft` overrides
82
+ * added for the "Border options" popover's per-side control) are free-form
83
+ * CSS border shorthand strings, already rendered (surface/renderer.ts) and
84
+ * round-tripped through HTML/DOCX. These compose/parse the one shape this
85
+ * UI itself ever writes ("{width}px {style} {hex}") - a value pasted in some
86
+ * other shape still renders fine (the renderer applies the raw string
87
+ * unconditionally) but won't round-trip through these two helpers, which is
88
+ * fine: they exist only to seed/update this UI's own controls, not to be a
89
+ * general CSS border parser.
90
+ */
91
+ const composeBorderShorthand = (widthPx, style, hex) => `${widthPx}px ${style} ${hex}`;
92
+ const parseBorderShorthand = (value) => {
93
+ if (typeof value !== "string")
94
+ return null;
95
+ const width = /(\d+(?:\.\d+)?)\s*px/.exec(value);
96
+ const color = /#[0-9a-f]{3,8}\b/i.exec(value);
97
+ if (!width || !color)
98
+ return null;
99
+ const styleMatch = /\b(solid|dashed|dotted)\b/i.exec(value);
100
+ return { widthPx: Number(width[1]), style: styleMatch?.[1].toLowerCase() ?? "solid", hex: color[0].toLowerCase() };
101
+ };
102
+ const isCollapsedTextSelection = (selection) => selection.type === "text" && selection.anchor.offset === selection.head.offset
103
+ && selection.anchor.path.length === selection.head.path.length
104
+ && selection.anchor.path.every((part, index) => part === selection.head.path[index]);
105
+ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEditor({ defaultValue, preset, onChange, onHtmlChange, onClipboardDiagnostic, mediaProvider, mediaPicker: MediaPicker = DefaultMediaPicker, mediaManager = true, versionProvider, commentProvider, suggestionProvider, authorId, placeholder = "Type here…", minHeight = 200, maxHeight = 500, readOnly = false, className, onRuntime, }, forwardedRef) {
54
106
  ensureStyleSheet();
55
107
  const rootRef = useRef(null);
56
108
  const importRef = useRef(null);
57
109
  const runtimeRef = useRef();
58
110
  const pendingMediaUploads = useRef(new Set());
59
111
  const [mediaKind, setMediaKind] = useState(null);
112
+ const [versionHistoryOpen, setVersionHistoryOpen] = useState(false);
113
+ const [threads, setThreads] = useState([]);
114
+ const [commentPanelOpen, setCommentPanelOpen] = useState(false);
115
+ const [activeThreadId, setActiveThreadId] = useState(null);
116
+ const [pendingCommentRange, setPendingCommentRange] = useState(null);
117
+ const [busyThreadId, setBusyThreadId] = useState(null);
118
+ const beforeCommentDocumentRef = useRef(null);
119
+ const [structuralSuggestions, setStructuralSuggestions] = useState([]);
120
+ const [suggestionPanelOpen, setSuggestionPanelOpen] = useState(false);
121
+ const [activeSuggestionId, setActiveSuggestionId] = useState(null);
122
+ const [pendingSuggestInsertAt, setPendingSuggestInsertAt] = useState(null);
123
+ const [busySuggestionId, setBusySuggestionId] = useState(null);
124
+ const beforeSuggestionDocumentRef = useRef(null);
125
+ const [trackChangesEnabled, setTrackChangesEnabled] = useState(false);
126
+ const mediaManagerAdapter = useMemo(() => mediaProvider ? mediaManagerAdapterFrom(mediaProvider) : null, [mediaProvider]);
127
+ const [linkPopover, setLinkPopover] = useState(null);
128
+ const [colorPopover, setColorPopover] = useState(null);
129
+ const [recentColors, setRecentColors] = useState({ text: [], background: [], border: [] });
130
+ const [tableSizePopover, setTableSizePopover] = useState(null);
131
+ const [tableBorderPopover, setTableBorderPopover] = useState(null);
132
+ const borderPreviewCheckpointRef = useRef(null);
133
+ const [formulaLibraryPopover, setFormulaLibraryPopover] = useState(null);
134
+ const [specialCharPopover, setSpecialCharPopover] = useState(null);
135
+ const [recentSpecialChars, setRecentSpecialChars] = useState([]);
136
+ // Set once the native color input's first live-preview frame fires for
137
+ // the currently-open popover, cleared on commit/cancel - lets both paths
138
+ // tell "a preview is in progress that needs to be unwound" from "the user
139
+ // never touched the native input at all" (the latter needs no unwinding,
140
+ // since nothing was ever applied to the model).
141
+ const colorPreviewCheckpointRef = useRef(null);
142
+ const [contextMenu, setContextMenu] = useState(null);
143
+ // Tracks the exact caret position (path+offset) the link overlay was
144
+ // last dismissed at (Escape/outside click) - suppresses an instant
145
+ // re-popup while the caret hasn't moved, without needing a separate
146
+ // "is this the same link" identity check. Cleared implicitly the moment
147
+ // selection.head differs from this value.
148
+ const [linkOverlayDismissedAt, setLinkOverlayDismissedAt] = useState(null);
60
149
  if (!runtimeRef.current)
61
- runtimeRef.current = new CanonicalEditorRuntime({ initialValue: defaultValue, onChange, onHtmlChange, onClipboardDiagnostic });
150
+ runtimeRef.current = new CanonicalEditorRuntime({ initialValue: defaultValue, preset, onChange, onHtmlChange, onClipboardDiagnostic });
62
151
  const runtime = runtimeRef.current;
63
152
  const [, setEditorTick] = useState(0);
64
153
  runtime.setCallbacks(onChange, onHtmlChange);
@@ -76,6 +165,103 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
76
165
  };
77
166
  }, [runtime, onRuntime]);
78
167
  useEffect(() => runtime.editor.subscribe(() => setEditorTick((value) => value + 1)), [runtime]);
168
+ useEffect(() => {
169
+ if (!commentProvider) {
170
+ setThreads([]);
171
+ return;
172
+ }
173
+ let cancelled = false;
174
+ (async () => {
175
+ try {
176
+ const loaded = await commentProvider.list();
177
+ if (!cancelled)
178
+ setThreads([...loaded]);
179
+ }
180
+ catch {
181
+ // A host's initial load failing just means threads/markers start
182
+ // empty - the same degraded-but-not-broken behaviour as when no
183
+ // commentProvider is supplied at all.
184
+ }
185
+ })();
186
+ return () => { cancelled = true; };
187
+ }, [commentProvider]);
188
+ // Replays each committed transaction's operations against the document
189
+ // as it existed immediately before that transaction (tracked in this ref
190
+ // across commits, exactly the `beforeDocument` rebaseCommentThreadsThroughTransaction
191
+ // needs) so thread ranges stay anchored through structural edits.
192
+ // Reference-identity is preserved for every thread the transaction didn't
193
+ // actually touch (see annotations/range.ts's rebaseThroughMergedRemove),
194
+ // so only genuinely-changed threads get persisted back to commentProvider.
195
+ useEffect(() => {
196
+ beforeCommentDocumentRef.current = runtime.editor.document;
197
+ return runtime.editor.subscribe((transaction, state) => {
198
+ const before = beforeCommentDocumentRef.current;
199
+ beforeCommentDocumentRef.current = state.document;
200
+ if (!before)
201
+ return;
202
+ setThreads((current) => {
203
+ if (!current.length)
204
+ return current;
205
+ const rebased = rebaseCommentThreadsThroughTransaction(current, before, transaction, foundationSchema);
206
+ if (rebased === current)
207
+ return current;
208
+ if (commentProvider) {
209
+ rebased.forEach((thread, index) => {
210
+ if (thread !== current[index])
211
+ void commentProvider.save(thread).catch(() => { });
212
+ });
213
+ }
214
+ return [...rebased];
215
+ });
216
+ });
217
+ }, [runtime, commentProvider]);
218
+ useEffect(() => {
219
+ if (!suggestionProvider) {
220
+ setStructuralSuggestions([]);
221
+ return;
222
+ }
223
+ let cancelled = false;
224
+ (async () => {
225
+ try {
226
+ const loaded = await suggestionProvider.list();
227
+ if (!cancelled)
228
+ setStructuralSuggestions([...loaded]);
229
+ }
230
+ catch {
231
+ // Degrades the same way an empty/absent provider does - see the
232
+ // matching comment-load effect above.
233
+ }
234
+ })();
235
+ return () => { cancelled = true; };
236
+ }, [suggestionProvider]);
237
+ // Mirrors the comment-rebase effect above exactly (same reasoning, same
238
+ // reference-identity-preserving persistence), kept as an independent
239
+ // effect/ref rather than merged into it, matching how the core layer
240
+ // keeps comments/rebase.ts and suggestions/structural.ts as separate,
241
+ // structurally-parallel modules instead of sharing code.
242
+ useEffect(() => {
243
+ beforeSuggestionDocumentRef.current = runtime.editor.document;
244
+ return runtime.editor.subscribe((transaction, state) => {
245
+ const before = beforeSuggestionDocumentRef.current;
246
+ beforeSuggestionDocumentRef.current = state.document;
247
+ if (!before)
248
+ return;
249
+ setStructuralSuggestions((current) => {
250
+ if (!current.length)
251
+ return current;
252
+ const rebased = rebaseStructuralSuggestionsThroughTransaction(current, before, transaction, foundationSchema);
253
+ if (rebased === current)
254
+ return current;
255
+ if (suggestionProvider) {
256
+ rebased.forEach((suggestion, index) => {
257
+ if (suggestion !== current[index])
258
+ void suggestionProvider.save(suggestion).catch(() => { });
259
+ });
260
+ }
261
+ return [...rebased];
262
+ });
263
+ });
264
+ }, [runtime, suggestionProvider]);
79
265
  const transactBlock = (operations) => {
80
266
  runtime.executeOperations(operations);
81
267
  };
@@ -124,17 +310,111 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
124
310
  const canMoveUp = currentListStates.some(({ indexes }) => indexes.length > 0 && Math.min(...indexes) > 0);
125
311
  const canMoveDown = currentListStates.some(({ indexes, list }) => indexes.length > 0
126
312
  && Math.max(...indexes) < (list?.children?.length || 0) - 1);
313
+ // Whether the "table" plugin was included when this instance was
314
+ // constructed (see capabilityPresets.ts's "simple" preset) - the toolbar
315
+ // JSX is hand-authored, not driven by the plugin registry's own toolbar
316
+ // contributions the way the context menu already is, so this needs an
317
+ // explicit check to hide table-insertion UI for a schema that can't
318
+ // represent tables at all. Existing content containing a table, loaded
319
+ // under a schema without the table plugin, still round-trips safely as
320
+ // an `unknown` node (Phase 10's disable-safety contract, unchanged) -
321
+ // this only hides the *toolbar affordance* for creating new ones.
322
+ const tablesEnabled = Boolean(runtime.editor.schema.nodes.table);
127
323
  const tableSelected = currentTableScope.kind === "table-grid";
324
+ const selectedTableElement = tableSelected
325
+ ? runtime.surface.renderer?.mapping.nodeToDom(currentTableScope.tableId)
326
+ : undefined;
128
327
  const atomSelected = currentAtomScope.kind === "atomic-node";
328
+ const selectedAtomElement = atomSelected
329
+ ? runtime.surface.renderer?.mapping.nodeToDom(currentAtomScope.nodeId)
330
+ : undefined;
331
+ const selectedAtomNode = atomSelected ? findNode(runtime.editor.document, currentAtomScope.nodeId) : null;
332
+ // A divider (<hr>) is atomic/selectable (so ordinary caret/Backspace/
333
+ // Delete behavior around it works, and the toolbar's "Delete selected
334
+ // atom" button applies to it), but it has no src/alt/width/height at
335
+ // all - MediaOverlay and the media-specific Edit/Resize actions assume
336
+ // every atom is a real media item, and unconditionally showing them for
337
+ // a divider surfaced nonsensical empty/undefined values with no
338
+ // sensible edit or resize target. Media-only UI is gated on this
339
+ // instead of plain atomSelected; delete stays available for any atom.
340
+ const mediaAtomSelected = atomSelected && selectedAtomNode?.type !== "divider";
341
+ // Formula (like divider above) has no width/height concept - KaTeX sizes
342
+ // its own rendering from the source/font-size, not stored dimensions, so
343
+ // "Enlarge/Shrink selected media" and MediaOverlay's resize handle had no
344
+ // real effect other than silently persisting meaningless width/height
345
+ // attrs onto the formula node (see
346
+ // docs/bugs/formula-resize-controls-shown-for-non-resizable-atom.md).
347
+ // Edit/Delete stay available - editSelectedAtom already branches on
348
+ // node.type.includes("formula") for its own correct "Formula source"
349
+ // prompt.
350
+ const resizableAtomSelected = mediaAtomSelected && selectedAtomNode?.type !== "formula" && selectedAtomNode?.type !== "block_formula";
351
+ // Context menu scope reduction: link no longer gets a right-click menu -
352
+ // LinkEditorPopover (already built and wired for the toolbar's Link
353
+ // button, per §1's investigation) auto-appears, anchored to the link's
354
+ // own DOM bounds, whenever the caret is inside an existing link -
355
+ // exactly the same description.marks lookup the toolbar button and the
356
+ // old context-menu items already used to detect "is there a link here."
357
+ const linkDescription = runtime.editor.resolveScope({ want: "describe" });
358
+ const currentLinkEntry = linkDescription.marks?.find((entry) => entry.mark.type === "link");
359
+ const selectionHead = runtime.editor.selection.head;
360
+ const dismissedAtCurrentPosition = Boolean(linkOverlayDismissedAt)
361
+ && linkOverlayDismissedAt.offset === selectionHead.offset
362
+ && linkOverlayDismissedAt.path.length === selectionHead.path.length
363
+ && linkOverlayDismissedAt.path.every((part, index) => part === selectionHead.path[index]);
364
+ const linkAnchorElement = currentLinkEntry && !linkPopover && !dismissedAtCurrentPosition
365
+ ? (() => {
366
+ const domPos = runtime.surface.renderer?.mapping.posToDom(selectionHead);
367
+ const node = domPos?.node;
368
+ const element = (node instanceof Element ? node : node?.parentElement) ?? null;
369
+ return element?.closest("a[href]") ?? null;
370
+ })()
371
+ : null;
372
+ useEffect(() => {
373
+ if (!linkAnchorElement || !currentLinkEntry)
374
+ return;
375
+ const rect = linkAnchorElement.getBoundingClientRect();
376
+ setLinkPopover({
377
+ x: rect.left, y: rect.bottom + 6,
378
+ editingExisting: true,
379
+ href: typeof currentLinkEntry.mark.attrs?.href === "string" ? currentLinkEntry.mark.attrs.href : "",
380
+ openInNewTab: currentLinkEntry.mark.attrs?.target === "_blank",
381
+ collapsed: linkDescription.collapsed,
382
+ autoTriggered: true,
383
+ });
384
+ // Only the DOM element identity matters here - re-running for every
385
+ // keystroke re-render while the caret stays inside the same link (same
386
+ // element, new object each render otherwise) would fight the user
387
+ // editing the popover's own fields.
388
+ // eslint-disable-next-line react-hooks/exhaustive-deps
389
+ }, [linkAnchorElement]);
390
+ const hasLinkAtCursor = Boolean(currentLinkEntry);
391
+ useEffect(() => {
392
+ // Dismiss when the selection moves off the link the popover was opened
393
+ // for - covers arrow-key navigation and any other selection change
394
+ // that isn't itself a popover action (apply/remove/cancel already
395
+ // clear linkPopover directly).
396
+ if (linkPopover?.editingExisting && !hasLinkAtCursor)
397
+ setLinkPopover(null);
398
+ // eslint-disable-next-line react-hooks/exhaustive-deps
399
+ }, [hasLinkAtCursor]);
129
400
  const blockTypeAt = (position) => {
130
401
  let node = runtime.editor.document;
131
402
  for (let depth = 0; depth <= position.path.length; depth += 1) {
132
403
  if (!isTextNode(node) && node.type !== "doc" && runtime.editor.schema.nodes[node.type]?.group === "block") {
404
+ // paragraph/heading/code_block are this dropdown's own options -
405
+ // answer immediately. Everything else block-group is either a
406
+ // container that holds further blocks (blockquote, list, list_item,
407
+ // table/table_row/table_cell) - keep walking the path to find the
408
+ // actual innermost block instead of answering for the wrapper - or
409
+ // an atomic node with no dropdown answer of its own, which falls
410
+ // through to the loop's own "paragraph" default below once it runs
411
+ // out of path to follow.
133
412
  if (node.type === "code_block")
134
413
  return "code_block";
135
414
  if (node.type === "heading")
136
415
  return `heading-${Number(node.attrs?.level || 1)}`;
137
- return "paragraph";
416
+ if (node.type === "paragraph")
417
+ return "paragraph";
138
418
  }
139
419
  if (depth === position.path.length || isTextNode(node))
140
420
  break;
@@ -166,19 +446,40 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
166
446
  return;
167
447
  runtime.executeOperations(restartListNumbering(runtime.editor.document, currentListScope, { start }, blockContext()), { preserveSelectionById: true });
168
448
  };
449
+ // A list's "current style" the toggle buttons care about (bullet vs.
450
+ // ordered) can come from either a literal .attrs.style (disc/decimal, what
451
+ // these buttons themselves write) *or* a .attrs.preset (bullet-diamond,
452
+ // ordered-upper-alpha, etc. - the preset dropdown clears .attrs.style when
453
+ // setting one, see docs/bugs/list-marker-competing-style-and-preset-signals.md).
454
+ // Comparing raw strings only ever matched the literal case - after picking
455
+ // a preset, every toggle button read as "not active" (style is unset) even
456
+ // though the list still visually is a bullet/ordered list, so the first
457
+ // click on the matching button silently replaced the preset with a bare
458
+ // disc/decimal instead of removing the list, and only a *second* click
459
+ // actually toggled it off. Comparing by kind (bullet/ordered) instead
460
+ // fixes both the toolbar's own pressed-state display and the toggle
461
+ // decision in one place, so they can't drift apart from each other again.
462
+ const BULLET_LIST_STYLES = new Set(["disc", "circle", "square"]);
463
+ const listActiveKind = (list) => {
464
+ if (typeof list?.attrs?.style === "string")
465
+ return BULLET_LIST_STYLES.has(list.attrs.style) ? "bullet" : "ordered";
466
+ if (typeof list?.attrs?.preset === "string")
467
+ return SMART_LIST_PRESETS.find((preset) => preset.id === list.attrs?.preset)?.kind;
468
+ return undefined;
469
+ };
169
470
  // Shared by the toggle buttons' click handler and their aria-pressed state,
170
471
  // so "this button looks active" and "clicking it again removes the list"
171
472
  // can never drift apart. Checked against the outermost list (see
172
473
  // outermostListId) so a deeply nested cursor still reports the true
173
474
  // whole-list state, matching what applying a new type would change.
174
- const listStyleActive = (style, checkable = false) => rootList?.attrs?.style === style && Boolean(rootList.attrs?.checkable) === checkable;
475
+ const listStyleActive = (style, checkable = false) => listActiveKind(rootList) === (style === "decimal" ? "ordered" : "bullet") && Boolean(rootList?.attrs?.checkable) === checkable;
175
476
  const toggleList = (style, checkable = false) => {
176
477
  const selectedList = listScope();
177
478
  const context = blockContext();
178
479
  if (selectedList.kind === "list-selection") {
179
480
  const rootId = outermostListId(selectedList.listId);
180
481
  const list = findNode(runtime.editor.document, rootId);
181
- const sameStyle = list?.attrs?.style === style && Boolean(list.attrs?.checkable) === checkable;
482
+ const sameStyle = listActiveKind(list) === (style === "decimal" ? "ordered" : "bullet") && Boolean(list?.attrs?.checkable) === checkable;
182
483
  const operations = sameStyle
183
484
  // Toggling an already-active style off is deliberately scoped to just
184
485
  // the current item (selectedList), not the whole list.
@@ -206,37 +507,191 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
206
507
  : moveListItems(runtime.editor.document, scope, { direction: action }, context);
207
508
  runtime.executeOperations(operations, { preserveSelectionById: true });
208
509
  };
209
- const applyAttributedMark = (id) => {
510
+ /** Shared by the prompt-based fontSize/fontFamily path and the popover-based color path below. */
511
+ const applyMarkAttrs = (id, attrs, options = {}) => {
210
512
  const declaration = inlineToolDeclarations.find((tool) => tool.id === id);
211
- if (!declaration)
513
+ if (!declaration || !attrs)
212
514
  return;
213
- const attrs = id === "textColor" || id === "backgroundColor"
515
+ try {
516
+ executeMarkTool(runtime.editor, declaration, "apply", attrs, options);
517
+ // Skipped during a live preview (addToHistory: false) - see the
518
+ // matching comment in canonicalEditorRuntime.ts's executeOperations,
519
+ // same reasoning: focusing the main editor mid-drag would steal focus
520
+ // away from the color popover's own native input.
521
+ if (options.addToHistory ?? true)
522
+ runtime.focus();
523
+ }
524
+ catch (error) {
525
+ window.alert(error instanceof Error ? error.message : "Invalid formatting value.");
526
+ }
527
+ };
528
+ const applyAttributedMark = (id) => {
529
+ const attrs = id === "fontSize"
214
530
  ? (() => {
215
- const value = window.prompt(id === "textColor" ? "Text colour" : "Background colour", "#000000");
216
- return value ? { value } : undefined;
531
+ const value = window.prompt("Font size (px)", "16");
532
+ const valuePx = value === null ? undefined : Number(value);
533
+ return Number.isFinite(valuePx) ? { valuePx } : undefined;
217
534
  })()
218
- : id === "fontSize"
535
+ : id === "fontFamily"
219
536
  ? (() => {
220
- const value = window.prompt("Font size (px)", "16");
221
- const valuePx = value === null ? undefined : Number(value);
222
- return Number.isFinite(valuePx) ? { valuePx } : undefined;
537
+ const value = window.prompt("Font family", "system-ui");
538
+ return value ? { value } : undefined;
223
539
  })()
224
- : id === "fontFamily"
225
- ? (() => {
226
- const value = window.prompt("Font family", "system-ui");
227
- return value ? { value } : undefined;
228
- })()
229
- : undefined;
230
- if ((id === "textColor" || id === "backgroundColor" || id === "fontSize" || id === "fontFamily") && !attrs)
540
+ : undefined;
541
+ applyMarkAttrs(id, attrs);
542
+ };
543
+ /**
544
+ * Phase 11.5 §2.4: ColorPickerPopover (swatch grid + hex input) replaces
545
+ * window.prompt("#000000") for textColor/backgroundColor. Same
546
+ * applyMarkAttrs call as before - no command-layer change. Post-batch:
547
+ * the same popover also drives table_cell.attrs.background/textColor
548
+ * ("cell style" the table context menu previously had none of) via
549
+ * table.setCellAttributes instead of a mark - a different command, not
550
+ * a different UI.
551
+ */
552
+ /**
553
+ * The color picker previously always opened blank ("#000000") regardless
554
+ * of the caret's or cell's actual current color - these compute what to
555
+ * seed it with, read at the moment the popover opens (not re-derived
556
+ * while it stays open, since the underlying selection/cell isn't
557
+ * expected to change out from under an open picker).
558
+ */
559
+ const currentMarkColor = (markId) => {
560
+ const description = runtime.editor.resolveScope({ want: "describe" });
561
+ const value = description.marks.find((entry) => entry.mark.type === markId)?.mark.attrs?.value;
562
+ return typeof value === "string" ? value : undefined;
563
+ };
564
+ const currentCellColor = (attr) => {
565
+ const scope = tableScope();
566
+ if (!("kind" in scope) || scope.kind !== "table-grid" || !scope.cellIds.length)
567
+ return undefined;
568
+ const cell = findNode(runtime.editor.document, scope.cellIds[0]);
569
+ const value = cell?.attrs?.[attr];
570
+ return typeof value === "string" ? value : undefined;
571
+ };
572
+ const CELL_BORDER_SIDE_KEYS = { top: "borderTop", right: "borderRight", bottom: "borderBottom", left: "borderLeft" };
573
+ /** Reads the cell's *effective* per-side border (a side's own override, falling back to the legacy uniform `borders` value) to seed the Border options popover. */
574
+ const currentCellBorderDraft = (scope) => {
575
+ const cell = scope.kind === "table-grid" && scope.cellIds.length
576
+ ? findNode(runtime.editor.document, scope.cellIds[0]) : null;
577
+ const resolvedSide = (side) => cell?.attrs?.[CELL_BORDER_SIDE_KEYS[side]] ?? cell?.attrs?.borders;
578
+ const sides = { top: resolvedSide("top") !== undefined, right: resolvedSide("right") !== undefined, bottom: resolvedSide("bottom") !== undefined, left: resolvedSide("left") !== undefined };
579
+ const firstSetValue = ["top", "right", "bottom", "left"].map(resolvedSide).find((value) => value !== undefined);
580
+ const parsed = parseBorderShorthand(firstSetValue);
581
+ return { sides, style: parsed?.style ?? "solid", widthPx: parsed?.widthPx ?? BORDER_WIDTH_PRESETS[0].px, hex: parsed?.hex ?? "#000000" };
582
+ };
583
+ /** Applies a Border options draft to `scope`'s cell(s): a toggled-on side gets the composed shorthand, a toggled-off side has its per-side override cleared (falling back to no border on that side, unless the legacy uniform `borders` still has a value - a pre-existing pasted uniform border isn't silently touched by only clearing a side that never had its own override). */
584
+ const applyCellBorderDraft = (scope, draft, options) => {
585
+ const value = composeBorderShorthand(draft.widthPx, draft.style, draft.hex);
586
+ const attrs = ["top", "right", "bottom", "left"].reduce((acc, side) => {
587
+ acc[CELL_BORDER_SIDE_KEYS[side]] = draft.sides[side] ? value : undefined;
588
+ return acc;
589
+ }, {});
590
+ runtime.executeOperations(setTableCellAttributesCommand(runtime.editor.document, scope, { attrs }, blockContext()), { preserveSelectionById: true, ...options });
591
+ };
592
+ /** Live preview while adjusting the Border options popover - same checkpoint-then-reapply pattern as previewColor below. */
593
+ const previewCellBorder = (draft) => {
594
+ if (!tableBorderPopover)
231
595
  return;
232
- try {
233
- executeMarkTool(runtime.editor, declaration, "apply", attrs);
234
- runtime.focus();
596
+ if (!borderPreviewCheckpointRef.current)
597
+ borderPreviewCheckpointRef.current = runtime.createCheckpoint();
598
+ runtime.restoreCheckpoint(borderPreviewCheckpointRef.current);
599
+ applyCellBorderDraft(tableBorderPopover.scope, draft, { addToHistory: false });
600
+ };
601
+ const applyCellBorderCommit = (draft) => {
602
+ if (!tableBorderPopover)
603
+ return;
604
+ if (borderPreviewCheckpointRef.current) {
605
+ runtime.restoreCheckpoint(borderPreviewCheckpointRef.current);
606
+ borderPreviewCheckpointRef.current = null;
235
607
  }
236
- catch (error) {
237
- window.alert(error instanceof Error ? error.message : "Invalid formatting value.");
608
+ applyCellBorderDraft(tableBorderPopover.scope, draft, { addToHistory: true });
609
+ recordRecentColor("border", draft.hex);
610
+ setTableBorderPopover(null);
611
+ runtime.focus();
612
+ };
613
+ const cancelTableBorderPopover = () => {
614
+ if (borderPreviewCheckpointRef.current) {
615
+ runtime.restoreCheckpoint(borderPreviewCheckpointRef.current);
616
+ borderPreviewCheckpointRef.current = null;
617
+ }
618
+ setTableBorderPopover(null);
619
+ runtime.focus();
620
+ };
621
+ const recordRecentColor = (bucket, hex) => {
622
+ setRecentColors((current) => ({
623
+ ...current,
624
+ [bucket]: [hex, ...current[bucket].filter((existing) => existing.toLowerCase() !== hex.toLowerCase())].slice(0, RECENT_COLOR_LIMIT),
625
+ }));
626
+ };
627
+ /** Applies `hex` to whatever colorPopover.target currently points at - shared by both the live-preview path and the real commit path below, differing only in `addToHistory`. */
628
+ const applyColorToTarget = (hex, options) => {
629
+ if (!colorPopover)
630
+ return;
631
+ if (colorPopover.target.kind === "mark") {
632
+ applyMarkAttrs(colorPopover.target.markId, { value: hex }, options);
633
+ }
634
+ else {
635
+ // Uses the scope captured when the popover opened, not a fresh
636
+ // tableScope() re-resolved against the live selection - see the
637
+ // colorPopover state's own doc comment for why.
638
+ runtime.executeOperations(setTableCellAttributesCommand(runtime.editor.document, colorPopover.target.scope, { attrs: { [colorPopover.target.attr]: hex } }, blockContext()), { preserveSelectionById: true, ...options });
238
639
  }
239
640
  };
641
+ /**
642
+ * Live preview while dragging the in-page saturation/hue picker (or typing
643
+ * a valid hex, or clicking a recent swatch), mirroring TableResizeHandles'
644
+ * own live-preview-then-commit-once pattern: every drag frame re-applies
645
+ * the color for real (so a multi-node mark selection, not just a single
646
+ * element's style, previews correctly), but always starting from a
647
+ * checkpoint of the state from *before* any preview began, and always with
648
+ * `addToHistory: false` - so however many drag frames fire, none of them
649
+ * become their own undo step, and each one fully supersedes the last
650
+ * rather than compounding on top of it. The checkpoint is created lazily,
651
+ * on the first preview frame, so a picker session that's opened and
652
+ * closed without ever touching anything never creates or restores
653
+ * anything extra.
654
+ */
655
+ const previewColor = (hex) => {
656
+ if (!colorPopover)
657
+ return;
658
+ if (!colorPreviewCheckpointRef.current)
659
+ colorPreviewCheckpointRef.current = runtime.createCheckpoint();
660
+ runtime.restoreCheckpoint(colorPreviewCheckpointRef.current);
661
+ applyColorToTarget(hex, { addToHistory: false });
662
+ };
663
+ /**
664
+ * There is no separate Apply button (ColorPickerPopover's own doc comment)
665
+ * - this fires when the popover is dismissed any way *other than* Discard,
666
+ * committing whatever was last staged by a drag/type/swatch-click.
667
+ */
668
+ const applyColor = (hex) => {
669
+ if (!colorPopover)
670
+ return;
671
+ // Undo whatever the live preview left in the (non-history) model state
672
+ // before making the real, history-eligible change - re-applying a
673
+ // setNodeAttributes-style operation on top of an already-previewed
674
+ // value would compute a before/after diff against the *previewed*
675
+ // state, not the true original, which would either no-op or corrupt
676
+ // the resulting undo step depending on the target.
677
+ if (colorPreviewCheckpointRef.current) {
678
+ runtime.restoreCheckpoint(colorPreviewCheckpointRef.current);
679
+ colorPreviewCheckpointRef.current = null;
680
+ }
681
+ applyColorToTarget(hex, { addToHistory: true });
682
+ recordRecentColor(colorBucketFor(colorPopover.target), hex);
683
+ setColorPopover(null);
684
+ runtime.focus();
685
+ };
686
+ /** Discard - the only way to revert to the color the popover opened with; every other dismissal commits (see applyColor). */
687
+ const cancelColorPopover = () => {
688
+ if (colorPreviewCheckpointRef.current) {
689
+ runtime.restoreCheckpoint(colorPreviewCheckpointRef.current);
690
+ colorPreviewCheckpointRef.current = null;
691
+ }
692
+ setColorPopover(null);
693
+ runtime.focus();
694
+ };
240
695
  const toggleBlockquote = () => {
241
696
  const scope = blockScope();
242
697
  const resolved = runtime.editor.resolve({ pos: runtime.editor.selection.head });
@@ -271,14 +726,14 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
271
726
  : outdentBlockCommand(runtime.editor.document, scope, {}, context);
272
727
  runtime.executeOperations(operations, { preserveSelectionById: true });
273
728
  };
274
- const insertTable = () => {
275
- const paragraphIds = ids(4);
729
+ const insertTable = (rows = 2, columns = 2) => {
730
+ const paragraphIds = ids(rows * columns);
276
731
  const selected = blockScope();
277
732
  const firstId = selected.kind === "block-range" ? selected.blockIds[0] : undefined;
278
733
  const target = firstId ? runtime.editor.positions.positionOf(firstId) : null;
279
734
  const operations = insertTableCommand(runtime.editor.document, selected, {
280
- rows: 2, columns: 2, placement: "after",
281
- ids: { tableId: createNodeId(), rowIds: ids(2), cellIds: ids(4), paragraphIds },
735
+ rows, columns, placement: "after",
736
+ ids: { tableId: createNodeId(), rowIds: ids(rows), cellIds: ids(rows * columns), paragraphIds },
282
737
  }, blockContext());
283
738
  // Keep an editable block after a table inserted at the end of its
284
739
  // container. Without this, the browser has no legal caret position below
@@ -308,16 +763,157 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
308
763
  : moveTableColumnCommand(runtime.editor.document, scope, { direction: action === "column-left" ? "left" : "right" }, context);
309
764
  runtime.executeOperations(operations, { preserveSelectionById: true });
310
765
  };
311
- const insertInlineFormula = () => {
312
- const source = window.prompt("Formula (LaTeX)", "E=mc^2");
313
- if (!source)
314
- return;
315
- const declaration = atomDeclarations.find((entry) => entry.type === "formula");
766
+ /**
767
+ * Phase 11.5 §2.2: drag-handle resize, calling the existing
768
+ * setTableColumnWidthCommand/setTableRowHeightCommand - "Move row up/
769
+ * down"/"Move column left/right" (runTable above) already had toolbar
770
+ * buttons before this phase; resize had no UI at all. Uses the
771
+ * currently-selected table's scope, not the boundary's own position,
772
+ * since a resize handle can be dragged from anywhere in the table
773
+ * without first re-selecting a specific cell.
774
+ */
775
+ const resizeTableColumn = (index, width, currentWidths) => {
776
+ runtime.executeOperations(setTableColumnWidthCommand(runtime.editor.document, tableScope(), { index, width, widths: currentWidths }, blockContext()), { preserveSelectionById: true });
777
+ };
778
+ // Post-batch follow-up ("resizing a column/row disturbs the whole
779
+ // table") - dragging an *internal* boundary now redistributes between
780
+ // the two adjacent rows/columns (the border just moves, total table
781
+ // size is unchanged) instead of only ever growing/shrinking the table's
782
+ // own overall size; only the last row/column's own outer edge still
783
+ // changes the table's total height/width. TableResizeHandles.tsx computes
784
+ // both new sizes (clamped so neither side goes below its minimum) and
785
+ // passes the second one here as `adjacent` - both commit as one
786
+ // operation batch, one undo step.
787
+ const resizeTableRow = (index, height, adjacent) => {
788
+ const context = blockContext();
789
+ const operations = [
790
+ ...setTableRowHeightCommand(runtime.editor.document, tableScope(), { index, height }, context),
791
+ ...(adjacent ? setTableRowHeightCommand(runtime.editor.document, tableScope(), { index: adjacent.index, height: adjacent.height }, context) : []),
792
+ ];
793
+ runtime.executeOperations(operations, { preserveSelectionById: true });
794
+ };
795
+ /**
796
+ * Phase 11.5 §2.3: mirrors surface/input.ts's Tab dispatch
797
+ * (resolveShortcut) for a different contribution kind - try each
798
+ * registered contextMenu contribution's declared scopeKinds[0], keep it
799
+ * only if that scope actually resolves at the current selection. Unlike
800
+ * Tab this collects every match (a menu, not a single key), and unlike
801
+ * keyboardShortcuts, table.insertRow/insertColumn need caller-generated
802
+ * ids a static contribution can't carry - generated here per invocation,
803
+ * the same way Tab special-cases list.indent/outdent's dynamic params.
804
+ */
805
+ /**
806
+ * Context menu scope reduction: table-only now. Marks (already on the
807
+ * toolbar) and atom/link (moved to their own dedicated overlays -
808
+ * MediaOverlay below, and the auto-triggered LinkEditorPopover - see
809
+ * linkAnchorElement) no longer register contextMenu contributions at
810
+ * all (marks/plugin.ts, atom/plugin.ts) or get hardcoded item blocks
811
+ * here - only table plugin contributions (the generic loop) and the
812
+ * cell-colour items remain.
813
+ */
814
+ const resolveContextMenuItems = () => {
815
+ const many = () => ids(128);
816
+ const items = [];
817
+ runtime.editor.contextMenu.forEach((contribution) => {
818
+ const wanted = contribution.scopeKinds[0];
819
+ if (!wanted || wanted === "mixed" || wanted === "empty")
820
+ return;
821
+ const scope = runtime.editor.resolveScope({ want: wanted });
822
+ if (!("kind" in scope) || scope.kind !== wanted)
823
+ return;
824
+ const command = runtime.editor.commands.get(contribution.commandId);
825
+ if (!command)
826
+ return;
827
+ const position = contribution.params?.position;
828
+ // Commands like removeTableRowCommand read params.rowIndex ??
829
+ // <fallback> - a genuinely undefined params object throws on that
830
+ // property access (contribution.params is undefined for any static
831
+ // contribution with no params field, e.g. removeRow/removeColumn/
832
+ // mergeCells/removeTable), where the toolbar's equivalent buttons
833
+ // already pass {} for exactly this reason. Matching that here was
834
+ // the missing piece - this loop previously passed contribution.params
835
+ // straight through.
836
+ const params = contribution.commandId === "table.insertRow"
837
+ ? { position, rowId: createNodeId(), cellIds: many(), paragraphIds: many() }
838
+ : contribution.commandId === "table.insertColumn"
839
+ ? { position, cellIds: many(), paragraphIds: many() }
840
+ : contribution.params || {};
841
+ items.push({
842
+ id: contribution.id,
843
+ label: contribution.label,
844
+ danger: CONTEXT_MENU_DANGER_COMMANDS.has(contribution.commandId),
845
+ onSelect: () => {
846
+ runtime.executeOperations(command.run(runtime.editor.document, scope, params, blockContext()), { preserveSelectionById: true });
847
+ },
848
+ });
849
+ });
850
+ // "Cell style etc" - table_cell.attrs.background/textColor already
851
+ // existed (settable via table.setCellAttributes; background was
852
+ // rendered, textColor wasn't until this pass - see surface/
853
+ // renderer.ts) but the table context menu had no UI for either. Same
854
+ // ColorPickerPopover as the toolbar's text/background colour buttons,
855
+ // routed to applyColor's "cell" branch instead of a mark.
856
+ const tableGridScope = runtime.editor.resolveScope({ want: "table-grid" });
857
+ if ("kind" in tableGridScope && tableGridScope.kind === "table-grid") {
858
+ const openX = contextMenu?.x ?? 0;
859
+ const openY = contextMenu?.y ?? 0;
860
+ items.push({
861
+ id: "table.contextMenu.cellBackgroundColor",
862
+ label: "Cell background colour",
863
+ onSelect: () => setColorPopover({ x: openX, y: openY, target: { kind: "cell", attr: "background", scope: tableGridScope }, initialValue: currentCellColor("background") }),
864
+ }, {
865
+ id: "table.contextMenu.cellTextColor",
866
+ label: "Cell text colour",
867
+ onSelect: () => setColorPopover({ x: openX, y: openY, target: { kind: "cell", attr: "textColor", scope: tableGridScope }, initialValue: currentCellColor("textColor") }),
868
+ }, {
869
+ id: "table.contextMenu.cellBorderOptions",
870
+ label: "Cell border options",
871
+ onSelect: () => setTableBorderPopover({ x: openX, y: openY, scope: tableGridScope, initial: currentCellBorderDraft(tableGridScope) }),
872
+ });
873
+ }
874
+ return items;
875
+ };
876
+ /**
877
+ * Inserts a formula-library entry (see formulaLibrary.ts), then
878
+ * auto-selects the new atom and opens the existing edit-formula
879
+ * interaction immediately - most real use needs customization (different
880
+ * variable names, specific values), so landing the user straight into an
881
+ * editable state beats a silent insert-and-walk-away. Reuses the atom
882
+ * "node" selection pattern already used elsewhere (e.g. the context
883
+ * menu's own atom-selection logic) rather than inventing a new one.
884
+ */
885
+ const insertFormulaFromLibrary = (entry) => {
886
+ setFormulaLibraryPopover(null);
887
+ const declaration = atomDeclarations.find((atom) => atom.type === "formula");
316
888
  const resolved = runtime.editor.resolve({ pos: runtime.editor.selection.head });
889
+ const formulaAtomId = createNodeId();
317
890
  runtime.executeOperations(insertAtom(runtime.editor.document, atomScope(), {
318
- declaration, nodeId: createNodeId(), ownerId: resolved.nodeId, offset: runtime.editor.selection.head.offset,
319
- attrs: { source, notation: "latex" },
891
+ declaration, nodeId: formulaAtomId, ownerId: resolved.nodeId, offset: runtime.editor.selection.head.offset,
892
+ attrs: { source: entry.latex, notation: "latex" },
320
893
  }, blockContext()));
894
+ const range = runtime.editor.positions.rangeOf(formulaAtomId);
895
+ if (range) {
896
+ runtime.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "api" });
897
+ runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
898
+ editSelectedAtom();
899
+ }
900
+ };
901
+ /**
902
+ * Inserts plain text at the cursor via a real synthetic `beforeinput`
903
+ * event (inputType "insertText") rather than a hand-rolled operation -
904
+ * this is the exact same event InputController's own beforeInputListener
905
+ * already handles correctly for ordinary typing (replaceSelection),
906
+ * including deleting an active selection first. Verified this works
907
+ * identically across Chromium/Firefox/WebKit rather than assumed.
908
+ */
909
+ const insertSpecialCharacter = (char) => {
910
+ setSpecialCharPopover(null);
911
+ setRecentSpecialChars((current) => [char, ...current.filter((existing) => existing !== char)].slice(0, 8));
912
+ const root = runtime.surface.root;
913
+ if (!root)
914
+ return;
915
+ root.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertText", data: char, bubbles: true, cancelable: true }));
916
+ runtime.focus();
321
917
  };
322
918
  const insertBlockAtom = (type, attrs, nodeId) => {
323
919
  const declaration = atomDeclarations.find((entry) => entry.type === type);
@@ -325,6 +921,20 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
325
921
  let parentId;
326
922
  let index;
327
923
  const resolved = runtime.editor.resolve({ pos: selection.head });
924
+ // A selected atom's own parent is only a legal insertion point for a
925
+ // *block* atom (image/video/audio/table etc., whose parent is the
926
+ // document root or a table cell). An inline atom (formula, inline image)
927
+ // lives inside a paragraph - inserting a block atom there would nest a
928
+ // block inside a paragraph, which insertAtom's schema validation rejects,
929
+ // silently no-opping. Fall through to the generic "insert after the
930
+ // current block" logic below for that case instead.
931
+ const selectedBlockAtom = (() => {
932
+ if (selection.type !== "node")
933
+ return false;
934
+ const scope = atomScope();
935
+ const atomType = scope.kind === "atomic-node" ? findNode(runtime.editor.document, scope.nodeId)?.type : undefined;
936
+ return atomType ? atomDeclarations.find((entry) => entry.type === atomType)?.group === "block" : false;
937
+ })();
328
938
  if (selection.type === "cell") {
329
939
  // A cell range resolves at the active cell's content boundary. Block
330
940
  // atoms belong inside that cell, never as invalid siblings of the row.
@@ -338,7 +948,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
338
948
  parentId = resolved.parent.id;
339
949
  index = Math.max(selection.anchor.offset, selection.head.offset);
340
950
  }
341
- else if (selection.type === "node") {
951
+ else if (selectedBlockAtom) {
342
952
  parentId = resolved.parent.id;
343
953
  index = resolved.pos.offset;
344
954
  }
@@ -389,6 +999,14 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
389
999
  pendingMediaUploads.current.add(controller);
390
1000
  try {
391
1001
  await runAtomUpload(runtime.editor, nodeId, async () => {
1002
+ // Best-effort client-side check only - a UX nicety that catches an
1003
+ // obviously wrong file early, not a security boundary. A client can
1004
+ // always be bypassed, so MediaProvider.upload's real host
1005
+ // implementation MUST validate independently server-side; see the
1006
+ // doc comment on MediaProvider.upload (mediaProvider.ts).
1007
+ if (!file.type.startsWith(`${kind}/`)) {
1008
+ throw new Error(`"${file.type || "unknown type"}" does not look like a ${kind} file.`);
1009
+ }
392
1010
  const result = await mediaProvider.upload(file, { signal: controller.signal });
393
1011
  return { src: result.url, id: result.id };
394
1012
  });
@@ -398,6 +1016,201 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
398
1016
  URL.revokeObjectURL(preview);
399
1017
  }
400
1018
  };
1019
+ /**
1020
+ * Phase 11.5 §2.1: MediaManager already performed the upload (or the item
1021
+ * was already in the library) before onSelect fires, unlike
1022
+ * insertMediaFile's DefaultMediaPicker path (raw File in, pending
1023
+ * placeholder + async upload). Insert straight to "ready" - no pending
1024
+ * placeholder, no upload step to await.
1025
+ */
1026
+ const selectFromMediaManager = (item) => {
1027
+ setMediaKind(null);
1028
+ insertBlockAtom("block_image", {
1029
+ src: item.url, alt: item.alt || item.title || "Image", decorative: false, status: "ready",
1030
+ ...(item.width ? { width: item.width } : {}), ...(item.height ? { height: item.height } : {}),
1031
+ }, createNodeId());
1032
+ };
1033
+ const commentAuthorId = authorId || "anonymous";
1034
+ useEffect(() => {
1035
+ runtime.surface.pipeline?.setTrackChanges(trackChangesEnabled, commentAuthorId);
1036
+ }, [runtime, trackChangesEnabled, commentAuthorId]);
1037
+ const selectThread = (threadId) => {
1038
+ setActiveThreadId(threadId);
1039
+ setCommentPanelOpen(true);
1040
+ };
1041
+ const startComment = () => {
1042
+ const range = commentRangeFromSelection(runtime.editor.document, runtime.editor.selection);
1043
+ if (!range)
1044
+ return;
1045
+ setPendingCommentRange(range);
1046
+ setActiveThreadId(null);
1047
+ setCommentPanelOpen(true);
1048
+ };
1049
+ const createThread = (text) => {
1050
+ if (!pendingCommentRange || !commentProvider)
1051
+ return;
1052
+ const thread = {
1053
+ id: createNodeId(),
1054
+ range: pendingCommentRange,
1055
+ resolved: false,
1056
+ replies: [{ id: createNodeId(), authorId: commentAuthorId, text, createdAt: Date.now() }],
1057
+ };
1058
+ setThreads((current) => [...current, thread]);
1059
+ setPendingCommentRange(null);
1060
+ setActiveThreadId(thread.id);
1061
+ void commentProvider.save(thread).catch(() => { });
1062
+ };
1063
+ const replyToThread = (threadId, text) => {
1064
+ const target = threads.find((thread) => thread.id === threadId);
1065
+ if (!target)
1066
+ return;
1067
+ const updated = {
1068
+ ...target,
1069
+ replies: [...target.replies, { id: createNodeId(), authorId: commentAuthorId, text, createdAt: Date.now() }],
1070
+ };
1071
+ setThreads((current) => current.map((thread) => thread.id === threadId ? updated : thread));
1072
+ if (commentProvider)
1073
+ void commentProvider.save(updated).catch(() => { });
1074
+ };
1075
+ const setThreadResolved = (threadId, resolved) => {
1076
+ const target = threads.find((thread) => thread.id === threadId);
1077
+ if (!target)
1078
+ return;
1079
+ const updated = { ...target, resolved };
1080
+ setThreads((current) => current.map((thread) => thread.id === threadId ? updated : thread));
1081
+ if (commentProvider)
1082
+ void commentProvider.save(updated).catch(() => { });
1083
+ };
1084
+ const deleteThread = (threadId) => {
1085
+ setBusyThreadId(threadId);
1086
+ setThreads((current) => current.filter((thread) => thread.id !== threadId));
1087
+ if (activeThreadId === threadId)
1088
+ setActiveThreadId(null);
1089
+ const finish = () => setBusyThreadId((current) => current === threadId ? null : current);
1090
+ if (commentProvider)
1091
+ void commentProvider.remove(threadId).then(finish).catch(finish);
1092
+ else
1093
+ finish();
1094
+ };
1095
+ const canComment = !readOnly && Boolean(commentProvider)
1096
+ && Boolean(commentRangeFromSelection(runtime.editor.document, runtime.editor.selection));
1097
+ const selectSuggestion = (id) => {
1098
+ setActiveSuggestionId(id);
1099
+ setSuggestionPanelOpen(true);
1100
+ };
1101
+ const suggestDelete = () => {
1102
+ const scope = runtime.editor.resolveScope({ want: "inline-range" });
1103
+ if (!("kind" in scope) || scope.kind !== "inline-range" || scope.collapsed)
1104
+ return;
1105
+ const operations = suggestDeleteCommand(runtime.editor.document, scope, { authorId: commentAuthorId }, blockContext());
1106
+ if (operations.length)
1107
+ runtime.executeOperations(operations);
1108
+ };
1109
+ const startSuggestInsert = () => {
1110
+ setPendingSuggestInsertAt(runtime.editor.selection.head);
1111
+ setActiveSuggestionId(null);
1112
+ setSuggestionPanelOpen(true);
1113
+ };
1114
+ const submitSuggestInsert = (text) => {
1115
+ if (!pendingSuggestInsertAt)
1116
+ return;
1117
+ runtime.executeOperations([suggestInsertOperation(pendingSuggestInsertAt, text, { authorId: commentAuthorId })]);
1118
+ setPendingSuggestInsertAt(null);
1119
+ };
1120
+ const suggestBlockRemoval = () => {
1121
+ const owner = resolvePos(runtime.editor.document, runtime.editor.selection.head);
1122
+ const suggestion = structuralSuggestionFromNode(owner.nodeId, commentAuthorId);
1123
+ setStructuralSuggestions((current) => [...current, suggestion]);
1124
+ setActiveSuggestionId(suggestion.id);
1125
+ setSuggestionPanelOpen(true);
1126
+ if (suggestionProvider)
1127
+ void suggestionProvider.save(suggestion).catch(() => { });
1128
+ };
1129
+ const acceptInlineSuggestion = (id) => {
1130
+ const operations = acceptSuggestionCommand(runtime.editor.document, id);
1131
+ if (operations.length)
1132
+ runtime.executeOperations(operations);
1133
+ };
1134
+ const rejectInlineSuggestion = (id) => {
1135
+ const operations = rejectSuggestionCommand(runtime.editor.document, id);
1136
+ if (operations.length)
1137
+ runtime.executeOperations(operations);
1138
+ };
1139
+ const acceptStructural = (suggestion) => {
1140
+ setBusySuggestionId(suggestion.id);
1141
+ const operations = acceptStructuralSuggestionCommand(runtime.editor.document, suggestion, runtime.editor.positions);
1142
+ if (operations.length)
1143
+ runtime.executeOperations(operations);
1144
+ setStructuralSuggestions((current) => current.filter((entry) => entry.id !== suggestion.id));
1145
+ if (activeSuggestionId === suggestion.id)
1146
+ setActiveSuggestionId(null);
1147
+ const finish = () => setBusySuggestionId((current) => current === suggestion.id ? null : current);
1148
+ if (suggestionProvider)
1149
+ void suggestionProvider.remove(suggestion.id).then(finish).catch(finish);
1150
+ else
1151
+ finish();
1152
+ };
1153
+ const rejectStructural = (suggestion) => {
1154
+ setBusySuggestionId(suggestion.id);
1155
+ setStructuralSuggestions((current) => current.filter((entry) => entry.id !== suggestion.id));
1156
+ if (activeSuggestionId === suggestion.id)
1157
+ setActiveSuggestionId(null);
1158
+ const finish = () => setBusySuggestionId((current) => current === suggestion.id ? null : current);
1159
+ if (suggestionProvider)
1160
+ void suggestionProvider.remove(suggestion.id).then(finish).catch(finish);
1161
+ else
1162
+ finish();
1163
+ };
1164
+ const canSuggestDelete = (() => {
1165
+ if (readOnly || !suggestionProvider)
1166
+ return false;
1167
+ const scope = runtime.editor.resolveScope({ want: "inline-range" });
1168
+ return "kind" in scope && scope.kind === "inline-range" && !scope.collapsed;
1169
+ })();
1170
+ /**
1171
+ * Phase 11.5 §2.5: wires the already-tested LinkEditorPopover (previously
1172
+ * built but never imported anywhere) to the already-tested command-layer
1173
+ * link logic (editLinkCommand/resolveMarkRun, previously only reachable
1174
+ * via window.prompt). A collapsed selection with no existing link has
1175
+ * nothing to apply the mark to and no text to become the label - unlike
1176
+ * bold/italic, arming storedMarks alone would insert nothing visible, so
1177
+ * that case inserts the popover's typed display text directly with the
1178
+ * link mark attached, rather than going through executeMarkTool.
1179
+ */
1180
+ const applyLink = (value) => {
1181
+ if (!linkPopover)
1182
+ return;
1183
+ const tool = inlineToolDeclarations.find((entry) => entry.id === "link");
1184
+ const attrs = { href: value.href, ...(value.openInNewTab ? { target: "_blank" } : {}) };
1185
+ if (linkPopover.editingExisting) {
1186
+ executeMarkTool(runtime.editor, tool, "editLink", attrs);
1187
+ }
1188
+ else if (linkPopover.collapsed && value.text) {
1189
+ const pos = runtime.editor.selection.head;
1190
+ runtime.editor.transact((builder) => {
1191
+ builder.operations.push({ type: "insertText", pos, text: value.text, marks: [{ type: "link", attrs }] });
1192
+ const next = { path: [...pos.path], offset: pos.offset + value.text.length };
1193
+ builder.setSelection({ type: "text", anchor: next, head: next });
1194
+ }, { source: "toolbar", addToHistory: true });
1195
+ }
1196
+ else {
1197
+ executeMarkTool(runtime.editor, tool, "apply", attrs);
1198
+ }
1199
+ // Without this, the auto-trigger effect (linkAnchorElement) would see
1200
+ // the same still-a-link caret position on the very next render and
1201
+ // immediately reopen the popover that "Update" just closed.
1202
+ setLinkOverlayDismissedAt(runtime.editor.selection.head);
1203
+ runtime.focus();
1204
+ setLinkPopover(null);
1205
+ };
1206
+ const removeLink = () => {
1207
+ const tool = inlineToolDeclarations.find((entry) => entry.id === "link");
1208
+ if (tool)
1209
+ executeMarkTool(runtime.editor, tool, "remove");
1210
+ setLinkOverlayDismissedAt(runtime.editor.selection.head);
1211
+ runtime.focus();
1212
+ setLinkPopover(null);
1213
+ };
401
1214
  const editSelectedAtom = (resizeBy) => {
402
1215
  const scope = atomScope();
403
1216
  if (scope.kind !== "atomic-node")
@@ -415,30 +1228,33 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
415
1228
  }, blockContext());
416
1229
  runtime.executeOperations(operations, resizeBy === undefined ? {} : { historyGroup: `resize-${scope.nodeId}` });
417
1230
  };
1231
+ /** MediaOverlay's drag-corner-handle commit - explicit width/height rather than editSelectedAtom's fixed +/-20 increment. */
1232
+ const resizeSelectedAtomTo = (width, height) => {
1233
+ const scope = atomScope();
1234
+ if (scope.kind !== "atomic-node")
1235
+ return;
1236
+ runtime.executeOperations(resizeAtom(runtime.editor.document, scope, { width, height }, blockContext()), { historyGroup: `resize-${scope.nodeId}` });
1237
+ };
1238
+ /** Same atom.delete command the removed atom.contextMenu.delete contribution used to dispatch, now called directly from MediaOverlay. */
1239
+ const deleteSelectedAtom = () => {
1240
+ const scope = atomScope();
1241
+ if (scope.kind !== "atomic-node")
1242
+ return;
1243
+ runtime.executeOperations(deleteAtom(runtime.editor.document, scope, {}, blockContext()));
1244
+ runtime.focus();
1245
+ };
418
1246
  const replaceCanonicalDocument = (document) => {
419
1247
  runtime.replaceValue({ schemaVersion: runtime.editor.schema.version, revision: runtime.getRevision() + 1, document });
420
1248
  runtime.focus();
421
1249
  };
422
- const legacyDocument = () => {
423
- const ownerDocument = rootRef.current?.ownerDocument;
424
- if (!ownerDocument)
425
- return null;
426
- return smartDocumentFromHtml(serializeCanonicalListHtml(runtime.editor.document, { clean: true }), ownerDocument);
427
- };
428
1250
  const runImport = async (file) => {
429
1251
  if (/\.docx$/i.test(file.name) || file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document") {
430
- const ownerDocument = rootRef.current?.ownerDocument;
431
- if (!ownerDocument)
432
- return;
433
- const result = await importStyledDocxDocument(await file.arrayBuffer(), ownerDocument);
434
- replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml || serializeSmartDocument(result.document)));
1252
+ const result = await importStyledDocxDocument(await file.arrayBuffer());
1253
+ replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml));
435
1254
  return;
436
1255
  }
437
1256
  if (/\.pdf$/i.test(file.name) || file.type === "application/pdf") {
438
- const ownerDocument = rootRef.current?.ownerDocument;
439
- if (!ownerDocument)
440
- return;
441
- const result = await importPdfDocument(await file.arrayBuffer(), ownerDocument);
1257
+ const result = await importPdfDocument(await file.arrayBuffer());
442
1258
  replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml));
443
1259
  return;
444
1260
  }
@@ -455,64 +1271,222 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
455
1271
  return downloadText(rootRef.current.ownerDocument, "smart-rte.html", "text/html", serializeCanonicalListHtml(envelope.document, { clean: true }));
456
1272
  };
457
1273
  const runDocxExport = async () => {
458
- const document = legacyDocument();
459
- if (!document || !rootRef.current)
1274
+ if (!rootRef.current)
460
1275
  return;
461
- const blob = await exportDocxDocument(document);
1276
+ const blob = await exportDocxDocument(runtime.editor.document);
462
1277
  downloadBlob(rootRef.current.ownerDocument, "smart-rte.docx", blob);
463
1278
  };
464
1279
  const runPdfExport = () => {
465
- const document = legacyDocument();
466
1280
  const view = rootRef.current?.ownerDocument.defaultView;
467
- if (document && view)
468
- printSmartDocumentAsPdf(document, view);
469
- };
470
- return _jsxs("section", { className: `srte-root srte-editor srte-canonical-authority${className ? ` ${className}` : ""}`, "data-smart-authority": "canonical", children: [_jsxs("div", { className: "srte-toolbar", role: "toolbar", "aria-label": "Formatting toolbar", children: [inlineToolDeclarations.filter((tool) => labels[tool.id]).map((tool) => _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": labels[tool.id], title: labels[tool.id], disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => {
471
- if (["textColor", "backgroundColor", "fontSize", "fontFamily"].includes(tool.id))
472
- applyAttributedMark(tool.id);
473
- else {
474
- executeMarkTool(runtime.editor, tool, "toggle");
475
- runtime.focus();
476
- }
477
- }, children: labels[tool.id] }, tool.id)), _jsxs("select", { "aria-label": "Block type", value: currentBlockType, disabled: readOnly, onChange: (event) => transactBlock(setBlockTypeCommand(runtime.editor.document, blockScope(), {
478
- type: event.target.value === "paragraph" ? "paragraph" : event.target.value === "code_block" ? "code_block" : "heading",
479
- attrs: event.target.value.startsWith("heading-") ? { level: Number(event.target.value.slice(8)) } : {},
480
- }, blockContext())), children: [_jsx("option", { value: "paragraph", children: "Paragraph" }), Array.from({ length: 6 }, (_, index) => _jsxs("option", { value: `heading-${index + 1}`, children: ["Heading ", index + 1] }, index + 1)), _jsx("option", { value: "code_block", children: "Code block" })] }), ["left", "center", "right", "justify"].map((align) => _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": `Align ${align}`, disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align } }, blockContext())), children: align }, align)), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Blockquote", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: toggleBlockquote, children: "Blockquote" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Move block up", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => runBlock("up"), children: "Block \u2191" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Move block down", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => runBlock("down"), children: "Block \u2193" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Indent block", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => runBlock("indent"), children: "Block indent" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Outdent block", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => runBlock("outdent"), children: "Block outdent" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Insert or edit link", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => {
481
- const href = window.prompt("Link URL", "https://");
482
- const tool = inlineToolDeclarations.find((entry) => entry.id === "link");
483
- const description = runtime.editor.resolveScope({ want: "describe" });
484
- const editingExisting = description.marks.some((entry) => entry.mark.type === "link");
485
- if (href && tool) {
486
- executeMarkTool(runtime.editor, tool, editingExisting ? "editLink" : "apply", { href });
487
- runtime.focus();
488
- }
489
- }, children: "Link" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Remove link", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => {
490
- const tool = inlineToolDeclarations.find((entry) => entry.id === "link");
491
- if (tool) {
492
- executeMarkTool(runtime.editor, tool, "remove");
493
- runtime.focus();
494
- }
495
- }, children: "Unlink" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Bulleted list", "aria-pressed": listStyleActive("disc"), disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => toggleList("disc"), children: "Bullets" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Numbered list", "aria-pressed": listStyleActive("decimal"), disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => toggleList("decimal"), children: "Numbering" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Checklist", "aria-pressed": listStyleActive("disc", true), disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => toggleList("disc", true), children: "Checklist" }), _jsxs("select", { "aria-label": "List preset", title: "List type / preset", disabled: readOnly || currentListParts.length !== 1, value: currentListPreset, onChange: (event) => {
496
- const preset = event.target.value;
497
- if (!preset || currentListParts.length !== 1)
498
- return;
499
- // A preset choice applies to the whole list, regardless of how deep
500
- // the cursor is nested see outermostListId.
501
- const rootId = outermostListId(currentListParts[0].listId);
502
- runtime.executeOperations(setListPreset(runtime.editor.document, { ...currentListParts[0], listId: rootId }, { preset }, blockContext()), { preserveSelectionById: true });
503
- }, children: [_jsx("option", { value: "", children: "List preset" }), SMART_LIST_PRESETS.map((preset) => _jsx("option", { value: preset.id, children: preset.kind === "bullet" ? `Bullet · ${preset.label}` : `Number · ${preset.label}` }, preset.id))] }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Check selected items", "aria-pressed": currentListScope.kind === "list-selection" && currentListScope.items.every((item) => findNode(runtime.editor.document, item.itemId)?.attrs?.checked === true), disabled: readOnly || currentList?.attrs?.checkable !== true, onMouseDown: (event) => event.preventDefault(), onClick: toggleCheckedItems, children: "Check" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Indent list item", disabled: readOnly || !canIndent, onMouseDown: (event) => event.preventDefault(), onClick: () => runList("indent"), children: "Indent" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Outdent list item", disabled: readOnly || currentListParts.length === 0, onMouseDown: (event) => event.preventDefault(), onClick: () => runList("outdent"), children: "Outdent" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Move item up", disabled: readOnly || !canMoveUp, onMouseDown: (event) => event.preventDefault(), onClick: () => runList("up"), children: "Item \u2191" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Move item down", disabled: readOnly || !canMoveDown, onMouseDown: (event) => event.preventDefault(), onClick: () => runList("down"), children: "Item \u2193" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Restart numbering", disabled: readOnly || !orderedList, onMouseDown: (event) => event.preventDefault(), onClick: restartNumbering, children: "Restart" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Continue numbering", disabled: readOnly || !orderedList, onMouseDown: (event) => event.preventDefault(), onClick: () => runtime.executeOperations(continueListNumbering(runtime.editor.document, currentListScope, {}, blockContext()), { preserveSelectionById: true }), children: "Continue" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Insert table", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: insertTable, children: "Table 2\u00D72" }), [["row+", "Add row"], ["row-", "Remove row"], ["column+", "Add column"], ["column-", "Remove column"], ["merge", "Merge cells"], ["split", "Split cell"], ["header", "Header row"], ["row-up", "Move row up"], ["row-down", "Move row down"], ["column-left", "Move column left"], ["column-right", "Move column right"], ["remove", "Remove table"]].map(([action, label]) => _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": label, disabled: readOnly || !tableSelected
504
- || action === "merge" && currentTableScope.cellIds.length < 2
505
- || action === "row-up" && currentTableScope.rect.top === 0
506
- || action === "column-left" && currentTableScope.rect.left === 0, onMouseDown: (event) => event.preventDefault(), onClick: () => runTable(action), children: label }, action)), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Insert image", disabled: readOnly || !mediaProvider, onMouseDown: (event) => event.preventDefault(), onClick: () => setMediaKind("image"), children: "Image" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Insert video", disabled: readOnly || !mediaProvider, onMouseDown: (event) => event.preventDefault(), onClick: () => setMediaKind("video"), children: "Video" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Insert audio", disabled: readOnly || !mediaProvider, onMouseDown: (event) => event.preventDefault(), onClick: () => setMediaKind("audio"), children: "Audio" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Insert formula", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: insertInlineFormula, children: "Formula" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Edit selected atom", disabled: readOnly || !atomSelected, onMouseDown: (event) => event.preventDefault(), onClick: () => editSelectedAtom(), children: "Edit media" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Grow selected atom", disabled: readOnly || !atomSelected, onMouseDown: (event) => event.preventDefault(), onClick: () => editSelectedAtom(20), children: "Resize +" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Shrink selected atom", disabled: readOnly || !atomSelected, onMouseDown: (event) => event.preventDefault(), onClick: () => editSelectedAtom(-20), children: "Resize \u2212" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Delete selected atom", disabled: readOnly || !atomSelected, onMouseDown: (event) => event.preventDefault(), onClick: () => runtime.executeOperations(deleteAtom(runtime.editor.document, atomScope(), {}, blockContext())), children: "Delete media" }), _jsx("input", { ref: importRef, type: "file", accept: ".html,.htm,.md,.markdown,.docx,.pdf,text/html,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/pdf", hidden: true, onChange: (event) => {
1281
+ if (view)
1282
+ printSmartDocumentAsPdf(runtime.editor.document, view);
1283
+ };
1284
+ const markTool = (id) => inlineToolDeclarations.find((tool) => tool.id === id);
1285
+ // aria-pressed tri-state per docs/bugs/mark-toolbar-buttons-no-pressed-state.md's
1286
+ // prescribed fix: SelectionDescription.marks already reports "all"/"partial"
1287
+ // coverage per mark, so a selection only partially covered by e.g. bold
1288
+ // reports aria-pressed="mixed" rather than silently rounding to true/false.
1289
+ const markCoverage = (id) => {
1290
+ if (readOnly)
1291
+ return undefined;
1292
+ const description = runtime.editor.resolveScope({ want: "describe" });
1293
+ const markType = markTool(id).markType;
1294
+ const entry = description.marks.find((entry) => entry.mark.type === markType);
1295
+ if (!entry)
1296
+ return false;
1297
+ return entry.coverage === "all" ? true : "mixed";
1298
+ };
1299
+ const toggleMark = (id) => { executeMarkTool(runtime.editor, markTool(id), "toggle"); runtime.focus(); };
1300
+ const openLinkPopover = (event) => {
1301
+ const description = runtime.editor.resolveScope({ want: "describe" });
1302
+ const linkEntry = description.marks.find((entry) => entry.mark.type === "link");
1303
+ const rect = event.currentTarget.getBoundingClientRect();
1304
+ setLinkPopover({
1305
+ x: rect.left, y: rect.bottom + 4,
1306
+ editingExisting: Boolean(linkEntry),
1307
+ href: typeof linkEntry?.mark.attrs?.href === "string" ? linkEntry.mark.attrs.href : "",
1308
+ openInNewTab: linkEntry?.mark.attrs?.target === "_blank",
1309
+ collapsed: description.collapsed,
1310
+ });
1311
+ };
1312
+ const tableAction = (action) => {
1313
+ const disabled = readOnly || !tableSelected
1314
+ || action === "merge" && currentTableScope.cellIds.length < 2
1315
+ || action === "row-up" && currentTableScope.rect.top === 0
1316
+ || action === "column-left" && currentTableScope.rect.left === 0;
1317
+ return { disabled, onClick: () => runTable(action) };
1318
+ };
1319
+ // Rendered in two places (its home dropdown, and the narrow-viewport
1320
+ // overflow menu) so nothing becomes unreachable once dropdowns collapse -
1321
+ // see ToolbarPrimitives.tsx's MobileMoreMenu doc comment.
1322
+ // Superscript/Subscript/Text colour/Background colour/Font size/Font
1323
+ // family also get a standalone widePromote ToolbarButton (below, in the
1324
+ // main toolbar row) that appears past theme.ts's 1440px breakpoint - this
1325
+ // dropdown copy's `widePromote` flag hides it there so it isn't offered
1326
+ // twice; below 1440px (including mobile) this copy is what's reachable,
1327
+ // same as before. "Code" isn't promoted - see the docs/bugs/ writeup on
1328
+ // why it stayed the dropdown's sole remaining item at wide widths.
1329
+ const textStylesMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "code", label: "Code", pressed: markCoverage("inlineCode"), disabled: readOnly, onClick: () => toggleMark("inlineCode") }), _jsx(ToolbarMenuItem, { icon: "superscript", label: "Superscript", pressed: markCoverage("superscript"), disabled: readOnly, onClick: () => toggleMark("superscript"), widePromote: true }), _jsx(ToolbarMenuItem, { icon: "subscript", label: "Subscript", pressed: markCoverage("subscript"), disabled: readOnly, onClick: () => toggleMark("subscript"), widePromote: true }), _jsx(ToolbarMenuItem, { icon: "textColor", label: "Text colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1330
+ const rect = event.currentTarget.getBoundingClientRect();
1331
+ setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "textColor" }, initialValue: currentMarkColor("textColor") });
1332
+ } }), _jsx(ToolbarMenuItem, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1333
+ const rect = event.currentTarget.getBoundingClientRect();
1334
+ setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "backgroundColor" }, initialValue: currentMarkColor("backgroundColor") });
1335
+ } }), _jsx(ToolbarMenuItem, { icon: "fontSize", label: "Font size", disabled: readOnly, onClick: () => applyAttributedMark("fontSize"), widePromote: true }), _jsx(ToolbarMenuItem, { icon: "fontFamily", label: "Font family", disabled: readOnly, onClick: () => applyAttributedMark("fontFamily"), widePromote: true })] });
1336
+ const paragraphToolsMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "moveUp", label: "Move block up", disabled: readOnly, onClick: () => runBlock("up") }), _jsx(ToolbarMenuItem, { icon: "moveDown", label: "Move block down", disabled: readOnly, onClick: () => runBlock("down") }), _jsx(ToolbarMenuItem, { icon: "indent", label: "Indent block", disabled: readOnly, onClick: () => runBlock("indent") }), _jsx(ToolbarMenuItem, { icon: "outdent", label: "Outdent block", disabled: readOnly, onClick: () => runBlock("outdent") })] });
1337
+ const listToolsMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "checkSquare", label: "Check selected items", pressed: currentListScope.kind === "list-selection" && currentListScope.items.every((item) => findNode(runtime.editor.document, item.itemId)?.attrs?.checked === true), disabled: readOnly || currentList?.attrs?.checkable !== true, onClick: toggleCheckedItems }), _jsx(ToolbarMenuItem, { icon: "indent", label: "Indent list item", disabled: readOnly || !canIndent, onClick: () => runList("indent") }), _jsx(ToolbarMenuItem, { icon: "outdent", label: "Outdent list item", disabled: readOnly || currentListParts.length === 0, onClick: () => runList("outdent") }), _jsx(ToolbarMenuItem, { icon: "moveUp", label: "Move item up", disabled: readOnly || !canMoveUp, onClick: () => runList("up") }), _jsx(ToolbarMenuItem, { icon: "moveDown", label: "Move item down", disabled: readOnly || !canMoveDown, onClick: () => runList("down") }), _jsx(ToolbarMenuItem, { icon: "restart", label: "Restart numbering", disabled: readOnly || !orderedList, onClick: restartNumbering }), _jsx(ToolbarMenuItem, { icon: "continueNumbering", label: "Continue numbering", disabled: readOnly || !orderedList, onClick: () => runtime.executeOperations(continueListNumbering(runtime.editor.document, currentListScope, {}, blockContext()), { preserveSelectionById: true }) })] });
1338
+ // Remove link/Insert formula/Special characters also get a standalone
1339
+ // widePromote ToolbarButton (see textStylesMenuItems's own comment above
1340
+ // for the mechanism) - Insert video/audio and the selected-media actions
1341
+ // stay dropdown-only, matching the report's own named tool list.
1342
+ const insertMoreMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "unlink", label: "Remove link", disabled: readOnly, onClick: removeLink, widePromote: true }), _jsx(ToolbarMenuItem, { icon: "video", label: "Insert video", disabled: readOnly || !mediaProvider, onClick: () => setMediaKind("video") }), _jsx(ToolbarMenuItem, { icon: "audio", label: "Insert audio", disabled: readOnly || !mediaProvider, onClick: () => setMediaKind("audio") }), _jsx(ToolbarMenuItem, { icon: "formula", label: "Insert formula", disabled: readOnly, widePromote: true, onClick: (event) => {
1343
+ const rect = event.currentTarget.getBoundingClientRect();
1344
+ setFormulaLibraryPopover({ x: rect.left, y: rect.bottom + 4 });
1345
+ } }), _jsx(ToolbarMenuItem, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
1346
+ const rect = event.currentTarget.getBoundingClientRect();
1347
+ setSpecialCharPopover({ x: rect.left, y: rect.bottom + 4 });
1348
+ } }), _jsx(ToolbarMenuItem, { icon: "edit", label: "Edit selected media", disabled: readOnly || !mediaAtomSelected, onClick: () => editSelectedAtom() }), _jsx(ToolbarMenuItem, { icon: "zoomIn", label: "Enlarge selected media", disabled: readOnly || !resizableAtomSelected, onClick: () => editSelectedAtom(20) }), _jsx(ToolbarMenuItem, { icon: "zoomOut", label: "Shrink selected media", disabled: readOnly || !resizableAtomSelected, onClick: () => editSelectedAtom(-20) }), _jsx(ToolbarMenuItem, { icon: "delete", label: "Delete selected media", disabled: readOnly || !atomSelected, onClick: () => runtime.executeOperations(deleteAtom(runtime.editor.document, atomScope(), {}, blockContext())) })] });
1349
+ // "Remove row"/"Remove column"/"Remove table" renamed to "Delete ..." -
1350
+ // matches the right-click table context menu's existing wording
1351
+ // (table/plugin.ts's table.contextMenu.removeRow/removeColumn/removeTable
1352
+ // contributions, labeled "Delete row"/"Delete column"/"Delete table")
1353
+ // for the identical action, found as a wording inconsistency during the
1354
+ // toolbar audit.
1355
+ const tableToolsMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "addRow", label: "Add row", ...tableAction("row+") }), _jsx(ToolbarMenuItem, { icon: "delete", label: "Delete row", ...tableAction("row-") }), _jsx(ToolbarMenuItem, { icon: "addColumn", label: "Add column", ...tableAction("column+") }), _jsx(ToolbarMenuItem, { icon: "delete", label: "Delete column", ...tableAction("column-") }), _jsx(ToolbarMenuItem, { icon: "mergeCells", label: "Merge cells", ...tableAction("merge") }), _jsx(ToolbarMenuItem, { icon: "splitCell", label: "Split cell", ...tableAction("split") }), _jsx(ToolbarMenuItem, { icon: "headerRow", label: "Header row", ...tableAction("header") }), _jsx(ToolbarMenuItem, { icon: "moveUp", label: "Move row up", ...tableAction("row-up") }), _jsx(ToolbarMenuItem, { icon: "moveDown", label: "Move row down", ...tableAction("row-down") }), _jsx(ToolbarMenuItem, { icon: "moveLeft", label: "Move column left", ...tableAction("column-left") }), _jsx(ToolbarMenuItem, { icon: "moveRight", label: "Move column right", ...tableAction("column-right") }), _jsx(ToolbarMenuItem, { icon: "deleteTable", label: "Delete table", ...tableAction("remove") }), _jsx("div", { className: "srte-menu-separator" }), _jsx(ToolbarMenuItem, { icon: "cellBorder", label: "Border options", disabled: readOnly || !tableSelected, onClick: (event) => {
1356
+ const scope = runtime.editor.resolveScope({ want: "table-grid" });
1357
+ if (!("kind" in scope) || scope.kind !== "table-grid")
1358
+ return;
1359
+ const rect = event.currentTarget.getBoundingClientRect();
1360
+ setTableBorderPopover({ x: rect.left, y: rect.bottom + 4, scope: scope, initial: currentCellBorderDraft(scope) });
1361
+ } })] });
1362
+ const saveCopyMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { label: "Save as HTML", onClick: () => runExport("html") }), _jsx(ToolbarMenuItem, { label: "Save as Markdown", onClick: () => runExport("markdown") }), _jsx(ToolbarMenuItem, { label: "Save as Word document", onClick: () => void runDocxExport() }), _jsx(ToolbarMenuItem, { label: "Save as PDF", onClick: runPdfExport }), _jsx(ToolbarMenuItem, { icon: "json", label: "Save as Smart RTE file", onClick: () => runExport("native") })] });
1363
+ const reviewMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "addComment", label: "Add comment", disabled: !canComment, onClick: startComment }), _jsx(ToolbarMenuItem, { icon: "comments", label: "Comments", pressed: commentPanelOpen, disabled: !commentProvider, onClick: () => setCommentPanelOpen((open) => !open) }), _jsx(ToolbarMenuItem, { icon: "suggest", label: "Suggest deletion", disabled: !canSuggestDelete, onClick: suggestDelete }), _jsx(ToolbarMenuItem, { icon: "suggest", label: "Suggest insertion", disabled: readOnly || !suggestionProvider, onClick: startSuggestInsert }), _jsx(ToolbarMenuItem, { icon: "suggest", label: "Suggest removing this", disabled: readOnly || !suggestionProvider, onClick: suggestBlockRemoval }), _jsx(ToolbarMenuItem, { icon: "suggestions", label: "Suggestions", pressed: suggestionPanelOpen, disabled: !suggestionProvider, onClick: () => setSuggestionPanelOpen((open) => !open) }), _jsx(ToolbarMenuItem, { icon: "showEdits", label: "Show edits", pressed: trackChangesEnabled, title: "Tracks ordinary typing and edits within a single paragraph as suggestions. Merging paragraphs together - typing over a selection that spans multiple paragraphs, or pressing Backspace/Delete at a paragraph boundary - still applies directly and can't be reviewed as a suggestion.", disabled: readOnly || !suggestionProvider, onClick: () => setTrackChangesEnabled((enabled) => !enabled) })] });
1364
+ return _jsxs("section", { className: `srte-root srte-editor srte-canonical-authority${className ? ` ${className}` : ""}`, "data-smart-authority": "canonical", children: [_jsxs("div", { className: "srte-toolbar", role: "toolbar", "aria-label": "Formatting toolbar", children: [_jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "bold", label: "Bold", pressed: markCoverage("bold"), disabled: readOnly, onClick: () => toggleMark("bold") }), _jsx(ToolbarButton, { icon: "italic", label: "Italic", pressed: markCoverage("italic"), disabled: readOnly, onClick: () => toggleMark("italic") }), _jsx(ToolbarButton, { icon: "underline", label: "Underline", pressed: markCoverage("underline"), disabled: readOnly, onClick: () => toggleMark("underline") }), _jsx(ToolbarButton, { icon: "strikethrough", label: "Strikethrough", pressed: markCoverage("strikethrough"), disabled: readOnly, onClick: () => toggleMark("strikethrough") }), _jsx(ToolbarButton, { icon: "superscript", label: "Superscript", pressed: markCoverage("superscript"), disabled: readOnly, onClick: () => toggleMark("superscript"), widePromote: true }), _jsx(ToolbarButton, { icon: "subscript", label: "Subscript", pressed: markCoverage("subscript"), disabled: readOnly, onClick: () => toggleMark("subscript"), widePromote: true }), _jsx(ToolbarButton, { icon: "textColor", label: "Text colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1365
+ const rect = event.currentTarget.getBoundingClientRect();
1366
+ setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "textColor" }, initialValue: currentMarkColor("textColor") });
1367
+ } }), _jsx(ToolbarButton, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1368
+ const rect = event.currentTarget.getBoundingClientRect();
1369
+ setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "backgroundColor" }, initialValue: currentMarkColor("backgroundColor") });
1370
+ } }), _jsx(ToolbarButton, { icon: "fontSize", label: "Font size", disabled: readOnly, onClick: () => applyAttributedMark("fontSize"), widePromote: true }), _jsx(ToolbarButton, { icon: "fontFamily", label: "Font family", disabled: readOnly, onClick: () => applyAttributedMark("fontFamily"), widePromote: true }), _jsx(ToolbarDropdown, { icon: "textColor", label: "More text styles", priority: 2, children: textStylesMenuItems })] }), _jsxs(ToolbarGroup, { children: [_jsxs("select", { "aria-label": "Block type", title: "Block type", value: currentBlockType, disabled: readOnly, onChange: (event) => transactBlock(setBlockTypeCommand(runtime.editor.document, blockScope(), {
1371
+ type: event.target.value === "paragraph" ? "paragraph" : event.target.value === "code_block" ? "code_block" : "heading",
1372
+ attrs: event.target.value.startsWith("heading-") ? { level: Number(event.target.value.slice(8)) } : {},
1373
+ }, blockContext())), children: [_jsx("option", { value: "paragraph", children: "Paragraph" }), Array.from({ length: 6 }, (_, index) => _jsxs("option", { value: `heading-${index + 1}`, children: ["Heading ", index + 1] }, index + 1)), _jsx("option", { value: "code_block", children: "Code block" })] }), _jsx(ToolbarButton, { icon: "alignLeft", label: "Align left", ariaLabel: "Align left", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "left" } }, blockContext())) }), _jsx(ToolbarButton, { icon: "alignCenter", label: "Align center", ariaLabel: "Align center", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "center" } }, blockContext())) }), _jsx(ToolbarButton, { icon: "alignRight", label: "Align right", ariaLabel: "Align right", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "right" } }, blockContext())) }), _jsx(ToolbarButton, { icon: "alignJustify", label: "Justify", ariaLabel: "Align justify", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "justify" } }, blockContext())) }), _jsx(ToolbarButton, { icon: "quote", label: "Quote", ariaLabel: "Blockquote", disabled: readOnly, onClick: toggleBlockquote }), _jsx(ToolbarDropdown, { icon: "moveUp", label: "More paragraph tools", priority: 2, children: paragraphToolsMenuItems })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "bulletedList", label: "Bulleted list", ariaLabel: "Bulleted list", pressed: listStyleActive("disc"), disabled: readOnly, onClick: () => toggleList("disc") }), _jsx(ToolbarButton, { icon: "numberedList", label: "Numbered list", ariaLabel: "Numbered list", pressed: listStyleActive("decimal"), disabled: readOnly, onClick: () => toggleList("decimal") }), _jsx(ToolbarButton, { icon: "checklist", label: "Checklist", ariaLabel: "Checklist", pressed: listStyleActive("disc", true), disabled: readOnly, onClick: () => toggleList("disc", true) }), _jsxs(ToolbarDropdown, { icon: "restart", label: "More list tools", priority: 2, children: [_jsx("div", { style: { padding: "4px 8px" }, children: _jsxs("select", { "aria-label": "List preset", title: "List type / preset", disabled: readOnly || currentListParts.length !== 1, value: currentListPreset, onChange: (event) => {
1374
+ const preset = event.target.value;
1375
+ if (!preset || currentListParts.length !== 1)
1376
+ return;
1377
+ // A preset choice applies to the whole list, regardless of how deep
1378
+ // the cursor is nested — see outermostListId.
1379
+ const rootId = outermostListId(currentListParts[0].listId);
1380
+ runtime.executeOperations(setListPreset(runtime.editor.document, { ...currentListParts[0], listId: rootId }, { preset }, blockContext()), { preserveSelectionById: true });
1381
+ }, style: { width: "100%" }, children: [_jsx("option", { value: "", children: "List preset" }), SMART_LIST_PRESETS.map((preset) => _jsx("option", { value: preset.id, children: preset.kind === "bullet" ? `Bullet · ${preset.label}` : `Number · ${preset.label}` }, preset.id))] }) }), _jsx("div", { className: "srte-menu-separator" }), listToolsMenuItems] })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "link", label: "Link", ariaLabel: "Insert or edit link", disabled: readOnly, onClick: openLinkPopover }), _jsx(ToolbarButton, { icon: "image", label: "Image", ariaLabel: "Insert image", disabled: readOnly || !mediaProvider, onClick: () => setMediaKind("image") }), _jsx(ToolbarButton, { icon: "unlink", label: "Remove link", disabled: readOnly, onClick: removeLink, widePromote: true }), _jsx(ToolbarButton, { icon: "formula", label: "Insert formula", disabled: readOnly, widePromote: true, onClick: (event) => {
1382
+ const rect = event.currentTarget.getBoundingClientRect();
1383
+ setFormulaLibraryPopover({ x: rect.left, y: rect.bottom + 4 });
1384
+ } }), _jsx(ToolbarButton, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
1385
+ const rect = event.currentTarget.getBoundingClientRect();
1386
+ setSpecialCharPopover({ x: rect.left, y: rect.bottom + 4 });
1387
+ } }), _jsx(ToolbarDropdown, { icon: "video", label: "More to insert", priority: 2, children: insertMoreMenuItems })] }), tablesEnabled && _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "table", label: "Insert table", ariaLabel: "Insert table", disabled: readOnly, onClick: (event) => {
1388
+ const rect = event.currentTarget.getBoundingClientRect();
1389
+ setTableSizePopover({ x: rect.left, y: rect.bottom + 4 });
1390
+ } }), _jsx(ToolbarDropdown, { icon: "mergeCells", label: "Table tools", priority: 2, children: tableToolsMenuItems })] }), _jsx("input", { ref: importRef, type: "file", accept: ".html,.htm,.md,.markdown,.docx,.pdf,text/html,text/markdown,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/pdf", hidden: true, onChange: (event) => {
507
1391
  const file = event.currentTarget.files?.[0];
508
1392
  if (file)
509
1393
  void runImport(file);
510
1394
  event.currentTarget.value = "";
511
- } }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Import document", disabled: readOnly, onClick: () => importRef.current?.click(), children: "Import" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Export HTML", onClick: () => runExport("html"), children: "Export HTML" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Export Markdown", onClick: () => runExport("markdown"), children: "Export Markdown" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Export DOCX", onClick: () => void runDocxExport(), children: "Export DOCX" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Export PDF", onClick: runPdfExport, children: "Export PDF" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Export native document", onClick: () => runExport("native"), children: "Export Native" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Undo", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => { runtime.editor.undo(); runtime.focus(); }, children: "Undo" }), _jsx("button", { type: "button", className: "srte-tool-button", "aria-label": "Redo", disabled: readOnly, onMouseDown: (event) => event.preventDefault(), onClick: () => { runtime.editor.redo(); runtime.focus(); }, children: "Redo" })] }), mediaKind && mediaProvider && _jsx(MediaPicker, { kind: mediaKind, onPick: (file) => void insertMediaFile(mediaKind, file), onCancel: () => setMediaKind(null) }), _jsx("div", { ref: rootRef, className: "srte-editor", "data-placeholder": placeholder, contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-label": "Smart RTE editing surface", "aria-multiline": "true", onClick: (event) => {
1395
+ } }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "import", label: "Import", ariaLabel: "Import document", disabled: readOnly, onClick: () => importRef.current?.click() }), _jsx(ToolbarDropdown, { icon: "saveCopy", label: "Save a copy", priority: 2, children: saveCopyMenuItems }), _jsx(ToolbarButton, { icon: "history", label: "Version history", ariaLabel: "Version history", disabled: readOnly || !versionProvider, onClick: () => setVersionHistoryOpen(true) }), _jsx(ToolbarDropdown, { icon: "comments", label: "Review", priority: 2, children: reviewMenuItems })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "undo", label: "Undo", ariaLabel: "Undo", disabled: readOnly, onClick: () => { runtime.editor.undo(); runtime.focus(); } }), _jsx(ToolbarButton, { icon: "redo", label: "Redo", ariaLabel: "Redo", disabled: readOnly, onClick: () => { runtime.editor.redo(); runtime.focus(); } })] }), _jsxs(MobileMoreMenu, { children: [textStylesMenuItems, _jsx("div", { className: "srte-menu-separator" }), paragraphToolsMenuItems, _jsx("div", { className: "srte-menu-separator" }), listToolsMenuItems, _jsx("div", { className: "srte-menu-separator" }), insertMoreMenuItems, tablesEnabled && _jsxs(_Fragment, { children: [_jsx("div", { className: "srte-menu-separator" }), tableToolsMenuItems] }), _jsx("div", { className: "srte-menu-separator" }), saveCopyMenuItems, _jsx("div", { className: "srte-menu-separator" }), reviewMenuItems] })] }), mediaKind === "image" && mediaManager && mediaProvider && _jsx(MediaManager, { open: true, onClose: () => setMediaKind(null), adapter: mediaManagerAdapter, onSelect: selectFromMediaManager }), mediaKind && mediaProvider && !(mediaKind === "image" && mediaManager) && _jsx(MediaPicker, { kind: mediaKind, onPick: (file) => void insertMediaFile(mediaKind, file), onCancel: () => setMediaKind(null) }), versionProvider && _jsx(VersionHistoryPanel, { open: versionHistoryOpen, onClose: () => setVersionHistoryOpen(false), runtime: runtime, versionProvider: versionProvider }), commentProvider && rootRef.current && runtime.surface.renderer?.mapping && _jsx(CommentMarkers, { positions: runtime.editor.positions, mapping: runtime.surface.renderer.mapping, containerElement: rootRef.current, threads: threads, activeThreadId: activeThreadId, onSelectThread: selectThread }), commentProvider && _jsx(CommentThreadPanel, { open: commentPanelOpen, onClose: () => { setCommentPanelOpen(false); setPendingCommentRange(null); }, threads: threads, activeThreadId: activeThreadId, onSelectThread: selectThread, pendingRange: pendingCommentRange, onCreateThread: createThread, onReply: replyToThread, onResolve: setThreadResolved, onDelete: deleteThread, busyThreadId: busyThreadId }), suggestionProvider && rootRef.current && runtime.surface.renderer?.mapping && _jsx(StructuralSuggestionMarkers, { positions: runtime.editor.positions, mapping: runtime.surface.renderer.mapping, containerElement: rootRef.current, suggestions: structuralSuggestions, activeSuggestionId: activeSuggestionId, onSelectSuggestion: selectSuggestion }), suggestionProvider && _jsx(SuggestionPanel, { open: suggestionPanelOpen, onClose: () => { setSuggestionPanelOpen(false); setPendingSuggestInsertAt(null); }, document: runtime.editor.document, structuralSuggestions: structuralSuggestions, activeId: activeSuggestionId, onSelect: selectSuggestion, pendingInsert: Boolean(pendingSuggestInsertAt), onSubmitInsert: submitSuggestInsert, onAcceptInline: acceptInlineSuggestion, onRejectInline: rejectInlineSuggestion, onAcceptStructural: acceptStructural, onRejectStructural: rejectStructural, busyId: busySuggestionId }), linkPopover && _jsx(LinkEditorPopover, { x: linkPopover.x, y: linkPopover.y, initialHref: linkPopover.href, initialOpenInNewTab: linkPopover.openInNewTab, showTextInput: linkPopover.collapsed && !linkPopover.editingExisting, showRemove: linkPopover.editingExisting, autoFocus: !linkPopover.autoTriggered, onApply: applyLink, onRemove: removeLink, onCancel: () => {
1396
+ setLinkOverlayDismissedAt(runtime.editor.selection.head);
1397
+ setLinkPopover(null);
1398
+ runtime.focus();
1399
+ } }), !readOnly && mediaAtomSelected && selectedAtomElement && _jsx(MediaOverlay, { atomElement: selectedAtomElement, alt: typeof selectedAtomNode?.attrs?.alt === "string" ? selectedAtomNode.attrs.alt : "", width: typeof selectedAtomNode?.attrs?.width === "number" ? selectedAtomNode.attrs.width : undefined, height: typeof selectedAtomNode?.attrs?.height === "number" ? selectedAtomNode.attrs.height : undefined, src: typeof selectedAtomNode?.attrs?.src === "string" ? selectedAtomNode.attrs.src : undefined, resizable: resizableAtomSelected, onEdit: () => editSelectedAtom(), onResize: (by) => editSelectedAtom(by), onResizeTo: resizeSelectedAtomTo, onDelete: deleteSelectedAtom, onDismiss: () => runtime.focus() }), colorPopover && _jsx(ColorPickerPopover, { x: colorPopover.x, y: colorPopover.y, label: colorPopover.target.kind === "mark"
1400
+ ? (colorPopover.target.markId === "textColor" ? "Text colour" : "Background colour")
1401
+ : colorPopover.target.attr === "textColor" ? "Cell text colour" : "Cell background colour", ...(colorPopover.initialValue ? { initialValue: colorPopover.initialValue } : {}), recentColors: recentColors[colorBucketFor(colorPopover.target)], onPreview: previewColor, onApply: applyColor, onCancel: cancelColorPopover }), tableSizePopover && _jsx(TableSizePickerPopover, { x: tableSizePopover.x, y: tableSizePopover.y, onInsert: (rows, columns) => {
1402
+ setTableSizePopover(null);
1403
+ insertTable(rows, columns);
1404
+ runtime.focus();
1405
+ }, onCancel: () => {
1406
+ setTableSizePopover(null);
1407
+ runtime.focus();
1408
+ } }), tableBorderPopover && _jsx(TableBorderPopover, { x: tableBorderPopover.x, y: tableBorderPopover.y, initial: tableBorderPopover.initial, recentColors: recentColors.border, onPreview: previewCellBorder, onApply: applyCellBorderCommit, onCancel: cancelTableBorderPopover }), formulaLibraryPopover && _jsx(FormulaLibraryPopover, { x: formulaLibraryPopover.x, y: formulaLibraryPopover.y, onInsert: insertFormulaFromLibrary, onCancel: () => { setFormulaLibraryPopover(null); runtime.focus(); } }), specialCharPopover && _jsx(SpecialCharacterPopover, { x: specialCharPopover.x, y: specialCharPopover.y, recentCharacters: recentSpecialChars, onInsert: insertSpecialCharacter, onCancel: () => { setSpecialCharPopover(null); runtime.focus(); } }), !readOnly && selectedTableElement && _jsx(TableResizeHandles, { tableElement: selectedTableElement, onResizeColumn: resizeTableColumn, onResizeRow: resizeTableRow }), contextMenu && _jsx(ContextMenu, { x: contextMenu.x, y: contextMenu.y, items: resolveContextMenuItems(), onDismiss: () => { setContextMenu(null); runtime.focus(); } }), _jsx("div", { ref: rootRef, className: "srte-editor", "data-placeholder": placeholder, contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-label": "Smart RTE editing surface", "aria-multiline": "true", onMouseDown: (event) => {
1409
+ // A Ctrl/Cmd+click on a link is meant to open it (onClick below),
1410
+ // not move the caret into it - but native mousedown's own default
1411
+ // action *is* the caret-repositioning, and mousedown fires (and
1412
+ // its default action resolves) before click ever does. Blocking
1413
+ // it here means the caret never enters the link for this
1414
+ // gesture, so the auto-triggered edit overlay (which keys off
1415
+ // exactly that) never gets a reason to open in the first place -
1416
+ // root-caused at the source rather than racing a flag against
1417
+ // it after the fact. (A flag set in onClick was tried first and
1418
+ // proved unreliable: by the time onClick ran, Chromium/Firefox/
1419
+ // WebKit had already carried the caret-move through to a
1420
+ // completed render in a varying number of passes, so the overlay
1421
+ // could already be open before the flag was ever set.)
1422
+ const anchor = event.target?.closest("a[href]");
1423
+ if (anchor && (event.metaKey || event.ctrlKey))
1424
+ event.preventDefault();
1425
+ }, onClick: (event) => {
512
1426
  const anchor = event.target?.closest("a[href]");
513
1427
  if (!anchor || !(event.metaKey || event.ctrlKey))
514
1428
  return;
515
1429
  event.preventDefault();
516
1430
  window.open(anchor.href, "_blank", "noopener,noreferrer");
1431
+ }, onContextMenu: (event) => {
1432
+ if (readOnly)
1433
+ return;
1434
+ event.preventDefault();
1435
+ // Atom selection (unlike caret/text placement) is not native
1436
+ // browser behavior - it's InputController's own clickListener
1437
+ // (surface/input.ts), bound to the "click" DOM event, which never
1438
+ // fires for a right-click. Without this, right-clicking an atom
1439
+ // directly (no preceding left-click) left whatever selection was
1440
+ // already there in place, so atomic-node scope never resolved and
1441
+ // MediaOverlay (which shows whenever atomSelected is true) never
1442
+ // appeared - right-clicking media looked like right-clicking
1443
+ // nothing. Mirrors clickListener's own atom-selection logic
1444
+ // exactly.
1445
+ const atomTarget = event.target?.closest("[data-smart-atomic]");
1446
+ const mapped = atomTarget ? runtime.surface.renderer?.mapping.domToNode(atomTarget) : null;
1447
+ if (mapped && !isTextNode(mapped.node) && runtime.editor.schema.nodes[mapped.node.type]?.selectable === true) {
1448
+ const range = runtime.editor.positions.rangeOf(mapped.nodeId);
1449
+ if (range) {
1450
+ runtime.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "api" });
1451
+ runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
1452
+ }
1453
+ }
1454
+ else if (isCollapsedTextSelection(runtime.editor.selection)) {
1455
+ // Chromium/Firefox reposition the native caret to the click
1456
+ // point on a bare right-click, the same as a left-click - the
1457
+ // context menu items resolved against runtime.editor.selection
1458
+ // afterward were already scoped correctly there. WebKit does
1459
+ // not: a right-click leaves the previous selection untouched,
1460
+ // so scope resolution (and, before the marksForRange fix above,
1461
+ // an unrelated bug in it too) saw stale state instead of what
1462
+ // was actually clicked. Explicitly resolving the caret from the
1463
+ // click point - the same caretPositionFromPoint/caretRangeFromPoint
1464
+ // + mapping.domToPos pattern InputController's own
1465
+ // selectionForPoint already uses for drag/drop - makes this
1466
+ // right-click path correct on every engine instead of relying
1467
+ // on inconsistent native behavior. Only when the selection was
1468
+ // already collapsed - right-clicking a deliberately selected
1469
+ // range (e.g. to apply Bold from the menu) must never collapse
1470
+ // it out from under the user, matching standard context-menu
1471
+ // convention (this is what "right-click here does something
1472
+ // useful to my selection" depends on).
1473
+ const pointDocument = event.currentTarget.ownerDocument;
1474
+ const caret = pointDocument.caretPositionFromPoint?.(event.clientX, event.clientY);
1475
+ const fallback = !caret ? pointDocument.caretRangeFromPoint?.(event.clientX, event.clientY) : null;
1476
+ const pos = caret ? runtime.surface.renderer?.mapping.domToPos(caret.offsetNode, caret.offset)
1477
+ : fallback ? runtime.surface.renderer?.mapping.domToPos(fallback.startContainer, fallback.startOffset) : null;
1478
+ if (pos) {
1479
+ runtime.editor.setSelection({ type: "text", anchor: pos, head: pos }, { source: "api" });
1480
+ runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
1481
+ }
1482
+ }
1483
+ // Table-only now (context menu scope reduction) - media/link moved
1484
+ // to their own dedicated overlays, marks live on the toolbar only.
1485
+ // A right-click that doesn't resolve to a table cell opens nothing
1486
+ // at all, rather than an empty "No actions here" menu.
1487
+ const rightClickTableScope = runtime.editor.resolveScope({ want: "table-grid" });
1488
+ if ("kind" in rightClickTableScope && rightClickTableScope.kind === "table-grid") {
1489
+ setContextMenu({ x: event.clientX, y: event.clientY });
1490
+ }
517
1491
  }, style: { minHeight, maxHeight, overflow: "auto" } })] });
518
1492
  });