@dxos/react-ui-editor 0.3.11-main.8be6c8a → 0.3.11-main.8feb6a5
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 +250 -333
- 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.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +2 -7
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +4 -4
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
- package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +6 -2
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/{util → extensions}/cursor.d.ts +9 -1
- package/dist/types/src/extensions/cursor.d.ts.map +1 -0
- package/dist/types/src/extensions/index.d.ts +1 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
- 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/useTextModel.d.ts +0 -5
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/hooks/yjs.d.ts.map +1 -1
- 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.map +1 -0
- package/package.json +24 -23
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +12 -12
- package/src/components/TextEditor/TextEditor.stories.tsx +10 -43
- package/src/components/TextEditor/TextEditor.tsx +21 -31
- package/src/components/TextEditor/comments.stories.tsx +329 -0
- package/src/extensions/automerge/automerge.ts +2 -2
- package/src/extensions/automerge/cursor.ts +2 -1
- package/src/extensions/awareness.ts +25 -32
- package/src/extensions/code.ts +10 -2
- package/src/extensions/comments.ts +86 -116
- package/src/extensions/cursor.ts +46 -0
- package/src/extensions/hr.ts +2 -2
- package/src/extensions/image.ts +2 -2
- package/src/extensions/index.ts +1 -0
- package/src/extensions/link.ts +4 -4
- package/src/extensions/markdown/highlight.ts +0 -8
- package/src/extensions/table.ts +2 -2
- package/src/extensions/tasklist.ts +53 -77
- 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/useTextModel.ts +3 -6
- package/src/hooks/yjs.ts +1 -23
- package/src/styles/markdown.ts +0 -2
- package/src/themes/default.ts +17 -50
- 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/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,20 +2,8 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import { type Extension, StateEffect, StateField } from '@codemirror/state';
|
|
6
|
-
import {
|
|
7
|
-
hoverTooltip,
|
|
8
|
-
keymap,
|
|
9
|
-
type Command,
|
|
10
|
-
type DecorationSet,
|
|
11
|
-
type ViewUpdate,
|
|
12
|
-
Decoration,
|
|
13
|
-
EditorView,
|
|
14
|
-
MatchDecorator,
|
|
15
|
-
ViewPlugin,
|
|
16
|
-
WidgetType,
|
|
17
|
-
type Rect,
|
|
18
|
-
} from '@codemirror/view';
|
|
5
|
+
import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
|
|
6
|
+
import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
|
|
19
7
|
import sortBy from 'lodash.sortby';
|
|
20
8
|
|
|
21
9
|
import { debounce } from '@dxos/async';
|
|
@@ -23,24 +11,16 @@ import { invariant } from '@dxos/invariant';
|
|
|
23
11
|
import { log } from '@dxos/log';
|
|
24
12
|
import { nonNullable } from '@dxos/util';
|
|
25
13
|
|
|
14
|
+
import { Cursor } from './cursor';
|
|
26
15
|
import { type CommentRange, type EditorModel, type Range } from '../hooks';
|
|
27
16
|
import { getToken } from '../styles';
|
|
28
|
-
import { callbackWrapper
|
|
17
|
+
import { callbackWrapper } from '../util';
|
|
29
18
|
|
|
30
19
|
// TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
|
|
31
20
|
// TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
|
|
32
21
|
|
|
33
22
|
// TODO(burdon): Reconcile with theme.
|
|
34
23
|
const styles = EditorView.baseTheme({
|
|
35
|
-
'& .cm-bookmark': {
|
|
36
|
-
cursor: 'pointer',
|
|
37
|
-
margin: '4px',
|
|
38
|
-
padding: '4px',
|
|
39
|
-
backgroundColor: 'yellow',
|
|
40
|
-
},
|
|
41
|
-
'& .cm-bookmark-selected': {
|
|
42
|
-
backgroundColor: 'orange',
|
|
43
|
-
},
|
|
44
24
|
'& .cm-comment': {
|
|
45
25
|
backgroundColor: getToken('extend.colors.yellow.50'),
|
|
46
26
|
},
|
|
@@ -98,7 +78,7 @@ const setCommentState = StateEffect.define<CommentsState>();
|
|
|
98
78
|
const commentsStateField = StateField.define<CommentsState>({
|
|
99
79
|
create: () => ({ ranges: [] }),
|
|
100
80
|
update: (value, tr) => {
|
|
101
|
-
const cursorConverter = tr.state.facet(
|
|
81
|
+
const cursorConverter = tr.state.facet(Cursor.converter);
|
|
102
82
|
|
|
103
83
|
for (const effect of tr.effects) {
|
|
104
84
|
// Update selection.
|
|
@@ -111,7 +91,7 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
111
91
|
const { comments } = effect.value;
|
|
112
92
|
const ranges: ExtendedCommentRange[] = comments
|
|
113
93
|
.map((comment) => {
|
|
114
|
-
const range = getRangeFromCursor(cursorConverter, comment.cursor);
|
|
94
|
+
const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
|
|
115
95
|
return range && { ...comment, ...range };
|
|
116
96
|
})
|
|
117
97
|
.filter(nonNullable);
|
|
@@ -154,42 +134,17 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
|
|
|
154
134
|
return Decoration.set(decorations);
|
|
155
135
|
});
|
|
156
136
|
|
|
157
|
-
/**
|
|
158
|
-
* Optional bookmark widget at the start/end of the selection.
|
|
159
|
-
* May correspond to a markdown bookmark.
|
|
160
|
-
*/
|
|
161
|
-
class BookmarkWidget extends WidgetType {
|
|
162
|
-
constructor(
|
|
163
|
-
private readonly _anchor: number,
|
|
164
|
-
private readonly _id: string,
|
|
165
|
-
private readonly _handleClick?: () => void,
|
|
166
|
-
) {
|
|
167
|
-
super();
|
|
168
|
-
invariant(this._id);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
override eq(other: BookmarkWidget) {
|
|
172
|
-
return this._anchor === other._anchor && this._id === other._id;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
override toDOM() {
|
|
176
|
-
const span = document.createElement('span');
|
|
177
|
-
span.className = 'cm-bookmark';
|
|
178
|
-
span.textContent = '※';
|
|
179
|
-
if (this._handleClick) {
|
|
180
|
-
span.onclick = () => this._handleClick!();
|
|
181
|
-
}
|
|
182
|
-
return span;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
137
|
export type CommentsOptions = {
|
|
187
138
|
key?: string;
|
|
188
139
|
footnote?: boolean;
|
|
189
140
|
/**
|
|
190
141
|
* Called to create a new thread and return the thread id.
|
|
191
142
|
*/
|
|
192
|
-
onCreate?: (
|
|
143
|
+
onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
|
|
144
|
+
/**
|
|
145
|
+
* Called when a comment is moved.
|
|
146
|
+
*/
|
|
147
|
+
onMove?: (threadID: string, cursor: string) => void;
|
|
193
148
|
/**
|
|
194
149
|
* Called to notify which thread is currently closest to the cursor.
|
|
195
150
|
*/
|
|
@@ -197,7 +152,66 @@ export type CommentsOptions = {
|
|
|
197
152
|
/**
|
|
198
153
|
* Called to render tooltip.
|
|
199
154
|
*/
|
|
200
|
-
onHover?: (el: Element) => void;
|
|
155
|
+
onHover?: (el: Element, shortcut: string) => void;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const trackPastedComments = (onMove: (threadID: string, cursor: string) => void) => {
|
|
159
|
+
let tracked: { text: Text; comments: { from: number; to: number; id: string }[] } | null = null;
|
|
160
|
+
|
|
161
|
+
const registerCopy = (event: Event, view: EditorView) => {
|
|
162
|
+
const comments = view.state.field(commentsStateField);
|
|
163
|
+
const { main } = view.state.selection;
|
|
164
|
+
const inSel = comments.ranges.filter(
|
|
165
|
+
(range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
|
|
166
|
+
);
|
|
167
|
+
if (!inSel.length) {
|
|
168
|
+
tracked = null;
|
|
169
|
+
} else {
|
|
170
|
+
tracked = {
|
|
171
|
+
text: view.state.doc.slice(main.from, main.to),
|
|
172
|
+
comments: inSel.map((range) => ({ from: range.from - main.from, to: range.to - main.from, id: range.id })),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
return [
|
|
178
|
+
EditorView.domEventHandlers({
|
|
179
|
+
cut: registerCopy,
|
|
180
|
+
copy: registerCopy,
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
EditorView.updateListener.of((update) => {
|
|
184
|
+
if (tracked) {
|
|
185
|
+
const paste = update.transactions.find((tr) => tr.isUserEvent('input.paste'));
|
|
186
|
+
if (paste) {
|
|
187
|
+
let found = -1;
|
|
188
|
+
paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
|
|
189
|
+
if (text.eq(tracked!.text)) {
|
|
190
|
+
for (let i = update.transactions.indexOf(paste!) + 1; i < update.transactions.length; i++) {
|
|
191
|
+
fromB = update.transactions[i].changes.mapPos(fromB);
|
|
192
|
+
}
|
|
193
|
+
found = fromB;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
if (found > -1) {
|
|
197
|
+
const active = update.view.state.field(commentsStateField).ranges;
|
|
198
|
+
for (const moved of tracked.comments) {
|
|
199
|
+
if (active.some((range) => range.id === moved.id && range.from === range.to)) {
|
|
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
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
tracked = null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}),
|
|
214
|
+
];
|
|
201
215
|
};
|
|
202
216
|
|
|
203
217
|
/**
|
|
@@ -213,13 +227,15 @@ export type CommentsOptions = {
|
|
|
213
227
|
* 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
214
228
|
*/
|
|
215
229
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
230
|
+
const { key: shortcut = "meta-'" } = options;
|
|
231
|
+
|
|
216
232
|
const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
|
|
217
233
|
|
|
218
234
|
/**
|
|
219
235
|
* Create comment thread action.
|
|
220
236
|
*/
|
|
221
237
|
const createCommentThread: Command = (view) => {
|
|
222
|
-
const cursorConverter = view.state.facet(
|
|
238
|
+
const cursorConverter = view.state.facet(Cursor.converter);
|
|
223
239
|
|
|
224
240
|
invariant(options.onCreate);
|
|
225
241
|
const { head, from, to } = view.state.selection.main;
|
|
@@ -227,10 +243,10 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
227
243
|
return false;
|
|
228
244
|
}
|
|
229
245
|
|
|
230
|
-
const
|
|
231
|
-
if (
|
|
246
|
+
const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
|
|
247
|
+
if (cursor) {
|
|
232
248
|
// Create thread via callback.
|
|
233
|
-
const id = callbackWrapper(options.onCreate)(
|
|
249
|
+
const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
|
|
234
250
|
if (id) {
|
|
235
251
|
// Update range.
|
|
236
252
|
view.dispatch({
|
|
@@ -254,44 +270,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
254
270
|
return false;
|
|
255
271
|
};
|
|
256
272
|
|
|
257
|
-
/**
|
|
258
|
-
* Bookmark widget decoration.
|
|
259
|
-
*/
|
|
260
|
-
const bookmarksViewPlugin = ViewPlugin.fromClass(
|
|
261
|
-
class BookmarkViewPlugin {
|
|
262
|
-
// Match markdown footnotes (option).
|
|
263
|
-
// https://www.markdownguide.org/extended-syntax/#footnotes
|
|
264
|
-
static bookmarkMatcher = new MatchDecorator({
|
|
265
|
-
regexp: /\[\^(\w+)\]/g,
|
|
266
|
-
decoration: (match, view, pos) => {
|
|
267
|
-
const id = match[1];
|
|
268
|
-
return Decoration.replace({
|
|
269
|
-
id,
|
|
270
|
-
widget: new BookmarkWidget(pos, id),
|
|
271
|
-
});
|
|
272
|
-
},
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
bookmarks: DecorationSet;
|
|
276
|
-
constructor(view: EditorView) {
|
|
277
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.createDeco(view);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
update(update: ViewUpdate) {
|
|
281
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.updateDeco(update, this.bookmarks);
|
|
282
|
-
}
|
|
283
|
-
},
|
|
284
|
-
{
|
|
285
|
-
decorations: (instance) => instance.bookmarks,
|
|
286
|
-
provide: (plugin) =>
|
|
287
|
-
EditorView.atomicRanges.of((view) => {
|
|
288
|
-
return view.plugin(plugin)?.bookmarks || Decoration.none;
|
|
289
|
-
}),
|
|
290
|
-
},
|
|
291
|
-
);
|
|
292
|
-
|
|
293
273
|
return [
|
|
294
|
-
bookmarksViewPlugin,
|
|
295
274
|
commentsStateField,
|
|
296
275
|
highlightDecorations,
|
|
297
276
|
styles,
|
|
@@ -302,7 +281,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
302
281
|
options.onCreate
|
|
303
282
|
? keymap.of([
|
|
304
283
|
{
|
|
305
|
-
key:
|
|
284
|
+
key: shortcut,
|
|
306
285
|
run: callbackWrapper(createCommentThread),
|
|
307
286
|
},
|
|
308
287
|
])
|
|
@@ -324,7 +303,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
324
303
|
create: () => {
|
|
325
304
|
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
326
305
|
const el = document.createElement('div');
|
|
327
|
-
options.onHover?.(el);
|
|
306
|
+
options.onHover?.(el, shortcut);
|
|
328
307
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
329
308
|
},
|
|
330
309
|
};
|
|
@@ -341,7 +320,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
341
320
|
// TODO(burdon): Is there a better (finer grained) way to do this?
|
|
342
321
|
//
|
|
343
322
|
EditorView.updateListener.of(({ view, state, changes }) => {
|
|
344
|
-
const cursorConverter = view.state.facet(
|
|
323
|
+
const cursorConverter = view.state.facet(Cursor.converter);
|
|
345
324
|
|
|
346
325
|
//
|
|
347
326
|
// Test if need to recompute indexed range if document changes before the end of the range.
|
|
@@ -352,7 +331,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
352
331
|
changes.iterChanges((from, to, from2, to2) => {
|
|
353
332
|
ranges.forEach((range) => {
|
|
354
333
|
if (from2 === to2) {
|
|
355
|
-
const newRange = getRangeFromCursor(cursorConverter, range.cursor);
|
|
334
|
+
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
356
335
|
if (!newRange || newRange.to - newRange.from === 0) {
|
|
357
336
|
// TODO(burdon): Delete range if empty.
|
|
358
337
|
log.info('deleted comment', { thread: range.id });
|
|
@@ -360,7 +339,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
360
339
|
}
|
|
361
340
|
|
|
362
341
|
if (from <= range.to) {
|
|
363
|
-
const newRange = getRangeFromCursor(cursorConverter, range.cursor);
|
|
342
|
+
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
364
343
|
Object.assign(range, newRange);
|
|
365
344
|
mod = true;
|
|
366
345
|
}
|
|
@@ -392,6 +371,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
392
371
|
}
|
|
393
372
|
});
|
|
394
373
|
|
|
374
|
+
// TODO(burdon): Fire debounced callback if range selected (to show hint).
|
|
375
|
+
|
|
395
376
|
if (selected.active !== active || selected.closest !== closest) {
|
|
396
377
|
view.dispatch({ effects: setSelection.of(selected) });
|
|
397
378
|
|
|
@@ -403,18 +384,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
403
384
|
}
|
|
404
385
|
}
|
|
405
386
|
}),
|
|
406
|
-
];
|
|
407
|
-
};
|
|
408
387
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const to = cursorConverter.toCursor(range.to, -1);
|
|
412
|
-
return [from, to].join(':');
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
const getRangeFromCursor = (cursorConverter: CursorConverter, cursor: string) => {
|
|
416
|
-
const parts = cursor.split(':');
|
|
417
|
-
const from = cursorConverter.fromCursor(parts[0]);
|
|
418
|
-
const to = cursorConverter.fromCursor(parts[1]);
|
|
419
|
-
return from !== undefined && to !== undefined ? { from, to } : undefined;
|
|
388
|
+
options.onMove ? trackPastedComments(options.onMove) : [],
|
|
389
|
+
];
|
|
420
390
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { 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 = (cursorConverter: CursorConverter, range: Range) => {
|
|
35
|
+
const from = cursorConverter.toCursor(range.from);
|
|
36
|
+
const to = cursorConverter.toCursor(range.to, -1);
|
|
37
|
+
return [from, to].join(':');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
static readonly getRangeFromCursor = (cursorConverter: CursorConverter, cursor: string) => {
|
|
41
|
+
const parts = cursor.split(':');
|
|
42
|
+
const from = cursorConverter.fromCursor(parts[0]);
|
|
43
|
+
const to = cursorConverter.fromCursor(parts[1]);
|
|
44
|
+
return from !== undefined && to !== undefined ? { from, to } : undefined;
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/extensions/hr.ts
CHANGED
|
@@ -28,11 +28,11 @@ class HorizontalRuleWidget extends WidgetType {
|
|
|
28
28
|
super();
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
override eq(other:
|
|
31
|
+
override eq(other: this) {
|
|
32
32
|
return this._pos === (other as any as HorizontalRuleWidget)._pos;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
toDOM(view: EditorView) {
|
|
35
|
+
override toDOM(view: EditorView) {
|
|
36
36
|
// TODO(burdon): Create <hr> element? Does this clash with markdown parser?
|
|
37
37
|
const el = document.createElement('div');
|
|
38
38
|
el.className = 'cm-hr';
|
package/src/extensions/image.ts
CHANGED
|
@@ -72,11 +72,11 @@ class ImageWidget extends WidgetType {
|
|
|
72
72
|
super();
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
override eq(other:
|
|
75
|
+
override eq(other: this) {
|
|
76
76
|
return this._url === (other as any as ImageWidget)._url;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
-
toDOM(view: EditorView) {
|
|
79
|
+
override toDOM(view: EditorView) {
|
|
80
80
|
const img = document.createElement('img');
|
|
81
81
|
img.setAttribute('src', this._url);
|
|
82
82
|
img.setAttribute('class', 'cm-image');
|
package/src/extensions/index.ts
CHANGED
package/src/extensions/link.ts
CHANGED
|
@@ -93,11 +93,11 @@ class LinkButton extends WidgetType {
|
|
|
93
93
|
super();
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
override eq(other:
|
|
96
|
+
override eq(other: this) {
|
|
97
97
|
return this._url === other._url;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
toDOM(view: EditorView) {
|
|
100
|
+
override toDOM(view: EditorView) {
|
|
101
101
|
const el = document.createElement('span');
|
|
102
102
|
this._onAttach!(el, this._url);
|
|
103
103
|
return el;
|
|
@@ -109,11 +109,11 @@ class LinkText extends WidgetType {
|
|
|
109
109
|
super();
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
override eq(other:
|
|
112
|
+
override eq(other: this) {
|
|
113
113
|
return this._url === other._url;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
toDOM(view: EditorView) {
|
|
116
|
+
override toDOM(view: EditorView) {
|
|
117
117
|
const link = document.createElement('a');
|
|
118
118
|
link.setAttribute('class', 'cm-link');
|
|
119
119
|
link.textContent = this._text;
|
|
@@ -31,7 +31,6 @@ export const markdownTags = {
|
|
|
31
31
|
CodeText: Tag.define(),
|
|
32
32
|
EmphasisMark: Tag.define(),
|
|
33
33
|
HeaderMark: Tag.define(),
|
|
34
|
-
// HorizontalRule: Tag.define(),
|
|
35
34
|
InlineCode: Tag.define(),
|
|
36
35
|
LinkLabel: Tag.define(),
|
|
37
36
|
LinkReference: Tag.define(),
|
|
@@ -177,13 +176,6 @@ export const markdownHighlightStyle = (readonly?: boolean) => {
|
|
|
177
176
|
class: blockquote,
|
|
178
177
|
},
|
|
179
178
|
|
|
180
|
-
// TODO(burdon): Replace with extension.
|
|
181
|
-
// {
|
|
182
|
-
// tag: [markdownTags.HorizontalRule],
|
|
183
|
-
// class: mx(horizontalRule, readonly && '-indent-[100rem]'),
|
|
184
|
-
// },
|
|
185
|
-
|
|
186
|
-
// TODO(burdon): Only if being edited.
|
|
187
179
|
{
|
|
188
180
|
tag: [markdownTags.TableCell],
|
|
189
181
|
class: 'font-mono',
|
package/src/extensions/table.ts
CHANGED
|
@@ -97,14 +97,14 @@ class TableWidget extends WidgetType {
|
|
|
97
97
|
super();
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
override eq(other:
|
|
100
|
+
override eq(other: this) {
|
|
101
101
|
return (
|
|
102
102
|
this._table.header?.join() === other._table.header?.join() &&
|
|
103
103
|
this._table.rows?.join() === other._table.rows?.join()
|
|
104
104
|
);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
toDOM(view: EditorView) {
|
|
107
|
+
override toDOM(view: EditorView) {
|
|
108
108
|
const table = document.createElement('table');
|
|
109
109
|
|
|
110
110
|
{
|
|
@@ -2,57 +2,39 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
WidgetType,
|
|
9
|
-
MatchDecorator,
|
|
10
|
-
ViewPlugin,
|
|
11
|
-
type DecorationSet,
|
|
12
|
-
type ViewUpdate,
|
|
13
|
-
} from '@codemirror/view';
|
|
14
|
-
|
|
15
|
-
// TODO(burdon): Reconcile with theme.
|
|
16
|
-
const styles = EditorView.baseTheme({
|
|
17
|
-
'& .cm-task-item': {
|
|
18
|
-
paddingLeft: '4px',
|
|
19
|
-
paddingRight: '8px',
|
|
20
|
-
},
|
|
21
|
-
});
|
|
5
|
+
import { syntaxTree } from '@codemirror/language';
|
|
6
|
+
import { RangeSetBuilder } from '@codemirror/state';
|
|
7
|
+
import { EditorView, Decoration, WidgetType, ViewPlugin, type DecorationSet, type ViewUpdate } from '@codemirror/view';
|
|
22
8
|
|
|
23
9
|
class CheckboxWidget extends WidgetType {
|
|
24
|
-
constructor(
|
|
25
|
-
private _checked: boolean,
|
|
26
|
-
private readonly _indent: number,
|
|
27
|
-
private readonly _onCheck?: (check: boolean) => void,
|
|
28
|
-
) {
|
|
10
|
+
constructor(private _checked: boolean) {
|
|
29
11
|
super();
|
|
30
12
|
}
|
|
31
13
|
|
|
32
|
-
override eq(other:
|
|
33
|
-
return this._checked === other._checked
|
|
14
|
+
override eq(other: this) {
|
|
15
|
+
return this._checked === other._checked;
|
|
34
16
|
}
|
|
35
17
|
|
|
36
|
-
toDOM(view: EditorView) {
|
|
37
|
-
const
|
|
38
|
-
wrap.className = 'cm-task-item';
|
|
39
|
-
wrap.setAttribute('aria-hidden', 'true');
|
|
40
|
-
wrap.style.setProperty('margin-left', this._indent * 24 + 'px');
|
|
41
|
-
|
|
42
|
-
const input = wrap.appendChild(document.createElement('input'));
|
|
18
|
+
override toDOM(view: EditorView) {
|
|
19
|
+
const input = document.createElement('input');
|
|
43
20
|
input.type = 'checkbox';
|
|
44
21
|
input.checked = this._checked;
|
|
45
|
-
if (
|
|
22
|
+
if (view.state.readOnly) {
|
|
46
23
|
input.setAttribute('disabled', 'true');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
24
|
+
} else {
|
|
25
|
+
input.onmousedown = (event: Event) => {
|
|
26
|
+
const pos = view.posAtDOM(input);
|
|
27
|
+
const text = view.state.sliceDoc(pos, pos + 3);
|
|
28
|
+
if (text === (this._checked ? '[x]' : '[ ]')) {
|
|
29
|
+
view.dispatch({
|
|
30
|
+
changes: { from: pos + 1, to: pos + 2, insert: this._checked ? ' ' : 'x' },
|
|
31
|
+
});
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
}
|
|
52
34
|
};
|
|
53
35
|
}
|
|
54
36
|
|
|
55
|
-
return
|
|
37
|
+
return input;
|
|
56
38
|
}
|
|
57
39
|
|
|
58
40
|
override ignoreEvent() {
|
|
@@ -60,60 +42,54 @@ class CheckboxWidget extends WidgetType {
|
|
|
60
42
|
}
|
|
61
43
|
}
|
|
62
44
|
|
|
45
|
+
const checkedDeco = Decoration.replace({ widget: new CheckboxWidget(true) });
|
|
46
|
+
const uncheckedDeco = Decoration.replace({ widget: new CheckboxWidget(false) });
|
|
47
|
+
|
|
63
48
|
export type TasklistOptions = {};
|
|
64
49
|
|
|
65
50
|
export const tasklist = (options: TasklistOptions = {}) => {
|
|
66
|
-
|
|
67
|
-
// Allows for indents to be greater than AST would allow.
|
|
68
|
-
const taskMatcher = new MatchDecorator({
|
|
69
|
-
regexp: /^(\s*)- \[([ xX])\]\s/g,
|
|
70
|
-
decoration: (match, view, pos) => {
|
|
71
|
-
const indent = Math.floor(match[1].length / 2);
|
|
72
|
-
const checked = match[2] === 'x' || match[2] === 'X';
|
|
73
|
-
return Decoration.replace({
|
|
74
|
-
widget: new CheckboxWidget(
|
|
75
|
-
checked,
|
|
76
|
-
indent,
|
|
77
|
-
view.state.readOnly
|
|
78
|
-
? undefined
|
|
79
|
-
: (checked) => {
|
|
80
|
-
const idx = pos + match[0].indexOf('[') + 1;
|
|
81
|
-
view.dispatch({
|
|
82
|
-
changes: {
|
|
83
|
-
from: idx,
|
|
84
|
-
to: idx + 1,
|
|
85
|
-
insert: checked ? 'x' : ' ',
|
|
86
|
-
},
|
|
87
|
-
// TODO(burdon): Restore cursor position? More useful to move to end of line (can indent).
|
|
88
|
-
selection: {
|
|
89
|
-
anchor: idx + 3,
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
},
|
|
93
|
-
),
|
|
94
|
-
});
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
const tasks = ViewPlugin.fromClass(
|
|
51
|
+
return ViewPlugin.fromClass(
|
|
99
52
|
class {
|
|
100
|
-
|
|
53
|
+
decorations: DecorationSet;
|
|
54
|
+
|
|
101
55
|
constructor(view: EditorView) {
|
|
102
|
-
this.
|
|
56
|
+
this.decorations = buildDecorations(view);
|
|
103
57
|
}
|
|
104
58
|
|
|
105
59
|
update(update: ViewUpdate) {
|
|
106
|
-
|
|
60
|
+
if (update.docChanged || update.viewportChanged || update.selectionSet) {
|
|
61
|
+
this.decorations = buildDecorations(update.view);
|
|
62
|
+
}
|
|
107
63
|
}
|
|
108
64
|
},
|
|
109
65
|
{
|
|
110
|
-
decorations: (
|
|
66
|
+
decorations: (v) => v.decorations,
|
|
111
67
|
provide: (plugin) =>
|
|
112
68
|
EditorView.atomicRanges.of((view) => {
|
|
113
|
-
return view.plugin(plugin)?.
|
|
69
|
+
return view.plugin(plugin)?.decorations || Decoration.none;
|
|
114
70
|
}),
|
|
115
71
|
},
|
|
116
72
|
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const buildDecorations = (view: EditorView): DecorationSet => {
|
|
76
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
77
|
+
const { state } = view;
|
|
78
|
+
const cursor = state.selection.main.head;
|
|
79
|
+
|
|
80
|
+
for (const { from, to } of view.visibleRanges) {
|
|
81
|
+
syntaxTree(state).iterate({
|
|
82
|
+
enter: (node) => {
|
|
83
|
+
// Check if cursor is inside text.
|
|
84
|
+
if (node.name === 'TaskMarker' && (cursor < node.from || cursor > node.to)) {
|
|
85
|
+
const checked = state.doc.sliceString(node.from + 1, node.to - 1) === 'x';
|
|
86
|
+
builder.add(node.from, node.to, checked ? checkedDeco : uncheckedDeco);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
from,
|
|
90
|
+
to,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
117
93
|
|
|
118
|
-
return
|
|
94
|
+
return builder.finish();
|
|
119
95
|
};
|
|
@@ -6,12 +6,13 @@ import * as Y from 'yjs';
|
|
|
6
6
|
|
|
7
7
|
import { arrayToString, stringToArray } from '@dxos/util';
|
|
8
8
|
|
|
9
|
-
import { type CursorConverter } from '
|
|
9
|
+
import { type CursorConverter } from '../cursor';
|
|
10
10
|
|
|
11
11
|
export const cursorConverter = (text: Y.Text): CursorConverter => ({
|
|
12
12
|
toCursor: (index, assoc) => {
|
|
13
13
|
return arrayToString(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, index, assoc)));
|
|
14
14
|
},
|
|
15
|
+
|
|
15
16
|
fromCursor: (cursor) => {
|
|
16
17
|
return (
|
|
17
18
|
Y.createAbsolutePositionFromRelativePosition(Y.decodeRelativePosition(stringToArray(cursor)), text.doc!)?.index ??
|
|
@@ -12,7 +12,7 @@ import { type YText } from '@dxos/text-model';
|
|
|
12
12
|
describe('YJS', () => {
|
|
13
13
|
// https://docs.yjs.dev/api/shared-types/y.text
|
|
14
14
|
// https://github.com/yjs/yjs?tab=readme-ov-file#relative-positions
|
|
15
|
-
test('absolute position', () => {
|
|
15
|
+
test.skip('absolute position', () => {
|
|
16
16
|
const obj = new TextObject('hello world');
|
|
17
17
|
const index = obj.text.indexOf('world');
|
|
18
18
|
const relPos = Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(obj.content as YText, index));
|