@kerebron/extension-yjs 0.4.28 → 0.4.30

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/src/lib.ts ADDED
@@ -0,0 +1,230 @@
1
+ import * as Y from 'yjs';
2
+ import { type EditorView } from 'prosemirror-view';
3
+ import { type Node } from 'prosemirror-model';
4
+
5
+ import { ySyncPluginKey } from './keys.js';
6
+
7
+ /**
8
+ * Either a node if type is YXmlElement or an Array of text nodes if YXmlText
9
+ */
10
+ type ProsemirrorMapping = Map<Y.AbstractType<any>, Node>;
11
+
12
+ /**
13
+ * Is null if no timeout is in progress.
14
+ * Is defined if a timeout is in progress.
15
+ * Maps from view
16
+ */
17
+ let viewsToUpdate: Map<EditorView, Map<any, any>> | null = null;
18
+
19
+ const updateMetas = () => {
20
+ const ups: Map<EditorView, Map<any, any>> | null = viewsToUpdate;
21
+ viewsToUpdate = null;
22
+ if (!ups) {
23
+ return;
24
+ }
25
+ ups.forEach((metas, view) => {
26
+ const tr = view.state.tr;
27
+ const syncState = ySyncPluginKey.getState(view.state);
28
+ if (syncState && syncState.binding && !syncState.binding.isDestroyed) {
29
+ metas.forEach((val, key) => {
30
+ tr.setMeta(key, val);
31
+ });
32
+ view.dispatch(tr);
33
+ }
34
+ });
35
+ };
36
+
37
+ export const setMeta = (view: EditorView, key, value) => {
38
+ if (!viewsToUpdate) {
39
+ viewsToUpdate = new Map();
40
+ setTimeout(updateMetas, 0);
41
+ }
42
+
43
+ let subMap = viewsToUpdate.get(view);
44
+ if (subMap === undefined) {
45
+ subMap = new Map();
46
+ viewsToUpdate.set(view, subMap);
47
+ }
48
+ subMap.set(key, value);
49
+ };
50
+
51
+ /**
52
+ * Transforms a Prosemirror based absolute position to a Yjs Cursor (relative position in the Yjs model).
53
+ */
54
+ export const absolutePositionToRelativePosition = (
55
+ pos: number,
56
+ type: Y.XmlFragment,
57
+ mapping: ProsemirrorMapping,
58
+ ): any => {
59
+ if (pos === 0) {
60
+ // if the type is later populated, we want to retain the 0 position (hence assoc=-1)
61
+ return Y.createRelativePositionFromTypeIndex(
62
+ type,
63
+ 0,
64
+ type.length === 0 ? -1 : 0,
65
+ );
66
+ }
67
+
68
+ let n: Y.AbstractType<any> | null = type._first === null
69
+ ? null
70
+ : /** @type {Y.ContentType} */ (type._first.content).type;
71
+ while (n !== null && type !== n) {
72
+ if (n instanceof Y.XmlText) {
73
+ if (n._length >= pos) {
74
+ return Y.createRelativePositionFromTypeIndex(
75
+ n,
76
+ pos,
77
+ type.length === 0 ? -1 : 0,
78
+ );
79
+ } else {
80
+ pos -= n._length;
81
+ }
82
+ if (n._item !== null && n._item.next !== null) {
83
+ n = /** @type {Y.ContentType} */ (n._item.next.content).type;
84
+ } else {
85
+ do {
86
+ n = n._item === null ? null : n._item.parent;
87
+ pos--;
88
+ } while (
89
+ n !== type && n !== null && n._item !== null && n._item.next === null
90
+ );
91
+ if (n !== null && n !== type) {
92
+ // @ts-gnore we know that n.next !== null because of above loop conditition
93
+ n = n._item === null
94
+ ? null
95
+ : /** @type {Y.ContentType} */ (/** @type Y.Item */ (n._item.next)
96
+ .content).type;
97
+ }
98
+ }
99
+ } else {
100
+ const pNodeSize =
101
+ /** @type {any} */ (mapping.get(n) || { nodeSize: 0 }).nodeSize;
102
+ if (n._first !== null && pos < pNodeSize) {
103
+ n = /** @type {Y.ContentType} */ (n._first.content).type;
104
+ pos--;
105
+ } else {
106
+ if (pos === 1 && n._length === 0 && pNodeSize > 1) {
107
+ // edge case, should end in this paragraph
108
+ return new Y.RelativePosition(
109
+ n._item === null ? null : n._item.id,
110
+ n._item === null ? Y.findRootTypeKey(n) : null,
111
+ null,
112
+ );
113
+ }
114
+ pos -= pNodeSize;
115
+ if (n._item !== null && n._item.next !== null) {
116
+ n = /** @type {Y.ContentType} */ (n._item.next.content).type;
117
+ } else {
118
+ if (pos === 0) {
119
+ // set to end of n.parent
120
+ n = n._item === null ? n : n._item.parent;
121
+ return new Y.RelativePosition(
122
+ n._item === null ? null : n._item.id,
123
+ n._item === null ? Y.findRootTypeKey(n) : null,
124
+ null,
125
+ );
126
+ }
127
+ do {
128
+ n = n._item.parent;
129
+ pos--;
130
+ } while (n !== type && /** @type {Y.Item} */ (n._item).next === null);
131
+ // if n is null at this point, we have an unexpected case
132
+ if (n !== type) {
133
+ // We know that n._item.next is defined because of above loop condition
134
+ n =
135
+ /** @type {Y.ContentType} */ (/** @type {Y.Item} */ (/** @type {Y.Item} */ (n
136
+ ._item).next).content).type;
137
+ }
138
+ }
139
+ }
140
+ }
141
+ if (n === null) {
142
+ throw new Error('Unexpected case');
143
+ }
144
+ if (pos === 0 && n.constructor !== Y.XmlText && n !== type) { // TODO: set to <= 0
145
+ return createRelativePosition(n._item.parent, n._item);
146
+ }
147
+ }
148
+ return Y.createRelativePositionFromTypeIndex(
149
+ type,
150
+ type._length,
151
+ type.length === 0 ? -1 : 0,
152
+ );
153
+ };
154
+
155
+ const createRelativePosition = (type: Y.AbstractType<any>, item: Y.Item) => {
156
+ let typeid = null;
157
+ let tname = null;
158
+ if (type._item === null) {
159
+ tname = Y.findRootTypeKey(type);
160
+ } else {
161
+ typeid = Y.createID(type._item.id.client, type._item.id.clock);
162
+ }
163
+ return new Y.RelativePosition(typeid, tname, item.id);
164
+ };
165
+
166
+ export const relativePositionToAbsolutePosition = (
167
+ yDoc: Y.Doc,
168
+ documentType: Y.XmlFragment,
169
+ relPos: any,
170
+ mapping: ProsemirrorMapping,
171
+ ): null | number => {
172
+ const decodedPos = Y.createAbsolutePositionFromRelativePosition(relPos, yDoc);
173
+ if (
174
+ decodedPos === null ||
175
+ (decodedPos.type !== documentType &&
176
+ !Y.isParentOf(documentType, decodedPos.type._item))
177
+ ) {
178
+ return null;
179
+ }
180
+ let type = decodedPos.type;
181
+ let pos = 0;
182
+ if (type instanceof Y.XmlText) {
183
+ pos = decodedPos.index;
184
+ } else if (type._item === null || !type._item.deleted) {
185
+ let n: Y.Item | null = type._first;
186
+ let i = 0;
187
+ while (i < type._length && i < decodedPos.index && n !== null) {
188
+ if (!n.deleted) {
189
+ const t: Y.AbstractType<any> = n.content.type;
190
+ i++;
191
+ if (t instanceof Y.XmlText) {
192
+ pos += t._length;
193
+ } else {
194
+ const node = mapping.get(t);
195
+ pos += node?.nodeSize || 0;
196
+ }
197
+ }
198
+ n = n.right;
199
+ }
200
+ pos += 1; // increase because we go out of n
201
+ }
202
+ while (type !== documentType && type._item !== null) {
203
+ const parent = type._item.parent;
204
+ if (parent instanceof Y.ID || parent === null) {
205
+ continue;
206
+ }
207
+ if (parent._item === null || !parent._item.deleted) {
208
+ pos += 1; // the start tag
209
+ let n = /** @type {Y.AbstractType} */ (parent)._first;
210
+ // now iterate until we found type
211
+ while (n !== null) {
212
+ const contentType: Y.AbstractType<any> = n.content.type;
213
+ if (contentType === type) {
214
+ break;
215
+ }
216
+ if (!n.deleted) {
217
+ if (contentType instanceof Y.XmlText) {
218
+ pos += contentType._length;
219
+ } else {
220
+ const node = mapping.get(contentType);
221
+ pos += node?.nodeSize || 0;
222
+ }
223
+ }
224
+ n = n.right;
225
+ }
226
+ }
227
+ type = parent;
228
+ }
229
+ return pos - 1; // we don't count the most outer tag, because it is a fragment
230
+ };
@@ -0,0 +1,10 @@
1
+ export const userColors = [
2
+ { color: '#30bced', light: '#30bced33' },
3
+ { color: '#6eeb83', light: '#6eeb8333' },
4
+ { color: '#ffbc42', light: '#ffbc4233' },
5
+ { color: '#ecd444', light: '#ecd44433' },
6
+ { color: '#ee6352', light: '#ee635233' },
7
+ { color: '#9ac2c9', light: '#9ac2c933' },
8
+ { color: '#8acb88', light: '#8acb8833' },
9
+ { color: '#1be7ff', light: '#1be7ff33' },
10
+ ];
package/src/utils.ts ADDED
@@ -0,0 +1,13 @@
1
+ import * as sha256 from 'lib0/hash/sha256';
2
+ import * as buf from 'lib0/buffer';
3
+
4
+ const _convolute = (digest: Uint8Array) => {
5
+ const N = 6;
6
+ for (let i = N; i < digest.length; i++) {
7
+ digest[i % N] = digest[i % N] ^ digest[i];
8
+ }
9
+ return digest.slice(0, N);
10
+ };
11
+
12
+ export const hashOfJSON = (json: any) =>
13
+ buf.toBase64(_convolute(sha256.digest(buf.encodeAny(json))));
@@ -0,0 +1,228 @@
1
+ import * as Y from 'yjs';
2
+ import * as awarenessProtocol from 'y-protocols/awareness';
3
+
4
+ import { EditorState, Plugin, PluginKey } from 'prosemirror-state';
5
+
6
+ import type { CoreEditor } from '@kerebron/editor';
7
+ import type {
8
+ ExtensionRemoteSelection,
9
+ SelectionState,
10
+ } from '@kerebron/extension-basic-editor/ExtensionRemoteSelection';
11
+ import { remoteSelectionPluginKey } from '@kerebron/extension-basic-editor/ExtensionRemoteSelection';
12
+
13
+ import {
14
+ absolutePositionToRelativePosition,
15
+ relativePositionToAbsolutePosition,
16
+ setMeta,
17
+ } from './lib.js';
18
+ import { ySyncPluginKey } from './keys.js';
19
+
20
+ export const yPositionPluginKey = new PluginKey('yjs-position');
21
+
22
+ type AwarenessListener = (
23
+ { added, updated, removed }: {
24
+ added: number[];
25
+ updated: number[];
26
+ removed: number[];
27
+ },
28
+ s: any,
29
+ t: any,
30
+ ) => void;
31
+
32
+ interface PositionPluginConfig {
33
+ getSelection?: (arg0: any) => any;
34
+ }
35
+
36
+ /**
37
+ * Default awareness state filter
38
+ */
39
+ export const defaultAwarenessStateFilter = (
40
+ currentClientId: number,
41
+ userClientId: number,
42
+ _user: any,
43
+ ): boolean => currentClientId !== userClientId;
44
+
45
+ export const yPositionPlugin = (
46
+ awareness: awarenessProtocol.Awareness,
47
+ editor: CoreEditor,
48
+ {
49
+ getSelection = (state: EditorState) => state.selection,
50
+ }: PositionPluginConfig = {},
51
+ cursorStateField: string = 'cursor',
52
+ ) => {
53
+ return new Plugin({
54
+ key: yPositionPluginKey,
55
+ view: (view) => {
56
+ const extension: ExtensionRemoteSelection = editor.getExtension(
57
+ 'remote-selection',
58
+ )!;
59
+
60
+ const awarenessListener: AwarenessListener = (
61
+ { added, updated, removed },
62
+ ) => {
63
+ const clients = added.concat(updated).concat(removed);
64
+ if (
65
+ clients.findIndex((id: number) => id !== awareness.doc.clientID) ===
66
+ -1
67
+ ) {
68
+ return;
69
+ }
70
+
71
+ if (view.docView) {
72
+ setMeta(view, remoteSelectionPluginKey, {
73
+ remotePositionUpdated: true,
74
+ });
75
+ }
76
+
77
+ const remoteStates: SelectionState[] = [];
78
+
79
+ const ystate = ySyncPluginKey.getState(view.state);
80
+ const y = ystate.doc;
81
+
82
+ awareness.getStates().forEach((aw, clientId) => {
83
+ if (!defaultAwarenessStateFilter(y.clientID, clientId, aw)) {
84
+ return;
85
+ }
86
+
87
+ if (!aw.cursor) {
88
+ return;
89
+ }
90
+
91
+ let anchor = relativePositionToAbsolutePosition(
92
+ y,
93
+ ystate.type,
94
+ Y.createRelativePositionFromJSON(aw.cursor.anchor),
95
+ ystate.binding.mapping,
96
+ );
97
+ let head = relativePositionToAbsolutePosition(
98
+ y,
99
+ ystate.type,
100
+ Y.createRelativePositionFromJSON(aw.cursor.head),
101
+ ystate.binding.mapping,
102
+ );
103
+
104
+ if (anchor !== null && head !== null) {
105
+ remoteStates.push({
106
+ clientId,
107
+ user: {
108
+ name: aw.user?.name,
109
+ color: aw.user?.color,
110
+ colorLight: aw.user?.colorLight,
111
+ },
112
+ cursor: {
113
+ anchor,
114
+ head,
115
+ },
116
+ });
117
+ }
118
+ });
119
+
120
+ extension.setRemoteStates(remoteStates);
121
+ // view.dispatch({ annotations: [yRemoteSelectionsAnnotation.of([])] });
122
+ };
123
+
124
+ {
125
+ // if (
126
+ // ystate.snapshot != null || ystate.prevSnapshot != null ||
127
+ // ystate.binding.mapping.size === 0
128
+ // ) {
129
+ // // do not render cursors while snapshot is active
130
+ // return DecorationSet.create(state.doc, []);
131
+ // }
132
+ }
133
+
134
+ const updateAwareness = (
135
+ selectionAnchor: number,
136
+ selectionHead: number,
137
+ ) => {
138
+ const ystate = ySyncPluginKey.getState(view.state);
139
+ const current = awareness.getLocalState() || {};
140
+
141
+ const anchor: Y.RelativePosition = absolutePositionToRelativePosition(
142
+ selectionAnchor,
143
+ ystate.type,
144
+ ystate.binding.mapping,
145
+ );
146
+ const head: Y.RelativePosition = absolutePositionToRelativePosition(
147
+ selectionHead,
148
+ ystate.type,
149
+ ystate.binding.mapping,
150
+ );
151
+
152
+ if (
153
+ current.cursor == null ||
154
+ !Y.compareRelativePositions(
155
+ Y.createRelativePositionFromJSON(current.cursor.anchor),
156
+ anchor,
157
+ ) ||
158
+ !Y.compareRelativePositions(
159
+ Y.createRelativePositionFromJSON(current.cursor.head),
160
+ head,
161
+ )
162
+ ) {
163
+ awareness.setLocalStateField(cursorStateField, {
164
+ anchor,
165
+ head,
166
+ });
167
+ }
168
+ };
169
+
170
+ const clearAwareness = () => {
171
+ const ystate = ySyncPluginKey.getState(view.state);
172
+ const current = awareness.getLocalState() || {};
173
+
174
+ if (
175
+ current.cursor != null &&
176
+ relativePositionToAbsolutePosition(
177
+ ystate.doc,
178
+ ystate.type,
179
+ Y.createRelativePositionFromJSON(current.cursor.anchor),
180
+ ystate.binding.mapping,
181
+ ) !== null
182
+ ) {
183
+ // delete cursor information if current cursor information is owned by this editor binding
184
+ awareness.setLocalStateField(cursorStateField, null);
185
+ }
186
+ };
187
+
188
+ const updateCursorInfo = () => {
189
+ if (view.hasFocus()) {
190
+ const selection = getSelection(view.state);
191
+ updateAwareness(selection.anchor, selection.head);
192
+ } else {
193
+ // clearAwareness();
194
+ }
195
+ };
196
+
197
+ const localPositionChangedListener = (event: CustomEvent) => {
198
+ const { detail } = event;
199
+ updateAwareness(detail.anchor, detail.head);
200
+ };
201
+
202
+ editor.addEventListener(
203
+ 'localPositionChanged',
204
+ localPositionChangedListener,
205
+ );
206
+
207
+ awareness.on('change', awarenessListener);
208
+ view.dom.addEventListener('focusin', updateCursorInfo);
209
+ view.dom.addEventListener('focusout', updateCursorInfo);
210
+ return {
211
+ update: updateCursorInfo,
212
+ destroy: () => {
213
+ view.dom.removeEventListener('focusin', updateCursorInfo);
214
+ view.dom.removeEventListener('focusout', updateCursorInfo);
215
+ awareness.off('change', awarenessListener);
216
+ awareness.setLocalStateField(cursorStateField, null);
217
+ editor.removeEventListener(
218
+ 'localPositionChanged',
219
+ localPositionChangedListener,
220
+ );
221
+ },
222
+ };
223
+ },
224
+ updateCursorInfo(state: EditorState) {
225
+ throw new Error('TODO: merge with updateCursorInfo above');
226
+ },
227
+ });
228
+ };