smartrte-react 0.3.5 → 1.0.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +205 -14
  3. package/dist/adapters/domChecklistCommandBridge.js +1 -1
  4. package/dist/adapters/domCommandBridge.d.ts +2 -2
  5. package/dist/adapters/domCommandBridge.js +2 -2
  6. package/dist/adapters/domInlineAtomCommandBridge.d.ts +10 -9
  7. package/dist/adapters/domInlineAtomCommandBridge.js +154 -50
  8. package/dist/adapters/domInlineImageCommandBridge.d.ts +7 -7
  9. package/dist/adapters/domInlineImageCommandBridge.js +6 -72
  10. package/dist/adapters/domMoveCommandBridge.js +1 -1
  11. package/dist/adapters/domSelectionBridge.d.ts +3 -3
  12. package/dist/adapters/domSmartDocument.d.ts +4 -4
  13. package/dist/adapters/domSmartDocument.js +2 -2
  14. package/dist/adapters/domTableCommandBridge.d.ts +42 -16
  15. package/dist/adapters/domTableCommandBridge.js +203 -173
  16. package/dist/adapters/internalFlags.d.ts +0 -8
  17. package/dist/adapters/internalFlags.js +0 -24
  18. package/dist/adapters/legacyListShadowComparator.d.ts +1 -1
  19. package/dist/adapters/legacyListShadowComparator.js +1 -1
  20. package/dist/adapters/pdfPrint.d.ts +8 -0
  21. package/dist/adapters/pdfPrint.js +22 -0
  22. package/dist/adapters/shadowMode.d.ts +3 -3
  23. package/dist/adapters/shadowMode.js +1 -1
  24. package/dist/canonicalEditorRuntime.d.ts +65 -1
  25. package/dist/canonicalEditorRuntime.js +63 -5
  26. package/dist/capabilityPresets.d.ts +27 -0
  27. package/dist/capabilityPresets.js +21 -0
  28. package/dist/collabTransport.d.ts +66 -0
  29. package/dist/collabTransport.js +1 -0
  30. package/dist/commentProvider.d.ts +24 -0
  31. package/dist/commentProvider.js +1 -0
  32. package/dist/components/CanonicalAuthorityEditor.d.ts +56 -1
  33. package/dist/components/CanonicalAuthorityEditor.js +1304 -116
  34. package/dist/components/ClassicEditorAuthority.d.ts +93 -22
  35. package/dist/components/ClassicEditorAuthority.js +11 -43
  36. package/dist/components/ColorPickerPopover.d.ts +39 -0
  37. package/dist/components/ColorPickerPopover.js +287 -0
  38. package/dist/components/CommentMarkers.d.ts +25 -0
  39. package/dist/components/CommentMarkers.js +105 -0
  40. package/dist/components/CommentThreadPanel.d.ts +26 -0
  41. package/dist/components/CommentThreadPanel.js +47 -0
  42. package/dist/components/ContextMenu.d.ts +23 -0
  43. package/dist/components/ContextMenu.js +141 -0
  44. package/dist/components/FormulaLibraryPopover.d.ts +16 -0
  45. package/dist/components/FormulaLibraryPopover.js +107 -0
  46. package/dist/components/LinkEditorPopover.d.ts +3 -1
  47. package/dist/components/LinkEditorPopover.js +22 -6
  48. package/dist/components/MediaDetailsPopover.d.ts +35 -0
  49. package/dist/components/MediaDetailsPopover.js +136 -0
  50. package/dist/components/MediaManager.d.ts +4 -20
  51. package/dist/components/MediaManager.js +40 -11
  52. package/dist/components/MediaOverlay.d.ts +27 -0
  53. package/dist/components/MediaOverlay.js +228 -0
  54. package/dist/components/SpecialCharacterPopover.d.ts +18 -0
  55. package/dist/components/SpecialCharacterPopover.js +99 -0
  56. package/dist/components/StructuralSuggestionMarkers.d.ts +20 -0
  57. package/dist/components/StructuralSuggestionMarkers.js +56 -0
  58. package/dist/components/SuggestionPanel.d.ts +26 -0
  59. package/dist/components/SuggestionPanel.js +33 -0
  60. package/dist/components/TableBorderPopover.d.ts +59 -0
  61. package/dist/components/TableBorderPopover.js +149 -0
  62. package/dist/components/TableResizeHandles.d.ts +25 -0
  63. package/dist/components/TableResizeHandles.js +323 -0
  64. package/dist/components/TableSizePickerPopover.d.ts +16 -0
  65. package/dist/components/TableSizePickerPopover.js +126 -0
  66. package/dist/components/ToolbarPrimitives.d.ts +161 -0
  67. package/dist/components/ToolbarPrimitives.js +237 -0
  68. package/dist/components/VersionHistoryPanel.d.ts +9 -0
  69. package/dist/components/VersionHistoryPanel.js +157 -0
  70. package/dist/components/fixedPositioning.d.ts +25 -0
  71. package/dist/components/fixedPositioning.js +42 -0
  72. package/dist/formulaLibrary.d.ts +31 -0
  73. package/dist/formulaLibrary.js +90 -0
  74. package/dist/index.d.ts +25 -24
  75. package/dist/index.js +15 -11
  76. package/dist/mediaManagerAdapter.d.ts +12 -0
  77. package/dist/mediaManagerAdapter.js +34 -0
  78. package/dist/mediaProvider.d.ts +21 -1
  79. package/dist/specialCharacters.d.ts +22 -0
  80. package/dist/specialCharacters.js +86 -0
  81. package/dist/standalone/classic-editor-embed.js +19 -12
  82. package/dist/suggestionProvider.d.ts +17 -0
  83. package/dist/suggestionProvider.js +1 -0
  84. package/dist/theme.d.ts +1 -1
  85. package/dist/theme.js +219 -30
  86. package/dist/toolbarTools.d.ts +95 -0
  87. package/dist/toolbarTools.js +17 -0
  88. package/dist/versionProvider.d.ts +29 -0
  89. package/dist/versionProvider.js +1 -0
  90. package/package.json +28 -14
  91. package/dist/adapters/canonicalInlineCommandBridge.d.ts +0 -15
  92. package/dist/adapters/canonicalInlineCommandBridge.js +0 -269
  93. package/dist/adapters/canonicalListCommandBridge.d.ts +0 -26
  94. package/dist/adapters/canonicalListCommandBridge.js +0 -430
  95. package/dist/adapters/coreBold.d.ts +0 -5
  96. package/dist/adapters/coreBold.js +0 -3
  97. package/dist/adapters/documentFormats.d.ts +0 -26
  98. package/dist/adapters/documentFormats.js +0 -49
  99. package/dist/adapters/docxFormat.d.ts +0 -5
  100. package/dist/adapters/docxFormat.js +0 -272
  101. package/dist/adapters/domBlockCommandBridge.d.ts +0 -19
  102. package/dist/adapters/domBlockCommandBridge.js +0 -79
  103. package/dist/adapters/inlineMarkCoreExecution.d.ts +0 -9
  104. package/dist/adapters/inlineMarkCoreExecution.js +0 -25
  105. package/dist/adapters/pdfFormat.d.ts +0 -23
  106. package/dist/adapters/pdfFormat.js +0 -204
  107. package/dist/adapters/portableDocxAtoms.d.ts +0 -4
  108. package/dist/adapters/portableDocxAtoms.js +0 -58
  109. package/dist/adapters/styledDocxFormat.d.ts +0 -8
  110. package/dist/adapters/styledDocxFormat.js +0 -190
  111. package/dist/builtInFormatDefinitions.d.ts +0 -5
  112. package/dist/builtInFormatDefinitions.js +0 -82
  113. package/dist/canonicalAuthorityFlag.d.ts +0 -32
  114. package/dist/canonicalAuthorityFlag.js +0 -49
  115. package/dist/canonicalClipboardRuntime.d.ts +0 -15
  116. package/dist/canonicalClipboardRuntime.js +0 -76
  117. package/dist/components/ClassicEditor.d.ts +0 -76
  118. package/dist/components/ClassicEditor.js +0 -6729
  119. package/dist/editorController.d.ts +0 -78
  120. package/dist/editorController.js +0 -298
  121. package/dist/formatFidelity.d.ts +0 -14
  122. package/dist/formatFidelity.js +0 -107
  123. package/dist/formatRuntime.d.ts +0 -50
  124. package/dist/formatRuntime.js +0 -20
  125. package/dist/pluginRuntime.d.ts +0 -68
  126. package/dist/pluginRuntime.js +0 -78
  127. package/dist/standalone/editor.js +0 -1068
  128. package/dist/test-harness/atomShadowComparator.d.ts +0 -14
  129. package/dist/test-harness/atomShadowComparator.js +0 -63
  130. package/dist/test-harness/blockShadowComparator.d.ts +0 -30
  131. package/dist/test-harness/blockShadowComparator.js +0 -140
  132. package/dist/test-harness/clipboardShadowComparator.d.ts +0 -12
  133. package/dist/test-harness/clipboardShadowComparator.js +0 -46
  134. package/dist/test-harness/inlineShadowComparator.d.ts +0 -32
  135. package/dist/test-harness/inlineShadowComparator.js +0 -152
  136. package/dist/test-harness/legacyAtomEngine.d.ts +0 -10
  137. package/dist/test-harness/legacyAtomEngine.js +0 -15
  138. package/dist/test-harness/legacyBlockEngine.d.ts +0 -31
  139. package/dist/test-harness/legacyBlockEngine.js +0 -31
  140. package/dist/test-harness/legacyClipboardEngine.d.ts +0 -14
  141. package/dist/test-harness/legacyClipboardEngine.js +0 -67
  142. package/dist/test-harness/legacyInlineEngine.d.ts +0 -19
  143. package/dist/test-harness/legacyInlineEngine.js +0 -49
  144. package/dist/test-harness/legacyTableEngine.d.ts +0 -12
  145. package/dist/test-harness/legacyTableEngine.js +0 -25
  146. package/dist/test-harness/tableShadowComparator.d.ts +0 -29
  147. package/dist/test-harness/tableShadowComparator.js +0 -101
