smartrte-react 1.0.0-beta.1 → 1.0.0-beta.11

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.
@@ -19,8 +19,10 @@ import { SpecialCharacterPopover } from "./SpecialCharacterPopover.js";
19
19
  import { TableBorderPopover, BORDER_WIDTH_PRESETS } from "./TableBorderPopover.js";
20
20
  import { TableResizeHandles } from "./TableResizeHandles.js";
21
21
  import { MediaOverlay } from "./MediaOverlay.js";
22
+ import { MediaDetailsPopover } from "./MediaDetailsPopover.js";
22
23
  import { ContextMenu } from "./ContextMenu.js";
23
24
  import { ToolbarButton, ToolbarDropdown, ToolbarGroup, ToolbarMenuItem, MobileMoreMenu } from "./ToolbarPrimitives.js";
25
+ import { resolveToolbarTools } from "../toolbarTools.js";
24
26
  import { exportDocxDocument, importStyledDocxDocument, importPdfDocument } from "smartrte-core/foundation";
25
27
  import { printSmartDocumentAsPdf } from "../adapters/pdfPrint.js";
26
28
  import { CanonicalEditorRuntime, } from "../canonicalEditorRuntime.js";
@@ -102,7 +104,7 @@ const parseBorderShorthand = (value) => {
102
104
  const isCollapsedTextSelection = (selection) => selection.type === "text" && selection.anchor.offset === selection.head.offset
103
105
  && selection.anchor.path.length === selection.head.path.length
104
106
  && selection.anchor.path.every((part, index) => part === selection.head.path[index]);
105
- export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEditor({ defaultValue, preset, onChange, onHtmlChange, onClipboardDiagnostic, mediaProvider, mediaPicker: MediaPicker = DefaultMediaPicker, mediaManager = true, versionProvider, commentProvider, suggestionProvider, authorId, placeholder = "Type here…", minHeight = 200, maxHeight = 500, readOnly = false, className, onRuntime, }, forwardedRef) {
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) {
106
108
  ensureStyleSheet();
107
109
  const rootRef = useRef(null);
108
110
  const importRef = useRef(null);
@@ -129,6 +131,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
129
131
  const [recentColors, setRecentColors] = useState({ text: [], background: [], border: [] });
130
132
  const [tableSizePopover, setTableSizePopover] = useState(null);
131
133
  const [tableBorderPopover, setTableBorderPopover] = useState(null);
134
+ const [mediaDetailsPopover, setMediaDetailsPopover] = useState(null);
132
135
  const borderPreviewCheckpointRef = useRef(null);
133
136
  const [formulaLibraryPopover, setFormulaLibraryPopover] = useState(null);
134
137
  const [specialCharPopover, setSpecialCharPopover] = useState(null);
@@ -140,6 +143,8 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
140
143
  // since nothing was ever applied to the model).
141
144
  const colorPreviewCheckpointRef = useRef(null);
142
145
  const [contextMenu, setContextMenu] = useState(null);
146
+ const [mediaContextMenu, setMediaContextMenu] = useState(null);
147
+ const [mediaResizeTarget, setMediaResizeTarget] = useState(null);
143
148
  // Tracks the exact caret position (path+offset) the link overlay was
144
149
  // last dismissed at (Escape/outside click) - suppresses an instant
145
150
  // re-popup while the caret hasn't moved, without needing a separate
@@ -147,7 +152,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
147
152
  // selection.head differs from this value.
148
153
  const [linkOverlayDismissedAt, setLinkOverlayDismissedAt] = useState(null);
149
154
  if (!runtimeRef.current)
150
- runtimeRef.current = new CanonicalEditorRuntime({ initialValue: defaultValue, preset, onChange, onHtmlChange, onClipboardDiagnostic });
155
+ runtimeRef.current = new CanonicalEditorRuntime({ initialValue: defaultValue, preset, renderFormulaHtml, onChange, onHtmlChange, onClipboardDiagnostic });
151
156
  const runtime = runtimeRef.current;
152
157
  const [, setEditorTick] = useState(0);
153
158
  runtime.setCallbacks(onChange, onHtmlChange);
@@ -267,6 +272,35 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
267
272
  };
268
273
  const blockScope = () => runtime.editor.resolveScope({ want: "block-range" });
269
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()));
270
304
  const listScope = () => runtime.editor.resolveScope({ want: "list-selection" });
271
305
  const tableScope = () => runtime.editor.resolveScope({ want: "table-grid" });
