@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,100 @@
1
+ import type { EditorState } from "prosemirror-state";
2
+
3
+ /**
4
+ * LocalEditSessionState — internal to the mounted surface.
5
+ *
6
+ * Tracks the current canonical revision token, any predicted text ops that
7
+ * have been dispatched locally but not yet reconciled, and a pre-image per op
8
+ * so the lane can roll back on a `rejected` or `structural-divergence` ack.
9
+ *
10
+ * The lane owns all mutation of this state. No React state, no context, no
11
+ * event emission — purely a synchronous bookkeeping ledger.
12
+ */
13
+
14
+ export interface PredictedPreImagePM {
15
+ /** Captured PM state BEFORE the predicted tx. Restored via view.updateState on rollback. */
16
+ preState: EditorState;
17
+ }
18
+
19
+ export type PredictedIntent =
20
+ | { kind: "text.insert"; text: string }
21
+ | { kind: "text.delete-backward" }
22
+ | { kind: "text.delete-forward" }
23
+ | { kind: "paragraph.split" }
24
+ | { kind: "text.insert-hard-break" };
25
+
26
+ export interface PendingOp {
27
+ opId: string;
28
+ intent: PredictedIntent;
29
+ preImagePM: PredictedPreImagePM | null;
30
+ /** Runtime range the predicted tx targeted BEFORE application (selection bounds). */
31
+ fromRuntime: number;
32
+ toRuntime: number;
33
+ /** PM selection head after the predicted tx applied. */
34
+ predictedSelectionHead?: number;
35
+ }
36
+
37
+ export interface LocalEditSessionState {
38
+ getBaseRevisionToken(): string;
39
+ getPendingOps(): readonly PendingOp[];
40
+ appendPending(op: PendingOp): void;
41
+ advanceToRevision(ack: { opId: string; newRevisionToken: string }): void;
42
+ rollbackOp(opId: string): PendingOp | null;
43
+ clearAllPending(): PendingOp[];
44
+ hasPending(): boolean;
45
+ isPredicted(opId: string): boolean;
46
+ /**
47
+ * IME composition state. Set to true while the browser is composing an
48
+ * IME input sequence (between `compositionstart` and `compositionend`);
49
+ * the predicted lane must bail from `run()` when composing so IME and
50
+ * prediction do not fight over the same DOM range.
51
+ */
52
+ isComposing(): boolean;
53
+ setComposing(composing: boolean): void;
54
+ }
55
+
56
+ export interface CreateLocalEditSessionStateOptions {
57
+ baseRevisionToken: string;
58
+ }
59
+
60
+ export function createLocalEditSessionState(
61
+ options: CreateLocalEditSessionStateOptions,
62
+ ): LocalEditSessionState {
63
+ let baseRevisionToken = options.baseRevisionToken;
64
+ let composing = false;
65
+ const pendingOps: PendingOp[] = [];
66
+ const predictedIds = new Set<string>();
67
+
68
+ return {
69
+ getBaseRevisionToken: () => baseRevisionToken,
70
+ getPendingOps: () => pendingOps.slice(),
71
+ isComposing: () => composing,
72
+ setComposing: (value) => { composing = value; },
73
+ appendPending(op) {
74
+ pendingOps.push(op);
75
+ predictedIds.add(op.opId);
76
+ },
77
+ advanceToRevision({ opId, newRevisionToken }) {
78
+ const idx = pendingOps.findIndex((op) => op.opId === opId);
79
+ if (idx >= 0) {
80
+ pendingOps.splice(idx, 1);
81
+ predictedIds.delete(opId);
82
+ }
83
+ baseRevisionToken = newRevisionToken;
84
+ },
85
+ rollbackOp(opId) {
86
+ const idx = pendingOps.findIndex((op) => op.opId === opId);
87
+ if (idx < 0) return null;
88
+ const [op] = pendingOps.splice(idx, 1);
89
+ predictedIds.delete(opId);
90
+ return op;
91
+ },
92
+ clearAllPending() {
93
+ const all = pendingOps.splice(0, pendingOps.length);
94
+ predictedIds.clear();
95
+ return all;
96
+ },
97
+ hasPending: () => pendingOps.length > 0,
98
+ isPredicted: (opId) => predictedIds.has(opId),
99
+ };
100
+ }
@@ -1,5 +1,8 @@
1
1
  export type PerfProbeKind =
2
2
  | "typing"