@@ -0,0 +1,105 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useState } from "react";
3
+ import { resolveAnnotationRange } from "smartrte-core/foundation";
4
+ /**
5
+ * Live inline markers for comment threads, following `TableResizeHandles`'
6
+ * precedent (Phase 12a §1.2 finding): comments need many simultaneous
7
+ * persistent anchors, not the single-instance overlay pattern the other
8
+ * popovers use, so this measures real DOM rects from the live document and
9
+ * re-measures via ResizeObserver/MutationObserver/scroll, exactly like that
10
+ * component's column/row handles do for table boundaries.
11
+ *
12
+ * A thread's range is resolved fresh on every recompute via
13
+ * `resolveAnnotationRange` (not cached) - it is the authoritative "is this
14
+ * thread still anchored to real content" check, matching every other
15
+ * AnnotationRange consumer. A thread whose range no longer resolves (fully
16
+ * orphaned - the content it anchored to is gone) renders nothing here; it
17
+ * still exists in `threads` for a host's thread-list UI to surface.
18
+ */
19
+ export function CommentMarkers({ positions, mapping, containerElement, threads, activeThreadId, onSelectThread }) {
20
+ const [rects, setRects] = useState([]);
21
+ const [badges, setBadges] = useState([]);
22
+ const recompute = useCallback(() => {
23
+ if (!threads.length) {
24
+ setRects([]);
25
+ setBadges([]);
26
+ return;
27
+ }
28
+ const nextRects = [];
29
+ const nextBadges = [];
30
+ threads.forEach((thread) => {
31
+ const range = resolveAnnotationRange(thread.range, positions);
32
+ if (!range)
33
+ return;
34
+ const fromDom = mapping.posToDom(range.from);
35
+ const toDom = mapping.posToDom(range.to);
36
+ if (!fromDom || !toDom)
37
+ return;
38
+ const domRange = containerElement.ownerDocument.createRange();
39
+ try {
40
+ domRange.setStart(fromDom.node, fromDom.offset);
41
+ domRange.setEnd(toDom.node, toDom.offset);
42
+ }
43
+ catch {
44
+ return;
45
+ }
46
+ const clientRects = [...domRange.getClientRects()];
47
+ clientRects.forEach((rect) => {
48
+ nextRects.push({ threadId: thread.id, resolved: thread.resolved, left: rect.left, top: rect.top, width: rect.width, height: rect.height });
49
+ });
50
+ const anchor = clientRects[clientRects.length - 1] || domRange.getBoundingClientRect();
51
+ nextBadges.push({ threadId: thread.id, resolved: thread.resolved, replyCount: thread.replies.length, left: anchor.right, top: anchor.top });
52
+ });
53
+ setRects(nextRects);
54
+ setBadges(nextBadges);
55
+ }, [positions, mapping, containerElement, threads]);
56
+ useEffect(() => {
57
+ recompute();
58
+ const observer = new ResizeObserver(recompute);
59
+ observer.observe(containerElement);
60
+ const mutationObserver = new MutationObserver(recompute);
61
+ mutationObserver.observe(containerElement, { childList: true, subtree: true, characterData: true });
62
+ const ownerWindow = containerElement.ownerDocument.defaultView;
63
+ ownerWindow?.addEventListener("scroll", recompute, true);
64
+ ownerWindow?.addEventListener("resize", recompute);
65
+ return () => {
66
+ observer.disconnect();
67
+ mutationObserver.disconnect();
68
+ ownerWindow?.removeEventListener("scroll", recompute, true);
69
+ ownerWindow?.removeEventListener("resize", recompute);
70
+ };
71
+ }, [containerElement, recompute]);
72
+ return (_jsxs("div", { "data-srte-comment-markers": "true", style: { position: "fixed", inset: 0, pointerEvents: "none", zIndex: 55 }, children: [rects.map((rect, index) => (_jsx("div", { "data-srte-comment-highlight": rect.threadId, onPointerDown: () => onSelectThread(rect.threadId), style: {
73
+ position: "fixed",
74
+ left: rect.left,
75
+ top: rect.top,
76
+ width: rect.width,
77
+ height: rect.height,
78
+ background: rect.resolved
79
+ ? "var(--srte-comment-highlight-resolved, rgba(148, 148, 148, 0.18))"
80
+ : activeThreadId === rect.threadId
81
+ ? "var(--srte-comment-highlight-active, rgba(245, 180, 0, 0.55))"
82
+ : "var(--srte-comment-highlight, rgba(245, 180, 0, 0.28))",
83
+ cursor: "pointer",
84
+ pointerEvents: "auto",
85
+ } }, `${rect.threadId}-${index}`))), badges.map((badge) => (_jsx("button", { type: "button", "aria-label": badge.resolved ? "Open resolved comment thread" : "Open comment thread", "data-srte-comment-badge": badge.threadId, onClick: () => onSelectThread(badge.threadId), style: {
86
+ position: "fixed",
87
+ left: badge.left + 4,
88
+ top: badge.top - 4,
89
+ minWidth: 16,
90
+ height: 16,
91
+ padding: "0 4px",
92
+ borderRadius: 8,
93
+ border: "1px solid var(--srte-border, #d0d0d0)",
94
+ background: badge.resolved
95
+ ? "var(--srte-comment-badge-resolved, #e3e3e3)"
96
+ : activeThreadId === badge.threadId
97
+ ? "var(--srte-primary)"
98
+ : "var(--srte-comment-badge, #f5b400)",
99
+ color: badge.resolved ? "#555" : "#1a1300",
100
+ fontSize: 10,
101
+ lineHeight: "14px",
102
+ cursor: "pointer",
103
+ pointerEvents: "auto",
104
+ }, children: badge.replyCount }, badge.threadId)))] }));
105
+ }
@@ -0,0 +1,26 @@
1
+ import type { AnnotationRange, CommentThread } from "smartrte-core/foundation";
2
+ export interface CommentThreadPanelProps {
3
+ open: boolean;
4
+ onClose: () => void;
5
+ threads: readonly CommentThread[];
6
+ activeThreadId: string | null;
7
+ onSelectThread: (id: string) => void;
8
+ /** Set when "Add comment" was just clicked against a valid, non-collapsed selection - renders a compose box for a brand-new thread. */
9
+ pendingRange: AnnotationRange | null;
10
+ onCreateThread: (text: string) => void;
11
+ onReply: (threadId: string, text: string) => void;
12
+ onResolve: (threadId: string, resolved: boolean) => void;
13
+ onDelete: (threadId: string) => void;
14
+ busyThreadId: string | null;
15
+ }
16
+ /**
17
+ * Docked side panel, not a blocking modal like `VersionHistoryPanel`/
18
+ * `MediaManager` - a comment thread is meant to be read and replied to
19
+ * while continuing to edit nearby, not a full-attention dialog. Threads
20
+ * whose range no longer resolves aren't distinguished here with a special
21
+ * flag (per `CommentThread`'s own doc comment, "orphaned" is derived, not
22
+ * stored) - a host wanting to visually flag them can cross-reference
23
+ * `CommentMarkers`' own resolution, which already only renders markers for
24
+ * threads that still resolve.
25
+ */
26
+ export declare function CommentThreadPanel(props: CommentThreadPanelProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ const formatTimestamp = (createdAt) => new Date(createdAt).toLocaleString();
4
+ /**
5
+ * Docked side panel, not a blocking modal like `VersionHistoryPanel`/
6
+ * `MediaManager` - a comment thread is meant to be read and replied to
7
+ * while continuing to edit nearby, not a full-attention dialog. Threads
8
+ * whose range no longer resolves aren't distinguished here with a special
9
+ * flag (per `CommentThread`'s own doc comment, "orphaned" is derived, not
10
+ * stored) - a host wanting to visually flag them can cross-reference
11
+ * `CommentMarkers`' own resolution, which already only renders markers for
12
+ * threads that still resolve.
13
+ */
14
+ export function CommentThreadPanel(props) {
15
+ const { open, onClose, threads, activeThreadId, onSelectThread, pendingRange, onCreateThread, onReply, onResolve, onDelete, busyThreadId } = props;
16
+ const [showResolved, setShowResolved] = useState(false);
17
+ const [draft, setDraft] = useState("");
18
+ const [replyDrafts, setReplyDrafts] = useState({});
19
+ if (!open)
20
+ return null;
21
+ const visible = threads.filter((thread) => showResolved || !thread.resolved);
22
+ const submitCompose = () => {
23
+ const text = draft.trim();
24
+ if (!text)
25
+ return;
26
+ onCreateThread(text);
27
+ setDraft("");
28
+ };
29
+ const submitReply = (threadId) => {
30
+ const text = (replyDrafts[threadId] || "").trim();
31
+ if (!text)
32
+ return;
33
+ onReply(threadId, text);
34
+ setReplyDrafts((current) => ({ ...current, [threadId]: "" }));
35
+ };
36
+ return (_jsxs("div", { role: "complementary", "aria-label": "Comments", "data-srte-comment-panel": "true", style: {
37
+ position: "fixed", top: 0, right: 0, bottom: 0, width: 320, maxWidth: "90vw",
38
+ background: "var(--srte-modal-bg)", color: "var(--srte-modal-text)",
39
+ borderLeft: "1px solid var(--srte-border-light)", boxShadow: "var(--srte-menu-shadow)",
40
+ display: "flex", flexDirection: "column", zIndex: 70,
41
+ }, children: [_jsxs("div", { style: { display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", borderBottom: "1px solid var(--srte-border-light)" }, children: [_jsx("div", { style: { fontWeight: 600 }, children: "Comments" }), _jsx("button", { type: "button", "aria-label": "Close comments", onClick: onClose, children: "\u2715" })] }), _jsxs("label", { style: { display: "flex", alignItems: "center", gap: 6, padding: "8px 14px", fontSize: 13, color: "var(--srte-text-muted)", borderBottom: "1px solid var(--srte-border-light)" }, children: [_jsx("input", { type: "checkbox", checked: showResolved, onChange: (event) => setShowResolved(event.target.checked) }), "Show resolved"] }), pendingRange && (_jsxs("div", { "data-srte-comment-compose": "true", style: { padding: "10px 14px", borderBottom: "1px solid var(--srte-border-light)" }, children: [_jsx("textarea", { value: draft, onChange: (event) => setDraft(event.target.value), placeholder: "Add a comment\u2026", rows: 3, style: { width: "100%", padding: "6px 8px", border: "1px solid var(--srte-border)", borderRadius: 6, background: "var(--srte-input-bg)", color: "var(--srte-input-text)", resize: "vertical" } }), _jsx("div", { style: { display: "flex", justifyContent: "flex-end", gap: 8, marginTop: 6 }, children: _jsx("button", { type: "button", "data-srte-comment-compose-submit": "true", disabled: !draft.trim(), onClick: submitCompose, children: "Comment" }) })] })), _jsxs("div", { style: { flex: 1, overflowY: "auto", padding: "0 14px 14px" }, children: [visible.length === 0 && _jsx("div", { style: { color: "var(--srte-text-muted)", padding: "12px 0" }, children: "No comments yet." }), visible.map((thread) => (_jsxs("div", { "data-srte-comment-thread": thread.id, onClick: () => onSelectThread(thread.id), style: {
42
+ padding: "10px 0", borderBottom: "1px solid var(--srte-border-light)",
43
+ opacity: thread.resolved ? 0.6 : 1,
44
+ outline: activeThreadId === thread.id ? "2px solid var(--srte-primary)" : "none",
45
+ }, children: [thread.replies.map((reply) => (_jsxs("div", { style: { marginBottom: 6 }, children: [_jsx("div", { style: { fontSize: 12, fontWeight: 600 }, children: reply.authorId }), _jsx("div", { style: { fontSize: 13, whiteSpace: "pre-wrap" }, children: reply.text }), _jsx("div", { style: { fontSize: 11, color: "var(--srte-text-muted)" }, children: formatTimestamp(reply.createdAt) })] }, reply.id))), _jsxs("div", { style: { display: "flex", gap: 8, marginTop: 6 }, children: [_jsx("button", { type: "button", "data-srte-comment-resolve": thread.id, disabled: busyThreadId === thread.id, onClick: (event) => { event.stopPropagation(); onResolve(thread.id, !thread.resolved); }, children: thread.resolved ? "Reopen" : "Resolve" }), _jsx("button", { type: "button", "data-srte-comment-delete": thread.id, disabled: busyThreadId === thread.id, onClick: (event) => { event.stopPropagation(); onDelete(thread.id); }, style: { color: "var(--srte-danger)" }, children: "Delete" })] }), !thread.resolved && (_jsxs("div", { style: { display: "flex", gap: 6, marginTop: 8 }, onClick: (event) => event.stopPropagation(), children: [_jsx("input", { value: replyDrafts[thread.id] || "", onChange: (event) => setReplyDrafts((current) => ({ ...current, [thread.id]: event.target.value })), onKeyDown: (event) => { if (event.key === "Enter")
46
+ submitReply(thread.id); }, placeholder: "Reply\u2026", style: { flex: 1, padding: "5px 7px", border: "1px solid var(--srte-border)", borderRadius: 6, background: "var(--srte-input-bg)", color: "var(--srte-input-text)" } }), _jsx("button", { type: "button", "data-srte-comment-reply-submit": thread.id, disabled: !(replyDrafts[thread.id] || "").trim(), onClick: () => submitReply(thread.id), children: "Reply" })] }))] }, thread.id)))] })] }));
47
+ }
@@ -0,0 +1,23 @@
1
+ export interface ContextMenuItem {
2
+ id: string;
3
+ label: string;
4
+ onSelect: () => void;
5
+ danger?: boolean;
6
+ }
7
+ export interface ContextMenuProps {
8
+ x: number;
9
+ y: number;
10
+ items: readonly ContextMenuItem[];
11
+ onDismiss: () => void;
12
+ }
13
+ /**
14
+ * Phase 11.5 §2.3: a generic right-click menu, position-at-cursor and
15
+ * keyboard-dismissible following LinkEditorPopover/ColorPickerPopover's
16
+ * pattern - plus click-outside-to-close, which those two don't have but a
17
+ * native-context-menu-replacement genuinely needs (an accidental click
18
+ * elsewhere shouldn't leave it stuck open). Items are supplied by the
19
+ * caller (CanonicalAuthorityEditor's resolveContextMenuItems), which
20
+ * resolves the applicable plugin registry `contextMenu` contributions for
21
+ * the current scope - this component has no knowledge of commands/scopes.
22
+ */
23
+ export declare function ContextMenu({ x, y, items, onDismiss }: ContextMenuProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,141 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ // A fixed cap, not just "whatever fits below the click point" - the menu's
4
+ // item count has grown considerably (marks, table, media, cell colour,
5
+ // link actions can all stack for one click), and a height that changes
6
+ // with click position made the scroll behavior feel inconsistent. Still
7
+ // clamped against the real available viewport space too (see placement
8
+ // below), so it never overflows a genuinely short viewport.
9
+ const MAX_MENU_HEIGHT = 400;
10
+ const itemStyle = (danger) => ({
11
+ display: "block",
12
+ width: "100%",
13
+ textAlign: "left",
14
+ padding: "8px 10px",
15
+ border: 0,
16
+ borderRadius: 6,
17
+ background: "transparent",
18
+ color: danger ? "var(--srte-danger)" : "var(--srte-menu-text)",
19
+ cursor: "pointer",
20
+ font: "inherit",
21
+ fontSize: 13,
22
+ fontWeight: 500,
23
+ });
24
+ /**
25
+ * Phase 11.5 §2.3: a generic right-click menu, position-at-cursor and
26
+ * keyboard-dismissible following LinkEditorPopover/ColorPickerPopover's
27
+ * pattern - plus click-outside-to-close, which those two don't have but a
28
+ * native-context-menu-replacement genuinely needs (an accidental click
29
+ * elsewhere shouldn't leave it stuck open). Items are supplied by the
30
+ * caller (CanonicalAuthorityEditor's resolveContextMenuItems), which
31
+ * resolves the applicable plugin registry `contextMenu` contributions for
32
+ * the current scope - this component has no knowledge of commands/scopes.
33
+ */
34
+ export function ContextMenu({ x, y, items, onDismiss }) {
35
+ const rootRef = useRef(null);
36
+ // Null until measured: the menu's real width/height depends on its
37
+ // content (item label lengths), not a fixed guess, so the final position
38
+ // - including whether to flip left/up to stay in the viewport - can only
39
+ // be computed after a first, invisible render exposes the real
40
+ // getBoundingClientRect(). Fixes both the viewport-overflow bug (the
41
+ // previous clamp math assumed a hardcoded 220px width that real labels
42
+ // like "Insert column right" can exceed) and gives scroll a real
43
+ // available-height number to clamp against.
44
+ const [placement, setPlacement] = useState(null);
45
+ // CanonicalAuthorityEditor passes a fresh onDismiss closure on every
46
+ // render (it re-renders on every editor subscribe tick, i.e. on every
47
+ // document/selection change - possible even while this menu is open, if
48
+ // the triggering right-click itself moved the caret). Reading through a
49
+ // ref - rather than depending on `onDismiss` in the effect below - keeps
50
+ // the outside-click listener mounted exactly once for this menu's
51
+ // lifetime, so a re-render can't create a window where the listener has
52
+ // been torn down and not yet reattached.
53
+ const onDismissRef = useRef(onDismiss);
54
+ onDismissRef.current = onDismiss;
55
+ useLayoutEffect(() => {
56
+ const el = rootRef.current;
57
+ if (!el)
58
+ return;
59
+ const margin = 8;
60
+ const { width, height } = el.getBoundingClientRect();
61
+ const viewportWidth = window.innerWidth;
62
+ const viewportHeight = window.innerHeight;
63
+ // Open leftward (menu's right edge at the click point) instead of the
64
+ // default rightward open when the default would overflow the right
65
+ // edge - never silently overflow.
66
+ const overflowsRight = x + width > viewportWidth - margin;
67
+ const left = overflowsRight ? x - width : x;
68
+ const clampedLeft = Math.min(Math.max(margin, left), Math.max(margin, viewportWidth - width - margin));
69
+ // Same flip for the bottom edge.
70
+ const overflowsBottom = y + height > viewportHeight - margin;
71
+ const top = overflowsBottom ? y - height : y;
72
+ const clampedTop = Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - margin));
73
+ // The smaller of the fixed cap and whatever space actually remains
74
+ // below wherever the menu opens - a menu taller than that scrolls
75
+ // internally instead of extending past the viewport OR growing
76
+ // without bound on a tall screen.
77
+ const maxHeight = Math.min(MAX_MENU_HEIGHT, viewportHeight - clampedTop - margin);
78
+ setPlacement({ left: clampedLeft, top: clampedTop, maxHeight });
79
+ }, [x, y, items.length]);
80
+ useEffect(() => {
81
+ if (!placement)
82
+ return;
83
+ const buttons = rootRef.current?.querySelectorAll("[role=menuitem]");
84
+ buttons?.[0]?.focus();
85
+ }, [placement]);
86
+ useEffect(() => {
87
+ const dismissIfOutside = (event) => {
88
+ if (rootRef.current && !rootRef.current.contains(event.target))
89
+ onDismissRef.current();
90
+ };
91
+ // Both pointerdown and mousedown: pointerdown is the primary signal,
92
+ // but not every input path is guaranteed to dispatch PointerEvents in
93
+ // every embedding context - mousedown is the same "was this outside"
94
+ // check as a defense-in-depth fallback, not a second independent path.
95
+ window.addEventListener("pointerdown", dismissIfOutside, true);
96
+ window.addEventListener("mousedown", dismissIfOutside, true);
97
+ return () => {
98
+ window.removeEventListener("pointerdown", dismissIfOutside, true);
99
+ window.removeEventListener("mousedown", dismissIfOutside, true);
100
+ };
101
+ }, []);
102
+ return (_jsxs("div", { ref: rootRef, "data-srte-context-menu": "true", role: "menu", "aria-label": "Editor actions", style: {
103
+ position: "fixed",
104
+ left: placement?.left ?? x,
105
+ top: placement?.top ?? y,
106
+ // Hidden (not unrendered) until the real size is measured and a
107
+ // final, in-viewport position is computed - unrendered would mean
108
+ // getBoundingClientRect() has nothing to measure; visible-but-
109
+ // unpositioned would flash at the wrong spot for a frame first.
110
+ visibility: placement ? "visible" : "hidden",
111
+ zIndex: 80,
112
+ minWidth: 200,
113
+ maxWidth: "calc(100vw - 16px)",
114
+ maxHeight: placement ? placement.maxHeight : "calc(100vh - 16px)",
115
+ overflowY: "auto",
116
+ boxSizing: "border-box",
117
+ background: "var(--srte-menu-bg)",
118
+ color: "var(--srte-menu-text)",
119
+ border: "1px solid var(--srte-border)",
120
+ borderRadius: 10,
121
+ boxShadow: "var(--srte-menu-shadow)",
122
+ padding: 6,
123
+ }, onKeyDown: (event) => {
124
+ if (event.key === "Escape") {
125
+ event.preventDefault();
126
+ onDismiss();
127
+ return;
128
+ }
129
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp")
130
+ return;
131
+ event.preventDefault();
132
+ const buttons = Array.from(rootRef.current?.querySelectorAll("[role=menuitem]") || []);
133
+ if (!buttons.length)
134
+ return;
135
+ const currentIndex = buttons.indexOf(document.activeElement);
136
+ const nextIndex = event.key === "ArrowDown"
137
+ ? (currentIndex + 1) % buttons.length
138
+ : (currentIndex - 1 + buttons.length) % buttons.length;
139
+ buttons[nextIndex]?.focus();
140
+ }, children: [items.length === 0 && (_jsx("div", { style: { padding: "8px 10px", fontSize: 13, color: "var(--srte-menu-text)", opacity: 0.6 }, children: "No actions here" })), items.map((item) => (_jsx("button", { type: "button", role: "menuitem", "data-srte-context-menu-item": item.id, onClick: () => { item.onSelect(); onDismiss(); }, style: itemStyle(item.danger), children: item.label }, item.id)))] }));
141
+ }
@@ -0,0 +1,16 @@
1
+ import "katex/contrib/mhchem";
2
+ import { type FormulaLibraryEntry } from "../formulaLibrary.js";
3
+ export interface FormulaLibraryPopoverProps {
4
+ x: number;
5
+ y: number;
6
+ onInsert: (entry: FormulaLibraryEntry) => void;
7
+ onCancel: () => void;
8
+ }
9
+ /**
10
+ * Formula library popover ("Small" scope, 55 entries across 6 domains -
11
+ * see formulaLibrary.ts's own doc comment). Browsable by domain tab and
12
+ * searchable by name; each entry renders a real KaTeX preview (the same
13
+ * renderer the document itself uses) so the user sees exactly what they're
14
+ * about to insert, not a name they have to guess the shape of.
15
+ */
16
+ export declare function FormulaLibraryPopover({ x, y, onInsert, onCancel }: FormulaLibraryPopoverProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,107 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import katex from "katex";
4
+ // Registers \ce{...} for this popover's own chemistry-entry previews - see
5
+ // the matching import's comment in core's surface/renderer.ts for why this
6
+ // is needed once per katex module instance.
7
+ import "katex/contrib/mhchem";
8
+ import { FORMULA_DOMAINS, FORMULA_LIBRARY } from "../formulaLibrary.js";
9
+ const renderPreview = (latex) => {
10
+ try {
11
+ return katex.renderToString(latex, { trust: false, strict: "error", throwOnError: true });
12
+ }
13
+ catch {
14
+ return latex;
15
+ }
16
+ };
17
+ /**
18
+ * Formula library popover ("Small" scope, 55 entries across 6 domains -
19
+ * see formulaLibrary.ts's own doc comment). Browsable by domain tab and
20
+ * searchable by name; each entry renders a real KaTeX preview (the same
21
+ * renderer the document itself uses) so the user sees exactly what they're
22
+ * about to insert, not a name they have to guess the shape of.
23
+ */
24
+ export function FormulaLibraryPopover({ x, y, onInsert, onCancel }) {
25
+ const [domain, setDomain] = useState("all");
26
+ const [query, setQuery] = useState("");
27
+ const rootRef = useRef(null);
28
+ const [placement, setPlacement] = useState(null);
29
+ const onCancelRef = useRef(onCancel);
30
+ onCancelRef.current = onCancel;
31
+ useLayoutEffect(() => {
32
+ const el = rootRef.current;
33
+ if (!el)
34
+ return;
35
+ const margin = 8;
36
+ const { width, height } = el.getBoundingClientRect();
37
+ const viewportWidth = window.innerWidth;
38
+ const viewportHeight = window.innerHeight;
39
+ const overflowsRight = x + width > viewportWidth - margin;
40
+ const left = overflowsRight ? x - width : x;
41
+ const clampedLeft = Math.min(Math.max(margin, left), Math.max(margin, viewportWidth - width - margin));
42
+ const overflowsBottom = y + height > viewportHeight - margin;
43
+ const top = overflowsBottom ? y - height : y;
44
+ const clampedTop = Math.min(Math.max(margin, top), Math.max(margin, viewportHeight - margin));
45
+ setPlacement({ left: clampedLeft, top: clampedTop, maxHeight: viewportHeight - clampedTop - margin });
46
+ }, [x, y]);
47
+ useEffect(() => {
48
+ const dismissIfOutside = (event) => {
49
+ if (rootRef.current && !rootRef.current.contains(event.target))
50
+ onCancelRef.current();
51
+ };
52
+ window.addEventListener("pointerdown", dismissIfOutside, true);
53
+ window.addEventListener("mousedown", dismissIfOutside, true);
54
+ return () => {
55
+ window.removeEventListener("pointerdown", dismissIfOutside, true);
56
+ window.removeEventListener("mousedown", dismissIfOutside, true);
57
+ };
58
+ }, []);
59
+ useEffect(() => {
60
+ const onWindowKeyDown = (event) => {
61
+ if (event.key === "Escape")
62
+ onCancelRef.current();
63
+ };
64
+ window.addEventListener("keydown", onWindowKeyDown);
65
+ return () => window.removeEventListener("keydown", onWindowKeyDown);
66
+ }, []);
67
+ const filtered = useMemo(() => {
68
+ const needle = query.trim().toLowerCase();
69
+ return FORMULA_LIBRARY.filter((entry) => (domain === "all" || entry.domain === domain) && (!needle || entry.name.toLowerCase().includes(needle)));
70
+ }, [domain, query]);
71
+ return (_jsxs("div", { ref: rootRef, "data-srte-formula-library": "true", role: "dialog", "aria-label": "Formula library", style: {
72
+ position: "fixed",
73
+ left: placement?.left ?? x,
74
+ top: placement?.top ?? y,
75
+ visibility: placement ? "visible" : "hidden",
76
+ zIndex: 70,
77
+ width: 420,
78
+ maxWidth: "calc(100vw - 16px)",
79
+ maxHeight: placement ? placement.maxHeight : "calc(100vh - 16px)",
80
+ display: "flex",
81
+ flexDirection: "column",
82
+ boxSizing: "border-box",
83
+ background: "var(--srte-menu-bg)",
84
+ color: "var(--srte-menu-text)",
85
+ border: "1px solid var(--srte-border)",
86
+ borderRadius: 12,
87
+ boxShadow: "var(--srte-menu-shadow)",
88
+ overflow: "hidden",
89
+ }, children: [_jsxs("div", { style: { padding: "12px 14px 8px", borderBottom: "1px solid var(--srte-border)" }, children: [_jsx("input", { "data-srte-formula-search": "true", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Search formulas\u2026 (e.g. \u201Cquadratic\u201D)", "aria-label": "Search formulas", autoComplete: "off", style: {
90
+ width: "100%", height: 34, boxSizing: "border-box", padding: "0 10px",
91
+ border: "1px solid var(--srte-input-border)", borderRadius: 8, outline: "none",
92
+ background: "var(--srte-input-bg)", color: "var(--srte-input-text)", font: "inherit", fontSize: 13.5,
93
+ } }), _jsxs("div", { role: "tablist", "aria-label": "Formula domain", style: { display: "flex", gap: 4, flexWrap: "wrap", marginTop: 10 }, children: [_jsx("button", { type: "button", role: "tab", "aria-selected": domain === "all", onMouseDown: (event) => event.preventDefault(), onClick: () => setDomain("all"), style: tabStyle(domain === "all"), children: "All" }), FORMULA_DOMAINS.map((entry) => (_jsx("button", { type: "button", role: "tab", "aria-selected": domain === entry.id, onMouseDown: (event) => event.preventDefault(), onClick: () => setDomain(entry.id), style: tabStyle(domain === entry.id), children: entry.label }, entry.id)))] })] }), _jsxs("div", { "data-srte-formula-grid": "true", style: {
94
+ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))", gap: 8,
95
+ padding: 12, overflowY: "auto",
96
+ }, children: [filtered.length === 0 && (_jsxs("div", { style: { gridColumn: "1 / -1", fontSize: 13, color: "var(--srte-menu-text)", opacity: 0.7, padding: "12px 4px" }, children: ["No formulas match \u201C", query, "\u201D."] })), filtered.map((entry) => (_jsxs("button", { type: "button", "data-srte-formula-entry": entry.id, "aria-label": `Insert ${entry.name}`, title: entry.name, onMouseDown: (event) => event.preventDefault(), onClick: () => onInsert(entry), style: {
97
+ display: "flex", flexDirection: "column", alignItems: "center", gap: 8,
98
+ border: "1px solid var(--srte-input-border)", borderRadius: 10, padding: "10px 8px",
99
+ background: "var(--srte-input-bg)", cursor: "pointer", minHeight: 76,
100
+ }, children: [_jsx("span", { "aria-hidden": "true", style: { fontSize: 14, lineHeight: 1.2, minHeight: 22, display: "flex", alignItems: "center", justifyContent: "center", overflow: "hidden", maxWidth: "100%" }, dangerouslySetInnerHTML: { __html: renderPreview(entry.latex) } }), _jsx("span", { style: { fontSize: 11, color: "var(--srte-menu-text)", opacity: 0.75, textAlign: "center" }, children: entry.name })] }, entry.id)))] })] }));
101
+ }
102
+ const tabStyle = (active) => ({
103
+ fontSize: 12, fontWeight: 600, padding: "5px 10px", borderRadius: 7, cursor: "pointer",
104
+ border: "1px solid " + (active ? "var(--srte-primary)" : "transparent"),
105
+ background: active ? "var(--srte-accent-bg)" : "transparent",
106
+ color: active ? "var(--srte-primary)" : "var(--srte-menu-text)",
107
+ });
@@ -12,9 +12,11 @@ export interface LinkEditorPopoverProps {
12
12
  showTextInput?: boolean;
13
13
  showOpen?: boolean;
14
14
  showRemove?: boolean;
15
+ /** Defaults to true (the original toolbar-Link-button behavior: the user asked to edit, so jump straight to the href field). Pass false when the popover opened itself just because the caret entered a link - autofocusing there would steal focus from the editor on every ordinary click/caret-move, trapping the cursor the overlay exists specifically to avoid. */
16
+ autoFocus?: boolean;
15
17
  onApply: (value: LinkEditorApplyValue) => void;
16
18
  onOpen?: () => void;
17
19
  onRemove?: () => void;
18
20
  onCancel: () => void;
19
21
  }
