@dxos/react-ui-editor 0.3.11-main.42059c4 → 0.3.11-main.47875f3
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 +72 -57
- package/dist/lib/browser/index.mjs.map +3 -3
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +2 -2
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/defs.d.ts +4 -4
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +9 -4
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +4 -9
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +1 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/package.json +23 -23
- package/src/components/TextEditor/TextEditor.tsx +10 -10
- package/src/components/TextEditor/comments.stories.tsx +48 -74
- package/src/extensions/automerge/automerge.ts +11 -11
- package/src/extensions/automerge/defs.ts +9 -13
- package/src/extensions/automerge/semaphore.ts +12 -10
- package/src/extensions/comments.ts +49 -61
- package/src/hooks/index.ts +1 -2
|
@@ -8,14 +8,17 @@ import sortBy from 'lodash.sortby';
|
|
|
8
8
|
|
|
9
9
|
import { debounce } from '@dxos/async';
|
|
10
10
|
import { invariant } from '@dxos/invariant';
|
|
11
|
+
import { log } from '@dxos/log';
|
|
11
12
|
import { nonNullable } from '@dxos/util';
|
|
12
13
|
|
|
13
|
-
import { semaphoreFacet } from './automerge/defs';
|
|
14
14
|
import { Cursor } from './cursor';
|
|
15
15
|
import { type CommentRange, type EditorModel, 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
|
+
|
|
19
22
|
// TODO(burdon): Reconcile with theme.
|
|
20
23
|
const styles = EditorView.baseTheme({
|
|
21
24
|
'& .cm-comment': {
|
|
@@ -57,7 +60,7 @@ export const setFocus = (view: EditorView, thread: string) => {
|
|
|
57
60
|
view.dispatch({
|
|
58
61
|
effects: setSelection.of({ active: thread }),
|
|
59
62
|
selection: { anchor: range.from },
|
|
60
|
-
scrollIntoView: true,
|
|
63
|
+
scrollIntoView: true,
|
|
61
64
|
});
|
|
62
65
|
};
|
|
63
66
|
|
|
@@ -69,7 +72,8 @@ const setCommentState = StateEffect.define<CommentsState>();
|
|
|
69
72
|
|
|
70
73
|
/**
|
|
71
74
|
* State field (reducer) that tracks comment ranges.
|
|
72
|
-
* The ranges are tracked as
|
|
75
|
+
* The ranges are tracked as codemirror ranges (i.e., not relative YJS/Automerge positions), and
|
|
76
|
+
* therefore must be updated when the document changes.
|
|
73
77
|
*/
|
|
74
78
|
const commentsStateField = StateField.define<CommentsState>({
|
|
75
79
|
create: () => ({ ranges: [] }),
|
|
@@ -83,12 +87,11 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
// Update range from store.
|
|
86
|
-
if (effect.is(setCommentRange)
|
|
90
|
+
if (effect.is(setCommentRange)) {
|
|
87
91
|
const { comments } = effect.value;
|
|
88
92
|
const ranges: ExtendedCommentRange[] = comments
|
|
89
93
|
.map((comment) => {
|
|
90
94
|
const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
|
|
91
|
-
// console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
|
|
92
95
|
return range && { ...comment, ...range };
|
|
93
96
|
})
|
|
94
97
|
.filter(nonNullable);
|
|
@@ -132,22 +135,16 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
|
|
|
132
135
|
});
|
|
133
136
|
|
|
134
137
|
export type CommentsOptions = {
|
|
135
|
-
/**
|
|
136
|
-
* Key shortcut to create a new thread.
|
|
137
|
-
*/
|
|
138
138
|
key?: string;
|
|
139
|
+
footnote?: boolean;
|
|
139
140
|
/**
|
|
140
141
|
* Called to create a new thread and return the thread id.
|
|
141
142
|
*/
|
|
142
143
|
onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
|
|
143
|
-
/**
|
|
144
|
-
* Selection cut/deleted.
|
|
145
|
-
*/
|
|
146
|
-
onDelete?: (thread: string) => void;
|
|
147
144
|
/**
|
|
148
145
|
* Called when a comment is moved.
|
|
149
146
|
*/
|
|
150
|
-
|
|
147
|
+
onMove?: (threadID: string, cursor: string) => void;
|
|
151
148
|
/**
|
|
152
149
|
* Called to notify which thread is currently closest to the cursor.
|
|
153
150
|
*/
|
|
@@ -158,78 +155,58 @@ export type CommentsOptions = {
|
|
|
158
155
|
onHover?: (el: Element, shortcut: string) => void;
|
|
159
156
|
};
|
|
160
157
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
// Cut/deleted comments.
|
|
164
|
-
// Tracks indexed selections within text.
|
|
165
|
-
let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
|
|
158
|
+
const trackPastedComments = (onMove: (threadID: string, cursor: string) => void) => {
|
|
159
|
+
let tracked: { text: Text; comments: { from: number; to: number; id: string }[] } | null = null;
|
|
166
160
|
|
|
167
|
-
|
|
168
|
-
const handleTrack = (event: Event, view: EditorView) => {
|
|
161
|
+
const registerCopy = (event: Event, view: EditorView) => {
|
|
169
162
|
const comments = view.state.field(commentsStateField);
|
|
170
163
|
const { main } = view.state.selection;
|
|
171
|
-
const
|
|
164
|
+
const inSel = comments.ranges.filter(
|
|
172
165
|
(range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
|
|
173
166
|
);
|
|
174
|
-
|
|
175
|
-
if (!selectedRanges.length) {
|
|
167
|
+
if (!inSel.length) {
|
|
176
168
|
tracked = null;
|
|
177
169
|
} else {
|
|
178
170
|
tracked = {
|
|
179
171
|
text: view.state.doc.slice(main.from, main.to),
|
|
180
|
-
comments:
|
|
181
|
-
id: range.id,
|
|
182
|
-
from: range.from - main.from,
|
|
183
|
-
to: range.to - main.from,
|
|
184
|
-
})),
|
|
172
|
+
comments: inSel.map((range) => ({ from: range.from - main.from, to: range.to - main.from, id: range.id })),
|
|
185
173
|
};
|
|
186
174
|
}
|
|
187
175
|
};
|
|
188
176
|
|
|
189
177
|
return [
|
|
190
178
|
EditorView.domEventHandlers({
|
|
191
|
-
cut:
|
|
192
|
-
copy:
|
|
179
|
+
cut: registerCopy,
|
|
180
|
+
copy: registerCopy,
|
|
193
181
|
}),
|
|
194
182
|
|
|
195
|
-
|
|
196
|
-
EditorView.updateListener.of(({ view, state, transactions }) => {
|
|
183
|
+
EditorView.updateListener.of((update) => {
|
|
197
184
|
if (tracked) {
|
|
198
|
-
const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
|
|
185
|
+
const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
|
|
199
186
|
if (paste) {
|
|
200
187
|
let found = -1;
|
|
201
188
|
paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
|
|
202
189
|
if (text.eq(tracked!.text)) {
|
|
203
|
-
for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
|
|
204
|
-
fromB = transactions[i].changes.mapPos(fromB);
|
|
190
|
+
for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
|
|
191
|
+
fromB = update.transactions[i].changes.mapPos(fromB);
|
|
205
192
|
}
|
|
206
|
-
|
|
207
193
|
found = fromB;
|
|
208
194
|
}
|
|
209
195
|
});
|
|
210
|
-
|
|
211
196
|
if (found > -1) {
|
|
212
|
-
const
|
|
213
|
-
|
|
214
|
-
// Sync before recomputing cursor/range.
|
|
215
|
-
// TODO(burdon): Decouple from automerge?
|
|
216
|
-
view.state.facet(semaphoreFacet).reconcile(view);
|
|
217
|
-
|
|
218
|
-
const active = state.field(commentsStateField).ranges;
|
|
219
|
-
for (const moved of comments) {
|
|
197
|
+
const active = update.view.state.field(commentsStateField).ranges;
|
|
198
|
+
for (const moved of tracked.comments) {
|
|
220
199
|
if (active.some((range) => range.id === moved.id && range.from === range.to)) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
onUpdate(moved.id, cursor);
|
|
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
|
+
);
|
|
229
207
|
}
|
|
230
208
|
}
|
|
231
209
|
}
|
|
232
|
-
|
|
233
210
|
tracked = null;
|
|
234
211
|
}
|
|
235
212
|
}
|
|
@@ -246,7 +223,8 @@ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>)
|
|
|
246
223
|
* 4). Tracks the current cursor position to:
|
|
247
224
|
* a). Update the decoration to show if the cursor is within a current selection.
|
|
248
225
|
* b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
|
|
249
|
-
* 5). Optionally,
|
|
226
|
+
* 5). Optionally, inserts a markdown footnote when creating a comment thread.
|
|
227
|
+
* 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
250
228
|
*/
|
|
251
229
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
252
230
|
const { key: shortcut = "meta-'" } = options;
|
|
@@ -260,7 +238,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
260
238
|
const cursorConverter = view.state.facet(Cursor.converter);
|
|
261
239
|
|
|
262
240
|
invariant(options.onCreate);
|
|
263
|
-
const { from, to } = view.state.selection.main;
|
|
241
|
+
const { head, from, to } = view.state.selection.main;
|
|
264
242
|
if (from === to) {
|
|
265
243
|
return false;
|
|
266
244
|
}
|
|
@@ -276,6 +254,15 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
276
254
|
selection: { anchor: from },
|
|
277
255
|
});
|
|
278
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
|
+
|
|
279
266
|
return true;
|
|
280
267
|
}
|
|
281
268
|
}
|
|
@@ -314,6 +301,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
314
301
|
end: selection.to,
|
|
315
302
|
above: true,
|
|
316
303
|
create: () => {
|
|
304
|
+
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
317
305
|
const el = document.createElement('div');
|
|
318
306
|
options.onHover?.(el, shortcut);
|
|
319
307
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
@@ -341,12 +329,12 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
341
329
|
let mod = false;
|
|
342
330
|
const { active, ranges } = state.field(commentsStateField);
|
|
343
331
|
changes.iterChanges((from, to, from2, to2) => {
|
|
344
|
-
// TODO(burdon): Skip deleted (tracked) ranges.
|
|
345
332
|
ranges.forEach((range) => {
|
|
346
333
|
if (from2 === to2) {
|
|
347
334
|
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
348
335
|
if (!newRange || newRange.to - newRange.from === 0) {
|
|
349
|
-
|
|
336
|
+
// TODO(burdon): Delete range if empty.
|
|
337
|
+
log.info('deleted comment', { thread: range.id });
|
|
350
338
|
}
|
|
351
339
|
}
|
|
352
340
|
|
|
@@ -372,8 +360,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
372
360
|
let min = Infinity;
|
|
373
361
|
const { active, closest, ranges } = state.field(commentsStateField);
|
|
374
362
|
const selected: CommentSelected = { active: undefined, closest: undefined };
|
|
375
|
-
|
|
376
|
-
// TODO(burdon): Skip deleted (tracked) ranges.
|
|
377
363
|
ranges.forEach((comment) => {
|
|
378
364
|
const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
|
|
379
365
|
if (head >= comment.from && head <= comment.to) {
|
|
@@ -385,6 +371,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
385
371
|
}
|
|
386
372
|
});
|
|
387
373
|
|
|
374
|
+
// TODO(burdon): Fire debounced callback if range selected (to show hint).
|
|
375
|
+
|
|
388
376
|
if (selected.active !== active || selected.closest !== closest) {
|
|
389
377
|
view.dispatch({ effects: setSelection.of(selected) });
|
|
390
378
|
|
|
@@ -397,6 +385,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
397
385
|
}
|
|
398
386
|
}),
|
|
399
387
|
|
|
400
|
-
options.
|
|
388
|
+
options.onMove ? trackPastedComments(options.onMove) : [],
|
|
401
389
|
];
|
|
402
390
|
};
|