@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.
Files changed (40) hide show
  1. package/package.json +33 -44
  2. package/src/api/public-types.ts +41 -0
  3. package/src/io/docx-session.ts +167 -8
  4. package/src/io/export/serialize-footnotes.ts +36 -5
  5. package/src/io/export/serialize-headers-footers.ts +7 -0
  6. package/src/io/export/serialize-main-document.ts +25 -18
  7. package/src/io/export/serialize-paragraph-formatting.ts +6 -0
  8. package/src/io/export/serialize-settings.ts +130 -3
  9. package/src/io/normalize/normalize-text.ts +8 -4
  10. package/src/io/ooxml/parse-footnotes.ts +11 -0
  11. package/src/io/ooxml/parse-headers-footers.ts +117 -42
  12. package/src/io/ooxml/parse-main-document.ts +20 -8
  13. package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
  14. package/src/io/ooxml/parse-settings.ts +91 -1
  15. package/src/model/canonical-document.ts +36 -2
  16. package/src/runtime/document-runtime.ts +424 -0
  17. package/src/runtime/footnote-resolver.ts +32 -8
  18. package/src/runtime/layout/layout-engine-version.ts +7 -1
  19. package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
  20. package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
  21. package/src/runtime/layout/paginated-layout-engine.ts +41 -8
  22. package/src/runtime/layout/resolved-formatting-document.ts +11 -9
  23. package/src/runtime/layout/resolved-formatting-state.ts +4 -0
  24. package/src/runtime/numbering-prefix.ts +26 -2
  25. package/src/runtime/surface-projection.ts +75 -14
  26. package/src/runtime/table-schema.ts +26 -0
  27. package/src/ui/WordReviewEditor.tsx +25 -0
  28. package/src/ui/editor-runtime-boundary.ts +1 -0
  29. package/src/ui/editor-shell-view.tsx +8 -0
  30. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
  31. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
  32. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
  33. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  34. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
  35. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
  36. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
  37. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
  38. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
  39. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
  40. 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
+ &gt;
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;