smartrte-react 0.3.5 → 1.0.0-beta.10

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 (147) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +205 -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 +65 -1
  25. package/dist/canonicalEditorRuntime.js +63 -5
  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 +56 -1
  33. package/dist/components/CanonicalAuthorityEditor.js +1304 -116
  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 +287 -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/MediaDetailsPopover.d.ts +35 -0
  49. package/dist/components/MediaDetailsPopover.js +136 -0
  50. package/dist/components/MediaManager.d.ts +4 -20
  51. package/dist/components/MediaManager.js +40 -11
  52. package/dist/components/MediaOverlay.d.ts +27 -0
  53. package/dist/components/MediaOverlay.js +228 -0
  54. package/dist/components/SpecialCharacterPopover.d.ts +18 -0
  55. package/dist/components/SpecialCharacterPopover.js +99 -0
  56. package/dist/components/StructuralSuggestionMarkers.d.ts +20 -0
  57. package/dist/components/StructuralSuggestionMarkers.js +56 -0
  58. package/dist/components/SuggestionPanel.d.ts +26 -0
  59. package/dist/components/SuggestionPanel.js +33 -0
  60. package/dist/components/TableBorderPopover.d.ts +59 -0
  61. package/dist/components/TableBorderPopover.js +149 -0
  62. package/dist/components/TableResizeHandles.d.ts +25 -0
  63. package/dist/components/TableResizeHandles.js +323 -0
  64. package/dist/components/TableSizePickerPopover.d.ts +16 -0
  65. package/dist/components/TableSizePickerPopover.js +126 -0
  66. package/dist/components/ToolbarPrimitives.d.ts +161 -0
  67. package/dist/components/ToolbarPrimitives.js +237 -0
  68. package/dist/components/VersionHistoryPanel.d.ts +9 -0
  69. package/dist/components/VersionHistoryPanel.js +157 -0
  70. package/dist/components/fixedPositioning.d.ts +25 -0
  71. package/dist/components/fixedPositioning.js +42 -0
  72. package/dist/formulaLibrary.d.ts +31 -0
  73. package/dist/formulaLibrary.js +90 -0
  74. package/dist/index.d.ts +25 -24
  75. package/dist/index.js +15 -11
  76. package/dist/mediaManagerAdapter.d.ts +12 -0
  77. package/dist/mediaManagerAdapter.js +34 -0
  78. package/dist/mediaProvider.d.ts +21 -1
  79. package/dist/specialCharacters.d.ts +22 -0
  80. package/dist/specialCharacters.js +86 -0
  81. package/dist/standalone/classic-editor-embed.js +19 -12
  82. package/dist/suggestionProvider.d.ts +17 -0
  83. package/dist/suggestionProvider.js +1 -0
  84. package/dist/theme.d.ts +1 -1
  85. package/dist/theme.js +219 -30
  86. package/dist/toolbarTools.d.ts +95 -0
  87. package/dist/toolbarTools.js +17 -0
  88. package/dist/versionProvider.d.ts +29 -0
  89. package/dist/versionProvider.js +1 -0
  90. package/package.json +28 -14
  91. package/dist/adapters/canonicalInlineCommandBridge.d.ts +0 -15
  92. package/dist/adapters/canonicalInlineCommandBridge.js +0 -269
  93. package/dist/adapters/canonicalListCommandBridge.d.ts +0 -26
  94. package/dist/adapters/canonicalListCommandBridge.js +0 -430
  95. package/dist/adapters/coreBold.d.ts +0 -5
  96. package/dist/adapters/coreBold.js +0 -3
  97. package/dist/adapters/documentFormats.d.ts +0 -26
  98. package/dist/adapters/documentFormats.js +0 -49
  99. package/dist/adapters/docxFormat.d.ts +0 -5
  100. package/dist/adapters/docxFormat.js +0 -272
  101. package/dist/adapters/domBlockCommandBridge.d.ts +0 -19
  102. package/dist/adapters/domBlockCommandBridge.js +0 -79
  103. package/dist/adapters/inlineMarkCoreExecution.d.ts +0 -9
  104. package/dist/adapters/inlineMarkCoreExecution.js +0 -25
  105. package/dist/adapters/pdfFormat.d.ts +0 -23
  106. package/dist/adapters/pdfFormat.js +0 -204
  107. package/dist/adapters/portableDocxAtoms.d.ts +0 -4
  108. package/dist/adapters/portableDocxAtoms.js +0 -58
  109. package/dist/adapters/styledDocxFormat.d.ts +0 -8
  110. package/dist/adapters/styledDocxFormat.js +0 -190
  111. package/dist/builtInFormatDefinitions.d.ts +0 -5
  112. package/dist/builtInFormatDefinitions.js +0 -82
  113. package/dist/canonicalAuthorityFlag.d.ts +0 -32
  114. package/dist/canonicalAuthorityFlag.js +0 -49
  115. package/dist/canonicalClipboardRuntime.d.ts +0 -15
  116. package/dist/canonicalClipboardRuntime.js +0 -76
  117. package/dist/components/ClassicEditor.d.ts +0 -76
  118. package/dist/components/ClassicEditor.js +0 -6729
  119. package/dist/editorController.d.ts +0 -78
  120. package/dist/editorController.js +0 -298
  121. package/dist/formatFidelity.d.ts +0 -14
  122. package/dist/formatFidelity.js +0 -107
  123. package/dist/formatRuntime.d.ts +0 -50
  124. package/dist/formatRuntime.js +0 -20
  125. package/dist/pluginRuntime.d.ts +0 -68
  126. package/dist/pluginRuntime.js +0 -78
  127. package/dist/standalone/editor.js +0 -1068
  128. package/dist/test-harness/atomShadowComparator.d.ts +0 -14
  129. package/dist/test-harness/atomShadowComparator.js +0 -63
  130. package/dist/test-harness/blockShadowComparator.d.ts +0 -30
  131. package/dist/test-harness/blockShadowComparator.js +0 -140
  132. package/dist/test-harness/clipboardShadowComparator.d.ts +0 -12
  133. package/dist/test-harness/clipboardShadowComparator.js +0 -46
  134. package/dist/test-harness/inlineShadowComparator.d.ts +0 -32
  135. package/dist/test-harness/inlineShadowComparator.js +0 -152
  136. package/dist/test-harness/legacyAtomEngine.d.ts +0 -10
  137. package/dist/test-harness/legacyAtomEngine.js +0 -15
  138. package/dist/test-harness/legacyBlockEngine.d.ts +0 -31
  139. package/dist/test-harness/legacyBlockEngine.js +0 -31
  140. package/dist/test-harness/legacyClipboardEngine.d.ts +0 -14
  141. package/dist/test-harness/legacyClipboardEngine.js +0 -67
  142. package/dist/test-harness/legacyInlineEngine.d.ts +0 -19
  143. package/dist/test-harness/legacyInlineEngine.js +0 -49
  144. package/dist/test-harness/legacyTableEngine.d.ts +0 -12
  145. package/dist/test-harness/legacyTableEngine.js +0 -25
  146. package/dist/test-harness/tableShadowComparator.d.ts +0 -29
  147. package/dist/test-harness/tableShadowComparator.js +0 -101
@@ -1,20 +1,31 @@
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 { MediaDetailsPopover } from "./MediaDetailsPopover.js";
23
+ import { ContextMenu } from "./ContextMenu.js";
24
+ import { ToolbarButton, ToolbarDropdown, ToolbarGroup, ToolbarMenuItem, MobileMoreMenu } from "./ToolbarPrimitives.js";
25
+ import { resolveToolbarTools } from "../toolbarTools.js";
26
+ import { exportDocxDocument, importStyledDocxDocument, importPdfDocument } from "smartrte-core/foundation";
27
+ import { printSmartDocumentAsPdf } from "../adapters/pdfPrint.js";
11
28
  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
29
  const listSelectionParts = (scope) => {
19
30
  if (scope.kind === "list-selection")
20
31
  return [scope];
@@ -50,15 +61,98 @@ const downloadBlob = (ownerDocument, name, blob) => {
50
61
  anchor.click();
51
62
  URL.revokeObjectURL(url);
52
63
  };
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) {
64
+ const CONTEXT_MENU_DANGER_COMMANDS = new Set(["table.remove", "table.removeRow", "table.removeColumn", "atom.delete"]);
65
+ /**
66
+ * Recently-used colors are bucketed by "text-like" vs "background-like"
67
+ * rather than by the 4 exact target shapes (mark.textColor, mark.
68
+ * backgroundColor, cell.textColor, cell.background) - a user picking "our
69
+ * brand navy" almost always means the same thing whether it's landing on a
70
+ * text selection or a table cell, and a combined-across-everything list
71
+ * would mix genuinely different intents (a text color next to a page
72
+ * background color) into one row. In-memory only, for the current editor
73
+ * instance's lifetime: this project has no existing pattern for persisting
74
+ * light UI preference state across sessions (no localStorage/sessionStorage
75
+ * usage anywhere in the codebase), and building new persistence
76
+ * infrastructure for this one feature would be disproportionate.
77
+ */
78
+ const RECENT_COLOR_LIMIT = 4;
79
+ const colorBucketFor = (target) => target.kind === "mark" ? (target.markId === "textColor" ? "text" : "background")
80
+ : target.attr === "textColor" ? "text" : "background";
81
+ /**
82
+ * table_cell's border attrs (schema.ts: legacy uniform `borders`, plus
83
+ * per-side `borderTop`/`borderRight`/`borderBottom`/`borderLeft` overrides
84
+ * added for the "Border options" popover's per-side control) are free-form
85
+ * CSS border shorthand strings, already rendered (surface/renderer.ts) and
86
+ * round-tripped through HTML/DOCX. These compose/parse the one shape this
87
+ * UI itself ever writes ("{width}px {style} {hex}") - a value pasted in some
88
+ * other shape still renders fine (the renderer applies the raw string
89
+ * unconditionally) but won't round-trip through these two helpers, which is
90
+ * fine: they exist only to seed/update this UI's own controls, not to be a
91
+ * general CSS border parser.
92
+ */
93
+ const composeBorderShorthand = (widthPx, style, hex) => `${widthPx}px ${style} ${hex}`;
94
+ const parseBorderShorthand = (value) => {
95
+ if (typeof value !== "string")
96
+ return null;
97
+ const width = /(\d+(?:\.\d+)?)\s*px/.exec(value);
98
+ const color = /#[0-9a-f]{3,8}\b/i.exec(value);
99
+ if (!width || !color)
100
+ return null;
101
+ const styleMatch = /\b(solid|dashed|dotted)\b/i.exec(value);
102
+ return { widthPx: Number(width[1]), style: styleMatch?.[1].toLowerCase() ?? "solid", hex: color[0].toLowerCase() };
103
+ };
104
+ const isCollapsedTextSelection = (selection) => selection.type === "text" && selection.anchor.offset === selection.head.offset
105
+ && selection.anchor.path.length === selection.head.path.length
106
+ && selection.anchor.path.every((part, index) => part === selection.head.path[index]);
107
+ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEditor({ defaultValue, preset, renderFormulaHtml, onChange, onHtmlChange, onClipboardDiagnostic, mediaProvider, mediaPicker: MediaPicker = DefaultMediaPicker, mediaManager = true, tools, versionProvider, commentProvider, suggestionProvider, authorId, placeholder = "Type here…", minHeight = 200, maxHeight = 500, readOnly = false, className, onRuntime, }, forwardedRef) {
54
108
  ensureStyleSheet();
55
109
  const rootRef = useRef(null);
56
110
  const importRef = useRef(null);
57
111
  const runtimeRef = useRef();
58
112
  const pendingMediaUploads = useRef(new Set());
59
113
  const [mediaKind, setMediaKind] = useState(null);
114
+ const [versionHistoryOpen, setVersionHistoryOpen] = useState(false);
115
+ const [threads, setThreads] = useState([]);
116
+ const [commentPanelOpen, setCommentPanelOpen] = useState(false);
117
+ const [activeThreadId, setActiveThreadId] = useState(null);
118
+ const [pendingCommentRange, setPendingCommentRange] = useState(null);
119
+ const [busyThreadId, setBusyThreadId] = useState(null);
120
+ const beforeCommentDocumentRef = useRef(null);
121
+ const [structuralSuggestions, setStructuralSuggestions] = useState([]);
122
+ const [suggestionPanelOpen, setSuggestionPanelOpen] = useState(false);
123
+ const [activeSuggestionId, setActiveSuggestionId] = useState(null);
124
+ const [pendingSuggestInsertAt, setPendingSuggestInsertAt] = useState(null);
125
+ const [busySuggestionId, setBusySuggestionId] = useState(null);
126
+ const beforeSuggestionDocumentRef = useRef(null);
127
+ const [trackChangesEnabled, setTrackChangesEnabled] = useState(false);
128
+ const mediaManagerAdapter = useMemo(() => mediaProvider ? mediaManagerAdapterFrom(mediaProvider) : null, [mediaProvider]);
129
+ const [linkPopover, setLinkPopover] = useState(null);
130
+ const [colorPopover, setColorPopover] = useState(null);
131
+ const [recentColors, setRecentColors] = useState({ text: [], background: [], border: [] });
132
+ const [tableSizePopover, setTableSizePopover] = useState(null);
133
+ const [tableBorderPopover, setTableBorderPopover] = useState(null);
134
+ const [mediaDetailsPopover, setMediaDetailsPopover] = useState(null);
135
+ const borderPreviewCheckpointRef = useRef(null);
136
+ const [formulaLibraryPopover, setFormulaLibraryPopover] = useState(null);
137
+ const [specialCharPopover, setSpecialCharPopover] = useState(null);
138
+ const [recentSpecialChars, setRecentSpecialChars] = useState([]);
139
+ // Set once the native color input's first live-preview frame fires for
140
+ // the currently-open popover, cleared on commit/cancel - lets both paths
141
+ // tell "a preview is in progress that needs to be unwound" from "the user
142
+ // never touched the native input at all" (the latter needs no unwinding,
143
+ // since nothing was ever applied to the model).
144
+ const colorPreviewCheckpointRef = useRef(null);
145
+ const [contextMenu, setContextMenu] = useState(null);
146
+ const [mediaContextMenu, setMediaContextMenu] = useState(null);
147
+ const [mediaResizeTarget, setMediaResizeTarget] = useState(null);
148
+ // Tracks the exact caret position (path+offset) the link overlay was
149
+ // last dismissed at (Escape/outside click) - suppresses an instant
150
+ // re-popup while the caret hasn't moved, without needing a separate
151
+ // "is this the same link" identity check. Cleared implicitly the moment
152
+ // selection.head differs from this value.
153
+ const [linkOverlayDismissedAt, setLinkOverlayDismissedAt] = useState(null);
60
154
  if (!runtimeRef.current)
61
- runtimeRef.current = new CanonicalEditorRuntime({ initialValue: defaultValue, onChange, onHtmlChange, onClipboardDiagnostic });
155
+ runtimeRef.current = new CanonicalEditorRuntime({ initialValue: defaultValue, preset, renderFormulaHtml, onChange, onHtmlChange, onClipboardDiagnostic });
62
156
  const runtime = runtimeRef.current;
63
157
  const [, setEditorTick] = useState(0);
64
158
  runtime.setCallbacks(onChange, onHtmlChange);
@@ -76,11 +170,137 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
76
170
  };
77
171
  }, [runtime, onRuntime]);
