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