Skip to content

Commit d3481fd

Browse files
authored
fix: ignore out-of-bounds positions (#47)
1 parent df52c3f commit d3481fd

3 files changed

Lines changed: 77 additions & 63 deletions

File tree

src/lib.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import {
1313
type Value,
1414
} from "loro-crdt";
1515
import { type Attrs, Mark, Node, Schema } from "prosemirror-model";
16-
import { EditorState } from "prosemirror-state";
16+
import { type EditorState, TextSelection } from "prosemirror-state";
17+
import type { EditorView } from "prosemirror-view";
1718

1819
export type LoroChildrenListType = LoroList<
1920
LoroMap<LoroNodeContainerType> | LoroText
@@ -691,3 +692,30 @@ export function clearChangedNodes(
691692
}
692693
}
693694
}
695+
696+
/**
697+
* Set a text selection between the given anchor and head positions. This
698+
* function will ignore out-of-bounds positions, and find a valid selection near
699+
* the given positions.
700+
*/
701+
export function safeSetSelection(
702+
view: EditorView,
703+
anchor: number,
704+
head?: number,
705+
): void {
706+
const doc = view.state.doc;
707+
const docSize = doc.content.size;
708+
if (
709+
anchor < 0 ||
710+
anchor > docSize ||
711+
(head != null && (head < 0 || head > docSize))
712+
) {
713+
return;
714+
}
715+
716+
const $anchor = doc.resolve(anchor);
717+
const $head = head != null ? doc.resolve(head) : undefined;
718+
719+
const selection = TextSelection.between($anchor, $head || $anchor);
720+
view.dispatch(view.state.tr.setSelection(selection));
721+
}

src/sync-plugin.ts

Lines changed: 43 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,35 @@
1-
import {
2-
type ContainerID,
1+
import type {
2+
ContainerID,
3+
Cursor,
34
LoroDoc,
4-
type LoroEventBatch,
5+
LoroEventBatch,
56
LoroMap,
6-
type Subscription,
7+
Subscription,
78
} from "loro-crdt";
89
import { Fragment, Slice } from "prosemirror-model";
910
import {
10-
EditorState,
11+
type EditorState,
1112
Plugin,
1213
PluginKey,
1314
type StateField,
14-
TextSelection,
1515
} from "prosemirror-state";
16-
import { EditorView } from "prosemirror-view";
16+
import type { EditorView } from "prosemirror-view";
1717

18+
import {
19+
convertPmSelectionToCursors,
20+
cursorToAbsolutePosition,
21+
} from "./cursor-plugin";
1822
import {
1923
clearChangedNodes,
2024
createNodeFromLoroObj,
2125
type LoroDocType,
2226
type LoroNodeContainerType,
2327
type LoroNodeMapping,
2428
ROOT_DOC_KEY,
29+
safeSetSelection,
2530
updateLoroToPmState,
2631
} from "./lib";
2732
import { configLoroTextStyle } from "./text-style";
28-
import {
29-
convertPmSelectionToCursors,
30-
cursorToAbsolutePosition,
31-
} from "./cursor-plugin";
3233
import { loroUndoPluginKey } from "./undo-plugin";
3334

3435
export const loroSyncPluginKey = new PluginKey<LoroSyncPluginState>(
@@ -227,25 +228,36 @@ function updateNodeOnLoroEvent(view: EditorView, event: LoroEventBatch) {
227228
type: "non-local-updates",
228229
});
229230
view.dispatch(tr);
230-
Promise.resolve().then(() => {
231-
if (anchor && focus) {
232-
const state = loroSyncPluginKey.getState(
233-
view.state,
234-
) as LoroSyncPluginState;
235-
const anchorPos = cursorToAbsolutePosition(
236-
anchor,
237-
state.doc,
238-
state.mapping,
239-
)[0];
240-
const focusPos =
241-
focus && cursorToAbsolutePosition(focus, state.doc, state.mapping)[0];
242-
const selection = TextSelection.create(
243-
view.state.tr.doc,
244-
anchorPos,
245-
focusPos ?? undefined,
246-
);
247-
const tr = view.state.tr.setSelection(selection);
248-
view.dispatch(tr);
249-
}
231+
232+
if (anchor == null) {
233+
return;
234+
}
235+
setTimeout(() => {
236+
syncCursorsToPmSelection(view, anchor, focus);
250237
});
251238
}
239+
240+
/**
241+
* Update ProseMirror selection based on the given Loro cursors.
242+
*/
243+
export function syncCursorsToPmSelection(
244+
view: EditorView,
245+
anchor: Cursor,
246+
focus?: Cursor,
247+
) {
248+
const state = loroSyncPluginKey.getState(view.state);
249+
if (!state) {
250+
return;
251+
}
252+
253+
const { doc, mapping } = state;
254+
const anchorPos = cursorToAbsolutePosition(anchor, doc, mapping)[0];
255+
const focusPos = focus && cursorToAbsolutePosition(focus, doc, mapping)[0];
256+
if (anchorPos == null) {
257+
return;
258+
}
259+
260+
// If the cursors are synced faster than the document, then the cursors might
261+
// be out of bounds. Thus, we need to check if the cursors are out of bounds.
262+
safeSetSelection(view, anchorPos, focusPos);
263+
}

src/undo-plugin.ts

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,10 @@ import {
66
Plugin,
77
PluginKey,
88
type StateField,
9-
TextSelection,
109
} from "prosemirror-state";
1110
import { EditorView } from "prosemirror-view";
12-
import {
13-
convertPmSelectionToCursors,
14-
cursorToAbsolutePosition,
15-
} from "./cursor-plugin";
16-
import { loroSyncPluginKey } from "./sync-plugin";
11+
import { convertPmSelectionToCursors } from "./cursor-plugin";
12+
import { loroSyncPluginKey, syncCursorsToPmSelection } from "./sync-plugin";
1713
import { configLoroTextStyle } from "./text-style";
1814

1915
export interface LoroUndoPluginProps {
@@ -130,35 +126,13 @@ export const LoroUndoPlugin = (props: LoroUndoPluginProps): Plugin => {
130126
return;
131127
}
132128

133-
const anchor = meta.cursors[0] ?? null;
134-
const focus = meta.cursors[1] ?? null;
129+
const anchor: Cursor | undefined = meta.cursors[0];
130+
const focus: Cursor | undefined = meta.cursors[1];
135131
if (anchor == null) {
136132
return;
137133
}
138-
139134
setTimeout(() => {
140-
try {
141-
const anchorPos = cursorToAbsolutePosition(
142-
anchor,
143-
loroState.doc,
144-
loroState.mapping,
145-
)[0];
146-
const focusPos =
147-
focus &&
148-
cursorToAbsolutePosition(
149-
focus,
150-
loroState.doc,
151-
loroState.mapping,
152-
)[0];
153-
const selection = TextSelection.create(
154-
view.state.doc,
155-
anchorPos,
156-
focusPos ?? undefined,
157-
);
158-
view.dispatch(view.state.tr.setSelection(selection));
159-
} catch (e) {
160-
console.error(e);
161-
}
135+
syncCursorsToPmSelection(view, anchor, focus);
162136
}, 0);
163137
});
164138
return {

0 commit comments

Comments
 (0)