78
172
  useEffect(() => runtime.editor.subscribe(() => setEditorTick((value) => value + 1)), [runtime]);
173
+ useEffect(() => {
174
+ if (!commentProvider) {
175
+ setThreads([]);
176
+ return;
177
+ }
178
+ let cancelled = false;
179
+ (async () => {
180
+ try {
181
+ const loaded = await commentProvider.list();
182
+ if (!cancelled)
183
+ setThreads([...loaded]);
184
+ }
185
+ catch {
186
+ // A host's initial load failing just means threads/markers start
187
+ // empty - the same degraded-but-not-broken behaviour as when no
188
+ // commentProvider is supplied at all.
189
+ }
190
+ })();
191
+ return () => { cancelled = true; };
192
+ }, [commentProvider]);
193
+ // Replays each committed transaction's operations against the document
194
+ // as it existed immediately before that transaction (tracked in this ref
195
+ // across commits, exactly the `beforeDocument` rebaseCommentThreadsThroughTransaction
196
+ // needs) so thread ranges stay anchored through structural edits.
197
+ // Reference-identity is preserved for every thread the transaction didn't
198
+ // actually touch (see annotations/range.ts's rebaseThroughMergedRemove),
199
+ // so only genuinely-changed threads get persisted back to commentProvider.
200
+ useEffect(() => {
201
+ beforeCommentDocumentRef.current = runtime.editor.document;
202
+ return runtime.editor.subscribe((transaction, state) => {
203
+ const before = beforeCommentDocumentRef.current;
204
+ beforeCommentDocumentRef.current = state.document;
205
+ if (!before)
206
+ return;
207
+ setThreads((current) => {
208
+ if (!current.length)
209
+ return current;
210
+ const rebased = rebaseCommentThreadsThroughTransaction(current, before, transaction, foundationSchema);
211
+ if (rebased === current)
212
+ return current;
213
+ if (commentProvider) {
214
+ rebased.forEach((thread, index) => {
215
+ if (thread !== current[index])
216
+ void commentProvider.save(thread).catch(() => { });
217
+ });
218
+ }
219
+ return [...rebased];
220
+ });
221
+ });
222
+ }, [runtime, commentProvider]);
223
+ useEffect(() => {
224
+ if (!suggestionProvider) {
225
+ setStructuralSuggestions([]);
226
+ return;
227
+ }
228
+ let cancelled = false;
229
+ (async () => {
230
+ try {
231
+ const loaded = await suggestionProvider.list();
232
+ if (!cancelled)
233
+ setStructuralSuggestions([...loaded]);
234
+ }
235
+ catch {
236
+ // Degrades the same way an empty/absent provider does - see the
237
+ // matching comment-load effect above.
238
+ }
239
+ })();
240
+ return () => { cancelled = true; };
241
+ }, [suggestionProvider]);
242
+ // Mirrors the comment-rebase effect above exactly (same reasoning, same
243
+ // reference-identity-preserving persistence), kept as an independent
244
+ // effect/ref rather than merged into it, matching how the core layer
245
+ // keeps comments/rebase.ts and suggestions/structural.ts as separate,
246
+ // structurally-parallel modules instead of sharing code.
247
+ useEffect(() => {
248
+ beforeSuggestionDocumentRef.current = runtime.editor.document;
249
+ return runtime.editor.subscribe((transaction, state) => {
250
+ const before = beforeSuggestionDocumentRef.current;
251
+ beforeSuggestionDocumentRef.current = state.document;
252
+ if (!before)
253
+ return;
254
+ setStructuralSuggestions((current) => {
255
+ if (!current.length)
256
+ return current;
257
+ const rebased = rebaseStructuralSuggestionsThroughTransaction(current, before, transaction, foundationSchema);
258
+ if (rebased === current)
259
+ return current;
260
+ if (suggestionProvider) {
261
+ rebased.forEach((suggestion, index) => {
262
+ if (suggestion !== current[index])
263
+ void suggestionProvider.save(suggestion).catch(() => { });
264
+ });
265
+ }
266
+ return [...rebased];
267
+ });
268
+ });
269
+ }, [runtime, suggestionProvider]);
79
270
  const transactBlock = (operations) => {
80
271
  runtime.executeOperations(operations);
81
272
  };
82
273
  const blockScope = () => runtime.editor.resolveScope({ want: "block-range" });
83
274
  const blockContext = () => ({ schema: runtime.editor.schema, positions: runtime.editor.positions });
275
+ /**
276
+ * `SelectionDescription` (resolveScope({want:"describe"})) tracks mark
277
+ * coverage (markCoverage below) but has no equivalent for block
278
+ * *attributes* - `blockTypes` is the closest thing, and it's just node
279
+ * types, not attrs. This mirrors foundation/block/commands.ts's own
280
+ * (core-internal, not exported) blockScopes/selectedBlockIds traversal -
281
+ * a "mixed" scope is a caret/selection spanning more than one real
282
+ * block-range part (e.g. a paragraph plus a list item), each of which
283
+ * carries its own blockIds.
284
+ */
285
+ const selectedLineHeightBlockIds = (scope) => {
286
+ const collect = (candidate) => candidate.kind === "block-range" ? candidate.blockIds
287
+ : candidate.kind === "mixed" ? candidate.parts.flatMap(collect)
288
+ : [];
289
+ return [...new Set(collect(scope))];
290
+ };
291
+ /** `undefined` = no block selected/no override anywhere, a number = every selected block shares that exact value, `"mixed"` = selected blocks disagree - same three-state shape as markCoverage's boolean|"mixed"|undefined. */
292
+ const currentLineHeight = () => {
293
+ const ids = selectedLineHeightBlockIds(blockScope());
294
+ if (!ids.length)
295
+ return undefined;
296
+ const values = ids.map((id) => {
297
+ const value = findNode(runtime.editor.document, id)?.attrs?.lineHeight;
298
+ return typeof value === "number" ? value : undefined;
299
+ });
300
+ const unique = [...new Set(values)];
301
+ return unique.length === 1 ? unique[0] : "mixed";
302
+ };
303
+ const setLineHeight = (value) => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { lineHeight: value } }, blockContext()));
84
304
  const listScope = () => runtime.editor.resolveScope({ want: "list-selection" });
85
305
  const tableScope = () => runtime.editor.resolveScope({ want: "table-grid" });
86
306
  const atomScope = () => runtime.editor.resolveScope({ want: "atomic-node" });
@@ -124,17 +344,141 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
124
344
  const canMoveUp = currentListStates.some(({ indexes }) => indexes.length > 0 && Math.min(...indexes) > 0);
125
345
  const canMoveDown = currentListStates.some(({ indexes, list }) => indexes.length > 0
126
346
  && Math.max(...indexes) < (list?.children?.length || 0) - 1);
347
+ // Whether the "table" plugin was included when this instance was
348
+ // constructed (see capabilityPresets.ts's "simple" preset) - the toolbar
349
+ // JSX is hand-authored, not driven by the plugin registry's own toolbar
350
+ // contributions the way the context menu already is, so this needs an
351
+ // explicit check to hide table-insertion UI for a schema that can't
352
+ // represent tables at all. Existing content containing a table, loaded
353
+ // under a schema without the table plugin, still round-trips safely as
354
+ // an `unknown` node (Phase 10's disable-safety contract, unchanged) -
355
+ // this only hides the *toolbar affordance* for creating new ones.
356
+ const tablesEnabled = Boolean(runtime.editor.schema.nodes.table);
357
+ // Per-tool toolbar visibility (toolbarTools.ts) - `tools` is a pure
358
+ // UI-visibility layer, composed with (never overriding) the deeper
359
+ // existing gates below: a required provider being absent, or a plugin
360
+ // having been excluded via `preset`, still wins even if a tool is
361
+ // explicitly left enabled here.
362
+ const t = resolveToolbarTools(tools);
363
+ const showInsertTable = t.insertTable && tablesEnabled;
364
+ const showImageTool = t.image && Boolean(mediaProvider);
365
+ const showVideoTool = t.video && Boolean(mediaProvider);
366
+ const showAudioTool = t.audio && Boolean(mediaProvider);
367
+ const showVersionHistoryTool = t.versionHistory && Boolean(versionProvider);
368
+ const showCommentsTool = t.comments && Boolean(commentProvider);
369
+ const showSuggestionsTool = t.suggestions && Boolean(suggestionProvider);
127
370
  const tableSelected = currentTableScope.kind === "table-grid";
371
+ const selectedTableElement = tableSelected
372
+ ? runtime.surface.renderer?.mapping.nodeToDom(currentTableScope.tableId)
373
+ : undefined;
128
374
  const atomSelected = currentAtomScope.kind === "atomic-node";
