@beyondwork/docx-react-component 1.0.57 → 1.0.59

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 (135) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/src/api/awareness-identity-types.ts +4 -2
  4. package/src/api/comment-negotiation-types.ts +4 -1
  5. package/src/api/external-custody-types.ts +16 -0
  6. package/src/api/internal/build-ref-projections.ts +108 -0
  7. package/src/api/package-version.ts +1 -1
  8. package/src/api/participants-types.ts +11 -1
  9. package/src/api/public-types.ts +1149 -8
  10. package/src/api/scope-metadata-resolver-types.ts +6 -0
  11. package/src/compare/diff-engine.ts +3 -0
  12. package/src/core/commands/formatting-commands.ts +1 -0
  13. package/src/core/commands/index.ts +225 -16
  14. package/src/core/commands/legacy-form-field-commands.ts +181 -0
  15. package/src/core/commands/table-structure-commands.ts +149 -31
  16. package/src/core/selection/mapping.ts +20 -0
  17. package/src/core/state/editor-state.ts +2 -1
  18. package/src/index.ts +28 -0
  19. package/src/io/docx-session.ts +22 -3
  20. package/src/io/export/export-session.ts +11 -7
  21. package/src/io/export/ooxml-namespaces.ts +47 -0
  22. package/src/io/export/reattach-preserved-parts.ts +4 -16
  23. package/src/io/export/serialize-comments.ts +3 -131
  24. package/src/io/export/serialize-ffdata.ts +89 -0
  25. package/src/io/export/serialize-headers-footers.ts +5 -0
  26. package/src/io/export/serialize-main-document.ts +224 -34
  27. package/src/io/export/serialize-numbering.ts +22 -2
  28. package/src/io/export/serialize-revisions.ts +99 -0
  29. package/src/io/export/serialize-tables.ts +9 -0
  30. package/src/io/export/split-review-boundaries.ts +1 -0
  31. package/src/io/export/table-properties-xml.ts +14 -0
  32. package/src/io/load-scheduler.ts +70 -28
  33. package/src/io/normalize/normalize-text.ts +13 -0
  34. package/src/io/ooxml/_mini-xml.ts +198 -0
  35. package/src/io/ooxml/canonicalize-payload.ts +1 -4
  36. package/src/io/ooxml/chart/chart-style-table.ts +4 -3
  37. package/src/io/ooxml/chart/parse-chart-space.ts +2 -4
  38. package/src/io/ooxml/chart/parse-series.ts +2 -1
  39. package/src/io/ooxml/chart/resolve-color.ts +2 -2
  40. package/src/io/ooxml/chart/types.ts +6 -434
  41. package/src/io/ooxml/comment-presentation-payload.ts +6 -5
  42. package/src/io/ooxml/highlight-colors.ts +8 -5
  43. package/src/io/ooxml/parse-anchor.ts +68 -53
  44. package/src/io/ooxml/parse-comments.ts +14 -142
  45. package/src/io/ooxml/parse-complex-content.ts +3 -106
  46. package/src/io/ooxml/parse-drawing.ts +100 -195
  47. package/src/io/ooxml/parse-ffdata.ts +93 -0
  48. package/src/io/ooxml/parse-fields.ts +7 -146
  49. package/src/io/ooxml/parse-fill.ts +88 -8
  50. package/src/io/ooxml/parse-font-table.ts +5 -105
  51. package/src/io/ooxml/parse-footnotes.ts +28 -152
  52. package/src/io/ooxml/parse-headers-footers.ts +106 -212
  53. package/src/io/ooxml/parse-inline-media.ts +3 -200
  54. package/src/io/ooxml/parse-main-document.ts +180 -217
  55. package/src/io/ooxml/parse-numbering.ts +154 -335
  56. package/src/io/ooxml/parse-object.ts +147 -0
  57. package/src/io/ooxml/parse-ole-relationship.ts +82 -0
  58. package/src/io/ooxml/parse-paragraph-formatting.ts +7 -10
  59. package/src/io/ooxml/parse-picture-sdt.ts +85 -0
  60. package/src/io/ooxml/parse-picture.ts +120 -39
  61. package/src/io/ooxml/parse-revisions.ts +285 -51
  62. package/src/io/ooxml/parse-settings.ts +6 -99
  63. package/src/io/ooxml/parse-shapes.ts +25 -140
  64. package/src/io/ooxml/parse-styles.ts +3 -218
  65. package/src/io/ooxml/parse-tables.ts +76 -256
  66. package/src/io/ooxml/parse-theme.ts +1 -4
  67. package/src/io/ooxml/property-grab-bag.ts +5 -47
  68. package/src/io/ooxml/xml-element-serialize.ts +32 -0
  69. package/src/io/ooxml/xml-parser.ts +183 -0
  70. package/src/legal/bookmarks.ts +1 -1
  71. package/src/legal/cross-references.ts +1 -1
  72. package/src/legal/defined-terms.ts +1 -1
  73. package/src/legal/{_document-root.ts → document-root.ts} +8 -0
  74. package/src/legal/signature-blocks.ts +1 -1
  75. package/src/model/canonical-document.ts +165 -6
  76. package/src/model/chart-types.ts +439 -0
  77. package/src/model/snapshot.ts +3 -1
  78. package/src/review/store/comment-remapping.ts +24 -11
  79. package/src/review/store/revision-actions.ts +482 -2
  80. package/src/review/store/revision-store.ts +15 -0
  81. package/src/review/store/revision-types.ts +76 -0
  82. package/src/runtime/collab/remote-cursor-awareness.ts +24 -0
  83. package/src/runtime/collab/runtime-collab-sync.ts +33 -0
  84. package/src/runtime/diagnostics/build-diagnostic.ts +151 -0
  85. package/src/runtime/diagnostics/code-metadata-table.ts +221 -0
  86. package/src/runtime/document-runtime.ts +544 -35
  87. package/src/runtime/document-search.ts +176 -0
  88. package/src/runtime/edit-ops/index.ts +18 -2
  89. package/src/runtime/footnote-resolver.ts +130 -0
  90. package/src/runtime/layout/layout-engine-instance.ts +31 -4
  91. package/src/runtime/layout/layout-engine-version.ts +37 -1
  92. package/src/runtime/layout/page-graph.ts +14 -1
  93. package/src/runtime/layout/resolved-formatting-state.ts +21 -0
  94. package/src/runtime/numbering-prefix.ts +17 -0
  95. package/src/runtime/query-scopes.ts +183 -0
  96. package/src/runtime/resolved-numbering-geometry.ts +37 -6
  97. package/src/runtime/revision-runtime.ts +27 -1
  98. package/src/runtime/scope-resolver.ts +60 -0
  99. package/src/runtime/selection/post-edit-validator.ts +60 -6
  100. package/src/runtime/structure-ops/index.ts +20 -4
  101. package/src/runtime/surface-projection.ts +293 -18
  102. package/src/runtime/table-schema.ts +6 -0
  103. package/src/runtime/theme-color-resolver.ts +2 -2
  104. package/src/runtime/units.ts +9 -0
  105. package/src/runtime/workflow-rail-segments.ts +4 -0
  106. package/src/ui/WordReviewEditor.tsx +258 -44
  107. package/src/ui/editor-runtime-boundary.ts +13 -0
  108. package/src/ui/editor-shell-view.tsx +4 -1
  109. package/src/ui/headless/chrome-registry.ts +53 -0
  110. package/src/ui/headless/selection-tool-resolver.ts +11 -1
  111. package/src/ui-tailwind/chrome/chrome-preset-model.ts +13 -0
  112. package/src/ui-tailwind/chrome/tw-command-palette-mount.tsx +96 -0
  113. package/src/ui-tailwind/chrome/tw-context-menu.tsx +2 -1
  114. package/src/ui-tailwind/chrome/tw-image-context-toolbar.tsx +5 -4
  115. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +6 -2
  116. package/src/ui-tailwind/chrome/use-container-breakpoint.ts +111 -0
  117. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +23 -9
  118. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +158 -0
  119. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +6 -7
  120. package/src/ui-tailwind/editor-surface/pm-schema.ts +105 -17
  121. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +13 -0
  122. package/src/ui-tailwind/editor-surface/shape-renderer.ts +76 -14
  123. package/src/ui-tailwind/editor-surface/tw-page-block-view.helpers.ts +18 -1
  124. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +2 -0
  125. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +18 -2
  126. package/src/ui-tailwind/index.ts +9 -0
  127. package/src/ui-tailwind/page-chrome-model.ts +77 -5
  128. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +56 -1
  129. package/src/ui-tailwind/page-stack/tw-region-block-renderer.tsx +2 -0
  130. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +116 -113
  131. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +2 -2
  132. package/src/ui-tailwind/theme/tokens.ts +14 -0
  133. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +5 -0
  134. package/src/ui-tailwind/tw-review-workspace.tsx +52 -87
  135. package/src/validation/diagnostics.ts +1 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * TwCommandPaletteMount — ready-to-use mount adapter that wires