3
+ | "typing.predicted"
4
+ | "typing.reconcile"
5
+ | "typing.divergence"
3
6
  | "selection"
4
7
  | "runtime.create"
5
8
  | "snapshot.surface"
@@ -10,7 +13,30 @@ export type PerfProbeKind =
10
13
  | "pm.mount"
11
14
  | "shell.render"
12
15
  | "workspace.chrome"
13
- | "selection.sync";
16
+ | "selection.sync"
17
+ | "layout.incremental"
18
+ | "layout.full"
19
+ | "render.frame_build"
20
+ | "render.frame_diff"
21
+ | "render.decoration_resolve"
22
+ | "chrome.overlay_reposition"
23
+ | "chrome.hit_test"
24
+ | "rail.segment_project";
25
+
26
+ /**
27
+ * Counter names the FastTextEditLane emits via `incrementInvalidationCounter`.
28
+ * Expose them as a const so integrators can read the shape without duplicating
29
+ * strings.
30
+ */
31
+ export const PREDICTED_LANE_COUNTERS = {
32
+ applied: "predictions.applied",
33
+ equivalent: "predictions.equivalent",
34
+ adjusted: "predictions.adjusted",
35
+ rejected: "predictions.rejected",
36
+ rollback: "predictions.rollback",
37
+ structuralDivergence: "predictions.structuralDivergence",
38
+ bailBeforePredict: "predictions.bailBeforePredict",
39
+ } as const;
14
40
 
