@beyondwork/docx-react-component 1.0.55 → 1.0.57

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 (107) hide show
  1. package/package.json +43 -32
  2. package/src/api/public-types.ts +157 -0
  3. package/src/compare/diff-engine.ts +3 -0
  4. package/src/core/commands/formatting-commands.ts +1 -0
  5. package/src/core/commands/index.ts +17 -11
  6. package/src/core/selection/mapping.ts +18 -1
  7. package/src/core/selection/review-anchors.ts +29 -18
  8. package/src/io/chart-preview-resolver.ts +175 -41
  9. package/src/io/docx-session.ts +57 -2
  10. package/src/io/export/serialize-main-document.ts +82 -0
  11. package/src/io/export/serialize-styles.ts +61 -3
  12. package/src/io/export/table-properties-xml.ts +19 -4
  13. package/src/io/normalize/normalize-text.ts +33 -0
  14. package/src/io/ooxml/parse-anchor.ts +182 -0
  15. package/src/io/ooxml/parse-drawing.ts +319 -0
  16. package/src/io/ooxml/parse-fields.ts +115 -2
  17. package/src/io/ooxml/parse-fill.ts +215 -0
  18. package/src/io/ooxml/parse-font-table.ts +190 -0
  19. package/src/io/ooxml/parse-footnotes.ts +52 -1
  20. package/src/io/ooxml/parse-main-document.ts +241 -1
  21. package/src/io/ooxml/parse-numbering.ts +96 -0
  22. package/src/io/ooxml/parse-picture.ts +107 -0
  23. package/src/io/ooxml/parse-settings.ts +34 -0
  24. package/src/io/ooxml/parse-shapes.ts +87 -0
  25. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  26. package/src/io/ooxml/parse-styles.ts +74 -1
  27. package/src/io/ooxml/parse-theme.ts +60 -0
  28. package/src/io/paste/html-clipboard.ts +449 -0
  29. package/src/io/paste/word-clipboard.ts +5 -1
  30. package/src/legal/_document-root.ts +26 -0
  31. package/src/legal/bookmarks.ts +4 -3
  32. package/src/legal/cross-references.ts +3 -2
  33. package/src/legal/defined-terms.ts +2 -1
  34. package/src/legal/signature-blocks.ts +2 -1
  35. package/src/model/canonical-document.ts +415 -3
  36. package/src/runtime/chart/chart-model-store.ts +73 -10
  37. package/src/runtime/document-runtime.ts +693 -41
  38. package/src/runtime/edit-ops/index.ts +129 -0
  39. package/src/runtime/event-refresh-hints.ts +7 -0
  40. package/src/runtime/field-resolver.ts +341 -0
  41. package/src/runtime/footnote-resolver.ts +55 -0
  42. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  43. package/src/runtime/object-grab/index.ts +51 -0
  44. package/src/runtime/paragraph-style-resolver.ts +105 -0
  45. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  46. package/src/runtime/selection/cursor-ops.ts +186 -15
  47. package/src/runtime/selection/index.ts +17 -1
  48. package/src/runtime/structure-ops/index.ts +77 -0
  49. package/src/runtime/styles-cascade.ts +33 -0
  50. package/src/runtime/surface-projection.ts +186 -12
  51. package/src/runtime/theme-color-resolver.ts +189 -44
  52. package/src/runtime/units.ts +46 -0
  53. package/src/runtime/view-state.ts +13 -2
  54. package/src/ui/WordReviewEditor.tsx +168 -10
  55. package/src/ui/editor-runtime-boundary.ts +94 -1
  56. package/src/ui/editor-shell-view.tsx +1 -1
  57. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  58. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  59. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  60. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  61. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  62. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  63. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  64. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  65. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  66. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  67. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  68. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  69. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  70. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  71. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  72. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  73. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  74. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  75. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  76. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  77. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  78. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +1 -0
  79. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  80. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  81. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  83. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  85. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  86. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  87. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  88. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  90. package/src/ui-tailwind/editor-surface/pm-schema.ts +192 -11
  91. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +28 -3
  92. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  93. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  94. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  95. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  96. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  97. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  98. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  99. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  100. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  101. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  102. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  103. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  104. package/src/ui-tailwind/theme/tokens.css +6 -0
  105. package/src/ui-tailwind/theme/tokens.ts +10 -0
  106. package/src/validation/compatibility-engine.ts +2 -0
  107. package/src/validation/docx-comment-proof.ts +12 -3
