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