@beyondwork/docx-react-component 1.0.47 → 1.0.49
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 +16 -11
- package/package.json +30 -41
- package/src/api/public-types.ts +199 -13
- package/src/compare/diff-engine.ts +4 -0
- package/src/core/commands/add-scope.ts +257 -0
- package/src/core/commands/formatting-commands.ts +2 -0
- package/src/core/commands/index.ts +9 -1
- package/src/core/commands/text-commands.ts +3 -1
- package/src/core/schema/text-schema.ts +95 -1
- package/src/core/selection/anchor-conversion.ts +112 -0
- package/src/core/selection/review-anchors.ts +108 -3
- package/src/core/state/text-transaction.ts +103 -7
- package/src/internal/harness-debug-ports.ts +168 -0
- package/src/io/chart-preview-resolver.ts +59 -1
- package/src/io/docx-session.ts +226 -38
- package/src/io/export/serialize-main-document.ts +46 -0
- package/src/io/export/serialize-paragraph-formatting.ts +8 -0
- package/src/io/export/serialize-run-formatting.ts +10 -1
- package/src/io/export/serialize-settings.ts +421 -0
- package/src/io/export/serialize-styles.ts +10 -0
- package/src/io/normalize/normalize-text.ts +1 -0
- package/src/io/ooxml/chart/chart-style-table.ts +543 -0
- package/src/io/ooxml/chart/color-palette.ts +101 -0
- package/src/io/ooxml/chart/compose-series-color.ts +147 -0
- package/src/io/ooxml/chart/parse-axis.ts +277 -0
- package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
- package/src/io/ooxml/chart/parse-series.ts +635 -0
- package/src/io/ooxml/chart/resolve-color.ts +261 -0
- package/src/io/ooxml/chart/types.ts +439 -0
- package/src/io/ooxml/parse-block-structure.ts +99 -0
- package/src/io/ooxml/parse-complex-content.ts +90 -2
- package/src/io/ooxml/parse-main-document.ts +156 -1
- package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
- package/src/io/ooxml/parse-run-formatting.ts +49 -0
- package/src/io/ooxml/parse-scope-markers.ts +184 -0
- package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
- package/src/io/ooxml/parse-settings.ts +97 -1
- package/src/io/ooxml/parse-styles.ts +65 -0
- package/src/io/ooxml/parse-theme.ts +2 -127
- package/src/io/ooxml/property-grab-bag.ts +211 -0
- package/src/io/ooxml/xml-attr-helpers.ts +59 -1
- package/src/io/ooxml/xml-parser.ts +142 -0
- package/src/model/canonical-document.ts +160 -0
- package/src/model/scope-markers.ts +144 -0
- package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
- package/src/runtime/collab/checkpoint-election.ts +75 -0
- package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
- package/src/runtime/collab/checkpoint-store.ts +115 -0
- package/src/runtime/collab/event-types.ts +27 -0
- package/src/runtime/collab/index.ts +29 -0
- package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
- package/src/runtime/collab/runtime-collab-sync.ts +330 -0
- package/src/runtime/collab/workflow-shared.ts +247 -0
- package/src/runtime/document-locations.ts +1 -9
- package/src/runtime/document-outline.ts +1 -9
- package/src/runtime/document-runtime.ts +288 -65
- package/src/runtime/editor-surface/capabilities.ts +63 -50
- package/src/runtime/hyperlink-color-resolver.ts +119 -0
- package/src/runtime/layout/layout-engine-version.ts +8 -1
- package/src/runtime/prerender/cache-envelope.ts +19 -7
- package/src/runtime/prerender/cache-key.ts +25 -14
- package/src/runtime/prerender/canonical-document-hash.ts +63 -0
- package/src/runtime/prerender/customxml-cache.ts +211 -0
- package/src/runtime/prerender/customxml-probe.ts +78 -0
- package/src/runtime/prerender/prerender-document.ts +74 -7
- package/src/runtime/scope-resolver.ts +148 -0
- package/src/runtime/scope-tag-registry.ts +10 -0
- package/src/runtime/surface-projection.ts +102 -37
- package/src/runtime/theme-color-resolver.ts +188 -0
- package/src/runtime/workflow-markup.ts +7 -18
- package/src/ui/WordReviewEditor.tsx +48 -2
- package/src/ui/editor-runtime-boundary.ts +42 -1
- package/src/ui/headless/selection-helpers.ts +10 -23
- package/src/ui/runtime-shortcut-dispatch.ts +12 -7
- package/src/ui/unsupported-previews-policy.ts +23 -0
- package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
- package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
- package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
- package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +16 -1
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type { CommandEvent } from "./event-types.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context passed to `onTrigger` when the scheduler decides a checkpoint
|
|
7
|
+
* should be produced.
|
|
8
|
+
*
|
|
9
|
+
* - `reason: "event-count"` — the count of broadcast events since the
|
|
10
|
+
* last `markCheckpointed()` crossed `eventCountThreshold`.
|
|
11
|
+
* - `reason: "inactivity"` — no new broadcast events arrived for
|
|
12
|
+
* `inactivityMs` after the most recent one, so the document has
|
|
13
|
+
* stabilized and it is a good moment to snapshot.
|
|
14
|
+
*
|
|
15
|
+
* `afterEventId` is the `eventId` of the most recent broadcast event
|
|
16
|
+
* included in the trigger. The caller (the elected author) should
|
|
17
|
+
* pass this verbatim into the `Checkpoint.afterEventId` field so
|
|
18
|
+
* joiners know which tail events still need replay.
|
|
19
|
+
*/
|
|
20
|
+
export interface CheckpointTriggerContext {
|
|
21
|
+
reason: "event-count" | "inactivity";
|
|
22
|
+
afterEventId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CreateCheckpointSchedulerOptions {
|
|
26
|
+
ydoc: Y.Doc;
|
|
27
|
+
/**
|
|
28
|
+
* Fire a trigger once the count of broadcast events since the last
|
|
29
|
+
* `markCheckpointed()` reaches this number. Omit (or set to
|
|
30
|
+
* `undefined`) to disable count-based triggers.
|
|
31
|
+
*/
|
|
32
|
+
eventCountThreshold?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Fire a trigger once no new broadcast events have arrived for this
|
|
35
|
+
* many milliseconds (measured from the most recent event). Omit (or
|
|
36
|
+
* set to `undefined`) to disable inactivity triggers.
|
|
37
|
+
*/
|
|
38
|
+
inactivityMs?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Called when a threshold or inactivity window fires. The scheduler
|
|
41
|
+
* suppresses further triggers until the caller acknowledges by
|
|
42
|
+
* calling `markCheckpointed()` — preventing duplicate checkpoints
|
|
43
|
+
* while the author is still finishing its write.
|
|
44
|
+
*/
|
|
45
|
+
onTrigger: (ctx: CheckpointTriggerContext) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Override `Date.now` — used by tests to avoid real clocks. Defaults
|
|
48
|
+
* to `Date.now`.
|
|
49
|
+
*/
|
|
50
|
+
now?: () => number;
|
|
51
|
+
/**
|
|
52
|
+
* Override `setTimeout` — used by tests to inject a fake timer.
|
|
53
|
+
* Defaults to the global `setTimeout`.
|
|
54
|
+
*/
|
|
55
|
+
setTimeout?: typeof setTimeout;
|
|
56
|
+
/** Override `clearTimeout` — matches `setTimeout`. */
|
|
57
|
+
clearTimeout?: typeof clearTimeout;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CheckpointSchedulerHandle {
|
|
61
|
+
/**
|
|
62
|
+
* Resets the counter + idle state after the caller has written the
|
|
63
|
+
* corresponding checkpoint. Clears the internal "fired" flag so
|
|
64
|
+
* future thresholds can trigger again.
|
|
65
|
+
*/
|
|
66
|
+
markCheckpointed(): void;
|
|
67
|
+
destroy(): void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Watches a shared `Y.Array<CommandEvent>("commandEvents")` and fires
|
|
72
|
+
* `onTrigger` when a count or inactivity threshold suggests a
|
|
73
|
+
* checkpoint should be written. Stateless with respect to the actual
|
|
74
|
+
* checkpoint store — callers compose the two explicitly (election
|
|
75
|
+
* decides who writes; the writer calls `markCheckpointed()` after
|
|
76
|
+
* appending).
|
|
77
|
+
*/
|
|
78
|
+
export function createCheckpointScheduler(
|
|
79
|
+
options: CreateCheckpointSchedulerOptions,
|
|
80
|
+
): CheckpointSchedulerHandle {
|
|
81
|
+
const {
|
|
82
|
+
ydoc,
|
|
83
|
+
eventCountThreshold,
|
|
84
|
+
inactivityMs,
|
|
85
|
+
onTrigger,
|
|
86
|
+
now = Date.now,
|
|
87
|
+
setTimeout: setTimeoutFn = setTimeout,
|
|
88
|
+
clearTimeout: clearTimeoutFn = clearTimeout,
|
|
89
|
+
} = options;
|
|
90
|
+
|
|
91
|
+
const yEvents = ydoc.getArray<CommandEvent>("commandEvents");
|
|
92
|
+
const disabled = eventCountThreshold === undefined && inactivityMs === undefined;
|
|
93
|
+
|
|
94
|
+
let eventsSinceLastCheckpoint = 0;
|
|
95
|
+
let lastEventId: string | null = null;
|
|
96
|
+
let fired = false;
|
|
97
|
+
let idleTimerHandle: ReturnType<typeof setTimeout> | null = null;
|
|
98
|
+
let destroyed = false;
|
|
99
|
+
|
|
100
|
+
function clearIdleTimer(): void {
|
|
101
|
+
if (idleTimerHandle !== null) {
|
|
102
|
+
clearTimeoutFn(idleTimerHandle);
|
|
103
|
+
idleTimerHandle = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function armIdleTimer(): void {
|
|
108
|
+
if (destroyed) return;
|
|
109
|
+
if (inactivityMs === undefined) return;
|
|
110
|
+
clearIdleTimer();
|
|
111
|
+
idleTimerHandle = setTimeoutFn(() => {
|
|
112
|
+
idleTimerHandle = null;
|
|
113
|
+
if (destroyed || fired || lastEventId === null) return;
|
|
114
|
+
fired = true;
|
|
115
|
+
try {
|
|
116
|
+
onTrigger({ reason: "inactivity", afterEventId: lastEventId });
|
|
117
|
+
} catch {
|
|
118
|
+
// Caller errors are isolated; the scheduler keeps running.
|
|
119
|
+
}
|
|
120
|
+
}, inactivityMs);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Avoid pretending there was no history: on init, check what's
|
|
124
|
+
// already in the array so scheduler state reflects the current doc.
|
|
125
|
+
// We don't fire for pre-existing events — we only count new ones
|
|
126
|
+
// post-subscribe. `lastEventId` seeds from the tail so an
|
|
127
|
+
// `inactivityMs` trigger has something to report if the caller
|
|
128
|
+
// wires `now` to a later time.
|
|
129
|
+
const initial = yEvents.toArray();
|
|
130
|
+
if (initial.length > 0) {
|
|
131
|
+
const tail = initial[initial.length - 1];
|
|
132
|
+
if (tail && typeof tail.eventId === "string") {
|
|
133
|
+
lastEventId = tail.eventId;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function onYEventsChange(event: Y.YArrayEvent<CommandEvent>): void {
|
|
138
|
+
if (destroyed) return;
|
|
139
|
+
if (disabled) return;
|
|
140
|
+
let inserted = 0;
|
|
141
|
+
let tailEventId: string | null = null;
|
|
142
|
+
for (const delta of event.changes.delta) {
|
|
143
|
+
if (!Array.isArray(delta.insert)) continue;
|
|
144
|
+
for (const value of delta.insert) {
|
|
145
|
+
const evt = value as CommandEvent | undefined;
|
|
146
|
+
if (evt && typeof evt.eventId === "string") {
|
|
147
|
+
inserted += 1;
|
|
148
|
+
tailEventId = evt.eventId;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (inserted === 0) return;
|
|
153
|
+
|
|
154
|
+
eventsSinceLastCheckpoint += inserted;
|
|
155
|
+
if (tailEventId !== null) lastEventId = tailEventId;
|
|
156
|
+
|
|
157
|
+
if (fired) {
|
|
158
|
+
// Suppress until caller acknowledges with markCheckpointed().
|
|
159
|
+
// Keep counter growing for accuracy on next cycle though.
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (
|
|
164
|
+
eventCountThreshold !== undefined &&
|
|
165
|
+
eventsSinceLastCheckpoint >= eventCountThreshold &&
|
|
166
|
+
lastEventId !== null
|
|
167
|
+
) {
|
|
168
|
+
fired = true;
|
|
169
|
+
clearIdleTimer();
|
|
170
|
+
try {
|
|
171
|
+
onTrigger({ reason: "event-count", afterEventId: lastEventId });
|
|
172
|
+
} catch {
|
|
173
|
+
// Caller errors isolated.
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// New activity: reset the idle window.
|
|
179
|
+
armIdleTimer();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!disabled) {
|
|
183
|
+
yEvents.observe(onYEventsChange);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// `now` is accepted for test determinism; the scheduler doesn't read
|
|
187
|
+
// it today but keeping the option lets future slices (e.g., per-peer
|
|
188
|
+
// backoff) use a shared clock without a signature change.
|
|
189
|
+
void now;
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
markCheckpointed() {
|
|
193
|
+
eventsSinceLastCheckpoint = 0;
|
|
194
|
+
fired = false;
|
|
195
|
+
clearIdleTimer();
|
|
196
|
+
},
|
|
197
|
+
destroy() {
|
|
198
|
+
if (destroyed) return;
|
|
199
|
+
destroyed = true;
|
|
200
|
+
if (!disabled) yEvents.unobserve(onYEventsChange);
|
|
201
|
+
clearIdleTimer();
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
import type { CanonicalDocumentEnvelope } from "../../core/state/editor-state.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single snapshot of the canonical document at a specific point in the
|
|
7
|
+
* broadcast event log. Stored in `Y.Array<Checkpoint>("checkpoints")` so
|
|
8
|
+
* late joiners can apply the latest checkpoint instead of replaying the
|
|
9
|
+
* full command history.
|
|
10
|
+
*
|
|
11
|
+
* Contract:
|
|
12
|
+
* - `afterEventId` is the `eventId` of the last `CommandEvent` included
|
|
13
|
+
* in the snapshot. Joiners replay broadcast events whose position in
|
|
14
|
+
* the shared `Y.Array<CommandEvent>` comes AFTER the event with this
|
|
15
|
+
* id. If the id is not found (e.g. out-of-order Yjs merge), joiners
|
|
16
|
+
* fall back to replaying from the start — the runtime's existing
|
|
17
|
+
* `appliedEventIds` dedup makes that safe but wasteful.
|
|
18
|
+
* - `documentAtCheckpoint` is a full `CanonicalDocumentEnvelope`.
|
|
19
|
+
* Passing it to `createDocumentRuntime({ initialCanonicalDocument })`
|
|
20
|
+
* produces a runtime that matches the author's state at that event.
|
|
21
|
+
* - `authorClientId` identifies which peer produced the checkpoint
|
|
22
|
+
* (single author is elected deterministically — see
|
|
23
|
+
* `checkpoint-scheduler.ts` in a later slice).
|
|
24
|
+
*/
|
|
25
|
+
export interface Checkpoint {
|
|
26
|
+
checkpointId: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
afterEventId: string;
|
|
29
|
+
documentAtCheckpoint: CanonicalDocumentEnvelope;
|
|
30
|
+
authorClientId: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CHECKPOINTS_KEY = "checkpoints";
|
|
34
|
+
|
|
35
|
+
export interface CreateCheckpointStoreOptions {
|
|
36
|
+
ydoc: Y.Doc;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CheckpointStoreHandle {
|
|
40
|
+
/** Append a new checkpoint to the shared log. */
|
|
41
|
+
append(checkpoint: Checkpoint): void;
|
|
42
|
+
/** Most recent checkpoint, or `undefined` when the log is empty. */
|
|
43
|
+
latest(): Checkpoint | undefined;
|
|
44
|
+
/** All checkpoints in insertion order. */
|
|
45
|
+
all(): Checkpoint[];
|
|
46
|
+
/**
|
|
47
|
+
* Subscribe to post-insert notifications. The listener receives the
|
|
48
|
+
* current full list (cheap copy) on every change. Returns an
|
|
49
|
+
* unsubscribe function.
|
|
50
|
+
*/
|
|
51
|
+
subscribe(listener: (checkpoints: Checkpoint[]) => void): () => void;
|
|
52
|
+
/** Detach the underlying observer. Safe to call multiple times. */
|
|
53
|
+
destroy(): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Creates a thin adapter over `ydoc.getArray<Checkpoint>("checkpoints")`.
|
|
58
|
+
* The array is the canonical list — multiple stores on the same Y.Doc
|
|
59
|
+
* share state via Yjs, and peers on the network see every append.
|
|
60
|
+
*
|
|
61
|
+
* Writers should elect a single author (see
|
|
62
|
+
* `createCheckpointScheduler` + election slice) to avoid duplicate
|
|
63
|
+
* snapshots. Readers can always open a store and call `latest()`; the
|
|
64
|
+
* adapter is cheap (no internal cache beyond the Y.Array).
|
|
65
|
+
*/
|
|
66
|
+
export function createCheckpointStore(
|
|
67
|
+
options: CreateCheckpointStoreOptions,
|
|
68
|
+
): CheckpointStoreHandle {
|
|
69
|
+
const { ydoc } = options;
|
|
70
|
+
const yCheckpoints = ydoc.getArray<Checkpoint>(CHECKPOINTS_KEY);
|
|
71
|
+
|
|
72
|
+
const listeners = new Set<(checkpoints: Checkpoint[]) => void>();
|
|
73
|
+
let destroyed = false;
|
|
74
|
+
|
|
75
|
+
function onChange(): void {
|
|
76
|
+
if (destroyed) return;
|
|
77
|
+
if (listeners.size === 0) return;
|
|
78
|
+
const snapshot = yCheckpoints.toArray() as Checkpoint[];
|
|
79
|
+
for (const listener of [...listeners]) {
|
|
80
|
+
try {
|
|
81
|
+
listener(snapshot);
|
|
82
|
+
} catch {
|
|
83
|
+
// Listener exceptions are isolated; the store continues.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
yCheckpoints.observe(onChange);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
append(checkpoint) {
|
|
91
|
+
if (destroyed) return;
|
|
92
|
+
yCheckpoints.push([checkpoint]);
|
|
93
|
+
},
|
|
94
|
+
latest() {
|
|
95
|
+
const length = yCheckpoints.length;
|
|
96
|
+
if (length === 0) return undefined;
|
|
97
|
+
return yCheckpoints.get(length - 1) as Checkpoint;
|
|
98
|
+
},
|
|
99
|
+
all() {
|
|
100
|
+
return yCheckpoints.toArray() as Checkpoint[];
|
|
101
|
+
},
|
|
102
|
+
subscribe(listener) {
|
|
103
|
+
listeners.add(listener);
|
|
104
|
+
return () => {
|
|
105
|
+
listeners.delete(listener);
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
destroy() {
|
|
109
|
+
if (destroyed) return;
|
|
110
|
+
destroyed = true;
|
|
111
|
+
yCheckpoints.unobserve(onChange);
|
|
112
|
+
listeners.clear();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -28,9 +28,35 @@ import type { EditorStoryTarget } from "../../api/public-types.ts";
|
|
|
28
28
|
* current canonical state — see `applyDocumentPatch` in
|
|
29
29
|
* `core/commands/index.ts`.
|
|
30
30
|
*/
|
|
31
|
+
/**
|
|
32
|
+
* Wire version of a {@link CommandEvent}. Bump to `2` on any breaking
|
|
33
|
+
* change to the envelope shape (renames, reorderings, or removed fields).
|
|
34
|
+
*
|
|
35
|
+
* **Versioning policy** — strict equality, not semver. Peers that see an
|
|
36
|
+
* event with a `schemaVersion !== COMMAND_EVENT_SCHEMA_VERSION` skip the
|
|
37
|
+
* event and emit `collab_event_schema_mismatch` on the collab-sync handle
|
|
38
|
+
* so the host can decide whether to prompt a reload. Additive changes
|
|
39
|
+
* (new optional `context` fields, new broadcast command types) do NOT
|
|
40
|
+
* bump this number; peers preserve unknown fields on decode and ignore
|
|
41
|
+
* unknown command types via the existing `BROADCAST_COMMAND_TYPES`
|
|
42
|
+
* classifier.
|
|
43
|
+
*
|
|
44
|
+
* Legacy events written before this field existed decode as `1` via the
|
|
45
|
+
* decoder fallback in `runtime-collab-sync.ts:asCommandEvent` — they
|
|
46
|
+
* continue to replay cleanly on upgraded clients.
|
|
47
|
+
*/
|
|
48
|
+
export const COMMAND_EVENT_SCHEMA_VERSION = 1 as const;
|
|
49
|
+
|
|
31
50
|
export interface CommandEvent {
|
|
32
51
|
/** UUID — dedup key for defensive idempotency. */
|
|
33
52
|
eventId: string;
|
|
53
|
+
/**
|
|
54
|
+
* Wire version of this envelope. See {@link COMMAND_EVENT_SCHEMA_VERSION}
|
|
55
|
+
* for the policy. Every event minted by `createCommandEvent` carries
|
|
56
|
+
* the current version; legacy events without this field are defaulted
|
|
57
|
+
* to `1` at decode time.
|
|
58
|
+
*/
|
|
59
|
+
schemaVersion: 1;
|
|
34
60
|
/** `Y.Doc.clientID` of the originating client. */
|
|
35
61
|
originClientId: number;
|
|
36
62
|
/** User identity for attribution (e.g. `currentUser.userId`). */
|
|
@@ -169,6 +195,7 @@ export interface CreateCommandEventInput {
|
|
|
169
195
|
export function createCommandEvent(input: CreateCommandEventInput): CommandEvent {
|
|
170
196
|
return {
|
|
171
197
|
eventId: generateEventId(),
|
|
198
|
+
schemaVersion: COMMAND_EVENT_SCHEMA_VERSION,
|
|
172
199
|
originClientId: input.originClientId,
|
|
173
200
|
authorId: input.authorId,
|
|
174
201
|
timestamp: input.timestamp,
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
export {
|
|
2
2
|
createRuntimeCollabSync,
|
|
3
3
|
createRuntimeCommandAppliedBridge,
|
|
4
|
+
type RuntimeCollabSyncEvent,
|
|
4
5
|
type RuntimeCollabSyncHandle,
|
|
5
6
|
type RuntimeCollabSyncOptions,
|
|
6
7
|
type RuntimeCommandAppliedBridge,
|
|
7
8
|
type RuntimeCommandAppliedListener,
|
|
8
9
|
} from "./runtime-collab-sync.ts";
|
|
9
10
|
export {
|
|
11
|
+
createCheckpointStore,
|
|
12
|
+
type Checkpoint,
|
|
13
|
+
type CheckpointStoreHandle,
|
|
14
|
+
type CreateCheckpointStoreOptions,
|
|
15
|
+
} from "./checkpoint-store.ts";
|
|
16
|
+
export {
|
|
17
|
+
createCheckpointScheduler,
|
|
18
|
+
type CheckpointSchedulerHandle,
|
|
19
|
+
type CheckpointTriggerContext,
|
|
20
|
+
type CreateCheckpointSchedulerOptions,
|
|
21
|
+
} from "./checkpoint-scheduler.ts";
|
|
22
|
+
export {
|
|
23
|
+
isElectedCheckpointAuthor,
|
|
24
|
+
subscribeElectedCheckpointAuthor,
|
|
25
|
+
} from "./checkpoint-election.ts";
|
|
26
|
+
export {
|
|
27
|
+
COMMAND_EVENT_SCHEMA_VERSION,
|
|
10
28
|
createCommandEvent,
|
|
11
29
|
isBroadcastCommand,
|
|
12
30
|
isLocalOnlyCommand,
|
|
@@ -15,8 +33,19 @@ export {
|
|
|
15
33
|
} from "./event-types.ts";
|
|
16
34
|
export {
|
|
17
35
|
clearLocalCursorState,
|
|
36
|
+
createRemoteCursorTracker,
|
|
18
37
|
getCursorColorForUser,
|
|
19
38
|
getRemoteCursorStates,
|
|
39
|
+
mapRemoteCursorThroughMapping,
|
|
20
40
|
setLocalCursorState,
|
|
21
41
|
type RemoteCursorState,
|
|
42
|
+
type RemoteCursorTrackerHandle,
|
|
43
|
+
type RemoteCursorTrackerOptions,
|
|
22
44
|
} from "./remote-cursor-awareness.ts";
|
|
45
|
+
export {
|
|
46
|
+
createWorkflowShared,
|
|
47
|
+
type CreateWorkflowSharedOptions,
|
|
48
|
+
type SharedWorkflowState,
|
|
49
|
+
type WorkflowSharedHandle,
|
|
50
|
+
type WorkflowSharedResult,
|
|
51
|
+
} from "./workflow-shared.ts";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Awareness } from "y-protocols/awareness";
|
|
2
2
|
import type { EditorStoryTarget } from "../../api/public-types";
|
|
3
|
+
import { mapPosition, type TransactionMapping } from "../../core/selection/mapping.ts";
|
|
4
|
+
import type { RuntimeCommandAppliedBridge } from "./runtime-collab-sync.ts";
|
|
3
5
|
|
|
4
6
|
export interface RemoteCursorState {
|
|
5
7
|
userId: string;
|
|
@@ -91,3 +93,168 @@ export function getRemoteCursorStates(
|
|
|
91
93
|
|
|
92
94
|
return result;
|
|
93
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Maps a remote cursor's anchor and head positions through a
|
|
99
|
+
* {@link TransactionMapping}, returning a new cursor with the positions
|
|
100
|
+
* shifted to point at the same text after the local edit has applied.
|
|
101
|
+
*
|
|
102
|
+
* Semantics:
|
|
103
|
+
* - Insertions before the cursor shift the cursor forward by the insert
|
|
104
|
+
* size.
|
|
105
|
+
* - Insertions at or after the cursor leave it in place.
|
|
106
|
+
* - Deletions before the cursor shift it backward by the deleted span.
|
|
107
|
+
* - Deletions containing the cursor collapse it to the start of the
|
|
108
|
+
* deleted range (the text the cursor was pointing at is gone).
|
|
109
|
+
*
|
|
110
|
+
* Uses the project's existing {@link mapPosition} helper with
|
|
111
|
+
* forward association (`assoc: 1`) so the cursor sticks to the right
|
|
112
|
+
* on insertions — matches Yjs / y-prosemirror conventions.
|
|
113
|
+
*
|
|
114
|
+
* Pure function — safe to call from a `commandAppliedBridge` subscriber
|
|
115
|
+
* or any caller that has captured a transaction mapping.
|
|
116
|
+
*/
|
|
117
|
+
export function mapRemoteCursorThroughMapping(
|
|
118
|
+
cursor: RemoteCursorState,
|
|
119
|
+
mapping: TransactionMapping,
|
|
120
|
+
): RemoteCursorState {
|
|
121
|
+
if (mapping.steps.length === 0) {
|
|
122
|
+
return cursor;
|
|
123
|
+
}
|
|
124
|
+
const anchor = mapPosition(cursor.anchor, 1, mapping);
|
|
125
|
+
const head = mapPosition(cursor.head, 1, mapping);
|
|
126
|
+
if (anchor.position === cursor.anchor && head.position === cursor.head) {
|
|
127
|
+
return cursor;
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
...cursor,
|
|
131
|
+
anchor: anchor.position,
|
|
132
|
+
head: head.position,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface RemoteCursorTrackerOptions {
|
|
137
|
+
awareness: Awareness;
|
|
138
|
+
/**
|
|
139
|
+
* The local `awareness.clientID`. Accepted as a parameter (rather
|
|
140
|
+
* than read from awareness) so hosts that gate on identity policy
|
|
141
|
+
* can reuse the value they already stamped.
|
|
142
|
+
*/
|
|
143
|
+
localClientId: number;
|
|
144
|
+
/**
|
|
145
|
+
* When provided, the tracker subscribes to every local command commit
|
|
146
|
+
* and maps each cached remote cursor through the transaction's
|
|
147
|
+
* mapping. Without this, remote cursor positions go stale on local
|
|
148
|
+
* edits until the peer republishes.
|
|
149
|
+
*/
|
|
150
|
+
commandAppliedBridge?: RuntimeCommandAppliedBridge;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export interface RemoteCursorTrackerHandle {
|
|
154
|
+
/**
|
|
155
|
+
* Returns the current cached view of remote cursors, with positions
|
|
156
|
+
* mapped through every local commit that has landed since the peer
|
|
157
|
+
* last published. Excludes the local client.
|
|
158
|
+
*/
|
|
159
|
+
getRemoteCursors(): RemoteCursorState[];
|
|
160
|
+
destroy(): void;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Creates a stateful tracker that mirrors each peer's awareness cursor
|
|
165
|
+
* locally, maps cached positions through the current client's
|
|
166
|
+
* transaction mappings, and resets the cache from authoritative
|
|
167
|
+
* awareness state whenever a peer publishes.
|
|
168
|
+
*
|
|
169
|
+
* Without the tracker, a remote cursor in `awareness.getStates()` goes
|
|
170
|
+
* stale as soon as the local user edits — the peer's published offset
|
|
171
|
+
* still points at the pre-edit position. The tracker fixes that.
|
|
172
|
+
*/
|
|
173
|
+
export function createRemoteCursorTracker(
|
|
174
|
+
options: RemoteCursorTrackerOptions,
|
|
175
|
+
): RemoteCursorTrackerHandle {
|
|
176
|
+
const { awareness, localClientId, commandAppliedBridge } = options;
|
|
177
|
+
const cache = new Map<number, RemoteCursorState>();
|
|
178
|
+
|
|
179
|
+
function setFromAwareness(clientId: number): void {
|
|
180
|
+
if (clientId === localClientId) {
|
|
181
|
+
// Local client's own state must NEVER populate the remote view.
|
|
182
|
+
// Drop any stale entry (e.g., clientID reuse across sessions).
|
|
183
|
+
cache.delete(clientId);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const clientState = awareness.getStates().get(clientId);
|
|
187
|
+
const cursorState = clientState?.[CURSOR_STATE_KEY];
|
|
188
|
+
if (!cursorState) {
|
|
189
|
+
cache.delete(clientId);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (
|
|
193
|
+
typeof cursorState.userId !== "string" ||
|
|
194
|
+
typeof cursorState.displayName !== "string" ||
|
|
195
|
+
typeof cursorState.anchor !== "number" ||
|
|
196
|
+
typeof cursorState.head !== "number" ||
|
|
197
|
+
!cursorState.storyTarget ||
|
|
198
|
+
typeof cursorState.storyTarget.kind !== "string"
|
|
199
|
+
) {
|
|
200
|
+
cache.delete(clientId);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const safeColor = isSafeCursorColor(cursorState.color)
|
|
204
|
+
? cursorState.color
|
|
205
|
+
: getCursorColorForUser(cursorState.userId);
|
|
206
|
+
cache.set(clientId, {
|
|
207
|
+
...(cursorState as RemoteCursorState),
|
|
208
|
+
color: safeColor,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Seed from current awareness state (covers peers that published
|
|
213
|
+
// before the tracker started).
|
|
214
|
+
for (const [clientId] of awareness.getStates()) {
|
|
215
|
+
setFromAwareness(clientId);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function onAwarenessChange(changes: {
|
|
219
|
+
added: number[];
|
|
220
|
+
updated: number[];
|
|
221
|
+
removed: number[];
|
|
222
|
+
}): void {
|
|
223
|
+
// Only react to REMOTE changes. Local-only awareness updates (e.g.
|
|
224
|
+
// the local user publishing their own cursor) must not wipe the
|
|
225
|
+
// mapped remote entries.
|
|
226
|
+
for (const id of changes.removed) {
|
|
227
|
+
if (id !== localClientId) cache.delete(id);
|
|
228
|
+
}
|
|
229
|
+
for (const id of changes.added) {
|
|
230
|
+
if (id !== localClientId) setFromAwareness(id);
|
|
231
|
+
}
|
|
232
|
+
for (const id of changes.updated) {
|
|
233
|
+
if (id !== localClientId) setFromAwareness(id);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
awareness.on("change", onAwarenessChange);
|
|
237
|
+
|
|
238
|
+
let unsubscribeCommandApplied: (() => void) | null = null;
|
|
239
|
+
if (commandAppliedBridge) {
|
|
240
|
+
unsubscribeCommandApplied = commandAppliedBridge.subscribe(
|
|
241
|
+
(_command, transaction) => {
|
|
242
|
+
if (transaction.mapping.steps.length === 0) return;
|
|
243
|
+
for (const [clientId, cursor] of cache) {
|
|
244
|
+
cache.set(clientId, mapRemoteCursorThroughMapping(cursor, transaction.mapping));
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
getRemoteCursors() {
|
|
252
|
+
return [...cache.values()];
|
|
253
|
+
},
|
|
254
|
+
destroy() {
|
|
255
|
+
awareness.off("change", onAwarenessChange);
|
|
256
|
+
unsubscribeCommandApplied?.();
|
|
257
|
+
cache.clear();
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|