@dxos/react-ui-editor 0.3.11-main.836d602 → 0.3.11-main.86514dd

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 (79) hide show
  1. package/dist/lib/browser/index.mjs +510 -569
  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 +9 -5
  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/cursor.d.ts +1 -1
  15. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  16. package/dist/types/src/extensions/code.d.ts.map +1 -1
  17. package/dist/types/src/extensions/comments.d.ts +6 -2
  18. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  19. package/dist/types/src/{util → extensions}/cursor.d.ts +9 -1
  20. package/dist/types/src/extensions/cursor.d.ts.map +1 -0
  21. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  22. package/dist/types/src/extensions/image.d.ts.map +1 -1
  23. package/dist/types/src/extensions/index.d.ts +1 -1
  24. package/dist/types/src/extensions/index.d.ts.map +1 -1
  25. package/dist/types/src/extensions/link.d.ts.map +1 -1
  26. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  27. package/dist/types/src/extensions/tasklist.d.ts +5 -1
  28. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  29. package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
  30. package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
  31. package/dist/types/src/hooks/useTextModel.d.ts +0 -5
  32. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  33. package/dist/types/src/hooks/yjs.d.ts.map +1 -1
  34. package/dist/types/src/index.d.ts +3 -0
  35. package/dist/types/src/index.d.ts.map +1 -1
  36. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  37. package/dist/types/src/styles/tokens.d.ts +1 -0
  38. package/dist/types/src/styles/tokens.d.ts.map +1 -1
  39. package/dist/types/src/themes/default.d.ts.map +1 -1
  40. package/dist/types/src/util.d.ts.map +1 -0
  41. package/package.json +25 -24
  42. package/src/components/TextEditor/MarkdownEditor.stories.tsx +25 -23
  43. package/src/components/TextEditor/TextEditor.stories.tsx +12 -46
  44. package/src/components/TextEditor/TextEditor.tsx +68 -64
  45. package/src/components/TextEditor/comments.stories.tsx +329 -0
  46. package/src/extensions/autocomplete.ts +18 -17
  47. package/src/extensions/automerge/automerge.ts +2 -2
  48. package/src/extensions/automerge/cursor.ts +2 -1
  49. package/src/extensions/awareness.ts +25 -32
  50. package/src/extensions/code.ts +14 -7
  51. package/src/extensions/comments.ts +89 -120
  52. package/src/extensions/cursor.ts +46 -0
  53. package/src/extensions/hr.ts +4 -5
  54. package/src/extensions/image.ts +40 -22
  55. package/src/extensions/index.ts +1 -1
  56. package/src/extensions/link.ts +86 -88
  57. package/src/extensions/markdown/highlight.ts +3 -12
  58. package/src/extensions/table.ts +10 -4
  59. package/src/extensions/tasklist.ts +53 -79
  60. package/src/extensions/yjs/cursor.ts +2 -1
  61. package/src/extensions/yjs/yjs.test.ts +1 -1
  62. package/src/extensions/yjs/yjs.ts +2 -2
  63. package/src/hooks/useTextModel.ts +4 -7
  64. package/src/hooks/yjs.ts +1 -23
  65. package/src/index.ts +3 -0
  66. package/src/styles/markdown.ts +0 -2
  67. package/src/styles/tokens.ts +3 -0
  68. package/src/themes/default.ts +51 -78
  69. package/dist/types/src/extensions/mermaid.d.ts +0 -3
  70. package/dist/types/src/extensions/mermaid.d.ts.map +0 -1
  71. package/dist/types/src/util/cursor.d.ts.map +0 -1
  72. package/dist/types/src/util/index.d.ts +0 -3
  73. package/dist/types/src/util/index.d.ts.map +0 -1
  74. package/dist/types/src/util/util.d.ts.map +0 -1
  75. package/src/extensions/mermaid.ts +0 -11
  76. package/src/util/cursor.ts +0 -29
  77. package/src/util/index.ts +0 -6
  78. /package/dist/types/src/{util/util.d.ts → util.d.ts} +0 -0
  79. /package/src/{util/util.ts → util.ts} +0 -0
@@ -2,21 +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';
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';
@@ -24,29 +11,21 @@ import { invariant } from '@dxos/invariant';
24
11
  import { log } from '@dxos/log';
