@dxos/react-ui-editor 0.3.11-main.c01834e → 0.3.11-main.c8e9429

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.
Files changed (81) hide show
  1. package/dist/lib/browser/index.mjs +347 -448
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +6 -2
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +12 -17
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -6
  9. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
  11. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
  12. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +1 -1
  13. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  15. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  16. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  17. package/dist/types/src/extensions/automerge/defs.d.ts +4 -4
  18. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  19. package/dist/types/src/extensions/automerge/semaphore.d.ts +4 -9
  20. package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
  21. package/dist/types/src/extensions/code.d.ts.map +1 -1
  22. package/dist/types/src/extensions/comments.d.ts +14 -5
  23. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  24. package/dist/types/src/{util → extensions}/cursor.d.ts +9 -1
  25. package/dist/types/src/extensions/cursor.d.ts.map +1 -0
  26. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  27. package/dist/types/src/extensions/index.d.ts +1 -0
  28. package/dist/types/src/extensions/index.d.ts.map +1 -1
  29. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  30. package/dist/types/src/extensions/tasklist.d.ts +5 -1
  31. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  32. package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
  33. package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
  34. package/dist/types/src/hooks/index.d.ts +1 -1
  35. package/dist/types/src/hooks/index.d.ts.map +1 -1
  36. package/dist/types/src/hooks/useTextModel.d.ts +0 -5
  37. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  38. package/dist/types/src/hooks/yjs.d.ts.map +1 -1
  39. package/dist/types/src/index.d.ts +2 -0
  40. package/dist/types/src/index.d.ts.map +1 -1
  41. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  42. package/dist/types/src/themes/default.d.ts.map +1 -1
  43. package/dist/types/src/util.d.ts.map +1 -0
  44. package/package.json +25 -24
  45. package/src/components/TextEditor/MarkdownEditor.stories.tsx +19 -16
  46. package/src/components/TextEditor/TextEditor.stories.tsx +12 -46
  47. package/src/components/TextEditor/TextEditor.tsx +71 -67
  48. package/src/components/TextEditor/comments.stories.tsx +355 -0
  49. package/src/extensions/autocomplete.ts +18 -17
  50. package/src/extensions/automerge/automerge.ts +12 -14
  51. package/src/extensions/automerge/cursor.ts +2 -1
  52. package/src/extensions/automerge/defs.ts +13 -9
  53. package/src/extensions/automerge/semaphore.ts +10 -12
  54. package/src/extensions/awareness.ts +27 -33
  55. package/src/extensions/code.ts +24 -18
  56. package/src/extensions/comments.ts +128 -140
  57. package/src/extensions/cursor.ts +46 -0
  58. package/src/extensions/hr.ts +8 -9
  59. package/src/extensions/image.ts +2 -2
  60. package/src/extensions/index.ts +1 -0
  61. package/src/extensions/link.ts +7 -7
  62. package/src/extensions/markdown/highlight.ts +0 -8
  63. package/src/extensions/table.ts +2 -2
  64. package/src/extensions/tasklist.ts +54 -77
  65. package/src/extensions/yjs/cursor.ts +2 -1
  66. package/src/extensions/yjs/yjs.test.ts +1 -1
  67. package/src/extensions/yjs/yjs.ts +2 -2
  68. package/src/hooks/index.ts +2 -1
  69. package/src/hooks/useTextModel.ts +4 -7
  70. package/src/hooks/yjs.ts +1 -23
  71. package/src/index.ts +2 -0
  72. package/src/styles/markdown.ts +0 -2
  73. package/src/themes/default.ts +17 -50
  74. package/dist/types/src/util/cursor.d.ts.map +0 -1
  75. package/dist/types/src/util/index.d.ts +0 -3
  76. package/dist/types/src/util/index.d.ts.map +0 -1
  77. package/dist/types/src/util/util.d.ts.map +0 -1
  78. package/src/util/cursor.ts +0 -29
  79. package/src/util/index.ts +0 -6
  80. /package/dist/types/src/{util/util.d.ts → util.d.ts} +0 -0
  81. /package/src/{util/util.ts → util.ts} +0 -0
@@ -2,45 +2,22 @@
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';
22
10
  import { invariant } from '@dxos/invariant';
23
- import { log } from '@dxos/log';
24
11
  import { nonNullable } from '@dxos/util';
25
12
 
13
+ import { semaphoreFacet } from './automerge/defs';
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, CursorConverter } from '../util';
29
-
30
- // TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
31
- // TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
17
+ import { callbackWrapper } from '../util';
32
18
 
33
19
  // TODO(burdon): Reconcile with theme.
34
20
  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
21
  '& .cm-comment': {
45
22
  backgroundColor: getToken('extend.colors.yellow.50'),
46
23
  },