3
+ * `TwCommandPalette` + `isCommandPaletteOpenShortcut` together so a
4
+ * host only needs to supply the command graph.
5
+ *
6
+ * design-close-chrome Phase 3 / designsystem §6.25.
7
+ *
8
+ * Why a separate mount component:
9
+ * - `TwCommandPalette` is intentionally pure — no global state, no
10
+ * key subscription. That keeps it testable and composable.
11
+ * - Most hosts want the Ctrl+K / Cmd+K binding, open-state
12
+ * management, and click-off-to-close plumbing out of the box.
13
+ * - This adapter pairs them with a single `groups` prop. Opt-out
14
+ * is trivial: use `TwCommandPalette` directly with your own open
15
+ * state + key handler.
16
+ *
17
+ * The adapter:
18
+ * - Subscribes to window keydown with `isCommandPaletteOpenShortcut`
19
+ * (only when not already open, to let the palette own Escape/Enter).
20
+ * - Calls `event.preventDefault()` so Ctrl+K does not bubble to the
21
+ * browser (avoids the URL-bar focus on macOS Chrome).
22
+ * - Honors a `disabled` flag so hosts can suppress the binding while
23
+ * another modal captures input.
24
+ */
25
+
26
+ import React, { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
27
+
28
+ import {
29
+ TwCommandPalette,
30
+ isCommandPaletteOpenShortcut,
31
+ type CommandPaletteGroup,
32
+ type TwCommandPaletteProps,
33
+ } from "./tw-command-palette";
34
+
35
+ export interface TwCommandPaletteMountProps
36
+ extends Omit<TwCommandPaletteProps, "open" | "onOpenChange"> {
37
+ /** Command graph supplied by the host. */
38
+ groups: readonly CommandPaletteGroup[];
39
+ /**
40
+ * When `true`, the global Ctrl+K / Cmd+K listener is suppressed so
41
+ * a higher-priority modal keeps keyboard focus. Defaults to `false`.
42
+ */
43
+ disabled?: boolean;
44
+ /**
45
+ * Optional: fires when the palette's open state transitions. Callers
46
+ * can use this to emit analytics or to coordinate focus with other
47
+ * chrome surfaces.
48
+ */
49
+ onOpenChange?: (open: boolean) => void;
50
+ /**
51
+ * Optional: the initial open state. Defaults to `false`. Primarily
52
+ * useful for Storybook / tests.
53
+ */
54
+ defaultOpen?: boolean;
55
+ }
56
+
57
+ export function TwCommandPaletteMount(props: TwCommandPaletteMountProps): ReactNode {
58
+ const { disabled = false, defaultOpen = false, onOpenChange, ...paletteProps } = props;
59
+ const [open, setOpen] = useState(defaultOpen);
60
+ // Ref mirrors `open` so the keydown handler can toggle without reading
61
+ // React state (which React 18 strict mode double-invokes inside
62
+ // `setState` updaters). Fire `onOpenChange` exactly once per Ctrl+K.
63
+ const openRef = useRef(defaultOpen);
64
+
65
+ const handleOpenChange = useCallback(
66
+ (next: boolean) => {
67
+ openRef.current = next;
68
+ setOpen(next);
69
+ onOpenChange?.(next);
70
+ },
71
+ [onOpenChange],
72
+ );
73
+
74
+ useEffect(() => {
75
+ if (disabled) return;
76
+ if (typeof window === "undefined") return;
77
+ const handler = (event: KeyboardEvent): void => {
78
+ if (!isCommandPaletteOpenShortcut(event)) return;
79
+ event.preventDefault();
80
+ // Toggle: Ctrl+K with palette open dismisses it. That matches
81
+ // VSCode / most IDE palettes. Side effect (onOpenChange) lives
82
+ // here — NOT inside a setState updater — so React 18 strict mode
83
+ // cannot fire it twice for a single keypress.
84
+ const next = !openRef.current;
85
+ openRef.current = next;
86
+ setOpen(next);
87
+ onOpenChange?.(next);
88
+ };
89
+ window.addEventListener("keydown", handler);
90
+ return () => {
91
+ window.removeEventListener("keydown", handler);
92
+ };
93
+ }, [disabled, onOpenChange]);
94
+
95
+ return <TwCommandPalette {...paletteProps} open={open} onOpenChange={handleOpenChange} />;
96
+ }
@@ -11,6 +11,7 @@
11
11
  import * as React from "react";
12
12
 
13
13
  import { TwShortcutHint, type ShortcutKey } from "./tw-shortcut-hint";
14
+ import { FOCUS_RING_CLASSES } from "../theme/tokens";
14
15
 
15
16
  // ---------------------------------------------------------------------------
16
17
  // Dedupe context
@@ -222,7 +223,7 @@ function ContextMenuRow({ item, platform }: ContextMenuRowProps): React.JSX.Elem
222
223
  "text-[13px] text-[var(--color-text-primary)] text-left",
223
224
  "hover:bg-[var(--color-bg-hover)]",
224
225
  "disabled:opacity-40 disabled:cursor-not-allowed",
225
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-accent)]",
226
+ FOCUS_RING_CLASSES,
226
227
  ].join(" ")}