375
+ const selectedAtomElement = atomSelected
376
+ ? runtime.surface.renderer?.mapping.nodeToDom(currentAtomScope.nodeId)
377
+ : undefined;
378
+ const selectedAtomNode = atomSelected ? findNode(runtime.editor.document, currentAtomScope.nodeId) : null;
379
+ // A divider (<hr>) or page break is atomic/selectable (so ordinary caret/
380
+ // Backspace/Delete behavior around it works, and the toolbar's "Delete
381
+ // selected atom" button applies to it), but neither has any src/alt/
382
+ // width/height at all - MediaOverlay and the media-specific Edit/Resize
383
+ // actions assume every atom is a real media item, and unconditionally
384
+ // showing them for one of these surfaced nonsensical empty/undefined
385
+ // values with no sensible edit or resize target. Media-only UI is gated
386
+ // on this instead of plain atomSelected; delete stays available for any
387
+ // atom.
388
+ const mediaAtomSelected = atomSelected && selectedAtomNode?.type !== "divider" && selectedAtomNode?.type !== "page_break";
389
+ // Formula (like divider above) has no width/height concept - KaTeX sizes
390
+ // its own rendering from the source/font-size, not stored dimensions, so
391
+ // "Enlarge/Shrink selected media" and MediaOverlay's resize handle had no
392
+ // real effect other than silently persisting meaningless width/height
393
+ // attrs onto the formula node (see
394
+ // docs/bugs/formula-resize-controls-shown-for-non-resizable-atom.md).
395
+ // Edit/Delete stay available - editSelectedAtom already branches on
396
+ // node.type.includes("formula") for its own correct "Formula source"
397
+ // prompt.
398
+ const resizableAtomSelected = mediaAtomSelected && selectedAtomNode?.type !== "formula" && selectedAtomNode?.type !== "block_formula";
399
+ // A divider/page break has no editable field and no width/height, but it
400
+ // is still a real, selectable, deletable atom - right-clicking one should
401
+ // surface the same kind of Delete-only action menu formula already gets
402
+ // (docs/bugs/horizontal-line-not-visibly-selectable.md), not the silence
403
+ // `mediaAtomSelected` alone produces (that flag exists to hide fields
404
+ // that don't apply, not to hide a delete affordance entirely).
405
+ const actionOnlyAtomSelected = atomSelected && (selectedAtomNode?.type === "divider" || selectedAtomNode?.type === "page_break");
406
+ const selectedAtomId = atomSelected && "nodeId" in currentAtomScope ? currentAtomScope.nodeId : null;
407
+ useEffect(() => {
408
+ if (mediaContextMenu && (!(mediaAtomSelected || actionOnlyAtomSelected) || mediaContextMenu.nodeId !== selectedAtomId))
409
+ setMediaContextMenu(null);
410
+ }, [mediaContextMenu, mediaAtomSelected, actionOnlyAtomSelected, selectedAtomId]);
411
+ useEffect(() => {
412
+ if (mediaResizeTarget && (!mediaAtomSelected || mediaResizeTarget.nodeId !== selectedAtomId))
413
+ setMediaResizeTarget(null);
414
+ }, [mediaResizeTarget, mediaAtomSelected, selectedAtomId]);
415
+ // Context menu scope reduction: link no longer gets a right-click menu -
416
+ // LinkEditorPopover (already built and wired for the toolbar's Link
417
+ // button, per §1's investigation) auto-appears, anchored to the link's
418
+ // own DOM bounds, whenever the caret is inside an existing link -
419
+ // exactly the same description.marks lookup the toolbar button and the
420
+ // old context-menu items already used to detect "is there a link here."
421
+ const linkDescription = runtime.editor.resolveScope({ want: "describe" });
422
+ const currentLinkEntry = linkDescription.marks?.find((entry) => entry.mark.type === "link");
423
+ const selectionHead = runtime.editor.selection.head;
424
+ const dismissedAtCurrentPosition = Boolean(linkOverlayDismissedAt)
425
+ && linkOverlayDismissedAt.offset === selectionHead.offset
426
+ && linkOverlayDismissedAt.path.length === selectionHead.path.length
427
+ && linkOverlayDismissedAt.path.every((part, index) => part === selectionHead.path[index]);
428
+ const linkAnchorElement = currentLinkEntry && !linkPopover && !dismissedAtCurrentPosition
429
+ ? (() => {
430
+ const domPos = runtime.surface.renderer?.mapping.posToDom(selectionHead);
431
+ const node = domPos?.node;
432
+ const element = (node instanceof Element ? node : node?.parentElement) ?? null;
433
+ return element?.closest("a[href]") ?? null;
434
+ })()
435
+ : null;
436
+ useEffect(() => {
437
+ if (!linkAnchorElement || !currentLinkEntry)
438
+ return;
439
+ const rect = linkAnchorElement.getBoundingClientRect();
440
+ setLinkPopover({
441
+ x: rect.left, y: rect.bottom + 6,
442
+ editingExisting: true,
443
+ href: typeof currentLinkEntry.mark.attrs?.href === "string" ? currentLinkEntry.mark.attrs.href : "",
444
+ openInNewTab: currentLinkEntry.mark.attrs?.target === "_blank",
445
+ collapsed: linkDescription.collapsed,
446
+ autoTriggered: true,
447
+ });
448
+ // Only the DOM element identity matters here - re-running for every
449
+ // keystroke re-render while the caret stays inside the same link (same
450
+ // element, new object each render otherwise) would fight the user
451
+ // editing the popover's own fields.
452
+ // eslint-disable-next-line react-hooks/exhaustive-deps
453
+ }, [linkAnchorElement]);
454
+ const hasLinkAtCursor = Boolean(currentLinkEntry);
455
+ useEffect(() => {
456
+ // Dismiss when the selection moves off the link the popover was opened
457
+ // for - covers arrow-key navigation and any other selection change
458
+ // that isn't itself a popover action (apply/remove/cancel already
459
+ // clear linkPopover directly).
460
+ if (linkPopover?.editingExisting && !hasLinkAtCursor)
461
+ setLinkPopover(null);
462
+ // eslint-disable-next-line react-hooks/exhaustive-deps
463
+ }, [hasLinkAtCursor]);
129
464
  const blockTypeAt = (position) => {
130
465
  let node = runtime.editor.document;
131
466
  for (let depth = 0; depth <= position.path.length; depth += 1) {
132
467
  if (!isTextNode(node) && node.type !== "doc" && runtime.editor.schema.nodes[node.type]?.group === "block") {
468
+ // paragraph/heading/code_block are this dropdown's own options -
469
+ // answer immediately. Everything else block-group is either a
470
+ // container that holds further blocks (blockquote, list, list_item,
471
+ // table/table_row/table_cell) - keep walking the path to find the
472
+ // actual innermost block instead of answering for the wrapper - or
473
+ // an atomic node with no dropdown answer of its own, which falls
474
+ // through to the loop's own "paragraph" default below once it runs
475
+ // out of path to follow.
133
476
  if (node.type === "code_block")
134
477
  return "code_block";
135
478
  if (node.type === "heading")
136
479
  return `heading-${Number(node.attrs?.level || 1)}`;
137
- return "paragraph";
480
+ if (node.type === "paragraph")
481
+ return "paragraph";
138
482
  }
139
483
  if (depth === position.path.length || isTextNode(node))
140
484
  break;
@@ -166,19 +510,40 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
166
510
  return;
167
511
  runtime.executeOperations(restartListNumbering(runtime.editor.document, currentListScope, { start }, blockContext()), { preserveSelectionById: true });
168
512
  };
513
+ // A list's "current style" the toggle buttons care about (bullet vs.
514
+ // ordered) can come from either a literal .attrs.style (disc/decimal, what
515
+ // these buttons themselves write) *or* a .attrs.preset (bullet-diamond,
516
+ // ordered-upper-alpha, etc. - the preset dropdown clears .attrs.style when
517
+ // setting one, see docs/bugs/list-marker-competing-style-and-preset-signals.md).
518
+ // Comparing raw strings only ever matched the literal case - after picking
519
+ // a preset, every toggle button read as "not active" (style is unset) even
520
+ // though the list still visually is a bullet/ordered list, so the first
521
+ // click on the matching button silently replaced the preset with a bare
522
+ // disc/decimal instead of removing the list, and only a *second* click
523
+ // actually toggled it off. Comparing by kind (bullet/ordered) instead
524
+ // fixes both the toolbar's own pressed-state display and the toggle
525
+ // decision in one place, so they can't drift apart from each other again.
526
+ const BULLET_LIST_STYLES = new Set(["disc", "circle", "square"]);
527
+ const listActiveKind = (list) => {
528
+ if (typeof list?.attrs?.style === "string")
529
+ return BULLET_LIST_STYLES.has(list.attrs.style) ? "bullet" : "ordered";
530
+ if (typeof list?.attrs?.preset === "string")
531
+ return SMART_LIST_PRESETS.find((preset) => preset.id === list.attrs?.preset)?.kind;
532
+ return undefined;
533
+ };
169
534
  // Shared by the toggle buttons' click handler and their aria-pressed state,
170
535
  // so "this button looks active" and "clicking it again removes the list"
171
536
  // can never drift apart. Checked against the outermost list (see
172
537
  // outermostListId) so a deeply nested cursor still reports the true
173
538
  // 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;
539
+ const listStyleActive = (style, checkable = false) => listActiveKind(rootList) === (style === "decimal" ? "ordered" : "bullet") && Boolean(rootList?.attrs?.checkable) === checkable;
175
540
  const toggleList = (style, checkable = false) => {
176
541
  const selectedList = listScope();
177
542
  const context = blockContext();
178
543
  if (selectedList.kind === "list-selection") {
179
544
  const rootId = outermostListId(selectedList.listId);
180
545
  const list = findNode(runtime.editor.document, rootId);
181
- const sameStyle = list?.attrs?.style === style && Boolean(list.attrs?.checkable) === checkable;
546
+ const sameStyle = listActiveKind(list) === (style === "decimal" ? "ordered" : "bullet") && Boolean(list?.attrs?.checkable) === checkable;
182
547
  const operations = sameStyle
183
548
  // Toggling an already-active style off is deliberately scoped to just
184
549
  // the current item (selectedList), not the whole list.
@@ -206,37 +571,191 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
206
571
  : moveListItems(runtime.editor.document, scope, { direction: action }, context);
207
572
  runtime.executeOperations(operations, { preserveSelectionById: true });
208
573
  };
209
- const applyAttributedMark = (id) => {
574
+ /** Shared by the prompt-based fontSize/fontFamily path and the popover-based color path below. */
575
+ const applyMarkAttrs = (id, attrs, options = {}) => {
210
576
  const declaration = inlineToolDeclarations.find((tool) => tool.id === id);
211
- if (!declaration)
577
+ if (!declaration || !attrs)
212
578
  return;
213
- const attrs = id === "textColor" || id === "backgroundColor"
579
+ try {
580
+ executeMarkTool(runtime.editor, declaration, "apply", attrs, options);
581
+ // Skipped during a live preview (addToHistory: false) - see the
582
+ // matching comment in canonicalEditorRuntime.ts's executeOperations,
583
+ // same reasoning: focusing the main editor mid-drag would steal focus
584
+ // away from the color popover's own native input.
585
+ if (options.addToHistory ?? true)
586
+ runtime.focus();
587
+ }
588
+ catch (error) {
589
+ window.alert(error instanceof Error ? error.message : "Invalid formatting value.");
590
+ }
591
+ };
592
+ const applyAttributedMark = (id) => {
593
+ const attrs = id === "fontSize"
214
594
  ? (() => {
215
- const value = window.prompt(id === "textColor" ? "Text colour" : "Background colour", "#000000");
216
- return value ? { value } : undefined;
595
+ const value = window.prompt("Font size (px)", "16");
596
+ const valuePx = value === null ? undefined : Number(value);
597
+ return Number.isFinite(valuePx) ? { valuePx } : undefined;
217
598
  })()
218
- : id === "fontSize"
599
+ : id === "fontFamily"
219
600
  ? (() => {
220
- const value = window.prompt("Font size (px)", "16");
221
- const valuePx = value === null ? undefined : Number(value);
222
- return Number.isFinite(valuePx) ? { valuePx } : undefined;
601
+ const value = window.prompt("Font family", "system-ui");
602
+ return value ? { value } : undefined;
223
603
  })()
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)
604
+ : undefined;
605
+ applyMarkAttrs(id, attrs);
606
+ };
607
+ /**
608
+ * Phase 11.5 §2.4: ColorPickerPopover (swatch grid + hex input) replaces
609
+ * window.prompt("#000000") for textColor/backgroundColor. Same
610
+ * applyMarkAttrs call as before - no command-layer change. Post-batch:
611
+ * the same popover also drives table_cell.attrs.background/textColor
612
+ * ("cell style" the table context menu previously had none of) via
613
+ * table.setCellAttributes instead of a mark - a different command, not
614
+ * a different UI.
615
+ */
616
+ /**
617
+ * The color picker previously always opened blank ("#000000") regardless
618
+ * of the caret's or cell's actual current color - these compute what to
619
+ * seed it with, read at the moment the popover opens (not re-derived
620
+ * while it stays open, since the underlying selection/cell isn't
621
+ * expected to change out from under an open picker).
622
+ */
623
+ const currentMarkColor = (markId) => {
624
+ const description = runtime.editor.resolveScope({ want: "describe" });
625
+ const value = description.marks.find((entry) => entry.mark.type === markId)?.mark.attrs?.value;
626
+ return typeof value === "string" ? value : undefined;
627
+ };
628
+ const currentCellColor = (attr) => {
629
+ const scope = tableScope();
630
+ if (!("kind" in scope) || scope.kind !== "table-grid" || !scope.cellIds.length)
631
+ return undefined;
632
+ const cell = findNode(runtime.editor.document, scope.cellIds[0]);
633
+ const value = cell?.attrs?.[attr];
634
+ return typeof value === "string" ? value : undefined;
635
+ };
636
+ const CELL_BORDER_SIDE_KEYS = { top: "borderTop", right: "borderRight", bottom: "borderBottom", left: "borderLeft" };
637
+ /** 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. */
638
+ const currentCellBorderDraft = (scope) => {
639
+ const cell = scope.kind === "table-grid" && scope.cellIds.length
640
+ ? findNode(runtime.editor.document, scope.cellIds[0]) : null;
641
+ const resolvedSide = (side) => cell?.attrs?.[CELL_BORDER_SIDE_KEYS[side]] ?? cell?.attrs?.borders;
642
+ const sides = { top: resolvedSide("top") !== undefined, right: resolvedSide("right") !== undefined, bottom: resolvedSide("bottom") !== undefined, left: resolvedSide("left") !== undefined };
643
+ const firstSetValue = ["top", "right", "bottom", "left"].map(resolvedSide).find((value) => value !== undefined);
644
+ const parsed = parseBorderShorthand(firstSetValue);
645
+ return { sides, style: parsed?.style ?? "solid", widthPx: parsed?.widthPx ?? BORDER_WIDTH_PRESETS[0].px, hex: parsed?.hex ?? "#000000" };
646
+ };
647
+ /** 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). */
648
+ const applyCellBorderDraft = (scope, draft, options) => {
649
+ const value = composeBorderShorthand(draft.widthPx, draft.style, draft.hex);
650
+ const attrs = ["top", "right", "bottom", "left"].reduce((acc, side) => {
651
+ acc[CELL_BORDER_SIDE_KEYS[side]] = draft.sides[side] ? value : undefined;
652
+ return acc;
653
+ }, {});
654
+ runtime.executeOperations(setTableCellAttributesCommand(runtime.editor.document, scope, { attrs }, blockContext()), { preserveSelectionById: true, ...options });
655
+ };
656
+ /** Live preview while adjusting the Border options popover - same checkpoint-then-reapply pattern as previewColor below. */
657
+ const previewCellBorder = (draft) => {
658
+ if (!tableBorderPopover)
231
659
  return;
232
- try {
233
- executeMarkTool(runtime.editor, declaration, "apply", attrs);
234
- runtime.focus();
660
+ if (!borderPreviewCheckpointRef.current)
661
+ borderPreviewCheckpointRef.current = runtime.createCheckpoint();
662
+ runtime.restoreCheckpoint(borderPreviewCheckpointRef.current);
663
+ applyCellBorderDraft(tableBorderPopover.scope, draft, { addToHistory: false });
664
+ };
665
+ const applyCellBorderCommit = (draft) => {
666
+ if (!tableBorderPopover)
667
+ return;
668
+ if (borderPreviewCheckpointRef.current) {
669
+ runtime.restoreCheckpoint(borderPreviewCheckpointRef.current);
670
+ borderPreviewCheckpointRef.current = null;
235
671
  }
236
- catch (error) {
237
- window.alert(error instanceof Error ? error.message : "Invalid formatting value.");
672
+ applyCellBorderDraft(tableBorderPopover.scope, draft, { addToHistory: true });
673
+ recordRecentColor("border", draft.hex);
674
+ setTableBorderPopover(null);
675
+ runtime.focus();
676
+ };
677
+ const cancelTableBorderPopover = () => {
678
+ if (borderPreviewCheckpointRef.current) {
679
+ runtime.restoreCheckpoint(borderPreviewCheckpointRef.current);
680
+ borderPreviewCheckpointRef.current = null;
681
+ }
682
+ setTableBorderPopover(null);
683
+ runtime.focus();
684
+ };
685
+ const recordRecentColor = (bucket, hex) => {
686
+ setRecentColors((current) => ({
687
+ ...current,
688
+ [bucket]: [hex, ...current[bucket].filter((existing) => existing.toLowerCase() !== hex.toLowerCase())].slice(0, RECENT_COLOR_LIMIT),
689
+ }));
690
+ };
691
+ /** 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`. */
692
+ const applyColorToTarget = (hex, options) => {
693
+ if (!colorPopover)
694
+ return;
695
+ if (colorPopover.target.kind === "mark") {
696
+ applyMarkAttrs(colorPopover.target.markId, { value: hex }, options);
697
+ }
698
+ else {
699
+ // Uses the scope captured when the popover opened, not a fresh
700
+ // tableScope() re-resolved against the live selection - see the
701
+ // colorPopover state's own doc comment for why.
702
+ runtime.executeOperations(setTableCellAttributesCommand(runtime.editor.document, colorPopover.target.scope, { attrs: { [colorPopover.target.attr]: hex } }, blockContext()), { preserveSelectionById: true, ...options });
238
703
  }
239
704
  };
705
+ /**
706
+ * Live preview while dragging the in-page saturation/hue picker (or typing
707
+ * a valid hex, or clicking a recent swatch), mirroring TableResizeHandles'
708
+ * own live-preview-then-commit-once pattern: every drag frame re-applies
709
+ * the color for real (so a multi-node mark selection, not just a single
710
+ * element's style, previews correctly), but always starting from a
711
+ * checkpoint of the state from *before* any preview began, and always with
712
+ * `addToHistory: false` - so however many drag frames fire, none of them
713
+ * become their own undo step, and each one fully supersedes the last
714
+ * rather than compounding on top of it. The checkpoint is created lazily,
715
+ * on the first preview frame, so a picker session that's opened and
716
+ * closed without ever touching anything never creates or restores
717
+ * anything extra.
718
+ */
719
+ const previewColor = (hex) => {
720
+ if (!colorPopover)
721
+ return;
722
+ if (!colorPreviewCheckpointRef.current)
723
+ colorPreviewCheckpointRef.current = runtime.createCheckpoint();
724
+ runtime.restoreCheckpoint(colorPreviewCheckpointRef.current);
725
+ applyColorToTarget(hex, { addToHistory: false });
726
+ };
727
+ /**
728
+ * There is no separate Apply button (ColorPickerPopover's own doc comment)
729
+ * - this fires when the popover is dismissed any way *other than* Discard,
730
+ * committing whatever was last staged by a drag/type/swatch-click.
731
+ */
732
+ const applyColor = (hex) => {
733
+ if (!colorPopover)
734
+ return;
735
+ // Undo whatever the live preview left in the (non-history) model state
736
+ // before making the real, history-eligible change - re-applying a
737
+ // setNodeAttributes-style operation on top of an already-previewed
738
+ // value would compute a before/after diff against the *previewed*
739
+ // state, not the true original, which would either no-op or corrupt
740
+ // the resulting undo step depending on the target.
741
+ if (colorPreviewCheckpointRef.current) {
742
+ runtime.restoreCheckpoint(colorPreviewCheckpointRef.current);
743
+ colorPreviewCheckpointRef.current = null;
744
+ }
745
+ applyColorToTarget(hex, { addToHistory: true });
746
+ recordRecentColor(colorBucketFor(colorPopover.target), hex);
747
+ setColorPopover(null);
748
+ runtime.focus();
749
+ };
750
+ /** Discard - the only way to revert to the color the popover opened with; every other dismissal commits (see applyColor). */
751
+ const cancelColorPopover = () => {
752
+ if (colorPreviewCheckpointRef.current) {
753
+ runtime.restoreCheckpoint(colorPreviewCheckpointRef.current);
754
+ colorPreviewCheckpointRef.current = null;
755
+ }
756
+ setColorPopover(null);
757
+ runtime.focus();
758
+ };
240
759
  const toggleBlockquote = () => {
241
760
  const scope = blockScope();
242
761
  const resolved = runtime.editor.resolve({ pos: runtime.editor.selection.head });
@@ -271,14 +790,14 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
271
790
  : outdentBlockCommand(runtime.editor.document, scope, {}, context);
272
791
  runtime.executeOperations(operations, { preserveSelectionById: true });
273
792
  };
274
- const insertTable = () => {
275
- const paragraphIds = ids(4);
793
+ const insertTable = (rows = 2, columns = 2) => {
794
+ const paragraphIds = ids(rows * columns);
276
795
  const selected = blockScope();
277
796
  const firstId = selected.kind === "block-range" ? selected.blockIds[0] : undefined;
278
797
  const target = firstId ? runtime.editor.positions.positionOf(firstId) : null;
279
798
  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 },
799
+ rows, columns, placement: "after",
800
+ ids: { tableId: createNodeId(), rowIds: ids(rows), cellIds: ids(rows * columns), paragraphIds },
282
801
  }, blockContext());
283
802
  // Keep an editable block after a table inserted at the end of its
284
803
  // container. Without this, the browser has no legal caret position below
@@ -308,16 +827,154 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
308
827
  : moveTableColumnCommand(runtime.editor.document, scope, { direction: action === "column-left" ? "left" : "right" }, context);
309
828
  runtime.executeOperations(operations, { preserveSelectionById: true });
310
829
  };
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");
830
+ /**
831
+ * Phase 11.5 §2.2: drag-handle resize, calling the existing
832
+ * setTableColumnWidthCommand/setTableRowHeightCommand - "Move row up/
833
+ * down"/"Move column left/right" (runTable above) already had toolbar
834
+ * buttons before this phase; resize had no UI at all. Uses the
835
+ * currently-selected table's scope, not the boundary's own position,
836
+ * since a resize handle can be dragged from anywhere in the table
837
+ * without first re-selecting a specific cell.
838
+ */
839
+ const resizeTableColumn = (index, width, currentWidths) => {
840
+ runtime.executeOperations(setTableColumnWidthCommand(runtime.editor.document, tableScope(), { index, width, widths: currentWidths }, blockContext()), { preserveSelectionById: true });
841
+ };
842
+ // Post-batch follow-up ("resizing a column/row disturbs the whole
843
+ // table") - dragging an *internal* boundary now redistributes between
844
+ // the two adjacent rows/columns (the border just moves, total table
845
+ // size is unchanged) instead of only ever growing/shrinking the table's
846
+ // own overall size; only the last row/column's own outer edge still
847
+ // changes the table's total height/width. TableResizeHandles.tsx computes
848
+ // both new sizes (clamped so neither side goes below its minimum) and
849
+ // passes the second one here as `adjacent` - both commit as one
850
+ // operation batch, one undo step.
851
+ const resizeTableRow = (index, height, adjacent) => {
852
+ const context = blockContext();
853
+ const operations = [
854
+ ...setTableRowHeightCommand(runtime.editor.document, tableScope(), { index, height }, context),
855
+ ...(adjacent ? setTableRowHeightCommand(runtime.editor.document, tableScope(), { index: adjacent.index, height: adjacent.height }, context) : []),
856
+ ];
857
+ runtime.executeOperations(operations, { preserveSelectionById: true });
858
+ };
859
+ /**
860
+ * Phase 11.5 §2.3: mirrors surface/input.ts's Tab dispatch
861
+ * (resolveShortcut) for a different contribution kind - try each
862
+ * registered contextMenu contribution's declared scopeKinds[0], keep it
863
+ * only if that scope actually resolves at the current selection. Unlike
864
+ * Tab this collects every match (a menu, not a single key), and unlike
865
+ * keyboardShortcuts, table.insertRow/insertColumn need caller-generated
866
+ * ids a static contribution can't carry - generated here per invocation,
867
+ * the same way Tab special-cases list.indent/outdent's dynamic params.
868
+ */
869
+ /**
870
+ * The generic plugin context menu remains table-scoped. Images use their
871
+ * direct right-click details editor below, other media keeps its dedicated
872
+ * quick-action menu, while marks stay on the toolbar and links use the
873
+ * auto-triggered LinkEditorPopover.
874
+ */
875
+ const resolveContextMenuItems = () => {
876
+ const many = () => ids(128);
877
+ const items = [];
878
+ runtime.editor.contextMenu.forEach((contribution) => {
879
+ const wanted = contribution.scopeKinds[0];
880
+ if (!wanted || wanted === "mixed" || wanted === "empty")
881
+ return;
882
+ const scope = runtime.editor.resolveScope({ want: wanted });
883
+ if (!("kind" in scope) || scope.kind !== wanted)
884
+ return;
885
+ const command = runtime.editor.commands.get(contribution.commandId);
886
+ if (!command)
887
+ return;
888
+ const position = contribution.params?.position;
889
+ // Commands like removeTableRowCommand read params.rowIndex ??
890
+ // <fallback> - a genuinely undefined params object throws on that
891
+ // property access (contribution.params is undefined for any static
892
+ // contribution with no params field, e.g. removeRow/removeColumn/
893
+ // mergeCells/removeTable), where the toolbar's equivalent buttons
894
+ // already pass {} for exactly this reason. Matching that here was
895
+ // the missing piece - this loop previously passed contribution.params
896
+ // straight through.
897
+ const params = contribution.commandId === "table.insertRow"
898
+ ? { position, rowId: createNodeId(), cellIds: many(), paragraphIds: many() }
899
+ : contribution.commandId === "table.insertColumn"
900
+ ? { position, cellIds: many(), paragraphIds: many() }
901
+ : contribution.params || {};
902
+ items.push({
903
+ id: contribution.id,
904
+ label: contribution.label,
905
+ danger: CONTEXT_MENU_DANGER_COMMANDS.has(contribution.commandId),
906
+ onSelect: () => {
907
+ runtime.executeOperations(command.run(runtime.editor.document, scope, params, blockContext()), { preserveSelectionById: true });
908
+ },
909
+ });
910
+ });
911
+ // "Cell style etc" - table_cell.attrs.background/textColor already
912
+ // existed (settable via table.setCellAttributes; background was
913
+ // rendered, textColor wasn't until this pass - see surface/
914
+ // renderer.ts) but the table context menu had no UI for either. Same
915
+ // ColorPickerPopover as the toolbar's text/background colour buttons,
916
+ // routed to applyColor's "cell" branch instead of a mark.
917
+ const tableGridScope = runtime.editor.resolveScope({ want: "table-grid" });
918
+ if ("kind" in tableGridScope && tableGridScope.kind === "table-grid") {
919
+ const openX = contextMenu?.x ?? 0;
920
+ const openY = contextMenu?.y ?? 0;
921
+ items.push({
922
+ id: "table.contextMenu.cellBackgroundColor",
923
+ label: "Cell background colour",
924
+ onSelect: () => setColorPopover({ x: openX, y: openY, target: { kind: "cell", attr: "background", scope: tableGridScope }, initialValue: currentCellColor("background") }),
925
+ }, {
926
+ id: "table.contextMenu.cellTextColor",
927
+ label: "Cell text colour",
928
+ onSelect: () => setColorPopover({ x: openX, y: openY, target: { kind: "cell", attr: "textColor", scope: tableGridScope }, initialValue: currentCellColor("textColor") }),
929
+ }, {
930
+ id: "table.contextMenu.cellBorderOptions",
931
+ label: "Cell border options",
932
+ onSelect: () => setTableBorderPopover({ x: openX, y: openY, scope: tableGridScope, initial: currentCellBorderDraft(tableGridScope) }),
933
+ });
934
+ }
935
+ return items;
936
+ };
937
+ /**
938
+ * Inserts a formula-library entry (see formulaLibrary.ts), then
939
+ * auto-selects the new atom and opens the existing edit-formula
940
+ * interaction immediately - most real use needs customization (different
941
+ * variable names, specific values), so landing the user straight into an
942
+ * editable state beats a silent insert-and-walk-away. Reuses the atom
943
+ * "node" selection pattern already used elsewhere (e.g. the context
944
+ * menu's own atom-selection logic) rather than inventing a new one.
945
+ */
946
+ const insertFormulaFromLibrary = (entry) => {
947
+ setFormulaLibraryPopover(null);
948
+ const declaration = atomDeclarations.find((atom) => atom.type === "formula");
316
949
  const resolved = runtime.editor.resolve({ pos: runtime.editor.selection.head });
950
+ const formulaAtomId = createNodeId();
317
951
  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" },
952
+ declaration, nodeId: formulaAtomId, ownerId: resolved.nodeId, offset: runtime.editor.selection.head.offset,
953
+ attrs: { source: entry.latex, notation: "latex" },
320
954
  }, blockContext()));
