@dxos/react-ui-editor 0.3.11-main.0e40963 → 0.3.11-main.108b22e
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 +1837 -1502
- 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 +16 -13
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +20 -17
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -3
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/{hooks/automerge → components/TextEditor}/automerge.stories.d.ts +6 -1
- package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -0
- 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/autocomplete.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts +9 -0
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/cursor.d.ts +5 -0
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/defs.d.ts +24 -0
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/index.d.ts +3 -0
- package/dist/types/src/extensions/automerge/index.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/semaphore.d.ts +14 -0
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/update-automerge.d.ts +5 -0
- package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -0
- package/dist/types/src/extensions/awareness.d.ts +46 -0
- package/dist/types/src/extensions/awareness.d.ts.map +1 -0
- package/dist/types/src/extensions/basic.d.ts +4 -5
- package/dist/types/src/extensions/basic.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +33 -18
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/cursor.d.ts +25 -0
- 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/image.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +4 -1
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/link.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/bundle.d.ts +2 -5
- package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
- package/dist/types/src/extensions/mention.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.map +1 -1
- package/dist/types/src/hooks/awareness-provider.d.ts +31 -0
- package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -0
- package/dist/types/src/hooks/defs.d.ts +25 -0
- package/dist/types/src/hooks/defs.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +3 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
- package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
- package/dist/types/src/hooks/useTextModel.d.ts +2 -31
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/styles/cursors.d.ts.map +1 -0
- package/dist/types/src/styles/index.d.ts +1 -0
- package/dist/types/src/styles/index.d.ts.map +1 -1
- package/dist/types/src/styles/markdown.d.ts.map +1 -1
- package/dist/types/src/styles/tokens.d.ts +1 -0
- package/dist/types/src/styles/tokens.d.ts.map +1 -1
- package/dist/types/src/themes/default.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +7 -0
- package/dist/types/src/util.d.ts.map +1 -0
- package/package.json +33 -34
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +77 -80
- package/src/components/TextEditor/TextEditor.stories.tsx +13 -34
- package/src/components/TextEditor/TextEditor.tsx +122 -93
- package/src/{hooks/automerge → components/TextEditor}/automerge.stories.tsx +66 -6
- package/src/components/TextEditor/comments.stories.tsx +357 -0
- package/src/extensions/autocomplete.ts +23 -19
- package/src/extensions/automerge/automerge.ts +88 -0
- package/src/extensions/automerge/cursor.ts +52 -0
- package/src/extensions/automerge/defs.ts +41 -0
- package/src/extensions/automerge/index.ts +6 -0
- package/src/{hooks → extensions}/automerge/semaphore.ts +34 -29
- package/src/{hooks → extensions}/automerge/update-automerge.ts +5 -6
- package/src/{hooks → extensions}/automerge/update-codemirror.ts +13 -5
- package/src/extensions/awareness.ts +310 -0
- package/src/extensions/basic.ts +22 -17
- package/src/extensions/code.ts +28 -23
- package/src/extensions/comments.ts +256 -219
- package/src/extensions/cursor.ts +50 -0
- package/src/extensions/hr.ts +50 -52
- package/src/extensions/image.ts +40 -22
- package/src/extensions/index.ts +4 -1
- package/src/extensions/link.ts +86 -88
- package/src/extensions/markdown/bundle.ts +18 -24
- package/src/extensions/markdown/highlight.ts +3 -12
- package/src/extensions/mention.ts +9 -2
- package/src/extensions/outliner.ts +12 -0
- package/src/extensions/table.ts +10 -4
- package/src/extensions/tasklist.ts +78 -88
- package/src/hooks/awareness-provider.ts +124 -0
- package/src/hooks/defs.ts +42 -0
- package/src/hooks/index.ts +4 -1
- package/src/hooks/useTextEditor.ts +29 -0
- package/src/hooks/useTextModel.ts +30 -116
- package/src/index.ts +3 -0
- package/src/styles/index.ts +1 -0
- package/src/styles/markdown.ts +0 -2
- package/src/styles/tokens.ts +3 -0
- package/src/themes/default.ts +51 -78
- package/src/util.ts +41 -0
- package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
- package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
- package/dist/types/src/extensions/mermaid.d.ts +0 -3
- package/dist/types/src/extensions/mermaid.d.ts.map +0 -1
- package/dist/types/src/extensions/util.d.ts +0 -5
- package/dist/types/src/extensions/util.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/handle.d.ts +0 -9
- package/dist/types/src/hooks/automerge/handle.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/index.d.ts +0 -3
- package/dist/types/src/hooks/automerge/index.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/plugin.d.ts +0 -26
- package/dist/types/src/hooks/automerge/plugin.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/semaphore.d.ts +0 -19
- package/dist/types/src/hooks/automerge/semaphore.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/update-automerge.d.ts +0 -6
- package/dist/types/src/hooks/automerge/update-automerge.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/update-codemirror.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/cursors.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/index.d.ts +0 -3
- package/dist/types/src/hooks/yjs/index.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/space-provider.d.ts +0 -27
- package/dist/types/src/hooks/yjs/space-provider.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/yjs.stories.d.ts +0 -12
- package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/yjs.test.d.ts +0 -2
- package/dist/types/src/hooks/yjs/yjs.test.d.ts.map +0 -1
- package/dist/types/src/testing/index.d.ts +0 -3
- package/dist/types/src/testing/index.d.ts.map +0 -1
- package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
- package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
- package/dist/types/src/testing/proto/index.d.ts +0 -2
- package/dist/types/src/testing/proto/index.d.ts.map +0 -1
- package/dist/types/src/testing/replicator.d.ts +0 -45
- package/dist/types/src/testing/replicator.d.ts.map +0 -1
- package/src/components/TextEditor/codemirror.stories.ts +0 -114
- package/src/extensions/mermaid.ts +0 -11
- package/src/extensions/util.ts +0 -18
- package/src/hooks/automerge/handle.ts +0 -14
- package/src/hooks/automerge/index.ts +0 -6
- package/src/hooks/automerge/plugin.ts +0 -134
- package/src/hooks/yjs/index.ts +0 -6
- package/src/hooks/yjs/space-provider.ts +0 -126
- package/src/hooks/yjs/yjs.stories.tsx +0 -56
- package/src/hooks/yjs/yjs.test.ts +0 -59
- package/src/testing/index.ts +0 -6
- package/src/testing/proto/gen/schema.ts +0 -49
- package/src/testing/proto/index.ts +0 -5
- package/src/testing/proto/schema.proto +0 -15
- package/src/testing/replicator.ts +0 -189
- /package/dist/types/src/{hooks → extensions}/automerge/update-codemirror.d.ts +0 -0
- /package/dist/types/src/{hooks/yjs → styles}/cursors.d.ts +0 -0
- /package/src/{hooks/yjs → styles}/cursors.ts +0 -0
|
@@ -2,106 +2,104 @@
|
|
|
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';
|
|
8
|
+
import { useEffect } from 'react';
|
|
20
9
|
|
|
21
10
|
import { debounce } from '@dxos/async';
|
|
22
11
|
import { invariant } from '@dxos/invariant';
|
|
23
|
-
import { log } from '@dxos/log';
|
|
24
12
|
import { nonNullable } from '@dxos/util';
|
|
25
13
|
|
|
26
|
-
import {
|
|
27
|
-
import { type
|
|
28
|
-
|
|
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?
|
|
14
|
+
import { Cursor } from './cursor';
|
|
15
|
+
import { type Comment, type Range } from '../hooks';
|
|
16
|
+
import { getToken } from '../styles';
|
|
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
|
-
backgroundColor: 'yellow',
|
|
22
|
+
backgroundColor: getToken('extend.colors.yellow.50'),
|
|
46
23
|
},
|
|
47
|
-
'& .cm-comment-
|
|
48
|
-
backgroundColor: '
|
|
24
|
+
'& .cm-comment-current': {
|
|
25
|
+
backgroundColor: getToken('extend.colors.yellow.100'),
|
|
49
26
|
},
|
|
50
27
|
});
|
|
51
28
|
|
|
52
29
|
const marks = {
|
|
53
30
|
highlight: Decoration.mark({ class: 'cm-comment' }),
|
|
54
|
-
highlightActive: Decoration.mark({ class: 'cm-comment-
|
|
31
|
+
highlightActive: Decoration.mark({ class: 'cm-comment-current' }),
|
|
55
32
|
};
|
|
56
33
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
34
|
+
type CommentState = {
|
|
35
|
+
comment: Comment;
|
|
36
|
+
range: Range;
|
|
37
|
+
location?: Rect | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type SelectionState = {
|
|
41
|
+
current?: string;
|
|
60
42
|
closest?: string;
|
|
61
43
|
};
|
|
62
44
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
location?: Rect | null;
|
|
68
|
-
};
|
|
45
|
+
export type CommentsState = {
|
|
46
|
+
comments: CommentState[];
|
|
47
|
+
selection: SelectionState;
|
|
48
|
+
};
|
|
69
49
|
|
|
70
|
-
export
|
|
71
|
-
|
|
50
|
+
export const setFocus = (view: EditorView, id: string, center = true) => {
|
|
51
|
+
const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
|
|
52
|
+
if (!comment?.comment.cursor) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
|
|
57
|
+
if (range) {
|
|
58
|
+
view.dispatch({
|
|
59
|
+
selection: { anchor: range.from },
|
|
60
|
+
effects: [
|
|
61
|
+
//
|
|
62
|
+
EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined),
|
|
63
|
+
setSelection.of({ current: id }),
|
|
64
|
+
],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
72
67
|
};
|
|
73
68
|
|
|
74
|
-
export const
|
|
69
|
+
export const setComments = StateEffect.define<Comment[]>();
|
|
75
70
|
|
|
76
|
-
const setSelection = StateEffect.define<
|
|
71
|
+
export const setSelection = StateEffect.define<SelectionState>();
|
|
77
72
|
|
|
78
73
|
const setCommentState = StateEffect.define<CommentsState>();
|
|
79
74
|
|
|
80
75
|
/**
|
|
81
76
|
* State field (reducer) that tracks comment ranges.
|
|
82
|
-
* The ranges are tracked as
|
|
83
|
-
* therefore must be updated when the document changes.
|
|
77
|
+
* The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
|
|
84
78
|
*/
|
|
85
|
-
const
|
|
86
|
-
create: () => ({
|
|
79
|
+
const commentsState = StateField.define<CommentsState>({
|
|
80
|
+
create: () => ({ comments: [], selection: {} }),
|
|
87
81
|
update: (value, tr) => {
|
|
88
82
|
for (const effect of tr.effects) {
|
|
89
83
|
// Update selection.
|
|
90
84
|
if (effect.is(setSelection)) {
|
|
91
|
-
return { ...value,
|
|
85
|
+
return { ...value, selection: effect.value };
|
|
92
86
|
}
|
|
93
87
|
|
|
94
88
|
// Update range from store.
|
|
95
|
-
if (effect.is(
|
|
96
|
-
const
|
|
97
|
-
const ranges: ExtendedCommentRange[] = comments
|
|
89
|
+
if (effect.is(setComments)) {
|
|
90
|
+
const comments: CommentState[] = effect.value
|
|
98
91
|
.map((comment) => {
|
|
99
|
-
|
|
100
|
-
|
|
92
|
+
// Skip cut/deleted comments.
|
|
93
|
+
if (!comment.cursor) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const range = Cursor.getRangeFromCursor(tr.state, comment.cursor);
|
|
98
|
+
return range && { comment, range };
|
|
101
99
|
})
|
|
102
100
|
.filter(nonNullable);
|
|
103
101
|
|
|
104
|
-
return { ...value,
|
|
102
|
+
return { ...value, comments };
|
|
105
103
|
}
|
|
106
104
|
|
|
107
105
|
// Update entire state.
|
|
@@ -117,64 +115,48 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
117
115
|
/**
|
|
118
116
|
* Decorate ranges.
|
|
119
117
|
*/
|
|
120
|
-
const highlightDecorations = EditorView.decorations.compute([
|
|
121
|
-
const {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
118
|
+
const highlightDecorations = EditorView.decorations.compute([commentsState], (state) => {
|
|
119
|
+
const {
|
|
120
|
+
selection: { current },
|
|
121
|
+
comments,
|
|
122
|
+
} = state.field(commentsState);
|
|
123
|
+
|
|
124
|
+
const decorations = sortBy(comments ?? [], (range) => range.range.from)
|
|
125
|
+
?.flatMap((comment) => {
|
|
126
|
+
const range = comment.range;
|
|
127
|
+
if (!range || range.from === range.to) {
|
|
128
|
+
console.warn('Invalid range:', range);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
129
131
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
.filter(nonNullable) ?? [];
|
|
132
|
+
if (comment.comment.id === current) {
|
|
133
|
+
return marks.highlightActive.range(range.from, range.to);
|
|
134
|
+
} else {
|
|
135
|
+
return marks.highlight.range(range.from, range.to);
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
.filter(nonNullable);
|
|
138
139
|
|
|
139
140
|
return Decoration.set(decorations);
|
|
140
141
|
});
|
|
141
142
|
|
|
142
|
-
/**
|
|
143
|
-
* Optional bookmark widget at the start/end of the selection.
|
|
144
|
-
* May correspond to a markdown bookmark.
|
|
145
|
-
*/
|
|
146
|
-
class BookmarkWidget extends WidgetType {
|
|
147
|
-
constructor(
|
|
148
|
-
private readonly _anchor: number,
|
|
149
|
-
private readonly _id: string,
|
|
150
|
-
private readonly _handleClick?: () => void,
|
|
151
|
-
) {
|
|
152
|
-
super();
|
|
153
|
-
invariant(this._id);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
override eq(other: BookmarkWidget) {
|
|
157
|
-
return this._anchor === other._anchor && this._id === other._id;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
override toDOM() {
|
|
161
|
-
const span = document.createElement('span');
|
|
162
|
-
span.className = 'cm-bookmark';
|
|
163
|
-
span.textContent = '※';
|
|
164
|
-
if (this._handleClick) {
|
|
165
|
-
span.onclick = () => this._handleClick!();
|
|
166
|
-
}
|
|
167
|
-
return span;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
143
|
export type CommentsOptions = {
|
|
144
|
+
/**
|
|
145
|
+
* Key shortcut to create a new thread.
|
|
146
|
+
*/
|
|
172
147
|
key?: string;
|
|
173
|
-
footnote?: boolean;
|
|
174
148
|
/**
|
|
175
149
|
* Called to create a new thread and return the thread id.
|
|
176
150
|
*/
|
|
177
|
-
onCreate?: (
|
|
151
|
+
onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
|
|
152
|
+
/**
|
|
153
|
+
* Selection cut/deleted.
|
|
154
|
+
*/
|
|
155
|
+
onDelete?: (thread: string) => void;
|
|
156
|
+
/**
|
|
157
|
+
* Called when a comment is moved.
|
|
158
|
+
*/
|
|
159
|
+
onUpdate?: (thread: string, cursor: string) => void;
|
|
178
160
|
/**
|
|
179
161
|
* Called to notify which thread is currently closest to the cursor.
|
|
180
162
|
*/
|
|
@@ -182,7 +164,76 @@ export type CommentsOptions = {
|
|
|
182
164
|
/**
|
|
183
165
|
* Called to render tooltip.
|
|
184
166
|
*/
|
|
185
|
-
onHover?: (el: Element) => void;
|
|
167
|
+
onHover?: (el: Element, shortcut: string) => void;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
|
|
171
|
+
const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
|
|
172
|
+
// Tracks indexed selections within text.
|
|
173
|
+
// TODO(burdon): Move to main state field?
|
|
174
|
+
let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
|
|
175
|
+
|
|
176
|
+
// Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
|
|
177
|
+
const handleTrack = (event: Event, view: EditorView) => {
|
|
178
|
+
const comments = view.state.field(commentsState);
|
|
179
|
+
const { main } = view.state.selection;
|
|
180
|
+
const selectedRanges = comments.comments.filter(
|
|
181
|
+
({ range }) => range.from >= main.from && range.to <= main.to && range.from < range.to,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
if (!selectedRanges.length) {
|
|
185
|
+
tracked = null;
|
|
186
|
+
} else {
|
|
187
|
+
tracked = {
|
|
188
|
+
text: view.state.doc.slice(main.from, main.to),
|
|
189
|
+
comments: selectedRanges.map(({ comment, range }) => ({
|
|
190
|
+
id: comment.id,
|
|
191
|
+
from: range.from - main.from,
|
|
192
|
+
to: range.to - main.from,
|
|
193
|
+
})),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return [
|
|
199
|
+
EditorView.domEventHandlers({
|
|
200
|
+
cut: handleTrack,
|
|
201
|
+
copy: handleTrack,
|
|
202
|
+
}),
|
|
203
|
+
|
|
204
|
+
// Handle paste.
|
|
205
|
+
EditorView.updateListener.of(({ view, state, transactions }) => {
|
|
206
|
+
if (tracked) {
|
|
207
|
+
const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
|
|
208
|
+
if (paste) {
|
|
209
|
+
let found = -1;
|
|
210
|
+
paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
|
|
211
|
+
if (text.eq(tracked!.text)) {
|
|
212
|
+
for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
|
|
213
|
+
fromB = transactions[i].changes.mapPos(fromB);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
found = fromB;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (found > -1) {
|
|
221
|
+
for (const moved of tracked.comments) {
|
|
222
|
+
const range = {
|
|
223
|
+
from: found + moved.from,
|
|
224
|
+
to: found + moved.to,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const cursor = Cursor.getCursorFromRange(state, range);
|
|
228
|
+
onUpdate(moved.id, cursor);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
tracked = null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}),
|
|
236
|
+
];
|
|
186
237
|
};
|
|
187
238
|
|
|
188
239
|
/**
|
|
@@ -194,10 +245,11 @@ export type CommentsOptions = {
|
|
|
194
245
|
* 4). Tracks the current cursor position to:
|
|
195
246
|
* a). Update the decoration to show if the cursor is within a current selection.
|
|
196
247
|
* b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
|
|
197
|
-
* 5). Optionally,
|
|
198
|
-
* 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
248
|
+
* 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
199
249
|
*/
|
|
200
250
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
251
|
+
const { key: shortcut = "meta-'" } = options;
|
|
252
|
+
|
|
201
253
|
const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
|
|
202
254
|
|
|
203
255
|
/**
|
|
@@ -205,32 +257,32 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
205
257
|
*/
|
|
206
258
|
const createCommentThread: Command = (view) => {
|
|
207
259
|
invariant(options.onCreate);
|
|
208
|
-
const {
|
|
260
|
+
const { from, to } = view.state.selection.main;
|
|
209
261
|
if (from === to) {
|
|
210
262
|
return false;
|
|
211
263
|
}
|
|
212
264
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
265
|
+
// Don't allow selection at end of document.
|
|
266
|
+
if (to === view.state.doc.length) {
|
|
267
|
+
view.dispatch({
|
|
268
|
+
changes: {
|
|
269
|
+
from: to,
|
|
270
|
+
insert: '\n',
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const cursor = Cursor.getCursorFromRange(view.state, { from, to });
|
|
276
|
+
if (cursor) {
|
|
216
277
|
// Create thread via callback.
|
|
217
|
-
const id =
|
|
278
|
+
const id = options.onCreate?.(cursor, view.coordsAtPos(from));
|
|
218
279
|
if (id) {
|
|
219
280
|
// Update range.
|
|
220
281
|
view.dispatch({
|
|
221
|
-
effects: setSelection.of({
|
|
282
|
+
effects: setSelection.of({ current: id }),
|
|
222
283
|
selection: { anchor: from },
|
|
223
284
|
});
|
|
224
285
|
|
|
225
|
-
// Insert footnote.
|
|
226
|
-
if (options.footnote) {
|
|
227
|
-
const tag = `[^${id}]`;
|
|
228
|
-
view.dispatch({
|
|
229
|
-
changes: { from: head, insert: tag },
|
|
230
|
-
selection: { anchor: head + tag.length },
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
286
|
return true;
|
|
235
287
|
}
|
|
236
288
|
}
|
|
@@ -238,45 +290,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
238
290
|
return false;
|
|
239
291
|
};
|
|
240
292
|
|
|
241
|
-
/**
|
|
242
|
-
* Bookmark widget decoration.
|
|
243
|
-
*/
|
|
244
|
-
const bookmarksViewPlugin = ViewPlugin.fromClass(
|
|
245
|
-
class BookmarkViewPlugin {
|
|
246
|
-
// Match markdown footnotes (option).
|
|
247
|
-
// https://www.markdownguide.org/extended-syntax/#footnotes
|
|
248
|
-
static bookmarkMatcher = new MatchDecorator({
|
|
249
|
-
regexp: /\[\^(\w+)\]/g,
|
|
250
|
-
decoration: (match, view, pos) => {
|
|
251
|
-
const id = match[1];
|
|
252
|
-
return Decoration.replace({
|
|
253
|
-
id,
|
|
254
|
-
widget: new BookmarkWidget(pos, id),
|
|
255
|
-
});
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
bookmarks: DecorationSet;
|
|
260
|
-
constructor(view: EditorView) {
|
|
261
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.createDeco(view);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
update(update: ViewUpdate) {
|
|
265
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.updateDeco(update, this.bookmarks);
|
|
266
|
-
}
|
|
267
|
-
},
|
|
268
|
-
{
|
|
269
|
-
decorations: (instance) => instance.bookmarks,
|
|
270
|
-
provide: (plugin) =>
|
|
271
|
-
EditorView.atomicRanges.of((view) => {
|
|
272
|
-
return view.plugin(plugin)?.bookmarks || Decoration.none;
|
|
273
|
-
}),
|
|
274
|
-
},
|
|
275
|
-
);
|
|
276
|
-
|
|
277
293
|
return [
|
|
278
|
-
|
|
279
|
-
commentsStateField,
|
|
294
|
+
commentsState,
|
|
280
295
|
highlightDecorations,
|
|
281
296
|
styles,
|
|
282
297
|
|
|
@@ -286,7 +301,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
286
301
|
options.onCreate
|
|
287
302
|
? keymap.of([
|
|
288
303
|
{
|
|
289
|
-
key:
|
|
304
|
+
key: shortcut,
|
|
290
305
|
run: callbackWrapper(createCommentThread),
|
|
291
306
|
},
|
|
292
307
|
])
|
|
@@ -306,9 +321,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
306
321
|
end: selection.to,
|
|
307
322
|
above: true,
|
|
308
323
|
create: () => {
|
|
309
|
-
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
310
324
|
const el = document.createElement('div');
|
|
311
|
-
options.onHover
|
|
325
|
+
options.onHover!(el, shortcut);
|
|
312
326
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
313
327
|
},
|
|
314
328
|
};
|
|
@@ -316,76 +330,99 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
316
330
|
|
|
317
331
|
return null;
|
|
318
332
|
},
|
|
319
|
-
{
|
|
333
|
+
{
|
|
334
|
+
// TODO(burdon): Hide on change triggered immediately?
|
|
335
|
+
// hideOnChange: true,
|
|
336
|
+
hoverTime: 1_000,
|
|
337
|
+
},
|
|
320
338
|
)
|
|
321
339
|
: [],
|
|
322
340
|
|
|
323
341
|
//
|
|
324
|
-
//
|
|
325
|
-
// TODO(burdon): Is there a better (finer grained) way to do this?
|
|
342
|
+
// Track deleted ranges and update ranges for decorations.
|
|
326
343
|
//
|
|
327
|
-
EditorView.updateListener.of(({ view, state,
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
ranges.forEach((range) => {
|
|
338
|
-
if (from2 === to2) {
|
|
339
|
-
const newRange = model.getRangeFromCursor!(range.cursor);
|
|
340
|
-
if (!newRange || newRange.to - newRange.from === 0) {
|
|
341
|
-
// TODO(burdon): Delete range if empty.
|
|
342
|
-
log.info('deleted comment', { thread: range.id });
|
|
343
|
-
}
|
|
344
|
+
EditorView.updateListener.of(({ view, state, changes }) => {
|
|
345
|
+
let mod = false;
|
|
346
|
+
const { comments, ...value } = state.field(commentsState);
|
|
347
|
+
changes.iterChanges((from, to, from2, to2) => {
|
|
348
|
+
comments.forEach(({ comment, range }) => {
|
|
349
|
+
// Test if range deleted.
|
|
350
|
+
if (from2 === to2) {
|
|
351
|
+
const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
|
|
352
|
+
if (!newRange || newRange.to - newRange.from === 0) {
|
|
353
|
+
options.onDelete?.(comment.id);
|
|
344
354
|
}
|
|
355
|
+
}
|
|
345
356
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
357
|
+
// Update range.
|
|
358
|
+
if (from <= range.to) {
|
|
359
|
+
const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
|
|
360
|
+
Object.assign(range, newRange);
|
|
361
|
+
mod = true;
|
|
362
|
+
}
|
|
352
363
|
});
|
|
364
|
+
});
|
|
353
365
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
366
|
+
if (mod) {
|
|
367
|
+
view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
|
|
357
368
|
}
|
|
369
|
+
}),
|
|
358
370
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
//
|
|
372
|
+
// Track selection/proximity.
|
|
373
|
+
//
|
|
374
|
+
EditorView.updateListener.of(({ view, state }) => {
|
|
375
|
+
let min = Infinity;
|
|
376
|
+
const {
|
|
377
|
+
selection: { current, closest },
|
|
378
|
+
comments,
|
|
379
|
+
} = state.field(commentsState);
|
|
380
|
+
|
|
381
|
+
const { head } = state.selection.main;
|
|
382
|
+
const selection: SelectionState = {};
|
|
383
|
+
comments.forEach(({ comment, range }) => {
|
|
384
|
+
if (head >= range.from && head <= range.to) {
|
|
385
|
+
selection.current = comment.id;
|
|
386
|
+
selection.closest = undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (!selection.current) {
|
|
390
|
+
const d = Math.min(Math.abs(head - range.from), Math.abs(head - range.to));
|
|
373
391
|
if (d < min) {
|
|
374
|
-
|
|
392
|
+
selection.closest = comment.id;
|
|
375
393
|
min = d;
|
|
376
394
|
}
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
if (selected.active !== active || selected.closest !== closest) {
|
|
380
|
-
view.dispatch({ effects: setSelection.of(selected) });
|
|
381
|
-
|
|
382
|
-
// Update callback.
|
|
383
|
-
handleSelect({
|
|
384
|
-
...selected,
|
|
385
|
-
ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
|
|
386
|
-
});
|
|
387
395
|
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
if (selection.current !== current || selection.closest !== closest) {
|
|
399
|
+
view.dispatch({ effects: setSelection.of(selection) });
|
|
400
|
+
|
|
401
|
+
// Update callback.
|
|
402
|
+
handleSelect({
|
|
403
|
+
selection,
|
|
404
|
+
comments: comments.map(({ comment, range }) => ({
|
|
405
|
+
comment,
|
|
406
|
+
range,
|
|
407
|
+
location: view.coordsAtPos(range.from),
|
|
408
|
+
})),
|
|
409
|
+
});
|
|
388
410
|
}
|
|
389
411
|
}),
|
|
412
|
+
|
|
413
|
+
options.onUpdate ? trackPastedComments(options.onUpdate) : [],
|
|
390
414
|
];
|
|
391
415
|
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Update comments state field.
|
|
419
|
+
*/
|
|
420
|
+
export const useComments = (view: EditorView | null, comments: Comment[] = []) => {
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
if (view) {
|
|
423
|
+
view.dispatch({
|
|
424
|
+
effects: setComments.of(comments),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}, [view, comments]);
|
|
428
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type EditorState, 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 = (state: EditorState, range: Range) => {
|
|
35
|
+
const cursorConverter = state.facet(Cursor.converter);
|
|
36
|
+
|
|
37
|
+
const from = cursorConverter.toCursor(range.from);
|
|
38
|
+
const to = cursorConverter.toCursor(range.to, -1);
|
|
39
|
+
return [from, to].join(':');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
static readonly getRangeFromCursor = (state: EditorState, cursor: string) => {
|
|
43
|
+
const cursorConverter = state.facet(Cursor.converter);
|
|
44
|
+
|
|
45
|
+
const parts = cursor.split(':');
|
|
46
|
+
const from = cursorConverter.fromCursor(parts[0]);
|
|
47
|
+
const to = cursorConverter.fromCursor(parts[1]);
|
|
48
|
+
return from !== undefined && to !== undefined ? { from, to } : undefined;
|
|
49
|
+
};
|
|
50
|
+
}
|