@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.
Files changed (80) hide show
  1. package/README.md +16 -11
  2. package/package.json +30 -41
  3. package/src/api/public-types.ts +199 -13
  4. package/src/compare/diff-engine.ts +4 -0
  5. package/src/core/commands/add-scope.ts +257 -0
  6. package/src/core/commands/formatting-commands.ts +2 -0
  7. package/src/core/commands/index.ts +9 -1
  8. package/src/core/commands/text-commands.ts +3 -1
  9. package/src/core/schema/text-schema.ts +95 -1
  10. package/src/core/selection/anchor-conversion.ts +112 -0
  11. package/src/core/selection/review-anchors.ts +108 -3
  12. package/src/core/state/text-transaction.ts +103 -7
  13. package/src/internal/harness-debug-ports.ts +168 -0
  14. package/src/io/chart-preview-resolver.ts +59 -1
  15. package/src/io/docx-session.ts +226 -38
  16. package/src/io/export/serialize-main-document.ts +46 -0
  17. package/src/io/export/serialize-paragraph-formatting.ts +8 -0
  18. package/src/io/export/serialize-run-formatting.ts +10 -1
  19. package/src/io/export/serialize-settings.ts +421 -0
  20. package/src/io/export/serialize-styles.ts +10 -0
  21. package/src/io/normalize/normalize-text.ts +1 -0
  22. package/src/io/ooxml/chart/chart-style-table.ts +543 -0
  23. package/src/io/ooxml/chart/color-palette.ts +101 -0
  24. package/src/io/ooxml/chart/compose-series-color.ts +147 -0
  25. package/src/io/ooxml/chart/parse-axis.ts +277 -0
  26. package/src/io/ooxml/chart/parse-chart-space.ts +885 -0
  27. package/src/io/ooxml/chart/parse-series.ts +635 -0
  28. package/src/io/ooxml/chart/resolve-color.ts +261 -0
  29. package/src/io/ooxml/chart/types.ts +439 -0
  30. package/src/io/ooxml/parse-block-structure.ts +99 -0
  31. package/src/io/ooxml/parse-complex-content.ts +90 -2
  32. package/src/io/ooxml/parse-main-document.ts +156 -1
  33. package/src/io/ooxml/parse-paragraph-formatting.ts +46 -0
  34. package/src/io/ooxml/parse-run-formatting.ts +49 -0
  35. package/src/io/ooxml/parse-scope-markers.ts +184 -0
  36. package/src/io/ooxml/parse-settings-blueprint.ts +349 -0
  37. package/src/io/ooxml/parse-settings.ts +97 -1
  38. package/src/io/ooxml/parse-styles.ts +65 -0
  39. package/src/io/ooxml/parse-theme.ts +2 -127
  40. package/src/io/ooxml/property-grab-bag.ts +211 -0
  41. package/src/io/ooxml/xml-attr-helpers.ts +59 -1
  42. package/src/io/ooxml/xml-parser.ts +142 -0
  43. package/src/model/canonical-document.ts +160 -0
  44. package/src/model/scope-markers.ts +144 -0
  45. package/src/runtime/collab/base-doc-fingerprint.ts +99 -0
  46. package/src/runtime/collab/checkpoint-election.ts +75 -0
  47. package/src/runtime/collab/checkpoint-scheduler.ts +204 -0
  48. package/src/runtime/collab/checkpoint-store.ts +115 -0
  49. package/src/runtime/collab/event-types.ts +27 -0
  50. package/src/runtime/collab/index.ts +29 -0
  51. package/src/runtime/collab/remote-cursor-awareness.ts +167 -0
  52. package/src/runtime/collab/runtime-collab-sync.ts +330 -0
  53. package/src/runtime/collab/workflow-shared.ts +247 -0
  54. package/src/runtime/document-locations.ts +1 -9
  55. package/src/runtime/document-outline.ts +1 -9
  56. package/src/runtime/document-runtime.ts +288 -65
  57. package/src/runtime/editor-surface/capabilities.ts +63 -50
  58. package/src/runtime/hyperlink-color-resolver.ts +119 -0
  59. package/src/runtime/layout/layout-engine-version.ts +8 -1
  60. package/src/runtime/prerender/cache-envelope.ts +19 -7
  61. package/src/runtime/prerender/cache-key.ts +25 -14
  62. package/src/runtime/prerender/canonical-document-hash.ts +63 -0
  63. package/src/runtime/prerender/customxml-cache.ts +211 -0
  64. package/src/runtime/prerender/customxml-probe.ts +78 -0
  65. package/src/runtime/prerender/prerender-document.ts +74 -7
  66. package/src/runtime/scope-resolver.ts +148 -0
  67. package/src/runtime/scope-tag-registry.ts +10 -0
  68. package/src/runtime/surface-projection.ts +102 -37
  69. package/src/runtime/theme-color-resolver.ts +188 -0
  70. package/src/runtime/workflow-markup.ts +7 -18
  71. package/src/ui/WordReviewEditor.tsx +48 -2
  72. package/src/ui/editor-runtime-boundary.ts +42 -1
  73. package/src/ui/headless/selection-helpers.ts +10 -23
  74. package/src/ui/runtime-shortcut-dispatch.ts +12 -7
  75. package/src/ui/unsupported-previews-policy.ts +23 -0
  76. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +10 -0
  77. package/src/ui-tailwind/editor-surface/perf-probe.ts +1 -0
  78. package/src/ui-tailwind/page-stack/tw-page-stack-chrome-layer.tsx +47 -0
  79. package/src/ui-tailwind/page-stack/use-visible-block-range.ts +88 -0
  80. 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
+ }