955
+ const range = runtime.editor.positions.rangeOf(formulaAtomId);
956
+ if (range) {
957
+ runtime.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "api" });
958
+ runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
959
+ editSelectedAtom();
960
+ }
961
+ };
962
+ /**
963
+ * Inserts plain text at the cursor via a real synthetic `beforeinput`
964
+ * event (inputType "insertText") rather than a hand-rolled operation -
965
+ * this is the exact same event InputController's own beforeInputListener
966
+ * already handles correctly for ordinary typing (replaceSelection),
967
+ * including deleting an active selection first. Verified this works
968
+ * identically across Chromium/Firefox/WebKit rather than assumed.
969
+ */
970
+ const insertSpecialCharacter = (char) => {
971
+ setSpecialCharPopover(null);
972
+ setRecentSpecialChars((current) => [char, ...current.filter((existing) => existing !== char)].slice(0, 8));
973
+ const root = runtime.surface.root;
974
+ if (!root)
975
+ return;
976
+ root.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertText", data: char, bubbles: true, cancelable: true }));
977
+ runtime.focus();
321
978
  };
322
979
  const insertBlockAtom = (type, attrs, nodeId) => {
323
980
  const declaration = atomDeclarations.find((entry) => entry.type === type);
@@ -325,6 +982,20 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
325
982
  let parentId;
326
983
  let index;
327
984
  const resolved = runtime.editor.resolve({ pos: selection.head });
985
+ // A selected atom's own parent is only a legal insertion point for a
986
+ // *block* atom (image/video/audio/table etc., whose parent is the
987
+ // document root or a table cell). An inline atom (formula, inline image)
988
+ // lives inside a paragraph - inserting a block atom there would nest a
989
+ // block inside a paragraph, which insertAtom's schema validation rejects,
990
+ // silently no-opping. Fall through to the generic "insert after the
991
+ // current block" logic below for that case instead.
992
+ const selectedBlockAtom = (() => {
993
+ if (selection.type !== "node")
994
+ return false;
995
+ const scope = atomScope();
996
+ const atomType = scope.kind === "atomic-node" ? findNode(runtime.editor.document, scope.nodeId)?.type : undefined;
997
+ return atomType ? atomDeclarations.find((entry) => entry.type === atomType)?.group === "block" : false;
998
+ })();
328
999
  if (selection.type === "cell") {
329
1000
  // A cell range resolves at the active cell's content boundary. Block
330
1001
  // atoms belong inside that cell, never as invalid siblings of the row.
@@ -338,7 +1009,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
338
1009
  parentId = resolved.parent.id;
339
1010
  index = Math.max(selection.anchor.offset, selection.head.offset);
340
1011
  }
341
- else if (selection.type === "node") {
1012
+ else if (selectedBlockAtom) {
342
1013
  parentId = resolved.parent.id;
343
1014
  index = resolved.pos.offset;
344
1015
  }
@@ -389,6 +1060,14 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
389
1060
  pendingMediaUploads.current.add(controller);
390
1061
  try {
391
1062
  await runAtomUpload(runtime.editor, nodeId, async () => {
1063
+ // Best-effort client-side check only - a UX nicety that catches an
1064
+ // obviously wrong file early, not a security boundary. A client can
1065
+ // always be bypassed, so MediaProvider.upload's real host
1066
+ // implementation MUST validate independently server-side; see the
1067
+ // doc comment on MediaProvider.upload (mediaProvider.ts).
1068
+ if (!file.type.startsWith(`${kind}/`)) {
1069
+ throw new Error(`"${file.type || "unknown type"}" does not look like a ${kind} file.`);
1070
+ }
392
1071
  const result = await mediaProvider.upload(file, { signal: controller.signal });
393
1072
  return { src: result.url, id: result.id };
394
1073
  });
@@ -398,6 +1077,235 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
398
1077
  URL.revokeObjectURL(preview);
399
1078
  }
400
1079
  };
1080
+ /**
1081
+ * Phase 11.5 §2.1: MediaManager already performed the upload (or the item
1082
+ * was already in the library) before onSelect fires, unlike
1083
+ * insertMediaFile's DefaultMediaPicker path (raw File in, pending
1084
+ * placeholder + async upload). Insert straight to "ready" - no pending
1085
+ * placeholder, no upload step to await.
1086
+ */
1087
+ const selectFromMediaManager = (item) => {
1088
+ setMediaKind(null);
1089
+ insertBlockAtom("block_image", {
1090
+ src: item.url, alt: item.alt || item.title || "Image", decorative: false, status: "ready",
1091
+ ...(item.width ? { width: item.width } : {}), ...(item.height ? { height: item.height } : {}),
1092
+ // docs/bugs/media-details-old-editor-field-parity.md: item.license was
1093
+ // already fetched and shown in MediaManager's own library-browser info
1094
+ // panel (MediaManager.tsx), but had nowhere to persist once inserted -
1095
+ // the atom schema had no license attrs at all, so this real,
1096
+ // already-available data was silently discarded at exactly this call
1097
+ // site. `licenseVersion` has no MediaItem.license equivalent (its
1098
+ // shape has no separate version field) - left for the user to fill in
1099
+ // via the media details panel, same as a freshly uploaded image.
1100
+ ...(item.license?.workName || item.license?.licenseText
1101
+ ? { licenseDescription: [item.license.workName, item.license.licenseText].filter(Boolean).join(" - ") } : {}),
1102
+ ...(item.license?.sourceUrl ? { licenseSourceUrl: item.license.sourceUrl } : {}),
1103
+ ...(item.license?.licenseType ? { licenseType: item.license.licenseType } : {}),
1104
+ ...(item.license?.author ? { licenseAttribution: item.license.author } : {}),
1105
+ }, createNodeId());
1106
+ };
1107
+ const commentAuthorId = authorId || "anonymous";
1108
+ useEffect(() => {
1109
+ runtime.surface.pipeline?.setTrackChanges(trackChangesEnabled, commentAuthorId);
1110
+ }, [runtime, trackChangesEnabled, commentAuthorId]);
1111
+ const selectThread = (threadId) => {
1112
+ setActiveThreadId(threadId);
1113
+ setCommentPanelOpen(true);
1114
+ };
1115
+ const startComment = () => {
1116
+ const range = commentRangeFromSelection(runtime.editor.document, runtime.editor.selection);
1117
+ if (!range)
1118
+ return;
1119
+ setPendingCommentRange(range);
1120
+ setActiveThreadId(null);
1121
+ setCommentPanelOpen(true);
1122
+ };
1123
+ const createThread = (text) => {
1124
+ if (!pendingCommentRange || !commentProvider)
1125
+ return;
1126
+ const thread = {
1127
+ id: createNodeId(),
1128
+ range: pendingCommentRange,
1129
+ resolved: false,
1130
+ replies: [{ id: createNodeId(), authorId: commentAuthorId, text, createdAt: Date.now() }],
1131
+ };
1132
+ setThreads((current) => [...current, thread]);
1133
+ setPendingCommentRange(null);
1134
+ setActiveThreadId(thread.id);
1135
+ void commentProvider.save(thread).catch(() => { });
1136
+ };
1137
+ const replyToThread = (threadId, text) => {
1138
+ const target = threads.find((thread) => thread.id === threadId);
1139
+ if (!target)
1140
+ return;
1141
+ const updated = {
1142
+ ...target,
1143
+ replies: [...target.replies, { id: createNodeId(), authorId: commentAuthorId, text, createdAt: Date.now() }],
1144
+ };
1145
+ setThreads((current) => current.map((thread) => thread.id === threadId ? updated : thread));
1146
+ if (commentProvider)
1147
+ void commentProvider.save(updated).catch(() => { });
1148
+ };
1149
+ const setThreadResolved = (threadId, resolved) => {
1150
+ const target = threads.find((thread) => thread.id === threadId);
1151
+ if (!target)
1152
+ return;
1153
+ const updated = { ...target, resolved };
1154
+ setThreads((current) => current.map((thread) => thread.id === threadId ? updated : thread));
1155
+ if (commentProvider)
1156
+ void commentProvider.save(updated).catch(() => { });
1157
+ };
1158
+ const deleteThread = (threadId) => {
1159
+ setBusyThreadId(threadId);
1160
+ setThreads((current) => current.filter((thread) => thread.id !== threadId));
1161
+ if (activeThreadId === threadId)
1162
+ setActiveThreadId(null);
1163
+ const finish = () => setBusyThreadId((current) => current === threadId ? null : current);
1164
+ if (commentProvider)
1165
+ void commentProvider.remove(threadId).then(finish).catch(finish);
1166
+ else
1167
+ finish();
1168
+ };
1169
+ const canComment = !readOnly && Boolean(commentProvider)
1170
+ && Boolean(commentRangeFromSelection(runtime.editor.document, runtime.editor.selection));
1171
+ const selectSuggestion = (id) => {
1172
+ setActiveSuggestionId(id);
1173
+ setSuggestionPanelOpen(true);
1174
+ };
1175
+ const suggestDelete = () => {
1176
+ const scope = runtime.editor.resolveScope({ want: "inline-range" });
1177
+ if (!("kind" in scope) || scope.kind !== "inline-range" || scope.collapsed)
1178
+ return;
1179
+ const operations = suggestDeleteCommand(runtime.editor.document, scope, { authorId: commentAuthorId }, blockContext());
1180
+ if (operations.length)
1181
+ runtime.executeOperations(operations);
1182
+ };
1183
+ const startSuggestInsert = () => {
1184
+ setPendingSuggestInsertAt(runtime.editor.selection.head);
1185
+ setActiveSuggestionId(null);
1186
+ setSuggestionPanelOpen(true);
1187
+ };
1188
+ const submitSuggestInsert = (text) => {
1189
+ if (!pendingSuggestInsertAt)
1190
+ return;
1191
+ runtime.executeOperations([suggestInsertOperation(pendingSuggestInsertAt, text, { authorId: commentAuthorId })]);
1192
+ setPendingSuggestInsertAt(null);
1193
+ };
1194
+ const suggestBlockRemoval = () => {
1195
+ const owner = resolvePos(runtime.editor.document, runtime.editor.selection.head);
1196
+ const suggestion = structuralSuggestionFromNode(owner.nodeId, commentAuthorId);
1197
+ setStructuralSuggestions((current) => [...current, suggestion]);
1198
+ setActiveSuggestionId(suggestion.id);
1199
+ setSuggestionPanelOpen(true);
1200
+ if (suggestionProvider)
1201
+ void suggestionProvider.save(suggestion).catch(() => { });
1202
+ };
1203
+ const acceptInlineSuggestion = (id) => {
1204
+ const operations = acceptSuggestionCommand(runtime.editor.document, id);
1205
+ if (operations.length)
1206
+ runtime.executeOperations(operations);
1207
+ };
1208
+ const rejectInlineSuggestion = (id) => {
1209
+ const operations = rejectSuggestionCommand(runtime.editor.document, id);
1210
+ if (operations.length)
1211
+ runtime.executeOperations(operations);
1212
+ };
1213
+ const acceptStructural = (suggestion) => {
1214
+ setBusySuggestionId(suggestion.id);
1215
+ const operations = acceptStructuralSuggestionCommand(runtime.editor.document, suggestion, runtime.editor.positions);
1216
+ if (operations.length)
1217
+ runtime.executeOperations(operations);
1218
+ setStructuralSuggestions((current) => current.filter((entry) => entry.id !== suggestion.id));
1219
+ if (activeSuggestionId === suggestion.id)
1220
+ setActiveSuggestionId(null);
1221
+ const finish = () => setBusySuggestionId((current) => current === suggestion.id ? null : current);
1222
+ if (suggestionProvider)
1223
+ void suggestionProvider.remove(suggestion.id).then(finish).catch(finish);
1224
+ else
1225
+ finish();
1226
+ };
1227
+ const rejectStructural = (suggestion) => {
1228
+ setBusySuggestionId(suggestion.id);
1229
+ setStructuralSuggestions((current) => current.filter((entry) => entry.id !== suggestion.id));
1230
+ if (activeSuggestionId === suggestion.id)
1231
+ setActiveSuggestionId(null);
1232
+ const finish = () => setBusySuggestionId((current) => current === suggestion.id ? null : current);
1233
+ if (suggestionProvider)
1234
+ void suggestionProvider.remove(suggestion.id).then(finish).catch(finish);
1235
+ else
1236
+ finish();
1237
+ };
1238
+ const canSuggestDelete = (() => {
1239
+ if (readOnly || !suggestionProvider)
1240
+ return false;
1241
+ const scope = runtime.editor.resolveScope({ want: "inline-range" });
1242
+ return "kind" in scope && scope.kind === "inline-range" && !scope.collapsed;
1243
+ })();
1244
+ /**
1245
+ * Phase 11.5 §2.5: wires the already-tested LinkEditorPopover (previously
1246
+ * built but never imported anywhere) to the already-tested command-layer
1247
+ * link logic (editLinkCommand/resolveMarkRun, previously only reachable
1248
+ * via window.prompt). A collapsed selection with no existing link has
1249
+ * nothing to apply the mark to and no text to become the label - unlike
1250
+ * bold/italic, arming storedMarks alone would insert nothing visible, so
1251
+ * that case inserts the popover's typed display text directly with the
1252
+ * link mark attached, rather than going through executeMarkTool.
1253
+ */
1254
+ const applyLink = (value) => {
1255
+ if (!linkPopover)
1256
+ return;
1257
+ const tool = inlineToolDeclarations.find((entry) => entry.id === "link");
1258
+ const attrs = { href: value.href, ...(value.openInNewTab ? { target: "_blank" } : {}) };
1259
+ if (linkPopover.editingExisting) {
1260
+ executeMarkTool(runtime.editor, tool, "editLink", attrs);
1261
+ }
1262
+ else if (linkPopover.collapsed && value.text) {
1263
+ const pos = runtime.editor.selection.head;
1264
+ runtime.editor.transact((builder) => {
1265
+ builder.operations.push({ type: "insertText", pos, text: value.text, marks: [{ type: "link", attrs }] });
1266
+ const next = { path: [...pos.path], offset: pos.offset + value.text.length };
1267
+ builder.setSelection({ type: "text", anchor: next, head: next });
1268
+ }, { source: "toolbar", addToHistory: true });
1269
+ }
1270
+ else {
1271
+ executeMarkTool(runtime.editor, tool, "apply", attrs);
1272
+ }
1273
+ // Without this, the auto-trigger effect (linkAnchorElement) would see
1274
+ // the same still-a-link caret position on the very next render and
1275
+ // immediately reopen the popover that "Update" just closed.
1276
+ setLinkOverlayDismissedAt(runtime.editor.selection.head);
1277
+ runtime.focus();
1278
+ setLinkPopover(null);
1279
+ };
1280
+ const removeLink = () => {
1281
+ const tool = inlineToolDeclarations.find((entry) => entry.id === "link");
1282
+ if (tool)
1283
+ executeMarkTool(runtime.editor, tool, "remove");
1284
+ setLinkOverlayDismissedAt(runtime.editor.selection.head);
1285
+ runtime.focus();
1286
+ setLinkPopover(null);
1287
+ };
1288
+ const openMediaDetails = (scope, node, element) => {
1289
+ if (scope.kind !== "atomic-node")
1290
+ return;
1291
+ const rect = (element || selectedAtomElement)?.getBoundingClientRect();
1292
+ setMediaDetailsPopover({
1293
+ x: rect ? rect.left : 0, y: rect ? rect.bottom + 6 : 0, scope,
1294
+ initial: {
1295
+ alt: String(node.attrs?.alt || ""),
1296
+ href: String(node.attrs?.href || ""),
1297
+ openInNewTab: node.attrs?.target === "_blank",
1298
+ width: typeof node.attrs?.width === "number" ? node.attrs.width : undefined,
1299
+ borderRadius: typeof node.attrs?.borderRadius === "number" ? node.attrs.borderRadius : undefined,
1300
+ align: node.attrs?.align === "left" || node.attrs?.align === "center" || node.attrs?.align === "right" ? node.attrs.align : undefined,
1301
+ licenseDescription: String(node.attrs?.licenseDescription || ""),
1302
+ licenseSourceUrl: String(node.attrs?.licenseSourceUrl || ""),
1303
+ licenseType: String(node.attrs?.licenseType || ""),
1304
+ licenseVersion: String(node.attrs?.licenseVersion || ""),
1305
+ licenseAttribution: String(node.attrs?.licenseAttribution || ""),
1306
+ },
1307
+ });
1308
+ };
401
1309
  const editSelectedAtom = (resizeBy) => {
402
1310
  const scope = atomScope();
403
1311
  if (scope.kind !== "atomic-node")
@@ -405,40 +1313,86 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
405
1313
  const node = findNode(runtime.editor.document, scope.nodeId);
406
1314
  if (!node)
407
1315
  return;
1316
+ if (resizeBy === undefined && !node.type.includes("formula")) {
1317
+ // Images: the old window.prompt("Alt text") one-field edit is
1318
+ // replaced by the full Media details panel (docs/bugs/
1319
+ // media-details-old-editor-field-parity.md) - formulas keep their
1320
+ // existing prompt unchanged below, since this panel's fields
1321
+ // (link/align/radius/license) don't apply to a formula atom at all.
1322
+ openMediaDetails(scope, node);
1323
+ return;
1324
+ }
408
1325
  const operations = resizeBy === undefined
409
- ? updateAtom(runtime.editor.document, scope, node.type.includes("formula")
410
- ? { attrs: { source: window.prompt("Formula source", String(node.attrs?.source || "")) || node.attrs?.source } }
411
- : { attrs: { alt: window.prompt("Alt text", String(node.attrs?.alt || "")) ?? node.attrs?.alt } }, blockContext())
1326
+ ? updateAtom(runtime.editor.document, scope, { attrs: { source: window.prompt("Formula source", String(node.attrs?.source || "")) || node.attrs?.source } }, blockContext())
412
1327
  : resizeAtom(runtime.editor.document, scope, {
413
1328
  width: Math.max(16, Number(node.attrs?.width || 160) + resizeBy),
414
1329
  height: Math.max(16, Number(node.attrs?.height || 90) + resizeBy),
415
1330
  }, blockContext());
416
1331
  runtime.executeOperations(operations, resizeBy === undefined ? {} : { historyGroup: `resize-${scope.nodeId}` });
417
1332
  };
1333
+ const applyMediaDetails = (draft) => {
1334
+ if (!mediaDetailsPopover)
1335
+ return;
1336
+ const { scope } = mediaDetailsPopover;
1337
+ if (scope.kind !== "atomic-node") {
1338
+ setMediaDetailsPopover(null);
1339
+ return;
1340
+ }
1341
+ const node = findNode(runtime.editor.document, scope.nodeId);
1342
+ const currentWidth = typeof node?.attrs?.width === "number" ? node.attrs.width : undefined;
1343
+ const currentHeight = typeof node?.attrs?.height === "number" ? node.attrs.height : undefined;
1344
+ // Width preserves the original aspect ratio (matches the drag handle
1345
+ // and Resize +/- buttons' own behavior) rather than exposing a separate
1346
+ // Height field the old editor's own screenshot didn't have either.
1347
+ const resizedHeight = draft.width && currentWidth && currentHeight
1348
+ ? Math.round((draft.width * currentHeight) / currentWidth)
1349
+ : currentHeight;
1350
+ setMediaDetailsPopover(null);
1351
+ runtime.executeOperations(updateAtom(runtime.editor.document, scope, {
1352
+ attrs: {
1353
+ alt: draft.alt,
1354
+ href: draft.href || undefined,
1355
+ target: draft.href && draft.openInNewTab ? "_blank" : undefined,
1356
+ width: draft.width ?? currentWidth,
1357
+ height: draft.width ? resizedHeight : currentHeight,
1358
+ borderRadius: draft.borderRadius || undefined,
1359
+ align: draft.align,
1360
+ licenseDescription: draft.licenseDescription || undefined,
1361
+ licenseSourceUrl: draft.licenseSourceUrl || undefined,
1362
+ licenseType: draft.licenseType || undefined,
1363
+ licenseVersion: draft.licenseVersion || undefined,
1364
+ licenseAttribution: draft.licenseAttribution || undefined,
1365
+ },
1366
+ }, blockContext()));
1367
+ runtime.focus();
1368
+ };
1369
+ /** MediaOverlay's drag-corner-handle commit - explicit width/height rather than editSelectedAtom's fixed +/-20 increment. */
1370
+ const resizeSelectedAtomTo = (width, height) => {
1371
+ const scope = atomScope();
1372
+ if (scope.kind !== "atomic-node")
1373
+ return;
1374
+ runtime.executeOperations(resizeAtom(runtime.editor.document, scope, { width, height }, blockContext()), { historyGroup: `resize-${scope.nodeId}` });
1375
+ };
1376
+ /** Same atom.delete command the removed atom.contextMenu.delete contribution used to dispatch, now called directly from MediaOverlay. */
1377
+ const deleteSelectedAtom = () => {
1378
+ const scope = atomScope();
1379
+ if (scope.kind !== "atomic-node")
1380
+ return;
1381
+ runtime.executeOperations(deleteAtom(runtime.editor.document, scope, {}, blockContext()));
1382
+ runtime.focus();
1383
+ };
418
1384
  const replaceCanonicalDocument = (document) => {
419
1385
  runtime.replaceValue({ schemaVersion: runtime.editor.schema.version, revision: runtime.getRevision() + 1, document });
420
1386
  runtime.focus();
421
1387
  };
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
1388
  const runImport = async (file) => {
429
1389
  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)));