272
306
  const atomScope = () => runtime.editor.resolveScope({ want: "atomic-node" });
@@ -320,6 +354,19 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
320
354
  // an `unknown` node (Phase 10's disable-safety contract, unchanged) -
321
355
  // this only hides the *toolbar affordance* for creating new ones.
322
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);
323
370
  const tableSelected = currentTableScope.kind === "table-grid";
324
371
  const selectedTableElement = tableSelected
325
372
  ? runtime.surface.renderer?.mapping.nodeToDom(currentTableScope.tableId)
@@ -329,15 +376,16 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
329
376
  ? runtime.surface.renderer?.mapping.nodeToDom(currentAtomScope.nodeId)
330
377
  : undefined;
331
378
  const selectedAtomNode = atomSelected ? findNode(runtime.editor.document, currentAtomScope.nodeId) : null;
332
- // A divider (<hr>) is atomic/selectable (so ordinary caret/Backspace/
333
- // Delete behavior around it works, and the toolbar's "Delete selected
334
- // atom" button applies to it), but it has no src/alt/width/height at
335
- // all - MediaOverlay and the media-specific Edit/Resize actions assume
336
- // every atom is a real media item, and unconditionally showing them for
337
- // a divider surfaced nonsensical empty/undefined values with no
338
- // sensible edit or resize target. Media-only UI is gated on this
339
- // instead of plain atomSelected; delete stays available for any atom.
340
- const mediaAtomSelected = atomSelected && selectedAtomNode?.type !== "divider";
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";
341
389
  // Formula (like divider above) has no width/height concept - KaTeX sizes
342
390
  // its own rendering from the source/font-size, not stored dimensions, so
343
391
  // "Enlarge/Shrink selected media" and MediaOverlay's resize handle had no
@@ -348,6 +396,22 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
348
396
  // node.type.includes("formula") for its own correct "Formula source"
349
397
  // prompt.
350
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]);
351
415
  // Context menu scope reduction: link no longer gets a right-click menu -
352
416
  // LinkEditorPopover (already built and wired for the toolbar's Link
353
417
  // button, per §1's investigation) auto-appears, anchored to the link's
@@ -803,13 +867,10 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
803
867
  * the same way Tab special-cases list.indent/outdent's dynamic params.
804
868
  */
805
869
  /**
806
- * Context menu scope reduction: table-only now. Marks (already on the
807
- * toolbar) and atom/link (moved to their own dedicated overlays -
808
- * MediaOverlay below, and the auto-triggered LinkEditorPopover - see
809
- * linkAnchorElement) no longer register contextMenu contributions at
810
- * all (marks/plugin.ts, atom/plugin.ts) or get hardcoded item blocks
811
- * here - only table plugin contributions (the generic loop) and the
812
- * cell-colour items remain.
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.
813
874
  */
814
875
  const resolveContextMenuItems = () => {
815
876
  const many = () => ids(128);
@@ -1028,6 +1089,19 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1028
1089
  insertBlockAtom("block_image", {
1029
1090
  src: item.url, alt: item.alt || item.title || "Image", decorative: false, status: "ready",
1030
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 } : {}),
1031
1105
  }, createNodeId());
1032
1106
  };
1033
1107
  const commentAuthorId = authorId || "anonymous";
@@ -1211,6 +1285,27 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1211
1285
  runtime.focus();
1212
1286
  setLinkPopover(null);
1213
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
+ };
1214
1309
  const editSelectedAtom = (resizeBy) => {
1215
1310
  const scope = atomScope();
1216
1311
  if (scope.kind !== "atomic-node")
@@ -1218,16 +1313,59 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1218
1313
  const node = findNode(runtime.editor.document, scope.nodeId);
1219
1314
  if (!node)
1220
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
+ }
1221
1325
  const operations = resizeBy === undefined
1222
- ? updateAtom(runtime.editor.document, scope, node.type.includes("formula")
1223
- ? { attrs: { source: window.prompt("Formula source", String(node.attrs?.source || "")) || node.attrs?.source } }
1224
- : { 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())
1225
1327
  : resizeAtom(runtime.editor.document, scope, {
1226
1328
  width: Math.max(16, Number(node.attrs?.width || 160) + resizeBy),
1227
1329
  height: Math.max(16, Number(node.attrs?.height || 90) + resizeBy),
1228
1330
  }, blockContext());
1229
1331
  runtime.executeOperations(operations, resizeBy === undefined ? {} : { historyGroup: `resize-${scope.nodeId}` });
1230
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
+ };
1231
1369
  /** MediaOverlay's drag-corner-handle commit - explicit width/height rather than editSelectedAtom's fixed +/-20 increment. */
