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