15
41
  export interface PerfProbeSample {
16
42
  token: string;
@@ -26,6 +26,19 @@ export interface CommandBridgeCallbacks extends SelectionSyncCallbacks {
26
26
  onUndo: () => void;
27
27
  onRedo: () => void;
28
28
  onBlockedInput?: (command: "paste" | "drop", message: string) => void;
29
+ /**
30
+ * Optional. Fires on `compositionstart` (true) and `compositionend`
31
+ * (false). The surface forwards this to the predicted lane's session
32
+ * so the lane can bail from `run()` while IME is composing.
33
+ */
34
+ onCompositionChange?: (composing: boolean) => void;
35
+ /**
36
+ * Optional predicted-tx gate plugin. When provided, it replaces the
37
+ * default unconditional filter so the FastTextEditLane can apply
38
+ * registered predicted transactions locally before the canonical commit
39
+ * lands. When absent, the legacy "block all docChanged" behavior applies.
40
+ */
41
+ gate?: Plugin;
29
42
  }
30
43
 
31
44
  const bridgeKey = new PluginKey("command-bridge");
@@ -69,7 +82,7 @@ export function createCommandBridgePlugins(
69
82
  ): Plugin[] {
70
83
  let isComposing = false;
71
84
 
72
- const filterPlugin = new Plugin({
85
+ const filterPlugin = callbacks.gate ?? new Plugin({
73
86
  key: bridgeKey,
74
87
  filterTransaction(tr) {
75
88
  if (!tr.docChanged) return true;
@@ -84,15 +97,20 @@ export function createCommandBridgePlugins(
84
97
  props: {
85
98
  handleDOMEvents: {
86
99
  blur() {
87
- isComposing = false;
100
+ if (isComposing) {
101
+ isComposing = false;
102
+ callbacks.onCompositionChange?.(false);
103
+ }
88
104
  return false;
89
105
  },
90
106
  compositionstart() {
91
107
  isComposing = true;
108
+ callbacks.onCompositionChange?.(true);
92
109
  return false;
93
110
  },
94
111
  compositionend() {
95
112
  isComposing = false;
113
+ callbacks.onCompositionChange?.(false);
96
114
  return false;
97
115
  },
98
116
  },
@@ -223,6 +223,19 @@ function subtractInlineOverlaps(
223
223
  return segments.filter((segment) => segment.from < segment.to);
224
224
  }
225
225
 
226
+ /**
227
+ * Rail decorations are now rendered on the `ChromeOverlay` plane via the
228
+ * `TwScopeRailLayer` consumer of `facet.getAllScopeRailSegments()`, not
229
+ * through PM Decoration.node. This function keeps its signature so the
230
+ * call sites below continue to compile; it warms the range cache (which
231
+ * other PM decorations can still consume) but emits no node decoration.
232
+ *
233
+ * Per runtime-rendering-and-chrome-phase.md §5 the rail must live outside
234
+ * the PM NodeView tree so: (a) the user perceives it as chrome, not
235
+ * document content, (b) predicted transactions never flash rail visuals,
236
+ * and (c) the rail can extend into the page-margin gutter, which PM
237
+ * cannot paint through block decorations.
238
+ */
226
239
  function pushRailDecorations(
227
240
  decorations: Decoration[],
228
241
  doc: PMNode,
@@ -231,19 +244,11 @@ function pushRailDecorations(
231
244
  spec: RailDecorationSpec,
232
245
  rangeCache: Map<string, Array<{ from: number; to: number }>>,
233
246
  ): void {
247
+ void decorations;
248
+ void spec;
234
249
  const cacheKey = `${from}:${to}`;
235
- const ranges = rangeCache.get(cacheKey) ?? collectRailRanges(doc, from, to);
236
250
  if (!rangeCache.has(cacheKey)) {
237
- rangeCache.set(cacheKey, ranges);
238
- }
239
- for (const range of ranges) {
240
- decorations.push(
241
- Decoration.node(range.from, range.to, {
242
- class: spec.className,
243
- "data-workflow-rail": spec.railKind,
244
- ...spec.attrs,
245
- }),
246
- );
251
+ rangeCache.set(cacheKey, collectRailRanges(doc, from, to));
247
252
  }
248
253
  }
249
254
 
@@ -261,19 +266,79 @@ export function buildDecorations(
261
266
  revisionModel: RevisionDecorationModel | undefined,
262
267
  markupDisplay: MarkupDisplay,
263
268
  showTrackedChanges = true,
264
- suggestionsEnabled = false,
265
- workflowScopes?: readonly WorkflowScope[],
266
- activeStory: EditorStoryTarget = MAIN_STORY_TARGET,
267
- workflowCandidates?: readonly WorkflowCandidateRange[],
268
- workflowBlockedReasons?: readonly WorkflowBlockedCommandReason[],
269
- workflowLockedZones?: readonly WorkflowLockedZone[],
270
- activeWorkflowWorkItemId?: string | null,
271
- activeWorkflowScopeIds?: readonly string[],
269
+ suggestionsEnabledOrWorkflowScopes: boolean | readonly WorkflowScope[] = false,
270
+ workflowScopesOrActiveStory?: readonly WorkflowScope[] | EditorStoryTarget,
271
+ activeStoryOrWorkflowCandidates: EditorStoryTarget | readonly WorkflowCandidateRange[] = MAIN_STORY_TARGET,
272
+ workflowCandidatesOrBlockedReasons?: readonly WorkflowCandidateRange[] | readonly WorkflowBlockedCommandReason[],
273
+ workflowBlockedReasonsOrLockedZones?: readonly WorkflowBlockedCommandReason[] | readonly WorkflowLockedZone[],
274
+ workflowLockedZonesOrActiveWorkItemId?: readonly WorkflowLockedZone[] | string | null,
275
+ activeWorkflowWorkItemIdOrScopeIds?: string | null | readonly string[],
276
+ activeWorkflowScopeIdsOrMetadata?: readonly string[] | readonly WorkflowMetadataMarkup[],
272
277
  workflowMetadata?: readonly WorkflowMetadataMarkup[],
273
278
  ): DecorationSet {
279
+ const isStoryTarget = (value: unknown): value is EditorStoryTarget =>
280
+ Boolean(value) &&
281
+ typeof value === "object" &&
282
+ "kind" in (value as Record<string, unknown>) &&
283
+ typeof (value as Record<string, unknown>).kind === "string";
284
+ const isStringArray = (value: unknown): value is readonly string[] =>
285
+ Array.isArray(value) && (value.length === 0 || typeof value[0] === "string");
286
+ const isWorkflowMetadataArray = (value: unknown): value is readonly WorkflowMetadataMarkup[] =>
287
+ Array.isArray(value) &&
288
+ value.length > 0 &&
289
+ typeof value[0] === "object" &&
290
+ value[0] !== null &&
291
+ "metadataId" in (value[0] as Record<string, unknown>);
292
+
293
+ const useLegacyShape =
294
+ typeof suggestionsEnabledOrWorkflowScopes !== "boolean" ||
295
+ isStoryTarget(workflowScopesOrActiveStory);
296
+ const suggestionsEnabled = useLegacyShape ? false : suggestionsEnabledOrWorkflowScopes;
297
+ const workflowScopes = useLegacyShape
298
+ ? (Array.isArray(suggestionsEnabledOrWorkflowScopes)
299
+ ? suggestionsEnabledOrWorkflowScopes
300
+ : undefined)
301
+ : (workflowScopesOrActiveStory as readonly WorkflowScope[] | undefined);
302
+ const activeStory = useLegacyShape
303
+ ? ((isStoryTarget(workflowScopesOrActiveStory)
304
+ ? workflowScopesOrActiveStory
305
+ : MAIN_STORY_TARGET) as EditorStoryTarget)
306
+ : ((activeStoryOrWorkflowCandidates as EditorStoryTarget | undefined) ?? MAIN_STORY_TARGET);
307
+ const workflowCandidates = useLegacyShape
308
+ ? (Array.isArray(activeStoryOrWorkflowCandidates)
309
+ ? activeStoryOrWorkflowCandidates as readonly WorkflowCandidateRange[]
310
+ : undefined)
311
+ : (workflowCandidatesOrBlockedReasons as readonly WorkflowCandidateRange[] | undefined);
312
+ const workflowBlockedReasons = useLegacyShape
313
+ ? (Array.isArray(workflowCandidatesOrBlockedReasons)
314
+ ? workflowCandidatesOrBlockedReasons as readonly WorkflowBlockedCommandReason[]
315
+ : undefined)
316
+ : (workflowBlockedReasonsOrLockedZones as readonly WorkflowBlockedCommandReason[] | undefined);
317
+ const workflowLockedZones = useLegacyShape
318
+ ? (Array.isArray(workflowBlockedReasonsOrLockedZones)
319
+ ? workflowBlockedReasonsOrLockedZones as readonly WorkflowLockedZone[]
320
+ : undefined)
321
+ : (workflowLockedZonesOrActiveWorkItemId as readonly WorkflowLockedZone[] | undefined);
322
+ const activeWorkflowWorkItemId = useLegacyShape
323
+ ? (typeof workflowLockedZonesOrActiveWorkItemId === "string" || workflowLockedZonesOrActiveWorkItemId === null
324
+ ? workflowLockedZonesOrActiveWorkItemId
325
+ : undefined)
326
+ : (activeWorkflowWorkItemIdOrScopeIds as string | null | undefined);
327
+ const activeWorkflowScopeIds = useLegacyShape
328
+ ? (isStringArray(activeWorkflowWorkItemIdOrScopeIds)
329
+ ? activeWorkflowWorkItemIdOrScopeIds as readonly string[]
330
+ : undefined)
331
+ : (activeWorkflowScopeIdsOrMetadata as readonly string[] | undefined);
332
+ const resolvedWorkflowMetadata = useLegacyShape
333
+ ? (isWorkflowMetadataArray(activeWorkflowScopeIdsOrMetadata)
334
+ ? activeWorkflowScopeIdsOrMetadata
335
+ : undefined)
336
+ : workflowMetadata;
337
+
274
338
  const decorations: Decoration[] = [];
275
339
  const railRangeCache = new Map<string, Array<{ from: number; to: number }>>();
276
340
  const activeScopeIds = new Set(activeWorkflowScopeIds ?? []);
341
+ const effectiveWorkflowScopes = workflowScopes ?? [];
277
342
  const lockedPmRanges = collectLockedPmRanges(workflowLockedZones, activeStory, positionMap);
278
343
 
279
344
  // Walk comment threads and create inline decorations
@@ -384,8 +449,8 @@ export function buildDecorations(
384
449
  }
385
450
  }
386
451
 
387
- if (workflowScopes) {
388
- for (const scope of workflowScopes) {
452
+ if (effectiveWorkflowScopes.length > 0) {
453
+ for (const scope of effectiveWorkflowScopes) {
389
454
  const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
390
455
  if (!storyTargetsEqual(scopeStoryTarget, activeStory)) continue;
391
456
  const pmRange = buildAnchorPmRange(scope.anchor, positionMap);
@@ -399,6 +464,11 @@ export function buildDecorations(
399
464
  );
400
465
 
401
466
  if (pmRange.allowInline && pmRange.from < pmRange.to) {
467
+ // Post-R3a: every workflow scope emits inline decorations with
468
+ // the scope-id attribution. The flat block-tint + gutter rail
469
+ // render on the ChromeOverlay — PM keeps only inline class hooks
470
+ // so selection tools, accessibility, and host scripts can still
471
+ // resolve the active scope at a text offset.
402
472
  const visibleScopeSegments = subtractInlineOverlaps(
403
473
  { from: pmRange.from, to: pmRange.to },
404
474
  lockedPmRanges.filter((range) => range.to > pmRange.from && range.from < pmRange.to),
@@ -429,8 +499,8 @@ export function buildDecorations(
429
499
  }
430
500
  }
431
501
 
432
- if (workflowMetadata) {
433
- for (const metadata of workflowMetadata) {
502
+ if (resolvedWorkflowMetadata) {
503
+ for (const metadata of resolvedWorkflowMetadata) {
434
504
  const metadataStoryTarget = metadata.storyTarget ?? MAIN_STORY_TARGET;
435
505
  if (!storyTargetsEqual(metadataStoryTarget, activeStory)) continue;
436
506
  const pmRange = buildAnchorPmRange(metadata.anchor, positionMap);
@@ -0,0 +1,78 @@
1
+ import type { PositionMap } from "./pm-position-map.ts";
2
+ import type { PendingOp } from "./local-edit-session-state.ts";
3
+
4
+ /**
5
+ * PredictedPositionMap — layers pending predicted-op deltas on top of the
6
+ * canonical `PositionMap`.
7
+ *
8
+ * When there are no pending ops this passes through the canonical map
9
+ * unchanged. When predictions are outstanding, selection-sync and external
10
+ * runtime queries use this view to map runtime positions through the
11
+ * applied-but-not-yet-committed local edits.
12
+ *
13
+ * After a reconciled commit, the lane advances the session's base revision
14
+ * token and discards the corresponding predicted op; subsequent queries go
15
+ * through the new canonical map.
16
+ */
17
+ export function createPredictedPositionMap(
18
+ canonical: PositionMap,
19
+ pendingOps: readonly PendingOp[],
20
+ ): PositionMap {
21
+ if (pendingOps.length === 0) return canonical;
22
+
23
+ function opsBefore(runtimePos: number): number {
24
+ let delta = 0;
25
+ for (const op of pendingOps) {
26
+ if (op.fromRuntime <= runtimePos) {
27
+ delta += opSizeDelta(op);
28
+ }
29
+ }
30
+ return delta;
31
+ }
32
+
33
+ return {
34
+ runtimeToPm(runtimePos) {
35
+ return canonical.runtimeToPm(runtimePos) + opsBefore(runtimePos);
36
+ },
37
+ pmToRuntime(pmPos) {
38
+ let adjusted = pmPos;
39
+ for (const op of pendingOps) {
40
+ const opPmStart = canonical.runtimeToPm(op.fromRuntime);
41
+ if (adjusted > opPmStart) {
42
+ adjusted -= opSizeDelta(op);
43
+ }
44
+ }
45
+ return canonical.pmToRuntime(Math.max(1, adjusted));
46
+ },
47
+ get pmDocSize() {
48
+ return canonical.pmDocSize + totalDelta(pendingOps);
49
+ },
50
+ get runtimeStorySize() {
51
+ return canonical.runtimeStorySize + totalDelta(pendingOps);
52
+ },
53
+ };
54
+ }
55
+
56
+ function totalDelta(pendingOps: readonly PendingOp[]): number {
57
+ let delta = 0;
58
+ for (const op of pendingOps) {
59
+ delta += opSizeDelta(op);
60
+ }
61
+ return delta;
62
+ }
63
+
64
+ function opSizeDelta(op: PendingOp): number {
65
+ switch (op.intent.kind) {
66
+ case "text.insert":
67
+ return op.intent.text.length;
68
+ case "text.delete-backward":
69
+ case "text.delete-forward":
70
+ return -(op.toRuntime - op.fromRuntime);
71
+ case "paragraph.split":
72
+ return 2;
73
+ case "text.insert-hard-break":
74
+ return 1;
75
+ default:
76
+ return 0;
77
+ }
78
+ }
@@ -0,0 +1,63 @@
1
+ import type {
2
+ EditorSurfaceSnapshot,
3
+ SurfaceBlockSnapshot,
4
+ } from "../../api/public-types.ts";
5
+ import type { ScopeTagRegistry } from "../../runtime/scope-tag-registry.ts";
6
+
7
+ /**
8
+ * Pre-flight check the FastTextEditLane consults before applying a predicted
9
+ * transaction. Returns true when the proposed edit range intersects any tag
10
+ * whose registry behavior is `bailIfCrossed: true` — today, that is fields,
11
+ * SDTs, and opaque (preserve-only) blocks. Such edits would be rolled back by
12
+ * the runtime's workflow / structural-divergence checks anyway; bailing
13
+ * before predicting saves the predicted-then-restored PM churn.
14
+ *
15
+ * Phase 1 scope: top-level surface blocks (paragraph, opaque_block, sdt_block)
16
+ * plus inline field_ref segments inside paragraphs. Does NOT recurse into
17
+ * sdt_block.children (the block boundary already bails). Does NOT walk into
18
+ * `table` blocks or their cells (left to the runtime safety net).
19
+ *
20
+ * Boundary semantics: this uses strict-open intersection
21
+ * (`aFrom < bTo && aTo > bFrom`). A collapsed cursor sitting exactly at
22
+ * a tag boundary is NOT considered intersecting. This intentionally
23
+ * under-bails on boundary-touching cursors: an insert at the left edge
24
+ * of a field is a legal edit (the field shifts), so over-bailing would
25
+ * cost a predicted-tx optimization for no correctness benefit. The
26
+ * downside is that a delete-forward at the left edge or a delete-backward
27
+ * at the right edge of a bail-if-crossed tag will fall through to the
28
+ * runtime, which still rejects the edit — the lane pays one
29
+ * predicted-then-rolled-back PM cycle for those keystrokes. A future
30
+ * phase that takes the predicted intent's direction can tighten this.
31
+ */
32
+ export function hasBailIfCrossedTagInRange(
33
+ surface: EditorSurfaceSnapshot,
34
+ registry: ScopeTagRegistry,
35
+ fromRuntime: number,
36
+ toRuntime: number,
37
+ ): boolean {
38
+ const opaqueBails = registry.get("opaque").bailIfCrossed;
39
+ const sdtBails = registry.get("sdt").bailIfCrossed;
40
+ const fieldBails = registry.get("field").bailIfCrossed;
41
+ for (const block of surface.blocks) {
42
+ if (!intersects(block.from, block.to, fromRuntime, toRuntime)) continue;
43
+ if (block.kind === "opaque_block" && opaqueBails) {
44
+ return true;
45
+ }
46
+ if (block.kind === "sdt_block" && sdtBails) {
47
+ return true;
48
+ }
49
+ if (block.kind === "paragraph" && fieldBails) {
50
+ for (const segment of block.segments) {
51
+ if (segment.kind !== "field_ref") continue;
52
+ if (intersects(segment.from, segment.to, fromRuntime, toRuntime)) {
53
+ return true;
54
+ }
55
+ }
56
+ }
57
+ }
58
+ return false;
59
+ }
60
+
61
+ function intersects(aFrom: number, aTo: number, bFrom: number, bTo: number): boolean {
62
+ return aFrom < bTo && aTo > bFrom;
63
+ }
@@ -0,0 +1,39 @@
1
+ import { Plugin, PluginKey, type Transaction } from "prosemirror-state";
2
+
3
+ /**
4
+ * Key used to stamp predicted PM transactions with their `opId`. The gate
5
+ * plugin reads this meta and allows a doc-changing transaction through only
6
+ * when the lane has registered the `opId`.
7
+ */
8
+ export const PREDICTED_META_KEY = "bounded-local-first/predicted";
9
+
10
+ export interface PredictedMeta {
11
+ opId: string;
12
+ }
13
+
14
+ export interface PredictedTxGateOptions {
15
+ /** The lane's `LocalEditSessionState.isPredicted(opId)` — consulted per tx. */
16
+ isPredicted(opId: string): boolean;
17
+ }
18
+
19
+ const gateKey = new PluginKey("predicted-tx-gate");
20
+
21
+ /**
22
+ * PredictedTxGate — replaces the unconditional "block every `docChanged`"
23
+ * filter with one that lets through predicted transactions whose `opId` the
24
+ * lane has registered. Unregistered or unstamped doc-changing transactions
25
+ * are still blocked — the runtime remains the canonical mutation path.
26
+ *
27
+ * Selection-only transactions always pass.
28
+ */
29
+ export function createPredictedTxGate(options: PredictedTxGateOptions): Plugin {
30
+ return new Plugin({
31
+ key: gateKey,
32
+ filterTransaction(tr: Transaction) {
33
+ if (!tr.docChanged) return true;
34
+ const meta = tr.getMeta(PREDICTED_META_KEY) as PredictedMeta | undefined;
35
+ if (!meta) return false;
36
+ return options.isPredicted(meta.opId);
37
+ },
38
+ });
39
+ }