25
12
  import { nonNullable } from '@dxos/util';
26
13
 
14
+ import { Cursor } from './cursor';
27
15
  import { type CommentRange, type EditorModel, type Range } from '../hooks';
28
- import { tokens } from '../styles';
29
- import { callbackWrapper, CursorConverter } from '../util';
16
+ import { getToken } from '../styles';
17
+ import { callbackWrapper } from '../util';
30
18
 
31
19
  // TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
32
20
  // TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
33
21
 
34
22
  // TODO(burdon): Reconcile with theme.
35
23
  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
24
  '& .cm-comment': {
46
- backgroundColor: get(tokens, 'extend.colors.yellow.50'),
25
+ backgroundColor: getToken('extend.colors.yellow.50'),
47
26
  },
48
27
  '& .cm-comment-active': {
49
- backgroundColor: get(tokens, 'extend.colors.yellow.100'),
28
+ backgroundColor: getToken('extend.colors.yellow.100'),
50
29
  },
51
30
  });
52
31
 
@@ -99,7 +78,7 @@ const setCommentState = StateEffect.define<CommentsState>();
99
78
  const commentsStateField = StateField.define<CommentsState>({
100
79
  create: () => ({ ranges: [] }),
101
80
  update: (value, tr) => {
102
- const cursorConverter = tr.state.facet(CursorConverter);
81
+ const cursorConverter = tr.state.facet(Cursor.converter);
103
82
 
104
83
  for (const effect of tr.effects) {
105
84
  // Update selection.
@@ -112,7 +91,7 @@ const commentsStateField = StateField.define<CommentsState>({
112
91
  const { comments } = effect.value;
113
92
  const ranges: ExtendedCommentRange[] = comments
114
93
  .map((comment) => {
115
- const range = getRangeFromCursor(cursorConverter, comment.cursor);
94
+ const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
116
95
  return range && { ...comment, ...range };
117
96
  })
118
97
  .filter(nonNullable);
@@ -155,42 +134,17 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
155
134
  return Decoration.set(decorations);
156
135
  });
157
136
 
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
137
  export type CommentsOptions = {
188
138
  key?: string;
189
139
  footnote?: boolean;
190
140
  /**
191
141
  * Called to create a new thread and return the thread id.
192
142
  */
193
- onCreate?: (range: string) => string | undefined;
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;
194
148
  /**
195
149
  * Called to notify which thread is currently closest to the cursor.
196
150
  */
@@ -198,7 +152,66 @@ export type CommentsOptions = {
198
152
  /**
199
153
  * Called to render tooltip.
200
154
  */
201
- 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
+ ];
202
215
  };
203
216
 
204
217
  /**
@@ -214,13 +227,15 @@ export type CommentsOptions = {
214
227
  * 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
215
228
  */
216
229
  export const comments = (options: CommentsOptions = {}): Extension => {
230
+ const { key: shortcut = "meta-'" } = options;
231
+
217
232
  const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
218
233
 
219
234
  /**
220
235
  * Create comment thread action.
221
236
  */
222
237
  const createCommentThread: Command = (view) => {
223
- const cursorConverter = view.state.facet(CursorConverter);
238
+ const cursorConverter = view.state.facet(Cursor.converter);
224
239
 
225
240
  invariant(options.onCreate);
226
241
  const { head, from, to } = view.state.selection.main;
@@ -228,10 +243,10 @@ export const comments = (options: CommentsOptions = {}): Extension => {
228
243
  return false;
229
244
  }
230
245
 
231
- const relPos = getCursorFromRange(cursorConverter, { from, to });
232
- if (relPos) {
246
+ const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
247
+ if (cursor) {
233
248
  // Create thread via callback.
234
- const id = callbackWrapper(options.onCreate)(relPos);
249
+ const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
235
250
  if (id) {
236
251
  // Update range.
237
252
  view.dispatch({
@@ -255,44 +270,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
255
270
  return false;
256
271
  };
257
272
 
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
273
  return [
295
- bookmarksViewPlugin,
296
274
  commentsStateField,
297
275
  highlightDecorations,
298
276
  styles,
@@ -303,7 +281,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
303
281
  options.onCreate
304
282
  ? keymap.of([
305
283
  {
306
- key: options?.key ?? "meta-'",
284
+ key: shortcut,
307
285
  run: callbackWrapper(createCommentThread),
308
286
  },
309
287
  ])
@@ -325,7 +303,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
325
303
  create: () => {
326
304
  // TODO(burdon): Dispatch to react callback to render (or use SSR)?
327
305
  const el = document.createElement('div');
328
- options.onHover?.(el);
306
+ options.onHover?.(el, shortcut);
329
307
  return { dom: el, offset: { x: 0, y: 8 } };
330
308
  },
331
309
  };
@@ -342,7 +320,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
342
320
  // TODO(burdon): Is there a better (finer grained) way to do this?
343
321
  //
344
322
  EditorView.updateListener.of(({ view, state, changes }) => {
345
- const cursorConverter = view.state.facet(CursorConverter);
323
+ const cursorConverter = view.state.facet(Cursor.converter);
346
324
 
347
325
  //
348
326
  // Test if need to recompute indexed range if document changes before the end of the range.
@@ -353,7 +331,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
353
331
  changes.iterChanges((from, to, from2, to2) => {
354
332
  ranges.forEach((range) => {
355
333
  if (from2 === to2) {
356
- const newRange = getRangeFromCursor(cursorConverter, range.cursor);
334
+ const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
357
335
  if (!newRange || newRange.to - newRange.from === 0) {
358
336
  // TODO(burdon): Delete range if empty.
359
337
  log.info('deleted comment', { thread: range.id });
@@ -361,7 +339,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
361
339
  }
362
340
 
363
341
  if (from <= range.to) {
364
- const newRange = getRangeFromCursor(cursorConverter, range.cursor);
342
+ const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
365
343
  Object.assign(range, newRange);
366
344
  mod = true;
367
345
  }
@@ -393,6 +371,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
393
371
  }
394
372
  });
395
373
 
374
+ // TODO(burdon): Fire debounced callback if range selected (to show hint).
375
+
396
376
  if (selected.active !== active || selected.closest !== closest) {
397
377
  view.dispatch({ effects: setSelection.of(selected) });
398
378
 
@@ -404,18 +384,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
404
384
  }
405
385
  }
406
386
  }),
407
- ];
408
- };
409
387
 
410
- const getCursorFromRange = (cursorConverter: CursorConverter, range: Range) => {
411
- const from = cursorConverter.toCursor(range.from);
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;
388
+ options.onMove ? trackPastedComments(options.onMove) : [],
389
+ ];
421
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
+ }
@@ -11,16 +11,15 @@ import {
11
11
  type ViewUpdate,
12
12
  WidgetType,
13
13
  } from '@codemirror/view';
14
- import get from 'lodash.get';
15
14
 
16
- import { tokens } from '../styles';
15
+ import { getToken } from '../styles';
17
16
 
18
17
  // TODO(burdon): Reconcile with theme.
19
18
  const styles = EditorView.baseTheme({
20
19
  '& .cm-hr': {
21
20
  // TODO(burdon): ???
22
21
  // Note that block-level decorations should not have vertical margins,
23
- borderBottom: `1px solid ${get(tokens, 'extend.colors.neutral.200')}`,
22
+ borderBottom: `1px solid ${getToken('extend.colors.neutral.200')}`,
24
23
  },
25
24
  });
26
25
 
@@ -29,11 +28,11 @@ class HorizontalRuleWidget extends WidgetType {
29
28
  super();
30
29
  }
31
30
 
32
- override eq(other: WidgetType) {
31
+ override eq(other: this) {
33
32
  return this._pos === (other as any as HorizontalRuleWidget)._pos;
34
33
  }
35
34
 
36
- toDOM(view: EditorView) {
35
+ override toDOM(view: EditorView) {
37
36
  // TODO(burdon): Create <hr> element? Does this clash with markdown parser?
38
37
  const el = document.createElement('div');
39
38
  el.className = 'cm-hr';
@@ -3,28 +3,45 @@
3
3
  //
4
4
 
5
5
  import { syntaxTree } from '@codemirror/language';
6
- import {
7
- type EditorState,
8
- type Extension,
9
- type RangeSet,
10
- RangeSetBuilder,
11
- StateField,
12
- type Transaction,
13
- } from '@codemirror/state';
14
- import { Decoration, EditorView, WidgetType } from '@codemirror/view';
6
+ import { type EditorState, type Extension, StateField, type Transaction, type Range } from '@codemirror/state';
7
+ import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
15
8
 
16
9
  export type ImageOptions = {};
17
10
 
18
11
  export const image = (options: ImageOptions = {}): Extension => {
19
- return StateField.define<RangeSet<any>>({
20
- create: (state) => update(state, options),
21
- update: (_: RangeSet<any>, tr: Transaction) => update(tr.state, options),
12
+ return StateField.define<DecorationSet>({
13
+ create: (state) => {
14
+ return Decoration.set(buildDecorations(0, state.doc.length, state));
15
+ },
16
+ update: (value: DecorationSet, tr: Transaction) => {
17
+ if (!tr.docChanged && !tr.selection) {
18
+ return value;
19
+ }
20
+ // Find range of changes and cursor changes.
21
+ const cursor = tr.state.selection.main.head;
22
+ const oldCursor = tr.changes.mapPos(tr.startState.selection.main.head);
23
+ let from = Math.min(cursor, oldCursor);
24
+ let to = Math.max(cursor, oldCursor);
25
+ tr.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
26
+ from = Math.min(from, fromB);
27
+ to = Math.max(to, toB);
28
+ });
29
+ // Expand to cover lines
30
+ from = tr.state.doc.lineAt(from).from;
31
+ to = tr.state.doc.lineAt(to).to;
32
+ return value.map(tr.changes).update({
33
+ filterFrom: from,
34
+ filterTo: to,
35
+ filter: () => false,
36
+ add: buildDecorations(from, to, tr.state),
37
+ });
38
+ },
22
39
  provide: (field) => EditorView.decorations.from(field),
23
40
  });
24
41
  };
25
42
 
26
- const update = (state: EditorState, options: ImageOptions) => {
27
- const builder = new RangeSetBuilder();
43
+ const buildDecorations = (from: number, to: number, state: EditorState) => {
44
+ const decorations: Range<Decoration>[] = [];
28
45
  const cursor = state.selection.main.head;
29
46
 
30
47
  syntaxTree(state).iterate({
@@ -34,20 +51,20 @@ const update = (state: EditorState, options: ImageOptions) => {
34
51
  if (urlNode) {
35
52
  const hide = state.readOnly || cursor < node.from || cursor > node.to;
36
53
  const url = state.sliceDoc(urlNode.from, urlNode.to);
37
- builder.add(
38
- hide ? node.from : node.to,
39
- node.to,
54
+ decorations.push(
40
55
  Decoration.replace({
41
56
  block: true, // Prevent cursor from entering.
42
57
  widget: new ImageWidget(url),
43
- }),
58
+ }).range(hide ? node.from : node.to, node.to),
44
59
  );
45
60
  }
46
61
  }
47
62
  },
63
+ from,
64
+ to,
48
65
  });
49
66
 
50
- return builder.finish();
67
+ return decorations;
51
68
  };
52
69
 
53
70
  class ImageWidget extends WidgetType {
@@ -55,15 +72,16 @@ class ImageWidget extends WidgetType {
55
72
  super();
56
73
  }
57
74
 
58
- // TODO(burdon): Does this need to be unique for each position?
59
- override eq(other: WidgetType) {
75
+ override eq(other: this) {
60
76
  return this._url === (other as any as ImageWidget)._url;
61
77
  }
62
78
 
63
- toDOM(view: EditorView) {
79
+ override toDOM(view: EditorView) {
64
80
  const img = document.createElement('img');
65
81
  img.setAttribute('src', this._url);
66
82
  img.setAttribute('class', 'cm-image');
83
+ // Images are hidden until successfully loaded to avoid flickering effects.
84
+ img.onload = () => img.classList.add('cm-loaded-image');
67
85
  return img;
68
86
  }
69
87
  }
@@ -9,13 +9,13 @@ 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';
15
16
  export * from './listener';
16
17
  export * from './markdown';
17
18
  export * from './mention';
18
- export * from './mermaid';
19
19
  export * from './table';
20
20
  export * from './tasklist';
21
21
  export * from './typewriter';