1390
+ const result = await importStyledDocxDocument(await file.arrayBuffer());
1391
+ replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml));
435
1392
  return;
436
1393
  }
437
1394
  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);
1395
+ const result = await importPdfDocument(await file.arrayBuffer());
442
1396
  replaceCanonicalDocument(parseCanonicalListHtml(result.layoutHtml));
443
1397
  return;
444
1398
  }
@@ -455,64 +1409,298 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
455
1409
  return downloadText(rootRef.current.ownerDocument, "smart-rte.html", "text/html", serializeCanonicalListHtml(envelope.document, { clean: true }));
456
1410
  };
457
1411
  const runDocxExport = async () => {
458
- const document = legacyDocument();
459
- if (!document || !rootRef.current)
1412
+ if (!rootRef.current)
460
1413
  return;
461
- const blob = await exportDocxDocument(document);
1414
+ const blob = await exportDocxDocument(runtime.editor.document);
462
1415
  downloadBlob(rootRef.current.ownerDocument, "smart-rte.docx", blob);
463
1416
  };
464
1417
  const runPdfExport = () => {
465
- const document = legacyDocument();
466
1418
  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)
1419
+ if (view)
1420
+ printSmartDocumentAsPdf(runtime.editor.document, view);
1421
+ };
1422
+ const markTool = (id) => inlineToolDeclarations.find((tool) => tool.id === id);
1423
+ // aria-pressed tri-state per docs/bugs/mark-toolbar-buttons-no-pressed-state.md's
1424
+ // prescribed fix: SelectionDescription.marks already reports "all"/"partial"
1425
+ // coverage per mark, so a selection only partially covered by e.g. bold
1426
+ // reports aria-pressed="mixed" rather than silently rounding to true/false.
1427
+ const markCoverage = (id) => {
1428
+ if (readOnly)
1429
+ return undefined;
1430
+ const description = runtime.editor.resolveScope({ want: "describe" });
1431
+ const markType = markTool(id).markType;
1432
+ const entry = description.marks.find((entry) => entry.mark.type === markType);
1433
+ if (!entry)
1434
+ return false;
1435
+ return entry.coverage === "all" ? true : "mixed";
1436
+ };
1437
+ const toggleMark = (id) => { executeMarkTool(runtime.editor, markTool(id), "toggle"); runtime.focus(); };
1438
+ const openLinkPopover = (event) => {
1439
+ const description = runtime.editor.resolveScope({ want: "describe" });
1440
+ const linkEntry = description.marks.find((entry) => entry.mark.type === "link");
1441
+ const rect = event.currentTarget.getBoundingClientRect();
1442
+ setLinkPopover({
1443
+ x: rect.left, y: rect.bottom + 4,
1444
+ editingExisting: Boolean(linkEntry),
1445
+ href: typeof linkEntry?.mark.attrs?.href === "string" ? linkEntry.mark.attrs.href : "",
1446
+ openInNewTab: linkEntry?.mark.attrs?.target === "_blank",
1447
+ collapsed: description.collapsed,
1448
+ });
1449
+ };
1450
+ const tableAction = (action) => {
1451
+ const disabled = readOnly || !tableSelected
1452
+ || action === "merge" && currentTableScope.cellIds.length < 2
1453
+ || action === "row-up" && currentTableScope.rect.top === 0
1454
+ || action === "column-left" && currentTableScope.rect.left === 0;
1455
+ return { disabled, onClick: () => runTable(action) };
1456
+ };
1457
+ // Rendered in two places (its home dropdown, and the narrow-viewport
1458
+ // overflow menu) so nothing becomes unreachable once dropdowns collapse -
1459
+ // see ToolbarPrimitives.tsx's MobileMoreMenu doc comment.
1460
+ // Superscript/Subscript/Text colour/Background colour/Font size/Font
1461
+ // family also get a standalone widePromote ToolbarButton (below, in the
1462
+ // main toolbar row) that appears past theme.ts's 1440px breakpoint - this
1463
+ // dropdown copy's `widePromote` flag hides it there so it isn't offered
1464
+ // twice; below 1440px (including mobile) this copy is what's reachable,
1465
+ // same as before. "Code" isn't promoted - see the docs/bugs/ writeup on
1466
+ // why it stayed the dropdown's sole remaining item at wide widths.
1467
+ const textStylesMenuItems = _jsxs(_Fragment, { children: [t.code && _jsx(ToolbarMenuItem, { icon: "code", label: "Code", pressed: markCoverage("inlineCode"), disabled: readOnly, onClick: () => toggleMark("inlineCode") }), t.superscript && _jsx(ToolbarMenuItem, { icon: "superscript", label: "Superscript", pressed: markCoverage("superscript"), disabled: readOnly, onClick: () => toggleMark("superscript"), widePromote: true }), t.subscript && _jsx(ToolbarMenuItem, { icon: "subscript", label: "Subscript", pressed: markCoverage("subscript"), disabled: readOnly, onClick: () => toggleMark("subscript"), widePromote: true }), t.textColor && _jsx(ToolbarMenuItem, { icon: "textColor", label: "Text colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1468
+ const rect = event.currentTarget.getBoundingClientRect();
1469
+ setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "textColor" }, initialValue: currentMarkColor("textColor") });
1470
+ } }), t.backgroundColor && _jsx(ToolbarMenuItem, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1471
+ const rect = event.currentTarget.getBoundingClientRect();
1472
+ setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "backgroundColor" }, initialValue: currentMarkColor("backgroundColor") });
1473
+ } }), t.fontSize && _jsx(ToolbarMenuItem, { icon: "fontSize", label: "Font size", disabled: readOnly, onClick: () => applyAttributedMark("fontSize"), widePromote: true }), t.fontFamily && _jsx(ToolbarMenuItem, { icon: "fontFamily", label: "Font family", disabled: readOnly, onClick: () => applyAttributedMark("fontFamily"), widePromote: true })] });
1474
+ const showMoreTextStyles = t.code || t.superscript || t.subscript || t.textColor || t.backgroundColor || t.fontSize || t.fontFamily;
1475
+ /**
1476
+ * Line spacing - matches Google Docs' own reference shape: a short preset
1477
+ * list plus a custom/exact value entry, with a checkmark on whichever
1478
+ * preset (or "Default") currently applies. "Default" clears the
1479
+ * attribute entirely (setLineHeight(undefined)) rather than writing an
1480
+ * explicit "1" - the block then renders at the browser/font's own
1481
+ * natural line-height, matching how no override ever forces a value for
1482
+ * align/indent either. A mixed-value selection (blocks disagreeing)
1483
+ * shows no checkmark on any entry, matching every comparison below
1484
+ * evaluating false against the "mixed" sentinel with no special-casing
1485
+ * needed.
1486
+ */
1487
+ const LINE_HEIGHT_PRESETS = [1, 1.15, 1.5, 2, 2.5];
1488
+ const lineHeightMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { label: "Default", pressed: currentLineHeight() === undefined, disabled: readOnly, onClick: () => setLineHeight(undefined) }), LINE_HEIGHT_PRESETS.map((preset) => _jsx(ToolbarMenuItem, { label: String(preset), pressed: currentLineHeight() === preset, disabled: readOnly, onClick: () => setLineHeight(preset) }, preset)), _jsx("div", { className: "srte-menu-separator" }), _jsxs("div", { className: "srte-line-height-custom", style: { padding: "4px 8px", display: "flex", alignItems: "center", gap: 6 }, children: [_jsx("span", { "aria-hidden": "true", style: { fontSize: 12, whiteSpace: "nowrap" }, children: "Custom:" }), _jsx("input", { "aria-label": "Custom line spacing", type: "number", min: 0.1, max: 10, step: 0.05, disabled: readOnly, defaultValue: (() => { const current = currentLineHeight(); return typeof current === "number" && !LINE_HEIGHT_PRESETS.includes(current) ? current : ""; })(), placeholder: "e.g. 1.75", style: { width: "100%", minWidth: 0 }, onKeyDown: (event) => {
1489
+ if (event.key !== "Enter")
498
1490
  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) => {
1491
+ const value = Number(event.currentTarget.value);
1492
+ if (Number.isFinite(value) && value >= 0.1 && value <= 10)
1493
+ setLineHeight(value);
1494
+ event.currentTarget.closest("details")?.removeAttribute("open");
1495
+ }, onBlur: (event) => {
1496
+ const value = Number(event.currentTarget.value);
1497
+ if (event.currentTarget.value && Number.isFinite(value) && value >= 0.1 && value <= 10)
1498
+ setLineHeight(value);
1499
+ } })] })] });
1500
+ 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") })] });
1501
+ /**
1502
+ * Named multi-level marker presets (decimal/lower-alpha/upper-alpha/
1503
+ * roman/outline) - a distinct control from the plain Bulleted/Numbered/
1504
+ * Checklist buttons above, which only toggle a raw list style. Extracted
1505
+ * to a variable so it can be reused verbatim in both the desktop "More
1506
+ * list tools" dropdown and MobileMoreMenu below - it was previously
1507
+ * hardcoded only into the dropdown, meaning a narrow/mobile viewport
1508
+ * (where every ToolbarDropdown hides entirely, per theme.ts's
1509
+ * 639px breakpoint) had no way to reach it at all - see
1510
+ * docs/bugs/list-preset-missing-from-mobile-more-menu.md.
1511
+ */
1512
+ const listPresetSelect = t.listPreset && _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) => {
1513
+ const preset = event.target.value;
1514
+ if (!preset || currentListParts.length !== 1)
1515
+ return;
1516
+ // A preset choice applies to the whole list, regardless of how deep
1517
+ // the cursor is nested — see outermostListId.
1518
+ const rootId = outermostListId(currentListParts[0].listId);
1519
+ runtime.executeOperations(setListPreset(runtime.editor.document, { ...currentListParts[0], listId: rootId }, { preset }, blockContext()), { preserveSelectionById: true });
1520
+ }, 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))] }) });
1521
+ 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 }) })] });
1522
+ // Remove link/Insert formula/Special characters also get a standalone
1523
+ // widePromote ToolbarButton (see textStylesMenuItems's own comment above
1524
+ // for the mechanism) - Insert video/audio and the selected-media actions
1525
+ // stay dropdown-only, matching the report's own named tool list.
1526
+ const insertMoreMenuItems = _jsxs(_Fragment, { children: [t.removeLink && _jsx(ToolbarMenuItem, { icon: "unlink", label: "Remove link", disabled: readOnly, onClick: removeLink, widePromote: true }), showVideoTool && _jsx(ToolbarMenuItem, { icon: "video", label: "Insert video", disabled: readOnly, onClick: () => setMediaKind("video") }), showAudioTool && _jsx(ToolbarMenuItem, { icon: "audio", label: "Insert audio", disabled: readOnly, onClick: () => setMediaKind("audio") }), t.insertFormula && _jsx(ToolbarMenuItem, { icon: "formula", label: "Insert formula", disabled: readOnly, widePromote: true, onClick: (event) => {
1527
+ const rect = event.currentTarget.getBoundingClientRect();
1528
+ setFormulaLibraryPopover({ x: rect.left, y: rect.bottom + 4 });
1529
+ } }), t.specialCharacters && _jsx(ToolbarMenuItem, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
1530
+ const rect = event.currentTarget.getBoundingClientRect();
1531
+ setSpecialCharPopover({ x: rect.left, y: rect.bottom + 4 });
1532
+ } }), t.horizontalLine && _jsx(ToolbarMenuItem, { icon: "divider", label: "Horizontal line", disabled: readOnly, widePromote: true, onClick: () => { insertBlockAtom("divider", {}, createNodeId()); runtime.focus(); } }), t.pageBreak && _jsx(ToolbarMenuItem, { icon: "pageBreak", label: "Page break", disabled: readOnly, widePromote: true, onClick: () => { insertBlockAtom("page_break", {}, createNodeId()); runtime.focus(); } }), _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())) })] });
1533
+ // "Remove row"/"Remove column"/"Remove table" renamed to "Delete ..." -
1534
+ // matches the right-click table context menu's existing wording
1535
+ // (table/plugin.ts's table.contextMenu.removeRow/removeColumn/removeTable
1536
+ // contributions, labeled "Delete row"/"Delete column"/"Delete table")
1537
+ // for the identical action, found as a wording inconsistency during the
1538
+ // toolbar audit.
1539
+ 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) => {
1540
+ const scope = runtime.editor.resolveScope({ want: "table-grid" });
1541
+ if (!("kind" in scope) || scope.kind !== "table-grid")
1542
+ return;
1543
+ const rect = event.currentTarget.getBoundingClientRect();
1544
+ setTableBorderPopover({ x: rect.left, y: rect.bottom + 4, scope: scope, initial: currentCellBorderDraft(scope) });
1545
+ } })] });
1546
+ const saveCopyMenuItems = _jsxs(_Fragment, { children: [t.saveAsHtml && _jsx(ToolbarMenuItem, { label: "Save as HTML", onClick: () => runExport("html") }), t.saveAsMarkdown && _jsx(ToolbarMenuItem, { label: "Save as Markdown", onClick: () => runExport("markdown") }), t.saveAsWord && _jsx(ToolbarMenuItem, { label: "Save as Word document", onClick: () => void runDocxExport() }), t.saveAsPdf && _jsx(ToolbarMenuItem, { label: "Print / Save as PDF", onClick: runPdfExport }), t.saveAsSmartRte && _jsx(ToolbarMenuItem, { icon: "json", label: "Save as Smart RTE file", onClick: () => runExport("native") })] });
1547
+ const showSaveCopy = t.saveAsHtml || t.saveAsMarkdown || t.saveAsWord || t.saveAsPdf || t.saveAsSmartRte;
1548
+ // "Review" splits into two independently-toggleable tools (comments,
1549
+ // suggestions) - not an arbitrary split, it maps directly onto the two
1550
+ // already-separate providers this architecture has (commentProvider,
1551
+ // suggestionProvider). Kept as ONE dropdown trigger (labeled "Review")
1552
+ // rather than two, showing whichever half(s) are actually enabled.
1553
+ const commentsMenuItems = _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) })] });
1554
+ const suggestionsMenuItems = _jsxs(_Fragment, { children: [_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) })] });
1555
+ const reviewMenuItems = _jsxs(_Fragment, { children: [showCommentsTool && commentsMenuItems, showCommentsTool && showSuggestionsTool && _jsx("div", { className: "srte-menu-separator" }), showSuggestionsTool && suggestionsMenuItems] });
1556
+ const showReviewDropdown = showCommentsTool || showSuggestionsTool;
1557
+ 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: [t.bold && _jsx(ToolbarButton, { icon: "bold", label: "Bold", pressed: markCoverage("bold"), disabled: readOnly, onClick: () => toggleMark("bold") }), t.italic && _jsx(ToolbarButton, { icon: "italic", label: "Italic", pressed: markCoverage("italic"), disabled: readOnly, onClick: () => toggleMark("italic") }), t.underline && _jsx(ToolbarButton, { icon: "underline", label: "Underline", pressed: markCoverage("underline"), disabled: readOnly, onClick: () => toggleMark("underline") }), t.strikethrough && _jsx(ToolbarButton, { icon: "strikethrough", label: "Strikethrough", pressed: markCoverage("strikethrough"), disabled: readOnly, onClick: () => toggleMark("strikethrough") }), t.superscript && _jsx(ToolbarButton, { icon: "superscript", label: "Superscript", pressed: markCoverage("superscript"), disabled: readOnly, onClick: () => toggleMark("superscript"), widePromote: true }), t.subscript && _jsx(ToolbarButton, { icon: "subscript", label: "Subscript", pressed: markCoverage("subscript"), disabled: readOnly, onClick: () => toggleMark("subscript"), widePromote: true }), t.textColor && _jsx(ToolbarButton, { icon: "textColor", label: "Text colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1558
+ const rect = event.currentTarget.getBoundingClientRect();
1559
+ setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "textColor" }, initialValue: currentMarkColor("textColor") });
1560
+ } }), t.backgroundColor && _jsx(ToolbarButton, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1561
+ const rect = event.currentTarget.getBoundingClientRect();
1562
+ setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "backgroundColor" }, initialValue: currentMarkColor("backgroundColor") });
1563
+ } }), t.fontSize && _jsx(ToolbarButton, { icon: "fontSize", label: "Font size", disabled: readOnly, onClick: () => applyAttributedMark("fontSize"), widePromote: true }), t.fontFamily && _jsx(ToolbarButton, { icon: "fontFamily", label: "Font family", disabled: readOnly, onClick: () => applyAttributedMark("fontFamily"), widePromote: true }), showMoreTextStyles && _jsx(ToolbarDropdown, { icon: "textColor", label: "More text styles", priority: 2, children: textStylesMenuItems })] }), _jsxs(ToolbarGroup, { children: [t.blockType && _jsxs("select", { "aria-label": "Block type", title: "Block type", value: currentBlockType, disabled: readOnly, onChange: (event) => transactBlock(setBlockTypeCommand(runtime.editor.document, blockScope(), {
1564
+ type: event.target.value === "paragraph" ? "paragraph" : event.target.value === "code_block" ? "code_block" : "heading",
1565
+ attrs: event.target.value.startsWith("heading-") ? { level: Number(event.target.value.slice(8)) } : {},
1566
+ }, 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" })] }), t.alignLeft && _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())) }), t.alignCenter && _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())) }), t.alignRight && _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())) }), t.alignJustify && _jsx(ToolbarButton, { icon: "alignJustify", label: "Justify", ariaLabel: "Align justify", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "justify" } }, blockContext())) }), t.lineHeight && _jsx(ToolbarDropdown, { icon: "lineHeight", label: "Line spacing", priority: 2, children: lineHeightMenuItems }), t.quote && _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: [t.bulletedList && _jsx(ToolbarButton, { icon: "bulletedList", label: "Bulleted list", ariaLabel: "Bulleted list", pressed: listStyleActive("disc"), disabled: readOnly, onClick: () => toggleList("disc") }), t.numberedList && _jsx(ToolbarButton, { icon: "numberedList", label: "Numbered list", ariaLabel: "Numbered list", pressed: listStyleActive("decimal"), disabled: readOnly, onClick: () => toggleList("decimal") }), t.checklist && _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: [listPresetSelect, t.listPreset && _jsx("div", { className: "srte-menu-separator" }), listToolsMenuItems] })] }), _jsxs(ToolbarGroup, { children: [t.link && _jsx(ToolbarButton, { icon: "link", label: "Link", ariaLabel: "Insert or edit link", disabled: readOnly, onClick: openLinkPopover }), showImageTool && _jsx(ToolbarButton, { icon: "image", label: "Image", ariaLabel: "Insert image", disabled: readOnly, onClick: () => setMediaKind("image") }), t.removeLink && _jsx(ToolbarButton, { icon: "unlink", label: "Remove link", disabled: readOnly, onClick: removeLink, widePromote: true }), t.insertFormula && _jsx(ToolbarButton, { icon: "formula", label: "Insert formula", disabled: readOnly, widePromote: true, onClick: (event) => {
1567
+ const rect = event.currentTarget.getBoundingClientRect();
1568
+ setFormulaLibraryPopover({ x: rect.left, y: rect.bottom + 4 });
1569
+ } }), t.specialCharacters && _jsx(ToolbarButton, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
1570
+ const rect = event.currentTarget.getBoundingClientRect();
1571
+ setSpecialCharPopover({ x: rect.left, y: rect.bottom + 4 });
1572
+ } }), t.horizontalLine && _jsx(ToolbarButton, { icon: "divider", label: "Horizontal line", disabled: readOnly, widePromote: true, onClick: () => { insertBlockAtom("divider", {}, createNodeId()); runtime.focus(); } }), t.pageBreak && _jsx(ToolbarButton, { icon: "pageBreak", label: "Page break", disabled: readOnly, widePromote: true, onClick: () => { insertBlockAtom("page_break", {}, createNodeId()); runtime.focus(); } }), _jsx(ToolbarDropdown, { icon: "video", label: "More to insert", priority: 2, children: insertMoreMenuItems })] }), showInsertTable && _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "table", label: "Insert table", ariaLabel: "Insert table", disabled: readOnly, onClick: (event) => {
1573
+ const rect = event.currentTarget.getBoundingClientRect();
1574
+ setTableSizePopover({ x: rect.left, y: rect.bottom + 4 });
1575
+ } }), _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
1576
  const file = event.currentTarget.files?.[0];
