@dxos/react-ui-editor 0.3.11-main.d7d4c52 → 0.3.11-main.d8b8a39
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 +656 -680
- 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 +13 -14
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +14 -6
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
- 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/defs.d.ts +7 -9
- 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 +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/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +30 -17
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/cursor.d.ts +3 -3
- package/dist/types/src/extensions/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/hr.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +1 -1
- package/dist/types/src/extensions/index.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/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 +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 -27
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/themes/default.d.ts.map +1 -1
- package/dist/types/src/util.d.ts +2 -0
- package/dist/types/src/util.d.ts.map +1 -1
- package/package.json +33 -36
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +64 -69
- package/src/components/TextEditor/TextEditor.tsx +95 -70
- package/src/components/TextEditor/comments.stories.tsx +113 -71
- package/src/extensions/autocomplete.ts +6 -3
- package/src/extensions/automerge/automerge.ts +39 -45
- package/src/extensions/automerge/defs.ts +10 -31
- package/src/extensions/automerge/index.ts +0 -1
- package/src/extensions/automerge/semaphore.ts +23 -20
- package/src/extensions/automerge/update-automerge.ts +2 -2
- package/src/extensions/awareness.ts +15 -14
- package/src/extensions/basic.ts +22 -17
- package/src/extensions/code.ts +15 -17
- package/src/extensions/comments.ts +239 -136
- package/src/extensions/cursor.ts +7 -3
- package/src/extensions/hr.ts +49 -50
- package/src/extensions/index.ts +1 -1
- package/src/extensions/link.ts +3 -3
- package/src/extensions/markdown/bundle.ts +18 -24
- package/src/extensions/mention.ts +9 -2
- package/src/extensions/outliner.ts +12 -0
- package/src/extensions/tasklist.ts +76 -84
- 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 +47 -56
- package/src/themes/default.ts +0 -8
- package/src/util.ts +26 -3
- 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/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/src/components/TextEditor/codemirror.stories.ts +0 -115
- package/src/components/TextEditor/yjs.stories.tsx +0 -56
- package/src/extensions/yjs/cursor.ts +0 -22
- 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 -156
- 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
|
@@ -2,101 +2,104 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { type Extension, StateEffect, StateField } from '@codemirror/state';
|
|
5
|
+
import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
|
|
6
6
|
import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
|
|
7
7
|
import sortBy from 'lodash.sortby';
|
|
8
|
+
import { useEffect } from 'react';
|
|
8
9
|
|
|
9
10
|
import { debounce } from '@dxos/async';
|
|
10
11
|
import { invariant } from '@dxos/invariant';
|
|
11
|
-
import { log } from '@dxos/log';
|
|
12
12
|
import { nonNullable } from '@dxos/util';
|
|
13
13
|
|
|
14
14
|
import { Cursor } from './cursor';
|
|
15
|
-
import { type
|
|
15
|
+
import { type Comment, type Range } from '../hooks';
|
|
16
16
|
import { getToken } from '../styles';
|
|
17
17
|
import { callbackWrapper } from '../util';
|
|
18
18
|
|
|
19
|
-
// TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
|
|
20
|
-
// TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
|
|
21
|
-
|
|
22
19
|
// TODO(burdon): Reconcile with theme.
|
|
23
20
|
const styles = EditorView.baseTheme({
|
|
24
21
|
'& .cm-comment': {
|
|
25
22
|
backgroundColor: getToken('extend.colors.yellow.50'),
|
|
26
23
|
},
|
|
27
|
-
'& .cm-comment-
|
|
24
|
+
'& .cm-comment-current': {
|
|
28
25
|
backgroundColor: getToken('extend.colors.yellow.100'),
|
|
29
26
|
},
|
|
30
27
|
});
|
|
31
28
|
|
|
32
29
|
const marks = {
|
|
33
30
|
highlight: Decoration.mark({ class: 'cm-comment' }),
|
|
34
|
-
highlightActive: Decoration.mark({ class: 'cm-comment-
|
|
31
|
+
highlightActive: Decoration.mark({ class: 'cm-comment-current' }),
|
|
35
32
|
};
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
type CommentState = {
|
|
35
|
+
comment: Comment;
|
|
36
|
+
range: Range;
|
|
37
|
+
location?: Rect | null;
|
|
41
38
|
};
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
location?: Rect | null;
|
|
48
|
-
};
|
|
40
|
+
type SelectionState = {
|
|
41
|
+
current?: string;
|
|
42
|
+
closest?: string;
|
|
43
|
+
};
|
|
49
44
|
|
|
50
|
-
export type CommentsState =
|
|
51
|
-
|
|
45
|
+
export type CommentsState = {
|
|
46
|
+
comments: CommentState[];
|
|
47
|
+
selection: SelectionState;
|
|
52
48
|
};
|
|
53
49
|
|
|
54
|
-
export const setFocus = (view: EditorView,
|
|
55
|
-
const
|
|
56
|
-
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) {
|
|
57
53
|
return;
|
|
58
54
|
}
|
|
59
55
|
|
|
60
|
-
view.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
|
65
67
|
};
|
|
66
68
|
|
|
67
|
-
export const
|
|
69
|
+
export const setComments = StateEffect.define<Comment[]>();
|
|
68
70
|
|
|
69
|
-
export const setSelection = StateEffect.define<
|
|
71
|
+
export const setSelection = StateEffect.define<SelectionState>();
|
|
70
72
|
|
|
71
73
|
const setCommentState = StateEffect.define<CommentsState>();
|
|
72
74
|
|
|
73
75
|
/**
|
|
74
76
|
* State field (reducer) that tracks comment ranges.
|
|
75
|
-
* The ranges are tracked as
|
|
76
|
-
* 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.
|
|
77
78
|
*/
|
|
78
|
-
const
|
|
79
|
-
create: () => ({
|
|
79
|
+
const commentsState = StateField.define<CommentsState>({
|
|
80
|
+
create: () => ({ comments: [], selection: {} }),
|
|
80
81
|
update: (value, tr) => {
|
|
81
|
-
const cursorConverter = tr.state.facet(Cursor.converter);
|
|
82
|
-
|
|
83
82
|
for (const effect of tr.effects) {
|
|
84
83
|
// Update selection.
|
|
85
84
|
if (effect.is(setSelection)) {
|
|
86
|
-
return { ...value,
|
|
85
|
+
return { ...value, selection: effect.value };
|
|
87
86
|
}
|
|
88
87
|
|
|
89
88
|
// Update range from store.
|
|
90
|
-
if (effect.is(
|
|
91
|
-
const
|
|
92
|
-
const ranges: ExtendedCommentRange[] = comments
|
|
89
|
+
if (effect.is(setComments)) {
|
|
90
|
+
const comments: CommentState[] = effect.value
|
|
93
91
|
.map((comment) => {
|
|
94
|
-
|
|
95
|
-
|
|
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 };
|
|
96
99
|
})
|
|
97
100
|
.filter(nonNullable);
|
|
98
101
|
|
|
99
|
-
return { ...value,
|
|
102
|
+
return { ...value, comments };
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
// Update entire state.
|
|
@@ -112,35 +115,48 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
112
115
|
/**
|
|
113
116
|
* Decorate ranges.
|
|
114
117
|
*/
|
|
115
|
-
const highlightDecorations = EditorView.decorations.compute([
|
|
116
|
-
const {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
}
|
|
124
131
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.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);
|
|
133
139
|
|
|
134
140
|
return Decoration.set(decorations);
|
|
135
141
|
});
|
|
136
142
|
|
|
137
143
|
export type CommentsOptions = {
|
|
144
|
+
/**
|
|
145
|
+
* Key shortcut to create a new thread.
|
|
146
|
+
*/
|
|
138
147
|
key?: string;
|
|
139
|
-
footnote?: boolean;
|
|
140
148
|
/**
|
|
141
149
|
* Called to create a new thread and return the thread id.
|
|
142
150
|
*/
|
|
143
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;
|
|
144
160
|
/**
|
|
145
161
|
* Called to notify which thread is currently closest to the cursor.
|
|
146
162
|
*/
|
|
@@ -151,6 +167,75 @@ export type CommentsOptions = {
|
|
|
151
167
|
onHover?: (el: Element, shortcut: string) => void;
|
|
152
168
|
};
|
|
153
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
|
+
];
|
|
237
|
+
};
|
|
238
|
+
|
|
154
239
|
/**
|
|
155
240
|
* Comment threads.
|
|
156
241
|
* 1). Updates the EditorModel to store relative selections for a set of comments threads.
|
|
@@ -160,8 +245,7 @@ export type CommentsOptions = {
|
|
|
160
245
|
* 4). Tracks the current cursor position to:
|
|
161
246
|
* a). Update the decoration to show if the cursor is within a current selection.
|
|
162
247
|
* b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
|
|
163
|
-
* 5). Optionally,
|
|
164
|
-
* 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.
|
|
165
249
|
*/
|
|
166
250
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
167
251
|
const { key: shortcut = "meta-'" } = options;
|
|
@@ -172,34 +256,33 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
172
256
|
* Create comment thread action.
|
|
173
257
|
*/
|
|
174
258
|
const createCommentThread: Command = (view) => {
|
|
175
|
-
const cursorConverter = view.state.facet(Cursor.converter);
|
|
176
|
-
|
|
177
259
|
invariant(options.onCreate);
|
|
178
|
-
const {
|
|
260
|
+
const { from, to } = view.state.selection.main;
|
|
179
261
|
if (from === to) {
|
|
180
262
|
return false;
|
|
181
263
|
}
|
|
182
264
|
|
|
183
|
-
|
|
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 });
|
|
184
276
|
if (cursor) {
|
|
185
277
|
// Create thread via callback.
|
|
186
|
-
const id =
|
|
278
|
+
const id = options.onCreate?.(cursor, view.coordsAtPos(from));
|
|
187
279
|
if (id) {
|
|
188
280
|
// Update range.
|
|
189
281
|
view.dispatch({
|
|
190
|
-
effects: setSelection.of({
|
|
282
|
+
effects: setSelection.of({ current: id }),
|
|
191
283
|
selection: { anchor: from },
|
|
192
284
|
});
|
|
193
285
|
|
|
194
|
-
// Insert footnote.
|
|
195
|
-
if (options.footnote) {
|
|
196
|
-
const tag = `[^${id}]`;
|
|
197
|
-
view.dispatch({
|
|
198
|
-
changes: { from: head, insert: tag },
|
|
199
|
-
selection: { anchor: head + tag.length },
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
286
|
return true;
|
|
204
287
|
}
|
|
205
288
|
}
|
|
@@ -208,7 +291,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
208
291
|
};
|
|
209
292
|
|
|
210
293
|
return [
|
|
211
|
-
|
|
294
|
+
commentsState,
|
|
212
295
|
highlightDecorations,
|
|
213
296
|
styles,
|
|
214
297
|
|
|
@@ -238,9 +321,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
238
321
|
end: selection.to,
|
|
239
322
|
above: true,
|
|
240
323
|
create: () => {
|
|
241
|
-
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
242
324
|
const el = document.createElement('div');
|
|
243
|
-
options.onHover
|
|
325
|
+
options.onHover!(el, shortcut);
|
|
244
326
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
245
327
|
},
|
|
246
328
|
};
|
|
@@ -248,78 +330,99 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
248
330
|
|
|
249
331
|
return null;
|
|
250
332
|
},
|
|
251
|
-
{
|
|
333
|
+
{
|
|
334
|
+
// TODO(burdon): Hide on change triggered immediately?
|
|
335
|
+
// hideOnChange: true,
|
|
336
|
+
hoverTime: 1_000,
|
|
337
|
+
},
|
|
252
338
|
)
|
|
253
339
|
: [],
|
|
254
340
|
|
|
255
341
|
//
|
|
256
|
-
//
|
|
257
|
-
// TODO(burdon): Is there a better (finer grained) way to do this?
|
|
342
|
+
// Track deleted ranges and update ranges for decorations.
|
|
258
343
|
//
|
|
259
344
|
EditorView.updateListener.of(({ view, state, changes }) => {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
ranges.forEach((range) => {
|
|
270
|
-
if (from2 === to2) {
|
|
271
|
-
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
272
|
-
if (!newRange || newRange.to - newRange.from === 0) {
|
|
273
|
-
// TODO(burdon): Delete range if empty.
|
|
274
|
-
log.info('deleted comment', { thread: range.id });
|
|
275
|
-
}
|
|
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);
|
|
276
354
|
}
|
|
355
|
+
}
|
|
277
356
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
}
|
|
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
|
+
}
|
|
284
363
|
});
|
|
364
|
+
});
|
|
285
365
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
366
|
+
if (mod) {
|
|
367
|
+
view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
|
|
289
368
|
}
|
|
369
|
+
}),
|
|
290
370
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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));
|
|
305
391
|
if (d < min) {
|
|
306
|
-
|
|
392
|
+
selection.closest = comment.id;
|
|
307
393
|
min = d;
|
|
308
394
|
}
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
// TODO(burdon): Fire debounced callback if range selected (to show hint).
|
|
312
|
-
|
|
313
|
-
if (selected.active !== active || selected.closest !== closest) {
|
|
314
|
-
view.dispatch({ effects: setSelection.of(selected) });
|
|
315
|
-
|
|
316
|
-
// Update callback.
|
|
317
|
-
handleSelect({
|
|
318
|
-
...selected,
|
|
319
|
-
ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
|
|
320
|
-
});
|
|
321
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
|
+
});
|
|
322
410
|
}
|
|
323
411
|
}),
|
|
412
|
+
|
|
413
|
+
options.onUpdate ? trackPastedComments(options.onUpdate) : [],
|
|
324
414
|
];
|
|
325
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
|
+
};
|
package/src/extensions/cursor.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2024 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { Facet } from '@codemirror/state';
|
|
5
|
+
import { type EditorState, Facet } from '@codemirror/state';
|
|
6
6
|
|
|
7
7
|
import type { Range } from '../hooks';
|
|
8
8
|
|
|
@@ -31,13 +31,17 @@ export class Cursor {
|
|
|
31
31
|
combine: (providers) => providers[0] ?? defaultCursorConverter,
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
static readonly getCursorFromRange = (
|
|
34
|
+
static readonly getCursorFromRange = (state: EditorState, range: Range) => {
|
|
35
|
+
const cursorConverter = state.facet(Cursor.converter);
|
|
36
|
+
|
|
35
37
|
const from = cursorConverter.toCursor(range.from);
|
|
36
38
|
const to = cursorConverter.toCursor(range.to, -1);
|
|
37
39
|
return [from, to].join(':');
|
|
38
40
|
};
|
|
39
41
|
|
|
40
|
-
static readonly getRangeFromCursor = (
|
|
42
|
+
static readonly getRangeFromCursor = (state: EditorState, cursor: string) => {
|
|
43
|
+
const cursorConverter = state.facet(Cursor.converter);
|
|
44
|
+
|
|
41
45
|
const parts = cursor.split(':');
|
|
42
46
|
const from = cursorConverter.fromCursor(parts[0]);
|
|
43
47
|
const to = cursorConverter.fromCursor(parts[1]);
|
package/src/extensions/hr.ts
CHANGED
|
@@ -2,72 +2,71 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
EditorView,
|
|
9
|
-
MatchDecorator,
|
|
10
|
-
ViewPlugin,
|
|
11
|
-
type ViewUpdate,
|
|
12
|
-
WidgetType,
|
|
13
|
-
} from '@codemirror/view';
|
|
5
|
+
import { syntaxTree } from '@codemirror/language';
|
|
6
|
+
import { RangeSetBuilder } from '@codemirror/state';
|
|
7
|
+
import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view';
|
|
14
8
|
|
|
15
9
|
import { getToken } from '../styles';
|
|
16
10
|
|
|
17
11
|
// TODO(burdon): Reconcile with theme.
|
|
18
12
|
const styles = EditorView.baseTheme({
|
|
19
13
|
'& .cm-hr': {
|
|
20
|
-
// TODO(burdon): ???
|
|
21
14
|
// Note that block-level decorations should not have vertical margins,
|
|
22
|
-
|
|
15
|
+
borderTop: `1px solid ${getToken('extend.colors.neutral.200')}`,
|
|
23
16
|
},
|
|
24
17
|
});
|
|
25
18
|
|
|
26
19
|
class HorizontalRuleWidget extends WidgetType {
|
|
27
|
-
|
|
28
|
-
super();
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
override eq(other: this) {
|
|
32
|
-
return this._pos === (other as any as HorizontalRuleWidget)._pos;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
override toDOM(view: EditorView) {
|
|
36
|
-
// TODO(burdon): Create <hr> element? Does this clash with markdown parser?
|
|
20
|
+
override toDOM() {
|
|
37
21
|
const el = document.createElement('div');
|
|
38
22
|
el.className = 'cm-hr';
|
|
39
23
|
return el;
|
|
40
24
|
}
|
|
41
25
|
}
|
|
42
26
|
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
27
|
+
const decoration = Decoration.replace({ widget: new HorizontalRuleWidget() });
|
|
28
|
+
|
|
29
|
+
const buildDecorations = (view: EditorView): DecorationSet => {
|
|
30
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
31
|
+
const { state } = view;
|
|
32
|
+
const cursor = state.selection.main.head;
|
|
33
|
+
|
|
34
|
+
for (const { from, to } of view.visibleRanges) {
|
|
35
|
+
syntaxTree(state).iterate({
|
|
36
|
+
enter: (node) => {
|
|
37
|
+
if (node.name === 'HorizontalRule') {
|
|
38
|
+
// Check if cursor is inside text.
|
|
39
|
+
if (cursor <= node.from || cursor >= node.to) {
|
|
40
|
+
builder.add(node.from, node.to, decoration);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
from,
|
|
45
|
+
to,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return builder.finish();
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const hr = () => {
|
|
53
|
+
return [
|
|
54
|
+
styles,
|
|
55
|
+
ViewPlugin.fromClass(
|
|
56
|
+
class {
|
|
57
|
+
decorations: DecorationSet;
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class {
|
|
55
|
-
rules: DecorationSet;
|
|
56
|
-
constructor(view: EditorView) {
|
|
57
|
-
this.rules = placeholderMatcher.createDeco(view);
|
|
58
|
-
}
|
|
59
|
+
constructor(view: EditorView) {
|
|
60
|
+
this.decorations = buildDecorations(view);
|
|
61
|
+
}
|
|
59
62
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}),
|
|
71
|
-
},
|
|
72
|
-
),
|
|
73
|
-
];
|
|
63
|
+
update(update: ViewUpdate) {
|
|
64
|
+
this.decorations = buildDecorations(update.view);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
decorations: (v) => v.decorations,
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
];
|
|
72
|
+
};
|