20
- export declare function LinkEditorPopover({ x, y, initialHref, initialText, initialOpenInNewTab, showTextInput, showOpen, showRemove, onApply, onOpen, onRemove, onCancel, }: LinkEditorPopoverProps): import("react/jsx-runtime").JSX.Element;
22
+ export declare function LinkEditorPopover({ x, y, initialHref, initialText, initialOpenInNewTab, showTextInput, showOpen, showRemove, autoFocus, onApply, onOpen, onRemove, onCancel, }: LinkEditorPopoverProps): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useId, useRef, useState } from "react";
3
- import { normalizeLinkInput } from "smartrte-core";
3
+ import { normalizeLinkInput } from "smartrte-core/legacy";
4
4
  const inputStyle = {
5
5
  width: "100%",
6
6
  height: 36,
@@ -24,7 +24,7 @@ const buttonStyle = {
24
24
  cursor: "pointer",
25
25
  fontWeight: 500,
26
26
  };
27
- export function LinkEditorPopover({ x, y, initialHref = "", initialText = "", initialOpenInNewTab = false, showTextInput = false, showOpen = false, showRemove = false, onApply, onOpen, onRemove, onCancel, }) {
27
+ export function LinkEditorPopover({ x, y, initialHref = "", initialText = "", initialOpenInNewTab = false, showTextInput = false, showOpen = false, showRemove = false, autoFocus = true, onApply, onOpen, onRemove, onCancel, }) {
28
28
  const [href, setHref] = useState(initialHref);
29
29
  const [text, setText] = useState(initialText);
30
30
  const [openInNewTab, setOpenInNewTab] = useState(initialOpenInNewTab);
@@ -33,8 +33,24 @@ export function LinkEditorPopover({ x, y, initialHref = "", initialText = "", in
33
33
  const titleId = useId();
34
34
  const errorId = useId();
35
35
  useEffect(() => {
36
+ if (!autoFocus)
37
+ return;
36
38
  hrefRef.current?.focus();
37
39
  hrefRef.current?.select();
40
+ // eslint-disable-next-line react-hooks/exhaustive-deps
41
+ }, []);
42
+ // Escape must dismiss regardless of where focus actually is - when
43
+ // autoFocus is false (the auto-triggered case), focus deliberately stays
44
+ // on the editor, so the div's own onKeyDown below (which only fires while
45
+ // focus is inside the popover) would never see the keypress.
46
+ useEffect(() => {
47
+ const onWindowKeyDown = (event) => {
48
+ if (event.key === "Escape")
49
+ onCancel();
50
+ };
51
+ window.addEventListener("keydown", onWindowKeyDown);
52
+ return () => window.removeEventListener("keydown", onWindowKeyDown);
53
+ // eslint-disable-next-line react-hooks/exhaustive-deps
38
54
  }, []);
39
55
  const apply = () => {
40
56
  const normalized = normalizeLinkInput(href);
@@ -69,10 +85,10 @@ export function LinkEditorPopover({ x, y, initialHref = "", initialText = "", in
69
85
  boxShadow: "var(--srte-menu-shadow)",
70
86
  padding: 14,
71
87
  }, onKeyDown: (event) => {
72
- if (event.key === "Escape") {
73
- event.preventDefault();
74
- onCancel();
75
- }
88
+ // Escape is handled by the window-level listener above (needs to
89
+ // work even when autoFocus=false and focus never entered the
90
+ // popover) - not duplicated here to avoid a double-dismiss when
91
+ // focus *is* inside.
76
92
  if (event.key === "Enter" && !event.shiftKey) {
77
93
  event.preventDefault();
78
94
  apply();
@@ -0,0 +1,35 @@
1
+ export type MediaAlign = "left" | "center" | "right" | undefined;
2
+ export interface MediaDetailsDraft {
3
+ alt: string;
4
+ href: string;
5
+ openInNewTab: boolean;
6
+ width: number | undefined;
7
+ borderRadius: number | undefined;
8
+ align: MediaAlign;
9
+ licenseDescription: string;
10
+ licenseSourceUrl: string;
11
+ licenseType: string;
12
+ licenseVersion: string;
13
+ licenseAttribution: string;
14
+ }
15
+ export interface MediaDetailsPopoverProps {
16
+ x: number;
17
+ y: number;
18
+ initial: MediaDetailsDraft;
19
+ onApply: (draft: MediaDetailsDraft) => void;
20
+ onCancel: () => void;
21
+ }
22
+ /**
23
+ * "Media details" - replaces the crude window.prompt("Alt text") edit path
24
+ * (docs/bugs/media-details-old-editor-field-parity.md) with a real settings
25
+ * panel matching the old editor's own field set, following this project's
26
+ * established compact-popover pattern (position:fixed + JS-measured
27
+ * placement, Apply/Cancel commit - same shape as TableBorderPopover for a
28
+ * multi-field dialog like this one, rather than LinkEditorPopover's simpler
29
+ * live-Enter-to-apply form).
30
+ *
31
+ * Opened directly by an image's right-click interaction; the details panel
32
+ * is the image editing surface, while resize handles remain available
33
+ * independently around the selected image.
34
+ */
35
+ export declare function MediaDetailsPopover({ x, y, initial, onApply, onCancel }: MediaDetailsPopoverProps): import("react/jsx-runtime").JSX.Element;