@@ -0,0 +1,129 @@
1
+ /**
2
+ * R.2 EditLayer — named module for atomic content mutations. See
3
+ * `docs/plans/lane-1-editing-foundation.md` §R.2.
4
+ *
5
+ * The layer is a thin named seam over the existing pure functions in
6
+ * `src/core/commands/text-commands.ts`. Each method delegates verbatim — no
7
+ * behavior change, no new bookkeeping. The value is the named entry point:
8
+ * callers consume `editLayer.applyTextInsert(doc, sel, text, ctx, formatting?)`
9
+ * instead of reaching into `insertText` directly, which gives R.5.b (post-edit
10
+ * validation hook) and R.5.a (action bracketing) a central site to attach.
11
+ *
12
+ * `EditResult` is re-exported as `TextTransactionResult` — the layer returns
13
+ * exactly what the underlying pure functions return. Adding
14
+ * `{revisionId, changedParagraphIds}` metadata is deferred; those values live
15
+ * at the runtime level (snapshot emission) and don't belong in a pure edit-op.
16
+ */
17
+
18
+ import type { TextFormattingDirective } from "../../api/public-types.ts";
19
+ import {
20
+ deleteSelectionOrBackward,
21
+ deleteSelectionOrForward,
22
+ insertHardBreak,
23
+ insertTab,
24
+ insertText,
25
+ splitParagraph,
26
+ type TextCommandContext,
27
+ } from "../../core/commands/text-commands.ts";
28
+ import type {
29
+ CanonicalDocumentEnvelope,
30
+ SelectionSnapshot,
31
+ } from "../../core/state/editor-state.ts";
32
+ import type { TextTransactionResult } from "../../core/state/text-transaction.ts";
33
+ import { validateSelectionAgainstDocument } from "../selection/post-edit-validator.ts";
34
+
35
+ export type EditResult = TextTransactionResult;
36
+
37
+ export interface EditLayer {
38
+ /**
39
+ * Insert `text` at the current selection, optionally with a formatting
40
+ * directive (I7). Returns the post-edit document + selection.
41
+ */
42
+ applyTextInsert(
43
+ doc: CanonicalDocumentEnvelope,
44
+ selection: SelectionSnapshot,
45
+ text: string,
46
+ context: TextCommandContext,
47
+ formatting?: TextFormattingDirective,
48
+ ): EditResult;
49
+
50
+ /** Delete the selected range or, when collapsed, the character before the caret. */
51
+ applyDeleteBackward(
52
+ doc: CanonicalDocumentEnvelope,
53
+ selection: SelectionSnapshot,
54
+ context: TextCommandContext,
55
+ ): EditResult;
56
+
57
+ /** Delete the selected range or, when collapsed, the character after the caret. */
58
+ applyDeleteForward(
59
+ doc: CanonicalDocumentEnvelope,
60
+ selection: SelectionSnapshot,
61
+ context: TextCommandContext,
62
+ ): EditResult;
63
+
64
+ /** Insert a literal tab character. */
65
+ applyInsertTab(
66
+ doc: CanonicalDocumentEnvelope,
67
+ selection: SelectionSnapshot,
68
+ context: TextCommandContext,
69
+ ): EditResult;
70
+
71
+ /** Insert a hard break (`<w:br/>`). */
72
+ applyInsertHardBreak(
73
+ doc: CanonicalDocumentEnvelope,
74
+ selection: SelectionSnapshot,
75
+ context: TextCommandContext,
76
+ ): EditResult;
77
+
78
+ /** Split the current paragraph at the selection (Enter / paragraph-break). */
79
+ applySplitParagraph(
80
+ doc: CanonicalDocumentEnvelope,
81
+ selection: SelectionSnapshot,
82
+ context: TextCommandContext,
83
+ ): EditResult;
84
+ }
85
+
86
+ /**
87
+ * R.5.b — every EditLayer return passes through the post-edit selection
88
+ * validator before leaving the layer. The underlying commands already
89
+ * produce in-bounds selections on the golden path; this is a defense-in-
90
+ * depth hook so a future command that gets the math wrong is caught at the
91
+ * layer boundary rather than at render time.
92
+ *
93
+ * `result.storyText.length` is the authoritative post-mutation maximum
94
+ * offset (computed by `applyTextTransaction` from the new story). The
95
+ * validator is O(1) on in-bounds selections and returns the same reference,
96
+ * so the hook costs nothing on the hot path.
97
+ */
98
+ function validateResult(result: TextTransactionResult): TextTransactionResult {
99
+ const maxOffset = result.storyText.length;
100
+ const validated = validateSelectionAgainstDocument(result.document, result.selection, maxOffset);
101
+ if (validated === result.selection) {
102
+ return result;
103
+ }
104
+ return { ...result, selection: validated };
105
+ }
106
+
107
+ /**
108
+ * Default stateless EditLayer instance. Safe to share across runtimes.
109
+ */
110
+ export const editLayer: EditLayer = {
111
+ applyTextInsert(doc, selection, text, context, formatting) {
112
+ return validateResult(insertText(doc, selection, text, context, formatting));
113
+ },
114
+ applyDeleteBackward(doc, selection, context) {
115
+ return validateResult(deleteSelectionOrBackward(doc, selection, context));
116
+ },
117
+ applyDeleteForward(doc, selection, context) {
118
+ return validateResult(deleteSelectionOrForward(doc, selection, context));
119
+ },
120
+ applyInsertTab(doc, selection, context) {
121
+ return validateResult(insertTab(doc, selection, context));
122
+ },
123
+ applyInsertHardBreak(doc, selection, context) {
124
+ return validateResult(insertHardBreak(doc, selection, context));
125
+ },
126
+ applySplitParagraph(doc, selection, context) {
127
+ return validateResult(splitParagraph(doc, selection, context));
128
+ },
129
+ };
@@ -16,6 +16,13 @@ export function describeEventImpact(
16
16
  staleTargets: ["anchors"],
17
17
  changeKinds: ["selection", "structure"],
18
18
  };
