@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.
- package/README.md +103 -13
- package/package.json +1 -1
- package/src/api/package-version.ts +13 -0
- package/src/api/public-types.ts +84 -1
- package/src/core/commands/index.ts +19 -2
- package/src/core/selection/mapping.ts +6 -0
- package/src/io/docx-session.ts +24 -9
- package/src/io/export/build-app-properties-xml.ts +88 -0
- package/src/io/export/serialize-comments.ts +6 -1
- package/src/io/export/serialize-footnotes.ts +10 -9
- package/src/io/export/serialize-headers-footers.ts +11 -10
- package/src/io/export/serialize-main-document.ts +337 -50
- package/src/io/export/serialize-numbering.ts +115 -24
- package/src/io/export/serialize-tables.ts +13 -11
- package/src/io/export/table-properties-xml.ts +35 -16
- package/src/io/export/twip.ts +66 -0
- package/src/io/normalize/normalize-text.ts +5 -0
- package/src/io/ooxml/parse-footnotes.ts +2 -1
- package/src/io/ooxml/parse-headers-footers.ts +2 -1
- package/src/io/ooxml/parse-main-document.ts +21 -1
- package/src/legal/bookmarks.ts +78 -0
- package/src/model/canonical-document.ts +11 -0
- package/src/review/store/scope-tag-diff.ts +130 -0
- package/src/runtime/document-navigation.ts +1 -305
- package/src/runtime/document-runtime.ts +178 -16
- package/src/runtime/layout/docx-font-loader.ts +143 -0
- package/src/runtime/layout/index.ts +188 -0
- package/src/runtime/layout/inert-layout-facet.ts +45 -0
- package/src/runtime/layout/layout-engine-instance.ts +618 -0
- package/src/runtime/layout/layout-invalidation.ts +257 -0
- package/src/runtime/layout/layout-measurement-provider.ts +175 -0
- package/src/runtime/layout/measurement-backend-canvas.ts +307 -0
- package/src/runtime/layout/measurement-backend-empirical.ts +208 -0
- package/src/runtime/layout/page-fragment-mapper.ts +179 -0
- package/src/runtime/layout/page-graph.ts +433 -0
- package/src/runtime/layout/page-layout-snapshot-adapter.ts +70 -0
- package/src/runtime/layout/page-story-resolver.ts +195 -0
- package/src/runtime/layout/paginated-layout-engine.ts +788 -0
- package/src/runtime/layout/public-facet.ts +705 -0
- package/src/runtime/layout/resolved-formatting-document.ts +317 -0
- package/src/runtime/layout/resolved-formatting-state.ts +430 -0
- package/src/runtime/scope-tag-registry.ts +95 -0
- package/src/runtime/session-capabilities.ts +7 -4
- package/src/runtime/surface-projection.ts +1 -0
- package/src/runtime/text-ack-range.ts +49 -0
- package/src/ui/WordReviewEditor.tsx +15 -0
- package/src/ui/editor-runtime-boundary.ts +10 -1
- package/src/ui/editor-surface-controller.tsx +3 -0
- package/src/ui/headless/chrome-registry.ts +235 -0
- package/src/ui/headless/scoped-chrome-policy.ts +164 -0
- package/src/ui/headless/selection-tool-context.ts +2 -0
- package/src/ui/headless/selection-tool-resolver.ts +36 -17
- package/src/ui-tailwind/editor-surface/fast-text-edit-lane.ts +333 -0
- package/src/ui-tailwind/editor-surface/local-edit-session-state.ts +89 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +21 -1
- package/src/ui-tailwind/editor-surface/pm-command-bridge.ts +8 -1
- package/src/ui-tailwind/editor-surface/pm-decorations.ts +73 -13
- package/src/ui-tailwind/editor-surface/predicted-position-map.ts +78 -0
- package/src/ui-tailwind/editor-surface/predicted-tag-preflight.ts +63 -0
- package/src/ui-tailwind/editor-surface/predicted-tx-gate.ts +39 -0
- package/src/ui-tailwind/editor-surface/tw-prosemirror-surface.tsx +173 -6
- package/src/ui-tailwind/theme/editor-theme.css +40 -14
- package/src/ui-tailwind/toolbar/tw-toolbar-icon-button.tsx +2 -2
- package/src/ui-tailwind/toolbar/tw-toolbar.tsx +235 -166
- 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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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 (
|
|
388
|
-
for (const scope of
|
|
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 (
|
|
433
|
-
for (const metadata of
|
|
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
|
+
}
|