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