@beyondwork/docx-react-component 1.0.60 → 1.0.62
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/classify-embedding.ts +193 -0
- 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-object.ts +23 -0
- 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
|
@@ -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;
|