@dxos/react-ui-editor 0.3.11-main.a996413 → 0.3.11-main.afa73e0
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 +2014 -1648
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +16 -13
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +20 -17
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -3
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/{hooks/automerge → components/TextEditor}/automerge.stories.d.ts +6 -1
- package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
- package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts +9 -0
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/cursor.d.ts +5 -0
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/defs.d.ts +24 -0
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/index.d.ts +3 -0
- package/dist/types/src/extensions/automerge/index.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/semaphore.d.ts +14 -0
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/update-automerge.d.ts +5 -0
- package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -0
- package/dist/types/src/extensions/awareness.d.ts +46 -0
- package/dist/types/src/extensions/awareness.d.ts.map +1 -0
- package/dist/types/src/extensions/basic.d.ts +4 -5
- package/dist/types/src/extensions/basic.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +32 -19
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/cursor.d.ts +25 -0
- package/dist/types/src/extensions/cursor.d.ts.map +1 -0
- package/dist/types/src/extensions/index.d.ts +4 -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 +31 -0
- package/dist/types/src/hooks/awareness-provider.d.ts.map +1 -0
- package/dist/types/src/hooks/defs.d.ts +25 -0
- package/dist/types/src/hooks/defs.d.ts.map +1 -0
- package/dist/types/src/hooks/index.d.ts +3 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextEditor.d.ts +17 -0
- package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
- package/dist/types/src/hooks/useTextModel.d.ts +2 -31
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/styles/cursors.d.ts.map +1 -0
- package/dist/types/src/styles/index.d.ts +1 -0
- package/dist/types/src/styles/index.d.ts.map +1 -1
- package/dist/types/src/styles/markdown.d.ts +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 -35
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +82 -83
- package/src/components/TextEditor/TextEditor.stories.tsx +13 -34
- package/src/components/TextEditor/TextEditor.tsx +122 -93
- package/src/{hooks/automerge → components/TextEditor}/automerge.stories.tsx +66 -6
- package/src/components/TextEditor/comments.stories.tsx +357 -0
- package/src/extensions/autocomplete.ts +23 -19
- package/src/extensions/automerge/automerge.ts +88 -0
- package/src/extensions/automerge/cursor.ts +52 -0
- package/src/extensions/automerge/defs.ts +41 -0
- package/src/extensions/automerge/index.ts +6 -0
- package/src/{hooks → extensions}/automerge/semaphore.ts +34 -29
- package/src/{hooks → extensions}/automerge/update-automerge.ts +5 -6
- package/src/{hooks → extensions}/automerge/update-codemirror.ts +13 -5
- package/src/extensions/awareness.ts +310 -0
- package/src/extensions/basic.ts +22 -17
- package/src/extensions/comments.ts +252 -229
- package/src/extensions/cursor.ts +50 -0
- package/src/extensions/index.ts +4 -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 +11 -26
- package/src/extensions/markdown/hr.ts +71 -0
- package/src/extensions/markdown/image.ts +87 -0
- package/src/extensions/markdown/index.ts +7 -0
- package/src/extensions/{link.ts → markdown/link.ts} +85 -88
- package/src/extensions/{table.ts → markdown/table.ts} +10 -4
- 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 +124 -0
- package/src/hooks/defs.ts +42 -0
- package/src/hooks/index.ts +4 -1
- package/src/hooks/useTextEditor.ts +29 -0
- package/src/hooks/useTextModel.ts +30 -116
- package/src/index.ts +3 -0
- package/src/styles/index.ts +1 -0
- package/src/styles/markdown.ts +9 -12
- package/src/styles/tokens.ts +3 -0
- package/src/themes/default.ts +49 -78
- package/src/util.ts +41 -0
- package/dist/types/src/components/TextEditor/codemirror.stories.d.ts +0 -12
- package/dist/types/src/components/TextEditor/codemirror.stories.d.ts.map +0 -1
- package/dist/types/src/extensions/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/util.d.ts +0 -5
- package/dist/types/src/extensions/util.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/handle.d.ts +0 -9
- package/dist/types/src/hooks/automerge/handle.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/index.d.ts +0 -3
- package/dist/types/src/hooks/automerge/index.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/plugin.d.ts +0 -26
- package/dist/types/src/hooks/automerge/plugin.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/semaphore.d.ts +0 -19
- package/dist/types/src/hooks/automerge/semaphore.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/update-automerge.d.ts +0 -6
- package/dist/types/src/hooks/automerge/update-automerge.d.ts.map +0 -1
- package/dist/types/src/hooks/automerge/update-codemirror.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/cursors.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/index.d.ts +0 -3
- package/dist/types/src/hooks/yjs/index.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/space-provider.d.ts +0 -27
- package/dist/types/src/hooks/yjs/space-provider.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/yjs.stories.d.ts +0 -12
- package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +0 -1
- package/dist/types/src/hooks/yjs/yjs.test.d.ts +0 -2
- package/dist/types/src/hooks/yjs/yjs.test.d.ts.map +0 -1
- package/dist/types/src/testing/index.d.ts +0 -3
- package/dist/types/src/testing/index.d.ts.map +0 -1
- package/dist/types/src/testing/proto/gen/schema.d.ts +0 -15
- package/dist/types/src/testing/proto/gen/schema.d.ts.map +0 -1
- package/dist/types/src/testing/proto/index.d.ts +0 -2
- package/dist/types/src/testing/proto/index.d.ts.map +0 -1
- package/dist/types/src/testing/replicator.d.ts +0 -45
- package/dist/types/src/testing/replicator.d.ts.map +0 -1
- package/src/components/TextEditor/codemirror.stories.ts +0 -114
- package/src/extensions/code.ts +0 -99
- package/src/extensions/hr.ts +0 -74
- package/src/extensions/image.ts +0 -69
- package/src/extensions/mermaid.ts +0 -11
- package/src/extensions/tasklist.ts +0 -121
- package/src/extensions/util.ts +0 -18
- package/src/hooks/automerge/handle.ts +0 -14
- package/src/hooks/automerge/index.ts +0 -6
- package/src/hooks/automerge/plugin.ts +0 -132
- package/src/hooks/yjs/index.ts +0 -6
- package/src/hooks/yjs/space-provider.ts +0 -126
- package/src/hooks/yjs/yjs.stories.tsx +0 -56
- package/src/hooks/yjs/yjs.test.ts +0 -59
- package/src/testing/index.ts +0 -6
- package/src/testing/proto/gen/schema.ts +0 -49
- package/src/testing/proto/index.ts +0 -5
- package/src/testing/proto/schema.proto +0 -15
- package/src/testing/replicator.ts +0 -189
- /package/dist/types/src/{hooks → extensions}/automerge/update-codemirror.d.ts +0 -0
- /package/dist/types/src/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
- /package/dist/types/src/{hooks/yjs → styles}/cursors.d.ts +0 -0
- /package/src/{hooks/yjs → styles}/cursors.ts +0 -0
|
@@ -2,120 +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 { type
|
|
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
82
|
for (const effect of tr.effects) {
|
|
103
83
|
// Update selection.
|
|
104
84
|
if (effect.is(setSelection)) {
|
|
105
|
-
return { ...value,
|
|
85
|
+
return { ...value, selection: effect.value };
|
|
106
86
|
}
|
|
107
87
|
|
|
108
88
|
// Update range from store.
|
|
109
|
-
if (effect.is(
|
|
110
|
-
const
|
|
111
|
-
const ranges: ExtendedCommentRange[] = comments
|
|
89
|
+
if (effect.is(setComments)) {
|
|
90
|
+
const comments: CommentState[] = effect.value
|
|
112
91
|
.map((comment) => {
|
|
113
|
-
|
|
114
|
-
|
|
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 };
|
|
115
99
|
})
|
|
116
100
|
.filter(nonNullable);
|
|
117
101
|
|
|
118
|
-
return { ...value,
|
|
102
|
+
return { ...value, comments };
|
|
119
103
|
}
|
|
120
104
|
|
|
121
105
|
// Update entire state.
|
|
@@ -131,64 +115,48 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
131
115
|
/**
|
|
132
116
|
* Decorate ranges.
|
|
133
117
|
*/
|
|
134
|
-
const highlightDecorations = EditorView.decorations.compute([
|
|
135
|
-
const {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}
|
|
143
131
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
.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);
|
|
152
139
|
|
|
153
140
|
return Decoration.set(decorations);
|
|
154
141
|
});
|
|
155
142
|
|
|
156
|
-
/**
|
|
157
|
-
* Optional bookmark widget at the start/end of the selection.
|
|
158
|
-
* May correspond to a markdown bookmark.
|
|
159
|
-
*/
|
|
160
|
-
class BookmarkWidget extends WidgetType {
|
|
161
|
-
constructor(
|
|
162
|
-
private readonly _anchor: number,
|
|
163
|
-
private readonly _id: string,
|
|
164
|
-
private readonly _handleClick?: () => void,
|
|
165
|
-
) {
|
|
166
|
-
super();
|
|
167
|
-
invariant(this._id);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
override eq(other: BookmarkWidget) {
|
|
171
|
-
return this._anchor === other._anchor && this._id === other._id;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
override toDOM() {
|
|
175
|
-
const span = document.createElement('span');
|
|
176
|
-
span.className = 'cm-bookmark';
|
|
177
|
-
span.textContent = '※';
|
|
178
|
-
if (this._handleClick) {
|
|
179
|
-
span.onclick = () => this._handleClick!();
|
|
180
|
-
}
|
|
181
|
-
return span;
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
143
|
export type CommentsOptions = {
|
|
144
|
+
/**
|
|
145
|
+
* Key shortcut to create a new thread.
|
|
146
|
+
*/
|
|
186
147
|
key?: string;
|
|
187
|
-
footnote?: boolean;
|
|
188
148
|
/**
|
|
189
149
|
* Called to create a new thread and return the thread id.
|
|
190
150
|
*/
|
|
191
|
-
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;
|
|
192
160
|
/**
|
|
193
161
|
* Called to notify which thread is currently closest to the cursor.
|
|
194
162
|
*/
|
|
@@ -196,7 +164,76 @@ export type CommentsOptions = {
|
|
|
196
164
|
/**
|
|
197
165
|
* Called to render tooltip.
|
|
198
166
|
*/
|
|
199
|
-
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
|
+
];
|
|
200
237
|
};
|
|
201
238
|
|
|
202
239
|
/**
|
|
@@ -208,10 +245,11 @@ export type CommentsOptions = {
|
|
|
208
245
|
* 4). Tracks the current cursor position to:
|
|
209
246
|
* a). Update the decoration to show if the cursor is within a current selection.
|
|
210
247
|
* b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
|
|
211
|
-
* 5). Optionally,
|
|
212
|
-
* 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.
|
|
213
249
|
*/
|
|
214
250
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
251
|
+
const { key: shortcut = "meta-'" } = options;
|
|
252
|
+
|
|
215
253
|
const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
|
|
216
254
|
|
|
217
255
|
/**
|
|
@@ -219,32 +257,32 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
219
257
|
*/
|
|
220
258
|
const createCommentThread: Command = (view) => {
|
|
221
259
|
invariant(options.onCreate);
|
|
222
|
-
const {
|
|
260
|
+
const { from, to } = view.state.selection.main;
|
|
223
261
|
if (from === to) {
|
|
224
262
|
return false;
|
|
225
263
|
}
|
|
226
264
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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) {
|
|
230
277
|
// Create thread via callback.
|
|
231
|
-
const id =
|
|
278
|
+
const id = options.onCreate?.(cursor, view.coordsAtPos(from));
|
|
232
279
|
if (id) {
|
|
233
280
|
// Update range.
|
|
234
281
|
view.dispatch({
|
|
235
|
-
effects: setSelection.of({
|
|
282
|
+
effects: setSelection.of({ current: id }),
|
|
236
283
|
selection: { anchor: from },
|
|
237
284
|
});
|
|
238
285
|
|
|
239
|
-
// Insert footnote.
|
|
240
|
-
if (options.footnote) {
|
|
241
|
-
const tag = `[^${id}]`;
|
|
242
|
-
view.dispatch({
|
|
243
|
-
changes: { from: head, insert: tag },
|
|
244
|
-
selection: { anchor: head + tag.length },
|
|
245
|
-
});
|
|
246
|
-
}
|
|
247
|
-
|
|
248
286
|
return true;
|
|
249
287
|
}
|
|
250
288
|
}
|
|
@@ -252,45 +290,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
252
290
|
return false;
|
|
253
291
|
};
|
|
254
292
|
|
|
255
|
-
/**
|
|
256
|
-
* Bookmark widget decoration.
|
|
257
|
-
*/
|
|
258
|
-
const bookmarksViewPlugin = ViewPlugin.fromClass(
|
|
259
|
-
class BookmarkViewPlugin {
|
|
260
|
-
// Match markdown footnotes (option).
|
|
261
|
-
// https://www.markdownguide.org/extended-syntax/#footnotes
|
|
262
|
-
static bookmarkMatcher = new MatchDecorator({
|
|
263
|
-
regexp: /\[\^(\w+)\]/g,
|
|
264
|
-
decoration: (match, view, pos) => {
|
|
265
|
-
const id = match[1];
|
|
266
|
-
return Decoration.replace({
|
|
267
|
-
id,
|
|
268
|
-
widget: new BookmarkWidget(pos, id),
|
|
269
|
-
});
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
bookmarks: DecorationSet;
|
|
274
|
-
constructor(view: EditorView) {
|
|
275
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.createDeco(view);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
update(update: ViewUpdate) {
|
|
279
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.updateDeco(update, this.bookmarks);
|
|
280
|
-
}
|
|
281
|
-
},
|
|
282
|
-
{
|
|
283
|
-
decorations: (instance) => instance.bookmarks,
|
|
284
|
-
provide: (plugin) =>
|
|
285
|
-
EditorView.atomicRanges.of((view) => {
|
|
286
|
-
return view.plugin(plugin)?.bookmarks || Decoration.none;
|
|
287
|
-
}),
|
|
288
|
-
},
|
|
289
|
-
);
|
|
290
|
-
|
|
291
293
|
return [
|
|
292
|
-
|
|
293
|
-
commentsStateField,
|
|
294
|
+
commentsState,
|
|
294
295
|
highlightDecorations,
|
|
295
296
|
styles,
|
|
296
297
|
|
|
@@ -300,7 +301,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
300
301
|
options.onCreate
|
|
301
302
|
? keymap.of([
|
|
302
303
|
{
|
|
303
|
-
key:
|
|
304
|
+
key: shortcut,
|
|
304
305
|
run: callbackWrapper(createCommentThread),
|
|
305
306
|
},
|
|
306
307
|
])
|
|
@@ -320,9 +321,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
320
321
|
end: selection.to,
|
|
321
322
|
above: true,
|
|
322
323
|
create: () => {
|
|
323
|
-
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
324
324
|
const el = document.createElement('div');
|
|
325
|
-
options.onHover
|
|
325
|
+
options.onHover!(el, shortcut);
|
|
326
326
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
327
327
|
},
|
|
328
328
|
};
|
|
@@ -330,76 +330,99 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
330
330
|
|
|
331
331
|
return null;
|
|
332
332
|
},
|
|
333
|
-
{
|
|
333
|
+
{
|
|
334
|
+
// TODO(burdon): Hide on change triggered immediately?
|
|
335
|
+
// hideOnChange: true,
|
|
336
|
+
hoverTime: 1_000,
|
|
337
|
+
},
|
|
334
338
|
)
|
|
335
339
|
: [],
|
|
336
340
|
|
|
337
341
|
//
|
|
338
|
-
//
|
|
339
|
-
// TODO(burdon): Is there a better (finer grained) way to do this?
|
|
342
|
+
// Track deleted ranges and update ranges for decorations.
|
|
340
343
|
//
|
|
341
|
-
EditorView.updateListener.of(({ view, state,
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
ranges.forEach((range) => {
|
|
352
|
-
if (from2 === to2) {
|
|
353
|
-
const newRange = model.getRangeFromCursor!(range.cursor);
|
|
354
|
-
if (!newRange || newRange.to - newRange.from === 0) {
|
|
355
|
-
// TODO(burdon): Delete range if empty.
|
|
356
|
-
log.info('deleted comment', { thread: range.id });
|
|
357
|
-
}
|
|
344
|
+
EditorView.updateListener.of(({ view, state, changes }) => {
|
|
345
|
+
let mod = false;
|
|
346
|
+
const { comments, ...value } = state.field(commentsState);
|
|
347
|
+
changes.iterChanges((from, to, from2, to2) => {
|
|
348
|
+
comments.forEach(({ comment, range }) => {
|
|
349
|
+
// Test if range deleted.
|
|
350
|
+
if (from2 === to2) {
|
|
351
|
+
const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
|
|
352
|
+
if (!newRange || newRange.to - newRange.from === 0) {
|
|
353
|
+
options.onDelete?.(comment.id);
|
|
358
354
|
}
|
|
355
|
+
}
|
|
359
356
|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
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
|
+
}
|
|
366
363
|
});
|
|
364
|
+
});
|
|
367
365
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
}
|
|
366
|
+
if (mod) {
|
|
367
|
+
view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
|
|
371
368
|
}
|
|
369
|
+
}),
|
|
372
370
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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));
|
|
387
391
|
if (d < min) {
|
|
388
|
-
|
|
392
|
+
selection.closest = comment.id;
|
|
389
393
|
min = d;
|
|
390
394
|
}
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
if (selected.active !== active || selected.closest !== closest) {
|
|
394
|
-
view.dispatch({ effects: setSelection.of(selected) });
|
|
395
|
-
|
|
396
|
-
// Update callback.
|
|
397
|
-
handleSelect({
|
|
398
|
-
...selected,
|
|
399
|
-
ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
|
|
400
|
-
});
|
|
401
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
|
+
});
|
|
402
410
|
}
|
|
403
411
|
}),
|
|
412
|
+
|
|
413
|
+
options.onUpdate ? trackPastedComments(options.onUpdate) : [],
|
|
404
414
|
];
|
|
405
415
|
};
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Update comments state field.
|
|
419
|
+
*/
|
|
420
|
+
export const useComments = (view: EditorView | null, comments: Comment[] = []) => {
|
|
421
|
+
useEffect(() => {
|
|
422
|
+
if (view) {
|
|
423
|
+
view.dispatch({
|
|
424
|
+
effects: setComments.of(comments),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
}, [view, comments]);
|
|
428
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type EditorState, Facet } from '@codemirror/state';
|
|
6
|
+
|
|
7
|
+
import type { Range } from '../hooks';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts indexes into the text document into stable peer-independent cursors.
|
|
11
|
+
*
|
|
12
|
+
* See:
|
|
13
|
+
* - https://automerge.org/automerge/api-docs/js/functions/next.getCursor.html
|
|
14
|
+
* - https://github.com/yjs/yjs?tab=readme-ov-file#relative-positions
|
|
15
|
+
*
|
|
16
|
+
* @param {assoc} number Negative values will associate the cursor with the previous character
|
|
17
|
+
* while positive - with the next one.
|
|
18
|
+
*/
|
|
19
|
+
export interface CursorConverter {
|
|
20
|
+
toCursor(position: number, assoc?: -1 | 1 | undefined): string;
|
|
21
|
+
fromCursor(cursor: string): number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const defaultCursorConverter: CursorConverter = {
|
|
25
|
+
toCursor: (position) => position.toString(),
|
|
26
|
+
fromCursor: (cursor) => parseInt(cursor),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class Cursor {
|
|
30
|
+
static readonly converter = Facet.define<CursorConverter, CursorConverter>({
|
|
31
|
+
combine: (providers) => providers[0] ?? defaultCursorConverter,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
static readonly getCursorFromRange = (state: EditorState, range: Range) => {
|
|
35
|
+
const cursorConverter = state.facet(Cursor.converter);
|
|
36
|
+
|
|
37
|
+
const from = cursorConverter.toCursor(range.from);
|
|
38
|
+
const to = cursorConverter.toCursor(range.to, -1);
|
|
39
|
+
return [from, to].join(':');
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
static readonly getRangeFromCursor = (state: EditorState, cursor: string) => {
|
|
43
|
+
const cursorConverter = state.facet(Cursor.converter);
|
|
44
|
+
|
|
45
|
+
const parts = cursor.split(':');
|
|
46
|
+
const from = cursorConverter.fromCursor(parts[0]);
|
|
47
|
+
const to = cursorConverter.fromCursor(parts[1]);
|
|
48
|
+
return from !== undefined && to !== undefined ? { from, to } : undefined;
|
|
49
|
+
};
|
|
50
|
+
}
|