227
228
  >
228
229
  <span className="flex items-center gap-2 min-w-0">
@@ -2,6 +2,7 @@ import React from "react";
2
2
 
3
3
  import { preserveEditorSelectionMouseDown } from "../../ui/headless/preserve-editor-selection";
4
4
  import type { ActiveImageContext } from "../../ui/headless/selection-tool-types";
5
+ import { EMU_PER_INCH } from "../../runtime/units.ts";
5
6
 
6
7
  export interface TwImageContextToolbarProps {
7
8
  activeImage: ActiveImageContext;
@@ -17,10 +18,10 @@ export interface TwImageContextToolbarProps {
17
18
  }
18
19
 
19
20
  const IMAGE_SIZE_PRESETS = [
20
- { label: "Small image", widthEmu: 1828800, heightEmu: 914400 },
21
- { label: "Medium image", widthEmu: 2743200, heightEmu: 1371600 },
22
- { label: "Large image", widthEmu: 3657600, heightEmu: 1828800 },
23
- ] as const;
21
+ { label: "Small image", widthEmu: 2 * EMU_PER_INCH, heightEmu: 1 * EMU_PER_INCH },
22
+ { label: "Medium image", widthEmu: 3 * EMU_PER_INCH, heightEmu: 1.5 * EMU_PER_INCH },
23
+ { label: "Large image", widthEmu: 4 * EMU_PER_INCH, heightEmu: 2 * EMU_PER_INCH },
24
+ ];
24
25
 
