@beyondwork/docx-react-component 1.0.56 → 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.
Files changed (113) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/api/public-types.ts +330 -0
  4. package/src/compare/diff-engine.ts +3 -0
  5. package/src/core/commands/formatting-commands.ts +1 -0
  6. package/src/core/commands/index.ts +17 -11
  7. package/src/core/selection/mapping.ts +18 -1
  8. package/src/core/selection/review-anchors.ts +29 -18
  9. package/src/io/chart-preview-resolver.ts +175 -41
  10. package/src/io/docx-session.ts +57 -2
  11. package/src/io/export/serialize-main-document.ts +82 -0
  12. package/src/io/export/serialize-styles.ts +61 -3
  13. package/src/io/export/table-properties-xml.ts +19 -4
  14. package/src/io/normalize/normalize-text.ts +33 -0
  15. package/src/io/ooxml/parse-anchor.ts +182 -0
  16. package/src/io/ooxml/parse-drawing.ts +319 -0
  17. package/src/io/ooxml/parse-fields.ts +115 -2
  18. package/src/io/ooxml/parse-fill.ts +215 -0
  19. package/src/io/ooxml/parse-font-table.ts +190 -0
  20. package/src/io/ooxml/parse-footnotes.ts +52 -1
  21. package/src/io/ooxml/parse-main-document.ts +241 -1
  22. package/src/io/ooxml/parse-numbering.ts +96 -0
  23. package/src/io/ooxml/parse-picture.ts +158 -0
  24. package/src/io/ooxml/parse-settings.ts +34 -0
  25. package/src/io/ooxml/parse-shapes.ts +87 -0
  26. package/src/io/ooxml/parse-solid-fill.ts +11 -0
  27. package/src/io/ooxml/parse-styles.ts +74 -1
  28. package/src/io/ooxml/parse-theme.ts +60 -0
  29. package/src/io/paste/html-clipboard.ts +449 -0
  30. package/src/io/paste/word-clipboard.ts +5 -1
  31. package/src/legal/_document-root.ts +26 -0
  32. package/src/legal/bookmarks.ts +4 -3
  33. package/src/legal/cross-references.ts +3 -2
  34. package/src/legal/defined-terms.ts +2 -1
  35. package/src/legal/signature-blocks.ts +2 -1
  36. package/src/model/canonical-document.ts +421 -3
  37. package/src/runtime/chart/chart-model-store.ts +73 -10
  38. package/src/runtime/document-runtime.ts +760 -41
  39. package/src/runtime/document-search.ts +61 -0
  40. package/src/runtime/edit-ops/index.ts +129 -0
  41. package/src/runtime/event-refresh-hints.ts +7 -0
  42. package/src/runtime/field-resolver.ts +341 -0
  43. package/src/runtime/footnote-resolver.ts +55 -0
  44. package/src/runtime/hyperlink-color-resolver.ts +13 -10
  45. package/src/runtime/object-grab/index.ts +51 -0
  46. package/src/runtime/paragraph-style-resolver.ts +105 -0
  47. package/src/runtime/query-scopes.ts +186 -0
  48. package/src/runtime/resolved-numbering-geometry.ts +12 -0
  49. package/src/runtime/scope-resolver.ts +60 -0
  50. package/src/runtime/selection/cursor-ops.ts +186 -15
  51. package/src/runtime/selection/index.ts +17 -1
  52. package/src/runtime/structure-ops/index.ts +77 -0
  53. package/src/runtime/styles-cascade.ts +33 -0
  54. package/src/runtime/surface-projection.ts +192 -12
  55. package/src/runtime/theme-color-resolver.ts +189 -44
  56. package/src/runtime/units.ts +46 -0
  57. package/src/runtime/view-state.ts +13 -2
  58. package/src/ui/WordReviewEditor.tsx +239 -11
  59. package/src/ui/editor-runtime-boundary.ts +97 -1
  60. package/src/ui/editor-shell-view.tsx +1 -1
  61. package/src/ui/runtime-shortcut-dispatch.ts +17 -3
  62. package/src/ui-tailwind/chart/ChartSurface.tsx +36 -10
  63. package/src/ui-tailwind/chart/layout/plot-area.ts +120 -45
  64. package/src/ui-tailwind/chart/render/area.tsx +22 -4
  65. package/src/ui-tailwind/chart/render/bar-column.tsx +37 -11
  66. package/src/ui-tailwind/chart/render/bubble.tsx +6 -2
  67. package/src/ui-tailwind/chart/render/combo.tsx +37 -4
  68. package/src/ui-tailwind/chart/render/line.tsx +28 -5
  69. package/src/ui-tailwind/chart/render/pie.tsx +36 -16
  70. package/src/ui-tailwind/chart/render/progressive-render.ts +8 -1
  71. package/src/ui-tailwind/chart/render/scatter.tsx +9 -4
  72. package/src/ui-tailwind/chrome/avatar-initials.ts +15 -0
  73. package/src/ui-tailwind/chrome/tw-comment-preview.tsx +3 -1
  74. package/src/ui-tailwind/chrome/tw-context-menu.tsx +14 -0
  75. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +3 -2
  76. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +30 -11
  77. package/src/ui-tailwind/chrome/tw-shortcut-hint.tsx +15 -2
  78. package/src/ui-tailwind/chrome/tw-suggestion-card.tsx +1 -1
  79. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +24 -7
  80. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +31 -12
  81. package/src/ui-tailwind/chrome-overlay/page-border-resolver.ts +211 -0
  82. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +24 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-comment-balloon-layer.tsx +74 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-locked-block-layer.tsx +65 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-object-selection-overlay.tsx +157 -0
  86. package/src/ui-tailwind/chrome-overlay/tw-page-border-overlay.tsx +233 -0
  87. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +135 -13
  88. package/src/ui-tailwind/chrome-overlay/tw-revision-margin-bar-layer.tsx +51 -0
  89. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +12 -4
  90. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +32 -12
  91. package/src/ui-tailwind/chrome-overlay/tw-toc-outline-sidebar.tsx +133 -0
  92. package/src/ui-tailwind/editor-surface/chart-node-view.tsx +49 -10
  93. package/src/ui-tailwind/editor-surface/float-wrap-resolver.ts +119 -0
  94. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +236 -9
  95. package/src/ui-tailwind/editor-surface/pm-schema.ts +214 -11
  96. package/src/ui-tailwind/editor-surface/pm-state-from-snapshot.ts +32 -2
  97. package/src/ui-tailwind/editor-surface/shape-renderer.ts +206 -0
  98. package/src/ui-tailwind/editor-surface/surface-layer.ts +66 -0
  99. package/src/ui-tailwind/editor-surface/tw-inline-token.tsx +29 -0
  100. package/src/ui-tailwind/editor-surface/tw-segment-view.tsx +7 -1
  101. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +22 -6
  102. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +10 -16
  103. package/src/ui-tailwind/review/tw-health-panel.tsx +0 -25
  104. package/src/ui-tailwind/review/tw-rail-card.tsx +38 -17
  105. package/src/ui-tailwind/review/tw-review-rail.tsx +2 -2
  106. package/src/ui-tailwind/review/tw-revision-sidebar.tsx +5 -12
  107. package/src/ui-tailwind/review/tw-workflow-tab.tsx +2 -2
  108. package/src/ui-tailwind/theme/editor-theme.css +1 -0
  109. package/src/ui-tailwind/theme/tokens.css +6 -0
  110. package/src/ui-tailwind/theme/tokens.ts +10 -0
  111. package/src/ui-tailwind/tw-review-workspace.tsx +23 -0
  112. package/src/validation/compatibility-engine.ts +2 -0
  113. package/src/validation/docx-comment-proof.ts +12 -3
