@dxos/react-ui-editor 0.3.11-main.47875f3 → 0.3.11-main.47fbf79
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 +2614 -1077
- 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 -15
- 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/automerge.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/{yjs.stories.d.ts → hooks.stories.d.ts} +13 -2
- package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -0
- package/dist/types/src/components/Toolbar/Toolbar.d.ts +29 -0
- package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +25 -0
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
- package/dist/types/src/components/Toolbar/index.d.ts +2 -0
- package/dist/types/src/components/Toolbar/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.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/automerge.spec.d.ts +2 -0
- package/dist/types/src/extensions/automerge/automerge.spec.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/automerge.test.d.ts +2 -0
- package/dist/types/src/extensions/automerge/automerge.test.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/defs.d.ts +9 -11
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +8 -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/automerge/update-automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/awareness.d.ts +2 -2
- 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 +37 -20
- 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/index.d.ts +1 -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/formatting.d.ts +57 -0
- package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/formatting.test.d.ts +3 -0
- package/dist/types/src/extensions/markdown/formatting.test.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 +8 -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 +3 -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 +4 -2
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useActionHandler.d.ts +4 -0
- package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -0
- package/dist/types/src/hooks/useEditorView.d.ts +17 -0
- package/dist/types/src/hooks/useEditorView.d.ts.map +1 -0
- package/dist/types/src/hooks/useTextEditor.d.ts +39 -0
- package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -0
- package/dist/types/src/hooks/useTextModel.d.ts +14 -27
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -1
- 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 +2 -0
- package/dist/types/src/util.d.ts.map +1 -1
- package/package.json +46 -36
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +74 -83
- package/src/components/TextEditor/TextEditor.tsx +111 -75
- package/src/components/TextEditor/automerge.stories.tsx +1 -2
- package/src/components/TextEditor/hooks.stories.tsx +111 -0
- package/src/components/Toolbar/Toolbar.stories.tsx +102 -0
- package/src/components/Toolbar/Toolbar.tsx +245 -0
- package/src/components/Toolbar/index.ts +5 -0
- package/src/components/index.ts +1 -0
- package/src/extensions/autocomplete.ts +6 -3
- package/src/extensions/automerge/automerge.spec.tsx +76 -0
- package/src/extensions/automerge/automerge.test.ts +13 -0
- package/src/extensions/automerge/automerge.ts +54 -45
- package/src/extensions/automerge/cursor.ts +3 -3
- package/src/extensions/automerge/defs.ts +13 -34
- package/src/extensions/automerge/semaphore.ts +33 -32
- package/src/extensions/automerge/update-automerge.ts +14 -8
- package/src/extensions/automerge/update-codemirror.ts +5 -5
- package/src/extensions/awareness.ts +22 -18
- package/src/extensions/basic.ts +17 -10
- package/src/extensions/blast.ts +4 -1
- package/src/extensions/comments.ts +285 -189
- package/src/extensions/cursor.ts +7 -3
- package/src/extensions/index.ts +1 -7
- package/src/extensions/markdown/bundle.ts +23 -28
- package/src/extensions/markdown/code.ts +150 -0
- package/src/extensions/markdown/formatting.test.ts +481 -0
- package/src/extensions/markdown/formatting.ts +1198 -0
- package/src/extensions/markdown/heading.ts +59 -0
- package/src/extensions/markdown/highlight.ts +9 -15
- package/src/extensions/markdown/hr.ts +71 -0
- package/src/extensions/markdown/index.ts +8 -0
- package/src/extensions/{link.ts → markdown/link.ts} +16 -7
- package/src/extensions/{tasklist.ts → markdown/tasklist.ts} +48 -33
- 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 +5 -2
- package/src/hooks/useActionHandler.ts +93 -0
- package/src/hooks/useEditorView.ts +29 -0
- package/src/hooks/useTextEditor.ts +153 -0
- package/src/hooks/useTextModel.ts +66 -56
- package/src/index.ts +1 -1
- package/src/styles/markdown.ts +9 -10
- package/src/themes/default.ts +11 -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/comments.stories.d.ts +0 -26
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +0 -1
- 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 +0 -7
- 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/src/components/TextEditor/codemirror.stories.ts +0 -115
- package/src/components/TextEditor/comments.stories.tsx +0 -329
- package/src/components/TextEditor/yjs.stories.tsx +0 -56
- package/src/extensions/code.ts +0 -106
- package/src/extensions/hr.ts +0 -73
- 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
- /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/src/extensions/{image.ts → markdown/image.ts} +0 -0
- /package/src/extensions/{table.ts → markdown/table.ts} +0 -0
|
@@ -2,101 +2,112 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { invertedEffects } from '@codemirror/commands';
|
|
6
|
+
import { type Extension, Facet, StateEffect, StateField, type Text, type ChangeDesc } from '@codemirror/state';
|
|
6
7
|
import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
|
|
7
8
|
import sortBy from 'lodash.sortby';
|
|
9
|
+
import { useEffect } from 'react';
|
|
8
10
|
|
|
9
11
|
import { debounce } from '@dxos/async';
|
|
10
|
-
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
|
-
'& .cm-comment': {
|
|
21
|
+
'&light .cm-comment, &light .cm-comment-current': { mixBlendMode: 'darken' },
|
|
22
|
+
'&dark .cm-comment, &dark .cm-comment-current': { mixBlendMode: 'plus-lighter' },
|
|
23
|
+
'&light .cm-comment': {
|
|
25
24
|
backgroundColor: getToken('extend.colors.yellow.50'),
|
|
26
25
|
},
|
|
27
|
-
'& .cm-comment-
|
|
26
|
+
'&light .cm-comment-current': {
|
|
28
27
|
backgroundColor: getToken('extend.colors.yellow.100'),
|
|
29
28
|
},
|
|
29
|
+
'&dark .cm-comment': {
|
|
30
|
+
color: getToken('extend.colors.yellow.50'),
|
|
31
|
+
backgroundColor: getToken('extend.colors.yellow.900'),
|
|
32
|
+
},
|
|
33
|
+
'&dark .cm-comment-current': {
|
|
34
|
+
color: getToken('extend.colors.yellow.100'),
|
|
35
|
+
backgroundColor: getToken('extend.colors.yellow.950'),
|
|
36
|
+
},
|
|
30
37
|
});
|
|
31
38
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
39
|
+
const commentMark = Decoration.mark({ class: 'cm-comment' });
|
|
40
|
+
const commentCurrentMark = Decoration.mark({ class: 'cm-comment-current' });
|
|
41
|
+
|
|
42
|
+
type CommentState = {
|
|
43
|
+
comment: Comment;
|
|
44
|
+
range: Range;
|
|
45
|
+
location?: Rect | null;
|
|
35
46
|
};
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
active?: string;
|
|
48
|
+
type SelectionState = {
|
|
49
|
+
current?: string;
|
|
40
50
|
closest?: string;
|
|
41
51
|
};
|
|
42
52
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
// TODO(burdon): Not part of state; just required for callback.
|
|
47
|
-
location?: Rect | null;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export type CommentsState = CommentSelected & {
|
|
51
|
-
ranges: ExtendedCommentRange[];
|
|
53
|
+
export type CommentsState = {
|
|
54
|
+
comments: CommentState[];
|
|
55
|
+
selection: SelectionState;
|
|
52
56
|
};
|
|
53
57
|
|
|
54
|
-
export const
|
|
55
|
-
const
|
|
56
|
-
if (!
|
|
58
|
+
export const focusComment = (view: EditorView, id: string, center = true) => {
|
|
59
|
+
const comment = view.state.field(commentsState).comments.find((range) => range.comment.id === id);
|
|
60
|
+
if (!comment?.comment.cursor) {
|
|
57
61
|
return;
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
view.
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
const range = Cursor.getRangeFromCursor(view.state, comment.comment.cursor);
|
|
65
|
+
if (range) {
|
|
66
|
+
view.dispatch({
|
|
67
|
+
selection: { anchor: range.from },
|
|
68
|
+
effects: [
|
|
69
|
+
//
|
|
70
|
+
EditorView.scrollIntoView(range.from, center ? { y: 'center' } : undefined),
|
|
71
|
+
setSelection.of({ current: id }),
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
65
75
|
};
|
|
66
76
|
|
|
67
|
-
export const
|
|
77
|
+
export const setComments = StateEffect.define<Comment[]>();
|
|
68
78
|
|
|
69
|
-
export const setSelection = StateEffect.define<
|
|
79
|
+
export const setSelection = StateEffect.define<SelectionState>();
|
|
70
80
|
|
|
71
81
|
const setCommentState = StateEffect.define<CommentsState>();
|
|
72
82
|
|
|
73
83
|
/**
|
|
74
84
|
* State field (reducer) that tracks comment ranges.
|
|
75
|
-
* The ranges are tracked as
|
|
76
|
-
* therefore must be updated when the document changes.
|
|
85
|
+
* The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
|
|
77
86
|
*/
|
|
78
|
-
const
|
|
79
|
-
create: () => ({
|
|
87
|
+
const commentsState = StateField.define<CommentsState>({
|
|
88
|
+
create: () => ({ comments: [], selection: {} }),
|
|
80
89
|
update: (value, tr) => {
|
|
81
|
-
const cursorConverter = tr.state.facet(Cursor.converter);
|
|
82
|
-
|
|
83
90
|
for (const effect of tr.effects) {
|
|
84
91
|
// Update selection.
|
|
85
92
|
if (effect.is(setSelection)) {
|
|
86
|
-
return { ...value,
|
|
93
|
+
return { ...value, selection: effect.value };
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
// Update range from store.
|
|
90
|
-
if (effect.is(
|
|
91
|
-
const
|
|
92
|
-
const ranges: ExtendedCommentRange[] = comments
|
|
97
|
+
if (effect.is(setComments)) {
|
|
98
|
+
const comments: CommentState[] = effect.value
|
|
93
99
|
.map((comment) => {
|
|
94
|
-
|
|
95
|
-
|
|
100
|
+
// Skip cut/deleted comments.
|
|
101
|
+
if (!comment.cursor) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const range = Cursor.getRangeFromCursor(tr.state, comment.cursor);
|
|
106
|
+
return range && { comment, range };
|
|
96
107
|
})
|
|
97
108
|
.filter(nonNullable);
|
|
98
109
|
|
|
99
|
-
return { ...value,
|
|
110
|
+
return { ...value, comments };
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
// Update entire state.
|
|
@@ -112,39 +123,48 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
112
123
|
/**
|
|
113
124
|
* Decorate ranges.
|
|
114
125
|
*/
|
|
115
|
-
const
|
|
116
|
-
const {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
const commentsDecorations = EditorView.decorations.compute([commentsState], (state) => {
|
|
127
|
+
const {
|
|
128
|
+
selection: { current },
|
|
129
|
+
comments,
|
|
130
|
+
} = state.field(commentsState);
|
|
131
|
+
|
|
132
|
+
const decorations = sortBy(comments ?? [], (range) => range.range.from)
|
|
133
|
+
?.flatMap((comment) => {
|
|
134
|
+
const range = comment.range;
|
|
135
|
+
if (!range || range.from === range.to) {
|
|
136
|
+
console.warn('Invalid range:', range);
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
124
139
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.filter(nonNullable) ?? [];
|
|
140
|
+
if (comment.comment.id === current) {
|
|
141
|
+
return commentCurrentMark.range(range.from, range.to);
|
|
142
|
+
} else {
|
|
143
|
+
return commentMark.range(range.from, range.to);
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
.filter(nonNullable);
|
|
133
147
|
|
|
134
148
|
return Decoration.set(decorations);
|
|
135
149
|
});
|
|
136
150
|
|
|
137
151
|
export type CommentsOptions = {
|
|
152
|
+
/**
|
|
153
|
+
* Key shortcut to create a new thread.
|
|
154
|
+
*/
|
|
138
155
|
key?: string;
|
|
139
|
-
footnote?: boolean;
|
|
140
156
|
/**
|
|
141
157
|
* Called to create a new thread and return the thread id.
|
|
142
158
|
*/
|
|
143
|
-
onCreate?: (cursor: string
|
|
159
|
+
onCreate?: (params: { cursor: string; from: number; location?: Rect | null }) => string | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Selection cut/deleted.
|
|
162
|
+
*/
|
|
163
|
+
onDelete?: (thread: string) => void;
|
|
144
164
|
/**
|
|
145
165
|
* Called when a comment is moved.
|
|
146
166
|
*/
|
|
147
|
-
|
|
167
|
+
onUpdate?: (thread: string, cursor: string) => void;
|
|
148
168
|
/**
|
|
149
169
|
* Called to notify which thread is currently closest to the cursor.
|
|
150
170
|
*/
|
|
@@ -155,32 +175,74 @@ export type CommentsOptions = {
|
|
|
155
175
|
onHover?: (el: Element, shortcut: string) => void;
|
|
156
176
|
};
|
|
157
177
|
|
|
158
|
-
|
|
159
|
-
let tracked: { text: Text; comments: { from: number; to: number; id: string }[] } | null = null;
|
|
178
|
+
type TrackedComment = { id: string; from: number; to: number };
|
|
160
179
|
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
|
|
181
|
+
// Tracks indexed selections within text.
|
|
182
|
+
let tracked: { text: Text; comments: TrackedComment[] } | null = null;
|
|
183
|
+
|
|
184
|
+
// Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
|
|
185
|
+
const handleTrack = (event: Event, view: EditorView) => {
|
|
186
|
+
const comments = view.state.field(commentsState);
|
|
163
187
|
const { main } = view.state.selection;
|
|
164
|
-
const
|
|
165
|
-
(range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
|
|
188
|
+
const selectedRanges = comments.comments.filter(
|
|
189
|
+
({ range }) => range.from >= main.from && range.to <= main.to && range.from < range.to,
|
|
166
190
|
);
|
|
167
|
-
|
|
191
|
+
|
|
192
|
+
if (!selectedRanges.length) {
|
|
168
193
|
tracked = null;
|
|
169
194
|
} else {
|
|
170
195
|
tracked = {
|
|
171
196
|
text: view.state.doc.slice(main.from, main.to),
|
|
172
|
-
comments:
|
|
197
|
+
comments: selectedRanges.map(({ comment, range }) => ({
|
|
198
|
+
id: comment.id,
|
|
199
|
+
from: range.from - main.from,
|
|
200
|
+
to: range.to - main.from,
|
|
201
|
+
})),
|
|
173
202
|
};
|
|
174
203
|
}
|
|
175
204
|
};
|
|
176
205
|
|
|
177
206
|
return [
|
|
178
207
|
EditorView.domEventHandlers({
|
|
179
|
-
cut:
|
|
180
|
-
copy:
|
|
208
|
+
cut: handleTrack,
|
|
209
|
+
copy: handleTrack,
|
|
210
|
+
}),
|
|
211
|
+
|
|
212
|
+
// Track deleted comments.
|
|
213
|
+
invertedEffects.of((tr) => {
|
|
214
|
+
const { comments } = tr.startState.field(commentsState);
|
|
215
|
+
const effects: StateEffect<any>[] = [];
|
|
216
|
+
tr.changes.iterChangedRanges((fromA, toA) => {
|
|
217
|
+
for (const {
|
|
218
|
+
comment: { id },
|
|
219
|
+
range: { from, to },
|
|
220
|
+
} of comments) {
|
|
221
|
+
if (from < to && from >= fromA && to <= toA) {
|
|
222
|
+
effects.push(restoreCommentEffect.of({ id, from, to }));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return effects;
|
|
181
228
|
}),
|
|
182
229
|
|
|
230
|
+
// Handle paste or the undo of comment deletion.
|
|
183
231
|
EditorView.updateListener.of((update) => {
|
|
232
|
+
const restore: TrackedComment[] = [];
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < update.transactions.length; i++) {
|
|
235
|
+
const tr = update.transactions[i];
|
|
236
|
+
for (let j = 0; j < restore.length; j++) {
|
|
237
|
+
restore[j] = mapTrackedComment(restore[j], tr.changes);
|
|
238
|
+
}
|
|
239
|
+
for (const effect of tr.effects) {
|
|
240
|
+
if (effect.is(restoreCommentEffect)) {
|
|
241
|
+
restore.push(effect.value);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
184
246
|
if (tracked) {
|
|
185
247
|
const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
|
|
186
248
|
if (paste) {
|
|
@@ -190,30 +252,85 @@ const trackPastedComments = (onMove: (threadID: string, cursor: string) => void)
|
|
|
190
252
|
for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
|
|
191
253
|
fromB = update.transactions[i].changes.mapPos(fromB);
|
|
192
254
|
}
|
|
255
|
+
|
|
193
256
|
found = fromB;
|
|
194
257
|
}
|
|
195
258
|
});
|
|
259
|
+
|
|
196
260
|
if (found > -1) {
|
|
197
|
-
const active = update.view.state.field(commentsStateField).ranges;
|
|
198
261
|
for (const moved of tracked.comments) {
|
|
199
|
-
|
|
200
|
-
onMove(
|
|
201
|
-
moved.id,
|
|
202
|
-
Cursor.getCursorFromRange(update.view.state.facet(Cursor.converter), {
|
|
203
|
-
from: found + moved.from,
|
|
204
|
-
to: found + moved.to,
|
|
205
|
-
}),
|
|
206
|
-
);
|
|
207
|
-
}
|
|
262
|
+
restore.push({ id: moved.id, from: found + moved.from, to: found + moved.to });
|
|
208
263
|
}
|
|
209
264
|
}
|
|
265
|
+
|
|
210
266
|
tracked = null;
|
|
211
267
|
}
|
|
212
268
|
}
|
|
269
|
+
|
|
270
|
+
for (const comment of restore) {
|
|
271
|
+
const { comments } = update.startState.field(commentsState);
|
|
272
|
+
const exists = comments.some((c) => c.comment.id === comment.id && c.range.from < c.range.to);
|
|
273
|
+
if (!exists) {
|
|
274
|
+
const cursor = Cursor.getCursorFromRange(update.state, comment);
|
|
275
|
+
onUpdate(comment.id, cursor);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
213
278
|
}),
|
|
214
279
|
];
|
|
215
280
|
};
|
|
216
281
|
|
|
282
|
+
const mapTrackedComment = (comment: TrackedComment, changes: ChangeDesc) => ({
|
|
283
|
+
id: comment.id,
|
|
284
|
+
from: changes.mapPos(comment.from, 1),
|
|
285
|
+
to: changes.mapPos(comment.to, 1),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// These are attached to undone/redone transactions in the editor for the purpose of restoring comments
|
|
289
|
+
// that were deleted by the original changes.
|
|
290
|
+
const restoreCommentEffect = StateEffect.define<TrackedComment>({ map: mapTrackedComment });
|
|
291
|
+
|
|
292
|
+
const optionsFacet = Facet.define<CommentsOptions, CommentsOptions>({
|
|
293
|
+
combine: (providers) => providers[0],
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Create comment thread action.
|
|
298
|
+
*/
|
|
299
|
+
export const createComment: Command = (view) => {
|
|
300
|
+
const options = view.state.facet(optionsFacet);
|
|
301
|
+
const { from, to } = view.state.selection.main;
|
|
302
|
+
if (from === to) {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Don't allow selection at end of document.
|
|
307
|
+
if (to === view.state.doc.length) {
|
|
308
|
+
view.dispatch({
|
|
309
|
+
changes: {
|
|
310
|
+
from: to,
|
|
311
|
+
insert: '\n',
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const cursor = Cursor.getCursorFromRange(view.state, { from, to });
|
|
317
|
+
if (cursor) {
|
|
318
|
+
// Create thread via callback.
|
|
319
|
+
const id = options.onCreate?.({ cursor, from, location: view.coordsAtPos(from) });
|
|
320
|
+
if (id) {
|
|
321
|
+
// Update range.
|
|
322
|
+
view.dispatch({
|
|
323
|
+
effects: setSelection.of({ current: id }),
|
|
324
|
+
selection: { anchor: to },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return false;
|
|
332
|
+
};
|
|
333
|
+
|
|
217
334
|
/**
|
|
218
335
|
* Comment threads.
|
|
219
336
|
* 1). Updates the EditorModel to store relative selections for a set of comments threads.
|
|
@@ -223,56 +340,17 @@ const trackPastedComments = (onMove: (threadID: string, cursor: string) => void)
|
|
|
223
340
|
* 4). Tracks the current cursor position to:
|
|
224
341
|
* a). Update the decoration to show if the cursor is within a current selection.
|
|
225
342
|
* b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
|
|
226
|
-
* 5). Optionally,
|
|
227
|
-
* 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
343
|
+
* 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
228
344
|
*/
|
|
229
345
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
230
346
|
const { key: shortcut = "meta-'" } = options;
|
|
231
347
|
|
|
232
348
|
const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
|
|
233
349
|
|
|
234
|
-
/**
|
|
235
|
-
* Create comment thread action.
|
|
236
|
-
*/
|
|
237
|
-
const createCommentThread: Command = (view) => {
|
|
238
|
-
const cursorConverter = view.state.facet(Cursor.converter);
|
|
239
|
-
|
|
240
|
-
invariant(options.onCreate);
|
|
241
|
-
const { head, from, to } = view.state.selection.main;
|
|
242
|
-
if (from === to) {
|
|
243
|
-
return false;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
|
|
247
|
-
if (cursor) {
|
|
248
|
-
// Create thread via callback.
|
|
249
|
-
const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
|
|
250
|
-
if (id) {
|
|
251
|
-
// Update range.
|
|
252
|
-
view.dispatch({
|
|
253
|
-
effects: setSelection.of({ active: id }),
|
|
254
|
-
selection: { anchor: from },
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// Insert footnote.
|
|
258
|
-
if (options.footnote) {
|
|
259
|
-
const tag = `[^${id}]`;
|
|
260
|
-
view.dispatch({
|
|
261
|
-
changes: { from: head, insert: tag },
|
|
262
|
-
selection: { anchor: head + tag.length },
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return true;
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return false;
|
|
271
|
-
};
|
|
272
|
-
|
|
273
350
|
return [
|
|
274
|
-
|
|
275
|
-
|
|
351
|
+
optionsFacet.of(options),
|
|
352
|
+
commentsState,
|
|
353
|
+
commentsDecorations,
|
|
276
354
|
styles,
|
|
277
355
|
|
|
278
356
|
//
|
|
@@ -282,7 +360,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
282
360
|
? keymap.of([
|
|
283
361
|
{
|
|
284
362
|
key: shortcut,
|
|
285
|
-
run: callbackWrapper(
|
|
363
|
+
run: callbackWrapper(createComment),
|
|
286
364
|
},
|
|
287
365
|
])
|
|
288
366
|
: [],
|
|
@@ -301,9 +379,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
301
379
|
end: selection.to,
|
|
302
380
|
above: true,
|
|
303
381
|
create: () => {
|
|
304
|
-
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
305
382
|
const el = document.createElement('div');
|
|
306
|
-
options.onHover
|
|
383
|
+
options.onHover!(el, shortcut);
|
|
307
384
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
308
385
|
},
|
|
309
386
|
};
|
|
@@ -311,80 +388,99 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
311
388
|
|
|
312
389
|
return null;
|
|
313
390
|
},
|
|
314
|
-
{
|
|
391
|
+
{
|
|
392
|
+
// TODO(burdon): Hide on change triggered immediately?
|
|
393
|
+
// hideOnChange: true,
|
|
394
|
+
hoverTime: 1_000,
|
|
395
|
+
},
|
|
315
396
|
)
|
|
316
397
|
: [],
|
|
317
398
|
|
|
318
399
|
//
|
|
319
|
-
//
|
|
320
|
-
// TODO(burdon): Is there a better (finer grained) way to do this?
|
|
400
|
+
// Track deleted ranges and update ranges for decorations.
|
|
321
401
|
//
|
|
322
402
|
EditorView.updateListener.of(({ view, state, changes }) => {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
ranges.forEach((range) => {
|
|
333
|
-
if (from2 === to2) {
|
|
334
|
-
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
335
|
-
if (!newRange || newRange.to - newRange.from === 0) {
|
|
336
|
-
// TODO(burdon): Delete range if empty.
|
|
337
|
-
log.info('deleted comment', { thread: range.id });
|
|
338
|
-
}
|
|
403
|
+
let mod = false;
|
|
404
|
+
const { comments, ...value } = state.field(commentsState);
|
|
405
|
+
changes.iterChanges((from, to, from2, to2) => {
|
|
406
|
+
comments.forEach(({ comment, range }) => {
|
|
407
|
+
// Test if range deleted.
|
|
408
|
+
if (from2 === to2) {
|
|
409
|
+
const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
|
|
410
|
+
if (!newRange || newRange.to - newRange.from === 0) {
|
|
411
|
+
options.onDelete?.(comment.id);
|
|
339
412
|
}
|
|
413
|
+
}
|
|
340
414
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
}
|
|
415
|
+
// Update range.
|
|
416
|
+
if (from <= range.to) {
|
|
417
|
+
const newRange = Cursor.getRangeFromCursor(view.state, comment.cursor!);
|
|
418
|
+
Object.assign(range, newRange);
|
|
419
|
+
mod = true;
|
|
420
|
+
}
|
|
347
421
|
});
|
|
422
|
+
});
|
|
348
423
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
424
|
+
if (mod) {
|
|
425
|
+
view.dispatch({ effects: setCommentState.of({ comments, ...value }) });
|
|
352
426
|
}
|
|
427
|
+
}),
|
|
353
428
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
429
|
+
//
|
|
430
|
+
// Track selection/proximity.
|
|
431
|
+
//
|
|
432
|
+
EditorView.updateListener.of(({ view, state }) => {
|
|
433
|
+
let min = Infinity;
|
|
434
|
+
const {
|
|
435
|
+
selection: { current, closest },
|
|
436
|
+
comments,
|
|
437
|
+
} = state.field(commentsState);
|
|
438
|
+
|
|
439
|
+
const { head } = state.selection.main;
|
|
440
|
+
const selection: SelectionState = {};
|
|
441
|
+
comments.forEach(({ comment, range }) => {
|
|
442
|
+
if (head >= range.from && head <= range.to) {
|
|
443
|
+
selection.current = comment.id;
|
|
444
|
+
selection.closest = undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (!selection.current) {
|
|
448
|
+
const d = Math.min(Math.abs(head - range.from), Math.abs(head - range.to));
|
|
368
449
|
if (d < min) {
|
|
369
|
-
|
|
450
|
+
selection.closest = comment.id;
|
|
370
451
|
min = d;
|
|
371
452
|
}
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
// TODO(burdon): Fire debounced callback if range selected (to show hint).
|
|
375
|
-
|
|
376
|
-
if (selected.active !== active || selected.closest !== closest) {
|
|
377
|
-
view.dispatch({ effects: setSelection.of(selected) });
|
|
378
|
-
|
|
379
|
-
// Update callback.
|
|
380
|
-
handleSelect({
|
|
381
|
-
...selected,
|
|
382
|
-
ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
|
|
383
|
-
});
|
|
384
453
|
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
if (selection.current !== current || selection.closest !== closest) {
|
|
457
|
+
view.dispatch({ effects: setSelection.of(selection) });
|
|
458
|
+
|
|
459
|
+
// Update callback.
|
|
460
|
+
handleSelect({
|
|
461
|
+
selection,
|
|
462
|
+
comments: comments.map(({ comment, range }) => ({
|
|
463
|
+
comment,
|
|
464
|
+
range,
|
|
465
|
+
location: view.coordsAtPos(range.from),
|
|
466
|
+
})),
|
|
467
|
+
});
|
|
385
468
|
}
|
|
386
469
|
}),
|
|
387
470
|
|
|
388
|
-
options.
|
|
471
|
+
options.onUpdate ? trackPastedComments(options.onUpdate) : [],
|
|
389
472
|
];
|
|
390
473
|
};
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Update comments state field.
|
|
477
|
+
*/
|
|
478
|
+
export const useComments = (view: EditorView | null, comments: Comment[] = []) => {
|
|
479
|
+
useEffect(() => {
|
|
480
|
+
if (view) {
|
|
481
|
+
view.dispatch({
|
|
482
|
+
effects: setComments.of(comments),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
}, [view, comments]);
|
|
486
|
+
};
|
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]);
|