@beyondwork/docx-react-component 1.0.57 → 1.0.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -238,7 +238,7 @@ Engineering work is organized into 9 lanes (5 active now + 2 later polish + 2 fi
238
238
  | 6b | [**Shell & Workspace Chrome**](docs/plans/lane-6b-shell-workspace-chrome.md) | **~15%** | Gated on 6a.S1+S2 — shell header + toolbar + status + alert banner + unsaved modal + collab chrome restyle; mode-dock decommission; TwCommandPalette |
239
239
  | 6c | [**Context & Review Surfaces**](docs/plans/lane-6c-context-review-surfaces.md) | **~15%** | Gated on 6a.S1+S2 — selection toolbar + suggestion card + rail + scope + context toolbars + health panel restyle; TwCommentPreview / TwEmptyState / TwShortcutHint |
240
240
  | 6d | [**Visual Fidelity**](docs/plans/lane-6d-visual-fidelity.md) | **~20%** | Gated on 6a.S1+S2 + external-lane deps — L8 A/B/C shipped; L8 Phase D + P11 overlays + P7 + P12 + P14 next |
241
- | 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) | **0%** | LATER (gated on 6a–6d) — V5 covers, V6 REF/PAGEREF, V7 cascade audit |
241
+ | 7a | [**Visual Fidelity Register**](docs/plans/lane-7a-visual-fidelity-register.md) | **✅ 100%** | CLOSED — V5 covers (`6f8869b7`), V6 REF/PAGEREF baseline via CO3 + contract pin (`99b66a1f`), V7 × CO1 5-combo cascade audit (`df315488`) |
242
242
  | 7b | [**Revision & Redline Completion**](docs/plans/lane-7b-revision-redline-completion.md) | **0%** | LATER (gated on 6a–6d) — X4.a/b structural table revisions, X5 ffData, move-pairing |
243
243
  | 7c | [**Preservation & Format Coverage**](docs/plans/lane-7c-preservation-format-coverage.md) | **0%** | LATER (gated on 6a–6d) — O6 Strict, OLE preserve-only, picture-SDT, w14/kern/VML defer |
244
244
  | 7d | [**Platform Hardening & Hygiene**](docs/plans/lane-7d-platform-hardening-hygiene.md) | **0%** | LATER (gated on 6a–6d) — harness-crash-hardening, fastload activation, worktree consolidation |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.57",
4
+ "version": "1.0.58",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "packageManager": "pnpm@10.30.3",
7
7
  "type": "module",
@@ -482,6 +482,12 @@ export interface SearchOptions {
482
482
  matchCase?: boolean;
483
483
  wholeWord?: boolean;
484
484
  limit?: number;
485
+ /** Phase C §C3 — treat `query` as a JS regex pattern (compiled with `u` flag). */
486
+ regex?: boolean;
487
+ /** Phase C §C3 — restrict results to positions inside this scopeId's marker range. */
488
+ inScope?: string;
489
+ /** Phase C §C3 — restrict results to a specific story target (default: main). */
490
+ inStory?: EditorStoryTarget;
485
491
  }
486
492
 
