@dxos/react-ui-editor 0.3.11-main.8be6c8a → 0.3.11-main.8ef1cc6
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/dist/lib/browser/index.mjs +306 -402
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +3 -0
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +4 -9
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +4 -4
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/defs.d.ts +4 -4
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/index.d.ts +0 -1
- package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +4 -9
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +14 -5
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/{util → extensions}/cursor.d.ts +9 -1
- package/dist/types/src/extensions/cursor.d.ts.map +1 -0
- package/dist/types/src/extensions/hr.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +2 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner.d.ts +4 -0
- package/dist/types/src/extensions/outliner.d.ts.map +1 -0
- package/dist/types/src/extensions/tasklist.d.ts +5 -1
- package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +1 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextModel.d.ts +0 -5
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/hooks/yjs.d.ts.map +1 -1
- package/dist/types/src/styles/markdown.d.ts.map +1 -1
- package/dist/types/src/themes/default.d.ts.map +1 -1
- package/dist/types/src/util.d.ts.map +1 -0
- package/package.json +25 -24
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +28 -26
- package/src/components/TextEditor/TextEditor.stories.tsx +10 -43
- package/src/components/TextEditor/TextEditor.tsx +31 -41
- package/src/components/TextEditor/comments.stories.tsx +355 -0
- package/src/extensions/automerge/automerge.ts +12 -14
- package/src/extensions/automerge/cursor.ts +2 -1
- package/src/extensions/automerge/defs.ts +13 -9
- package/src/extensions/automerge/index.ts +0 -1
- package/src/extensions/automerge/semaphore.ts +10 -12
- package/src/extensions/awareness.ts +27 -33
- package/src/extensions/code.ts +24 -18
- package/src/extensions/comments.ts +128 -140
- package/src/extensions/cursor.ts +46 -0
- package/src/extensions/hr.ts +8 -9
- package/src/extensions/image.ts +2 -2
- package/src/extensions/index.ts +2 -0
- package/src/extensions/link.ts +7 -7
- package/src/extensions/markdown/highlight.ts +0 -8
- package/src/extensions/outliner.ts +12 -0
- package/src/extensions/table.ts +2 -2
- package/src/extensions/tasklist.ts +54 -77
- package/src/extensions/yjs/cursor.ts +2 -1
- package/src/extensions/yjs/yjs.test.ts +1 -1
- package/src/extensions/yjs/yjs.ts +2 -2
- package/src/hooks/index.ts +2 -1
- package/src/hooks/useTextModel.ts +3 -6
- package/src/hooks/yjs.ts +1 -23
- package/src/styles/markdown.ts +0 -2
- package/src/themes/default.ts +17 -50
- package/dist/types/src/util/cursor.d.ts.map +0 -1
- package/dist/types/src/util/index.d.ts +0 -3
- package/dist/types/src/util/index.d.ts.map +0 -1
- package/dist/types/src/util/util.d.ts.map +0 -1
- package/src/util/cursor.ts +0 -29
- package/src/util/index.ts +0 -6
- /package/dist/types/src/{util/util.d.ts → util.d.ts} +0 -0
- /package/src/{util/util.ts → util.ts} +0 -0
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { Event } from '@dxos/async';
|
|
17
17
|
import { Context } from '@dxos/context';
|
|
18
18
|
|
|
19
|
-
import { CursorConverter } from '
|
|
19
|
+
import { Cursor, type CursorConverter } from './cursor';
|
|
20
20
|
|
|
21
21
|
export interface AwarenessProvider {
|
|
22
22
|
remoteStateChange: Event<void>;
|
|
@@ -91,7 +91,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
91
91
|
private _lastHead?: number = undefined;
|
|
92
92
|
|
|
93
93
|
constructor(view: EditorView) {
|
|
94
|
-
this._cursorConverter = view.state.facet(
|
|
94
|
+
this._cursorConverter = view.state.facet(Cursor.converter);
|
|
95
95
|
this._provider = view.state.facet(AwarenessProvider);
|
|
96
96
|
this._provider.open();
|
|
97
97
|
this._provider.remoteStateChange.on(this._ctx, () => {
|
|
@@ -156,7 +156,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
156
156
|
to: end,
|
|
157
157
|
value: Decoration.mark({
|
|
158
158
|
attributes: { style: `background-color: ${lightColor}` },
|
|
159
|
-
class: 'cm-
|
|
159
|
+
class: 'cm-collab-selection',
|
|
160
160
|
}),
|
|
161
161
|
});
|
|
162
162
|
} else {
|
|
@@ -166,7 +166,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
166
166
|
to: startLine.from + startLine.length,
|
|
167
167
|
value: Decoration.mark({
|
|
168
168
|
attributes: { style: `background-color: ${color}` },
|
|
169
|
-
class: 'cm-
|
|
169
|
+
class: 'cm-collab-selection',
|
|
170
170
|
}),
|
|
171
171
|
});
|
|
172
172
|
|
|
@@ -176,7 +176,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
176
176
|
to: end,
|
|
177
177
|
value: Decoration.mark({
|
|
178
178
|
attributes: { style: `background-color: ${color}` },
|
|
179
|
-
class: 'cm-
|
|
179
|
+
class: 'cm-collab-selection',
|
|
180
180
|
}),
|
|
181
181
|
});
|
|
182
182
|
|
|
@@ -186,7 +186,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
186
186
|
from: linePos,
|
|
187
187
|
to: linePos,
|
|
188
188
|
value: Decoration.line({
|
|
189
|
-
attributes: { style: `background-color: ${color}`, class: 'cm-
|
|
189
|
+
attributes: { style: `background-color: ${color}`, class: 'cm-collab-selectionLine' },
|
|
190
190
|
}),
|
|
191
191
|
});
|
|
192
192
|
}
|
|
@@ -208,24 +208,22 @@ export class RemoteSelectionsDecorator implements PluginValue {
|
|
|
208
208
|
}
|
|
209
209
|
|
|
210
210
|
class RemoteCaretWidget extends WidgetType {
|
|
211
|
-
constructor(
|
|
211
|
+
constructor(private readonly _name: string, private readonly _color: string) {
|
|
212
212
|
super();
|
|
213
|
-
this.name = name;
|
|
214
|
-
this.color = color;
|
|
215
213
|
}
|
|
216
214
|
|
|
217
|
-
toDOM(): HTMLElement {
|
|
215
|
+
override toDOM(): HTMLElement {
|
|
218
216
|
const span = document.createElement('span');
|
|
219
|
-
span.className = 'cm-
|
|
220
|
-
span.style.backgroundColor = this.
|
|
221
|
-
span.style.borderColor = this.
|
|
217
|
+
span.className = 'cm-collab-selectionCaret';
|
|
218
|
+
span.style.backgroundColor = this._color;
|
|
219
|
+
span.style.borderColor = this._color;
|
|
222
220
|
|
|
223
221
|
const dot = document.createElement('div');
|
|
224
|
-
dot.className = 'cm-
|
|
222
|
+
dot.className = 'cm-collab-selectionCaretDot';
|
|
225
223
|
|
|
226
224
|
const info = document.createElement('div');
|
|
227
|
-
info.className = 'cm-
|
|
228
|
-
info.innerText = this.
|
|
225
|
+
info.className = 'cm-collab-selectionInfo';
|
|
226
|
+
info.innerText = this._name;
|
|
229
227
|
|
|
230
228
|
span.appendChild(document.createTextNode('\u2060'));
|
|
231
229
|
span.appendChild(dot);
|
|
@@ -236,18 +234,14 @@ class RemoteCaretWidget extends WidgetType {
|
|
|
236
234
|
return span;
|
|
237
235
|
}
|
|
238
236
|
|
|
239
|
-
override eq(widget: this) {
|
|
240
|
-
return widget.color === this.color;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
compare(widget: this) {
|
|
244
|
-
return widget.color === this.color;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
237
|
override updateDOM() {
|
|
248
238
|
return false;
|
|
249
239
|
}
|
|
250
240
|
|
|
241
|
+
override eq(widget: this) {
|
|
242
|
+
return widget._color === this._color;
|
|
243
|
+
}
|
|
244
|
+
|
|
251
245
|
override get estimatedHeight() {
|
|
252
246
|
return -1;
|
|
253
247
|
}
|
|
@@ -257,14 +251,14 @@ class RemoteCaretWidget extends WidgetType {
|
|
|
257
251
|
}
|
|
258
252
|
}
|
|
259
253
|
|
|
260
|
-
// TODO(burdon):
|
|
254
|
+
// TODO(burdon): Mixed case?
|
|
261
255
|
const styles = EditorView.baseTheme({
|
|
262
|
-
'.cm-
|
|
263
|
-
'.cm-
|
|
256
|
+
'.cm-collab-selection': {},
|
|
257
|
+
'.cm-collab-selectionLine': {
|
|
264
258
|
padding: 0,
|
|
265
259
|
margin: '0px 2px 0px 4px',
|
|
266
260
|
},
|
|
267
|
-
'.cm-
|
|
261
|
+
'.cm-collab-selectionCaret': {
|
|
268
262
|
position: 'relative',
|
|
269
263
|
borderLeft: '1px solid black',
|
|
270
264
|
borderRight: '1px solid black',
|
|
@@ -273,7 +267,7 @@ const styles = EditorView.baseTheme({
|
|
|
273
267
|
boxSizing: 'border-box',
|
|
274
268
|
display: 'inline',
|
|
275
269
|
},
|
|
276
|
-
'.cm-
|
|
270
|
+
'.cm-collab-selectionCaretDot': {
|
|
277
271
|
borderRadius: '50%',
|
|
278
272
|
position: 'absolute',
|
|
279
273
|
width: '.4em',
|
|
@@ -284,11 +278,11 @@ const styles = EditorView.baseTheme({
|
|
|
284
278
|
transition: 'transform .3s ease-in-out',
|
|
285
279
|
boxSizing: 'border-box',
|
|
286
280
|
},
|
|
287
|
-
'.cm-
|
|
281
|
+
'.cm-collab-selectionCaret:hover > .cm-collab-selectionCaretDot': {
|
|
288
282
|
transformOrigin: 'bottom center',
|
|
289
283
|
transform: 'scale(0)',
|
|
290
284
|
},
|
|
291
|
-
'.cm-
|
|
285
|
+
'.cm-collab-selectionInfo': {
|
|
292
286
|
position: 'absolute',
|
|
293
287
|
top: '-1.05em',
|
|
294
288
|
left: '-1px',
|
|
@@ -304,12 +298,12 @@ const styles = EditorView.baseTheme({
|
|
|
304
298
|
zIndex: 101,
|
|
305
299
|
transition: 'opacity .3s ease-in-out',
|
|
306
300
|
backgroundColor: 'inherit',
|
|
307
|
-
//
|
|
301
|
+
// These should be separate.
|
|
308
302
|
opacity: 0,
|
|
309
303
|
transitionDelay: '0s',
|
|
310
304
|
whiteSpace: 'nowrap',
|
|
311
305
|
},
|
|
312
|
-
'.cm-
|
|
306
|
+
'.cm-collab-selectionCaret:hover > .cm-collab-selectionInfo': {
|
|
313
307
|
opacity: 1,
|
|
314
308
|
transitionDelay: '0s',
|
|
315
309
|
},
|
package/src/extensions/code.ts
CHANGED
|
@@ -53,22 +53,20 @@ const getLineRange = (lines: BlockInfo[], from: number, to: number) => {
|
|
|
53
53
|
};
|
|
54
54
|
|
|
55
55
|
export const code = () => {
|
|
56
|
-
const
|
|
56
|
+
const buildDecorations = (view: EditorView) => {
|
|
57
57
|
const decorations: Range<Decoration>[] = [];
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
71
|
-
}
|
|
58
|
+
const text = view.state.doc.sliceString(0);
|
|
59
|
+
const matches = text.matchAll(CODE_REGEX);
|
|
60
|
+
const blocks = view.viewportLineBlocks;
|
|
61
|
+
for (const match of matches) {
|
|
62
|
+
const range = getLineRange(blocks, match.index!, match.index! + match[0].length);
|
|
63
|
+
for (let i = range[0]; i <= range[1]; i++) {
|
|
64
|
+
const block = blocks[i];
|
|
65
|
+
decorations.push(
|
|
66
|
+
Decoration.line({
|
|
67
|
+
class: mx('cm-codeblock', i === range[0] && 'cm-codeblock-first', i === range[1] && 'cm-codeblock-last'),
|
|
68
|
+
}).range(block.from),
|
|
69
|
+
);
|
|
72
70
|
}
|
|
73
71
|
}
|
|
74
72
|
|
|
@@ -78,14 +76,22 @@ export const code = () => {
|
|
|
78
76
|
return [
|
|
79
77
|
ViewPlugin.fromClass(
|
|
80
78
|
class {
|
|
79
|
+
hasFocus = false;
|
|
81
80
|
decorations: DecorationSet;
|
|
82
81
|
constructor(view: EditorView) {
|
|
83
|
-
this.decorations =
|
|
82
|
+
this.decorations = buildDecorations(view);
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
update(update: ViewUpdate) {
|
|
87
|
-
if (
|
|
88
|
-
|
|
86
|
+
if (
|
|
87
|
+
// TODO(burdon): Generalize for other extensions.
|
|
88
|
+
this.hasFocus !== update.view.hasFocus ||
|
|
89
|
+
update.startState.readOnly !== update.view.state.readOnly ||
|
|
90
|
+
update.docChanged ||
|
|
91
|
+
update.viewportChanged
|
|
92
|
+
) {
|
|
93
|
+
this.hasFocus = update.view.hasFocus;
|
|
94
|
+
this.decorations = buildDecorations(update.view);
|
|
89
95
|
}
|
|
90
96
|
}
|
|
91
97
|
},
|
|
@@ -2,45 +2,22 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { type Extension, StateEffect, StateField } from '@codemirror/state';
|
|
6
|
-
import {
|
|
7
|
-
hoverTooltip,
|
|
8
|
-
keymap,
|
|
9
|
-
type Command,
|
|
10
|
-
type DecorationSet,
|
|
11
|
-
type ViewUpdate,
|
|
12
|
-
Decoration,
|
|
13
|
-
EditorView,
|
|
14
|
-
MatchDecorator,
|
|
15
|
-
ViewPlugin,
|
|
16
|
-
WidgetType,
|
|
17
|
-
type Rect,
|
|
18
|
-
} from '@codemirror/view';
|
|
5
|
+
import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
|
|
6
|
+
import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
|
|
19
7
|
import sortBy from 'lodash.sortby';
|
|
20
8
|
|
|
21
9
|
import { debounce } from '@dxos/async';
|
|
22
10
|
import { invariant } from '@dxos/invariant';
|
|
23
|
-
import { log } from '@dxos/log';
|
|
24
11
|
import { nonNullable } from '@dxos/util';
|
|
25
12
|
|
|
13
|
+
import { semaphoreFacet } from './automerge/defs';
|
|
14
|
+
import { Cursor } from './cursor';
|
|
26
15
|
import { type CommentRange, type EditorModel, type Range } from '../hooks';
|
|
27
16
|
import { getToken } from '../styles';
|
|
28
|
-
import { callbackWrapper
|
|
29
|
-
|
|
30
|
-
// TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
|
|
31
|
-
// TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
|
|
17
|
+
import { callbackWrapper } from '../util';
|
|
32
18
|
|
|
33
19
|
// TODO(burdon): Reconcile with theme.
|
|
34
20
|
const styles = EditorView.baseTheme({
|
|
35
|
-
'& .cm-bookmark': {
|
|
36
|
-
cursor: 'pointer',
|
|
37
|
-
margin: '4px',
|
|
38
|
-
padding: '4px',
|
|
39
|
-
backgroundColor: 'yellow',
|
|
40
|
-
},
|
|
41
|
-
'& .cm-bookmark-selected': {
|
|
42
|
-
backgroundColor: 'orange',
|
|
43
|
-
},
|
|
44
21
|
'& .cm-comment': {
|
|
45
22
|
backgroundColor: getToken('extend.colors.yellow.50'),
|
|
46
23
|
},
|
|
@@ -80,7 +57,7 @@ export const setFocus = (view: EditorView, thread: string) => {
|
|
|
80
57
|
view.dispatch({
|
|
81
58
|
effects: setSelection.of({ active: thread }),
|
|
82
59
|
selection: { anchor: range.from },
|
|
83
|
-
scrollIntoView: true,
|
|
60
|
+
scrollIntoView: true, // TODO(burdon): Scroll to y-position (or center of screen?)
|
|
84
61
|
});
|
|
85
62
|
};
|
|
86
63
|
|
|
@@ -92,13 +69,12 @@ const setCommentState = StateEffect.define<CommentsState>();
|
|
|
92
69
|
|
|
93
70
|
/**
|
|
94
71
|
* State field (reducer) that tracks comment ranges.
|
|
95
|
-
* The ranges are tracked as
|
|
96
|
-
* therefore must be updated when the document changes.
|
|
72
|
+
* The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
|
|
97
73
|
*/
|
|
98
74
|
const commentsStateField = StateField.define<CommentsState>({
|
|
99
75
|
create: () => ({ ranges: [] }),
|
|
100
76
|
update: (value, tr) => {
|
|
101
|
-
const cursorConverter = tr.state.facet(
|
|
77
|
+
const cursorConverter = tr.state.facet(Cursor.converter);
|
|
102
78
|
|
|
103
79
|
for (const effect of tr.effects) {
|
|
104
80
|
// Update selection.
|
|
@@ -111,7 +87,8 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
111
87
|
const { comments } = effect.value;
|
|
112
88
|
const ranges: ExtendedCommentRange[] = comments
|
|
113
89
|
.map((comment) => {
|
|
114
|
-
const range = getRangeFromCursor(cursorConverter, comment.cursor);
|
|
90
|
+
const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
|
|
91
|
+
// console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
|
|
115
92
|
return range && { ...comment, ...range };
|
|
116
93
|
})
|
|
117
94
|
.filter(nonNullable);
|
|
@@ -154,42 +131,23 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
|
|
|
154
131
|
return Decoration.set(decorations);
|
|
155
132
|
});
|
|
156
133
|
|
|
157
|
-
/**
|
|
158
|
-
* Optional bookmark widget at the start/end of the selection.
|
|
159
|
-
* May correspond to a markdown bookmark.
|
|
160
|
-
*/
|
|
161
|
-
class BookmarkWidget extends WidgetType {
|
|
162
|
-
constructor(
|
|
163
|
-
private readonly _anchor: number,
|
|
164
|
-
private readonly _id: string,
|
|
165
|
-
private readonly _handleClick?: () => void,
|
|
166
|
-
) {
|
|
167
|
-
super();
|
|
168
|
-
invariant(this._id);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
override eq(other: BookmarkWidget) {
|
|
172
|
-
return this._anchor === other._anchor && this._id === other._id;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
override toDOM() {
|
|
176
|
-
const span = document.createElement('span');
|
|
177
|
-
span.className = 'cm-bookmark';
|
|
178
|
-
span.textContent = '※';
|
|
179
|
-
if (this._handleClick) {
|
|
180
|
-
span.onclick = () => this._handleClick!();
|
|
181
|
-
}
|
|
182
|
-
return span;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
134
|
export type CommentsOptions = {
|
|
135
|
+
/**
|
|
136
|
+
* Key shortcut to create a new thread.
|
|
137
|
+
*/
|
|
187
138
|
key?: string;
|
|
188
|
-
footnote?: boolean;
|
|
189
139
|
/**
|
|
190
140
|
* Called to create a new thread and return the thread id.
|
|
191
141
|
*/
|
|
192
|
-
onCreate?: (
|
|
142
|
+
onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Selection cut/deleted.
|
|
145
|
+
*/
|
|
146
|
+
onDelete?: (thread: string) => void;
|
|
147
|
+
/**
|
|
148
|
+
* Called when a comment is moved.
|
|
149
|
+
*/
|
|
150
|
+
onUpdate?: (thread: string, cursor: string) => void;
|
|
193
151
|
/**
|
|
194
152
|
* Called to notify which thread is currently closest to the cursor.
|
|
195
153
|
*/
|
|
@@ -197,7 +155,88 @@ export type CommentsOptions = {
|
|
|
197
155
|
/**
|
|
198
156
|
* Called to render tooltip.
|
|
199
157
|
*/
|
|
200
|
-
onHover?: (el: Element) => void;
|
|
158
|
+
onHover?: (el: Element, shortcut: string) => void;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
|
|
162
|
+
const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
|
|
163
|
+
// Cut/deleted comments.
|
|
164
|
+
// Tracks indexed selections within text.
|
|
165
|
+
let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
|
|
166
|
+
|
|
167
|
+
// Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
|
|
168
|
+
const handleTrack = (event: Event, view: EditorView) => {
|
|
169
|
+
const comments = view.state.field(commentsStateField);
|
|
170
|
+
const { main } = view.state.selection;
|
|
171
|
+
const selectedRanges = comments.ranges.filter(
|
|
172
|
+
(range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (!selectedRanges.length) {
|
|
176
|
+
tracked = null;
|
|
177
|
+
} else {
|
|
178
|
+
tracked = {
|
|
179
|
+
text: view.state.doc.slice(main.from, main.to),
|
|
180
|
+
comments: selectedRanges.map((range) => ({
|
|
181
|
+
id: range.id,
|
|
182
|
+
from: range.from - main.from,
|
|
183
|
+
to: range.to - main.from,
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// console.log('track', JSON.stringify({ tracked }, undefined, 2));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return [
|
|
192
|
+
EditorView.domEventHandlers({
|
|
193
|
+
cut: handleTrack,
|
|
194
|
+
copy: handleTrack,
|
|
195
|
+
}),
|
|
196
|
+
|
|
197
|
+
// Handle paste.
|
|
198
|
+
EditorView.updateListener.of(({ view, state, transactions }) => {
|
|
199
|
+
if (tracked) {
|
|
200
|
+
const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
|
|
201
|
+
if (paste) {
|
|
202
|
+
let found = -1;
|
|
203
|
+
paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
|
|
204
|
+
if (text.eq(tracked!.text)) {
|
|
205
|
+
for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
|
|
206
|
+
fromB = transactions[i].changes.mapPos(fromB);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
found = fromB;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (found > -1) {
|
|
214
|
+
const comments = tracked.comments;
|
|
215
|
+
|
|
216
|
+
// Sync before recomputing cursor/range.
|
|
217
|
+
// TODO(burdon): Generalize sync facet to decouple from automerge?
|
|
218
|
+
view.state.facet(semaphoreFacet).reconcile(view);
|
|
219
|
+
|
|
220
|
+
const active = state.field(commentsStateField).ranges;
|
|
221
|
+
for (const moved of comments) {
|
|
222
|
+
if (active.some((range) => range.id === moved.id && range.from === range.to)) {
|
|
223
|
+
const range = {
|
|
224
|
+
from: found + moved.from,
|
|
225
|
+
to: found + moved.to,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const cursor = Cursor.getCursorFromRange(state.facet(Cursor.converter), range);
|
|
229
|
+
// console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
|
|
230
|
+
onUpdate(moved.id, cursor);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
tracked = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}),
|
|
239
|
+
];
|
|
201
240
|
};
|
|
202
241
|
|
|
203
242
|
/**
|
|
@@ -209,28 +248,29 @@ export type CommentsOptions = {
|
|
|
209
248
|
* 4). Tracks the current cursor position to:
|
|
210
249
|
* a). Update the decoration to show if the cursor is within a current selection.
|
|
211
250
|
* b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
|
|
212
|
-
* 5). Optionally,
|
|
213
|
-
* 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
251
|
+
* 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
214
252
|
*/
|
|
215
253
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
254
|
+
const { key: shortcut = "meta-'" } = options;
|
|
255
|
+
|
|
216
256
|
const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
|
|
217
257
|
|
|
218
258
|
/**
|
|
219
259
|
* Create comment thread action.
|
|
220
260
|
*/
|
|
221
261
|
const createCommentThread: Command = (view) => {
|
|
222
|
-
const cursorConverter = view.state.facet(
|
|
262
|
+
const cursorConverter = view.state.facet(Cursor.converter);
|
|
223
263
|
|
|
224
264
|
invariant(options.onCreate);
|
|
225
|
-
const {
|
|
265
|
+
const { from, to } = view.state.selection.main;
|
|
226
266
|
if (from === to) {
|
|
227
267
|
return false;
|
|
228
268
|
}
|
|
229
269
|
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
270
|
+
const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
|
|
271
|
+
if (cursor) {
|
|
232
272
|
// Create thread via callback.
|
|
233
|
-
const id = callbackWrapper(options.onCreate)(
|
|
273
|
+
const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
|
|
234
274
|
if (id) {
|
|
235
275
|
// Update range.
|
|
236
276
|
view.dispatch({
|
|
@@ -238,15 +278,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
238
278
|
selection: { anchor: from },
|
|
239
279
|
});
|
|
240
280
|
|
|
241
|
-
// Insert footnote.
|
|
242
|
-
if (options.footnote) {
|
|
243
|
-
const tag = `[^${id}]`;
|
|
244
|
-
view.dispatch({
|
|
245
|
-
changes: { from: head, insert: tag },
|
|
246
|
-
selection: { anchor: head + tag.length },
|
|
247
|
-
});
|
|
248
|
-
}
|
|
249
|
-
|
|
250
281
|
return true;
|
|
251
282
|
}
|
|
252
283
|
}
|
|
@@ -254,44 +285,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
254
285
|
return false;
|
|
255
286
|
};
|
|
256
287
|
|
|
257
|
-
/**
|
|
258
|
-
* Bookmark widget decoration.
|
|
259
|
-
*/
|
|
260
|
-
const bookmarksViewPlugin = ViewPlugin.fromClass(
|
|
261
|
-
class BookmarkViewPlugin {
|
|
262
|
-
// Match markdown footnotes (option).
|
|
263
|
-
// https://www.markdownguide.org/extended-syntax/#footnotes
|
|
264
|
-
static bookmarkMatcher = new MatchDecorator({
|
|
265
|
-
regexp: /\[\^(\w+)\]/g,
|
|
266
|
-
decoration: (match, view, pos) => {
|
|
267
|
-
const id = match[1];
|
|
268
|
-
return Decoration.replace({
|
|
269
|
-
id,
|
|
270
|
-
widget: new BookmarkWidget(pos, id),
|
|
271
|
-
});
|
|
272
|
-
},
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
bookmarks: DecorationSet;
|
|
276
|
-
constructor(view: EditorView) {
|
|
277
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.createDeco(view);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
update(update: ViewUpdate) {
|
|
281
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.updateDeco(update, this.bookmarks);
|
|
282
|
-
}
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
decorations: (instance) => instance.bookmarks,
|
|
286
|
-
provide: (plugin) =>
|
|
287
|
-
EditorView.atomicRanges.of((view) => {
|
|
288
|
-
return view.plugin(plugin)?.bookmarks || Decoration.none;
|
|
289
|
-
}),
|
|
290
|
-
},
|
|
291
|
-
);
|
|
292
|
-
|
|
293
288
|
return [
|
|
294
|
-
bookmarksViewPlugin,
|
|
295
289
|
commentsStateField,
|
|
296
290
|
highlightDecorations,
|
|
297
291
|
styles,
|
|
@@ -302,7 +296,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
302
296
|
options.onCreate
|
|
303
297
|
? keymap.of([
|
|
304
298
|
{
|
|
305
|
-
key:
|
|
299
|
+
key: shortcut,
|
|
306
300
|
run: callbackWrapper(createCommentThread),
|
|
307
301
|
},
|
|
308
302
|
])
|
|
@@ -322,9 +316,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
322
316
|
end: selection.to,
|
|
323
317
|
above: true,
|
|
324
318
|
create: () => {
|
|
325
|
-
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
326
319
|
const el = document.createElement('div');
|
|
327
|
-
options.onHover
|
|
320
|
+
options.onHover!(el, shortcut);
|
|
328
321
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
329
322
|
},
|
|
330
323
|
};
|
|
@@ -332,7 +325,11 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
332
325
|
|
|
333
326
|
return null;
|
|
334
327
|
},
|
|
335
|
-
{
|
|
328
|
+
{
|
|
329
|
+
// TODO(burdon): Hide on change triggered immediately?
|
|
330
|
+
// hideOnChange: true,
|
|
331
|
+
hoverTime: 1_000,
|
|
332
|
+
},
|
|
336
333
|
)
|
|
337
334
|
: [],
|
|
338
335
|
|
|
@@ -341,7 +338,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
341
338
|
// TODO(burdon): Is there a better (finer grained) way to do this?
|
|
342
339
|
//
|
|
343
340
|
EditorView.updateListener.of(({ view, state, changes }) => {
|
|
344
|
-
const cursorConverter = view.state.facet(
|
|
341
|
+
const cursorConverter = view.state.facet(Cursor.converter);
|
|
345
342
|
|
|
346
343
|
//
|
|
347
344
|
// Test if need to recompute indexed range if document changes before the end of the range.
|
|
@@ -350,17 +347,17 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
350
347
|
let mod = false;
|
|
351
348
|
const { active, ranges } = state.field(commentsStateField);
|
|
352
349
|
changes.iterChanges((from, to, from2, to2) => {
|
|
350
|
+
// TODO(burdon): Skip deleted (tracked) ranges.
|
|
353
351
|
ranges.forEach((range) => {
|
|
354
352
|
if (from2 === to2) {
|
|
355
|
-
const newRange = getRangeFromCursor(cursorConverter, range.cursor);
|
|
353
|
+
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
356
354
|
if (!newRange || newRange.to - newRange.from === 0) {
|
|
357
|
-
|
|
358
|
-
log.info('deleted comment', { thread: range.id });
|
|
355
|
+
options.onDelete?.(range.id);
|
|
359
356
|
}
|
|
360
357
|
}
|
|
361
358
|
|
|
362
359
|
if (from <= range.to) {
|
|
363
|
-
const newRange = getRangeFromCursor(cursorConverter, range.cursor);
|
|
360
|
+
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
364
361
|
Object.assign(range, newRange);
|
|
365
362
|
mod = true;
|
|
366
363
|
}
|
|
@@ -381,6 +378,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
381
378
|
let min = Infinity;
|
|
382
379
|
const { active, closest, ranges } = state.field(commentsStateField);
|
|
383
380
|
const selected: CommentSelected = { active: undefined, closest: undefined };
|
|
381
|
+
|
|
382
|
+
// TODO(burdon): Skip deleted (tracked) ranges.
|
|
384
383
|
ranges.forEach((comment) => {
|
|
385
384
|
const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
|
|
386
385
|
if (head >= comment.from && head <= comment.to) {
|
|
@@ -403,18 +402,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
403
402
|
}
|
|
404
403
|
}
|
|
405
404
|
}),
|
|
406
|
-
];
|
|
407
|
-
};
|
|
408
405
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const to = cursorConverter.toCursor(range.to, -1);
|
|
412
|
-
return [from, to].join(':');
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
const getRangeFromCursor = (cursorConverter: CursorConverter, cursor: string) => {
|
|
416
|
-
const parts = cursor.split(':');
|
|
417
|
-
const from = cursorConverter.fromCursor(parts[0]);
|
|
418
|
-
const to = cursorConverter.fromCursor(parts[1]);
|
|
419
|
-
return from !== undefined && to !== undefined ? { from, to } : undefined;
|
|
406
|
+
options.onUpdate ? trackPastedComments(options.onUpdate) : [],
|
|
407
|
+
];
|
|
420
408
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Facet } from '@codemirror/state';
|
|
6
|
+
|
|
7
|
+
import type { Range } from '../hooks';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts indexes into the text document into stable peer-independent cursors.
|
|
11
|
+
*
|
|
12
|
+
* See:
|
|
13
|
+
* - https://automerge.org/automerge/api-docs/js/functions/next.getCursor.html
|
|
14
|
+
* - https://github.com/yjs/yjs?tab=readme-ov-file#relative-positions
|
|
15
|
+
*
|
|
16
|
+
* @param {assoc} number Negative values will associate the cursor with the previous character
|
|
17
|
+
* while positive - with the next one.
|
|
18
|
+
*/
|
|
19
|
+
export interface CursorConverter {
|
|
20
|
+
toCursor(position: number, assoc?: -1 | 1 | undefined): string;
|
|
21
|
+
fromCursor(cursor: string): number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const defaultCursorConverter: CursorConverter = {
|
|
25
|
+
toCursor: (position) => position.toString(),
|
|
26
|
+
fromCursor: (cursor) => parseInt(cursor),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class Cursor {
|
|
30
|
+
static readonly converter = Facet.define<CursorConverter, CursorConverter>({
|
|
31
|
+
combine: (providers) => providers[0] ?? defaultCursorConverter,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
static readonly getCursorFromRange = (cursorConverter: CursorConverter, range: Range) => {
|
|
35
|
+
const from = cursorConverter.toCursor(range.from);
|
|
36
|
+
const to = cursorConverter.toCursor(range.to, -1);
|
|
37
|
+
return [from, to].join(':');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
static readonly getRangeFromCursor = (cursorConverter: CursorConverter, cursor: string) => {
|
|
41
|
+
const parts = cursor.split(':');
|
|
42
|
+
const from = cursorConverter.fromCursor(parts[0]);
|
|
43
|
+
const to = cursorConverter.fromCursor(parts[1]);
|
|
44
|
+
return from !== undefined && to !== undefined ? { from, to } : undefined;
|
|
45
|
+
};
|
|
46
|
+
}
|