@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.
Files changed (42) 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/classify-embedding.ts +193 -0
  11. package/src/io/ooxml/parse-footnotes.ts +11 -0
  12. package/src/io/ooxml/parse-headers-footers.ts +117 -42
  13. package/src/io/ooxml/parse-main-document.ts +20 -8
  14. package/src/io/ooxml/parse-object.ts +23 -0
  15. package/src/io/ooxml/parse-paragraph-formatting.ts +25 -1
  16. package/src/io/ooxml/parse-settings.ts +91 -1
  17. package/src/model/canonical-document.ts +36 -2
  18. package/src/runtime/document-runtime.ts +424 -0
  19. package/src/runtime/footnote-resolver.ts +32 -8
  20. package/src/runtime/layout/layout-engine-version.ts +7 -1
  21. package/src/runtime/layout/measurement-backend-canvas.ts +1 -1
  22. package/src/runtime/layout/measurement-backend-empirical.ts +1 -1
  23. package/src/runtime/layout/paginated-layout-engine.ts +41 -8
  24. package/src/runtime/layout/resolved-formatting-document.ts +11 -9
  25. package/src/runtime/layout/resolved-formatting-state.ts +4 -0
  26. package/src/runtime/numbering-prefix.ts +26 -2
  27. package/src/runtime/surface-projection.ts +75 -14
  28. package/src/runtime/table-schema.ts +26 -0
  29. package/src/ui/WordReviewEditor.tsx +25 -0
  30. package/src/ui/editor-runtime-boundary.ts +1 -0
  31. package/src/ui/editor-shell-view.tsx +8 -0
  32. package/src/ui-tailwind/chrome/tw-runtime-repl-dialog.tsx +514 -0
  33. package/src/ui-tailwind/editor-surface/pm-schema.ts +14 -0
  34. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +55 -6
  35. package/src/ui-tailwind/editor-surface/surface-build-keys.ts +2 -0
  36. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +4 -0
  37. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +9 -1
  38. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +16 -0
  39. package/src/ui-tailwind/page-stack/floating-image-overlay-model.ts +319 -0
  40. package/src/ui-tailwind/page-stack/tw-floating-image-layer.tsx +248 -0
  41. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +4 -0
  42. package/src/ui-tailwind/tw-review-workspace.tsx +54 -3
@@ -9,6 +9,7 @@ import type {
9
9
  SurfaceTableRowSnapshot,
10
10
  SurfaceTableCellSnapshot,
11
11
  } from "../../api/public-types";
12
+ import { shouldRenderAbsoluteFloatingImageInPageOverlay } from "../page-stack/floating-image-overlay-model.ts";
12
13
  import { editorSchema } from "./pm-schema";
13
14
  import { buildPositionMap, type PositionMap } from "./pm-position-map";
14
15
  import { chartModelStore } from "../../runtime/chart/chart-model-store.ts";