@@ -0,0 +1,51 @@
1
+ /**
2
+ * R.3 ObjectGrabLayer — runtime-side grab state for inline / floating
3
+ * objects (images, shapes). Lane 6 P11 paints the chrome handles; this
4
+ * module owns the state model so both sides reference one source of truth.
5
+ *
6
+ * Design invariants:
7
+ * 1. **Single-select.** One grabbed object at a time (matches LibreOffice
8
+ * `SwFEShell::SelectObj`); multi-select for Alt+Shift drag selection
9
+ * lands in a follow-up once user flows require it.
10
+ * 2. **Local-only, not collab-broadcast.** A remote peer's grab state is
11
+ * surfaced via remote-cursor awareness, not via this layer — each peer
12
+ * has their own grab state, matching the text-selection model.
13
+ * 3. **Pure state transitions.** Every mutation returns a new object; the
14
+ * runtime stores the state alongside selection and emits on snapshot.
15
+ * 4. **String ids, not node references.** Grab state uses `objectId` so
16
+ * the state survives snapshot replacement. The runtime resolves the id
17
+ * to the current node when it needs to dispatch image commands.
18
+ *
19
+ * What this module deliberately does NOT do in Item C:
20
+ * - Rotation / anchor-mode / z-order mutation — those are image.set-*
21
+ * commands on the existing `image-commands.ts` module; the grab layer
22
+ * is just "which object has focus". Host wires chrome interactions to
23
+ * those commands via the ref methods.
24
+ * - Hit-testing — chrome layer uses Lane 3a P9 `RenderAnchorIndex` to
25
+ * convert click coords to an object id, then calls `selectObject(id)`.
26
+ */
27
+
28
+ export interface ObjectGrabState {
29
+ /** The grabbed object's stable id (image.mediaId / shape.shapeId), or null. */
30
+ objectId: string | null;
31
+ }
32
+
33
+ export function createObjectGrabState(): ObjectGrabState {
34
+ return { objectId: null };
35
+ }
36
+
37
+ export function selectObject(state: ObjectGrabState, objectId: string): ObjectGrabState {
38
+ if (state.objectId === objectId) {
39
+ return state; // idempotent — same id, same state (reference-equal)
40
+ }
41
+ return { objectId };
42
+ }
43
+
44
+ export function deselectObject(state: ObjectGrabState): ObjectGrabState {
45
+ if (state.objectId === null) return state;
46
+ return { objectId: null };
47
+ }
48
+
49
+ export function getGrabbedObject(state: ObjectGrabState): string | null {
50
+ return state.objectId;
51
+ }
@@ -11,11 +11,13 @@
11
11
  */