487
493
  export interface SearchResultSnapshot {
@@ -841,6 +847,12 @@ export interface SurfacePictureEffects {
841
847
  flipV?: boolean;
842
848
  presetGeom?: string;
843
849
  stretch?: boolean;
850
+ /** N11.b — a:softEdge feather radius in EMU. Maps to CSS `filter: blur(R)`. */
851
+ softEdgeRadius?: number;
852
+ /** N11.b — a:outerShdw drop shadow. `blurRad`/`dist` in EMU; `dir` in 60000ths of a degree. */
853
+ outerShadow?: { blurRad: number; dist: number; dir: number; color: string; colorType: "srgbClr" | "schemeClr" };
854
+ /** N11.b — a:glow ambient glow. `radius` in EMU. */
855
+ glow?: { radius: number; color: string; colorType: "srgbClr" | "schemeClr" };
844
856
  }
845
857
 
846
858
  export type SurfaceInlineSegment =
@@ -1812,6 +1824,8 @@ export interface RuntimeRenderSnapshot {
1812
1824
  commandState: CommandStateSnapshot;
1813
1825
  surface?: EditorSurfaceSnapshot;
1814
1826
  protectionSnapshot: ProtectionSnapshot;
1827
+ /** R.3 — stable id of the currently grabbed image/shape, or null. Populated from grab state so chrome overlays re-render on selectObject/deselectObject. */
1828
+ grabbedObjectId?: string | null;
1815
1829
  }
1816
1830
 
1817
1831
  export interface EditorSessionState {
@@ -2042,6 +2056,67 @@ export interface WorkflowMetadataSnapshot {
2042
2056
  entries: WorkflowMetadataEntry[];
2043
2057
  }
2044
2058
 
2059
+ // ---------------------------------------------------------------------------
2060
+ // Phase C — host-side scope query surface (§C1, §C2)
2061
+ // ---------------------------------------------------------------------------
2062
+
2063
+ /**
2064
+ * §C1 — Filter passed to `queryScopes` / `findScopesAt` /
2065
+ * `findScopesIntersecting`. All fields are optional and AND'd together.
2066
+ * Snapshot-based; never triggers runtime mutation.
2067
+ */
2068
+ export interface ScopeQueryFilter {
2069
+ /** Match only scopes attached to any of these work items. */
2070
+ workItemIds?: string[];
2071
+ /** Match only scopes in one of these modes. */
2072
+ modes?: WorkflowScopeMode[];
2073
+ /** Match only scopes whose `domain` is one of these. */
2074
+ domains?: Array<NonNullable<WorkflowScope["domain"]>>;
2075
+ /**
2076
+ * Match scopes that carry at least one entry with this `metadataId`.
2077
+ * Uses the runtime `WorkflowMetadataSnapshot` joined by `entry.scopeId`.
2078
+ */
2079
+ metadataId?: string;
2080
+ /**
2081
+ * Match scopes that carry at least one entry whose `value` passes the
2082
+ * predicate. Entries with no `value` are skipped.
2083
+ */
2084
+ hasValue?: (value: Record<string, unknown>, entry: WorkflowMetadataEntry) => boolean;
2085
+ /** Label prefix (case-insensitive). */
2086
+ labelPrefix?: string;
2087
+ /**
2088
+ * Story target filter. Defaults to `{ kind: "main" }` when omitted. Pass
2089
+ * `"*"` for any story.
2090
+ */
2091
+ storyTarget?: EditorStoryTarget | "*";
2092
+ /** Max result count. Undefined = no cap. */
2093
+ limit?: number;
2094
+ /**
2095
+ * §C8 — include scopes with `visibility: "hidden"`. Default: false.
2096
+ * Accepted but has no effect until §C8 adds the `visibility` field.
2097
+ */
2098
+ includeHidden?: boolean;
2099
+ /**
2100
+ * §C8 — include scopes with `visibility: "invisible"`. Default: false.
2101
+ * Accepted but has no effect until §C8 adds the `visibility` field.
2102
+ */
2103
+ includeInvisible?: boolean;
2104
+ }
2105
+
2106
+ /**
2107
+ * §C1 — One result from `queryScopes`. Joins the scope record with the
2108
+ * metadata entries that point at it and the resolved work item (when
2109
+ * `scope.workItemId` matches one in the overlay).
2110
+ */
2111
+ export interface ScopeQueryResult {
2112
+ /** Scope record from the overlay. */
2113
+ scope: WorkflowScope;
2114
+ /** Metadata entries whose `entry.scopeId === scope.scopeId`. */
2115
+ entries: WorkflowMetadataEntry[];
2116
+ /** Resolved work item when `scope.workItemId` is set and present. */
2117
+ workItem: WorkflowWorkItem | null;
2118
+ }
2119
+
2045
2120
  // ---------------------------------------------------------------------------
2046
2121
  // R2 — issue metadata (scope-card-overlay P1)
2047
2122
  // ---------------------------------------------------------------------------
@@ -3326,6 +3401,86 @@ export interface WordReviewEditorRef {
3326
3401
  setWorkflowMetadataEntries(entries: WorkflowMetadataEntry[]): void;
3327
3402
  clearWorkflowMetadataEntries(): void;
3328
3403
  getWorkflowMetadataSnapshot(): WorkflowMetadataSnapshot;
3404
+ /**
3405
+ * Phase C §C1 — filter + project the current workflow overlay into a
3406
+ * scope-joined view. Reads a snapshot of the current overlay + metadata
3407
+ * entries + work items; no mutation. Results are sorted by start-marker
3408
+ * document position, tie-broken by `scopeId` ascending.
3409
+ *
3410
+ * Defaults: `storyTarget` = `{ kind: "main" }` (pass `"*"` for any
3411
+ * story); `includeHidden` / `includeInvisible` default to `false`
3412
+ * (§C8 additive — accepted today but no-ops until `visibility` lands).
3413
+ *
3414
+ * Returns `[]` when no workflow overlay has been set.
3415
+ */
3416
+ queryScopes(filter?: ScopeQueryFilter): ScopeQueryResult[];
3417
+ /**
3418
+ * Phase C §C2 — every scope whose marker range contains `position.from`
3419
+ * (end-inclusive), ordered outermost → innermost. Pass a zero-length
3420
+ * range (`from === to`) for a point query. Returns the full
3421
+ * `ScopeQueryResult` (scope + entries + workItem) so results compose
3422
+ * directly with `setSelection`, `addScope`, `getLocationForAnchor`.
3423
+ *
3424
+ * Companion to `getInteractionGuardSnapshot().matchedScopeId` — that
3425
+ * API returns the innermost-only match; this one returns the whole
3426
+ * enclosing stack.
3427
+ */
3428
+ findScopesAt(
3429
+ position: EditorAnchorProjection,
3430
+ options?: { includeHidden?: boolean; includeInvisible?: boolean },
3431
+ ): ScopeQueryResult[];
3432
+ /**
3433
+ * Phase C §C2 — every scope whose marker range intersects `range`
3434
+ * (accepts a range anchor; non-range anchors yield `[]`). Default
3435
+ * `mode: "overlap"` matches any intersection including touching
3436
+ * endpoints; `mode: "contain"` requires the scope's entire range to
3437
+ * sit within `range`. Deterministic order by start-marker position.
3438
+ */
3439
+ findScopesIntersecting(
3440
+ range: EditorAnchorProjection,
3441
+ options?: {
3442
+ includeHidden?: boolean;
3443
+ includeInvisible?: boolean;
3444
+ mode?: "overlap" | "contain";
3445
+ },
3446
+ ): ScopeQueryResult[];
3447
+ /**
3448
+ * Phase C §C3 — find the first text match in the document.
3449
+ * Supports `regex`, `inScope`, `inStory` options.
3450
+ * Throws `EditorApiError({ code: "search_invalid_regex" })` synchronously
3451
+ * when `options.regex === true` and `query` is not a valid regex pattern.
3452
+ * Returns `null` when no match is found.
3453
+ */
3454
+ findFirstText(
3455
+ query: string,
3456
+ options?: SearchOptions,
3457
+ ): EditorAnchorProjection | null;
3458
+ /**
3459
+ * Phase C §C3 — find all text matches in the document.
3460
+ * Same options and error contract as `findFirstText`.
3461
+ * Returns `[]` when no matches are found.
3462
+ */
3463
+ findAllText(
3464
+ query: string,
3465
+ options?: SearchOptions,
3466
+ ): EditorAnchorProjection[];
3467
+ /**
3468
+ * Phase C §C3 — find first text match and select it. Returns `true` if a
3469
+ * match was found (and selection updated), `false` otherwise.
3470
+ */
3471
+ selectFirstText(
3472
+ query: string,
3473
+ options?: SearchOptions,
3474
+ ): boolean;
3475
+ /**
3476
+ * Phase C §C3 — select first text match and return the total match count.
3477
+ * Returns `0` when no matches are found. Multi-range selection deferred
3478
+ * to Lane 1 shipping `SelectionSnapshot.multi`.
3479
+ */
3480
+ selectAllText(
3481
+ query: string,
3482
+ options?: SearchOptions,
3483
+ ): number;
3329
3484
  /**
3330
3485
  * Schema 1.1 — set the overlay default for metadata persistence.
3331
3486
  * Author-only per collab-master-plan §7 role-gating matrix;
@@ -3863,6 +4018,24 @@ export interface ResolveMetadataConflictInput {
3863
4018
  }
3864
4019
 
3865
4020
  // ---------------------------------------------------------------------------
4021
+ // Phase C §C3 — generic editor API error
4022
+ // ---------------------------------------------------------------------------
4023
+
4024
+ /**
4025
+ * Thrown synchronously by query methods when the caller passes an invalid
4026
+ * argument. The `code` field identifies the specific failure:
4027
+ * - `"search_invalid_regex"` — `options.regex === true` and `query` is not
4028
+ * a valid JavaScript regular expression pattern.
4029
+ */
4030
+ export class EditorApiError extends Error {
4031
+ readonly code: string;
4032
+ constructor(params: { code: string; message?: string }) {
4033
+ super(params.message ?? `EditorApiError: ${params.code}`);
4034
+ this.name = "EditorApiError";
4035
+ this.code = params.code;
4036
+ }
4037
+ }
4038
+
3866
4039
  // Schema 1.1 — metadata-persistence errors (P17)
3867
4040
  // ---------------------------------------------------------------------------
3868
4041
 
@@ -60,6 +60,17 @@ export function parsePicture(graphicDataEl: XmlElementNode): PictureContent | nu
60
60
  const prstGeom = spPr ? findFirstChild(spPr, "prstGeom") : undefined;
61
61
  const presetGeom = prstGeom?.attributes.prst;
62
62
 
63
+ // N11.b — effectLst: softEdge, outerShdw, glow
64
+ const effectLst = spPr ? findFirstChild(spPr, "effectLst") : undefined;
65
+ const softEdgeEl = effectLst ? findFirstChild(effectLst, "softEdge") : undefined;
66
+ const softEdgeRadius = softEdgeEl ? readEmuAttr(softEdgeEl, "rad") : undefined;
67
+ const outerShdwEl = effectLst ? findFirstChild(effectLst, "outerShdw") : undefined;
68
+ const outerShadow = outerShdwEl
69
+ ? parseOuterShadow(outerShdwEl)
70
+ : undefined;
71
+ const glowEl = effectLst ? findFirstChild(effectLst, "glow") : undefined;
72
+ const glow = glowEl ? parseGlow(glowEl) : undefined;
73
+
63
74
  const result: PictureContent = { type: "picture", blipRef };
64
75
  if (srcRect) result.srcRect = srcRect;
65
76
  if (stretch !== undefined) result.stretch = stretch;
@@ -67,9 +78,49 @@ export function parsePicture(graphicDataEl: XmlElementNode): PictureContent | nu
67
78
  if (flipH !== undefined) result.flipH = flipH;
68
79
  if (flipV !== undefined) result.flipV = flipV;
69
80
  if (presetGeom) result.presetGeom = presetGeom;
81
+ if (softEdgeRadius !== undefined) result.softEdgeRadius = softEdgeRadius;
82
+ if (outerShadow) result.outerShadow = outerShadow;
83
+ if (glow) result.glow = glow;
70
84
  return result;
71
85
  }
72
86
 
87
+ function readEmuAttr(el: XmlElementNode, name: string): number | undefined {
88
+ const v = el.attributes[name];
89
+ if (v === undefined) return undefined;
90
+ const n = parseInt(v, 10);
91
+ return Number.isFinite(n) ? n : undefined;
92
+ }
93
+
94
+ function parseColorFromEl(el: XmlElementNode): { color: string; colorType: "srgbClr" | "schemeClr" } | null {
95
+ const srgb = findFirstChild(el, "srgbClr");
96
+ if (srgb) {
97
+ return { color: (srgb.attributes.val ?? "000000").toUpperCase(), colorType: "srgbClr" };
98
+ }
99
+ const scheme = findFirstChild(el, "schemeClr");
100
+ if (scheme) {
101
+ return { color: scheme.attributes.val ?? "dk1", colorType: "schemeClr" };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ function parseOuterShadow(
107
+ el: XmlElementNode,
108
+ ): PictureContent["outerShadow"] {
109
+ const blurRad = readEmuAttr(el, "blurRad") ?? 0;
110
+ const dist = readEmuAttr(el, "dist") ?? 0;
111
+ const dir = parseInt(el.attributes.dir ?? "0", 10) || 0;
112
+ const colorInfo = parseColorFromEl(el);
113
+ if (!colorInfo) return undefined;
114
+ return { blurRad, dist, dir, color: colorInfo.color, colorType: colorInfo.colorType };
115
+ }
116
+
117
+ function parseGlow(el: XmlElementNode): PictureContent["glow"] {
118
+ const radius = readEmuAttr(el, "rad") ?? 0;
119
+ const colorInfo = parseColorFromEl(el);
120
+ if (!colorInfo) return undefined;
121
+ return { radius, color: colorInfo.color, colorType: colorInfo.colorType };
122
+ }
123
+
73
124
  function readPercentAttr(el: XmlElementNode, name: string): number {
74
125
  const v = el.attributes[name];
75
126
  if (v === undefined) return 0;
@@ -1478,6 +1478,12 @@ export interface PictureContent {
1478
1478
  flipH?: boolean;
1479
1479
  flipV?: boolean;
1480
1480
  presetGeom?: string;
1481
+ /** N11.b — DrawingML a:softEdge feather radius in EMU. */
1482
+ softEdgeRadius?: number;
1483
+ /** N11.b — DrawingML a:outerShdw attributes. `dir` is in 60000ths of a degree; `blurRad`/`dist` in EMU. */
1484
+ outerShadow?: { blurRad: number; dist: number; dir: number; color: string; colorType: "srgbClr" | "schemeClr" };
1485
+ /** N11.b — DrawingML a:glow radius in EMU + color. */
1486
+ glow?: { radius: number; color: string; colorType: "srgbClr" | "schemeClr" };
1481
1487
  /** Original w:drawing XML slice, preserved for lossless round-trip serialization. */
1482
1488
  rawXml?: string;
1483
1489
  }
@@ -86,6 +86,8 @@ import type {
86
86
  WorkflowOverlay,
87
87
  WorkflowScope,
88
88
  WorkflowScopeSnapshot,
89
+ ScopeQueryFilter,
90
+ ScopeQueryResult,
89
91
  WorkspaceMode,
90
92
  WordReviewEditorEvent,
91
93
  ZoomLevel,
@@ -136,7 +138,16 @@ import {
136
138
  } from "../review/store/revision-store.ts";
137
139
  import { createSuggestionsSnapshot } from "./suggestions-snapshot.ts";
138
140
  import { validateSelectionAgainstDocument } from "./selection/post-edit-validator.ts";
139
- import { collectScopeLocations, resolveScope } from "./scope-resolver.ts";
141
+ import {
142
+ collectScopeLocations,
143
+ findAllScopesAt,
144
+ findScopesIntersecting,
145
+ resolveScope,
146
+ } from "./scope-resolver.ts";
147
+ import {
148
+ projectScopeQueryResults,
149
+ queryScopes as runQueryScopes,
150
+ } from "./query-scopes.ts";
140
151
  import {
141
152
  insertScopeMarkers,
142
153
  removeScopeMarkers,
@@ -504,6 +515,27 @@ export interface DocumentRuntime {
504
515
  setWorkflowMetadataEntries(entries: WorkflowMetadataEntry[]): void;
505
516
  clearWorkflowMetadataEntries(): void;
506
517
  getWorkflowMetadataSnapshot(): WorkflowMetadataSnapshot;
518
+ /**
519
+ * Phase C §C1 — snapshot-based filter + join projection. See
520
+ * `WordReviewEditorRef.queryScopes` for contract.
521
+ */
522
+ queryScopes(filter?: ScopeQueryFilter): ScopeQueryResult[];
523
+ /**
524
+ * Phase C §C2 — geometric scope queries. See `WordReviewEditorRef`
525
+ * for contract. Non-range anchors yield `[]`.
526
+ */
527
+ findScopesAt(
528
+ position: EditorAnchorProjection,
529
+ options?: { includeHidden?: boolean; includeInvisible?: boolean },
530
+ ): ScopeQueryResult[];
531
+ findScopesIntersecting(
532
+ range: EditorAnchorProjection,
533
+ options?: {
534
+ includeHidden?: boolean;
535
+ includeInvisible?: boolean;
536
+ mode?: "overlap" | "contain";
537
+ },
538
+ ): ScopeQueryResult[];
507
539
  setHostAnnotationOverlay(overlay: HostAnnotationOverlay): void;
508
540
  clearHostAnnotationOverlay(): void;
509
541
  getHostAnnotationSnapshot(): HostAnnotationSnapshot;
@@ -2215,6 +2247,7 @@ export function createDocumentRuntime(
2215
2247
  },
2216
2248
  surface,
2217
2249
  protectionSnapshot,
2250
+ grabbedObjectId: grabState.objectId,
2218
2251
  };
2219
2252
  }
2220
2253
 
@@ -3690,6 +3723,40 @@ export function createDocumentRuntime(
3690
3723
  getWorkflowMetadataSnapshot() {
3691
3724
  return deriveWorkflowMetadataSnapshot();
3692
3725
  },
3726
+ queryScopes(filter) {
3727
+ return runQueryScopes(
3728
+ {
3729
+ overlay: workflowOverlay,
3730
+ entries: workflowMetadataEntries,
3731
+ document: state.document,
3732
+ },
3733
+ filter,
3734
+ );
3735
+ },
3736
+ findScopesAt(position, options) {
3737
+ const pos =
3738
+ position.kind === "range"
3739
+ ? position.from
3740
+ : position.kind === "node"
3741
+ ? position.at
3742
+ : null;
3743
+ if (pos === null) return [];
3744
+ const hits = findAllScopesAt(state.document, pos);
3745
+ return projectScopeQueryResults(
3746
+ { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
3747
+ hits.map((h) => h.scopeId),
3748
+ options,
3749
+ );
3750
+ },
3751
+ findScopesIntersecting(range, options) {
3752
+ if (range.kind !== "range") return [];
3753
+ const hits = findScopesIntersecting(state.document, range.from, range.to, options?.mode);
3754
+ return projectScopeQueryResults(
3755
+ { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
3756
+ hits.map((h) => h.scopeId),
3757
+ options,
3758
+ );
3759
+ },
3693
3760
  setHostAnnotationOverlay(overlay) {
3694
3761
  this.dispatch({
3695
3762
  type: "host-annotation.set-overlay",
@@ -1,10 +1,12 @@
1
1
  import type {
2
2
  DocumentNavigationSnapshot,
3
+ EditorAnchorProjection,
3
4
  EditorStoryTarget,
4
5
  SearchOptions,
5
6
  SearchResultSnapshot,
6
7
  SelectionSnapshot,
7
8
  } from "../api/public-types";
9
+ import { EditorApiError } from "../api/public-types.ts";
8
10
  import {
9
11
  MAIN_STORY_TARGET,
10
12
  storyTargetsEqual,
@@ -23,6 +25,7 @@ import {
23
25
  resolveSectionForStoryTarget,
24
26
  } from "./document-layout.ts";
25
27
  import { createEditorSurfaceSnapshot } from "./surface-projection.ts";
28
+ import { resolveScope } from "./scope-resolver.ts";
26
29
 
27
30
  export function searchDocument(
28
31
  document: CanonicalDocumentEnvelope,
@@ -143,3 +146,61 @@ function getActiveSearchResultIndex(
143
146
 
144
147
  return activeIndex >= 0 ? activeIndex : 0;
145
148
  }
149
+
150
+ /**
151
+ * Phase C §C3 — find all text matches in the document, respecting the new
152
+ * `regex`, `inScope`, and `inStory` options. Throws `EditorApiError` with
153
+ * `code: "search_invalid_regex"` if `options.regex === true` and `query`
154
+ * is not a valid JavaScript regular expression pattern.
155
+ *
156
+ * Returns an array of `EditorAnchorProjection` values (range anchors) that
157
+ * compose directly with `setSelection`, `addScope`, `getLocationForAnchor`.
158
+ */
159
+ export function findTextMatches(
160
+ document: CanonicalDocumentEnvelope,
161
+ selection: SelectionSnapshot,
162
+ query: string,
163
+ options: SearchOptions = {},
164
+ ): EditorAnchorProjection[] {
165
+ const normalizedQuery = query.trim();
166
+ if (!normalizedQuery) return [];
167
+
168
+ if (options.regex) {
169
+ try {
170
+ const flags = options.matchCase ? "ug" : "uig";
171
+ new RegExp(normalizedQuery, flags);
172
+ } catch {
173
+ throw new EditorApiError({
174
+ code: "search_invalid_regex",
175
+ message: `Invalid regex pattern: ${normalizedQuery}`,
176
+ });
177
+ }
178
+ }
179
+
180
+ const storyTarget: EditorStoryTarget = options.inStory ?? MAIN_STORY_TARGET;
181
+ const surface = createEditorSurfaceSnapshot(
182
+ document,
183
+ createSelectionSnapshot(selection.anchor, selection.head),
184
+ storyTarget,
185
+ );
186
+
187
+ let results = searchSurfaceBlocks(surface.blocks, normalizedQuery, options);
188
+
189
+ if (options.inScope) {
190
+ const scopeAnchor = resolveScope(document, options.inScope);
191
+ if (!scopeAnchor || scopeAnchor.kind !== "range") {
192
+ return [];
193
+ }
194
+ const { from: scopeFrom, to: scopeTo } = scopeAnchor;
195
+ results = results.filter(
196
+ (r) => r.from >= scopeFrom && r.to <= scopeTo,
197
+ );
198
+ }
199
+
200
+ return results.map((r) => ({
201
+ kind: "range" as const,
202
+ from: r.from,
203
+ to: r.to,
204
+ assoc: { start: -1 as const, end: 1 as const },
205
+ }));
206
+ }
@@ -0,0 +1,186 @@
1
+ import type {
2
+ EditorStoryTarget,
3
+ ScopeQueryFilter,
4
+ ScopeQueryResult,
5
+ WorkflowMetadataEntry,
6
+ WorkflowOverlay,
7
+ WorkflowScope,
8
+ WorkflowWorkItem,
9
+ } from "../api/public-types.ts";
10
+ import type { CanonicalDocument } from "../model/canonical-document.ts";
11
+ import type { CanonicalDocumentEnvelope } from "../core/state/editor-state.ts";
12
+ import { collectScopeLocations } from "./scope-resolver.ts";
13
+
14
+ function storyTargetsEqual(a: EditorStoryTarget, b: EditorStoryTarget): boolean {
15
+ if (a.kind !== b.kind) return false;
16
+ switch (a.kind) {
17
+ case "main":
18
+ return true;
19
+ case "header":
20
+ case "footer":
21
+ return (
22
+ a.relationshipId === (b as typeof a).relationshipId &&
23
+ a.variant === (b as typeof a).variant &&
24
+ a.sectionIndex === (b as typeof a).sectionIndex
25
+ );
26
+ case "footnote":
27
+ case "endnote":
28
+ return a.noteId === (b as typeof a).noteId;
29
+ }
30
+ }
31
+
32
+ const MAIN_STORY: EditorStoryTarget = { kind: "main" };
33
+
34
+ export interface ScopeQueryInputs {
35
+ readonly overlay: WorkflowOverlay | null;
36
+ readonly entries: readonly WorkflowMetadataEntry[];
37
+ readonly document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope;
38
+ }
39
+
40
+ /**
41
+ * Phase C §C2 helper — project an ordered list of scopeIds (already produced
42
+ * by a geometric walk like `findAllScopesAt` / `findScopesIntersecting`)
43
+ * into `ScopeQueryResult[]`. Preserves incoming order. Filters out scopeIds
44
+ * that are not in the overlay (e.g. orphan markers). Applies the §C8
45
+ * visibility filter via `includeHidden` / `includeInvisible`.
46
+ */
47
+ export function projectScopeQueryResults(
48
+ inputs: ScopeQueryInputs,
49
+ scopeIds: readonly string[],
50
+ options: { includeHidden?: boolean; includeInvisible?: boolean } = {},
51
+ ): ScopeQueryResult[] {
52
+ const overlay = inputs.overlay;
53
+ if (!overlay) return [];
54
+ const includeHidden = options.includeHidden === true;
55
+ const includeInvisible = options.includeInvisible === true;
56
+
57
+ const scopesById = new Map<string, WorkflowScope>();
58
+ for (const scope of overlay.scopes) scopesById.set(scope.scopeId, scope);
59
+
60
+ const entriesByScope = new Map<string, WorkflowMetadataEntry[]>();
61
+ for (const entry of inputs.entries) {
62
+ if (!entry.scopeId) continue;
63
+ let list = entriesByScope.get(entry.scopeId);
64
+ if (!list) {
65
+ list = [];
66
+ entriesByScope.set(entry.scopeId, list);
67
+ }
68
+ list.push(entry);
69
+ }
70
+
71
+ const workItemsById = new Map<string, WorkflowWorkItem>();
72
+ for (const item of overlay.workItems ?? []) {
73
+ workItemsById.set(item.workItemId, item);
74
+ }
75
+
76
+ const results: ScopeQueryResult[] = [];
77
+ for (const scopeId of scopeIds) {
78
+ const scope = scopesById.get(scopeId);
79
+ if (!scope) continue;
80
+ const visibility = (scope as WorkflowScope & { visibility?: string }).visibility;
81
+ if (visibility === "hidden" && !includeHidden) continue;
82
+ if (visibility === "invisible" && !includeInvisible) continue;
83
+ const entries = entriesByScope.get(scopeId) ?? [];
84
+ const workItem = scope.workItemId
85
+ ? workItemsById.get(scope.workItemId) ?? null
86
+ : null;
87
+ results.push({ scope, entries, workItem });
88
+ }
89
+ return results;
90
+ }
91
+
92
+ /**
93
+ * Pure §C1 projector — filter + join scopes with entries + workItem, ordered
94
+ * by start-marker document position (scopeId ASC tiebreak). Snapshot-based;
95
+ * no runtime state mutation.
96
+ */
97
+ export function queryScopes(
98
+ inputs: ScopeQueryInputs,
99
+ filter: ScopeQueryFilter | undefined,
100
+ ): ScopeQueryResult[] {
101
+ const overlay = inputs.overlay;
102
+ if (!overlay) return [];
103
+
104
+ const normalizedStoryFilter = filter?.storyTarget ?? MAIN_STORY;
105
+ const workItemIdSet = filter?.workItemIds ? new Set(filter.workItemIds) : null;
106
+ const modeSet = filter?.modes ? new Set(filter.modes) : null;
107
+ const domainSet = filter?.domains ? new Set(filter.domains) : null;
108
+ const labelPrefix = filter?.labelPrefix?.toLowerCase();
109
+ const metadataId = filter?.metadataId;
110
+ const hasValue = filter?.hasValue;
111
+ const limit = filter?.limit;
112
+ const includeHidden = filter?.includeHidden === true;
113
+ const includeInvisible = filter?.includeInvisible === true;
114
+
115
+ const entriesByScope = new Map<string, WorkflowMetadataEntry[]>();
116
+ for (const entry of inputs.entries) {
117
+ if (!entry.scopeId) continue;
118
+ let list = entriesByScope.get(entry.scopeId);
119
+ if (!list) {
120
+ list = [];
121
+ entriesByScope.set(entry.scopeId, list);
122
+ }
123
+ list.push(entry);
124
+ }
125
+
126
+ const workItemsById = new Map<string, WorkflowWorkItem>();
127
+ for (const item of overlay.workItems ?? []) {
128
+ workItemsById.set(item.workItemId, item);
129
+ }
130
+
131
+ const locations = collectScopeLocations(inputs.document);
132
+
133
+ const candidates: Array<{ scope: WorkflowScope; startPos: number }> = [];
134
+ for (const scope of overlay.scopes) {
135
+ // §C8 pre-wire: respect visibility flags. Visibility field itself lands
136
+ // in §C8; treating missing as "visible" keeps this forward-compatible.
137
+ const visibility = (scope as WorkflowScope & { visibility?: string }).visibility;
138
+ if (visibility === "hidden" && !includeHidden) continue;
139
+ if (visibility === "invisible" && !includeInvisible) continue;
140
+
141
+ if (normalizedStoryFilter !== "*") {
142
+ const scopeStory = scope.storyTarget ?? MAIN_STORY;
143
+ if (!storyTargetsEqual(scopeStory, normalizedStoryFilter)) continue;
144
+ }
145
+ if (modeSet && !modeSet.has(scope.mode)) continue;
146
+ if (domainSet && !(scope.domain && domainSet.has(scope.domain))) continue;
147
+ if (workItemIdSet) {
148
+ if (!scope.workItemId || !workItemIdSet.has(scope.workItemId)) continue;
149
+ }
150
+ if (labelPrefix) {
151
+ const label = scope.label?.toLowerCase() ?? "";
152
+ if (!label.startsWith(labelPrefix)) continue;
153
+ }
154
+
155
+ const scopeEntries = entriesByScope.get(scope.scopeId) ?? [];
156
+
157
+ if (metadataId && !scopeEntries.some((e) => e.metadataId === metadataId)) continue;
158
+ if (hasValue) {
159
+ const anyMatch = scopeEntries.some(
160
+ (e) => e.value !== undefined && hasValue(e.value, e),
161
+ );
162
+ if (!anyMatch) continue;
163
+ }
164
+
165
+ const loc = locations.get(scope.scopeId);
166
+ const startPos =
167
+ loc?.startPos ?? loc?.endPos ?? Number.POSITIVE_INFINITY;
168
+ candidates.push({ scope, startPos });
169
+ }
170
+
171
+ candidates.sort((a, b) => {
172
+ if (a.startPos !== b.startPos) return a.startPos - b.startPos;
173
+ return a.scope.scopeId < b.scope.scopeId ? -1 : a.scope.scopeId > b.scope.scopeId ? 1 : 0;
174
+ });
175
+
176
+ const results: ScopeQueryResult[] = [];
177
+ for (const { scope } of candidates) {
178
+ if (limit !== undefined && results.length >= limit) break;
179
+ const entries = entriesByScope.get(scope.scopeId) ?? [];
180
+ const workItem = scope.workItemId
181
+ ? workItemsById.get(scope.workItemId) ?? null
182
+ : null;
183
+ results.push({ scope, entries, workItem });
184
+ }
185
+ return results;
186
+ }
@@ -146,3 +146,63 @@ export function findScopeAt(
146
146
  }
147
147
  return best;
148
148
  }
149
+
150
+ /**
151
+ * Phase C §C2 — every enclosing scope at `position`, ordered outermost →
152
+ * innermost (lowest startPos first; ties broken on scopeId ASC). Includes
153
+ * scopes that touch the position exactly (`startPos <= position <= endPos`).
154
+ * Companion to `findScopeAt`, which keeps the innermost-only contract.
155
+ */
156
+ export function findAllScopesAt(
157
+ document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
158
+ position: number,
159
+ ): ResolvedScopeLocation[] {
160
+ const locations = collectScopeLocations(document);
161
+ const hits: ResolvedScopeLocation[] = [];
162
+ for (const [scopeId, loc] of locations) {
163
+ if (loc.startPos === undefined || loc.endPos === undefined) continue;
164
+ if (position < loc.startPos || position > loc.endPos) continue;
165
+ hits.push({ scopeId, startPos: loc.startPos, endPos: loc.endPos });
166
+ }
167
+ hits.sort((a, b) => {
168
+ if (a.startPos !== b.startPos) return a.startPos - b.startPos;
169
+ return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
170
+ });
171
+ return hits;
172
+ }
173
+
174
+ /**
175
+ * Phase C §C2 — every scope whose marker range intersects `[rangeFrom,
176
+ * rangeTo]`. `mode: "overlap"` (default) accepts any intersection including
177
+ * touching endpoints; `mode: "contain"` requires the scope's entire marker
178
+ * range to lie within `[rangeFrom, rangeTo]`. Deterministic order: startPos
179
+ * ASC, scopeId ASC.
180
+ */
181
+ export function findScopesIntersecting(
182
+ document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope,
183
+ rangeFrom: number,
184
+ rangeTo: number,
185
+ mode: "overlap" | "contain" = "overlap",
186
+ ): ResolvedScopeLocation[] {
187
+ const from = Math.min(rangeFrom, rangeTo);
188
+ const to = Math.max(rangeFrom, rangeTo);
189
+ const locations = collectScopeLocations(document);
190
+ const hits: ResolvedScopeLocation[] = [];
191
+ for (const [scopeId, loc] of locations) {
192
+ if (loc.startPos === undefined || loc.endPos === undefined) continue;
193
+ const sFrom = Math.min(loc.startPos, loc.endPos);
194
+ const sTo = Math.max(loc.startPos, loc.endPos);
195
+ if (mode === "contain") {
196
+ if (sFrom < from || sTo > to) continue;
197
+ } else {
198
+ // overlap — any intersection including touching endpoints
199
+ if (sTo < from || sFrom > to) continue;
200
+ }
201
+ hits.push({ scopeId, startPos: loc.startPos, endPos: loc.endPos });
202
+ }
203
+ hits.sort((a, b) => {
204
+ if (a.startPos !== b.startPos) return a.startPos - b.startPos;
205
+ return a.scopeId < b.scopeId ? -1 : a.scopeId > b.scopeId ? 1 : 0;
206
+ });
207
+ return hits;
208
+ }
@@ -1320,7 +1320,10 @@ function surfacePictureEffectsFromContent(
1320
1320
  content.flipH !== undefined ||
1321
1321
  content.flipV !== undefined ||
1322
1322
  content.presetGeom !== undefined ||
1323
- content.stretch !== undefined;
1323
+ content.stretch !== undefined ||
1324
+ content.softEdgeRadius !== undefined ||
1325
+ content.outerShadow !== undefined ||
1326
+ content.glow !== undefined;
1324
1327
  if (!has) return undefined;
1325
1328
  return {
1326
1329
  ...(content.srcRect ? { srcRect: { ...content.srcRect } } : {}),
@@ -1329,6 +1332,9 @@ function surfacePictureEffectsFromContent(
1329
1332
  ...(content.flipV !== undefined ? { flipV: content.flipV } : {}),
1330
1333
  ...(content.presetGeom !== undefined ? { presetGeom: content.presetGeom } : {}),
1331
1334
  ...(content.stretch !== undefined ? { stretch: content.stretch } : {}),
1335
+ ...(content.softEdgeRadius !== undefined ? { softEdgeRadius: content.softEdgeRadius } : {}),
1336
+ ...(content.outerShadow !== undefined ? { outerShadow: { ...content.outerShadow } } : {}),
1337
+ ...(content.glow !== undefined ? { glow: { ...content.glow } } : {}),
1332
1338
  };
1333
1339
  }
1334
1340
 
@@ -151,7 +151,7 @@ import {
151
151
  } from "../io/source-package-provenance.ts";
152
152
  import { readOpcPackage } from "../io/opc/package-reader.ts";
153
153
  import { deriveCapabilities } from "../runtime/session-capabilities";
154
- import { searchDocument } from "../runtime/document-search.ts";
154
+ import { findTextMatches, searchDocument } from "../runtime/document-search.ts";
155
155
  import {
156
156
  resolveCurrentContextAnalyticsQuery,
157
157
  runtimeContextAnalyticsSnapshotsEqual,
@@ -909,6 +909,34 @@ export function __createWordReviewEditorRefBridge(
909
909
  getWorkflowMetadataSnapshot: () => {
910
910
  return clonePublicValue(runtime.getWorkflowMetadataSnapshot());
911
911
  },
912
+ queryScopes: (filter) => {
913
+ return clonePublicValue(runtime.queryScopes(filter));
914
+ },
915
+ findScopesAt: (position, options) => {
916
+ return clonePublicValue(runtime.findScopesAt(position, options));
917
+ },
918
+ findScopesIntersecting: (range, options) => {
919
+ return clonePublicValue(runtime.findScopesIntersecting(range, options));
920
+ },
921
+ findFirstText: (query, opts) => {
922
+ const hits = findTextMatchesForRuntime(runtime, query, opts);
923
+ return hits.length > 0 ? (hits[0] ?? null) : null;
924
+ },
925
+ findAllText: (query, opts) => {
926
+ return findTextMatchesForRuntime(runtime, query, opts);
927
+ },
928
+ selectFirstText: (query, opts) => {
929
+ const hits = findTextMatchesForRuntime(runtime, query, opts);
930
+ if (hits.length === 0) return false;
931
+ applyRuntimeSelection(runtime, createSelectionFromAnchor(hits[0]!));
932
+ return true;
933
+ },
934
+ selectAllText: (query, opts) => {
935
+ const hits = findTextMatchesForRuntime(runtime, query, opts);
936
+ if (hits.length === 0) return 0;
937
+ applyRuntimeSelection(runtime, createSelectionFromAnchor(hits[0]!));
938
+ return hits.length;
939
+ },
912
940
  // P17 — metadata persistence toggle + convert methods.
913
941
  setMetadataPersistenceMode: (mode) => {
914
942
  if (mode === "external" && !(options?.resolverRef?.current ?? null)) {
@@ -1976,6 +2004,34 @@ export const WordReviewEditor = forwardRef<WordReviewEditorRef, WordReviewEditor
1976
2004
  getWorkflowMetadataSnapshot: () => {
1977
2005
  return clonePublicValue(activeRuntime.getWorkflowMetadataSnapshot());
1978
2006
  },
2007
+ queryScopes: (filter) => {
2008
+ return clonePublicValue(activeRuntime.queryScopes(filter));
2009
+ },
2010
+ findScopesAt: (position, options) => {
2011
+ return clonePublicValue(activeRuntime.findScopesAt(position, options));
2012
+ },
2013
+ findScopesIntersecting: (range, options) => {
2014
+ return clonePublicValue(activeRuntime.findScopesIntersecting(range, options));
2015
+ },
2016
+ findFirstText: (query, opts) => {
2017
+ const hits = findTextMatchesForRuntime(activeRuntime, query, opts);
2018
+ return hits.length > 0 ? (hits[0] ?? null) : null;
2019
+ },
2020
+ findAllText: (query, opts) => {
2021
+ return findTextMatchesForRuntime(activeRuntime, query, opts);
2022
+ },
2023
+ selectFirstText: (query, opts) => {
2024
+ const hits = findTextMatchesForRuntime(activeRuntime, query, opts);
2025
+ if (hits.length === 0) return false;
2026
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(hits[0]!));
2027
+ return true;
2028
+ },
2029
+ selectAllText: (query, opts) => {
2030
+ const hits = findTextMatchesForRuntime(activeRuntime, query, opts);
2031
+ if (hits.length === 0) return 0;
2032
+ applyRuntimeSelection(activeRuntime, createSelectionFromAnchor(hits[0]!));
2033
+ return hits.length;
2034
+ },
1979
2035
  // P17 — metadata persistence toggle + convert methods.
1980
2036
  setMetadataPersistenceMode: (mode) => {
1981
2037
  if (mode === "external" && scopeMetadataResolverRef.current === null) {
@@ -5056,6 +5112,20 @@ function clonePublicValue<T>(value: T): T {
5056
5112
  return structuredClone(value);
5057
5113
  }
5058
5114
 
5115
+ function findTextMatchesForRuntime(
5116
+ runtime: WordReviewEditorRuntime,
5117
+ query: string,
5118
+ options: SearchOptions | undefined,
5119
+ ): EditorAnchorProjection[] {
5120
+ const snapshot = runtime.getRenderSnapshot();
5121
+ return findTextMatches(
5122
+ runtime.getSessionState().canonicalDocument,
5123
+ snapshot.selection,
5124
+ query,
5125
+ options ?? {},
5126
+ );
5127
+ }
5128
+
5059
5129
  /**
5060
5130
  * Open the correct header/footer story for a specific page. The page's
5061
5131
  * resolved `stories.header` / `stories.footer` already carries the
@@ -1154,6 +1154,9 @@ function createLoadingRuntimeBridge(input: {
1154
1154
  definitions: [],
1155
1155
  entries: [],
1156
1156
  }),
1157
+ queryScopes: () => [],
1158
+ findScopesAt: () => [],
1159
+ findScopesIntersecting: () => [],
1157
1160
  setHostAnnotationOverlay: () => undefined,
1158
1161
  clearHostAnnotationOverlay: () => undefined,
1159
1162
  getHostAnnotationSnapshot: () => ({
@@ -28,6 +28,7 @@ import { TwScopeCardLayer } from "./tw-scope-card-layer";
28
28
  import { TwPageStackOverlayLayer } from "./tw-page-stack-overlay-layer";
29
29
  import { TwPageStackChromeLayer, type PmPortalView } from "../page-stack/tw-page-stack-chrome-layer";
30
30
  import { TwTableGripLayer } from "../chrome/tw-table-grip-layer";
31
+ import { TwObjectSelectionOverlay } from "./tw-object-selection-overlay";
31
32
 
32
33
  export interface TwChromeOverlayProps {
33
34
  /** Layout facet the overlay layers read from. */
@@ -93,6 +94,16 @@ export interface TwChromeOverlayProps {
93
94
  /** Optional extra children (e.g., future comment balloon layer). */
94
95
  children?: React.ReactNode;
95
96
 
97
+ // Object selection overlay (N6) ----------------------------------------
98
+ /** R.3 — grabbed image/shape id, or null. When set, the selection overlay renders. */
99
+ grabbedObjectId?: string | null;
100
+ /** Document `from` offset of the grabbed segment (for anchor-index lookup). */
101
+ grabbedObjectFromOffset?: number | null;
102
+ /** Document `to` offset of the grabbed segment. */
103
+ grabbedObjectToOffset?: number | null;
104
+ /** Called when the user clicks outside the selection box to deselect. */
105
+ onDeselectObject?: () => void;
106
+
96
107
  // Table grip props (P6) -----------------------------------------------
97
108
  /** Active table context — when present, column/row resize grips are shown. */
98
109
  tableContext?: TableStructureContextSnapshot | null;
@@ -187,6 +198,10 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
187
198
  scopeCardScopeTagEditor,
188
199
  "data-testid": testId,
189
200
  children,
201
+ grabbedObjectId,
202
+ grabbedObjectFromOffset,
203
+ grabbedObjectToOffset,
204
+ onDeselectObject,
190
205
  tableContext,
191
206
  onSetColumnWidth,
192
207
  onSetRowHeight,
@@ -251,6 +266,14 @@ export const TwChromeOverlay: React.FC<TwChromeOverlayProps> = ({
251
266
  onSetColumnWidth={onSetColumnWidth}
252
267
  onSetRowHeight={onSetRowHeight}
253
268
  />
269
+ <TwObjectSelectionOverlay
270
+ grabbedObjectId={grabbedObjectId ?? null}
271
+ grabbedObjectFromOffset={grabbedObjectFromOffset ?? null}
272
+ grabbedObjectToOffset={grabbedObjectToOffset ?? null}
273
+ facet={facet}
274
+ space={space}
275
+ onDeselect={onDeselectObject}
276
+ />
254
277
  {children}
255
278
  </div>
256
279
  );
@@ -0,0 +1,157 @@
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-object-selection=""
106
+ data-object-id={grabbedObjectId}
107
+ aria-label="Selected object"
108
+ >
109
+ {HANDLE_POSITIONS.map((pos) => (
110
+ <ObjectHandle key={pos} position={pos} />
111
+ ))}
112
+ <RotateGrip />
113
+ </div>
114
+ );
115
+ }
116
+
117
+ function ObjectHandle({ position }: { position: HandlePosition }) {
118
+ const HANDLE_PX = 8;
119
+ const half = HANDLE_PX / 2;
120
+ const pos = position;
121
+
122
+ const style: React.CSSProperties = {
123
+ position: "absolute",
124
+ width: HANDLE_PX,
125
+ height: HANDLE_PX,
126
+ background: "white",
127
+ border: "1.5px solid var(--color-accent-primary)",
128
+ borderRadius: 1,
129
+ boxSizing: "border-box",
130
+ cursor: CURSOR_MAP[pos],
131
+ // Visual only in v1 — pointer events disabled so clicks fall through to
132
+ // the click-outside listener which deselectObjects.
133
+ pointerEvents: "none",
134
+ ...(pos.includes("w") ? { left: -half } : pos.includes("e") ? { right: -half } : { left: "50%", transform: "translateX(-50%)" }),
135
+ ...(pos.includes("n") ? { top: -half } : pos.includes("s") ? { bottom: -half } : { top: "50%", transform: `${pos === "w" || pos === "e" ? "translateY(-50%)" : "translateX(-50%) translateY(-50%)"}` }),
136
+ };
137
+
138
+ return <div style={style} data-handle={pos} aria-hidden="true" />;
139
+ }
140
+
141
+ function RotateGrip() {
142
+ const style: React.CSSProperties = {
143
+ position: "absolute",
144
+ width: 10,
145
+ height: 10,
146
+ borderRadius: "50%",
147
+ background: "white",
148
+ border: "1.5px solid var(--color-accent-primary)",
149
+ top: -24,
150
+ left: "50%",
151
+ transform: "translateX(-50%)",
152
+ cursor: "grab",
153
+ pointerEvents: "none",
154
+ boxSizing: "border-box",
155
+ };
156
+ return <div style={style} data-handle="rotate" aria-hidden="true" />;
157
+ }
@@ -465,6 +465,10 @@ export const editorSchema = new Schema({
465
465
  wrapMode: { default: null },
466
466
  distMargins: { default: null },
467
467
  positionH: { default: null },
468
+ // Lane 6d N11.b — CSS filter effects (soft-edge, outer shadow, glow).
469
+ softEdgeRadius: { default: null },
470
+ outerShadow: { default: null },
471
+ glow: { default: null },
468
472
  },
469
473
  toDOM(node) {
470
474
  const isMissing = node.attrs.state === "missing";
@@ -496,6 +500,27 @@ export const editorSchema = new Schema({
496
500
  `inset(${(srcRect.top / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.right / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.bottom / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}% ${(srcRect.left / SRCRECT_UNITS_PER_PERCENT).toFixed(3)}%)`,
497
501
  );
498
502
  }
503
+ // N11.b filter effects → CSS filter on the img element.
504
+ const softEdgeRadius = node.attrs.softEdgeRadius as number | null;
505
+ const outerShadow = node.attrs.outerShadow as {
506
+ blurRad: number; dist: number; dir: number; color: string;
507
+ } | null;
508
+ const glow = node.attrs.glow as { radius: number; color: string } | null;
509
+ const filterParts: string[] = [];
510
+ if (softEdgeRadius) {
511
+ filterParts.push(`blur(${(softEdgeRadius / EMU_PER_PX).toFixed(2)}px)`);
512
+ }
513
+ if (glow) {
514
+ filterParts.push(`drop-shadow(0 0 ${(glow.radius / EMU_PER_PX).toFixed(2)}px #${glow.color})`);
515
+ }
516
+ if (outerShadow) {
517
+ const blurPx = (outerShadow.blurRad / EMU_PER_PX).toFixed(2);
518
+ const distPx = outerShadow.dist / EMU_PER_PX;
519
+ const dirRad = (outerShadow.dir / ROTATION_UNITS_PER_DEGREE) * (Math.PI / 180);
520
+ const dx = (distPx * Math.cos(dirRad)).toFixed(2);
521
+ const dy = (distPx * Math.sin(dirRad)).toFixed(2);
522
+ filterParts.push(`drop-shadow(${dx}px ${dy}px ${blurPx}px #${outerShadow.color})`);
523
+ }
499
524
  // N9 float-wrap → CSS float + shape-outside on the wrapper span.
500
525
  const wrapMode = node.attrs.wrapMode as string | null;
501
526
  const positionH = node.attrs.positionH as { align?: string } | null;
@@ -527,6 +552,7 @@ export const editorSchema = new Schema({
527
552
  heightPx ? `height:${heightPx}px` : "",
528
553
  transformParts.length > 0 ? `transform:${transformParts.join(" ")}` : "",
529
554
  clipParts.length > 0 ? `clip-path:${clipParts[0]}` : "",
555
+ filterParts.length > 0 ? `filter:${filterParts.join(" ")}` : "",
530
556
  ].filter(Boolean).join(";");
531
557
  const wrapperStyle = wrapperStyleParts.join(";");
532
558
  const wrapperAttrs: Record<string, string> = {
@@ -464,6 +464,10 @@ function buildInlineContent(
464
464
  wrapMode: segment.anchor?.wrapMode ?? null,
465
465
  distMargins: segment.anchor?.distMargins ?? null,
466
466
  positionH: segment.anchor?.positionH ?? null,
467
+ // Lane 6d N11.b — filter effects.
468
+ softEdgeRadius: segment.pictureEffects?.softEdgeRadius ?? null,
469
+ outerShadow: segment.pictureEffects?.outerShadow ?? null,
470
+ glow: segment.pictureEffects?.glow ?? null,
467
471
  }),
468
472
  ];
469
473
  }
@@ -198,6 +198,8 @@ export interface TwReviewWorkspaceProps {
198
198
  selectionContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
199
199
  currentScopeContextAnalytics?: RuntimeContextAnalyticsSnapshot | null;
200
200
  commands: EditorCommandBag;
201
+ /** N6 — release the grabbed image/shape. Wired to `runtime.deselectObject()` by the host. */
202
+ onDeselectObject?: () => void;
201
203
  activeSelectionTool?: ActiveSelectionToolModel | null;
202
204
  selectionToolAnchor?: SelectionToolAnchor | null;
203
205
  documentNavigation?: DocumentNavigationSnapshot;
@@ -680,6 +682,23 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
680
682
  // above bumps `renderFrameRevision` on the same kinds; including it
681
683
  // in the dependency list re-runs this memo without a separate
682
684
  // subscription.
685
+ // N6 — resolve grabbed-object segment offsets from the surface so the
686
+ // selection overlay can query the anchor index without a full surface walk.
687
+ const grabbedSegmentOffsets = useMemo(() => {
688
+ const objectId = snapshot.grabbedObjectId ?? null;
689
+ if (!objectId || !snapshot.surface) return null;
690
+ for (const block of snapshot.surface.blocks) {
691
+ if (!("segments" in block)) continue;
692
+ for (const seg of (block as { segments?: unknown[] }).segments ?? []) {
693
+ const s = seg as { kind?: string; mediaId?: string; from?: number; to?: number };
694
+ if ((s.kind === "image" || s.kind === "shape") && s.mediaId === objectId && s.from != null) {
695
+ return { from: s.from, to: s.to ?? s.from + 1 };
696
+ }
697
+ }
698
+ }
699
+ return null;
700
+ }, [snapshot.grabbedObjectId, snapshot.surface]);
701
+
683
702
  const statusBarPageFacts = useMemo(() => {
684
703
  const facet = props.layoutFacet;
685
704
  if (!facet) {
@@ -1662,6 +1681,10 @@ export function TwReviewWorkspace(inputProps: TwReviewWorkspaceProps) {
1662
1681
  {props.layoutFacet ? (
1663
1682
  <TwChromeOverlay
1664
1683
  facet={props.layoutFacet}
1684
+ grabbedObjectId={snapshot.grabbedObjectId ?? null}
1685
+ grabbedObjectFromOffset={grabbedSegmentOffsets?.from ?? null}
1686
+ grabbedObjectToOffset={grabbedSegmentOffsets?.to ?? null}
1687
+ onDeselectObject={props.onDeselectObject}
1665
1688
  tableContext={props.tableContext}
1666
1689
  onSetColumnWidth={props.onSetColumnWidth}
1667
1690
  onSetRowHeight={props.onSetRowHeight}