@beyondwork/docx-react-component 1.0.36 → 1.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.md +103 -13
  2. package/package.json +1 -1
  3. package/src/api/package-version.ts +13 -0
  4. package/src/api/public-types.ts +402 -1
  5. package/src/core/commands/index.ts +18 -1
  6. package/src/core/commands/section-layout-commands.ts +58 -0
  7. package/src/core/commands/table-grid.ts +431 -0
  8. package/src/core/commands/table-structure-commands.ts +815 -55
  9. package/src/core/selection/mapping.ts +6 -0
  10. package/src/io/docx-session.ts +24 -9
  11. package/src/io/export/build-app-properties-xml.ts +88 -0
  12. package/src/io/export/serialize-comments.ts +6 -1
  13. package/src/io/export/serialize-footnotes.ts +10 -9
  14. package/src/io/export/serialize-headers-footers.ts +11 -10
  15. package/src/io/export/serialize-main-document.ts +328 -50
  16. package/src/io/export/serialize-numbering.ts +114 -24
  17. package/src/io/export/serialize-tables.ts +87 -11
  18. package/src/io/export/table-properties-xml.ts +174 -20
  19. package/src/io/export/twip.ts +66 -0
  20. package/src/io/normalize/normalize-text.ts +20 -0
  21. package/src/io/ooxml/parse-footnotes.ts +62 -1
  22. package/src/io/ooxml/parse-headers-footers.ts +62 -1
  23. package/src/io/ooxml/parse-main-document.ts +158 -1
  24. package/src/io/ooxml/parse-tables.ts +249 -0
  25. package/src/legal/bookmarks.ts +78 -0
  26. package/src/model/canonical-document.ts +45 -0
  27. package/src/review/store/scope-tag-diff.ts +130 -0
  28. package/src/runtime/document-layout.ts +4 -2
  29. package/src/runtime/document-navigation.ts +2 -306
  30. package/src/runtime/document-runtime.ts +287 -11
  31. package/src/runtime/layout/default-page-format.ts +96 -0
  32. package/src/runtime/layout/docx-font-loader.ts +143 -0
  33. package/src/runtime/layout/index.ts +233 -0
  34. package/src/runtime/layout/inert-layout-facet.ts +59 -0
  35. package/src/runtime/layout/layout-engine-instance.ts +628 -0
  36. package/src/runtime/layout/layout-invalidation.ts +257 -0
  37. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  38. package/src/runtime/layout/margin-preset-catalog.ts +178 -0
  39. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  40. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  41. package/src/runtime/layout/page-format-catalog.ts +233 -0
  42. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  43. package/src/runtime/layout/page-graph.ts +452 -0
  44. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  45. package/src/runtime/layout/page-story-resolver.ts +195 -0
  46. package/src/runtime/layout/paginated-layout-engine.ts +921 -0
  47. package/src/runtime/layout/project-block-fragments.ts +91 -0
  48. package/src/runtime/layout/public-facet.ts +1398 -0
  49. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  50. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  51. package/src/runtime/layout/table-render-plan.ts +229 -0
  52. package/src/runtime/render/block-fragment-projection.ts +35 -0
  53. package/src/runtime/render/decoration-resolver.ts +189 -0
  54. package/src/runtime/render/index.ts +57 -0
  55. package/src/runtime/render/pending-op-delta-reader.ts +129 -0
  56. package/src/runtime/render/render-frame-types.ts +317 -0
  57. package/src/runtime/render/render-kernel.ts +755 -0
  58. package/src/runtime/scope-tag-registry.ts +95 -0
  59. package/src/runtime/surface-projection.ts +1 -0
  60. package/src/runtime/text-ack-range.ts +49 -0
  61. package/src/runtime/view-state.ts +67 -0
  62. package/src/runtime/workflow-markup.ts +1 -5
  63. package/src/runtime/workflow-rail-segments.ts +280 -0
  64. package/src/ui/WordReviewEditor.tsx +99 -15
  65. package/src/ui/editor-runtime-boundary.ts +10 -1
  66. package/src/ui/editor-shell-view.tsx +6 -0
  67. package/src/ui/editor-surface-controller.tsx +3 -0
  68. package/src/ui/headless/chrome-registry.ts +501 -0
  69. package/src/ui/headless/scoped-chrome-policy.ts +183 -0
  70. package/src/ui/headless/selection-tool-context.ts +2 -0
  71. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  72. package/src/ui/headless/selection-tool-types.ts +10 -0
  73. package/src/ui-tailwind/chrome/chrome-preset-model.ts +23 -2
  74. package/src/ui-tailwind/chrome/role-action-sets.ts +74 -0
  75. package/src/ui-tailwind/chrome/tw-detach-handle.tsx +147 -0
  76. package/src/ui-tailwind/chrome/tw-selection-anchor-resolver.ts +163 -0
  77. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +57 -92
  78. package/src/ui-tailwind/chrome/tw-selection-tool-placement.ts +149 -0
  79. package/src/ui-tailwind/chrome/tw-selection-toolbar.tsx +15 -4
  80. package/src/ui-tailwind/chrome/tw-table-context-toolbar.tsx +274 -138
  81. package/src/ui-tailwind/chrome-overlay/chrome-overlay-projector.ts +90 -0
  82. package/src/ui-tailwind/chrome-overlay/index.ts +22 -0
  83. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +86 -0
  84. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +178 -0
  85. package/src/ui-tailwind/chrome-overlay/tw-workspace-view-switcher.tsx +95 -0
  86. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +337 -0
  87. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +100 -0
  88. package/src/ui-tailwind/editor-surface/perf-probe.ts +27 -1
  89. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +20 -2
  90. package/src/ui-tailwind/editor-surface/pm-decorations.ts +93 -23
  91. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  92. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  93. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  94. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +176 -6
  95. package/src/ui-tailwind/index.ts +33 -0
  96. package/src/ui-tailwind/review/tw-comment-sidebar.tsx +2 -2
  97. package/src/ui-tailwind/review/tw-rail-card.tsx +150 -0
  98. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +52 -0
  99. package/src/ui-tailwind/review/tw-review-rail.tsx +166 -11
  100. package/src/ui-tailwind/review/tw-workflow-tab.tsx +108 -0
  101. package/src/ui-tailwind/theme/editor-theme.css +505 -144
  102. package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +559 -0
  103. package/src/ui-tailwind/toolbar/tw-scope-posture-menu.tsx +182 -0
  104. package/src/ui-tailwind/toolbar/tw-shell-header.tsx +162 -0
  105. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  106. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +304 -166
  107. package/src/ui-tailwind/tw-review-workspace.tsx +163 -2
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Scope tag registry — describes how each annotation family responds to
3
+ * ordinary text edits. The predicted-text lane uses this registry to:
4
+ * - classify an `adjusted` ack (comment anchor extended, revision trimmed)
5
+ * - decide whether to bail on prediction when an unknown tag is in range
6
+ * - drive decoration redraws after a reconciled commit
7
+ *
8
+ * Known annotation families ship as `DEFAULT_REGISTRY_ENTRIES`. Host
9
+ * integrations can register additional families; unknown families default
10
+ * to "bailIfCrossed" so the lane falls back to the canonical round-trip.
11
+ */
12
+
13
+ export interface ScopeTagBehavior {
14
+ /** Inserting text at the left edge extends the tag leftward. */
15
+ extendOnInsertLeft: boolean;
16
+ /** Inserting text at the right edge extends the tag rightward. */
17
+ extendOnInsertRight: boolean;
18
+ /** Deleting a tag edge trims the tag boundary. */
19
+ trimOnDelete: boolean;
20
+ /** Any edit that crosses this tag must abort prediction and rebuild canonically. */
21
+ bailIfCrossed: boolean;
22
+ }
23
+
24
+ export const DEFAULT_UNKNOWN_BEHAVIOR: ScopeTagBehavior = {
25
+ extendOnInsertLeft: false,
26
+ extendOnInsertRight: false,
27
+ trimOnDelete: false,
28
+ bailIfCrossed: true,
29
+ };
30
+
31
+ export const DEFAULT_REGISTRY_ENTRIES: Readonly<Record<string, ScopeTagBehavior>> = {
32
+ comment: {
33
+ extendOnInsertLeft: true,
34
+ extendOnInsertRight: true,
35
+ trimOnDelete: true,
36
+ bailIfCrossed: false,
37
+ },
38
+ revision: {
39
+ extendOnInsertLeft: true,
40
+ extendOnInsertRight: true,
41
+ trimOnDelete: true,
42
+ bailIfCrossed: false,
43
+ },
44
+ field: {
45
+ extendOnInsertLeft: false,
46
+ extendOnInsertRight: false,
47
+ trimOnDelete: false,
48
+ bailIfCrossed: true,
49
+ },
50
+ bookmark: {
51
+ extendOnInsertLeft: true,
52
+ extendOnInsertRight: true,
53
+ trimOnDelete: true,
54
+ bailIfCrossed: false,
55
+ },
56
+ sdt: {
57
+ extendOnInsertLeft: false,
58
+ extendOnInsertRight: false,
59
+ trimOnDelete: false,
60
+ bailIfCrossed: true,
61
+ },
62
+ opaque: {
63
+ extendOnInsertLeft: false,
64
+ extendOnInsertRight: false,
65
+ trimOnDelete: false,
66
+ bailIfCrossed: true,
67
+ },
68
+ };
69
+
70
+ export interface ScopeTagRegistry {
71
+ get(tagType: string): ScopeTagBehavior;
72
+ register(tagType: string, behavior: ScopeTagBehavior): void;
73
+ has(tagType: string): boolean;
74
+ list(): ReadonlyArray<readonly [string, ScopeTagBehavior]>;
75
+ }
76
+
77
+ export function createScopeTagRegistry(): ScopeTagRegistry {
78
+ const entries = new Map<string, ScopeTagBehavior>(
79
+ Object.entries(DEFAULT_REGISTRY_ENTRIES),
80
+ );
81
+ return {
82
+ get(tagType) {
83
+ return entries.get(tagType) ?? DEFAULT_UNKNOWN_BEHAVIOR;
84
+ },
85
+ register(tagType, behavior) {
86
+ entries.set(tagType, behavior);
87
+ },
88
+ has(tagType) {
89
+ return entries.has(tagType);
90
+ },
91
+ list() {
92
+ return Array.from(entries.entries());
93
+ },
94
+ };
95
+ }
@@ -553,6 +553,7 @@ function createParagraphBlock(
553
553
  ...(paragraph.keepNext ? { keepNext: true } : {}),
554
554
  ...(paragraph.keepLines ? { keepLines: true } : {}),
555
555
  ...(paragraph.pageBreakBefore ? { pageBreakBefore: true } : {}),
556
+ ...(paragraph.widowControl !== undefined ? { widowControl: paragraph.widowControl } : {}),
556
557
  ...(paragraph.outlineLevel !== undefined ? { outlineLevel: paragraph.outlineLevel } : {}),
557
558
  ...(paragraph.bidi ? { bidi: true } : {}),
558
559
  ...(paragraph.suppressLineNumbers ? { suppressLineNumbers: true } : {}),
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Computes the post-edit range that the runtime's text-command ack reports
3
+ * as `adjustedRange`. The semantics: the union of all step post-edit ranges
4
+ * (where each step covers `[step.from, step.from + step.insertSize]` in the
5
+ * new document). When the transaction has no steps (e.g., a non-main-story
6
+ * `document.replace` that uses `createEmptyMapping()`), fall back to the
7
+ * normalized prior selection range so consumers still receive a meaningful
8
+ * pointer.
9
+ *
10
+ * Note: only `step.from` and `step.insertSize` are considered; `step.to`
11
+ * (the pre-edit end of the replaced range) is intentionally ignored. A pure
12
+ * delete (`insertSize === 0`) therefore yields a zero-width range at the
13
+ * post-delete cursor position, not the pre-edit affected span. If a consumer
14
+ * needs the source span of a delete, derive it from `step.to` directly.
15
+ */
16
+ export interface PriorSelectionRange {
17
+ from: number;
18
+ to: number;
19
+ }
20
+
21
+ export interface TextAckStepShape {
22
+ from: number;
23
+ insertSize: number;
24
+ }
25
+
26
+ export interface AdjustedRuntimeRange {
27
+ fromRuntime: number;
28
+ toRuntime: number;
29
+ }
30
+
31
+ export function computeAdjustedRange(
32
+ priorSelection: PriorSelectionRange,
33
+ steps: readonly TextAckStepShape[],
34
+ ): AdjustedRuntimeRange {
35
+ if (steps.length === 0) {
36
+ return {
37
+ fromRuntime: Math.min(priorSelection.from, priorSelection.to),
38
+ toRuntime: Math.max(priorSelection.from, priorSelection.to),
39
+ };
40
+ }
41
+ let from = Infinity;
42
+ let to = -Infinity;
43
+ for (const step of steps) {
44
+ if (step.from < from) from = step.from;
45
+ const stepEnd = step.from + step.insertSize;
46
+ if (stepEnd > to) to = stepEnd;
47
+ }
48
+ return { fromRuntime: from, toRuntime: to };
49
+ }
@@ -19,13 +19,17 @@ import type {
19
19
  ActiveListContext,
20
20
  ActiveNoteContext,
21
21
  CaretAffinity,
22
+ ChromePinsState,
23
+ ChromePinSurface,
22
24
  DocumentMode,
25
+ EditorRole,
23
26
  EditorStoryTarget,
24
27
  EditorSurfaceSnapshot,
25
28
  EditorViewStateSnapshot,
26
29
  LayoutMeasurement,
27
30
  PageLayoutSnapshot,
28
31
  PageRegionHitTest,
32
+ PinState,
29
33
  SelectionSnapshot,
30
34
  SurfaceBlockSnapshot,
31
35
  SurfaceInlineSegment,
@@ -44,6 +48,20 @@ export interface ViewState {
44
48
  caretAffinity: CaretAffinity;
45
49
  activePageRegion: PageRegionHitTest | null;
46
50
  activeObjectFrame: LayoutMeasurement["objectFrame"] | null;
51
+ /**
52
+ * Role-scoped chrome dimension (spec §6.4). Host apps drive the role via
53
+ * `setEditorRole`; the mounted shell reads this to pick a per-role
54
+ * toolbar action set. Independent of `viewMode` — one user session may
55
+ * start in "review" role and switch to "editor" without changing
56
+ * workspace or document mode.
57
+ */
58
+ editorRole: EditorRole;
59
+ /**
60
+ * Pin state for detachable chrome surfaces (topnav, selection tier).
61
+ * Lives here so it survives snapshot rebuilds within one session.
62
+ * Absent key ⇒ docked default.
63
+ */
64
+ chromePins: ChromePinsState;
47
65
  }
48
66
 
49
67
  const MIN_ZOOM_PERCENT = 50;
@@ -58,6 +76,8 @@ const DEFAULT_VIEW_STATE: ViewState = {
58
76
  caretAffinity: "none",
59
77
  activePageRegion: null,
60
78
  activeObjectFrame: null,
79
+ editorRole: "editor",
80
+ chromePins: {},
61
81
  };
62
82
 
63
83
  export function createViewState(initial?: Partial<ViewState>): ViewState {
@@ -113,6 +133,37 @@ export function setActiveObjectFrame(
113
133
  return { ...state, activeObjectFrame: frame };
114
134
  }
115
135
 
136
+ export function setEditorRole(state: ViewState, role: EditorRole): ViewState {
137
+ if (state.editorRole === role) return state;
138
+ return { ...state, editorRole: role };
139
+ }
140
+
141
+ export function setChromePin(
142
+ state: ViewState,
143
+ surface: ChromePinSurface,
144
+ pin: PinState | null,
145
+ ): ViewState {
146
+ const next: ChromePinsState = { ...state.chromePins };
147
+ if (pin === null) {
148
+ if (next[surface] === undefined) {
149
+ return state;
150
+ }
151
+ delete next[surface];
152
+ } else {
153
+ const current = next[surface];
154
+ if (
155
+ current &&
156
+ current.detached === pin.detached &&
157
+ current.offset.x === pin.offset.x &&
158
+ current.offset.y === pin.offset.y
159
+ ) {
160
+ return state;
161
+ }
162
+ next[surface] = pin;
163
+ }
164
+ return { ...state, chromePins: next };
165
+ }
166
+
116
167
  /**
117
168
  * Derive list context from the surface block at the current selection head.
118
169
  */
@@ -219,9 +270,25 @@ export function createEditorViewStateSnapshot(
219
270
  activeObjectFrame: derivedViewState.activeObjectFrame,
220
271
  measurement,
221
272
  isFocused: derivedViewState.isFocused,
273
+ editorRole: derivedViewState.editorRole,
274
+ chromePins: cloneChromePins(derivedViewState.chromePins),
222
275
  };
223
276
  }
224
277
 
278
+ function cloneChromePins(pins: ChromePinsState): ChromePinsState {
279
+ const out: ChromePinsState = {};
280
+ for (const key of Object.keys(pins) as ChromePinSurface[]) {
281
+ const pin = pins[key];
282
+ if (pin) {
283
+ out[key] = {
284
+ detached: pin.detached,
285
+ offset: { x: pin.offset.x, y: pin.offset.y },
286
+ };
287
+ }
288
+ }
289
+ return out;
290
+ }
291
+
225
292
  // ---------------------------------------------------------------------------
226
293
  // Internal helpers
227
294
  // ---------------------------------------------------------------------------
@@ -341,11 +341,7 @@ function collectOpaqueFragmentMarkup(
341
341
  const seen = new Set(existing.map((item) => item.fragmentId));
342
342
 
343
343
  return Object.values(preservation.opaqueFragments)
344
- .filter(
345
- (fragment) =>
346
- !seen.has(fragment.fragmentId)
347
- && fragment.packagePartName === "/word/document.xml",
348
- )
344
+ .filter((fragment) => !seen.has(fragment.fragmentId))
349
345
  .map((fragment) => {
350
346
  const descriptor = describeOpaqueFragment(fragment);
351
347
  const blockedReasonCode = isBlockedImportFeatureKey(descriptor.featureKey)
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Workflow rail-segment projection.
3
+ *
4
+ * Per runtime-rendering-and-chrome-phase.md §5, the action rail v2 renders
5
+ * OUTSIDE the PM NodeView tree as an overlay layer positioned from
6
+ * canonical scope data. This module joins the host-supplied
7
+ * `WorkflowOverlay` (scopes, candidates) + blocked-reason ranges +
8
+ * locked-zone data with the runtime page graph to produce
9
+ * `ScopeRailSegment[]` — the shape chrome consumes to render the
10
+ * left-gutter label column and the flat block-tint.
11
+ *
12
+ * The segments are pure reads over canonical state; no DOM, no PM.
13
+ */
14
+
15
+ import type {
16
+ EditorAnchorProjection,
17
+ EditorStoryTarget,
18
+ WorkflowBlockedCommandReason,
19
+ WorkflowCandidateRange,
20
+ WorkflowLockedZone,
21
+ WorkflowScope,
22
+ } from "../api/public-types";
23
+ import { MAIN_STORY_TARGET, storyTargetsEqual } from "../core/selection/mapping.ts";
24
+ import type { RuntimePageGraph } from "./layout/page-graph.ts";
25
+ import type { RenderFrameRect } from "./render/render-frame-types.ts";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public shape
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export type ScopeRailPosture =
32
+ | "edit"
33
+ | "suggest"
34
+ | "comment"
35
+ | "view"
36
+ | "candidate"
37
+ | "preserve-only"
38
+ | "blocked-import";
39
+
40
+ export interface ScopeRailSegment {
41
+ /** Identifier the chrome uses to sync with the Workflow rail tab. */
42
+ scopeId: string;
43
+ /** Visual+accessibility posture keyed off the scope mode or block reason. */
44
+ posture: ScopeRailPosture;
45
+ /** Human label; empty string when none was supplied by the host. */
46
+ label: string;
47
+ /** Runtime offsets (inclusive from, exclusive to) this segment spans on the active story. */
48
+ fromOffset: number;
49
+ toOffset: number;
50
+ /** Story the segment sits on. */
51
+ storyTarget: EditorStoryTarget;
52
+ /** Page index the segment renders on (may span multiple pages; emitted per page). */
53
+ pageIndex: number;
54
+ /** Section index derived from the page graph. */
55
+ sectionIndex: number;
56
+ /** True when the scope is the active work item. */
57
+ isActiveWorkItem: boolean;
58
+ /**
59
+ * Body-tint rect in overlay-space pixels, populated when the render kernel
60
+ * is available. Chrome consumers read this directly instead of
61
+ * re-projecting per render via the overlay projector. `null` when the
62
+ * segment is produced without a kernel (e.g., in tests or before the
63
+ * facet is bound to a page graph) — consumers fall back to per-render
64
+ * anchor resolution in that case.
65
+ */
66
+ bodyTintRect: RenderFrameRect | null;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Collector input
71
+ // ---------------------------------------------------------------------------
72
+
73
+ export interface CollectScopeRailSegmentsInput {
74
+ scopes: readonly WorkflowScope[] | undefined;
75
+ candidates?: readonly WorkflowCandidateRange[];
76
+ blockedReasons?: readonly WorkflowBlockedCommandReason[];
77
+ lockedZones?: readonly WorkflowLockedZone[];
78
+ activeWorkItemScopeIds?: readonly string[];
79
+ /** Active story scopes render on; segments for other stories are skipped. */
80
+ activeStory?: EditorStoryTarget;
81
+ pageGraph: RuntimePageGraph;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Entry point
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Build segments for every page in the graph. Callers that only want a
90
+ * single page can filter by `segment.pageIndex` on the returned list.
91
+ */
92
+ export function collectScopeRailSegments(
93
+ input: CollectScopeRailSegmentsInput,
94
+ ): ScopeRailSegment[] {
95
+ const segments: ScopeRailSegment[] = [];
96
+ const activeStory = input.activeStory ?? MAIN_STORY_TARGET;
97
+ const activeIds = new Set(input.activeWorkItemScopeIds ?? []);
98
+
99
+ for (const scope of input.scopes ?? []) {
100
+ const range = anchorToRuntimeRange(scope.anchor);
101
+ if (!range) continue;
102
+ const storyTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
103
+ if (!storyTargetsEqual(storyTarget, activeStory)) continue;
104
+
105
+ const posture = resolveScopePosture(scope);
106
+ const isActiveWorkItem = activeIds.has(scope.scopeId);
107
+ for (const page of pagesCoveringRange(input.pageGraph, range.from, range.to)) {
108
+ const { from, to } = clipRangeToPage(range.from, range.to, page);
109
+ if (from >= to) continue;
110
+ segments.push({
111
+ scopeId: scope.scopeId,
112
+ posture,
113
+ label: scope.label ?? "",
114
+ fromOffset: from,
115
+ toOffset: to,
116
+ storyTarget,
117
+ pageIndex: page.pageIndex,
118
+ sectionIndex: page.sectionIndex,
119
+ isActiveWorkItem,
120
+ bodyTintRect: null,
121
+ });
122
+ }
123
+ }
124
+
125
+ // Candidates render as a faint "candidate" posture so the reader knows
126
+ // where the host is proposing scopes before they're committed.
127
+ for (const candidate of input.candidates ?? []) {
128
+ const range = anchorToRuntimeRange(candidate.anchor);
129
+ if (!range) continue;
130
+ const storyTarget = candidate.storyTarget ?? MAIN_STORY_TARGET;
131
+ if (!storyTargetsEqual(storyTarget, activeStory)) continue;
132
+
133
+ for (const page of pagesCoveringRange(input.pageGraph, range.from, range.to)) {
134
+ const { from, to } = clipRangeToPage(range.from, range.to, page);
135
+ if (from >= to) continue;
136
+ segments.push({
137
+ scopeId: candidate.candidateId,
138
+ posture: "candidate",
139
+ label: candidate.label ?? "",
140
+ fromOffset: from,
141
+ toOffset: to,
142
+ storyTarget,
143
+ pageIndex: page.pageIndex,
144
+ sectionIndex: page.sectionIndex,
145
+ isActiveWorkItem: false,
146
+ bodyTintRect: null,
147
+ });
148
+ }
149
+ }
150
+
151
+ // Blocked-reason anchors: render a "blocked-import" or "preserve-only"
152
+ // posture so the rail signal matches the chrome message in image copy.png.
153
+ for (const reason of input.blockedReasons ?? []) {
154
+ if (!reason.anchor) continue;
155
+ const range = anchorToRuntimeRange(reason.anchor);
156
+ if (!range) continue;
157
+ const storyTarget = reason.storyTarget ?? MAIN_STORY_TARGET;
158
+ if (!storyTargetsEqual(storyTarget, activeStory)) continue;
159
+
160
+ const posture: ScopeRailPosture =
161
+ reason.code === "workflow_blocked_import"
162
+ ? "blocked-import"
163
+ : reason.code === "workflow_preserve_only"
164
+ ? "preserve-only"
165
+ : "view";
166
+ for (const page of pagesCoveringRange(input.pageGraph, range.from, range.to)) {
167
+ const { from, to } = clipRangeToPage(range.from, range.to, page);
168
+ if (from >= to) continue;
169
+ segments.push({
170
+ scopeId: `blocked:${reason.code}:${range.from}-${range.to}`,
171
+ posture,
172
+ label: reason.label ?? reason.message ?? "",
173
+ fromOffset: from,
174
+ toOffset: to,
175
+ storyTarget,
176
+ pageIndex: page.pageIndex,
177
+ sectionIndex: page.sectionIndex,
178
+ isActiveWorkItem: false,
179
+ bodyTintRect: null,
180
+ });
181
+ }
182
+ }
183
+
184
+ // Locked zones project as their own preserve-only / blocked-import rails so
185
+ // locked cells, locked images, and locked runs show the lock posture in
186
+ // the gutter even without a workflow scope covering them.
187
+ for (const zone of input.lockedZones ?? []) {
188
+ const range = anchorToRuntimeRange(zone.anchor);
189
+ if (!range) continue;
190
+ const storyTarget = zone.storyTarget ?? MAIN_STORY_TARGET;
191
+ if (!storyTargetsEqual(storyTarget, activeStory)) continue;
192
+
193
+ const posture: ScopeRailPosture =
194
+ zone.code === "workflow_blocked_import"
195
+ ? "blocked-import"
196
+ : "preserve-only";
197
+ for (const page of pagesCoveringRange(input.pageGraph, range.from, range.to)) {
198
+ const { from, to } = clipRangeToPage(range.from, range.to, page);
199
+ if (from >= to) continue;
200
+ segments.push({
201
+ scopeId: zone.fragmentId ?? `locked:${range.from}-${range.to}`,
202
+ posture,
203
+ label: zone.label ?? "",
204
+ fromOffset: from,
205
+ toOffset: to,
206
+ storyTarget,
207
+ pageIndex: page.pageIndex,
208
+ sectionIndex: page.sectionIndex,
209
+ isActiveWorkItem: false,
210
+ bodyTintRect: null,
211
+ });
212
+ }
213
+ }
214
+
215
+ return segments;
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Internals
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function anchorToRuntimeRange(
223
+ anchor: EditorAnchorProjection,
224
+ ): { from: number; to: number } | null {
225
+ if (anchor.kind === "range") {
226
+ const from = Math.min(anchor.from, anchor.to);
227
+ const to = Math.max(anchor.from, anchor.to);
228
+ return from < to ? { from, to } : null;
229
+ }
230
+ if (anchor.kind === "node") {
231
+ return { from: anchor.at, to: anchor.at + 1 };
232
+ }
233
+ // detached anchors cannot be rendered; use the last-known range as a
234
+ // best-effort ghost until the scope is re-anchored or dismissed.
235
+ if (anchor.lastKnownRange) {
236
+ const from = Math.min(anchor.lastKnownRange.from, anchor.lastKnownRange.to);
237
+ const to = Math.max(anchor.lastKnownRange.from, anchor.lastKnownRange.to);
238
+ return from < to ? { from, to } : null;
239
+ }
240
+ return null;
241
+ }
242
+
243
+ function pagesCoveringRange(
244
+ graph: RuntimePageGraph,
245
+ from: number,
246
+ to: number,
247
+ ): RuntimePageGraph["pages"] {
248
+ return graph.pages.filter(
249
+ (page) =>
250
+ !page.isBlankFiller &&
251
+ page.endOffset > from &&
252
+ page.startOffset < to,
253
+ );
254
+ }
255
+
256
+ function clipRangeToPage(
257
+ from: number,
258
+ to: number,
259
+ page: RuntimePageGraph["pages"][number],
260
+ ): { from: number; to: number } {
261
+ return {
262
+ from: Math.max(from, page.startOffset),
263
+ to: Math.min(to, page.endOffset),
264
+ };
265
+ }
266
+
267
+ function resolveScopePosture(scope: WorkflowScope): ScopeRailPosture {
268
+ switch (scope.mode) {
269
+ case "edit":
270
+ return "edit";
271
+ case "suggest":
272
+ return "suggest";
273
+ case "comment":
274
+ return "comment";
275
+ case "view":
276
+ return "view";
277
+ default:
278
+ return "view";
279
+ }
280
+ }