@beyondwork/docx-react-component 1.0.64 → 1.0.66

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.64",
4
+ "version": "1.0.66",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "type": "module",
7
7
  "sideEffects": [
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Local-selection remap for remote-origin replays.
3
+ *
4
+ * When a remote peer's command arrives, the runtime replays that command
5
+ * through `executeEditorCommand`. The resulting transaction carries a
6
+ * `nextState.selection` which represents the AUTHOR's reducer result —
7
+ * where the remote peer's caret landed after their own insert / delete.
8
+ * That value is useful for deterministic replay of the mutation but it is
9
+ * NOT the correct local caret for a passive viewer who did not initiate
10
+ * the edit.
11
+ *
12
+ * Without this helper, a passive viewer's caret would jump to wherever
13
+ * the remote author's caret ended up — a visible, confusing artefact
14
+ * reported by users as "my cursor moves when someone else types".
15
+ *
16
+ * Semantics (matches Yjs / y-prosemirror convention for local cursor
17
+ * preservation across remote edits):
18
+ *
19
+ * - Remote insert strictly BEFORE the local caret shifts the caret
20
+ * forward by the insert size. The caret still points at the same
21
+ * logical character.
22
+ * - Remote insert strictly AFTER the local caret leaves it unchanged.
23
+ * - Remote insert AT the caret respects forward association for the
24
+ * head (+1) and backward association for the anchor (-1) — inserts
25
+ * appear before the caret on the head side, matching PM / Yjs.
26
+ * - Remote delete entirely BEFORE the caret shifts it backward by the
27
+ * deleted span.
28
+ * - Remote delete containing the caret collapses it to the start of
29
+ * the deleted range (the character it pointed at is gone).
30
+ * - Remote delete of a range selection collapses the selection to the
31
+ * mapped boundary; we never leave the live local caret in a
32
+ * `detached` state — detached is a review-anchor concept, not a
33
+ * live caret concept.
34
+ *
35
+ * This helper is a pure function over the internal `SelectionSnapshot`
36
+ * shape (runtime-facing, NOT the public projection). Cross-story
37
+ * replays are filtered out by the caller (`applyRemoteCommand` /
38
+ * `applyRemoteCommandBatch`); this helper assumes the mapping is
39
+ * applicable to the local selection's story.
40
+ *
41
+ * Related deferred work: per-story anchor persistence across reload
42
+ * (Y.RelativePosition against a Y.Text mirror) is intentionally NOT
43
+ * done here — the current `TransactionMapping` substrate is sufficient
44
+ * for live-session caret stability, which is what this bug requires.
45
+ * See `docs/plans/lane-4-collab-clm-vallor.md` P11 sub-bullet for the
46
+ * follow-up.
47
+ */
48
+
49
+ import {
50
+ type SelectionSnapshot,
51
+ createSelectionSnapshot,
52
+ } from "../../core/state/editor-state.ts";
53
+ import {
54
+ anchorUnaffectedByMapping,
55
+ createNodeAnchor,
56
+ createRangeAnchor,
57
+ DEFAULT_BOUNDARY_ASSOC,
58
+ mapAnchor,
59
+ mapPosition,
60
+ type TransactionMapping,
61
+ } from "../../core/selection/mapping.ts";
62
+
63
+ /**
64
+ * Map a LIVE local selection forward through a remote transaction's
65
+ * mapping. Guarantees the returned selection never holds a `detached`
66
+ * `activeRange` — a detached anchor is a review-state concept
67
+ * (comments / revisions that lost their text) and is never a correct
68
+ * representation for a live caret. If the remote edit deleted the text
69
+ * the caret pointed at, the caret is collapsed to the logical boundary
70
+ * of the deletion.
71
+ *
72
+ * Returns the same reference when the mapping provably cannot shift the
73
+ * selection, so callers can use referential equality to skip downstream
74
+ * work.
75
+ */
76
+ export function mapLocalSelectionOnRemoteReplay(
77
+ selection: SelectionSnapshot,
78
+ mapping: TransactionMapping,
79
+ ): SelectionSnapshot {
80
+ if (mapping.steps.length === 0) {
81
+ return selection;
82
+ }
83
+ if (anchorUnaffectedByMapping(selection.activeRange, mapping)) {
84
+ return selection;
85
+ }
86
+
87
+ const active = selection.activeRange;
88
+
89
+ if (active.kind === "range") {
90
+ const directionForward = selection.anchor <= selection.head;
91
+ const isCollapsed = active.range.from === active.range.to;
92
+
93
+ if (isCollapsed) {
94
+ const collapsedAt = mapPosition(active.range.from, 1, mapping).position;
95
+ return {
96
+ anchor: collapsedAt,
97
+ head: collapsedAt,
98
+ isCollapsed: true,
99
+ activeRange: createRangeAnchor(
100
+ collapsedAt,
101
+ collapsedAt,
102
+ DEFAULT_BOUNDARY_ASSOC,
103
+ ),
104
+ };
105
+ }
106
+
107
+ const mapped = mapAnchor(active, mapping);
108
+
109
+ if (mapped.kind === "range") {
110
+ const { from, to } = mapped.range;
111
+ return {
112
+ anchor: directionForward ? from : to,
113
+ head: directionForward ? to : from,
114
+ isCollapsed: from === to,
115
+ activeRange: mapped,
116
+ };
117
+ }
118
+
119
+ // Detached — the text under the selection is gone. Collapse to the
120
+ // deletion boundary. We use `mapPosition` on the original anchor
121
+ // with forward association (+1); `mapPositionThroughStep` collapses
122
+ // positions that land inside a deleted span to `step.from +
123
+ // insertSize`, which is the correct logical "where the selection
124
+ // used to start" position.
125
+ const collapsedAt = mapPosition(active.range.from, 1, mapping).position;
126
+ return {
127
+ anchor: collapsedAt,
128
+ head: collapsedAt,
129
+ isCollapsed: true,
130
+ activeRange: createRangeAnchor(
131
+ collapsedAt,
132
+ collapsedAt,
133
+ DEFAULT_BOUNDARY_ASSOC,
134
+ ),
135
+ };
136
+ }
137
+
138
+ if (active.kind === "node") {
139
+ const mapped = mapAnchor(active, mapping);
140
+ if (mapped.kind === "node") {
141
+ return {
142
+ anchor: mapped.at,
143
+ head: mapped.at,
144
+ isCollapsed: true,
145
+ activeRange: mapped,
146
+ };
147
+ }
148
+ if (mapped.kind === "range") {
149
+ return {
150
+ anchor: mapped.range.from,
151
+ head: mapped.range.to,
152
+ isCollapsed: mapped.range.from === mapped.range.to,
153
+ activeRange: mapped,
154
+ };
155
+ }
156
+ const collapsedAt = mapPosition(active.at, active.assoc, mapping).position;
157
+ return {
158
+ anchor: collapsedAt,
159
+ head: collapsedAt,
160
+ isCollapsed: true,
161
+ activeRange: createNodeAnchor(collapsedAt, active.assoc),
162
+ };
163
+ }
164
+
165
+ // `kind === "detached"` — already review-state-detached before the
166
+ // remote edit. The public `SelectionSnapshot` is not supposed to hold
167
+ // a detached activeRange (it's for comment/revision anchors), but if
168
+ // one slips in here, collapse it to a safe zero position. This arm
169
+ // exists purely for type exhaustiveness.
170
+ return createSelectionSnapshot(0, 0);
171
+ }
@@ -285,6 +285,7 @@ import {
285
285
  } from "./object-grab/index.ts";
286
286
  import type { EditorStatePayload } from "../io/ooxml/workflow-payload.ts";
287
287
  import type { SharedWorkflowState } from "./collab/workflow-shared.ts";
288
+ import { mapLocalSelectionOnRemoteReplay } from "./collab/map-local-selection-on-remote-replay.ts";
288
289
  import { formatPageNumber } from "./page-number-format.ts";
289
290
 
290
291
  /** Internal extension of ExportDocxOptions that threads the collected
@@ -2983,13 +2984,9 @@ export function createDocumentRuntime(
2983
2984
  const transaction = executeEditorCommand(replayState, command, replayContext);
2984
2985
  if (crossStoryReplay) {
2985
2986
  // Cross-story replay: the transaction's resulting selection is
2986
- // in the remote story's region. Don't leak that into local
2987
- // `state.selection`preserve the pre-replay local selection
2988
- // so the local caret stays where the user is focused. The
2989
- // document delta still applies; only the selection is filtered.
2990
- // Full position-mapping of the local cursor through the remote
2991
- // edit is a separate P11 sub-bullet (Awareness cursor transaction
2992
- // mapping).
2987
+ // in the remote story's region. Preserve B's local selection
2988
+ // unchanged — the document delta applies to a story B isn't
2989
+ // looking at, so B's caret shouldn't move at all.
2993
2990
  commitRemote({
2994
2991
  ...transaction,
2995
2992
  nextState: {
@@ -2998,7 +2995,23 @@ export function createDocumentRuntime(
2998
2995
  },
2999
2996
  });
3000
2997
  } else {
3001
- commitRemote(transaction);
2998
+ // Same-story replay: map B's CURRENT local selection through
2999
+ // the remote transaction's mapping so the caret stays pointed
3000
+ // at the same logical content. Do NOT adopt
3001
+ // `transaction.nextState.selection` — that is the remote
3002
+ // author's caret position, not B's. Without this, B would see
3003
+ // their own cursor jump whenever A types.
3004
+ const mappedLocalSelection = mapLocalSelectionOnRemoteReplay(
3005
+ state.selection,
3006
+ transaction.mapping,
3007
+ );
3008
+ commitRemote({
3009
+ ...transaction,
3010
+ nextState: {
3011
+ ...transaction.nextState,
3012
+ selection: mappedLocalSelection,
3013
+ },
3014
+ });
3002
3015
  }
3003
3016
  } catch (error) {
3004
3017
  emitError(toRuntimeError(error));
@@ -3022,7 +3035,11 @@ export function createDocumentRuntime(
3022
3035
  const stepsAccumulator: import("../core/selection/mapping.ts").MappingStep[] = [];
3023
3036
  let anyDirty = false;
3024
3037
  let lastNextState: typeof state | null = null;
3025
- let lastCrossStoryReplay = false;
3038
+ // Running local selection: seeded from CURRENT local state, then
3039
+ // mapped forward through each same-story envelope's mapping.
3040
+ // Cross-story envelopes do not touch this value — B's caret
3041
+ // should never move from a story B isn't looking at.
3042
+ let runningLocalSelection: typeof state.selection = state.selection;
3026
3043
  const warningsAdded: import("../core/state/editor-state.ts").EditorWarning[] = [];
3027
3044
  const warningsCleared: Array<{ warningId: string; code: import("../core/state/editor-state.ts").EditorWarning["code"] }> = [];
3028
3045
  const SINGULAR_EFFECT_KEYS = [
@@ -3089,7 +3106,12 @@ export function createDocumentRuntime(
3089
3106
  }
3090
3107
  }
3091
3108
  lastNextState = transaction.nextState;
3092
- lastCrossStoryReplay = crossStoryReplay;
3109
+ if (!crossStoryReplay) {
3110
+ runningLocalSelection = mapLocalSelectionOnRemoteReplay(
3111
+ runningLocalSelection,
3112
+ transaction.mapping,
3113
+ );
3114
+ }
3093
3115
  }
3094
3116
 
3095
3117
  // Singular-effect collision (≥2 envelopes both setting the same
@@ -3114,9 +3136,7 @@ export function createDocumentRuntime(
3114
3136
  return;
3115
3137
  }
3116
3138
  const consolidated = {
3117
- nextState: lastCrossStoryReplay
3118
- ? { ...lastNextState, selection: state.selection }
3119
- : lastNextState,
3139
+ nextState: { ...lastNextState, selection: runningLocalSelection },
3120
3140
  mapping: { steps: stepsAccumulator },
3121
3141
  effects: {
3122
3142
  warningsAdded,