@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,178 @@
1
+ /**
2
+ * Scope rail layer — renders workflow scopes as a continuous zone with a
3
+ * gutter icon + label column OUTSIDE the document flow plus a flat block-
4
+ * level tint BEHIND the scoped paragraphs.
5
+ *
6
+ * Per runtime-rendering-and-chrome-phase.md §5, the rail is a projection
7
+ * over canonical workflow scopes; it never lives inside the PM NodeView
8
+ * tree. Positions come from the render kernel's anchor index — not from
9
+ * DOM rect math — so the rail stays aligned across scroll, zoom, resize,
10
+ * and through predicted-text reconciliation.
11
+ */
12
+
13
+ import * as React from "react";
14
+ import {
15
+ inflateRect,
16
+ projectRectToOverlay,
17
+ unionRect,
18
+ type OverlayCoordinateSpace,
19
+ } from "./chrome-overlay-projector";
20
+ import type { RenderFrameRect } from "../../runtime/render";
21
+ import type { ScopeRailSegment, ScopeRailPosture } from "../../runtime/layout";
22
+ import type { WordReviewEditorLayoutFacet } from "../../runtime/layout";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export interface TwScopeRailLayerProps {
29
+ /** Layout facet that provides segments + anchor index. */
30
+ facet: WordReviewEditorLayoutFacet;
31
+ /** Overlay's coordinate space. Defaults to the overlay's own origin. */
32
+ space?: OverlayCoordinateSpace;
33
+ /** Horizontal padding (px) the rail gutter occupies to the left of body. */
34
+ railLaneWidthPx?: number;
35
+ /** Optional click handler for a segment label (open-scope drawer, etc). */
36
+ onSegmentClick?: (segment: ScopeRailSegment) => void;
37
+ /** Scope id that should render with the `active` emphasis. */
38
+ activeScopeId?: string | null;
39
+ /** Test id applied to the layer root. */
40
+ "data-testid"?: string;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Posture → visual grammar
45
+ // ---------------------------------------------------------------------------
46
+
47
+ interface PostureStyle {
48
+ labelText: string;
49
+ icon: string; // lucide-style key; CSS ::before handles glyph in production
50
+ railToken: string;
51
+ tintToken: string;
52
+ }
53
+
54
+ const POSTURE_STYLES: Record<ScopeRailPosture, PostureStyle> = {
55
+ edit: { labelText: "EDIT", icon: "pencil", railToken: "accent", tintToken: "accent" },
56
+ suggest: { labelText: "SUGGEST", icon: "sparkles", railToken: "warning", tintToken: "warning" },
57
+ comment: { labelText: "COMMENT", icon: "message", railToken: "insert", tintToken: "insert" },
58
+ view: { labelText: "IN SCOPE", icon: "eye", railToken: "secondary", tintToken: "secondary" },
59
+ candidate: { labelText: "PROPOSED", icon: "flag", railToken: "warning", tintToken: "warning" },
60
+ "preserve-only": { labelText: "BLOCKED", icon: "lock", railToken: "danger", tintToken: "danger" },
61
+ "blocked-import": { labelText: "BLOCKED", icon: "lock", railToken: "danger", tintToken: "danger" },
62
+ };
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Component
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const DEFAULT_RAIL_LANE_PX = 120;
69
+ const LABEL_WIDTH_PX = 92;
70
+
71
+ export const TwScopeRailLayer: React.FC<TwScopeRailLayerProps> = ({
72
+ facet,
73
+ space,
74
+ railLaneWidthPx = DEFAULT_RAIL_LANE_PX,
75
+ onSegmentClick,
76
+ activeScopeId,
77
+ "data-testid": testId,
78
+ }) => {
79
+ // Read the render frame once per paint cycle. The facet.subscribe path
80
+ // already invalidates the caller's React state on layout changes, so we
81
+ // just read on render.
82
+ const frame = typeof facet.getRenderFrame === "function"
83
+ ? facet.getRenderFrame() ?? null
84
+ : null;
85
+ const segments = facet.getAllScopeRailSegments();
86
+
87
+ if (!frame || segments.length === 0) {
88
+ return null;
89
+ }
90
+
91
+ // Group segments by scopeId so multi-page scopes render one contiguous
92
+ // tint per page range. (Per-page render happens below because each
93
+ // scope may span pages.)
94
+ const items = segments.map((segment) => {
95
+ const rect = resolveSegmentRect(facet, frame, segment);
96
+ if (!rect) return null;
97
+ const style = POSTURE_STYLES[segment.posture];
98
+ return { segment, rect, style };
99
+ }).filter((item): item is NonNullable<typeof item> => item !== null);
100
+
101
+ const projectorSpace: OverlayCoordinateSpace = space ?? { originLeftPx: 0, originTopPx: 0 };
102
+
103
+ return (
104
+ <div
105
+ className="wre-scope-rail-layer pointer-events-none absolute inset-0 z-20"
106
+ data-testid={testId ?? "scope-rail-layer"}
107
+ aria-hidden="false"
108
+ role="group"
109
+ aria-label="Workflow scope rail"
110
+ >
111
+ {items.map(({ segment, rect, style }) => {
112
+ const isActive = activeScopeId === segment.scopeId || segment.isActiveWorkItem;
113
+ const tintRect = inflateRect(rect, { leftPx: 4, rightPx: 4, topPx: 2, bottomPx: 2 });
114
+ const labelRect: RenderFrameRect = {
115
+ leftPx: rect.leftPx - railLaneWidthPx,
116
+ topPx: rect.topPx,
117
+ widthPx: LABEL_WIDTH_PX,
118
+ heightPx: Math.max(20, Math.min(rect.heightPx, 48)),
119
+ };
120
+
121
+ return (
122
+ <React.Fragment key={`${segment.scopeId}:${segment.pageIndex}:${segment.fromOffset}`}>
123
+ {/* Flat tint behind the scoped block region */}
124
+ <div
125
+ className={`wre-scope-rail-tint wre-scope-rail-tint-${style.tintToken} absolute ${
126
+ isActive ? "wre-scope-rail-tint-active" : ""
127
+ }`}
128
+ data-scope-id={segment.scopeId}
129
+ data-posture={segment.posture}
130
+ style={projectRectToOverlay(tintRect, projectorSpace)}
131
+ />
132
+ {/* Gutter label + icon outside the page frame */}
133
+ <button
134
+ type="button"
135
+ className={`wre-scope-rail-label wre-scope-rail-label-${style.railToken} pointer-events-auto absolute flex flex-col items-center justify-center gap-1 rounded-md text-[10px] font-semibold uppercase tracking-[0.08em] ${
136
+ isActive ? "wre-scope-rail-label-active" : ""
137
+ }`}
138
+ data-scope-id={segment.scopeId}
139
+ data-posture={segment.posture}
140
+ data-icon={style.icon}
141
+ aria-label={`${style.labelText}${segment.label ? `: ${segment.label}` : ""}`}
142
+ onClick={onSegmentClick ? () => onSegmentClick(segment) : undefined}
143
+ style={projectRectToOverlay(labelRect, projectorSpace)}
144
+ >
145
+ <span aria-hidden="true" className={`wre-scope-rail-icon wre-scope-rail-icon-${style.icon}`} />
146
+ <span className="wre-scope-rail-label-text">{style.labelText}</span>
147
+ </button>
148
+ </React.Fragment>
149
+ );
150
+ })}
151
+ </div>
152
+ );
153
+ };
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Internals
157
+ // ---------------------------------------------------------------------------
158
+
159
+ function resolveSegmentRect(
160
+ facet: WordReviewEditorLayoutFacet,
161
+ _frame: { anchorIndex: { byRuntimeOffset: (offset: number) => RenderFrameRect | null } },
162
+ segment: ScopeRailSegment,
163
+ ): RenderFrameRect | null {
164
+ const fromRect = _frame.anchorIndex.byRuntimeOffset(segment.fromOffset);
165
+ const toRect = _frame.anchorIndex.byRuntimeOffset(Math.max(segment.fromOffset, segment.toOffset - 1));
166
+ const unioned = unionRect(fromRect, toRect);
167
+ if (unioned) return unioned;
168
+ // Fall back to the page rect so long scopes that can't resolve per-line
169
+ // still render a posture in the gutter.
170
+ const fallbackPage = facet.getPage(segment.pageIndex);
171
+ if (!fallbackPage) return null;
172
+ // Approximate: derive a rect from the page's body region by asking the
173
+ // render frame for the page rect. Without a render kernel kernel this
174
+ // function returns null and the segment is skipped.
175
+ return null;
176
+ }
177
+
178
+ export default TwScopeRailLayer;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * TwWorkspaceViewSwitcher — floating dock that sits at the bottom of the
3
+ * chrome overlay. Mirrors the "WORKFLOW VIEW" bottom bar in image copy.png.
4
+ *
5
+ * Per runtime-rendering-and-chrome-phase.md §6.3, the dock is a
6
+ * `DraggableFloat`-compatible surface that lives inside the `ChromeOverlay`
7
+ * plane. Today it ships the preset selector + the canonical workflow
8
+ * actions (review / comment / approve) so consumers can wire them without
9
+ * waiting for the full DraggableFloat primitive.
10
+ */
11
+
12
+ import * as React from "react";
13
+
14
+ export type WorkspaceView = "draft" | "layout" | "review" | "workflow";
15
+
16
+ export interface WorkspaceViewAction {
17
+ id: string;
18
+ label: string;
19
+ icon: string;
20
+ onClick?: () => void;
21
+ disabled?: boolean;
22
+ }
23
+
24
+ export interface TwWorkspaceViewSwitcherProps {
25
+ activeView: WorkspaceView;
26
+ onViewChange?: (view: WorkspaceView) => void;
27
+ actions?: readonly WorkspaceViewAction[];
28
+ "data-testid"?: string;
29
+ }
30
+
31
+ const DEFAULT_VIEW_ORDER: readonly WorkspaceView[] = [
32
+ "draft",
33
+ "layout",
34
+ "review",
35
+ "workflow",
36
+ ];
37
+
38
+ const VIEW_LABELS: Record<WorkspaceView, string> = {
39
+ draft: "DRAFT",
40
+ layout: "LAYOUT",
41
+ review: "REVIEW",
42
+ workflow: "WORKFLOW VIEW",
43
+ };
44
+
45
+ export const TwWorkspaceViewSwitcher: React.FC<TwWorkspaceViewSwitcherProps> = ({
46
+ activeView,
47
+ onViewChange,
48
+ actions,
49
+ "data-testid": testId,
50
+ }) => {
51
+ return (
52
+ <div
53
+ className="wre-workspace-dock pointer-events-auto absolute left-1/2 bottom-6 z-10 -translate-x-1/2 flex items-center gap-2 rounded-full border border-border/70 bg-canvas/95 px-4 py-2 shadow-lg backdrop-blur"
54
+ data-testid={testId ?? "workspace-view-switcher"}
55
+ role="toolbar"
56
+ aria-label="Workspace view"
57
+ >
58
+ {DEFAULT_VIEW_ORDER.map((view) => (
59
+ <button
60
+ key={view}
61
+ type="button"
62
+ className={`wre-workspace-dock-view-btn inline-flex items-center gap-1 rounded-full px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] ${
63
+ view === activeView
64
+ ? "wre-workspace-dock-view-btn-active bg-primary text-white"
65
+ : "text-secondary hover:text-primary"
66
+ }`}
67
+ aria-pressed={view === activeView}
68
+ onClick={onViewChange ? () => onViewChange(view) : undefined}
69
+ disabled={!onViewChange}
70
+ >
71
+ <span aria-hidden="true" className="wre-workspace-dock-icon h-3 w-3" />
72
+ {VIEW_LABELS[view]}
73
+ </button>
74
+ ))}
75
+ {actions && actions.length > 0 ? (
76
+ <div className="wre-workspace-dock-sep mx-1 h-5 w-px bg-border" />
77
+ ) : null}
78
+ {actions?.map((action) => (
79
+ <button
80
+ key={action.id}
81
+ type="button"
82
+ className="wre-workspace-dock-action inline-flex h-7 w-7 items-center justify-center rounded-full text-secondary hover:bg-surface hover:text-primary disabled:opacity-40"
83
+ aria-label={action.label}
84
+ disabled={action.disabled}
85
+ onClick={action.onClick}
86
+ data-action-id={action.id}
87
+ >
88
+ <span aria-hidden="true" className={`wre-workspace-dock-icon wre-workspace-dock-icon-${action.icon}`} />
89
+ </button>
90
+ ))}
91
+ </div>
92
+ );
93
+ };
94
+
95
+ export default TwWorkspaceViewSwitcher;
@@ -0,0 +1,337 @@
1
+ import type { Transaction } from "prosemirror-state";
2
+ import type { EditorView } from "prosemirror-view";
3
+
4
+ import type { TextCommandAck } from "../../api/public-types.ts";
5
+ import type {
6
+ LocalEditSessionState,
7
+ PendingOp,
8
+ PredictedIntent,
9
+ PredictedPreImagePM,
10
+ } from "./local-edit-session-state.ts";
11
+ import {
12
+ incrementInvalidationCounter,
13
+ PREDICTED_LANE_COUNTERS,
14
+ } from "./perf-probe.ts";
15
+ import type { PositionMap } from "./pm-position-map.ts";
16
+ import { PREDICTED_META_KEY } from "./predicted-tx-gate.ts";
17
+
18
+ /**
19
+ * Runtime-side text command the lane dispatches synchronously after applying
20
+ * a predicted PM transaction. The caller (React surface) wires this to
21
+ * `DocumentRuntime.applyActiveStoryTextCommand(command)` and returns the ack.
22
+ */
23
+ export type LaneRuntimeCommand =
24
+ | {
25
+ type: "text.insert";
26
+ text: string;
27
+ origin: { opId: string; timestamp: number };
28
+ }
29
+ | {
30
+ type: "text.delete-backward";
31
+ origin: { opId: string; timestamp: number };
32
+ }
33
+ | {
34
+ type: "text.delete-forward";
35
+ origin: { opId: string; timestamp: number };
36
+ }
37
+ | {
38
+ type: "paragraph.split";
39
+ origin: { opId: string; timestamp: number };
40
+ }
41
+ | {
42
+ type: "text.insert-hard-break";
43
+ origin: { opId: string; timestamp: number };
44
+ };
45
+
46
+ export interface FastTextEditLaneOptions {
47
+ session: LocalEditSessionState;
48
+ getView(): EditorView | null;
49
+ getPositionMap(): PositionMap | null;
50
+ /**
51
+ * Synchronously dispatch the canonical runtime command. The lane expects
52
+ * the returned ack to classify the outcome; if the runtime throws,
53
+ * implementations should return a `rejected` ack rather than propagating.
54
+ */
55
+ dispatchRuntimeCommand(command: LaneRuntimeCommand): TextCommandAck;
56
+ /**
57
+ * Optional. The lane toggles this around the predicted dispatch window so
58
+ * the surface's selection-sync plugin stays quiet while the PM doc is
59
+ * ahead of the canonical position map.
60
+ */
61
+ suppressSelectionSync?: (suppressed: boolean) => void;
62
+ /**
63
+ * Optional pre-flight check. When it returns true, the lane skips the
64
+ * predicted PM transaction and dispatches the canonical runtime command
65
+ * directly. Use this for tag families (field, sdt, opaque) where the
66
+ * runtime would reject or diverge anyway — bailing here avoids the
67
+ * predicted-then-restored PM churn.
68
+ */
69
+ shouldBailBeforePredict?(
70
+ intent: PredictedIntent,
71
+ fromRuntime: number,
72
+ toRuntime: number,
73
+ ): boolean;
74
+ onEquivalentAck(ack: TextCommandAck): void;
75
+ onAdjustedAck(ack: TextCommandAck): void;
76
+ onRejectedAck(ack: TextCommandAck): void;
77
+ onStructuralDivergence(ack: TextCommandAck): void;
78
+ /** Optional probe hooks for perf instrumentation. */
79
+ probe?: {
80
+ markPredicted(opId: string): void;
81
+ markReconciled(opId: string, kind: TextCommandAck["kind"]): void;
82
+ };
83
+ }
84
+
85
+ export interface FastTextEditLane {
86
+ onInsertText(text: string): void;
87
+ onDeleteBackward(): void;
88
+ onDeleteForward(): void;
89
+ onSplitParagraph(): void;
90
+ onInsertHardBreak(): void;
91
+ }
92
+
93
+ let nextOpIdCounter = 0;
94
+ function allocOpId(): string {
95
+ nextOpIdCounter += 1;
96
+ return `op-${Date.now().toString(36)}-${nextOpIdCounter}`;
97
+ }
98
+
99
+ export function createFastTextEditLane(
100
+ options: FastTextEditLaneOptions,
101
+ ): FastTextEditLane {
102
+ function run(
103
+ intent: PredictedIntent,
104
+ buildTx: (tr: Transaction) => Transaction | null,
105
+ ): void {
106
+ // IME composition holds the DOM range; predicting through it would
107
+ // fight the browser's composition DOM mutations. The bridge's
108
+ // compositionstart/end handlers toggle this flag on the session.
109
+ if (options.session.isComposing()) return;
110
+ const view = options.getView();
111
+ const positionMap = options.getPositionMap();
112
+ if (!view || !positionMap) return;
113
+
114
+ const opId = allocOpId();
115
+ const before = view.state;
116
+ const fromPm = Math.min(before.selection.from, before.selection.to);
117
+ const toPm = Math.max(before.selection.from, before.selection.to);
118
+
119
+ const tr = buildTxCompat(view, intent, buildTx);
120
+ if (!tr) return;
121
+ tr.setMeta(PREDICTED_META_KEY, { opId });
122
+
123
+ const fromRuntime = positionMap.pmToRuntime(fromPm);
124
+ const toRuntime = positionMap.pmToRuntime(toPm);
125
+
126
+ pushLaneDebug({
127
+ opId,
128
+ intent: intent.kind,
129
+ pmFrom: fromPm,
130
+ pmTo: toPm,
131
+ pmDocSize: positionMap.pmDocSize,
132
+ runtimeStorySize: positionMap.runtimeStorySize,
133
+ fromRuntime,
134
+ toRuntime,
135
+ });
136
+
137
+ if (options.shouldBailBeforePredict?.(intent, fromRuntime, toRuntime)) {
138
+ const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
139
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.bailBeforePredict);
140
+ options.probe?.markReconciled(opId, ack.kind);
141
+ switch (ack.kind) {
142
+ case "equivalent":
143
+ options.session.advanceToRevision({
144
+ opId,
145
+ newRevisionToken: ack.newRevisionToken,
146
+ });
147
+ options.onEquivalentAck(ack);
148
+ return;
149
+ case "adjusted":
150
+ options.session.advanceToRevision({
151
+ opId,
152
+ newRevisionToken: ack.newRevisionToken,
153
+ });
154
+ options.onAdjustedAck(ack);
155
+ return;
156
+ case "rejected":
157
+ options.onRejectedAck(ack);
158
+ return;
159
+ case "structural-divergence":
160
+ options.onStructuralDivergence(ack);
161
+ return;
162
+ }
163
+ }
164
+
165
+ const op: PendingOp = {
166
+ opId,
167
+ intent,
168
+ preImagePM: { preState: before },
169
+ fromRuntime,
170
+ toRuntime,
171
+ };
172
+
173
+ try {
174
+ options.session.appendPending(op);
175
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.applied);
176
+ options.probe?.markPredicted(opId);
177
+
178
+ // run() is invoked synchronously from PM input handlers and JavaScript is
179
+ // single-threaded, so reentrancy is impossible — a plain on/off toggle is
180
+ // correct here; no save/restore is needed.
181
+ options.suppressSelectionSync?.(true);
182
+ view.dispatch(tr);
183
+ op.predictedSelectionHead = view.state.selection.head;
184
+
185
+ const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
186
+ options.probe?.markReconciled(opId, ack.kind);
187
+
188
+ switch (ack.kind) {
189
+ case "equivalent":
190
+ options.session.advanceToRevision({
191
+ opId,
192
+ newRevisionToken: ack.newRevisionToken,
193
+ });
194
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.equivalent);
195
+ options.onEquivalentAck(ack);
196
+ return;
197
+ case "adjusted":
198
+ options.session.advanceToRevision({
199
+ opId,
200
+ newRevisionToken: ack.newRevisionToken,
201
+ });
202
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.adjusted);
203
+ options.onAdjustedAck(ack);
204
+ return;
205
+ case "rejected": {
206
+ const removed = options.session.rollbackOp(opId);
207
+ if (removed?.preImagePM) {
208
+ restorePreImage(view, removed.preImagePM);
209
+ }
210
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rejected);
211
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rollback);
212
+ options.onRejectedAck(ack);
213
+ return;
214
+ }
215
+ case "structural-divergence": {
216
+ const all = options.session.clearAllPending();
217
+ for (let i = all.length - 1; i >= 0; i -= 1) {
218
+ const pre = all[i].preImagePM;
219
+ if (pre) restorePreImage(view, pre);
220
+ }
221
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.structuralDivergence);
222
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rollback, all.length);
223
+ options.onStructuralDivergence(ack);
224
+ return;
225
+ }
226
+ }
227
+ } finally {
228
+ options.suppressSelectionSync?.(false);
229
+ }
230
+ }
231
+
232
+ function restorePreImage(view: EditorView, pre: PredictedPreImagePM): void {
233
+ // view.updateState bypasses the gate's filterTransaction entirely — it is
234
+ // the same path the React surface uses for full rebuilds. Safe here because
235
+ // we are restoring a state that already existed in this same view.
236
+ view.updateState(pre.preState);
237
+ }
238
+
239
+ return {
240
+ onInsertText(text) {
241
+ run({ kind: "text.insert", text }, (tr) => tr.insertText(text));
242
+ },
243
+ onDeleteBackward() {
244
+ run({ kind: "text.delete-backward" }, (tr) => {
245
+ if (!tr.selection.empty) return tr.deleteSelection();
246
+ const from = tr.selection.from;
247
+ if (from <= 1) return null;
248
+ return tr.delete(from - 1, from);
249
+ });
250
+ },
251
+ onDeleteForward() {
252
+ run({ kind: "text.delete-forward" }, (tr) => {
253
+ if (!tr.selection.empty) return tr.deleteSelection();
254
+ const to = tr.selection.to;
255
+ if (to >= tr.doc.content.size - 1) return null;
256
+ return tr.delete(to, to + 1);
257
+ });
258
+ },
259
+ onSplitParagraph() {
260
+ run({ kind: "paragraph.split" }, (tr) => tr.split(tr.selection.from));
261
+ },
262
+ onInsertHardBreak() {
263
+ run({ kind: "text.insert-hard-break" }, (tr) => {
264
+ const hardBreak = tr.doc.type.schema.nodes.hard_break;
265
+ if (!hardBreak) return null;
266
+ return tr.replaceSelectionWith(hardBreak.create(), true);
267
+ });
268
+ },
269
+ };
270
+ }
271
+
272
+ // ----- helpers -----
273
+
274
+ interface LaneDebugEntry {
275
+ opId: string;
276
+ intent: PredictedIntent["kind"];
277
+ pmFrom: number;
278
+ pmTo: number;
279
+ pmDocSize: number;
280
+ runtimeStorySize: number;
281
+ fromRuntime: number;
282
+ toRuntime: number;
283
+ }
284
+
285
+ declare global {
286
+ interface Window {
287
+ __DOCX_LANE_DEBUG__?: LaneDebugEntry[];
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Push a per-keystroke trace entry to a window-attached ring buffer when
293
+ * `window.__DOCX_LANE_DEBUG__` exists (initialized to an empty array). The
294
+ * buffer is capped at 200 entries; consumers can read it from the browser
295
+ * console to diagnose cursor position mismatches between PM and the runtime.
296
+ *
297
+ * To enable in the browser console:
298
+ * window.__DOCX_LANE_DEBUG__ = [];
299
+ * Then type, then:
300
+ * JSON.stringify(window.__DOCX_LANE_DEBUG__, null, 2)
301
+ */
302
+ function pushLaneDebug(entry: LaneDebugEntry): void {
303
+ if (typeof window === "undefined") return;
304
+ const buffer = window.__DOCX_LANE_DEBUG__;
305
+ if (!Array.isArray(buffer)) return;
306
+ buffer.push(entry);
307
+ if (buffer.length > 200) {
308
+ buffer.splice(0, buffer.length - 200);
309
+ }
310
+ }
311
+
312
+ function buildTxCompat(
313
+ view: EditorView,
314
+ _intent: PredictedIntent,
315
+ buildTx: (tr: Transaction) => Transaction | null,
316
+ ): Transaction | null {
317
+ return buildTx(view.state.tr);
318
+ }
319
+
320
+ function toRuntimeCommand(
321
+ intent: PredictedIntent,
322
+ opId: string,
323
+ ): LaneRuntimeCommand {
324
+ const origin = { opId, timestamp: Date.now() };
325
+ switch (intent.kind) {
326
+ case "text.insert":
327
+ return { type: "text.insert", text: intent.text, origin };
328
+ case "text.delete-backward":
329
+ return { type: "text.delete-backward", origin };
330
+ case "text.delete-forward":
331
+ return { type: "text.delete-forward", origin };
332
+ case "paragraph.split":
333
+ return { type: "paragraph.split", origin };
334
+ case "text.insert-hard-break":
335
+ return { type: "text.insert-hard-break", origin };
336
+ }
337
+ }