19
+ case "toc_auto_refreshed":
20
+ return {
21
+ invalidate: ["render", "navigation", "outline", "toc", "locations"],
22
+ staleTargets: ["none"],
23
+ changeKinds: ["content", "structure"],
24
+ tocRefreshTrigger: event.trigger,
25
+ };
19
26
  case "workflow_overlay_changed":
20
27
  case "workflow_active_work_item_changed":
21
28
  return {
@@ -0,0 +1,341 @@
1
+ /**
2
+ * CO3.2b — Field resolver
3
+ *
4
+ * Resolves PAGE, NUMPAGES, PAGEREF, REF, STYLEREF field entries against a
5
+ * RuntimePageGraph, a bookmark map, and a paragraph offset table. TOC entries
6
+ * return `undefined` (not refreshable at runtime).
7
+ */
8
+
9
+ import type {
10
+ FieldRefreshStatus,
11
+ FieldRegistryEntry,
12
+ InlineNode,
13
+ ParagraphNode,
14
+ StylesCatalog,
15
+ } from "../model/canonical-document.ts";
16
+ import type { RuntimePageGraph, RuntimePageNode } from "./layout/page-graph.ts";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Public types
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Structural content root type: any object that has `type: string` and
24
+ * optional `children` / `rows` arrays. The real root is `DocumentRootNode`
25
+ * from canonical-document but callers that build minimal stubs (e.g. tests)
26
+ * cast with `as never`.
27
+ */
28
+ export interface DocumentContainerNode {
29
+ type: string;
30
+ children?: unknown[];
31
+ rows?: unknown[];
32
+ }
33
+
34
+ export interface FieldResolverInput {
35
+ pageGraph: RuntimePageGraph;
36
+ activePageIndex: number;
37
+ bookmarkMap: Map<string, { bookmarkId: string; paragraphIndex: number }>;
38
+ paragraphOffsets: readonly number[];
39
+ styles: StylesCatalog;
40
+ contentRoot: DocumentContainerNode;
41
+ }
42
+
43
+ export interface ResolvedField {
44
+ displayText: string;
45
+ refreshStatus: FieldRefreshStatus;
46
+ /** True when the field's `\h` switch is set — renderers should wrap result in a hyperlink to the target bookmark. */
47
+ asHyperlink?: boolean;
48
+ }
49
+
50
+ export interface FieldResolver {
51
+ resolve(entry: FieldRegistryEntry): ResolvedField | undefined;
52
+ cacheKey: string;
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Factory
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export function createFieldResolver(input: FieldResolverInput): FieldResolver {
60
+ const { pageGraph, activePageIndex, bookmarkMap, paragraphOffsets, contentRoot } = input;
61
+
62
+ // Cache key captures the dimensions that affect resolution output.
63
+ const cacheKey = `${pageGraph.revision}:${bookmarkMap.size}:${activePageIndex}`;
64
+
65
+ function resolve(entry: FieldRegistryEntry): ResolvedField | undefined {
66
+ switch (entry.fieldFamily) {
67
+ case "PAGE": {
68
+ const page = pageGraph.pages[activePageIndex];
69
+ if (!page) {
70
+ return { displayText: "", refreshStatus: "unresolvable" };
71
+ }
72
+ return {
73
+ displayText: String(page.stories.displayPageNumber),
74
+ refreshStatus: "current",
75
+ };
76
+ }
77
+
78
+ case "NUMPAGES": {
79
+ if (pageGraph.contentPageCount === 0) {
80
+ return { displayText: "", refreshStatus: "unresolvable" };
81
+ }
82
+ return {
83
+ displayText: String(pageGraph.contentPageCount),
84
+ refreshStatus: "current",
85
+ };
86
+ }
87
+
88
+ case "PAGEREF": {
89
+ if (!entry.fieldTarget) {
90
+ return { displayText: "", refreshStatus: "unresolvable" };
91
+ }
92
+ const bookmark = bookmarkMap.get(entry.fieldTarget);
93
+ if (!bookmark) {
94
+ return { displayText: "", refreshStatus: "unresolvable" };
95
+ }
96
+ if (entry.switches?.relativePosition) {
97
+ const positionText =
98
+ bookmark.paragraphIndex < entry.paragraphIndex
99
+ ? "above"
100
+ : bookmark.paragraphIndex > entry.paragraphIndex
101
+ ? "below"
102
+ : "on this page";
103
+ return {
104
+ displayText: positionText,
105
+ refreshStatus: "current",
106
+ ...(entry.switches.hyperlink ? { asHyperlink: true as const } : {}),
107
+ };
108
+ }
109
+ const offset = paragraphOffsets[bookmark.paragraphIndex];
110
+ if (offset === undefined) {
111
+ return { displayText: "", refreshStatus: "unresolvable" };
112
+ }
113
+ const page = findPageForOffset(pageGraph.pages, offset);
114
+ if (!page) {
115
+ return { displayText: "", refreshStatus: "unresolvable" };
116
+ }
117
+ return {
118
+ displayText: String(page.stories.displayPageNumber),
119
+ refreshStatus: "current",
120
+ ...(entry.switches?.hyperlink ? { asHyperlink: true as const } : {}),
121
+ };
122
+ }
123
+
124
+ case "REF": {
125
+ if (!entry.fieldTarget) {
126
+ return { displayText: "", refreshStatus: "unresolvable" };
127
+ }
128
+ if (entry.switches?.relativePosition) {
129
+ const bookmark = bookmarkMap.get(entry.fieldTarget);
130
+ if (!bookmark) {
131
+ return { displayText: "", refreshStatus: "unresolvable" };
132
+ }
133
+ const positionText =
134
+ bookmark.paragraphIndex < entry.paragraphIndex
135
+ ? "above"
136
+ : bookmark.paragraphIndex > entry.paragraphIndex
137
+ ? "below"
138
+ : "on this page";
139
+ return {
140
+ displayText: positionText,
141
+ refreshStatus: "current",
142
+ ...(entry.switches.hyperlink ? { asHyperlink: true as const } : {}),
143
+ };
144
+ }
145
+ const text = resolveRefText(contentRoot, bookmarkMap, entry.fieldTarget, paragraphOffsets);
146
+ if (text === undefined) {
147
+ return { displayText: "", refreshStatus: "unresolvable" };
148
+ }
149
+ const displayText = entry.switches?.includeNumbering
150
+ ? extractNumberingPrefix(text)
151
+ : text;
152
+ return {
153
+ displayText,
154
+ refreshStatus: "current",
155
+ ...(entry.switches?.hyperlink ? { asHyperlink: true as const } : {}),
156
+ };
157
+ }
158
+
159
+ case "STYLEREF": {
160
+ if (!entry.fieldTarget) {
161
+ return { displayText: "", refreshStatus: "unresolvable" };
162
+ }
163
+ const text = resolveStyleRefText(contentRoot, entry.fieldTarget);
164
+ return text !== undefined
165
+ ? { displayText: text, refreshStatus: "current" }
166
+ : { displayText: "", refreshStatus: "unresolvable" };
167
+ }
168
+
169
+ case "TOC":
170
+ // TOC is not refreshable at runtime; return undefined so callers
171
+ // preserve the existing display text unchanged.
172
+ return undefined;
173
+
174
+ default:
175
+ return undefined;
176
+ }
177
+ }
178
+
179
+ return { resolve, cacheKey };
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // Internal helpers
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /**
187
+ * Extract the leading numbering prefix from heading text. The `\w` switch on
188
+ * REF strips the body of the heading and emits only its numbering marker.
189
+ *
190
+ * Heuristic: take everything up to the first whitespace that follows at least
191
+ * one digit. If no digits appear before the first whitespace, return the
192
+ * original string unchanged.
193
+ *
194
+ * Examples: `"1.2.3 Overview"` → `"1.2.3"`; `"Chapter 5. Intro"` → `"Chapter 5."`;
195
+ * `"Plain heading"` → `"Plain heading"` (no digits — nothing to strip).
196
+ */
197
+ function extractNumberingPrefix(text: string): string {
198
+ const match = /^(\S*\d[\d.\-]*)\s/.exec(text);
199
+ return match ? match[1]! : text;
200
+ }
201
+
202
+ /**
203
+ * Binary-search `pages` (sorted by `startOffset`) for the last page whose
204
+ * `startOffset` is ≤ `offset`. Returns `undefined` when the list is empty.
205
+ */
206
+ function findPageForOffset(
207
+ pages: readonly RuntimePageNode[],
208
+ offset: number,
209
+ ): RuntimePageNode | undefined {
210
+ let lo = 0;
211
+ let hi = pages.length - 1;
212
+ let result: RuntimePageNode | undefined;
213
+ while (lo <= hi) {
214
+ const mid = (lo + hi) >> 1;
215
+ const page = pages[mid]!;
216
+ if (page.startOffset <= offset) {
217
+ result = page;
218
+ lo = mid + 1;
219
+ } else {
220
+ hi = mid - 1;
221
+ }
222
+ }
223
+ return result;
224
+ }
225
+
226
+ /**
227
+ * Walk paragraphs in document order and invoke `visit` for each one.
228
+ * Tables are traversed via their `rows` arrays, cells via their `children`.
229
+ */
230
+ function walkParagraphs(
231
+ node: DocumentContainerNode,
232
+ visit: (para: ParagraphNode) => void,
233
+ ): void {
234
+ if (node.type === "paragraph") {
235
+ visit(node as unknown as ParagraphNode);
236
+ return;
237
+ }
238
+
239
+ const children = node.children;
240
+ if (Array.isArray(children)) {
241
+ for (const child of children) {
242
+ if (child != null && typeof child === "object" && "type" in child) {
243
+ walkParagraphs(child as DocumentContainerNode, visit);
244
+ }
245
+ }
246
+ }
247
+
248
+ // Tables carry rows separately from children in the canonical model.
249
+ if (node.type === "table") {
250
+ const rows = node.rows;
251
+ if (Array.isArray(rows)) {
252
+ for (const row of rows) {
253
+ if (row != null && typeof row === "object" && "type" in row) {
254
+ walkParagraphs(row as DocumentContainerNode, visit);
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ // Table rows carry cells separately from children; recurse into each cell
261
+ // so paragraphs nested inside table cells are visited.
262
+ if (node.type === "table_row") {
263
+ const cells = (node as { cells?: unknown[] }).cells;
264
+ if (Array.isArray(cells)) {
265
+ for (const cell of cells) {
266
+ if (cell && typeof cell === "object" && "type" in cell) {
267
+ walkParagraphs(cell as DocumentContainerNode, visit);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Flatten the inline children of a paragraph to plain text.
276
+ * Hyperlinks and fields are recursed; tabs become "\t"; hard/column breaks
277
+ * become "\n"; everything else (images, symbols, etc.) is omitted.
278
+ */
279
+ function flattenText(children: InlineNode[]): string {
280
+ return children
281
+ .map((c) => {
282
+ switch (c.type) {
283
+ case "text":
284
+ return c.text;
285
+ case "tab":
286
+ return "\t";
287
+ case "hard_break":
288
+ case "column_break":
289
+ return "\n";
290
+ case "hyperlink":
291
+ case "field":
292
+ return flattenText(c.children);
293
+ default:
294
+ return "";
295
+ }
296
+ })
297
+ .join("");
298
+ }
299
+
300
+ /**
301
+ * Resolve a REF field by locating the paragraph identified by `target` in
302
+ * `bookmarkMap` and flattening its text.
303
+ */
304
+ function resolveRefText(
305
+ root: DocumentContainerNode,
306
+ bookmarkMap: Map<string, { bookmarkId: string; paragraphIndex: number }>,
307
+ target: string,
308
+ _paragraphOffsets: readonly number[],
309
+ ): string | undefined {
310
+ const bookmark = bookmarkMap.get(target);
311
+ if (!bookmark) return undefined;
312
+
313
+ let index = -1;
314
+ let result: string | undefined;
315
+ walkParagraphs(root, (para) => {
316
+ index += 1;
317
+ if (result === undefined && index === bookmark.paragraphIndex) {
318
+ result = flattenText(para.children);
319
+ }
320
+ });
321
+ return result;
322
+ }
323
+
324
+ /**
325
+ * Resolve a STYLEREF field by finding the first paragraph whose `styleId`
326
+ * matches `styleId` and returning its flattened text (trimmed).
327
+ */
328
+ function resolveStyleRefText(
329
+ root: DocumentContainerNode,
330
+ styleId: string,
331
+ ): string | undefined {
332
+ let result: string | undefined;
333
+ walkParagraphs(root, (para) => {
334
+ if (result !== undefined) return;
335
+ if (para.styleId === styleId) {
336
+ const text = flattenText(para.children).trim();
337
+ if (text.length > 0) result = text;
338
+ }
339
+ });
340
+ return result;
341
+ }
@@ -0,0 +1,55 @@
1
+ import type {
2
+ EndnoteProperties,
3
+ FootnoteCollection,
4
+ FootnoteProperties,
5
+ SectionProperties,
6
+ } from "../model/canonical-document.ts";
7
+
8
+ export interface FootnoteResolver {
9
+ getContinuationSeparatorContent(kind: "footnote" | "endnote"): string | undefined;
10
+ getSeparatorContent(kind: "footnote" | "endnote"): string | undefined;
11
+ getFootnoteCount(): number;
12
+ getEndnoteCount(): number;
13
+ /**
14
+ * Resolve the effective `<w:footnotePr>` for a given section. Returns the
15
+ * section's typed properties when present, or `undefined` when the section
16
+ * has no override (callers should fall back to Word defaults). File-level
17
+ * defaults from `settings.xml` are not yet wired in — that's a later slice.
18
+ */
19
+ getFootnoteProperties(sectionIndex?: number): FootnoteProperties | undefined;
20
+ /**
21
+ * Resolve the effective `<w:endnotePr>` for a given section. Same semantics
22
+ * as `getFootnoteProperties` but reads `endnotePr` from the section list.
23
+ */
24
+ getEndnoteProperties(sectionIndex?: number): EndnoteProperties | undefined;
25
+ }
26
+
27
+ export function createFootnoteResolver(
28
+ collection: FootnoteCollection,
29
+ sections?: readonly SectionProperties[],
30
+ ): FootnoteResolver {
31
+ return {
32
+ getContinuationSeparatorContent(kind) {
33
+ const separators = kind === "footnote" ? collection.footnoteSeparators : collection.endnoteSeparators;
34
+ return separators?.continuationSeparatorContent;
35
+ },
36
+ getSeparatorContent(kind) {
37
+ const separators = kind === "footnote" ? collection.footnoteSeparators : collection.endnoteSeparators;
38
+ return separators?.separatorContent;
39
+ },
40
+ getFootnoteCount() {
41
+ return Object.keys(collection.footnotes).length;
42
+ },
43
+ getEndnoteCount() {
44
+ return Object.keys(collection.endnotes).length;
45
+ },
46
+ getFootnoteProperties(sectionIndex) {
47
+ if (sectionIndex === undefined || !sections) return undefined;
48
+ return sections[sectionIndex]?.footnotePr;
49
+ },
50
+ getEndnoteProperties(sectionIndex) {
51
+ if (sectionIndex === undefined || !sections) return undefined;
52
+ return sections[sectionIndex]?.endnotePr;
53
+ },
54
+ };
55
+ }
@@ -20,7 +20,8 @@
20
20
  * 4. Hardcoded Word default `#0563C1`.
21
21
  *
22
22
  * The resolver also honors `colorThemeSlot` + `colorThemeTint`/`colorThemeShade`
23
- * from L2.c by delegating to `resolveThemeColorHex`.
23
+ * by delegating to `ThemeColorResolver.resolveWordThemeColor` (which applies
24
+ * `w:clrSchemeMapping` remap).
24
25
  *
25
26
  * Contract: the returned `CanonicalRunFormatting` is the effective cascade
26
27
  * result with `colorHex` concretized to a non-theme hex (or `"auto"`). The
@@ -31,11 +32,9 @@
31
32
 
32
33
  import type {
33
34
  CanonicalRunFormatting,
34
- ResolvedTheme,
35
35
  StylesCatalog,
36
36
  } from "../model/canonical-document.ts";
37
- import { resolveThemeColor } from "../io/ooxml/parse-theme.ts";
38
- import { resolveThemeColorHex } from "./theme-color-resolver.ts";
37
+ import { ThemeColorResolver } from "./theme-color-resolver.ts";
39
38
  import {
40
39
  resolveEffectiveRunFormatting,
41
40
  type RunResolveInput,
@@ -64,7 +63,7 @@ export const DEFAULT_HYPERLINK_COLOR_HEX = "0563C1";
64
63
  export function resolveHyperlinkRunFormatting(
65
64
  input: RunResolveInput,
66
65
  catalog: StylesCatalog | undefined,
67
- theme: ResolvedTheme | undefined,
66
+ resolver: ThemeColorResolver | undefined,
68
67
  ): CanonicalRunFormatting {
69
68
  // V7a — auto-apply the Hyperlink character style when the caller did
70
69
  // not supply one (runs inside <w:hyperlink> typically lack explicit
@@ -77,7 +76,7 @@ export function resolveHyperlinkRunFormatting(
77
76
  const cascade = resolveEffectiveRunFormatting(augmentedInput, catalog);
78
77
 
79
78
  // V7b — concretize the color through the theme resolver + Word default.
80
- const resolvedColor = resolveHyperlinkColorHex(cascade, theme);
79
+ const resolvedColor = resolveHyperlinkColorHex(cascade, resolver);
81
80
  if (resolvedColor && resolvedColor !== cascade.colorHex) {
82
81
  return { ...cascade, colorHex: resolvedColor };
83
82
  }
@@ -93,7 +92,7 @@ export function resolveHyperlinkColorHex(
93
92
  CanonicalRunFormatting,
94
93
  "colorHex" | "colorThemeSlot" | "colorThemeTint" | "colorThemeShade"
95
94
  >,
96
- theme: ResolvedTheme | undefined,
95
+ resolver: ThemeColorResolver | undefined,
97
96
  ): string | undefined {
98
97
  // Tier 1 — direct non-auto hex wins.
99
98
  if (cascade.colorHex && cascade.colorHex !== "auto") {
@@ -101,8 +100,12 @@ export function resolveHyperlinkColorHex(
101
100
  }
102
101
  // Tier 2 — theme-slot reference from the cascade (which now includes the
103
102
  // Hyperlink style's rPr — typically `<w:color w:themeColor="hlink"/>`).
104
- if (cascade.colorThemeSlot) {
105
- const viaTheme = resolveThemeColorHex(cascade, theme);
103
+ if (cascade.colorThemeSlot && resolver) {
104
+ const viaTheme = resolver.resolveWordThemeColor(
105
+ cascade.colorThemeSlot,
106
+ cascade.colorThemeTint,
107
+ cascade.colorThemeShade,
108
+ );
106
109
  if (viaTheme && viaTheme !== "auto") {
107
110
  return viaTheme;
108
111
  }
@@ -110,7 +113,7 @@ export function resolveHyperlinkColorHex(
110
113
  // Tier 3 — theme hlink slot even when the cascade never wrote a slot
111
114
  // reference. This catches docs whose Hyperlink style lacks a color
112
115
  // declaration entirely but whose theme defines hlink.
113
- const themeHlink = resolveThemeColor(theme, "hlink");
116
+ const themeHlink = resolver?.resolveSchemeSlot("hlink");
114
117
  if (themeHlink) {
115
118
  return themeHlink;
116
119
  }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * R.3 ObjectGrabLayer — runtime-side grab state for inline / floating
3
+ * objects (images, shapes). Lane 6 P11 paints the chrome handles; this
4
+ * module owns the state model so both sides reference one source of truth.
5
+ *
6
+ * Design invariants:
7
+ * 1. **Single-select.** One grabbed object at a time (matches LibreOffice
8
+ * `SwFEShell::SelectObj`); multi-select for Alt+Shift drag selection
9
+ * lands in a follow-up once user flows require it.
10
+ * 2. **Local-only, not collab-broadcast.** A remote peer's grab state is
11
+ * surfaced via remote-cursor awareness, not via this layer — each peer
12
+ * has their own grab state, matching the text-selection model.
13
+ * 3. **Pure state transitions.** Every mutation returns a new object; the
14
+ * runtime stores the state alongside selection and emits on snapshot.
15
+ * 4. **String ids, not node references.** Grab state uses `objectId` so
16
+ * the state survives snapshot replacement. The runtime resolves the id
17
+ * to the current node when it needs to dispatch image commands.
18
+ *
19
+ * What this module deliberately does NOT do in Item C:
20
+ * - Rotation / anchor-mode / z-order mutation — those are image.set-*
21
+ * commands on the existing `image-commands.ts` module; the grab layer
22
+ * is just "which object has focus". Host wires chrome interactions to
23
+ * those commands via the ref methods.
24
+ * - Hit-testing — chrome layer uses Lane 3a P9 `RenderAnchorIndex` to
25
+ * convert click coords to an object id, then calls `selectObject(id)`.
26
+ */
27
+
28
+ export interface ObjectGrabState {
29
+ /** The grabbed object's stable id (image.mediaId / shape.shapeId), or null. */
30
+ objectId: string | null;
31
+ }
32
+
33
+ export function createObjectGrabState(): ObjectGrabState {
34
+ return { objectId: null };
35
+ }
36
+
37
+ export function selectObject(state: ObjectGrabState, objectId: string): ObjectGrabState {
38
+ if (state.objectId === objectId) {
39
+ return state; // idempotent — same id, same state (reference-equal)
40
+ }
41
+ return { objectId };
42
+ }
43
+
44
+ export function deselectObject(state: ObjectGrabState): ObjectGrabState {
45
+ if (state.objectId === null) return state;
46
+ return { objectId: null };
47
+ }
48
+
49
+ export function getGrabbedObject(state: ObjectGrabState): string | null {
50
+ return state.objectId;
51
+ }