@@ -24,7 +25,7 @@ export interface PMStateResult {
24
25
  * can pass partial objects in tests.
25
26
  */
26
27
  export function buildPmStateFromSnapshot(snapshot: EditorSurfaceSnapshot): EditorState {
27
- const doc = buildPMDoc(snapshot, {}, false);
28
+ const doc = buildPMDoc(snapshot, {}, false, false);
28
29
  return EditorState.create({ doc, plugins: [] });
29
30
  }
30
31
 
@@ -153,8 +154,14 @@ export function createPMStateFromSnapshot(
153
154
  plugins: Plugin[],
154
155
  mediaPreviews: Record<string, MediaPreviewDescriptor> = {},
155
156
  showUnsupportedObjectPreviews = false,
157
+ renderAbsoluteFloatingObjectsInPageOverlay = false,
156
158
  ): PMStateResult {
157
- const doc = buildPMDoc(surface, mediaPreviews, showUnsupportedObjectPreviews);
159
+ const doc = buildPMDoc(
160
+ surface,
161
+ mediaPreviews,
162
+ showUnsupportedObjectPreviews,
163
+ renderAbsoluteFloatingObjectsInPageOverlay,
164
+ );
158
165
  const positionMap = buildPositionMap(surface);
159
166
  const pmSelection = createPMSelectionFromSnapshot(doc, positionMap, selection);
160
167
 
@@ -229,8 +236,14 @@ function buildPMDoc(
229
236
  surface: EditorSurfaceSnapshot,
230
237
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
231
238
  showUnsupportedObjectPreviews: boolean,
239
+ renderAbsoluteFloatingObjectsInPageOverlay: boolean,
232
240
  ): PMNode {
233
- const blocks = buildPMBlocks(surface.blocks, mediaPreviews, showUnsupportedObjectPreviews);
241
+ const blocks = buildPMBlocks(
242
+ surface.blocks,
243
+ mediaPreviews,
244
+ showUnsupportedObjectPreviews,
245
+ renderAbsoluteFloatingObjectsInPageOverlay,
246
+ );
234
247
 
235
248
  // Ensure at least one block (PM requires non-empty doc)
236
249
  if (blocks.length === 0) {
@@ -244,6 +257,7 @@ function buildPMBlocks(
244
257
  blocks: SurfaceBlockSnapshot[],
245
258
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
246
259
  showUnsupportedObjectPreviews: boolean,
260
+ renderAbsoluteFloatingObjectsInPageOverlay: boolean,
247
261
  ): PMNode[] {
248
262
  const nodes: PMNode[] = [];
249
263
 
@@ -262,12 +276,27 @@ function buildPMBlocks(
262
276
  nextParagraph,
263
277
  mediaPreviews,
264
278
  showUnsupportedObjectPreviews,
279
+ renderAbsoluteFloatingObjectsInPageOverlay,
265
280
  ),
266
281
  );
267
282
  } else if (block.kind === "table") {
268
- nodes.push(buildTable(block, mediaPreviews, showUnsupportedObjectPreviews));
283
+ nodes.push(
284
+ buildTable(
285
+ block,
286
+ mediaPreviews,
287
+ showUnsupportedObjectPreviews,
288
+ renderAbsoluteFloatingObjectsInPageOverlay,
289
+ ),
290
+ );
269
291
  } else if (block.kind === "sdt_block") {
270
- nodes.push(buildSdtBlock(block, mediaPreviews, showUnsupportedObjectPreviews));
292
+ nodes.push(
293
+ buildSdtBlock(
294
+ block,
295
+ mediaPreviews,
296
+ showUnsupportedObjectPreviews,
297
+ renderAbsoluteFloatingObjectsInPageOverlay,
298
+ ),
299
+ );
271
300
  } else {
272
301
  nodes.push(buildOpaqueBlock(block, showUnsupportedObjectPreviews));
273
302
  }
@@ -282,6 +311,7 @@ function buildParagraph(
282
311
  nextParagraph: Extract<SurfaceBlockSnapshot, { kind: "paragraph" }> | null,
283
312
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
284
313
  showUnsupportedObjectPreviews: boolean,
314
+ renderAbsoluteFloatingObjectsInPageOverlay: boolean,
285
315
  ): PMNode {
286
316
  const content: PMNode[] = [];
287
317
  const paragraphLayout = resolveParagraphLayout(block);
@@ -318,7 +348,12 @@ function buildParagraph(
318
348
  );
319
349
  tabIndex++;
320
350
  } else {
321
- const nodes = buildInlineContent(segment, mediaPreviews, showUnsupportedObjectPreviews);
351
+ const nodes = buildInlineContent(
352
+ segment,
353
+ mediaPreviews,
354
+ showUnsupportedObjectPreviews,
355
+ renderAbsoluteFloatingObjectsInPageOverlay,
356
+ );
322
357
  content.push(...nodes);
323
358
  }
324
359
  }
@@ -429,6 +464,7 @@ function buildInlineContent(
429
464
  segment: SurfaceInlineSegment,
430
465
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
431
466
  showUnsupportedObjectPreviews: boolean,
467
+ renderAbsoluteFloatingObjectsInPageOverlay: boolean,
432
468
  ): PMNode[] {
433
469
  switch (segment.kind) {
434
470
  case "text": {
@@ -451,6 +487,9 @@ function buildInlineContent(
451
487
  case "image":
452
488
  {
453
489
  const preview = mediaPreviews[segment.mediaId];
490
+ const renderInPageOverlay =
491
+ renderAbsoluteFloatingObjectsInPageOverlay &&
492
+ shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor);
454
493
  return [
455
494
  editorSchema.nodes.image_atom.create({
456
495
  mediaId: segment.mediaId,
@@ -470,6 +509,8 @@ function buildInlineContent(
470
509
  wrapMode: segment.anchor?.wrapMode ?? null,
471
510
  distMargins: segment.anchor?.distMargins ?? null,
472
511
  positionH: segment.anchor?.positionH ?? null,
512
+ positionV: segment.anchor?.positionV ?? null,
513
+ renderInPageOverlay,
473
514
  // Lane 6d N9.b — polygon clip.
474
515
  wrapPolygon: segment.anchor?.wrapPolygon ?? null,
475
516
  // Lane 6d N11.b — filter effects.
@@ -528,6 +569,7 @@ function buildTable(
528
569
  block: Extract<SurfaceBlockSnapshot, { kind: "table" }>,
529
570
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
530
571
  showUnsupportedObjectPreviews: boolean,
572
+ renderAbsoluteFloatingObjectsInPageOverlay: boolean,
531
573
  ): PMNode {
532
574
  const rows: PMNode[] = [];
533
575
  for (const row of block.rows) {
@@ -537,6 +579,7 @@ function buildTable(
537
579
  cell.content,
538
580
  mediaPreviews,
539
581
  showUnsupportedObjectPreviews,
582
+ renderAbsoluteFloatingObjectsInPageOverlay,
540
583
  );
541
584
  // Ensure at least one paragraph in cell (PM requires non-empty)
542
585
  if (cellContent.length === 0) {
@@ -558,6 +601,10 @@ function buildTable(
558
601
  borderRight: cell.borderRight ?? null,
559
602
  borderBottom: cell.borderBottom ?? null,
560
603
  borderLeft: cell.borderLeft ?? null,
604
+ paddingTop: cell.paddingTop ?? null,
605
+ paddingRight: cell.paddingRight ?? null,
606
+ paddingBottom: cell.paddingBottom ?? null,
607
+ paddingLeft: cell.paddingLeft ?? null,
561
608
  // R3.a Phase 2 — per-cell text-flow direction.
562
609
  textDirection: cell.textDirection ?? null,
563
610
  bandClasses: cell.bandClasses ?? null,
@@ -651,11 +698,13 @@ function buildSdtBlock(
651
698
  block: Extract<SurfaceBlockSnapshot, { kind: "sdt_block" }>,
652
699
  mediaPreviews: Record<string, MediaPreviewDescriptor>,
653
700
  showUnsupportedObjectPreviews: boolean,
701
+ renderAbsoluteFloatingObjectsInPageOverlay: boolean,
654
702
  ): PMNode {
655
703
  const children = buildPMBlocks(
656
704
  block.children,
657
705
  mediaPreviews,
658
706
  showUnsupportedObjectPreviews,
707
+ renderAbsoluteFloatingObjectsInPageOverlay,
659
708
  );
660
709
 
661
710
  if (children.length === 0) {
@@ -22,6 +22,7 @@ export function createSurfaceDocumentBuildKey(input: {
22
22
  activeStory: EditorStoryTarget;
23
23
  mediaPreviewKey: string;
24
24
  showUnsupportedObjectPreviews?: boolean;
25
+ isPageWorkspace?: boolean;
25
26
  }): string {
26
27
  const vp = input.surface?.viewportBlockRange ?? null;
27
28
  return JSON.stringify({
@@ -32,6 +33,7 @@ export function createSurfaceDocumentBuildKey(input: {
32
33
  activeStory: input.activeStory,
33
34
  mediaPreviewKey: input.mediaPreviewKey,
34
35
  showUnsupportedObjectPreviews: input.showUnsupportedObjectPreviews ?? false,
36
+ isPageWorkspace: input.isPageWorkspace ?? false,
35
37
  viewport: vp ? `${vp.start}:${vp.end}` : "full",
36
38
  });
37
39
  }
@@ -223,6 +223,10 @@ function TableBlock({
223
223
  if (cell.borderRight) cellStyle.borderRight = cell.borderRight;
224
224
  if (cell.borderBottom) cellStyle.borderBottom = cell.borderBottom;
225
225
  if (cell.borderLeft) cellStyle.borderLeft = cell.borderLeft;
226
+ if (typeof cell.paddingTop === "number") cellStyle.paddingTop = `${cell.paddingTop / 20}pt`;
227
+ if (typeof cell.paddingRight === "number") cellStyle.paddingRight = `${cell.paddingRight / 20}pt`;
228
+ if (typeof cell.paddingBottom === "number") cellStyle.paddingBottom = `${cell.paddingBottom / 20}pt`;
229
+ if (typeof cell.paddingLeft === "number") cellStyle.paddingLeft = `${cell.paddingLeft / 20}pt`;
226
230
 
227
231
  return (
228
232
  <td
@@ -399,8 +399,15 @@ export const TwProseMirrorSurface = forwardRef<
399
399
  activeStory: snapshot.activeStory,
400
400
  mediaPreviewKey,
401
401
  showUnsupportedObjectPreviews: props.showUnsupportedObjectPreviews,
402
+ isPageWorkspace: props.isPageWorkspace,
402
403
  }),
403
- [mediaPreviewKey, props.showUnsupportedObjectPreviews, snapshot.activeStory, surface],
404
+ [
405
+ mediaPreviewKey,
406
+ props.isPageWorkspace,
407
+ props.showUnsupportedObjectPreviews,
408
+ snapshot.activeStory,
409
+ surface,
410
+ ],
404
411
  );
405
412
  const decorationBuildKey = useMemo(
406
413
  () =>
@@ -718,6 +725,7 @@ export const TwProseMirrorSurface = forwardRef<
718
725
  plugins,
719
726
  props.mediaPreviews,
720
727
  props.showUnsupportedObjectPreviews,
728
+ props.isPageWorkspace,
721
729
  );
722
730
  positionMapRef.current = positionMap;
723
731
  const decorations = buildDecorations(
@@ -488,6 +488,10 @@ function applyCellAttrs(cell: HTMLTableCellElement, node: PMNode, isHeader: bool
488
488
  const borderRight = node.attrs.borderRight as string | null | undefined;
489
489
  const borderBottom = node.attrs.borderBottom as string | null | undefined;
490
490
  const borderLeft = node.attrs.borderLeft as string | null | undefined;
491
+ const paddingTop = node.attrs.paddingTop as number | null | undefined;
492
+ const paddingRight = node.attrs.paddingRight as number | null | undefined;
493
+ const paddingBottom = node.attrs.paddingBottom as number | null | undefined;
494
+ const paddingLeft = node.attrs.paddingLeft as number | null | undefined;
491
495
  const bandClasses = node.attrs.bandClasses as string | null | undefined;
492
496
 
493
497
  if (backgroundColor) cell.setAttribute("data-cell-background", backgroundColor);
@@ -502,6 +506,14 @@ function applyCellAttrs(cell: HTMLTableCellElement, node: PMNode, isHeader: bool
502
506
  else cell.removeAttribute("data-border-bottom");
503
507
  if (borderLeft) cell.setAttribute("data-border-left", borderLeft);
504
508
  else cell.removeAttribute("data-border-left");
509
+ if (typeof paddingTop === "number") cell.setAttribute("data-cell-padding-top", String(paddingTop));
510
+ else cell.removeAttribute("data-cell-padding-top");
511
+ if (typeof paddingRight === "number") cell.setAttribute("data-cell-padding-right", String(paddingRight));
512
+ else cell.removeAttribute("data-cell-padding-right");
513
+ if (typeof paddingBottom === "number") cell.setAttribute("data-cell-padding-bottom", String(paddingBottom));
514
+ else cell.removeAttribute("data-cell-padding-bottom");
515
+ if (typeof paddingLeft === "number") cell.setAttribute("data-cell-padding-left", String(paddingLeft));
516
+ else cell.removeAttribute("data-cell-padding-left");
505
517
 
506
518
  // R2b: band classes come from the resolved style cascade. They layer on top
507
519
  // of the base Tailwind classes so theme vars (bg-surface-*, text-secondary,
@@ -522,6 +534,10 @@ function applyCellAttrs(cell: HTMLTableCellElement, node: PMNode, isHeader: bool
522
534
  cell.style.borderRight = borderRight ?? "";
523
535
  cell.style.borderBottom = borderBottom ?? "";
524
536
  cell.style.borderLeft = borderLeft ?? "";
537
+ cell.style.paddingTop = typeof paddingTop === "number" ? `${paddingTop / 20}pt` : "";
538
+ cell.style.paddingRight = typeof paddingRight === "number" ? `${paddingRight / 20}pt` : "";
539
+ cell.style.paddingBottom = typeof paddingBottom === "number" ? `${paddingBottom / 20}pt` : "";
540
+ cell.style.paddingLeft = typeof paddingLeft === "number" ? `${paddingLeft / 20}pt` : "";
525
541
 
526
542
  // R3.a Phase 2 — vertical text direction. OOXML: tbRl = top→bottom, right→left
527
543
  // (most common for vertical headers, reads when tilting the head right);
@@ -0,0 +1,319 @@
1
+ import type {
2
+ EditorStoryTarget,
3
+ EditorSurfaceSnapshot,
4
+ PublicPageNode,
5
+ SurfaceBlockSnapshot,
6
+ SurfaceDrawingAnchor,
7
+ SurfaceInlineSegment,
8
+ } from "../../api/public-types.ts";
9
+ import type { WordReviewEditorLayoutFacet } from "../../runtime/layout/index.ts";
10
+ import { storyTargetKey } from "../../runtime/story-targeting.ts";
11
+ import { EMU_PER_PX } from "../../runtime/units.ts";
12
+ import type { PageOverlayRect } from "../chrome-overlay/tw-page-stack-overlay-layer.tsx";
13
+
14
+ const FRAME_PX_PER_TWIP_AT_96DPI = 96 / 1440;
15
+
16
+ export interface FloatingImagePreviewDescriptor {
17
+ src: string;
18
+ widthEmu?: number;
19
+ heightEmu?: number;
20
+ }
21
+
22
+ export interface FloatingImageOverlayItem {
23
+ key: string;
24
+ mediaId: string;
25
+ from: number;
26
+ to: number;
27
+ pageId: string;
28
+ pageIndex: number;
29
+ topPx: number;
30
+ leftPx: number;
31
+ widthPx: number;
32
+ heightPx: number;
33
+ behindDoc: boolean;
34
+ src: string | null;
35
+ altText: string | null;
36
+ detail: string | null;
37
+ }
38
+
39
+ const SUPPORTED_HORIZONTAL_RELATIVE_FROM = new Set(["page", "margin"]);
40
+ const SUPPORTED_VERTICAL_RELATIVE_FROM = new Set(["page", "margin"]);
41
+
42
+ export function shouldRenderAbsoluteFloatingImageInPageOverlay(
43
+ anchor: SurfaceDrawingAnchor | undefined,
44
+ ): boolean {
45
+ if (!anchor || anchor.display !== "floating" || anchor.wrapMode !== "none") {
46
+ return false;
47
+ }
48
+ if (anchor.layoutInCell) {
49
+ return false;
50
+ }
51
+ if (!anchor.positionH || !anchor.positionV) {
52
+ return false;
53
+ }
54
+ if (!SUPPORTED_HORIZONTAL_RELATIVE_FROM.has(anchor.positionH.relativeFrom)) {
55
+ return false;
56
+ }
57
+ if (!SUPPORTED_VERTICAL_RELATIVE_FROM.has(anchor.positionV.relativeFrom)) {
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+
63
+ export function collectFloatingImageOverlayItems(input: {
64
+ surface: EditorSurfaceSnapshot | null | undefined;
65
+ activeStory: EditorStoryTarget;
66
+ facet: WordReviewEditorLayoutFacet;
67
+ pageRects: readonly PageOverlayRect[];
68
+ mediaPreviews?: Record<string, FloatingImagePreviewDescriptor>;
69
+ }): FloatingImageOverlayItem[] {
70
+ const { surface, activeStory, facet } = input;
71
+ if (!surface) {
72
+ return [];
73
+ }
74
+
75
+ const rectByPageIndex = new Map<number, PageOverlayRect>(
76
+ input.pageRects.map((rect) => [rect.pageIndex, rect]),
77
+ );
78
+ const items: FloatingImageOverlayItem[] = [];
79
+
80
+ walkSurfaceBlocks(surface.blocks, (segment) => {
81
+ if (segment.kind !== "image" || !shouldRenderAbsoluteFloatingImageInPageOverlay(segment.anchor)) {
82
+ return;
83
+ }
84
+
85
+ const pages = resolveTargetPages(facet, segment.from, activeStory);
86
+ for (const page of pages) {
87
+ const pageRect = rectByPageIndex.get(page.pageIndex);
88
+ if (!pageRect) {
89
+ continue;
90
+ }
91
+ const localRect = resolveFloatingImageLocalRect(page, activeStory, segment);
92
+ if (!localRect) {
93
+ continue;
94
+ }
95
+ const preview = input.mediaPreviews?.[segment.mediaId];
96
+ items.push({
97
+ key: `${segment.segmentId}:${page.pageId}`,
98
+ mediaId: segment.mediaId,
99
+ from: segment.from,
100
+ to: segment.to,
101
+ pageId: page.pageId,
102
+ pageIndex: page.pageIndex,
103
+ topPx: pageRect.topPx + localRect.topPx,
104
+ leftPx: localRect.leftPx,
105
+ widthPx: localRect.widthPx,
106
+ heightPx: localRect.heightPx,
107
+ behindDoc: Boolean(segment.anchor?.behindDoc),
108
+ src: preview?.src ?? null,
109
+ altText: segment.altText ?? null,
110
+ detail: segment.detail ?? null,
111
+ });
112
+ }
113
+ });
114
+
115
+ return items;
116
+ }
117
+
118
+ function walkSurfaceBlocks(
119
+ blocks: readonly SurfaceBlockSnapshot[],
120
+ visit: (segment: SurfaceInlineSegment) => void,
121
+ ): void {
122
+ for (const block of blocks) {
123
+ switch (block.kind) {
124
+ case "paragraph":
125
+ block.segments.forEach(visit);
126
+ break;
127
+ case "table":
128
+ for (const row of block.rows) {
129
+ for (const cell of row.cells) {
130
+ walkSurfaceBlocks(cell.content, visit);
131
+ }
132
+ }
133
+ break;
134
+ case "sdt_block":
135
+ walkSurfaceBlocks(block.children, visit);
136
+ break;
137
+ }
138
+ }
139
+ }
140
+
141
+ function resolveTargetPages(
142
+ facet: WordReviewEditorLayoutFacet,
143
+ offset: number,
144
+ activeStory: EditorStoryTarget,
145
+ ): PublicPageNode[] {
146
+ if (activeStory.kind === "main") {
147
+ const page = facet.getPageForOffset(offset, activeStory);
148
+ return page ? [page] : [];
149
+ }
150
+
151
+ const activeKey = storyTargetKey(activeStory);
152
+ return facet.getPages().filter((page) => {
153
+ const target =
154
+ activeStory.kind === "header"
155
+ ? page.stories.header
156
+ : activeStory.kind === "footer"
157
+ ? page.stories.footer
158
+ : undefined;
159
+ return target ? storyTargetKey(target) === activeKey : false;
160
+ });
161
+ }
162
+
163
+ function resolveFloatingImageLocalRect(
164
+ page: PublicPageNode,
165
+ activeStory: EditorStoryTarget,
166
+ segment: Extract<SurfaceInlineSegment, { kind: "image" }>,
167
+ ): {
168
+ topPx: number;
169
+ leftPx: number;
170
+ widthPx: number;
171
+ heightPx: number;
172
+ } | null {
173
+ const anchor = segment.anchor;
174
+ if (!anchor) {
175
+ return null;
176
+ }
177
+
178
+ const widthPx = Math.max(24, Math.round(anchor.extent.widthEmu / EMU_PER_PX));
179
+ const heightPx = Math.max(24, Math.round(anchor.extent.heightEmu / EMU_PER_PX));
180
+ const horizontalSpace = resolveHorizontalSpace(page, activeStory, anchor);
181
+ const verticalSpace = resolveVerticalSpace(page, activeStory, anchor);
182
+
183
+ if (!horizontalSpace || !verticalSpace) {
184
+ return null;
185
+ }
186
+
187
+ return {
188
+ topPx: resolveAxisPosition(verticalSpace, heightPx, anchor.positionV, page, "vertical"),
189
+ leftPx: resolveAxisPosition(horizontalSpace, widthPx, anchor.positionH, page, "horizontal"),
190
+ widthPx,
191
+ heightPx,
192
+ };
193
+ }
194
+
195
+ function resolveHorizontalSpace(
196
+ page: PublicPageNode,
197
+ activeStory: EditorStoryTarget,
198
+ anchor: SurfaceDrawingAnchor,
199
+ ): { startPx: number; sizePx: number } | null {
200
+ const pageWidthPx = twipsToPx(page.layout.pageWidth);
201
+ const storyHost = resolveStoryHostSpace(page, activeStory);
202
+ switch (anchor.positionH?.relativeFrom) {
203
+ case "page":
204
+ return { startPx: 0, sizePx: pageWidthPx };
205
+ case "margin":
206
+ return storyHost ? { startPx: storyHost.leftPx, sizePx: storyHost.widthPx } : null;
207
+ default:
208
+ return null;
209
+ }
210
+ }
211
+
212
+ function resolveVerticalSpace(
213
+ page: PublicPageNode,
214
+ activeStory: EditorStoryTarget,
215
+ anchor: SurfaceDrawingAnchor,
216
+ ): { startPx: number; sizePx: number } | null {
217
+ const pageHeightPx = twipsToPx(page.layout.pageHeight);
218
+ const storyHost = resolveStoryHostSpace(page, activeStory);
219
+ switch (anchor.positionV?.relativeFrom) {
220
+ case "page":
221
+ return { startPx: 0, sizePx: pageHeightPx };
222
+ case "margin":
223
+ return storyHost ? { startPx: storyHost.topPx, sizePx: storyHost.heightPx } : null;
224
+ default:
225
+ return null;
226
+ }
227
+ }
228
+
229
+ function resolveStoryHostSpace(
230
+ page: PublicPageNode,
231
+ activeStory: EditorStoryTarget,
232
+ ): { topPx: number; leftPx: number; widthPx: number; heightPx: number } | null {
233
+ switch (activeStory.kind) {
234
+ case "main":
235
+ return {
236
+ topPx: twipsToPx(page.regions.body.originTwips),
237
+ leftPx: twipsToPx(page.layout.marginLeft),
238
+ widthPx: twipsToPx(page.regions.body.widthTwips),
239
+ heightPx: twipsToPx(page.regions.body.heightTwips),
240
+ };
241
+ case "header":
242
+ return page.regions.header
243
+ ? {
244
+ topPx: twipsToPx(page.regions.header.originTwips),
245
+ leftPx: twipsToPx(page.layout.marginLeft),
246
+ widthPx: twipsToPx(page.regions.header.widthTwips),
247
+ heightPx: twipsToPx(page.regions.header.heightTwips),
248
+ }
249
+ : null;
250
+ case "footer":
251
+ return page.regions.footer
252
+ ? {
253
+ topPx: twipsToPx(page.regions.footer.originTwips),
254
+ leftPx: twipsToPx(page.layout.marginLeft),
255
+ widthPx: twipsToPx(page.regions.footer.widthTwips),
256
+ heightPx: twipsToPx(page.regions.footer.heightTwips),
257
+ }
258
+ : null;
259
+ default:
260
+ return null;
261
+ }
262
+ }
263
+
264
+ function resolveAxisPosition(
265
+ space: { startPx: number; sizePx: number },
266
+ objectSizePx: number,
267
+ axis:
268
+ | { relativeFrom: string; align?: string; offset?: number }
269
+ | undefined,
270
+ page: PublicPageNode,
271
+ orientation: "horizontal" | "vertical",
272
+ ): number {
273
+ if (!axis) {
274
+ return space.startPx;
275
+ }
276
+ if (axis.align) {
277
+ return alignAxisPosition(space, objectSizePx, axis.align, page, orientation);
278
+ }
279
+ if (axis.offset !== undefined) {
280
+ return space.startPx + Math.round(axis.offset / EMU_PER_PX);
281
+ }
282
+ return space.startPx;
283
+ }
284
+
285
+ function alignAxisPosition(
286
+ space: { startPx: number; sizePx: number },
287
+ objectSizePx: number,
288
+ align: string,
289
+ page: PublicPageNode,
290
+ orientation: "horizontal" | "vertical",
291
+ ): number {
292
+ const remainder = Math.max(0, space.sizePx - objectSizePx);
293
+ switch (align) {
294
+ case "left":
295
+ case "top":
296
+ return space.startPx;
297
+ case "center":
298
+ return space.startPx + remainder / 2;
299
+ case "right":
300
+ case "bottom":
301
+ return space.startPx + remainder;
302
+ case "inside":
303
+ if (orientation === "horizontal") {
304
+ return page.isEvenPage ? space.startPx + remainder : space.startPx;
305
+ }
306
+ return space.startPx;
307
+ case "outside":
308
+ if (orientation === "horizontal") {
309
+ return page.isEvenPage ? space.startPx : space.startPx + remainder;
310
+ }
311
+ return space.startPx + remainder;
312
+ default:
313
+ return space.startPx;
314
+ }
315
+ }
316
+
317
+ function twipsToPx(value: number): number {
318
+ return value * FRAME_PX_PER_TWIP_AT_96DPI;
319
+ }