1232
1370
  const resizeSelectedAtomTo = (width, height) => {
1233
1371
  const scope = atomScope();
@@ -1326,26 +1464,72 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1326
1464
  // twice; below 1440px (including mobile) this copy is what's reachable,
1327
1465
  // same as before. "Code" isn't promoted - see the docs/bugs/ writeup on
1328
1466
  // why it stayed the dropdown's sole remaining item at wide widths.
1329
- const textStylesMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "code", label: "Code", pressed: markCoverage("inlineCode"), disabled: readOnly, onClick: () => toggleMark("inlineCode") }), _jsx(ToolbarMenuItem, { icon: "superscript", label: "Superscript", pressed: markCoverage("superscript"), disabled: readOnly, onClick: () => toggleMark("superscript"), widePromote: true }), _jsx(ToolbarMenuItem, { icon: "subscript", label: "Subscript", pressed: markCoverage("subscript"), disabled: readOnly, onClick: () => toggleMark("subscript"), widePromote: true }), _jsx(ToolbarMenuItem, { icon: "textColor", label: "Text colour", disabled: readOnly, widePromote: true, onClick: (event) => {
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) => {
1330
1468
  const rect = event.currentTarget.getBoundingClientRect();
1331
1469
  setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "textColor" }, initialValue: currentMarkColor("textColor") });
1332
- } }), _jsx(ToolbarMenuItem, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1470
+ } }), t.backgroundColor && _jsx(ToolbarMenuItem, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1333
1471
  const rect = event.currentTarget.getBoundingClientRect();
1334
1472
  setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "backgroundColor" }, initialValue: currentMarkColor("backgroundColor") });
1335
- } }), _jsx(ToolbarMenuItem, { icon: "fontSize", label: "Font size", disabled: readOnly, onClick: () => applyAttributedMark("fontSize"), widePromote: true }), _jsx(ToolbarMenuItem, { icon: "fontFamily", label: "Font family", disabled: readOnly, onClick: () => applyAttributedMark("fontFamily"), widePromote: true })] });
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")
1490
+ return;
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
+ } })] })] });
1336
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))] }) });
1337
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 }) })] });
1338
1522
  // Remove link/Insert formula/Special characters also get a standalone
1339
1523
  // widePromote ToolbarButton (see textStylesMenuItems's own comment above
1340
1524
  // for the mechanism) - Insert video/audio and the selected-media actions
1341
1525
  // stay dropdown-only, matching the report's own named tool list.
1342
- const insertMoreMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "unlink", label: "Remove link", disabled: readOnly, onClick: removeLink, widePromote: true }), _jsx(ToolbarMenuItem, { icon: "video", label: "Insert video", disabled: readOnly || !mediaProvider, onClick: () => setMediaKind("video") }), _jsx(ToolbarMenuItem, { icon: "audio", label: "Insert audio", disabled: readOnly || !mediaProvider, onClick: () => setMediaKind("audio") }), _jsx(ToolbarMenuItem, { icon: "formula", label: "Insert formula", disabled: readOnly, widePromote: true, onClick: (event) => {
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) => {
1343
1527
  const rect = event.currentTarget.getBoundingClientRect();
1344
1528
  setFormulaLibraryPopover({ x: rect.left, y: rect.bottom + 4 });
1345
- } }), _jsx(ToolbarMenuItem, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
1529
+ } }), t.specialCharacters && _jsx(ToolbarMenuItem, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
1346
1530
  const rect = event.currentTarget.getBoundingClientRect();
1347
1531
  setSpecialCharPopover({ x: rect.left, y: rect.bottom + 4 });
1348
- } }), _jsx(ToolbarMenuItem, { icon: "edit", label: "Edit selected media", disabled: readOnly || !mediaAtomSelected, onClick: () => editSelectedAtom() }), _jsx(ToolbarMenuItem, { icon: "zoomIn", label: "Enlarge selected media", disabled: readOnly || !resizableAtomSelected, onClick: () => editSelectedAtom(20) }), _jsx(ToolbarMenuItem, { icon: "zoomOut", label: "Shrink selected media", disabled: readOnly || !resizableAtomSelected, onClick: () => editSelectedAtom(-20) }), _jsx(ToolbarMenuItem, { icon: "delete", label: "Delete selected media", disabled: readOnly || !atomSelected, onClick: () => runtime.executeOperations(deleteAtom(runtime.editor.document, atomScope(), {}, blockContext())) })] });
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())) })] });
1349
1533
  // "Remove row"/"Remove column"/"Remove table" renamed to "Delete ..." -