12
12
 
13
13
  import type {
14
+ CanonicalFontTable,
14
15
  CanonicalParagraphFormatting,
15
16
  CanonicalRunFormatting,
16
17
  CharacterStyleDefinition,
17
18
  ParagraphStyleDefinition,
18
19
  StylesCatalog,
20
+ TableStyleDefinition,
19
21
  } from "../model/canonical-document.ts";
20
22
 
21
23
  export function resolveParagraphStyleChain(
@@ -192,3 +194,106 @@ export function resolveNumberingMarkerRunFormatting(
192
194
  acc = mergeRun(acc, input.levelRunProperties);
193
195
  return acc ?? {};
194
196
  }
197
+
198
+ /**
199
+ * Return the `w:next` style ID for `styleId` — the paragraph style Word
200
+ * applies after pressing Enter at the end of a paragraph of this style.
201
+ * Used by Lane 1's suggest-paragraph-split command.
202
+ */
203
+ export function getNextStyleId(
204
+ styleId: string,
205
+ catalog: StylesCatalog | undefined,
206
+ ): string | undefined {
207
+ return catalog?.paragraphs[styleId]?.nextStyle;
208
+ }
209
+
210
+ /**
211
+ * Resolve the effective font family name for a run.
212
+ *
213
+ * Walks the ECMA-376 §17.3.2.26 precedence for `<w:rFonts>`: `ascii` →
214
+ * `hAnsi` → `eastAsia` → `cs`. When none of those resolve on the direct
215
+ * run formatting, layers the paragraph/character style cascade for the
216
+ * same four fields, then falls back to the caller-supplied
217
+ * `themeMinorFont` (ECMA-376 default for body text).
218
+ *
219
+ * `fontTable`, when supplied, is consulted only for presence — an entry
220
+ * whose `w:name` does not appear in `fontTable.fonts` is still returned
221
+ * as-is (Word behavior: substitute at render time, not at model resolve).
222
+ * Lane 3a's measurement backend owns the substitution decision.
223
+ *
224
+ * Returns undefined only when no source in the cascade supplies a family
225
+ * name (rare — production docs always hit docDefaults or the theme).
226
+ */
227
+ export function resolveRunFontFamily(
228
+ input: RunResolveInput,
229
+ catalog: StylesCatalog | undefined,
230
+ themeMinorFont: string | undefined,
231
+ fontTable?: CanonicalFontTable | undefined,
232
+ ): string | undefined {
233
+ // `fontTable` is currently unused inside this resolver; the ECMA-376
234
+ // rFonts precedence does not require consulting the package's fontTable
235
+ // to pick a name. It is reserved so Lane 3a's measurement backend can
236
+ // layer substitution (e.g. pick a monospace fallback when the resolved
237
+ // name has `pitch === "fixed"`) by threading the same table through.
238
+ void fontTable;
239
+ const resolved = resolveEffectiveRunFormatting(input, catalog);
240
+ const name =
241
+ resolved.fontFamilyAscii ??
242
+ resolved.fontFamilyHAnsi ??
243
+ resolved.fontFamilyEastAsia ??
244
+ resolved.fontFamilyCs ??
245
+ resolved.fontFamily ??
246
+ themeMinorFont;
247
+ return name;
248
+ }
249
+
250
+ function resolveTableStyleChain(
251
+ styleId: string,
252
+ catalog: StylesCatalog | undefined,
253
+ ): TableStyleDefinition[] {
254
+ const chain: TableStyleDefinition[] = [];
255
+ if (!catalog) return chain;
256
+ const visited = new Set<string>();
257
+ let current: string | undefined = styleId;
258
+ while (current && !visited.has(current)) {
259
+ visited.add(current);
260
+ const def: TableStyleDefinition | undefined = catalog.tables[current];
261
+ if (!def) break;
262
+ chain.push(def);
263
+ current = def.basedOn;
264
+ }
265
+ return chain;
266
+ }
267
+
268
+ /**
269
+ * Resolve the paragraph + run formatting that applies to a cell's body text
270
+ * when only the table style is known (no cell-paragraph-level overrides).
271
+ *
272
+ * Cascade (lowest to highest priority):
273
+ * 1. `catalog.docDefaults.paragraph` / `.run`
274
+ * 2. Table style's `basedOn` chain, walked root-to-leaf, projecting each
275
+ * style's `formatting.paragraphProperties` / `.runProperties`.
276
+ *
277
+ * Conditional-region pPr/rPr (`<w:tblStylePr w:type="...">`) is intentionally
278
+ * NOT layered here — callers that need region-aware cell-text cascade must
279
+ * extend this once the conditional region parser captures pPr/rPr (Lane 3a
280
+ * follow-up). Per-paragraph and per-run formatting from the cell's content
281
+ * is layered by `resolveEffectiveParagraphFormatting` /
282
+ * `resolveEffectiveRunFormatting`; this function only returns the floor.
283
+ */
284
+ export function resolveTableCellTextFormatting(
285
+ tableStyleId: string | undefined,
286
+ catalog: StylesCatalog | undefined,
287
+ ): { paragraph: CanonicalParagraphFormatting; run: CanonicalRunFormatting } {
288
+ let paragraph: CanonicalParagraphFormatting | undefined = catalog?.docDefaults?.paragraph;
289
+ let run: CanonicalRunFormatting | undefined = catalog?.docDefaults?.run;
290
+ if (catalog && tableStyleId) {
291
+ const chain = resolveTableStyleChain(tableStyleId, catalog);
292
+ for (let i = chain.length - 1; i >= 0; i -= 1) {
293
+ const formatting = chain[i]!.formatting;
294
+ paragraph = mergeParagraph(paragraph, formatting?.paragraphProperties);
295
+ run = mergeRun(run, formatting?.runProperties);
296
+ }
297
+ }
298
+ return { paragraph: paragraph ?? {}, run: run ?? {} };
299
+ }
@@ -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
+ }
@@ -12,16 +12,28 @@ import type {
12
12
 
13
13
  export const DEFAULT_NUMBERING_START_AT = 1;
14
14
 
15
+ /**
16
+ * Resolved numbering geometry. **All numeric distance values are in OOXML
17
+ * twips (1/1440 in)** — the same unit `w:ind`/`w:tabs`/`w:spacing` carry on
18
+ * the parse side. Render consumers convert to display units at the boundary
19
+ * (`pt = twips / 20`, `px = twips × FRAME_PX_PER_TWIP_AT_96DPI`) — see
20
+ * `src/ui-tailwind/editor-surface/pm-schema.ts:331` and
21
+ * `tw-page-block-view.helpers.ts:179` for the canonical conversion sites.
22
+ * Do not pre-convert here; the runtime stays in OOXML units so cache keys
23
+ * and serializer round-trips stay byte-identical.
24
+ */
15
25
  export interface ResolvedNumberingGeometry {
16
26
  markerJustification?: NumberingLevelParagraphGeometry["justification"];
17
27
  spacing?: ParagraphSpacing;
18
28
  indentation?: ParagraphIndentation;
19
29
  tabStops?: TabStop[];
30
+ /** All fields in twips. */
20
31
  markerLane?: {
21
32
  start: number;
22
33
  width: number;
23
34
  textStart: number;
24
35
  };
36
+ /** All fields in twips. */
25
37
  textColumn?: {
26
38
  start: number;
27
39
  right?: number;
@@ -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
+ }
@@ -1,27 +1,27 @@
1
1
  /**
2
- * R.1 Phase 6a — SelectionLayer cursor primitives.
2
+ * R.1 SelectionLayer cursor primitives.
3
+ *
4
+ * Phase 6a (shipped) — layout-independent primitives over
5
+ * `(DocumentRootNode, CursorSelection, op) → CursorSelection` using the
6
+ * canonical story layer (`createPlainText(parseTextStory(doc))`) and
7
+ * `Intl.Segmenter` for word boundaries.
8
+ *
9
+ * Phase 6b (this module) — layout-aware primitives (`moveUp` / `moveDown` /
10
+ * `moveLineStart` / `moveLineEnd`) that consult a `RenderAnchorIndex` from
11
+ * Lane 3a P9 to resolve line boundaries. When no index is supplied the
12
+ * primitives degrade gracefully (see per-function docs).
3
13
  *
4
- * Pure functions over `(DocumentRootNode, CursorSelection, op) → CursorSelection`.
5
14
  * `extend: true` keeps the anchor fixed and moves only the head — the
6
15
  * LibreOffice `SwPaM` head/anchor split that matches Shift+arrow semantics.
7
16
  *
8
- * Positions are 0-based logical positions per the canonical story layer
9
- * (`createPlainText(parseTextStory(doc))`). Scope markers are zero-width;
10
- * paragraph breaks are 1 wide; text / tab / hard_break / image / opaque are 1
11
- * wide. The plain-text string produced by `createPlainText` is a 1:1 mapping
12
- * of those logical positions to characters, which makes position-math trivial
13
- * and makes `Intl.Segmenter` (for word boundaries) a drop-in fit.
14
- *
15
- * What this module deliberately does NOT ship yet:
16
- * - `moveUp` / `moveDown` — genuinely layout-dependent (need column tracking
17
- * + line-wrap info). Follows Phase 6b once Lane 3a P9 exposes the per-run
18
- * layout facet needed for column-preserving movement.
19
- * - `moveLineStart` / `moveLineEnd` — same; these need soft-wrap info that
20
- * the canonical story layer does not expose.
17
+ * Positions are 0-based logical positions per the canonical story layer.
18
+ * Scope markers are zero-width; paragraph breaks are 1 wide; text / tab /
19
+ * hard_break / image / opaque are 1 wide.
21
20
  */
22
21
 
23
22
  import { createPlainText, parseTextStory } from "../../core/schema/text-schema.ts";
24
23
  import type { DocumentRootNode } from "../../model/canonical-document.ts";
24
+ import type { RenderAnchorIndex, RenderFrameRect } from "../render/render-frame-types.ts";
25
25
 
26
26
  export interface CursorSelection {
27
27
  anchor: number;
@@ -35,6 +35,13 @@ export interface CursorMoveOptions {
35
35
  * anchor and head land at the new head, collapsing any range selection.
36
36
  */
37
37
  extend?: boolean;
38
+ /**
39
+ * R.1 Phase 6b — optional. Layout-aware primitives
40
+ * (`moveUp` / `moveDown` / `moveLineStart` / `moveLineEnd`) consult this
41
+ * index to resolve line boundaries via `byRuntimeOffset(offset)`. Absent
42
+ * in pure unit-test contexts; primitives degrade gracefully without it.
43
+ */
44
+ anchorIndex?: RenderAnchorIndex;
38
45
  }
39
46
 
40
47
  export function moveCharLeft(
@@ -148,6 +155,170 @@ export function moveWordLeft(
148
155
  return finalize(selection, prevBoundary, options);
149
156
  }
150
157
 
158
+ // ─── Phase 6b — layout-aware primitives ────────────────────────────────────
159
+
160
+ /**
161
+ * Move the head to the character on the next line at the nearest column X
162
+ * to the current head. Requires an anchor index; without one, this is a
163
+ * no-op (graceful degradation for pure unit-test contexts).
164
+ *
165
+ * Algorithm: probe the current head's rect for its `topPx` + `leftPx` (the
166
+ * anchor column). Scan forward through offsets; the first offset whose
167
+ * rect has a strictly-greater `topPx` marks the start of the next line.
168
+ * From that offset, continue while `topPx` matches the next-line top,
169
+ * tracking which offset has the smallest `|leftPx - anchorLeftPx|`.
170
+ */
171
+ export function moveDown(
172
+ doc: DocumentRootNode,
173
+ selection: CursorSelection,
174
+ options: CursorMoveOptions = {},
175
+ ): CursorSelection {
176
+ const index = options.anchorIndex;
177
+ if (!index) return finalize(selection, selection.head, options);
178
+
179
+ const text = documentPlainText(doc);
180
+ const startHead = options.extend
181
+ ? selection.head
182
+ : Math.max(selection.anchor, selection.head);
183
+ const currentRect = index.byRuntimeOffset(startHead);
184
+ if (!currentRect) return finalize(selection, startHead, options);
185
+
186
+ const anchorLeft = currentRect.leftPx;
187
+ const currentTop = currentRect.topPx;
188
+
189
+ // Find first offset with a different (higher) topPx.
190
+ let nextLineStart = -1;
191
+ let nextLineTop = Number.POSITIVE_INFINITY;
192
+ for (let i = startHead + 1; i <= text.length; i += 1) {
193
+ const r = index.byRuntimeOffset(i);
194
+ if (!r) continue;
195
+ if (r.topPx > currentTop) {
196
+ nextLineStart = i;
197
+ nextLineTop = r.topPx;
198
+ break;
199
+ }
200
+ }
201
+ if (nextLineStart < 0) return finalize(selection, startHead, options);
202
+
203
+ // Scan the next line for the offset with leftPx closest to anchorLeft.
204
+ let bestOffset = nextLineStart;
205
+ let bestDelta = Math.abs((index.byRuntimeOffset(nextLineStart)?.leftPx ?? 0) - anchorLeft);
206
+ for (let i = nextLineStart + 1; i <= text.length; i += 1) {
207
+ const r = index.byRuntimeOffset(i);
208
+ if (!r || r.topPx !== nextLineTop) break;
209
+ const delta = Math.abs(r.leftPx - anchorLeft);
210
+ if (delta < bestDelta) {
211
+ bestDelta = delta;
212
+ bestOffset = i;
213
+ }
214
+ }
215
+ return finalize(selection, bestOffset, options);
216
+ }
217
+
218
+ /** Mirror of `moveDown`, scanning upward. */
219
+ export function moveUp(
220
+ doc: DocumentRootNode,
221
+ selection: CursorSelection,
222
+ options: CursorMoveOptions = {},
223
+ ): CursorSelection {
224
+ const index = options.anchorIndex;
225
+ if (!index) return finalize(selection, selection.head, options);
226
+
227
+ const startHead = options.extend
228
+ ? selection.head
229
+ : Math.min(selection.anchor, selection.head);
230
+ const currentRect = index.byRuntimeOffset(startHead);
231
+ if (!currentRect) return finalize(selection, startHead, options);
232
+
233
+ const anchorLeft = currentRect.leftPx;
234
+ const currentTop = currentRect.topPx;
235
+
236
+ let prevLineEnd = -1;
237
+ let prevLineTop = Number.NEGATIVE_INFINITY;
238
+ for (let i = startHead - 1; i >= 0; i -= 1) {
239
+ const r = index.byRuntimeOffset(i);
240
+ if (!r) continue;
241
+ if (r.topPx < currentTop) {
242
+ prevLineEnd = i;
243
+ prevLineTop = r.topPx;
244
+ break;
245
+ }
246
+ }
247
+ if (prevLineEnd < 0) return finalize(selection, startHead, options);
248
+
249
+ let bestOffset = prevLineEnd;
250
+ let bestDelta = Math.abs((index.byRuntimeOffset(prevLineEnd)?.leftPx ?? 0) - anchorLeft);
251
+ for (let i = prevLineEnd - 1; i >= 0; i -= 1) {
252
+ const r = index.byRuntimeOffset(i);
253
+ if (!r || r.topPx !== prevLineTop) break;
254
+ const delta = Math.abs(r.leftPx - anchorLeft);
255
+ if (delta < bestDelta) {
256
+ bestDelta = delta;
257
+ bestOffset = i;
258
+ }
259
+ }
260
+ return finalize(selection, bestOffset, options);
261
+ }
262
+
263
+ /**
264
+ * Move the head to the first offset on the current visual line. Requires an
265
+ * anchor index; without one, collapses to document start (offset 0), which
266
+ * is a common keyboard fallback (matches "Home" at the start of an unwrapped
267
+ * document).
268
+ */
269
+ export function moveLineStart(
270
+ doc: DocumentRootNode,
271
+ selection: CursorSelection,
272
+ options: CursorMoveOptions = {},
273
+ ): CursorSelection {
274
+ const index = options.anchorIndex;
275
+ if (!index) return finalize(selection, 0, options);
276
+
277
+ const startHead = selection.head;
278
+ const currentRect = index.byRuntimeOffset(startHead);
279
+ if (!currentRect) return finalize(selection, startHead, options);
280
+
281
+ const currentTop = currentRect.topPx;
282
+ let lineStart = startHead;
283
+ for (let i = startHead - 1; i >= 0; i -= 1) {
284
+ const r = index.byRuntimeOffset(i);
285
+ if (!r) continue;
286
+ if (r.topPx !== currentTop) break;
287
+ lineStart = i;
288
+ }
289
+ return finalize(selection, lineStart, options);
290
+ }
291
+
292
+ /**
293
+ * Move the head to the last offset on the current visual line. Without an
294
+ * anchor index, collapses to document end.
295
+ */
296
+ export function moveLineEnd(
297
+ doc: DocumentRootNode,
298
+ selection: CursorSelection,
299
+ options: CursorMoveOptions = {},
300
+ ): CursorSelection {
301
+ const text = documentPlainText(doc);
302
+ const index = options.anchorIndex;
303
+ if (!index) return finalize(selection, text.length, options);
304
+
305
+ const startHead = selection.head;
306
+ const currentRect = index.byRuntimeOffset(startHead);
307
+ if (!currentRect) return finalize(selection, startHead, options);
308
+
309
+ const currentTop = currentRect.topPx;
310
+ let lineEnd = startHead;
311
+ for (let i = startHead + 1; i <= text.length; i += 1) {
312
+ const r = index.byRuntimeOffset(i);
313
+ if (!r) continue;
314
+ if (r.topPx !== currentTop) break;
315
+ lineEnd = i;
316
+ }
317
+ return finalize(selection, lineEnd, options);
318
+ }
319
+
320
+ // ─── Helpers ───────────────────────────────────────────────────────────────
321
+
151
322
  function finalize(
152
323
  selection: CursorSelection,
153
324
  head: number,