@@ -80,7 +57,7 @@ export const setFocus = (view: EditorView, thread: string) => {
80
57
  view.dispatch({
81
58
  effects: setSelection.of({ active: thread }),
82
59
  selection: { anchor: range.from },
83
- scrollIntoView: true,
60
+ scrollIntoView: true, // TODO(burdon): Scroll to y-position (or center of screen?)
84
61
  });
85
62
  };
86
63
 
@@ -92,13 +69,12 @@ const setCommentState = StateEffect.define<CommentsState>();
92
69
 
93
70
  /**
94
71
  * State field (reducer) that tracks comment ranges.
95
- * The ranges are tracked as codemirror ranges (i.e., not relative YJS/Automerge positions), and
96
- * therefore must be updated when the document changes.
72
+ * The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
97
73
  */
98
74
  const commentsStateField = StateField.define<CommentsState>({
99
75
  create: () => ({ ranges: [] }),
100
76
  update: (value, tr) => {
101
- const cursorConverter = tr.state.facet(CursorConverter);
77
+ const cursorConverter = tr.state.facet(Cursor.converter);
102
78
 
103
79
  for (const effect of tr.effects) {
104
80
  // Update selection.
@@ -111,7 +87,8 @@ const commentsStateField = StateField.define<CommentsState>({
111
87
  const { comments } = effect.value;
112
88
  const ranges: ExtendedCommentRange[] = comments
113
89
  .map((comment) => {
114
- const range = getRangeFromCursor(cursorConverter, comment.cursor);
90
+ const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
91
+ // console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
115
92
  return range && { ...comment, ...range };
116
93
  })
117
94
  .filter(nonNullable);
@@ -154,42 +131,23 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
154
131
  return Decoration.set(decorations);
155
132
  });
156
133
 
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
134
  export type CommentsOptions = {
135
+ /**
136
+ * Key shortcut to create a new thread.
137
+ */
187
138
  key?: string;
188
- footnote?: boolean;
189
139
  /**
190
140
  * Called to create a new thread and return the thread id.
191
141
  */
192
- onCreate?: (range: string) => string | undefined;
142
+ onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
143
+ /**
144
+ * Selection cut/deleted.
145
+ */
146
+ onDelete?: (thread: string) => void;
147
+ /**
148
+ * Called when a comment is moved.
149
+ */
150
+ onUpdate?: (thread: string, cursor: string) => void;
193
151
  /**
194
152
  * Called to notify which thread is currently closest to the cursor.
195
153
  */
@@ -197,7 +155,88 @@ export type CommentsOptions = {
197
155
  /**
198
156
  * Called to render tooltip.
199
157
  */
200
- onHover?: (el: Element) => void;
158
+ onHover?: (el: Element, shortcut: string) => void;
159
+ };
160
+
161
+ // TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
162
+ const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
163
+ // Cut/deleted comments.
164
+ // Tracks indexed selections within text.
165
+ let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
166
+
167
+ // Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
168
+ const handleTrack = (event: Event, view: EditorView) => {
169
+ const comments = view.state.field(commentsStateField);
170
+ const { main } = view.state.selection;
171
+ const selectedRanges = comments.ranges.filter(
172
+ (range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
173
+ );
174
+
175
+ if (!selectedRanges.length) {
176
+ tracked = null;
177
+ } else {
178
+ tracked = {
179
+ text: view.state.doc.slice(main.from, main.to),
180
+ comments: selectedRanges.map((range) => ({
181
+ id: range.id,
182
+ from: range.from - main.from,
183
+ to: range.to - main.from,
184
+ })),
185
+ };
186
+ }
187
+
188
+ // console.log('track', JSON.stringify({ tracked }, undefined, 2));
189
+ };
190
+
191
+ return [
192
+ EditorView.domEventHandlers({
193
+ cut: handleTrack,
194
+ copy: handleTrack,
195
+ }),
196
+
197
+ // Handle paste.
198
+ EditorView.updateListener.of(({ view, state, transactions }) => {
199
+ if (tracked) {
200
+ const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
201
+ if (paste) {
202
+ let found = -1;
203
+ paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
204
+ if (text.eq(tracked!.text)) {
205
+ for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
206
+ fromB = transactions[i].changes.mapPos(fromB);
207
+ }
208
+
209
+ found = fromB;
210
+ }
211
+ });
212
+
213
+ if (found > -1) {
214
+ const comments = tracked.comments;
215
+
216
+ // Sync before recomputing cursor/range.
217
+ // TODO(burdon): Generalize sync facet to decouple from automerge?
218
+ view.state.facet(semaphoreFacet).reconcile(view);
219
+
220
+ const active = state.field(commentsStateField).ranges;
221
+ for (const moved of comments) {
222
+ if (active.some((range) => range.id === moved.id && range.from === range.to)) {
223
+ const range = {
224
+ from: found + moved.from,
225
+ to: found + moved.to,
226
+ };
227
+
228
+ const cursor = Cursor.getCursorFromRange(state.facet(Cursor.converter), range);
229
+ // console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
230
+ onUpdate(moved.id, cursor);
231
+ }
232
+ }
233
+ }
234
+
235
+ tracked = null;
236
+ }
237
+ }
238
+ }),
239
+ ];
201
240
  };
202
241
 
203
242
  /**
@@ -209,28 +248,29 @@ export type CommentsOptions = {
209
248
  * 4). Tracks the current cursor position to:
210
249
  * a). Update the decoration to show if the cursor is within a current selection.
211
250
  * b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
212
- * 5). Optionally, inserts a markdown footnote when creating a comment thread.
213
- * 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.
214
252
  */
215
253
  export const comments = (options: CommentsOptions = {}): Extension => {
254
+ const { key: shortcut = "meta-'" } = options;
255
+
216
256
  const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
217
257
 
218
258
  /**
219
259
  * Create comment thread action.
220
260
  */
221
261
  const createCommentThread: Command = (view) => {
222
- const cursorConverter = view.state.facet(CursorConverter);
262
+ const cursorConverter = view.state.facet(Cursor.converter);
223
263
 
224
264
  invariant(options.onCreate);
225
- const { head, from, to } = view.state.selection.main;
265
+ const { from, to } = view.state.selection.main;
226
266
  if (from === to) {
227
267
  return false;
228
268
  }
229
269
 
230
- const relPos = getCursorFromRange(cursorConverter, { from, to });
231
- if (relPos) {
270
+ const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
271
+ if (cursor) {
232
272
  // Create thread via callback.
233
- const id = callbackWrapper(options.onCreate)(relPos);
273
+ const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
234
274
  if (id) {
235
275
  // Update range.
236
276
  view.dispatch({
@@ -238,15 +278,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
238
278
  selection: { anchor: from },
239
279
  });
240
280
 
241
- // Insert footnote.
242
- if (options.footnote) {
243
- const tag = `[^${id}]`;
244
- view.dispatch({
245
- changes: { from: head, insert: tag },
246
- selection: { anchor: head + tag.length },
247
- });
248
- }
249
-
250
281
  return true;
251
282
  }
252
283
  }
@@ -254,44 +285,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
254
285
  return false;
255
286
  };
256
287
 
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
288
  return [
294
- bookmarksViewPlugin,
295
289
  commentsStateField,
296
290
  highlightDecorations,
297
291
  styles,
@@ -302,7 +296,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
302
296
  options.onCreate
303
297
  ? keymap.of([
304
298
  {
305
- key: options?.key ?? "meta-'",
299
+ key: shortcut,
306
300
  run: callbackWrapper(createCommentThread),
307
301
  },
308
302
  ])
@@ -322,9 +316,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
322
316
  end: selection.to,
323
317
  above: true,
324
318
  create: () => {
325
- // TODO(burdon): Dispatch to react callback to render (or use SSR)?
326
319
  const el = document.createElement('div');
327
- options.onHover?.(el);
320
+ options.onHover!(el, shortcut);
328
321
  return { dom: el, offset: { x: 0, y: 8 } };
329
322
  },
330
323
  };
@@ -332,7 +325,11 @@ export const comments = (options: CommentsOptions = {}): Extension => {
332
325
 
333
326
  return null;
334
327
  },
335
- { hideOnChange: true, hoverTime: 1000 },
328
+ {
329
+ // TODO(burdon): Hide on change triggered immediately?
330
+ // hideOnChange: true,
331
+ hoverTime: 1_000,
332
+ },
336
333
  )
337
334
  : [],
338
335
 
@@ -341,7 +338,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
341
338
  // TODO(burdon): Is there a better (finer grained) way to do this?
342
339
  //
343
340
  EditorView.updateListener.of(({ view, state, changes }) => {
344
- const cursorConverter = view.state.facet(CursorConverter);
341
+ const cursorConverter = view.state.facet(Cursor.converter);
345
342
 
346
343
  //
347
344
  // Test if need to recompute indexed range if document changes before the end of the range.
@@ -350,17 +347,17 @@ export const comments = (options: CommentsOptions = {}): Extension => {
350
347
  let mod = false;
351
348
  const { active, ranges } = state.field(commentsStateField);
352
349
  changes.iterChanges((from, to, from2, to2) => {
350
+ // TODO(burdon): Skip deleted (tracked) ranges.
353
351
  ranges.forEach((range) => {
354
352
  if (from2 === to2) {
355
- const newRange = getRangeFromCursor(cursorConverter, range.cursor);
353
+ const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
356
354
  if (!newRange || newRange.to - newRange.from === 0) {
357
- // TODO(burdon): Delete range if empty.
358
- log.info('deleted comment', { thread: range.id });
355
+ options.onDelete?.(range.id);
359
356
  }
360
357
  }
361
358
 
362
359
  if (from <= range.to) {
363
- const newRange = getRangeFromCursor(cursorConverter, range.cursor);
360
+ const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
364
361
  Object.assign(range, newRange);
365
362
  mod = true;
366
363
  }
@@ -381,6 +378,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
381
378
  let min = Infinity;
382
379
  const { active, closest, ranges } = state.field(commentsStateField);
383
380
  const selected: CommentSelected = { active: undefined, closest: undefined };
381
+
382
+ // TODO(burdon): Skip deleted (tracked) ranges.
384
383
  ranges.forEach((comment) => {
385
384
  const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
386
385
  if (head >= comment.from && head <= comment.to) {
@@ -403,18 +402,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
403
402
  }
404
403
  }
405
404
  }),
406
- ];
407
- };
408
405
 
409
- const getCursorFromRange = (cursorConverter: CursorConverter, range: Range) => {
410
- const from = cursorConverter.toCursor(range.from);
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;
406
+ options.onUpdate ? trackPastedComments(options.onUpdate) : [],
407
+ ];
420
408
  };
@@ -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
+ }
@@ -28,20 +28,24 @@ class HorizontalRuleWidget extends WidgetType {
28
28
  super();
29
29
  }
30
30
 
31
- override eq(other: WidgetType) {
31
+ override eq(other: this) {
32
32
  return this._pos === (other as any as HorizontalRuleWidget)._pos;
33
33
  }
34
34
 
35
- toDOM(view: EditorView) {
36
- // TODO(burdon): Create <hr> element? Does this clash with markdown parser?
35
+ override toDOM(view: EditorView) {
37
36
  const el = document.createElement('div');
38
37
  el.className = 'cm-hr';
39
38
  return el;
40
39
  }
41
40
  }
42
41
 
42
+ // TODO(burdon): Like Tasklist, allow cursor to move into range.
43
+
44
+ // NOTE: Without a blank line before this markup will treat the previous line as a heading.
45
+ // https://www.markdownguide.org/basic-syntax/#horizontal-rules
43
46
  const placeholderMatcher = new MatchDecorator({
44
- regexp: /^---$/g,
47
+ // regexp: /(?<=\n\n)---/gs,
48
+ regexp: /^---$/gs,
45
49
  decoration: (match, view, pos) =>
46
50
  Decoration.replace({
47
51
  widget: new HorizontalRuleWidget(pos),
@@ -63,11 +67,6 @@ export const hr = () => [
63
67
  },
64
68
  {
65
69
  decorations: (instance) => instance.rules,
66
- // TODO(burdon): Is this really atomic (cursor can move into ---).
67
- provide: (plugin) =>
68
- EditorView.atomicRanges.of((view) => {
69
- return view.plugin(plugin)?.rules || Decoration.none;
70
- }),
71
70
  },
72
71
  ),
73
72
  ];
@@ -72,11 +72,11 @@ class ImageWidget extends WidgetType {
72
72
  super();
73
73
  }
74
74
 
75
- override eq(other: WidgetType) {
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');
@@ -9,6 +9,7 @@ export * from './basic';
9
9
  export * from './blast';
10
10
  export * from './code';
11
11
  export * from './comments';
12
+ export * from './cursor';
12
13
  export * from './hr';
13
14
  export * from './image';
14
15
  export * from './link';
@@ -6,13 +6,13 @@
6
6
  import { syntaxTree } from '@codemirror/language';
7
7
  import { type Extension, RangeSetBuilder } from '@codemirror/state';
8
8
  import {
9
+ hoverTooltip,
9
10
  Decoration,
11
+ type DecorationSet,
10
12
  type EditorView,
11
- WidgetType,
12
- hoverTooltip,
13
13
  ViewPlugin,
14
14
  type ViewUpdate,
15
- type DecorationSet,
15
+ WidgetType,
16
16
  } from '@codemirror/view';
17
17
  import { type SyntaxNode } from '@lezer/common';
18
18
 
@@ -93,11 +93,11 @@ class LinkButton extends WidgetType {
93
93
  super();
94
94
  }
95
95
 
96
- override eq(other: LinkButton) {
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: LinkText) {
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',
@@ -97,14 +97,14 @@ class TableWidget extends WidgetType {
97
97
  super();
98
98
  }
99
99
 
100
- override eq(other: TableWidget) {
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
  {