1350
1534
  // matches the right-click table context menu's existing wording
1351
1535
  // (table/plugin.ts's table.contextMenu.removeRow/removeColumn/removeTable
@@ -1359,32 +1543,33 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1359
1543
  const rect = event.currentTarget.getBoundingClientRect();
1360
1544
  setTableBorderPopover({ x: rect.left, y: rect.bottom + 4, scope: scope, initial: currentCellBorderDraft(scope) });
1361
1545
  } })] });
1362
- const saveCopyMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { label: "Save as HTML", onClick: () => runExport("html") }), _jsx(ToolbarMenuItem, { label: "Save as Markdown", onClick: () => runExport("markdown") }), _jsx(ToolbarMenuItem, { label: "Save as Word document", onClick: () => void runDocxExport() }), _jsx(ToolbarMenuItem, { label: "Save as PDF", onClick: runPdfExport }), _jsx(ToolbarMenuItem, { icon: "json", label: "Save as Smart RTE file", onClick: () => runExport("native") })] });
1363
- const reviewMenuItems = _jsxs(_Fragment, { children: [_jsx(ToolbarMenuItem, { icon: "addComment", label: "Add comment", disabled: !canComment, onClick: startComment }), _jsx(ToolbarMenuItem, { icon: "comments", label: "Comments", pressed: commentPanelOpen, disabled: !commentProvider, onClick: () => setCommentPanelOpen((open) => !open) }), _jsx(ToolbarMenuItem, { icon: "suggest", label: "Suggest deletion", disabled: !canSuggestDelete, onClick: suggestDelete }), _jsx(ToolbarMenuItem, { icon: "suggest", label: "Suggest insertion", disabled: readOnly || !suggestionProvider, onClick: startSuggestInsert }), _jsx(ToolbarMenuItem, { icon: "suggest", label: "Suggest removing this", disabled: readOnly || !suggestionProvider, onClick: suggestBlockRemoval }), _jsx(ToolbarMenuItem, { icon: "suggestions", label: "Suggestions", pressed: suggestionPanelOpen, disabled: !suggestionProvider, onClick: () => setSuggestionPanelOpen((open) => !open) }), _jsx(ToolbarMenuItem, { icon: "showEdits", label: "Show edits", pressed: trackChangesEnabled, title: "Tracks ordinary typing and edits within a single paragraph as suggestions. Merging paragraphs together - typing over a selection that spans multiple paragraphs, or pressing Backspace/Delete at a paragraph boundary - still applies directly and can't be reviewed as a suggestion.", disabled: readOnly || !suggestionProvider, onClick: () => setTrackChangesEnabled((enabled) => !enabled) })] });
1364
- return _jsxs("section", { className: `srte-root srte-editor srte-canonical-authority${className ? ` ${className}` : ""}`, "data-smart-authority": "canonical", children: [_jsxs("div", { className: "srte-toolbar", role: "toolbar", "aria-label": "Formatting toolbar", children: [_jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "bold", label: "Bold", pressed: markCoverage("bold"), disabled: readOnly, onClick: () => toggleMark("bold") }), _jsx(ToolbarButton, { icon: "italic", label: "Italic", pressed: markCoverage("italic"), disabled: readOnly, onClick: () => toggleMark("italic") }), _jsx(ToolbarButton, { icon: "underline", label: "Underline", pressed: markCoverage("underline"), disabled: readOnly, onClick: () => toggleMark("underline") }), _jsx(ToolbarButton, { icon: "strikethrough", label: "Strikethrough", pressed: markCoverage("strikethrough"), disabled: readOnly, onClick: () => toggleMark("strikethrough") }), _jsx(ToolbarButton, { icon: "superscript", label: "Superscript", pressed: markCoverage("superscript"), disabled: readOnly, onClick: () => toggleMark("superscript"), widePromote: true }), _jsx(ToolbarButton, { icon: "subscript", label: "Subscript", pressed: markCoverage("subscript"), disabled: readOnly, onClick: () => toggleMark("subscript"), widePromote: true }), _jsx(ToolbarButton, { icon: "textColor", label: "Text colour", disabled: readOnly, widePromote: true, onClick: (event) => {
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) => {
1365
1558
  const rect = event.currentTarget.getBoundingClientRect();
1366
1559
  setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "textColor" }, initialValue: currentMarkColor("textColor") });
1367
- } }), _jsx(ToolbarButton, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1560
+ } }), t.backgroundColor && _jsx(ToolbarButton, { icon: "backgroundColor", label: "Background colour", disabled: readOnly, widePromote: true, onClick: (event) => {
1368
1561
  const rect = event.currentTarget.getBoundingClientRect();
1369
1562
  setColorPopover({ x: rect.left, y: rect.bottom + 4, target: { kind: "mark", markId: "backgroundColor" }, initialValue: currentMarkColor("backgroundColor") });
1370
- } }), _jsx(ToolbarButton, { icon: "fontSize", label: "Font size", disabled: readOnly, onClick: () => applyAttributedMark("fontSize"), widePromote: true }), _jsx(ToolbarButton, { icon: "fontFamily", label: "Font family", disabled: readOnly, onClick: () => applyAttributedMark("fontFamily"), widePromote: true }), _jsx(ToolbarDropdown, { icon: "textColor", label: "More text styles", priority: 2, children: textStylesMenuItems })] }), _jsxs(ToolbarGroup, { children: [_jsxs("select", { "aria-label": "Block type", title: "Block type", value: currentBlockType, disabled: readOnly, onChange: (event) => transactBlock(setBlockTypeCommand(runtime.editor.document, blockScope(), {
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(), {
1371
1564
  type: event.target.value === "paragraph" ? "paragraph" : event.target.value === "code_block" ? "code_block" : "heading",
1372
1565
  attrs: event.target.value.startsWith("heading-") ? { level: Number(event.target.value.slice(8)) } : {},
1373
- }, blockContext())), children: [_jsx("option", { value: "paragraph", children: "Paragraph" }), Array.from({ length: 6 }, (_, index) => _jsxs("option", { value: `heading-${index + 1}`, children: ["Heading ", index + 1] }, index + 1)), _jsx("option", { value: "code_block", children: "Code block" })] }), _jsx(ToolbarButton, { icon: "alignLeft", label: "Align left", ariaLabel: "Align left", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "left" } }, blockContext())) }), _jsx(ToolbarButton, { icon: "alignCenter", label: "Align center", ariaLabel: "Align center", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "center" } }, blockContext())) }), _jsx(ToolbarButton, { icon: "alignRight", label: "Align right", ariaLabel: "Align right", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "right" } }, blockContext())) }), _jsx(ToolbarButton, { icon: "alignJustify", label: "Justify", ariaLabel: "Align justify", iconOnly: true, disabled: readOnly, onClick: () => transactBlock(setBlockAttributes(runtime.editor.document, blockScope(), { attrs: { align: "justify" } }, blockContext())) }), _jsx(ToolbarButton, { icon: "quote", label: "Quote", ariaLabel: "Blockquote", disabled: readOnly, onClick: toggleBlockquote }), _jsx(ToolbarDropdown, { icon: "moveUp", label: "More paragraph tools", priority: 2, children: paragraphToolsMenuItems })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "bulletedList", label: "Bulleted list", ariaLabel: "Bulleted list", pressed: listStyleActive("disc"), disabled: readOnly, onClick: () => toggleList("disc") }), _jsx(ToolbarButton, { icon: "numberedList", label: "Numbered list", ariaLabel: "Numbered list", pressed: listStyleActive("decimal"), disabled: readOnly, onClick: () => toggleList("decimal") }), _jsx(ToolbarButton, { icon: "checklist", label: "Checklist", ariaLabel: "Checklist", pressed: listStyleActive("disc", true), disabled: readOnly, onClick: () => toggleList("disc", true) }), _jsxs(ToolbarDropdown, { icon: "restart", label: "More list tools", priority: 2, children: [_jsx("div", { style: { padding: "4px 8px" }, children: _jsxs("select", { "aria-label": "List preset", title: "List type / preset", disabled: readOnly || currentListParts.length !== 1, value: currentListPreset, onChange: (event) => {
1374
- const preset = event.target.value;
1375
- if (!preset || currentListParts.length !== 1)
1376
- return;
1377
- // A preset choice applies to the whole list, regardless of how deep
1378
- // the cursor is nested — see outermostListId.
1379
- const rootId = outermostListId(currentListParts[0].listId);
1380
- runtime.executeOperations(setListPreset(runtime.editor.document, { ...currentListParts[0], listId: rootId }, { preset }, blockContext()), { preserveSelectionById: true });
1381
- }, style: { width: "100%" }, children: [_jsx("option", { value: "", children: "List preset" }), SMART_LIST_PRESETS.map((preset) => _jsx("option", { value: preset.id, children: preset.kind === "bullet" ? `Bullet · ${preset.label}` : `Number · ${preset.label}` }, preset.id))] }) }), _jsx("div", { className: "srte-menu-separator" }), listToolsMenuItems] })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "link", label: "Link", ariaLabel: "Insert or edit link", disabled: readOnly, onClick: openLinkPopover }), _jsx(ToolbarButton, { icon: "image", label: "Image", ariaLabel: "Insert image", disabled: readOnly || !mediaProvider, onClick: () => setMediaKind("image") }), _jsx(ToolbarButton, { icon: "unlink", label: "Remove link", disabled: readOnly, onClick: removeLink, widePromote: true }), _jsx(ToolbarButton, { icon: "formula", label: "Insert formula", disabled: readOnly, widePromote: true, onClick: (event) => {
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) => {
1382
1567
  const rect = event.currentTarget.getBoundingClientRect();
1383
1568
  setFormulaLibraryPopover({ x: rect.left, y: rect.bottom + 4 });
1384
- } }), _jsx(ToolbarButton, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
1569
+ } }), t.specialCharacters && _jsx(ToolbarButton, { icon: "specialChar", label: "Special characters", disabled: readOnly, widePromote: true, onClick: (event) => {
1385
1570
  const rect = event.currentTarget.getBoundingClientRect();
1386
1571
  setSpecialCharPopover({ x: rect.left, y: rect.bottom + 4 });
1387
- } }), _jsx(ToolbarDropdown, { icon: "video", label: "More to insert", priority: 2, children: insertMoreMenuItems })] }), tablesEnabled && _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "table", label: "Insert table", ariaLabel: "Insert table", disabled: readOnly, onClick: (event) => {
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) => {
1388
1573
  const rect = event.currentTarget.getBoundingClientRect();
1389
1574
  setTableSizePopover({ x: rect.left, y: rect.bottom + 4 });
1390
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) => {
@@ -1392,11 +1577,11 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1392
1577
  if (file)
1393
1578
  void runImport(file);
1394
1579
  event.currentTarget.value = "";
1395
- } }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "import", label: "Import", ariaLabel: "Import document", disabled: readOnly, onClick: () => importRef.current?.click() }), _jsx(ToolbarDropdown, { icon: "saveCopy", label: "Save a copy", priority: 2, children: saveCopyMenuItems }), _jsx(ToolbarButton, { icon: "history", label: "Version history", ariaLabel: "Version history", disabled: readOnly || !versionProvider, onClick: () => setVersionHistoryOpen(true) }), _jsx(ToolbarDropdown, { icon: "comments", label: "Review", priority: 2, children: reviewMenuItems })] }), _jsxs(ToolbarGroup, { children: [_jsx(ToolbarButton, { icon: "undo", label: "Undo", ariaLabel: "Undo", disabled: readOnly, onClick: () => { runtime.editor.undo(); runtime.focus(); } }), _jsx(ToolbarButton, { icon: "redo", label: "Redo", ariaLabel: "Redo", disabled: readOnly, onClick: () => { runtime.editor.redo(); runtime.focus(); } })] }), _jsxs(MobileMoreMenu, { children: [textStylesMenuItems, _jsx("div", { className: "srte-menu-separator" }), paragraphToolsMenuItems, _jsx("div", { className: "srte-menu-separator" }), listToolsMenuItems, _jsx("div", { className: "srte-menu-separator" }), insertMoreMenuItems, tablesEnabled && _jsxs(_Fragment, { children: [_jsx("div", { className: "srte-menu-separator" }), tableToolsMenuItems] }), _jsx("div", { className: "srte-menu-separator" }), saveCopyMenuItems, _jsx("div", { className: "srte-menu-separator" }), reviewMenuItems] })] }), mediaKind === "image" && mediaManager && mediaProvider && _jsx(MediaManager, { open: true, onClose: () => setMediaKind(null), adapter: mediaManagerAdapter, onSelect: selectFromMediaManager }), mediaKind && mediaProvider && !(mediaKind === "image" && mediaManager) && _jsx(MediaPicker, { kind: mediaKind, onPick: (file) => void insertMediaFile(mediaKind, file), onCancel: () => setMediaKind(null) }), versionProvider && _jsx(VersionHistoryPanel, { open: versionHistoryOpen, onClose: () => setVersionHistoryOpen(false), runtime: runtime, versionProvider: versionProvider }), commentProvider && rootRef.current && runtime.surface.renderer?.mapping && _jsx(CommentMarkers, { positions: runtime.editor.positions, mapping: runtime.surface.renderer.mapping, containerElement: rootRef.current, threads: threads, activeThreadId: activeThreadId, onSelectThread: selectThread }), commentProvider && _jsx(CommentThreadPanel, { open: commentPanelOpen, onClose: () => { setCommentPanelOpen(false); setPendingCommentRange(null); }, threads: threads, activeThreadId: activeThreadId, onSelectThread: selectThread, pendingRange: pendingCommentRange, onCreateThread: createThread, onReply: replyToThread, onResolve: setThreadResolved, onDelete: deleteThread, busyThreadId: busyThreadId }), suggestionProvider && rootRef.current && runtime.surface.renderer?.mapping && _jsx(StructuralSuggestionMarkers, { positions: runtime.editor.positions, mapping: runtime.surface.renderer.mapping, containerElement: rootRef.current, suggestions: structuralSuggestions, activeSuggestionId: activeSuggestionId, onSelectSuggestion: selectSuggestion }), suggestionProvider && _jsx(SuggestionPanel, { open: suggestionPanelOpen, onClose: () => { setSuggestionPanelOpen(false); setPendingSuggestInsertAt(null); }, document: runtime.editor.document, structuralSuggestions: structuralSuggestions, activeId: activeSuggestionId, onSelect: selectSuggestion, pendingInsert: Boolean(pendingSuggestInsertAt), onSubmitInsert: submitSuggestInsert, onAcceptInline: acceptInlineSuggestion, onRejectInline: rejectInlineSuggestion, onAcceptStructural: acceptStructural, onRejectStructural: rejectStructural, busyId: busySuggestionId }), linkPopover && _jsx(LinkEditorPopover, { x: linkPopover.x, y: linkPopover.y, initialHref: linkPopover.href, initialOpenInNewTab: linkPopover.openInNewTab, showTextInput: linkPopover.collapsed && !linkPopover.editingExisting, showRemove: linkPopover.editingExisting, autoFocus: !linkPopover.autoTriggered, onApply: applyLink, onRemove: removeLink, onCancel: () => {
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: () => {
1396
1581
  setLinkOverlayDismissedAt(runtime.editor.selection.head);
1397
1582
  setLinkPopover(null);
1398
1583
  runtime.focus();
1399
- } }), !readOnly && mediaAtomSelected && selectedAtomElement && _jsx(MediaOverlay, { atomElement: selectedAtomElement, alt: typeof selectedAtomNode?.attrs?.alt === "string" ? selectedAtomNode.attrs.alt : "", width: typeof selectedAtomNode?.attrs?.width === "number" ? selectedAtomNode.attrs.width : undefined, height: typeof selectedAtomNode?.attrs?.height === "number" ? selectedAtomNode.attrs.height : undefined, src: typeof selectedAtomNode?.attrs?.src === "string" ? selectedAtomNode.attrs.src : undefined, resizable: resizableAtomSelected, onEdit: () => editSelectedAtom(), onResize: (by) => editSelectedAtom(by), onResizeTo: resizeSelectedAtomTo, onDelete: deleteSelectedAtom, onDismiss: () => runtime.focus() }), colorPopover && _jsx(ColorPickerPopover, { x: colorPopover.x, y: colorPopover.y, label: colorPopover.target.kind === "mark"
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"
1400
1585
  ? (colorPopover.target.markId === "textColor" ? "Text colour" : "Background colour")
1401
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) => {
1402
1587
  setTableSizePopover(null);
@@ -1405,7 +1590,7 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1405
1590
  }, onCancel: () => {
1406
1591
  setTableSizePopover(null);
1407
1592
  runtime.focus();
1408
- } }), tableBorderPopover && _jsx(TableBorderPopover, { x: tableBorderPopover.x, y: tableBorderPopover.y, initial: tableBorderPopover.initial, recentColors: recentColors.border, onPreview: previewCellBorder, onApply: applyCellBorderCommit, onCancel: cancelTableBorderPopover }), formulaLibraryPopover && _jsx(FormulaLibraryPopover, { x: formulaLibraryPopover.x, y: formulaLibraryPopover.y, onInsert: insertFormulaFromLibrary, onCancel: () => { setFormulaLibraryPopover(null); runtime.focus(); } }), specialCharPopover && _jsx(SpecialCharacterPopover, { x: specialCharPopover.x, y: specialCharPopover.y, recentCharacters: recentSpecialChars, onInsert: insertSpecialCharacter, onCancel: () => { setSpecialCharPopover(null); runtime.focus(); } }), !readOnly && selectedTableElement && _jsx(TableResizeHandles, { tableElement: selectedTableElement, onResizeColumn: resizeTableColumn, onResizeRow: resizeTableRow }), contextMenu && _jsx(ContextMenu, { x: contextMenu.x, y: contextMenu.y, items: resolveContextMenuItems(), onDismiss: () => { setContextMenu(null); runtime.focus(); } }), _jsx("div", { ref: rootRef, className: "srte-editor", "data-placeholder": placeholder, contentEditable: !readOnly, suppressContentEditableWarning: true, role: "textbox", "aria-label": "Smart RTE editing surface", "aria-multiline": "true", onMouseDown: (event) => {
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) => {
1409
1594
  // A Ctrl/Cmd+click on a link is meant to open it (onClick below),
1410
1595
  // not move the caret into it - but native mousedown's own default
1411
1596
  // action *is* the caret-repositioning, and mousedown fires (and
@@ -1420,27 +1605,45 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1420
1605
  // completed render in a varying number of passes, so the overlay
1421
1606
  // could already be open before the flag was ever set.)
1422
1607
  const anchor = event.target?.closest("a[href]");
1423
- if (anchor && (event.metaKey || event.ctrlKey))
1608
+ const linkedMedia = event.target?.closest("[data-smart-href]");
1609
+ if ((anchor || linkedMedia) && (event.metaKey || event.ctrlKey))
1424
1610
  event.preventDefault();
1425
1611
  }, onClick: (event) => {
1426
1612
  const anchor = event.target?.closest("a[href]");
1427
- 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");
1428
1623
  return;
1429
- event.preventDefault();
1430
- window.open(anchor.href, "_blank", "noopener,noreferrer");
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 });
1431
1632
  }, onContextMenu: (event) => {
1432
1633
  if (readOnly)
1433
1634
  return;
1434
1635
  event.preventDefault();
1636
+ setMediaContextMenu(null);
1435
1637
  // Atom selection (unlike caret/text placement) is not native
1436
1638
  // browser behavior - it's InputController's own clickListener
1437
1639
  // (surface/input.ts), bound to the "click" DOM event, which never
1438
1640
  // fires for a right-click. Without this, right-clicking an atom
1439
1641
  // directly (no preceding left-click) left whatever selection was
1440
1642
  // already there in place, so atomic-node scope never resolved and
1441
- // MediaOverlay (which shows whenever atomSelected is true) never
1442
- // appeared - right-clicking media looked like right-clicking
1443
- // nothing. Mirrors clickListener's own atom-selection logic
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
1444
1647
  // exactly.
1445
1648
  const atomTarget = event.target?.closest("[data-smart-atomic]");
1446
1649
  const mapped = atomTarget ? runtime.surface.renderer?.mapping.domToNode(atomTarget) : null;
@@ -1450,6 +1653,19 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1450
1653
  runtime.editor.setSelection({ type: "node", anchor: range.from, head: range.to }, { source: "api" });
1451
1654
  runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
1452
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
+ }
1453
1669
  }
1454
1670
  else if (isCollapsedTextSelection(runtime.editor.selection)) {
1455
1671
  // Chromium/Firefox reposition the native caret to the click
@@ -1480,10 +1696,8 @@ export const CanonicalAuthorityEditor = forwardRef(function CanonicalAuthorityEd
1480
1696
  runtime.surface.renderer?.render(runtime.editor.document, runtime.editor.selection);
1481
1697
  }
1482
1698
  }
1483
- // Table-only now (context menu scope reduction) - media/link moved
1484
- // to their own dedicated overlays, marks live on the toolbar only.
1485
- // A right-click that doesn't resolve to a table cell opens nothing
1486
- // at all, rather than an empty "No actions here" menu.
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.
1487
1701
  const rightClickTableScope = runtime.editor.resolveScope({ want: "table-grid" });
1488
1702
  if ("kind" in rightClickTableScope && rightClickTableScope.kind === "table-grid") {
1489
1703
  setContextMenu({ x: event.clientX, y: event.clientY });