25
26
  const NUDGE_EMU = 228600;
26
27
 
@@ -1,5 +1,7 @@
1
1
  import React, { type ReactNode } from "react";
2
2
 
3
+ import { FOCUS_RING_CLASSES } from "../theme/tokens";
4
+
3
5
  export interface TwModeDockAction {
4
6
  id: string;
5
7
  label: string;
@@ -15,8 +17,10 @@ export interface TwModeDockProps {
15
17
  className?: string;
16
18
  }
17
19
 
18
- const focusRingClass =
19
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-canvas";
20
+ // TwModeDock is a deprecated harness-only surface per designsystem §6.26,
21
+ // but its focus ring still participates in the canonical §4.7 contract —
22
+ // import the shared token constant so a future ring update cascades here.
23
+ const focusRingClass = FOCUS_RING_CLASSES;
20
24
 
21
25
  export function TwModeDock(props: TwModeDockProps) {
22
26
  const actions = (props.actions ?? []).slice(0, 3);
@@ -0,0 +1,111 @@
1
+ /**
2
+ * useContainerBreakpoint — rAF-coalesced ResizeObserver hook that
3
+ * resolves to a named breakpoint based on a monitored element's
4
+ * `contentRect.width`.
5
+ *
6
+ * Motivation: the shipped `responsive-chrome.ts` compact-mode hook is
7
+ * viewport-driven (single threshold), so a toolbar inside a narrow
8
+ * split-pane renders at full width even when its container is small.
9
+ * Container-driven breakpoints fix this — the toolbar responds to its
10
+ * own box, not the page.
11
+ *
12
+ * Perf discipline (CLAUDE.md §Performance Invariants #1 + #7):
13
+ * - The ResizeObserver callback MUST NOT call getBoundingClientRect
14
+ * or any offset/client read. It only reads `entry.contentRect.width`
15
+ * (which is already laid-out and cached by the browser's observer
16
+ * implementation).
17
+ * - State updates are coalesced inside `requestAnimationFrame` so
18
+ * multiple rapid resizes collapse into one render, and the new
19
+ * measurement never drives a synchronous paint.
20
+ *
21
+ * Usage:
22
+ *
23
+ * const ref = useRef<HTMLDivElement | null>(null);
24
+ * const bp = useContainerBreakpoint(ref, { compact: 0, wide: 720 });
25
+ * return <div ref={ref} data-breakpoint={bp}>...</div>;
26
+ *
27
+ * Thresholds map `width` to the highest-named breakpoint whose
28
+ * threshold is ≤ width. Every map MUST include `0` as one of its
29
+ * thresholds so every width resolves to some name.
30
+ */
31
+
32
+ import { useEffect, useMemo, useRef, useState } from "react";
33
+
34
+ export type BreakpointMap = Readonly<Record<string, number>>;
35
+
36
+ /**
37
+ * Resolve a width to the highest-named breakpoint whose threshold is
38
+ * ≤ width. Exported for unit testing and for callers that already own
39
+ * the width and just want the mapping logic.
40
+ */
41
+ export function resolveBreakpoint<T extends BreakpointMap>(
42
+ width: number,
43
+ thresholds: T,
44
+ ): keyof T | null {
45
+ let best: keyof T | null = null;
46
+ let bestThreshold = -Infinity;
47
+ for (const name of Object.keys(thresholds) as (keyof T)[]) {
48
+ const t = thresholds[name] as number;
49
+ if (width >= t && t > bestThreshold) {
50
+ best = name;
51
+ bestThreshold = t;
52
+ }
53
+ }
54
+ return best;
55
+ }
56
+
57
+ export function useContainerBreakpoint<T extends BreakpointMap>(
58
+ ref: React.RefObject<HTMLElement | null>,
59
+ thresholds: T,
60
+ initial: keyof T | null = null,
61
+ ): keyof T | null {
62
+ const [breakpoint, setBreakpoint] = useState<keyof T | null>(initial);
63
+ const rafRef = useRef<number | null>(null);
64
+ const latestWidthRef = useRef<number | null>(null);
65
+
66
+ // R8 — stringify thresholds into a stable deps key so inline
67
+ // literals (e.g. `{ compact: 0, wide: 720 }` declared in JSX) do
68
+ // not destroy + recreate the ResizeObserver on every render. The
69
+ // stringify cost is a single pass over a ~3-key object when the
70
+ // caller's identity changes, which is what we want.
71
+ const depsKey = useMemo(() => JSON.stringify(thresholds), [thresholds]);
72
+ // Thresholds snapshot that the effect reads — captured at subscription
73
+ // time so the observer callback uses the values that corresponded to
74
+ // the current subscription.
75
+ const thresholdsRef = useRef(thresholds);
76
+ thresholdsRef.current = thresholds;
77
+
78
+ useEffect(() => {
79
+ const el = ref.current;
80
+ if (!el || typeof ResizeObserver === "undefined") return;
81
+
82
+ const observer = new ResizeObserver((entries) => {
83
+ // Read-only access to contentRect; no layout-triggering calls.
84
+ const entry = entries[entries.length - 1];
85
+ if (!entry) return;
86
+ latestWidthRef.current = entry.contentRect.width;
87
+ if (rafRef.current !== null) return;
88
+ rafRef.current = requestAnimationFrame(() => {
89
+ rafRef.current = null;
90
+ const w = latestWidthRef.current;
91
+ if (w === null) return;
92
+ const next = resolveBreakpoint(w, thresholdsRef.current);
93
+ setBreakpoint((prev) => (prev === next ? prev : next));
94
+ });
95
+ });
96
+
97
+ observer.observe(el);
98
+ return () => {
99
+ observer.disconnect();
100
+ if (rafRef.current !== null) {
101
+ cancelAnimationFrame(rafRef.current);
102
+ rafRef.current = null;
103
+ }
104
+ };
105
+ // depsKey is the stable projection of `thresholds`; `ref` is an
106
+ // object reference that callers typically hold stable via useRef.
107
+ // eslint-disable-next-line react-hooks/exhaustive-deps
108
+ }, [ref, depsKey]);
109
+
110
+ return breakpoint;
111
+ }
@@ -25,9 +25,9 @@ import type {
25
25
  } from "../../api/public-types";
26
26
  import { TwScopeRailLayer } from "./tw-scope-rail-layer";
27
27
  import { TwScopeCardLayer } from "./tw-scope-card-layer";
28
- import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
29
28
  import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
30
29
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
30
+ import { TwObjectSelectionOverlay } from "./tw-object-selection-overlay";
31
31
 
32
32
  export interface TwChromeOverlayProps {
33
33
  /** Layout facet the overlay layers read from. */
@@ -93,6 +93,16 @@ export interface TwChromeOverlayProps {
93
93
  /** Optional extra children (e.g., future comment balloon layer). */
94
94
  children?: React.ReactNode;
95
95
 
96
+ // Object selection overlay (N6) ----------------------------------------
97
+ /** R.3 — grabbed image/shape id, or null. When set, the selection overlay renders. */
98
+ grabbedObjectId?: string | null;
99
+ /** Document `from` offset of the grabbed segment (for anchor-index lookup). */
100
+ grabbedObjectFromOffset?: number | null;
101
+ /** Document `to` offset of the grabbed segment. */
102
+ grabbedObjectToOffset?: number | null;
103
+ /** Called when the user clicks outside the selection box to deselect. */
104
+ onDeselectObject?: () => void;
105
+
96
106
  // Table grip props (P6) -----------------------------------------------
97
107
  /** Active table context — when present, column/row resize grips are shown. */
98
108
  tableContext?: TableStructureContextSnapshot | null;
@@ -187,6 +197,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
187
197
  scopeCardScopeTagEditor,
188
198
  "data-testid": testId,
189
199
  children,
200
+ grabbedObjectId,
201
+ grabbedObjectFromOffset,
202
+ grabbedObjectToOffset,
203
+ onDeselectObject,
190
204
  tableContext,
191
205
  onSetColumnWidth,
192
206
  onSetRowHeight,
@@ -204,14 +218,6 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
204
218
  data-testid={testId ?? "chrome-overlay"}
205
219
  role="presentation"
206
220
  >
207
- {pageStackScrollRoot !== undefined ? (
208
- <TwPageStackOverlayLayer
209
- facet={facet}
210
- scrollRoot={pageStackScrollRoot}
211
- renderFrameRevision={renderFrameRevision ?? 0}
212
- visiblePageIndexRange={visiblePageIndexRange ?? null}
213
- />
214
- ) : null}
215
221
  {pageStackScrollRoot !== undefined ? (
216
222
  <TwPageStackChromeLayer
217
223
  facet={facet}
@@ -251,6 +257,14 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
251
257
  onSetColumnWidth={onSetColumnWidth}
252
258
  onSetRowHeight={onSetRowHeight}
253
259
  />
260
+ <TwObjectSelectionOverlay
261
+ grabbedObjectId={grabbedObjectId ?? null}
262
+ grabbedObjectFromOffset={grabbedObjectFromOffset ?? null}
263
+ grabbedObjectToOffset={grabbedObjectToOffset ?? null}
264
+ facet={facet}
265
+ space={space}
266
+ onDeselect={onDeselectObject}
267
+ />
254
268
  {children}
255
269
  </div>
256
270
  );
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Lane 6d — N6 P11.1–P11.5: object selection chrome overlay.
3
+ *
4
+ * Renders a selection box with 8 resize handles and a rotate grip around the
5
+ * grabbed image or shape. Purely visual chrome — no resize/rotate mutations
6
+ * in this slice; handle interaction is deferred to the follow-up N6.b slice.
7
+ *
8
+ * Positioning: uses `RenderAnchorIndex.byRuntimeOffset(from)` + `bySelection`
9
+ * to compute the object rect in overlay-coordinate space, then paints over it.
10
+ *
11
+ * Dismissal: clicking outside the overlay calls `onDeselect()` which routes
12
+ * to `runtime.deselectObject()` in the workspace.
13
+ *
14
+ * v1 scope:
15
+ * - rect, ellipse, roundRect shapes and inline/floating images.
16
+ * - Resize handles are visual only (pointer-events-none on each handle).
17
+ * - Rotate grip is visual only.
18
+ * - Anchor drag indicator omitted until N6.b.
19
+ */
20
+
21
+ import * as React from "react";
22
+ import { useEffect, useRef } from "react";
23
+ import type { WordReviewEditorLayoutFacet } from "../../api/public-types";
24
+ import type { OverlayCoordinateSpace } from "./chrome-overlay-projector";
25
+ import { projectRectToOverlay } from "./chrome-overlay-projector";
26
+
27
+ /** The 8 corner/edge handle positions. */
28
+ const HANDLE_POSITIONS = [
29
+ "nw", "n", "ne",
30
+ "w", "e",
31
+ "sw", "s", "se",
32
+ ] as const;
33
+ type HandlePosition = typeof HANDLE_POSITIONS[number];
34
+
35
+ const CURSOR_MAP: Record<HandlePosition, string> = {
36
+ nw: "nw-resize", n: "n-resize", ne: "ne-resize",
37
+ w: "w-resize", e: "e-resize",
38
+ sw: "sw-resize", s: "s-resize", se: "se-resize",
39
+ };
40
+
41
+ export interface TwObjectSelectionOverlayProps {
42
+ /** Stable id of the grabbed image/shape (mediaId or shapeId), or null. */
43
+ grabbedObjectId: string | null;
44
+ /** Document offset (`from`) of the grabbed object's inline segment. Null when no object grabbed. */
45
+ grabbedObjectFromOffset: number | null;
46
+ /** Document offset (`to`) of the grabbed object's inline segment. Null when no object grabbed. */
47
+ grabbedObjectToOffset: number | null;
48
+ /** Layout facet for render-frame + anchor-index access. */
49
+ facet: WordReviewEditorLayoutFacet;
50
+ /** Optional overlay coordinate-space override. */
51
+ space?: OverlayCoordinateSpace;
52
+ /** Called when the user clicks outside the selection box. */
53
+ onDeselect?: () => void;
54
+ }
55
+
56
+ export function TwObjectSelectionOverlay({
57
+ grabbedObjectId,
58
+ grabbedObjectFromOffset,
59
+ grabbedObjectToOffset,
60
+ facet,
61
+ space,
62
+ onDeselect,
63
+ }: TwObjectSelectionOverlayProps) {
64
+ const overlayRef = useRef<HTMLDivElement>(null);
65
+
66
+ // Click-outside to deselect.
67
+ useEffect(() => {
68
+ if (!grabbedObjectId || !onDeselect) return;
69
+ function handlePointerDown(e: PointerEvent) {
70
+ if (overlayRef.current && !overlayRef.current.contains(e.target as Node)) {
71
+ onDeselect!();
72
+ }
73
+ }
74
+ document.addEventListener("pointerdown", handlePointerDown, { capture: true });
75
+ return () => document.removeEventListener("pointerdown", handlePointerDown, { capture: true });
76
+ }, [grabbedObjectId, onDeselect]);
77
+
78
+ if (!grabbedObjectId || grabbedObjectFromOffset == null) return null;
79
+
80
+ const frame = typeof facet.getRenderFrame === "function" ? facet.getRenderFrame() : null;
81
+ if (!frame) return null;
82
+
83
+ const rawRect = grabbedObjectToOffset != null
84
+ ? frame.anchorIndex.bySelection(grabbedObjectFromOffset, grabbedObjectToOffset)
85
+ : frame.anchorIndex.byRuntimeOffset(grabbedObjectFromOffset);
86
+ if (!rawRect) return null;
87
+
88
+ const rect = projectRectToOverlay(rawRect, space);
89
+
90
+ const boxStyle: React.CSSProperties = {
91
+ position: "absolute",
92
+ left: rect.left,
93
+ top: rect.top,
94
+ width: rect.width,
95
+ height: rect.height,
96
+ outline: "2px solid var(--color-accent-primary)",
97
+ boxSizing: "border-box",
98
+ pointerEvents: "auto",
99
+ };
100
+
101
+ return (
102
+ <div
103
+ ref={overlayRef}
104
+ style={boxStyle}
105
+ data-chrome-overlay=""
106
+ data-object-selection=""
107
+ data-object-id={grabbedObjectId}
108
+ aria-label="Selected object"
109
+ >
110
+ {HANDLE_POSITIONS.map((pos) => (
111
+ <ObjectHandle key={pos} position={pos} />
112
+ ))}
113
+ <RotateGrip />
114
+ </div>
115
+ );
116
+ }
117
+
118
+ function ObjectHandle({ position }: { position: HandlePosition }) {
119
+ const HANDLE_PX = 8;
120
+ const half = HANDLE_PX / 2;
121
+ const pos = position;
122
+
123
+ const style: React.CSSProperties = {
124
+ position: "absolute",
125
+ width: HANDLE_PX,
126
+ height: HANDLE_PX,
127
+ background: "white",
128
+ border: "1.5px solid var(--color-accent-primary)",
129
+ borderRadius: 1,
130
+ boxSizing: "border-box",
131
+ cursor: CURSOR_MAP[pos],
132
+ // Visual only in v1 — pointer events disabled so clicks fall through to
133
+ // the click-outside listener which deselectObjects.
134
+ pointerEvents: "none",
135
+ ...(pos.includes("w") ? { left: -half } : pos.includes("e") ? { right: -half } : { left: "50%", transform: "translateX(-50%)" }),
136
+ ...(pos.includes("n") ? { top: -half } : pos.includes("s") ? { bottom: -half } : { top: "50%", transform: `${pos === "w" || pos === "e" ? "translateY(-50%)" : "translateX(-50%) translateY(-50%)"}` }),
137
+ };
138
+
139
+ return <div style={style} data-handle={pos} aria-hidden="true" />;
140
+ }
141
+
142
+ function RotateGrip() {
143
+ const style: React.CSSProperties = {
144
+ position: "absolute",
145
+ width: 10,
146
+ height: 10,
147
+ borderRadius: "50%",
148
+ background: "white",
149
+ border: "1.5px solid var(--color-accent-primary)",
150
+ top: -24,
151
+ left: "50%",
152
+ transform: "translateX(-50%)",
153
+ cursor: "grab",
154
+ pointerEvents: "none",
155
+ boxSizing: "border-box",
156
+ };
157
+ return <div style={style} data-handle="rotate" aria-hidden="true" />;
158
+ }
@@ -628,13 +628,12 @@ export const TwPageStackOverlayLayer: React.FC<TwPageStackOverlayLayerProps> = (
628
628
  height: `${rect.heightPx}px`,
629
629
  left: 0,
630
630
  right: 0,
631
- // The overlay paints decorative paper-edge treatment (border
632
- // + soft shadow) around each page's vertical slice. It sits
633
- // ABOVE the PM surface in stacking order so we deliberately
634
- // leave `backgroundColor` unset a filled overlay would
635
- // occlude PM text. Border + box-shadow alone give the
636
- // 'distinct paper' perception without covering content.
637
- backgroundColor: "transparent",
631
+ // N1 (L8 Phase D): this component is placed at z-0 BEFORE
632
+ // the z-10 PM wrapper inside `wre-page-surface`, so an opaque
633
+ // page background here sits behind PM text rather than on top
634
+ // of it. White card + border + shadow gives the 'N distinct
635
+ // papers on a gray canvas' appearance.
636
+ backgroundColor: "var(--color-page-bg, white)",
638
637
  border: "1px solid var(--color-page-border, rgba(148,163,184,0.2))",
639
638
  borderRadius: "var(--radius-page, 4px)",
640
639
  boxShadow: