@beyondwork/docx-react-component 1.0.35 → 1.0.37

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 (65) 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 +84 -1
  5. package/src/core/commands/index.ts +19 -2
  6. package/src/core/selection/mapping.ts +6 -0
  7. package/src/io/docx-session.ts +24 -9
  8. package/src/io/export/build-app-properties-xml.ts +88 -0
  9. package/src/io/export/serialize-comments.ts +6 -1
  10. package/src/io/export/serialize-footnotes.ts +10 -9
  11. package/src/io/export/serialize-headers-footers.ts +11 -10
  12. package/src/io/export/serialize-main-document.ts +337 -50
  13. package/src/io/export/serialize-numbering.ts +115 -24
  14. package/src/io/export/serialize-tables.ts +13 -11
  15. package/src/io/export/table-properties-xml.ts +35 -16
  16. package/src/io/export/twip.ts +66 -0
  17. package/src/io/normalize/normalize-text.ts +5 -0
  18. package/src/io/ooxml/parse-footnotes.ts +2 -1
  19. package/src/io/ooxml/parse-headers-footers.ts +2 -1
  20. package/src/io/ooxml/parse-main-document.ts +21 -1
  21. package/src/legal/bookmarks.ts +78 -0
  22. package/src/model/canonical-document.ts +11 -0
  23. package/src/review/store/scope-tag-diff.ts +130 -0
  24. package/src/runtime/document-navigation.ts +1 -305
  25. package/src/runtime/document-runtime.ts +178 -16
  26. package/src/runtime/layout/docx-font-loader.ts +143 -0
  27. package/src/runtime/layout/index.ts +188 -0
  28. package/src/runtime/layout/inert-layout-facet.ts +45 -0
  29. package/src/runtime/layout/layout-engine-instance.ts +618 -0
  30. package/src/runtime/layout/layout-invalidation.ts +257 -0
  31. package/src/runtime/layout/layout-measurement-provider.ts +175 -0
  32. package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
  33. package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
  34. package/src/runtime/layout/page-fragment-mapper.ts +179 -0
  35. package/src/runtime/layout/page-graph.ts +433 -0
  36. package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
  37. package/src/runtime/layout/page-story-resolver.ts +195 -0
  38. package/src/runtime/layout/paginated-layout-engine.ts +788 -0
  39. package/src/runtime/layout/public-facet.ts +705 -0
  40. package/src/runtime/layout/resolved-formatting-document.ts +317 -0
  41. package/src/runtime/layout/resolved-formatting-state.ts +430 -0
  42. package/src/runtime/scope-tag-registry.ts +95 -0
  43. package/src/runtime/session-capabilities.ts +7 -4
  44. package/src/runtime/surface-projection.ts +1 -0
  45. package/src/runtime/text-ack-range.ts +49 -0
  46. package/src/ui/WordReviewEditor.tsx +15 -0
  47. package/src/ui/editor-runtime-boundary.ts +10 -1
  48. package/src/ui/editor-surface-controller.tsx +3 -0
  49. package/src/ui/headless/chrome-registry.ts +235 -0
  50. package/src/ui/headless/scoped-chrome-policy.ts +164 -0
  51. package/src/ui/headless/selection-tool-context.ts +2 -0
  52. package/src/ui/headless/selection-tool-resolver.ts +36 -17
  53. package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
  54. package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
  55. package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
  56. package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
  57. package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
  58. package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
  59. package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
  60. package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
  61. package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
  62. package/src/ui-tailwind/theme/editor-theme.css +40 -14
  63. package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
  64. package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
  65. package/src/ui-tailwind/tw-review-workspace.tsx +27 -1
@@ -0,0 +1,333 @@
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
+ const view = options.getView();
107
+ const positionMap = options.getPositionMap();
108
+ if (!view || !positionMap) return;
109
+
110
+ const opId = allocOpId();
111
+ const before = view.state;
112
+ const fromPm = Math.min(before.selection.from, before.selection.to);
113
+ const toPm = Math.max(before.selection.from, before.selection.to);
114
+
115
+ const tr = buildTxCompat(view, intent, buildTx);
116
+ if (!tr) return;
117
+ tr.setMeta(PREDICTED_META_KEY, { opId });
118
+
119
+ const fromRuntime = positionMap.pmToRuntime(fromPm);
120
+ const toRuntime = positionMap.pmToRuntime(toPm);
121
+
122
+ pushLaneDebug({
123
+ opId,
124
+ intent: intent.kind,
125
+ pmFrom: fromPm,
126
+ pmTo: toPm,
127
+ pmDocSize: positionMap.pmDocSize,
128
+ runtimeStorySize: positionMap.runtimeStorySize,
129
+ fromRuntime,
130
+ toRuntime,
131
+ });
132
+
133
+ if (options.shouldBailBeforePredict?.(intent, fromRuntime, toRuntime)) {
134
+ const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
135
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.bailBeforePredict);
136
+ options.probe?.markReconciled(opId, ack.kind);
137
+ switch (ack.kind) {
138
+ case "equivalent":
139
+ options.session.advanceToRevision({
140
+ opId,
141
+ newRevisionToken: ack.newRevisionToken,
142
+ });
143
+ options.onEquivalentAck(ack);
144
+ return;
145
+ case "adjusted":
146
+ options.session.advanceToRevision({
147
+ opId,
148
+ newRevisionToken: ack.newRevisionToken,
149
+ });
150
+ options.onAdjustedAck(ack);
151
+ return;
152
+ case "rejected":
153
+ options.onRejectedAck(ack);
154
+ return;
155
+ case "structural-divergence":
156
+ options.onStructuralDivergence(ack);
157
+ return;
158
+ }
159
+ }
160
+
161
+ const op: PendingOp = {
162
+ opId,
163
+ intent,
164
+ preImagePM: { preState: before },
165
+ fromRuntime,
166
+ toRuntime,
167
+ };
168
+
169
+ try {
170
+ options.session.appendPending(op);
171
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.applied);
172
+ options.probe?.markPredicted(opId);
173
+
174
+ // run() is invoked synchronously from PM input handlers and JavaScript is
175
+ // single-threaded, so reentrancy is impossible — a plain on/off toggle is
176
+ // correct here; no save/restore is needed.
177
+ options.suppressSelectionSync?.(true);
178
+ view.dispatch(tr);
179
+ op.predictedSelectionHead = view.state.selection.head;
180
+
181
+ const ack = options.dispatchRuntimeCommand(toRuntimeCommand(intent, opId));
182
+ options.probe?.markReconciled(opId, ack.kind);
183
+
184
+ switch (ack.kind) {
185
+ case "equivalent":
186
+ options.session.advanceToRevision({
187
+ opId,
188
+ newRevisionToken: ack.newRevisionToken,
189
+ });
190
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.equivalent);
191
+ options.onEquivalentAck(ack);
192
+ return;
193
+ case "adjusted":
194
+ options.session.advanceToRevision({
195
+ opId,
196
+ newRevisionToken: ack.newRevisionToken,
197
+ });
198
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.adjusted);
199
+ options.onAdjustedAck(ack);
200
+ return;
201
+ case "rejected": {
202
+ const removed = options.session.rollbackOp(opId);
203
+ if (removed?.preImagePM) {
204
+ restorePreImage(view, removed.preImagePM);
205
+ }
206
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rejected);
207
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rollback);
208
+ options.onRejectedAck(ack);
209
+ return;
210
+ }
211
+ case "structural-divergence": {
212
+ const all = options.session.clearAllPending();
213
+ for (let i = all.length - 1; i >= 0; i -= 1) {
214
+ const pre = all[i].preImagePM;
215
+ if (pre) restorePreImage(view, pre);
216
+ }
217
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.structuralDivergence);
218
+ incrementInvalidationCounter(PREDICTED_LANE_COUNTERS.rollback, all.length);
219
+ options.onStructuralDivergence(ack);
220
+ return;
221
+ }
222
+ }
223
+ } finally {
224
+ options.suppressSelectionSync?.(false);
225
+ }
226
+ }
227
+
228
+ function restorePreImage(view: EditorView, pre: PredictedPreImagePM): void {
229
+ // view.updateState bypasses the gate's filterTransaction entirely — it is
230
+ // the same path the React surface uses for full rebuilds. Safe here because
231
+ // we are restoring a state that already existed in this same view.
232
+ view.updateState(pre.preState);
233
+ }
234
+
235
+ return {
236
+ onInsertText(text) {
237
+ run({ kind: "text.insert", text }, (tr) => tr.insertText(text));
238
+ },
239
+ onDeleteBackward() {
240
+ run({ kind: "text.delete-backward" }, (tr) => {
241
+ if (!tr.selection.empty) return tr.deleteSelection();
242
+ const from = tr.selection.from;
243
+ if (from <= 1) return null;
244
+ return tr.delete(from - 1, from);
245
+ });
246
+ },
247
+ onDeleteForward() {
248
+ run({ kind: "text.delete-forward" }, (tr) => {
249
+ if (!tr.selection.empty) return tr.deleteSelection();
250
+ const to = tr.selection.to;
251
+ if (to >= tr.doc.content.size - 1) return null;
252
+ return tr.delete(to, to + 1);
253
+ });
254
+ },
255
+ onSplitParagraph() {
256
+ run({ kind: "paragraph.split" }, (tr) => tr.split(tr.selection.from));
257
+ },
258
+ onInsertHardBreak() {
259
+ run({ kind: "text.insert-hard-break" }, (tr) => {
260
+ const hardBreak = tr.doc.type.schema.nodes.hard_break;
261
+ if (!hardBreak) return null;
262
+ return tr.replaceSelectionWith(hardBreak.create(), true);
263
+ });
264
+ },
265
+ };
266
+ }
267
+
268
+ // ----- helpers -----
269
+
270
+ interface LaneDebugEntry {
271
+ opId: string;
272
+ intent: PredictedIntent["kind"];
273
+ pmFrom: number;
274
+ pmTo: number;
275
+ pmDocSize: number;
276
+ runtimeStorySize: number;
277
+ fromRuntime: number;
278
+ toRuntime: number;
279
+ }
280
+
281
+ declare global {
282
+ interface Window {
283
+ __DOCX_LANE_DEBUG__?: LaneDebugEntry[];
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Push a per-keystroke trace entry to a window-attached ring buffer when
289
+ * `window.__DOCX_LANE_DEBUG__` exists (initialized to an empty array). The
290
+ * buffer is capped at 200 entries; consumers can read it from the browser
291
+ * console to diagnose cursor position mismatches between PM and the runtime.
292
+ *
293
+ * To enable in the browser console:
294
+ * window.__DOCX_LANE_DEBUG__ = [];
295
+ * Then type, then:
296
+ * JSON.stringify(window.__DOCX_LANE_DEBUG__, null, 2)
297
+ */
298
+ function pushLaneDebug(entry: LaneDebugEntry): void {
299
+ if (typeof window === "undefined") return;
300
+ const buffer = window.__DOCX_LANE_DEBUG__;
301
+ if (!Array.isArray(buffer)) return;
302
+ buffer.push(entry);
303
+ if (buffer.length > 200) {
304
+ buffer.splice(0, buffer.length - 200);
305
+ }
306
+ }
307
+
308
+ function buildTxCompat(
309
+ view: EditorView,
310
+ _intent: PredictedIntent,
311
+ buildTx: (tr: Transaction) => Transaction | null,
312
+ ): Transaction | null {
313
+ return buildTx(view.state.tr);
314
+ }
315
+
316
+ function toRuntimeCommand(
317
+ intent: PredictedIntent,
318
+ opId: string,
319
+ ): LaneRuntimeCommand {
320
+ const origin = { opId, timestamp: Date.now() };
321
+ switch (intent.kind) {
322
+ case "text.insert":
323
+ return { type: "text.insert", text: intent.text, origin };
324
+ case "text.delete-backward":
325
+ return { type: "text.delete-backward", origin };
326
+ case "text.delete-forward":
327
+ return { type: "text.delete-forward", origin };
328
+ case "paragraph.split":
329
+ return { type: "paragraph.split", origin };
330
+ case "text.insert-hard-break":
331
+ return { type: "text.insert-hard-break", origin };
332
+ }
333
+ }
@@ -0,0 +1,89 @@
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
+
48
+ export interface CreateLocalEditSessionStateOptions {
49
+ baseRevisionToken: string;
50
+ }
51
+
52
+ export function createLocalEditSessionState(
53
+ options: CreateLocalEditSessionStateOptions,
54
+ ): LocalEditSessionState {
55
+ let baseRevisionToken = options.baseRevisionToken;
56
+ const pendingOps: PendingOp[] = [];
57
+ const predictedIds = new Set<string>();
58
+
59
+ return {
60
+ getBaseRevisionToken: () => baseRevisionToken,
61
+ getPendingOps: () => pendingOps.slice(),
62
+ appendPending(op) {
63
+ pendingOps.push(op);
64
+ predictedIds.add(op.opId);
65
+ },
66
+ advanceToRevision({ opId, newRevisionToken }) {
67
+ const idx = pendingOps.findIndex((op) => op.opId === opId);
68
+ if (idx >= 0) {
69
+ pendingOps.splice(idx, 1);
70
+ predictedIds.delete(opId);
71
+ }
72
+ baseRevisionToken = newRevisionToken;
73
+ },
74
+ rollbackOp(opId) {
75
+ const idx = pendingOps.findIndex((op) => op.opId === opId);
76
+ if (idx < 0) return null;
77
+ const [op] = pendingOps.splice(idx, 1);
78
+ predictedIds.delete(opId);
79
+ return op;
80
+ },
81
+ clearAllPending() {
82
+ const all = pendingOps.splice(0, pendingOps.length);
83
+ predictedIds.clear();
84
+ return all;
85
+ },
86
+ hasPending: () => pendingOps.length > 0,
87
+ isPredicted: (opId) => predictedIds.has(opId),
88
+ };
89
+ }
@@ -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,24 @@ 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
+
20
+ /**
21
+ * Counter names the FastTextEditLane emits via `incrementInvalidationCounter`.
22
+ * Expose them as a const so integrators can read the shape without duplicating
23
+ * strings.
24
+ */
25
+ export const PREDICTED_LANE_COUNTERS = {
26
+ applied: "predictions.applied",
27
+ equivalent: "predictions.equivalent",
28
+ adjusted: "predictions.adjusted",
29
+ rejected: "predictions.rejected",
30
+ rollback: "predictions.rollback",
31
+ structuralDivergence: "predictions.structuralDivergence",
32
+ bailBeforePredict: "predictions.bailBeforePredict",
33
+ } as const;
14
34
 
15
35
  export interface PerfProbeSample {
16
36
  token: string;
@@ -26,6 +26,13 @@ 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 predicted-tx gate plugin. When provided, it replaces the
31
+ * default unconditional filter so the FastTextEditLane can apply
32
+ * registered predicted transactions locally before the canonical commit
33
+ * lands. When absent, the legacy "block all docChanged" behavior applies.
34
+ */
35
+ gate?: Plugin;
29
36
  }
30
37
 
31
38
  const bridgeKey = new PluginKey("command-bridge");
@@ -69,7 +76,7 @@ export function createCommandBridgePlugins(
69
76
  ): Plugin[] {
70
77
  let isComposing = false;
71
78
 
72
- const filterPlugin = new Plugin({
79
+ const filterPlugin = callbacks.gate ?? new Plugin({
73
80
  key: bridgeKey,
74
81
  filterTransaction(tr) {
75
82
  if (!tr.docChanged) return true;
@@ -261,19 +261,79 @@ export function buildDecorations(
261
261
  revisionModel: RevisionDecorationModel | undefined,
262
262
  markupDisplay: MarkupDisplay,
263
263
  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[],
264
+ suggestionsEnabledOrWorkflowScopes: boolean | readonly WorkflowScope[] = false,
265
+ workflowScopesOrActiveStory?: readonly WorkflowScope[] | EditorStoryTarget,
266
+ activeStoryOrWorkflowCandidates: EditorStoryTarget | readonly WorkflowCandidateRange[] = MAIN_STORY_TARGET,
267
+ workflowCandidatesOrBlockedReasons?: readonly WorkflowCandidateRange[] | readonly WorkflowBlockedCommandReason[],
268
+ workflowBlockedReasonsOrLockedZones?: readonly WorkflowBlockedCommandReason[] | readonly WorkflowLockedZone[],
269
+ workflowLockedZonesOrActiveWorkItemId?: readonly WorkflowLockedZone[] | string | null,
270
+ activeWorkflowWorkItemIdOrScopeIds?: string | null | readonly string[],
271
+ activeWorkflowScopeIdsOrMetadata?: readonly string[] | readonly WorkflowMetadataMarkup[],
272
272
  workflowMetadata?: readonly WorkflowMetadataMarkup[],
273
273
  ): DecorationSet {
274
+ const isStoryTarget = (value: unknown): value is EditorStoryTarget =>
275
+ Boolean(value) &&
276
+ typeof value === "object" &&
277
+ "kind" in (value as Record<string, unknown>) &&
278
+ typeof (value as Record<string, unknown>).kind === "string";
279
+ const isStringArray = (value: unknown): value is readonly string[] =>
280
+ Array.isArray(value) && (value.length === 0 || typeof value[0] === "string");
281
+ const isWorkflowMetadataArray = (value: unknown): value is readonly WorkflowMetadataMarkup[] =>
282
+ Array.isArray(value) &&
283
+ value.length > 0 &&
284
+ typeof value[0] === "object" &&
285
+ value[0] !== null &&
286
+ "metadataId" in (value[0] as Record<string, unknown>);
287
+
288
+ const useLegacyShape =
289
+ typeof suggestionsEnabledOrWorkflowScopes !== "boolean" ||
290
+ isStoryTarget(workflowScopesOrActiveStory);
291
+ const suggestionsEnabled = useLegacyShape ? false : suggestionsEnabledOrWorkflowScopes;
292
+ const workflowScopes = useLegacyShape
293
+ ? (Array.isArray(suggestionsEnabledOrWorkflowScopes)
294
+ ? suggestionsEnabledOrWorkflowScopes
295
+ : undefined)
296
+ : (workflowScopesOrActiveStory as readonly WorkflowScope[] | undefined);
297
+ const activeStory = useLegacyShape
298
+ ? ((isStoryTarget(workflowScopesOrActiveStory)
299
+ ? workflowScopesOrActiveStory
300
+ : MAIN_STORY_TARGET) as EditorStoryTarget)
301
+ : ((activeStoryOrWorkflowCandidates as EditorStoryTarget | undefined) ?? MAIN_STORY_TARGET);
302
+ const workflowCandidates = useLegacyShape
303
+ ? (Array.isArray(activeStoryOrWorkflowCandidates)
304
+ ? activeStoryOrWorkflowCandidates as readonly WorkflowCandidateRange[]
305
+ : undefined)
306
+ : (workflowCandidatesOrBlockedReasons as readonly WorkflowCandidateRange[] | undefined);
307
+ const workflowBlockedReasons = useLegacyShape
308
+ ? (Array.isArray(workflowCandidatesOrBlockedReasons)
309
+ ? workflowCandidatesOrBlockedReasons as readonly WorkflowBlockedCommandReason[]
310
+ : undefined)
311
+ : (workflowBlockedReasonsOrLockedZones as readonly WorkflowBlockedCommandReason[] | undefined);
312
+ const workflowLockedZones = useLegacyShape
313
+ ? (Array.isArray(workflowBlockedReasonsOrLockedZones)
314
+ ? workflowBlockedReasonsOrLockedZones as readonly WorkflowLockedZone[]
315
+ : undefined)
316
+ : (workflowLockedZonesOrActiveWorkItemId as readonly WorkflowLockedZone[] | undefined);
317
+ const activeWorkflowWorkItemId = useLegacyShape
318
+ ? (typeof workflowLockedZonesOrActiveWorkItemId === "string" || workflowLockedZonesOrActiveWorkItemId === null
319
+ ? workflowLockedZonesOrActiveWorkItemId
320
+ : undefined)
321
+ : (activeWorkflowWorkItemIdOrScopeIds as string | null | undefined);
322
+ const activeWorkflowScopeIds = useLegacyShape
323
+ ? (isStringArray(activeWorkflowWorkItemIdOrScopeIds)
324
+ ? activeWorkflowWorkItemIdOrScopeIds as readonly string[]
325
+ : undefined)
326
+ : (activeWorkflowScopeIdsOrMetadata as readonly string[] | undefined);
327
+ const resolvedWorkflowMetadata = useLegacyShape
328
+ ? (isWorkflowMetadataArray(activeWorkflowScopeIdsOrMetadata)
329
+ ? activeWorkflowScopeIdsOrMetadata
330
+ : undefined)
331
+ : workflowMetadata;
332
+
274
333
  const decorations: Decoration[] = [];
275
334
  const railRangeCache = new Map<string, Array<{ from: number; to: number }>>();
276
335
  const activeScopeIds = new Set(activeWorkflowScopeIds ?? []);
336
+ const effectiveWorkflowScopes = workflowScopes ?? [];
277
337
  const lockedPmRanges = collectLockedPmRanges(workflowLockedZones, activeStory, positionMap);
278
338
 
279
339
  // Walk comment threads and create inline decorations
@@ -384,8 +444,8 @@ export function buildDecorations(
384
444
  }
385
445
  }
386
446
 
387
- if (workflowScopes) {
388
- for (const scope of workflowScopes) {
447
+ if (effectiveWorkflowScopes.length > 0) {
448
+ for (const scope of effectiveWorkflowScopes) {
389
449
  const scopeStoryTarget = scope.storyTarget ?? MAIN_STORY_TARGET;
390
450
  if (!storyTargetsEqual(scopeStoryTarget, activeStory)) continue;
391
451
  const pmRange = buildAnchorPmRange(scope.anchor, positionMap);
@@ -398,7 +458,7 @@ export function buildDecorations(
398
458
  activeScopeIds.has(scope.scopeId)
399
459
  );
400
460
 
401
- if (pmRange.allowInline && pmRange.from < pmRange.to) {
461
+ if (isSelectionZone && pmRange.allowInline && pmRange.from < pmRange.to) {
402
462
  const visibleScopeSegments = subtractInlineOverlaps(
403
463
  { from: pmRange.from, to: pmRange.to },
404
464
  lockedPmRanges.filter((range) => range.to > pmRange.from && range.from < pmRange.to),
@@ -429,8 +489,8 @@ export function buildDecorations(
429
489
  }
430
490
  }
431
491
 
432
- if (workflowMetadata) {
433
- for (const metadata of workflowMetadata) {
492
+ if (resolvedWorkflowMetadata) {
493
+ for (const metadata of resolvedWorkflowMetadata) {
434
494
  const metadataStoryTarget = metadata.storyTarget ?? MAIN_STORY_TARGET;
435
495
  if (!storyTargetsEqual(metadataStoryTarget, activeStory)) continue;
436
496
  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
+ }