@beyondwork/docx-react-component 1.0.60 → 1.0.61
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/package.json +33 -44
- package/src/api/public-types.ts +41 -0
- package/src/io/docx-session.ts +167 -8
- package/src/io/export/serialize-footnotes.ts +36 -5
- package/src/io/export/serialize-headers-footers.ts +7 -0
- package/src/io/export/serialize-main-document.ts +25 -18
- package/src/io/export/serialize-paragraph-formatting.ts +6 -0
- package/src/io/export/serialize-settings.ts +130 -3
- package/src/io/normalize/normalize-text.ts +8 -4
- package/src/io/ooxml/parse-footnotes.ts +11 -0
- package/src/io/ooxml/parse-headers-footers.ts +117 -42
- package/src/io/ooxml/parse-main-document.ts +20 -8
- package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
- package/src/io/ooxml/parse-settings.ts +91 -1
- package/src/model/canonical-document.ts +36 -2
- package/src/runtime/document-runtime.ts +424 -0
- package/src/runtime/footnote-resolver.ts +32 -8
- package/src/runtime/layout/layout-engine-version.ts +7 -1
- package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
- package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
- package/src/runtime/layout/paginated-layout-engine.ts +41 -8
- package/src/runtime/layout/resolved-formatting-document.ts +11 -9
- package/src/runtime/layout/resolved-formatting-state.ts +4 -0
- package/src/runtime/numbering-prefix.ts +26 -2
- package/src/runtime/surface-projection.ts +75 -14
- package/src/runtime/table-schema.ts +26 -0
- package/src/ui/WordReviewEditor.tsx +25 -0
- package/src/ui/editor-runtime-boundary.ts +1 -0
- package/src/ui/editor-shell-view.tsx +8 -0
- package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
- package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
- package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
- package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
- package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
- package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
- package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
- package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
- package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +54 -3
|
@@ -218,6 +218,7 @@ import {
|
|
|
218
218
|
resolveChromePreset,
|
|
219
219
|
resolveChromeVisibilityForPreset,
|
|
220
220
|
} from "../ui-tailwind/chrome/chrome-preset-model.ts";
|
|
221
|
+
import { TwRuntimeReplDialog } from "../ui-tailwind/chrome/tw-runtime-repl-dialog.tsx";
|
|
221
222
|
import { createRuntimeCollabSync } from "../runtime/collab/runtime-collab-sync.ts";
|
|
222
223
|
import {
|
|
223
224
|
clearLocalCursorState,
|
|
@@ -689,6 +690,7 @@ export function __createWordReviewEditorRefBridge(
|
|
|
689
690
|
color,
|
|
690
691
|
});
|
|
691
692
|
},
|
|
693
|
+
clearHighlight: (options) => runtime.clearHighlight(options),
|
|
692
694
|
setAlignment: (alignment) => {
|
|
693
695
|
applyRuntimeFormattingOperation(runtime, {
|
|
694
696
|
type: "set-alignment",
|
|
@@ -1237,6 +1239,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
1237
1239
|
const surfaceRef = useRef<TwProseMirrorSurfaceRef | null>(null);
|
|
1238
1240
|
const selectionToolbarElementRef = useRef<HTMLDivElement | null>(null);
|
|
1239
1241
|
const shellRef = useRef<HTMLDivElement | null>(null);
|
|
1242
|
+
const editorRefForRepl = useRef<WordReviewEditorRef | null>(null);
|
|
1240
1243
|
const lastSelectionToolbarKeyRef = useRef<string | null>(null);
|
|
1241
1244
|
const lastAnnouncedErrorIdRef = useRef<string | null>(null);
|
|
1242
1245
|
const scopeMetadataResolverRef = useRef<ScopeMetadataResolver | null>(null);
|
|
@@ -1817,6 +1820,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
1817
1820
|
color,
|
|
1818
1821
|
});
|
|
1819
1822
|
},
|
|
1823
|
+
clearHighlight: (options) => activeRuntime.clearHighlight(options),
|
|
1820
1824
|
setAlignment: (alignment) => {
|
|
1821
1825
|
applyRuntimeFormattingOperation(activeRuntime, {
|
|
1822
1826
|
type: "set-alignment",
|
|
@@ -2304,6 +2308,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
2304
2308
|
...projections,
|
|
2305
2309
|
}) as WordReviewEditorRef;
|
|
2306
2310
|
refHolder.current = refValue;
|
|
2311
|
+
editorRefForRepl.current = refValue;
|
|
2307
2312
|
return refValue;
|
|
2308
2313
|
},
|
|
2309
2314
|
[
|
|
@@ -3368,6 +3373,7 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
3368
3373
|
);
|
|
3369
3374
|
|
|
3370
3375
|
return (
|
|
3376
|
+
<>
|
|
3371
3377
|
<EditorShellView
|
|
3372
3378
|
shellRef={shellRef}
|
|
3373
3379
|
documentId={documentId}
|
|
@@ -3486,6 +3492,23 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
3486
3492
|
onScopeRejectSuggestionGroup={(payload) => {
|
|
3487
3493
|
applySuggestionGroupAction(activeRuntime, payload.groupId, "reject");
|
|
3488
3494
|
}}
|
|
3495
|
+
mediaPreviews={mediaPreviews}
|
|
3496
|
+
onActivateFloatingImage={(payload) => {
|
|
3497
|
+
activeRuntime.focus();
|
|
3498
|
+
applyRuntimeSelection(activeRuntime, {
|
|
3499
|
+
anchor: payload.from,
|
|
3500
|
+
head: payload.from,
|
|
3501
|
+
isCollapsed: true,
|
|
3502
|
+
activeRange: {
|
|
3503
|
+
kind: "node",
|
|
3504
|
+
at: payload.from,
|
|
3505
|
+
assoc: 1,
|
|
3506
|
+
},
|
|
3507
|
+
...(payload.storyTarget.kind === "main"
|
|
3508
|
+
? {}
|
|
3509
|
+
: { storyTarget: payload.storyTarget }),
|
|
3510
|
+
});
|
|
3511
|
+
}}
|
|
3489
3512
|
onDeselectObject={() => activeRuntime.deselectObject()}
|
|
3490
3513
|
onScopeAskAgent={(payload) => {
|
|
3491
3514
|
// Resolve the scope's anchor + story from the facet's card
|
|
@@ -3522,6 +3545,8 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
|
|
|
3522
3545
|
onEventRef.current?.(eventPayload);
|
|
3523
3546
|
}}
|
|
3524
3547
|
/>
|
|
3548
|
+
<TwRuntimeReplDialog runtime={activeRuntime} editorRef={editorRefForRepl} />
|
|
3549
|
+
</>
|
|
3525
3550
|
);
|
|
3526
3551
|
},
|
|
3527
3552
|
);
|
|
@@ -1071,6 +1071,7 @@ function createLoadingRuntimeBridge(input: {
|
|
|
1071
1071
|
rejectChange: () => undefined,
|
|
1072
1072
|
acceptAllChanges: () => undefined,
|
|
1073
1073
|
rejectAllChanges: () => undefined,
|
|
1074
|
+
clearHighlight: () => undefined,
|
|
1074
1075
|
openStory: () => false,
|
|
1075
1076
|
closeStory: () => undefined,
|
|
1076
1077
|
getActiveStory: () => input.snapshot.activeStory,
|
|
@@ -27,6 +27,7 @@ import type { EditorCommandBag } from "./editor-command-bag.ts";
|
|
|
27
27
|
import type { ReviewRailTab } from "../ui-tailwind/review/tw-review-rail.tsx";
|
|
28
28
|
import { TwReviewWorkspace } from "../ui-tailwind/tw-review-workspace.tsx";
|
|
29
29
|
import type { EditorViewStateSnapshot } from "../api/public-types.ts";
|
|
30
|
+
import type { MediaPreviewDescriptor } from "../ui-tailwind/editor-surface/pm-state-from-snapshot.ts";
|
|
30
31
|
|
|
31
32
|
export interface EditorShellViewProps {
|
|
32
33
|
shellRef: React.RefObject<HTMLDivElement | null>;
|
|
@@ -129,6 +130,13 @@ export interface EditorShellViewProps {
|
|
|
129
130
|
onScopeAskAgent?: (payload: { scopeId: string }) => void;
|
|
130
131
|
/** N6 — deselects the currently grabbed object; wired to runtime.deselectObject(). */
|
|
131
132
|
onDeselectObject?: () => void;
|
|
133
|
+
mediaPreviews?: Record<string, MediaPreviewDescriptor>;
|
|
134
|
+
onActivateFloatingImage?: (payload: {
|
|
135
|
+
mediaId: string;
|
|
136
|
+
from: number;
|
|
137
|
+
to: number;
|
|
138
|
+
storyTarget: import("../api/public-types.ts").EditorStoryTarget;
|
|
139
|
+
}) => void;
|
|
132
140
|
}
|
|
133
141
|
|
|
134
142
|
export function EditorShellView(props: EditorShellViewProps) {
|
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
} from "react";
|
|
7
|
+
|
|
8
|
+
import type { WordReviewEditorRef } from "../../api/public-types";
|
|
9
|
+
import type { DocumentRuntime } from "../../runtime/document-runtime";
|
|
10
|
+
|
|
11
|
+
export interface TwRuntimeReplDialogProps {
|
|
12
|
+
runtime: DocumentRuntime | null;
|
|
13
|
+
/**
|
|
14
|
+
* Optional editor ref. When provided, the REPL exposes it to evaluated
|
|
15
|
+
* expressions as `ref` — e.g. `ref.getRenderSnapshot()`. The REPL reads
|
|
16
|
+
* the live `.current` at evaluation time, so a stable refObject is fine.
|
|
17
|
+
*/
|
|
18
|
+
editorRef?: React.RefObject<WordReviewEditorRef | null>;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ReplEntry {
|
|
23
|
+
id: number;
|
|
24
|
+
input: string;
|
|
25
|
+
status: "ok" | "error";
|
|
26
|
+
output: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const MAX_HISTORY = 30;
|
|
30
|
+
const MAX_ENTRIES = 50;
|
|
31
|
+
const MAX_OUTPUT_CHARS = 8000;
|
|
32
|
+
const HISTORY_STORAGE_KEY = "wre-runtime-repl-history";
|
|
33
|
+
const HISTORY_STORAGE_VERSION = 1;
|
|
34
|
+
|
|
35
|
+
export function TwRuntimeReplDialog(props: TwRuntimeReplDialogProps): React.JSX.Element | null {
|
|
36
|
+
const { runtime, editorRef, disabled = false } = props;
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
const [input, setInput] = useState("");
|
|
39
|
+
const [entries, setEntries] = useState<readonly ReplEntry[]>([]);
|
|
40
|
+
const [history, setHistory] = useState<readonly string[]>(() =>
|
|
41
|
+
loadPersistedHistory(),
|
|
42
|
+
);
|
|
43
|
+
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
|
44
|
+
const openRef = useRef(false);
|
|
45
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
46
|
+
const outputRef = useRef<HTMLDivElement | null>(null);
|
|
47
|
+
const nextEntryIdRef = useRef(1);
|
|
48
|
+
|
|
49
|
+
const appendToHistory = useCallback((code: string) => {
|
|
50
|
+
setHistory((prev) => {
|
|
51
|
+
const next = appendHistoryEntry(prev, code);
|
|
52
|
+
if (next === prev) return prev;
|
|
53
|
+
savePersistedHistory(next);
|
|
54
|
+
return next;
|
|
55
|
+
});
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (disabled) return;
|
|
60
|
+
if (typeof window === "undefined") return;
|
|
61
|
+
const handler = (event: KeyboardEvent): void => {
|
|
62
|
+
if (!isReplToggleShortcut(event)) return;
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
const next = !openRef.current;
|
|
66
|
+
openRef.current = next;
|
|
67
|
+
setOpen(next);
|
|
68
|
+
};
|
|
69
|
+
window.addEventListener("keydown", handler, true);
|
|
70
|
+
return () => {
|
|
71
|
+
window.removeEventListener("keydown", handler, true);
|
|
72
|
+
};
|
|
73
|
+
}, [disabled]);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
openRef.current = open;
|
|
77
|
+
if (open) {
|
|
78
|
+
const id = requestAnimationFrame(() => {
|
|
79
|
+
textareaRef.current?.focus();
|
|
80
|
+
});
|
|
81
|
+
return () => cancelAnimationFrame(id);
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}, [open]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!open) return;
|
|
88
|
+
const el = outputRef.current;
|
|
89
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
90
|
+
}, [entries, open]);
|
|
91
|
+
|
|
92
|
+
const handleClose = useCallback(() => {
|
|
93
|
+
openRef.current = false;
|
|
94
|
+
setOpen(false);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const handleClear = useCallback(() => {
|
|
98
|
+
setEntries([]);
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
const pushEntry = useCallback((entry: Omit<ReplEntry, "id">) => {
|
|
102
|
+
setEntries((prev) => {
|
|
103
|
+
const next = [...prev, { ...entry, id: nextEntryIdRef.current }];
|
|
104
|
+
nextEntryIdRef.current += 1;
|
|
105
|
+
return next.length > MAX_ENTRIES ? next.slice(next.length - MAX_ENTRIES) : next;
|
|
106
|
+
});
|
|
107
|
+
}, []);
|
|
108
|
+
|
|
109
|
+
const runEval = useCallback(
|
|
110
|
+
async (code: string): Promise<void> => {
|
|
111
|
+
const trimmed = code.trim();
|
|
112
|
+
if (trimmed.length === 0) return;
|
|
113
|
+
appendToHistory(code);
|
|
114
|
+
if (!runtime) {
|
|
115
|
+
pushEntry({
|
|
116
|
+
input: code,
|
|
117
|
+
status: "error",
|
|
118
|
+
output: "runtime is not available yet",
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const value = await evaluateReplExpression(
|
|
124
|
+
code,
|
|
125
|
+
runtime,
|
|
126
|
+
editorRef?.current ?? null,
|
|
127
|
+
);
|
|
128
|
+
pushEntry({
|
|
129
|
+
input: code,
|
|
130
|
+
status: "ok",
|
|
131
|
+
output: formatReplValue(value),
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
pushEntry({
|
|
135
|
+
input: code,
|
|
136
|
+
status: "error",
|
|
137
|
+
output: formatReplError(error),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
[appendToHistory, editorRef, pushEntry, runtime],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const handleKeyDown = useCallback(
|
|
145
|
+
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
146
|
+
if (event.key === "Escape") {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
handleClose();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (event.key === "Enter" && !event.shiftKey) {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
const snapshot = input;
|
|
154
|
+
setInput("");
|
|
155
|
+
setHistoryIndex(null);
|
|
156
|
+
void runEval(snapshot);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
|
|
160
|
+
const textarea = event.currentTarget;
|
|
161
|
+
const isFirstLine = textarea.selectionStart <= (textarea.value.indexOf("\n") === -1 ? textarea.value.length : textarea.value.indexOf("\n"));
|
|
162
|
+
if (!isFirstLine || history.length === 0) return;
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
const nextIndex =
|
|
165
|
+
historyIndex === null
|
|
166
|
+
? history.length - 1
|
|
167
|
+
: Math.max(0, historyIndex - 1);
|
|
168
|
+
setHistoryIndex(nextIndex);
|
|
169
|
+
setInput(history[nextIndex] ?? "");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (event.key === "ArrowDown" && !event.shiftKey && !event.altKey && historyIndex !== null) {
|
|
173
|
+
const textarea = event.currentTarget;
|
|
174
|
+
const afterLastNewline = textarea.value.lastIndexOf("\n");
|
|
175
|
+
const isLastLine = textarea.selectionStart > afterLastNewline;
|
|
176
|
+
if (!isLastLine) return;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
const nextIndex = historyIndex + 1;
|
|
179
|
+
if (nextIndex >= history.length) {
|
|
180
|
+
setHistoryIndex(null);
|
|
181
|
+
setInput("");
|
|
182
|
+
} else {
|
|
183
|
+
setHistoryIndex(nextIndex);
|
|
184
|
+
setInput(history[nextIndex] ?? "");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
[handleClose, history, historyIndex, input, runEval],
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (!open) return null;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh]"
|
|
196
|
+
role="dialog"
|
|
197
|
+
aria-modal="true"
|
|
198
|
+
aria-labelledby="tw-runtime-repl__title"
|
|
199
|
+
data-testid="tw-runtime-repl"
|
|
200
|
+
>
|
|
201
|
+
<div
|
|
202
|
+
className="absolute inset-0 bg-[var(--color-bg-overlay)] backdrop-blur-sm transition-opacity duration-[var(--motion-fast)]"
|
|
203
|
+
onClick={handleClose}
|
|
204
|
+
data-testid="tw-runtime-repl__backdrop"
|
|
205
|
+
/>
|
|
206
|
+
<div
|
|
207
|
+
className={[
|
|
208
|
+
"relative mx-4 flex w-full max-w-3xl flex-col",
|
|
209
|
+
"rounded-[var(--radius-sm)]",
|
|
210
|
+
"bg-[var(--color-bg-elevated)]",
|
|
211
|
+
"shadow-[var(--shadow-float)]",
|
|
212
|
+
"ring-1 ring-[var(--color-border-subtle)]",
|
|
213
|
+
].join(" ")}
|
|
214
|
+
data-testid="tw-runtime-repl__card"
|
|
215
|
+
>
|
|
216
|
+
<div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] px-4 py-3">
|
|
217
|
+
<div className="flex items-baseline gap-2">
|
|
218
|
+
<h3
|
|
219
|
+
id="tw-runtime-repl__title"
|
|
220
|
+
className="text-sm font-semibold text-[var(--color-text-primary)]"
|
|
221
|
+
>
|
|
222
|
+
Runtime REPL
|
|
223
|
+
</h3>
|
|
224
|
+
<span className="text-xs text-[var(--color-text-tertiary)]">
|
|
225
|
+
evaluates against <code>runtime</code>
|
|
226
|
+
{editorRef ? (
|
|
227
|
+
<>
|
|
228
|
+
{" "}and <code>ref</code>
|
|
229
|
+
</>
|
|
230
|
+
) : null}
|
|
231
|
+
</span>
|
|
232
|
+
</div>
|
|
233
|
+
<div className="flex items-center gap-2">
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={handleClear}
|
|
237
|
+
className={[
|
|
238
|
+
"rounded-[var(--radius-sm)] px-2 py-1 text-xs font-medium",
|
|
239
|
+
"text-[var(--color-text-secondary)]",
|
|
240
|
+
"hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]",
|
|
241
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
242
|
+
"focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]",
|
|
243
|
+
].join(" ")}
|
|
244
|
+
data-testid="tw-runtime-repl__clear"
|
|
245
|
+
>
|
|
246
|
+
Clear
|
|
247
|
+
</button>
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={handleClose}
|
|
251
|
+
className={[
|
|
252
|
+
"rounded-[var(--radius-sm)] px-2 py-1 text-xs font-medium",
|
|
253
|
+
"text-[var(--color-text-secondary)]",
|
|
254
|
+
"hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]",
|
|
255
|
+
"transition-colors duration-[var(--motion-fast)]",
|
|
256
|
+
"focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]",
|
|
257
|
+
].join(" ")}
|
|
258
|
+
data-testid="tw-runtime-repl__close"
|
|
259
|
+
aria-label="Close REPL"
|
|
260
|
+
>
|
|
261
|
+
Esc
|
|
262
|
+
</button>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<div
|
|
267
|
+
ref={outputRef}
|
|
268
|
+
className="max-h-[50vh] min-h-[160px] overflow-y-auto px-4 py-3 font-mono text-xs leading-relaxed text-[var(--color-text-primary)]"
|
|
269
|
+
data-testid="tw-runtime-repl__output"
|
|
270
|
+
>
|
|
271
|
+
{entries.length === 0 ? (
|
|
272
|
+
<p className="text-[var(--color-text-tertiary)]">
|
|
273
|
+
Evaluate JavaScript against the active runtime
|
|
274
|
+
{editorRef ? " and editor ref" : ""}. Example:
|
|
275
|
+
{" "}
|
|
276
|
+
<code>
|
|
277
|
+
{editorRef
|
|
278
|
+
? "ref.getRenderSnapshot().surface?.blocks.length"
|
|
279
|
+
: "runtime.getRenderSnapshot().surface?.blocks.length"}
|
|
280
|
+
</code>
|
|
281
|
+
</p>
|
|
282
|
+
) : (
|
|
283
|
+
entries.map((entry) => (
|
|
284
|
+
<div key={entry.id} className="mb-3 last:mb-0">
|
|
285
|
+
<div className="flex items-start gap-2">
|
|
286
|
+
<span
|
|
287
|
+
aria-hidden="true"
|
|
288
|
+
className="select-none text-[var(--color-text-tertiary)]"
|
|
289
|
+
>
|
|
290
|
+
>
|
|
291
|
+
</span>
|
|
292
|
+
<pre
|
|
293
|
+
className="whitespace-pre-wrap break-all text-[var(--color-text-primary)]"
|
|
294
|
+
data-testid="tw-runtime-repl__entry-input"
|
|
295
|
+
>
|
|
296
|
+
{entry.input}
|
|
297
|
+
</pre>
|
|
298
|
+
</div>
|
|
299
|
+
<pre
|
|
300
|
+
data-testid={
|
|
301
|
+
entry.status === "error"
|
|
302
|
+
? "tw-runtime-repl__entry-error"
|
|
303
|
+
: "tw-runtime-repl__entry-output"
|
|
304
|
+
}
|
|
305
|
+
className={[
|
|
306
|
+
"mt-1 whitespace-pre-wrap break-all pl-4",
|
|
307
|
+
entry.status === "error"
|
|
308
|
+
? "text-[var(--color-semantic-error)]"
|
|
309
|
+
: "text-[var(--color-text-secondary)]",
|
|
310
|
+
].join(" ")}
|
|
311
|
+
>
|
|
312
|
+
{entry.output}
|
|
313
|
+
</pre>
|
|
314
|
+
</div>
|
|
315
|
+
))
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div className="border-t border-[var(--color-border-subtle)] px-4 py-3">
|
|
320
|
+
<label className="sr-only" htmlFor="tw-runtime-repl__input">
|
|
321
|
+
Runtime expression
|
|
322
|
+
</label>
|
|
323
|
+
<textarea
|
|
324
|
+
ref={textareaRef}
|
|
325
|
+
id="tw-runtime-repl__input"
|
|
326
|
+
data-testid="tw-runtime-repl__input"
|
|
327
|
+
value={input}
|
|
328
|
+
onChange={(event) => {
|
|
329
|
+
setInput(event.target.value);
|
|
330
|
+
setHistoryIndex(null);
|
|
331
|
+
}}
|
|
332
|
+
onKeyDown={handleKeyDown}
|
|
333
|
+
rows={3}
|
|
334
|
+
spellCheck={false}
|
|
335
|
+
placeholder={
|
|
336
|
+
editorRef
|
|
337
|
+
? "ref.getRenderSnapshot().selection"
|
|
338
|
+
: "runtime.getRenderSnapshot().selection"
|
|
339
|
+
}
|
|
340
|
+
className={[
|
|
341
|
+
"w-full resize-y rounded-[var(--radius-sm)] bg-[var(--color-bg-muted)]",
|
|
342
|
+
"px-3 py-2 font-mono text-xs leading-relaxed",
|
|
343
|
+
"text-[var(--color-text-primary)]",
|
|
344
|
+
"placeholder:text-[var(--color-text-tertiary)]",
|
|
345
|
+
"ring-1 ring-[var(--color-border-subtle)]",
|
|
346
|
+
"focus-visible:outline-none focus-visible:shadow-[var(--shadow-focus)]",
|
|
347
|
+
].join(" ")}
|
|
348
|
+
/>
|
|
349
|
+
<div className="mt-1.5 flex items-center justify-between text-[10px] text-[var(--color-text-tertiary)]">
|
|
350
|
+
<span>Enter to evaluate · Shift+Enter for newline · ↑/↓ history · Esc to close</span>
|
|
351
|
+
<span>{entries.length > 0 ? `${entries.length} entries` : ""}</span>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function isReplToggleShortcut(event: KeyboardEvent): boolean {
|
|
360
|
+
const isP =
|
|
361
|
+
event.code === "KeyP" ||
|
|
362
|
+
event.key === "p" ||
|
|
363
|
+
event.key === "P" ||
|
|
364
|
+
event.key === "π";
|
|
365
|
+
if (!isP) return false;
|
|
366
|
+
if (!event.altKey) return false;
|
|
367
|
+
if (event.shiftKey) return false;
|
|
368
|
+
return event.metaKey || event.ctrlKey;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export async function evaluateReplExpression(
|
|
372
|
+
code: string,
|
|
373
|
+
runtime: DocumentRuntime,
|
|
374
|
+
ref: WordReviewEditorRef | null = null,
|
|
375
|
+
): Promise<unknown> {
|
|
376
|
+
type ReplFn = (
|
|
377
|
+
runtime: DocumentRuntime,
|
|
378
|
+
ref: WordReviewEditorRef | null,
|
|
379
|
+
) => Promise<unknown>;
|
|
380
|
+
let fn: ReplFn | null = null;
|
|
381
|
+
try {
|
|
382
|
+
fn = new Function(
|
|
383
|
+
"runtime",
|
|
384
|
+
"ref",
|
|
385
|
+
`return (async () => { return (${code}); })();`,
|
|
386
|
+
) as ReplFn;
|
|
387
|
+
} catch (exprError) {
|
|
388
|
+
if (!(exprError instanceof SyntaxError)) throw exprError;
|
|
389
|
+
}
|
|
390
|
+
if (fn === null) {
|
|
391
|
+
fn = new Function(
|
|
392
|
+
"runtime",
|
|
393
|
+
"ref",
|
|
394
|
+
`return (async () => { ${code}\n })();`,
|
|
395
|
+
) as ReplFn;
|
|
396
|
+
}
|
|
397
|
+
return await fn(runtime, ref);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function formatReplValue(value: unknown): string {
|
|
401
|
+
if (value === undefined) return "undefined";
|
|
402
|
+
if (value === null) return "null";
|
|
403
|
+
const type = typeof value;
|
|
404
|
+
if (type === "string") return JSON.stringify(value);
|
|
405
|
+
if (type === "number" || type === "boolean" || type === "bigint") {
|
|
406
|
+
return String(value);
|
|
407
|
+
}
|
|
408
|
+
if (type === "symbol") return value.toString();
|
|
409
|
+
if (type === "function") {
|
|
410
|
+
const fn = value as (...args: unknown[]) => unknown;
|
|
411
|
+
return `[Function${fn.name ? `: ${fn.name}` : ""}]`;
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
const serialized = JSON.stringify(value, createCircularReplacer(), 2);
|
|
415
|
+
if (serialized === undefined) {
|
|
416
|
+
return String(value);
|
|
417
|
+
}
|
|
418
|
+
return truncate(serialized, MAX_OUTPUT_CHARS);
|
|
419
|
+
} catch (error) {
|
|
420
|
+
return `[unserializable value: ${formatReplError(error)}]`;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function formatReplError(error: unknown): string {
|
|
425
|
+
if (error instanceof Error) {
|
|
426
|
+
const name = error.name || "Error";
|
|
427
|
+
return truncate(`${name}: ${error.message}`, MAX_OUTPUT_CHARS);
|
|
428
|
+
}
|
|
429
|
+
return truncate(String(error), MAX_OUTPUT_CHARS);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function createCircularReplacer(): (key: string, value: unknown) => unknown {
|
|
433
|
+
const seen = new WeakSet<object>();
|
|
434
|
+
return function replacer(_key: string, value: unknown): unknown {
|
|
435
|
+
if (typeof value === "bigint") return `${value.toString()}n`;
|
|
436
|
+
if (typeof value === "function") {
|
|
437
|
+
const fn = value as (...args: unknown[]) => unknown;
|
|
438
|
+
return `[Function${fn.name ? `: ${fn.name}` : ""}]`;
|
|
439
|
+
}
|
|
440
|
+
if (typeof value === "symbol") return value.toString();
|
|
441
|
+
if (value !== null && typeof value === "object") {
|
|
442
|
+
if (seen.has(value as object)) return "[Circular]";
|
|
443
|
+
seen.add(value as object);
|
|
444
|
+
}
|
|
445
|
+
return value;
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function truncate(text: string, max: number): string {
|
|
450
|
+
if (text.length <= max) return text;
|
|
451
|
+
return `${text.slice(0, max)}\n... [truncated ${text.length - max} chars]`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function appendHistoryEntry(
|
|
455
|
+
history: readonly string[],
|
|
456
|
+
code: string,
|
|
457
|
+
): readonly string[] {
|
|
458
|
+
const trimmed = code.trim();
|
|
459
|
+
if (trimmed.length === 0) return history;
|
|
460
|
+
if (history.length > 0 && history[history.length - 1] === trimmed) {
|
|
461
|
+
return history;
|
|
462
|
+
}
|
|
463
|
+
return history.length >= MAX_HISTORY
|
|
464
|
+
? [...history.slice(history.length - MAX_HISTORY + 1), trimmed]
|
|
465
|
+
: [...history, trimmed];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
export function loadPersistedHistory(): readonly string[] {
|
|
469
|
+
if (typeof window === "undefined") return [];
|
|
470
|
+
try {
|
|
471
|
+
const raw = window.localStorage.getItem(HISTORY_STORAGE_KEY);
|
|
472
|
+
if (!raw) return [];
|
|
473
|
+
const parsed: unknown = JSON.parse(raw);
|
|
474
|
+
if (
|
|
475
|
+
typeof parsed !== "object" ||
|
|
476
|
+
parsed === null ||
|
|
477
|
+
!("version" in parsed) ||
|
|
478
|
+
!("entries" in parsed)
|
|
479
|
+
) {
|
|
480
|
+
return [];
|
|
481
|
+
}
|
|
482
|
+
const record = parsed as { version: unknown; entries: unknown };
|
|
483
|
+
if (record.version !== HISTORY_STORAGE_VERSION) return [];
|
|
484
|
+
if (!Array.isArray(record.entries)) return [];
|
|
485
|
+
const strings = record.entries.filter(
|
|
486
|
+
(entry): entry is string => typeof entry === "string" && entry.length > 0,
|
|
487
|
+
);
|
|
488
|
+
return strings.slice(-MAX_HISTORY);
|
|
489
|
+
} catch {
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export function savePersistedHistory(history: readonly string[]): void {
|
|
495
|
+
if (typeof window === "undefined") return;
|
|
496
|
+
try {
|
|
497
|
+
const payload = JSON.stringify({
|
|
498
|
+
version: HISTORY_STORAGE_VERSION,
|
|
499
|
+
entries: history.slice(-MAX_HISTORY),
|
|
500
|
+
});
|
|
501
|
+
window.localStorage.setItem(HISTORY_STORAGE_KEY, payload);
|
|
502
|
+
} catch {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
export function clearPersistedHistory(): void {
|
|
508
|
+
if (typeof window === "undefined") return;
|
|
509
|
+
try {
|
|
510
|
+
window.localStorage.removeItem(HISTORY_STORAGE_KEY);
|
|
511
|
+
} catch {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
@@ -498,6 +498,8 @@ export const editorSchema = new Schema({
|
|
|
498
498
|
wrapMode: { default: null },
|
|
499
499
|
distMargins: { default: null },
|
|
500
500
|
positionH: { default: null },
|
|
501
|
+
positionV: { default: null },
|
|
502
|
+
renderInPageOverlay: { default: false },
|
|
501
503
|
// Lane 6d N9.b — polygon clip for tight/through wrap.
|
|
502
504
|
wrapPolygon: { default: null },
|
|
503
505
|
// Lane 6d N11.b — CSS filter effects (soft-edge, outer shadow, glow).
|
|
@@ -508,9 +510,21 @@ export const editorSchema = new Schema({
|
|
|
508
510
|
toDOM(node) {
|
|
509
511
|
const isMissing = node.attrs.state === "missing";
|
|
510
512
|
const isFloating = node.attrs.display === "floating";
|
|
513
|
+
const renderInPageOverlay = Boolean(node.attrs.renderInPageOverlay);
|
|
511
514
|
const src = node.attrs.src as string | null;
|
|
512
515
|
const widthEmu = node.attrs.widthEmu as number | null;
|
|
513
516
|
const heightEmu = node.attrs.heightEmu as number | null;
|
|
517
|
+
if (renderInPageOverlay && isFloating) {
|
|
518
|
+
return [
|
|
519
|
+
"span",
|
|
520
|
+
{
|
|
521
|
+
class: "inline-block h-0 w-0 overflow-hidden align-middle",
|
|
522
|
+
"data-node-type": "image-floating-anchor",
|
|
523
|
+
title: (node.attrs.detail as string) ?? (node.attrs.altText as string) ?? "Floating image anchor",
|
|
524
|
+
"aria-hidden": "true",
|
|
525
|
+
},
|
|
526
|
+
];
|
|
527
|
+
}
|
|
514
528
|
if (!isMissing && src) {
|
|
515
529
|
const widthPx = widthEmu ? Math.max(24, Math.round(widthEmu / EMU_PER_PX)) : undefined;
|
|
516
530
|
const heightPx = heightEmu ? Math.max(24, Math.round(heightEmu / EMU_PER_PX)) : undefined;
|