508
1577
  if (file)
509
1578
  void runImport(file);
510
1579
  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) => {
1580
+ } }), _jsxs(ToolbarGroup, { children: [t.import && _jsx(ToolbarButton, { icon: "import", label: "Import", ariaLabel: "Import document", disabled: readOnly, onClick: () => importRef.current?.click() }), showSaveCopy && _jsx(ToolbarDropdown, { icon: "saveCopy", label: "Save a copy", priority: 2, children: saveCopyMenuItems }), showVersionHistoryTool && _jsx(ToolbarButton, { icon: "history", label: "Version history", ariaLabel: "Version history", disabled: readOnly, onClick: () => setVersionHistoryOpen(true) }), showReviewDropdown && _jsx(ToolbarDropdown, { icon: "comments", label: "Review", priority: 2, children: reviewMenuItems })] }), _jsxs(ToolbarGroup, { children: [t.undo && _jsx(ToolbarButton, { icon: "undo", label: "Undo", ariaLabel: "Undo", disabled: readOnly, onClick: () => { runtime.editor.undo(); runtime.focus(); } }), t.redo && _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, t.lineHeight && _jsx("div", { className: "srte-menu-separator" }), t.lineHeight && lineHeightMenuItems, _jsx("div", { className: "srte-menu-separator" }), listPresetSelect, listToolsMenuItems, _jsx("div", { className: "srte-menu-separator" }), insertMoreMenuItems, showInsertTable && _jsxs(_Fragment, { children: [_jsx("div", { className: "srte-menu-separator" }), tableToolsMenuItems] }), showSaveCopy && _jsxs(_Fragment, { children: [_jsx("div", { className: "srte-menu-separator" }), saveCopyMenuItems] }), showReviewDropdown && _jsxs(_Fragment, { children: [_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) }), showVersionHistoryTool && _jsx(VersionHistoryPanel, { open: versionHistoryOpen, onClose: () => setVersionHistoryOpen(false), runtime: runtime, versionProvider: versionProvider }), showCommentsTool && 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 }), showCommentsTool && _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 }), showSuggestionsTool && 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 }), showSuggestionsTool && _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: () => {
1581
+ setLinkOverlayDismissedAt(runtime.editor.selection.head);
1582
+ setLinkPopover(null);
1583
+ runtime.focus();
1584
+ } }), !readOnly && mediaResizeTarget && mediaResizeTarget.nodeId === selectedAtomId && 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, showMenu: false, resizable: resizableAtomSelected, onEdit: () => undefined, onResize: () => undefined, onResizeTo: resizeSelectedAtomTo, onDelete: () => undefined, onDismiss: () => setMediaResizeTarget(null) }), !readOnly && mediaContextMenu && (mediaAtomSelected || actionOnlyAtomSelected) && 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, x: mediaContextMenu.x, y: mediaContextMenu.y, resizable: resizableAtomSelected, editable: mediaAtomSelected, onEdit: () => { setMediaContextMenu(null); editSelectedAtom(); }, onResize: (by) => { setMediaContextMenu(null); editSelectedAtom(by); }, onResizeTo: resizeSelectedAtomTo, onDelete: () => { setMediaContextMenu(null); deleteSelectedAtom(); }, onDismiss: () => { setMediaContextMenu(null); runtime.focus(); } }), colorPopover && _jsx(ColorPickerPopover, { x: colorPopover.x, y: colorPopover.y, label: colorPopover.target.kind === "mark"
1585
+ ? (colorPopover.target.markId === "textColor" ? "Text colour" : "Background colour")
1586
+ : 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) => {
1587
+ setTableSizePopover(null);
1588
+ insertTable(rows, columns);
1589
+ runtime.focus();
1590
+ }, onCancel: () => {
1591
+ setTableSizePopover(null);
1592
+ runtime.focus();
1593
+ } }), tableBorderPopover && _jsx(TableBorderPopover, { x: tableBorderPopover.x, y: tableBorderPopover.y, initial: tableBorderPopover.initial, recentColors: recentColors.border, onPreview: previewCellBorder, onApply: applyCellBorderCommit, onCancel: cancelTableBorderPopover }), mediaDetailsPopover && _jsx(MediaDetailsPopover, { x: mediaDetailsPopover.x, y: mediaDetailsPopover.y, initial: mediaDetailsPopover.initial, onApply: applyMediaDetails, onCancel: () => { setMediaDetailsPopover(null); runtime.focus(); } }), 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) => {
1594
+ // A Ctrl/Cmd+click on a link is meant to open it (onClick below),
1595
+ // not move the caret into it - but native mousedown's own default
1596
+ // action *is* the caret-repositioning, and mousedown fires (and
1597
+ // its default action resolves) before click ever does. Blocking
1598
+ // it here means the caret never enters the link for this
1599
+ // gesture, so the auto-triggered edit overlay (which keys off
1600
+ // exactly that) never gets a reason to open in the first place -
1601
+ // root-caused at the source rather than racing a flag against
1602
+ // it after the fact. (A flag set in onClick was tried first and
1603
+ // proved unreliable: by the time onClick ran, Chromium/Firefox/
1604
+ // WebKit had already carried the caret-move through to a
1605
+ // completed render in a varying number of passes, so the overlay
1606
+ // could already be open before the flag was ever set.)
1607
+ const anchor = event.target?.closest("a[href]");
1608
+ const linkedMedia = event.target?.closest("[data-smart-href]");
1609
+ if ((anchor || linkedMedia) && (event.metaKey || event.ctrlKey))
1610
+ event.preventDefault();
1611
+ }, onClick: (event) => {
512
1612
  const anchor = event.target?.closest("a[href]");
513
- if (!anchor || !(event.metaKey || event.ctrlKey))
1613
+ // An image atom with a Link set (docs/bugs/media-details-old-editor-field-parity.md)
1614
+ // - plain data attributes, not a real <a> wrapper, in the live DOM
1615
+ // (see surface/renderer.ts's own reasoning); Ctrl/Cmd+click opens it
1616
+ // the same way a real text link does, matching this project's only
1617
+ // existing link-opening convention rather than inventing a second one.
1618
+ const linkedMedia = event.target?.closest("[data-smart-href]");
1619
+ const href = anchor?.href || linkedMedia?.getAttribute("data-smart-href");
1620
+ if (href && (event.metaKey || event.ctrlKey)) {
1621
+ event.preventDefault();
1622
+ window.open(href, "_blank", "noopener,noreferrer");
1623
+ return;
1624
+ }
1625
+ const imageTarget = event.target?.closest("[data-smart-type=\"image\"], [data-smart-type=\"block_image\"]");
1626
+ if (!imageTarget)
1627
+ return;
1628
+ setMediaContextMenu(null);
1629
+ const mappedImage = runtime.surface.renderer?.mapping.domToNode(imageTarget);
1630
+ if (mappedImage)
1631
+ setMediaResizeTarget({ nodeId: mappedImage.nodeId });
1632
+ }, onContextMenu: (event) => {
1633
+ if (readOnly)
514
1634
  return;
515
1635
  event.preventDefault();
516
- window.open(anchor.href, "_blank", "noopener,noreferrer");
1636
+ setMediaContextMenu(null);
1637
+ // Atom selection (unlike caret/text placement) is not native
1638
+ // browser behavior - it's InputController's own clickListener
1639
+ // (surface/input.ts), bound to the "click" DOM event, which never
1640
+ // fires for a right-click. Without this, right-clicking an atom
1641
+ // directly (no preceding left-click) left whatever selection was
1642
+ // already there in place, so atomic-node scope never resolved and
1643
+ // the media interaction (details editor for images, quick-action
1644
+ // menu for other media) never appeared - right-clicking media looked
1645
+ // like right-clicking nothing. Mirrors clickListener's own
1646
+ // atom-selection logic
1647
+ // exactly.
1648
+ const atomTarget = event.target?.closest("[data-smart-atomic]");
1649
+ const mapped = atomTarget ? runtime.surface.renderer?.mapping.domToNode(atomTarget) : null;
1650
+ if (mapped && !isTextNode(mapped.node) && runtime.editor.schema.nodes[mapped.node.type]?.selectable === true) {
1651
+ const range = runtime.editor.positions.rangeOf(mapped.nodeId);
1652
+ if (range) {
1653
+ runtime.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "api" });
1654
+ runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
1655
+ }
1656
+ if (mapped.node.type !== "unknown") {
1657
+ setContextMenu(null);
1658
+ const isImage = mapped.node.type === "image" || mapped.node.type === "block_image";
1659
+ if (isImage) {
1660
+ const scope = runtime.editor.resolveScope({ want: "atomic-node" });
1661
+ setMediaResizeTarget({ nodeId: mapped.nodeId });
1662
+ if (scope.kind === "atomic-node")
1663
+ openMediaDetails(scope, mapped.node, atomTarget || undefined);
1664
+ return;
1665
+ }
1666
+ setMediaContextMenu({ x: event.clientX, y: event.clientY, nodeId: mapped.nodeId });
1667
+ return;
1668
+ }
1669
+ }
1670
+ else if (isCollapsedTextSelection(runtime.editor.selection)) {
1671
+ // Chromium/Firefox reposition the native caret to the click
1672
+ // point on a bare right-click, the same as a left-click - the
1673
+ // context menu items resolved against runtime.editor.selection
1674
+ // afterward were already scoped correctly there. WebKit does
1675
+ // not: a right-click leaves the previous selection untouched,
1676
+ // so scope resolution (and, before the marksForRange fix above,
1677
+ // an unrelated bug in it too) saw stale state instead of what
1678
+ // was actually clicked. Explicitly resolving the caret from the
1679
+ // click point - the same caretPositionFromPoint/caretRangeFromPoint
1680
+ // + mapping.domToPos pattern InputController's own
1681
+ // selectionForPoint already uses for drag/drop - makes this
1682
+ // right-click path correct on every engine instead of relying
1683
+ // on inconsistent native behavior. Only when the selection was
1684
+ // already collapsed - right-clicking a deliberately selected
1685
+ // range (e.g. to apply Bold from the menu) must never collapse
1686
+ // it out from under the user, matching standard context-menu
1687
+ // convention (this is what "right-click here does something
1688
+ // useful to my selection" depends on).
1689
+ const pointDocument = event.currentTarget.ownerDocument;
1690
+ const caret = pointDocument.caretPositionFromPoint?.(event.clientX, event.clientY);
1691
+ const fallback = !caret ? pointDocument.caretRangeFromPoint?.(event.clientX, event.clientY) : null;
1692
+ const pos = caret ? runtime.surface.renderer?.mapping.domToPos(caret.offsetNode, caret.offset)
1693
+ : fallback ? runtime.surface.renderer?.mapping.domToPos(fallback.startContainer, fallback.startOffset) : null;
1694
+ if (pos) {
1695
+ runtime.editor.setSelection({ type: "text", anchor: pos, head: pos }, { source: "api" });
1696
+ runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
1697
+ }
1698
+ }
1699
+ // A right-click that is not handled by the media branch above and
1700
+ // does not resolve to a table cell opens no generic menu.
1701
+ const rightClickTableScope = runtime.editor.resolveScope({ want: "table-grid" });
1702
+ if ("kind" in rightClickTableScope && rightClickTableScope.kind === "table-grid") {
1703
+ setContextMenu({ x: event.clientX, y: event.clientY });
1704
+ }
517
1705
  }, style: { minHeight, maxHeight, overflow: "auto" } })] });
518
1706
  });