@dxos/react-ui-editor 0.3.10-main.8fd45da → 0.3.10-main.92d1f2c

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 (87) hide show
  1. package/dist/lib/browser/index.mjs +986 -296
  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 +19 -1
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +2 -1
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/extensions/autocomplete.d.ts.map +1 -1
  9. package/dist/types/src/components/TextEditor/extensions/basic.d.ts +2 -1
  10. package/dist/types/src/components/TextEditor/extensions/basic.d.ts.map +1 -1
  11. package/dist/types/src/components/TextEditor/extensions/blast.d.ts +25 -0
  12. package/dist/types/src/components/TextEditor/extensions/blast.d.ts.map +1 -0
  13. package/dist/types/src/components/TextEditor/extensions/comments.d.ts +10 -6
  14. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +1 -1
  15. package/dist/types/src/components/TextEditor/extensions/demo.d.ts +11 -0
  16. package/dist/types/src/components/TextEditor/extensions/demo.d.ts.map +1 -0
  17. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts +1 -1
  18. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts.map +1 -1
  19. package/dist/types/src/components/TextEditor/extensions/image.d.ts +4 -0
  20. package/dist/types/src/components/TextEditor/extensions/image.d.ts.map +1 -0
  21. package/dist/types/src/components/TextEditor/extensions/index.d.ts +5 -0
  22. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +1 -1
  23. package/dist/types/src/components/TextEditor/extensions/link.d.ts.map +1 -1
  24. package/dist/types/src/components/TextEditor/extensions/listener.d.ts +4 -2
  25. package/dist/types/src/components/TextEditor/extensions/listener.d.ts.map +1 -1
  26. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts +2 -1
  27. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +1 -1
  28. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts +9 -3
  29. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +1 -1
  30. package/dist/types/src/components/TextEditor/extensions/mention.d.ts +6 -0
  31. package/dist/types/src/components/TextEditor/extensions/mention.d.ts.map +1 -0
  32. package/dist/types/src/components/TextEditor/extensions/table.d.ts +8 -0
  33. package/dist/types/src/components/TextEditor/extensions/table.d.ts.map +1 -0
  34. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts +2 -1
  35. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts.map +1 -1
  36. package/dist/types/src/components/TextEditor/themes/default.d.ts +5 -0
  37. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +1 -1
  38. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts +3 -0
  39. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts.map +1 -1
  40. package/dist/types/src/hooks/automerge/automerge.stories.d.ts +8 -1
  41. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -1
  42. package/dist/types/src/hooks/automerge/index.d.ts +1 -1
  43. package/dist/types/src/hooks/automerge/index.d.ts.map +1 -1
  44. package/dist/types/src/hooks/automerge/plugin.d.ts.map +1 -1
  45. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts +5 -0
  46. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts.map +1 -0
  47. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts +6 -0
  48. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts.map +1 -0
  49. package/dist/types/src/hooks/automerge/translation/index.d.ts +3 -0
  50. package/dist/types/src/hooks/automerge/translation/index.d.ts.map +1 -0
  51. package/dist/types/src/{components/TextEditor → hooks/yjs}/yjs.stories.d.ts +1 -1
  52. package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +1 -0
  53. package/dist/types/src/styles/markdown.d.ts +3 -2
  54. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  55. package/package.json +20 -19
  56. package/src/components/TextEditor/MarkdownEditor.stories.tsx +106 -29
  57. package/src/components/TextEditor/TextEditor.tsx +12 -8
  58. package/src/components/TextEditor/extensions/autocomplete.ts +5 -20
  59. package/src/components/TextEditor/extensions/basic.ts +2 -1
  60. package/src/components/TextEditor/extensions/blast.ts +387 -0
  61. package/src/components/TextEditor/extensions/comments.ts +59 -60
  62. package/src/components/TextEditor/extensions/demo.ts +69 -0
  63. package/src/components/TextEditor/extensions/experimental.tsx +14 -35
  64. package/src/components/TextEditor/extensions/image.ts +67 -0
  65. package/src/components/TextEditor/extensions/index.ts +5 -0
  66. package/src/components/TextEditor/extensions/link.ts +17 -25
  67. package/src/components/TextEditor/extensions/listener.ts +4 -4
  68. package/src/components/TextEditor/extensions/markdown/bundle.ts +30 -19
  69. package/src/components/TextEditor/extensions/markdown/highlight.ts +147 -108
  70. package/src/components/TextEditor/extensions/mention.ts +31 -0
  71. package/src/components/TextEditor/extensions/table.ts +122 -0
  72. package/src/components/TextEditor/extensions/tasklist.ts +41 -27
  73. package/src/components/TextEditor/themes/default.ts +54 -2
  74. package/src/hooks/automerge/PatchSemaphore.ts +12 -9
  75. package/src/hooks/automerge/automerge.stories.tsx +3 -2
  76. package/src/hooks/automerge/index.ts +1 -1
  77. package/src/hooks/automerge/plugin.ts +1 -0
  78. package/src/hooks/automerge/{amToCodemirror.ts → translation/automerge-to-codemirror.ts} +10 -2
  79. package/src/hooks/automerge/{codeMirrorToAm.ts → translation/codemirror-to-automerge.ts} +7 -4
  80. package/src/hooks/automerge/translation/index.ts +6 -0
  81. package/src/{components/TextEditor → hooks/yjs}/yjs.stories.tsx +1 -1
  82. package/src/styles/markdown.ts +14 -8
  83. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  84. package/dist/types/src/hooks/automerge/amToCodemirror.d.ts +0 -6
  85. package/dist/types/src/hooks/automerge/amToCodemirror.d.ts.map +0 -1
  86. package/dist/types/src/hooks/automerge/codeMirrorToAm.d.ts +0 -7
  87. package/dist/types/src/hooks/automerge/codeMirrorToAm.d.ts.map +0 -1
@@ -0,0 +1,387 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ // Based on a demo by Joel Besada and Kushagra Gour.
4
+ // https://twitter.com/JoelBesada/status/670343885655293952
5
+ // https://github.com/chinchang/code-blast-codemirror/blob/master/code-blast.js
6
+ //
7
+
8
+ import { type Extension, StateField } from '@codemirror/state';
9
+ import { EditorView, keymap } from '@codemirror/view';
10
+ import defaultsDeep from 'lodash.defaultsdeep';
11
+
12
+ import { invariant } from '@dxos/invariant';
13
+
14
+ export type BlastOptions = {
15
+ effect?: number;
16
+ maxParticles: number;
17
+ particleGravity: number;
18
+ particleAlphaFadeout: number;
19
+ particleSize: { min: number; max: number };
20
+ particleNumRange: { min: number; max: number };
21
+ particleVelocityRange: { x: [number, number]; y: [number, number] };
22
+ particleShrinkRate: number;
23
+ shakeIntensity: number;
24
+ color?: () => number[];
25
+ };
26
+
27
+ export const defaultOptions: BlastOptions = {
28
+ effect: 2,
29
+ maxParticles: 200,
30
+ particleGravity: 0.08,
31
+ particleAlphaFadeout: 0.996,
32
+ particleSize: { min: 2, max: 8 },
33
+ particleNumRange: { min: 5, max: 10 },
34
+ particleVelocityRange: { x: [-1, 1], y: [-3.5, -1.5] },
35
+ particleShrinkRate: 0.95,
36
+ shakeIntensity: 5,
37
+ };
38
+
39
+ export const blast = (options: Partial<BlastOptions>): Extension => {
40
+ let blaster: Blaster | undefined;
41
+ let last = 0;
42
+
43
+ const getPoint = (view: EditorView, pos: number) => {
44
+ const { left: x = 0, top: y = 0 } = view.coordsForChar(pos) ?? {};
45
+ const element = document.elementFromPoint(x, y);
46
+
47
+ const { offsetLeft: left = 0, offsetTop: top = 0 } = view.scrollDOM.parentElement ?? {};
48
+ const point = { x: x - left, y: y - top };
49
+
50
+ return { element, point };
51
+ };
52
+
53
+ return [
54
+ // Cursor moved.
55
+ EditorView.updateListener.of((update) => {
56
+ // NOTE: The MarkdownEditor may recreated the EditorView.
57
+ if (blaster?.node !== update.view.scrollDOM) {
58
+ if (blaster) {
59
+ blaster.destroy();
60
+ }
61
+
62
+ blaster = new Blaster(
63
+ update.view.scrollDOM,
64
+ defaultsDeep(
65
+ {
66
+ particleGravity: 0.2,
67
+ particleShrinkRate: 0.995,
68
+ color: () => [100 + Math.random() * 100, 0, 0],
69
+ },
70
+ options,
71
+ defaultOptions,
72
+ ),
73
+ );
74
+ blaster.initialize();
75
+ blaster.start(); // TODO(burdon): Stop/clean-up.
76
+ } else {
77
+ blaster.resize();
78
+ }
79
+
80
+ const current = update.view.state.selection.main.head;
81
+ if (current !== last) {
82
+ last = current;
83
+ // TODO(burdon): Null if end of line.
84
+ const { element, point } = getPoint(update.view, current);
85
+ if (element && point) {
86
+ blaster.spawn({ element, point });
87
+ }
88
+ }
89
+ }),
90
+
91
+ // Document changed.
92
+ StateField.define({
93
+ create: () => null,
94
+ update: (_value, transaction) => {
95
+ if (blaster && transaction.docChanged) {
96
+ // TODO(burdon): Only on delete.
97
+ const view = EditorView.findFromDOM(blaster.node)!;
98
+ const { element, point } = getPoint(view, transaction.selection!.main.head);
99
+ if (element && point) {
100
+ blaster.spawn({ element, point });
101
+ }
102
+ }
103
+
104
+ return null;
105
+ },
106
+ }),
107
+
108
+ keymap.of([
109
+ {
110
+ any: (view, event) => {
111
+ if (blaster && event.key === 'Enter' && event.shiftKey) {
112
+ blaster.shake({ time: 0.4 });
113
+ }
114
+
115
+ return false;
116
+ },
117
+ },
118
+ ]),
119
+ ];
120
+ };
121
+
122
+ //
123
+ // Blaster (no CM deps).
124
+ //
125
+
126
+ class Blaster {
127
+ _canvas: HTMLCanvasElement | undefined;
128
+ _ctx: CanvasRenderingContext2D | undefined | null;
129
+
130
+ private readonly _effect: Effect;
131
+
132
+ _running = false;
133
+ _lastTime: number | undefined;
134
+ _shakeTime = 0;
135
+ _shakeTimeMax = 0;
136
+
137
+ _particles: Particle[] = [];
138
+ _particlePointer = 0;
139
+
140
+ _lastPoint = { x: 0, y: 0 };
141
+
142
+ constructor(private readonly _node: HTMLElement, private readonly _options: BlastOptions) {
143
+ this._effect = this._options.effect === 1 ? new Effect1(_options) : new Effect2(_options);
144
+ }
145
+
146
+ get node() {
147
+ return this._node;
148
+ }
149
+
150
+ initialize() {
151
+ // console.log('initialize');
152
+ invariant(!this._canvas && !this._ctx);
153
+
154
+ this._canvas = document.createElement('canvas');
155
+ this._canvas.id = 'code-blast-canvas';
156
+ this._canvas.style.position = 'absolute';
157
+ this._canvas.style.zIndex = '0';
158
+ this._canvas.style.pointerEvents = 'none';
159
+ // this._canvas.style.border = '2px solid red';
160
+
161
+ this._ctx = this._canvas.getContext('2d');
162
+
163
+ const parent = this._node.parentElement?.parentElement;
164
+ parent?.appendChild(this._canvas);
165
+
166
+ this.resize();
167
+ }
168
+
169
+ destroy() {
170
+ this.stop();
171
+ // console.log('destroy');
172
+ if (this._canvas) {
173
+ this._canvas.remove();
174
+ this._canvas = undefined;
175
+ this._ctx = undefined;
176
+ }
177
+ }
178
+
179
+ resize() {
180
+ if (this._node.parentElement && this._canvas) {
181
+ const { offsetLeft: x, offsetTop: y, offsetWidth: width, offsetHeight: height } = this._node.parentElement;
182
+ this._canvas.style.top = `${y}px`;
183
+ this._canvas.style.left = `${x}px`;
184
+ this._canvas.width = width;
185
+ this._canvas.height = height;
186
+ }
187
+ }
188
+
189
+ start() {
190
+ // console.log('start');
191
+ invariant(this._canvas && this._ctx);
192
+ this._running = true;
193
+ this.loop();
194
+ }
195
+
196
+ stop() {
197
+ // console.log('stop');
198
+ this._running = false;
199
+ this._node.style.transform = 'translate(0px, 0px)';
200
+ }
201
+
202
+ loop() {
203
+ if (!this._running || !this._canvas || !this._ctx) {
204
+ return;
205
+ }
206
+
207
+ this._ctx.clearRect(0, 0, this._canvas.width ?? 0, this._canvas.height ?? 0);
208
+
209
+ // Calculate the delta from the previous frame.
210
+ const now = new Date().getTime();
211
+ this._lastTime ??= now;
212
+ const dt = (now - this._lastTime) / 1000;
213
+ this._lastTime = now;
214
+
215
+ if (this._shakeTime > 0) {
216
+ this._shakeTime -= dt;
217
+ const magnitude = (this._shakeTime / this._shakeTimeMax) * this._options.shakeIntensity;
218
+ const shakeX = random(-magnitude, magnitude);
219
+ const shakeY = random(-magnitude, magnitude);
220
+ this._node!.style.transform = `translate(${shakeX}px,${shakeY}px)`;
221
+ }
222
+
223
+ this.drawParticles();
224
+
225
+ requestAnimationFrame(this.loop.bind(this));
226
+ }
227
+
228
+ shake = throttle<{ time: number }>(({ time }) => {
229
+ this._shakeTime = this._shakeTimeMax;
230
+ this._shakeTimeMax = time;
231
+ }, 100);
232
+
233
+ spawn = throttle<{ element: Element; point: { x: number; y: number } }>(({ element, point }) => {
234
+ const color = getRGBComponents(element, this._options.color);
235
+ const numParticles = random(this._options.particleNumRange.min, this._options.particleNumRange.max);
236
+ const dir = this._lastPoint.x === point.x ? 0 : this._lastPoint.x < point.x ? 1 : -1;
237
+ this._lastPoint = point;
238
+ for (let i = numParticles; i--; i > 0) {
239
+ this._particles[this._particlePointer] = this._effect.create(point.x - dir * 16, point.y, color);
240
+ this._particlePointer = (this._particlePointer + 1) % this._options.maxParticles;
241
+ }
242
+ }, 100);
243
+
244
+ drawParticles() {
245
+ for (let i = this._particles.length; i--; i > 0) {
246
+ const particle = this._particles[i];
247
+ if (!particle) {
248
+ continue;
249
+ }
250
+
251
+ if (particle.alpha < 0.01 || particle.size <= 0.5) {
252
+ continue;
253
+ }
254
+
255
+ this._effect.update(this._ctx!, particle);
256
+ }
257
+ }
258
+ }
259
+
260
+ //
261
+ // Effects
262
+ //
263
+
264
+ type Particle = {
265
+ x: number;
266
+ y: number;
267
+ vx: number;
268
+ vy: number;
269
+ size: number;
270
+ color: (string | number)[];
271
+ alpha: number;
272
+ theta?: number;
273
+ drag?: number;
274
+ wander?: number;
275
+ };
276
+
277
+ abstract class Effect {
278
+ constructor(protected readonly _options: BlastOptions) {}
279
+ abstract create(x: number, y: number, color: Particle['color']): Particle;
280
+ abstract update(ctx: CanvasRenderingContext2D, particle: Particle): void;
281
+ }
282
+
283
+ class Effect1 extends Effect {
284
+ create(x: number, y: number, color: Particle['color']) {
285
+ return {
286
+ x,
287
+ y: y + 10,
288
+ vx: random(this._options.particleVelocityRange.x[0], this._options.particleVelocityRange.x[1]),
289
+ vy: random(this._options.particleVelocityRange.y[0], this._options.particleVelocityRange.y[1]),
290
+ size: random(this._options.particleSize.min, this._options.particleSize.max),
291
+ color,
292
+ alpha: 1,
293
+ };
294
+ }
295
+
296
+ update(ctx: CanvasRenderingContext2D, particle: Particle) {
297
+ particle.vy += this._options.particleGravity;
298
+ particle.x += particle.vx;
299
+ particle.y += particle.vy;
300
+ particle.alpha *= this._options.particleAlphaFadeout;
301
+
302
+ ctx.fillStyle = `rgba(${particle.color[0]},${particle.color[1]},${particle.color[2]},${particle.alpha})`;
303
+ ctx.fillRect(Math.round(particle.x - 1), Math.round(particle.y - 1), particle.size, particle.size);
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Based on Soulwire's demo.
309
+ * http://codepen.io/soulwire/pen/foktm
310
+ */
311
+ class Effect2 extends Effect {
312
+ create(x: number, y: number, color: Particle['color']) {
313
+ return {
314
+ x,
315
+ y: y + 10,
316
+ vx: random(this._options.particleVelocityRange.x[0], this._options.particleVelocityRange.x[1]),
317
+ vy: random(this._options.particleVelocityRange.y[0], this._options.particleVelocityRange.y[1]),
318
+ size: random(this._options.particleSize.min, this._options.particleSize.max),
319
+ color,
320
+ alpha: 1,
321
+ theta: (random(0, 360) * Math.PI) / 180,
322
+ drag: 0.92,
323
+ wander: 0.15,
324
+ };
325
+ }
326
+
327
+ update(ctx: CanvasRenderingContext2D, particle: Particle) {
328
+ particle.vy += this._options.particleGravity;
329
+ particle.x += particle.vx;
330
+ particle.y += particle.vy;
331
+ particle.vx *= particle.drag!;
332
+ particle.vy *= particle.drag!;
333
+ particle.theta! += random(-0.5, 0.5);
334
+ particle.vx += Math.sin(particle.theta!) * 0.1;
335
+ particle.vy += Math.cos(particle.theta!) * 0.1;
336
+ particle.size *= this._options.particleShrinkRate;
337
+ particle.alpha *= this._options.particleAlphaFadeout;
338
+
339
+ ctx.fillStyle = `rgba(${particle.color[0]},${particle.color[1]},${particle.color[2]},${particle.alpha})`;
340
+ ctx.beginPath();
341
+ ctx.arc(Math.round(particle.x - 1), Math.round(particle.y - 1), particle.size, 0, 2 * Math.PI);
342
+ ctx.fill();
343
+ }
344
+ }
345
+
346
+ //
347
+ // Utils
348
+ //
349
+
350
+ function throttle<T>(callback: (arg: T) => void, limit: number): (arg: T) => void {
351
+ let wait = false;
352
+ return function (...args: any[]) {
353
+ if (!wait) {
354
+ // @ts-ignore
355
+ callback.apply(this, args);
356
+ wait = true;
357
+ setTimeout(() => {
358
+ wait = false;
359
+ }, limit);
360
+ }
361
+ };
362
+ }
363
+
364
+ const getRGBComponents = (node: Element, color: BlastOptions['color']): Particle['color'] => {
365
+ if (typeof color === 'function') {
366
+ return color();
367
+ }
368
+
369
+ const bgColor = getComputedStyle(node).color;
370
+ if (bgColor) {
371
+ const x = bgColor.match(/(\d+), (\d+), (\d+)/)?.slice(1);
372
+ if (x) {
373
+ return x;
374
+ }
375
+ }
376
+
377
+ return [50, 50, 50];
378
+ };
379
+
380
+ const random = (min: number, max: number) => {
381
+ if (!max) {
382
+ max = min;
383
+ min = 0;
384
+ }
385
+
386
+ return min + ~~(Math.random() * (max - min + 1));
387
+ };
@@ -4,38 +4,35 @@
4
4
 
5
5
  import { type Extension } from '@codemirror/state';
6
6
  import {
7
- Decoration,
7
+ keymap,
8
8
  type DecorationSet,
9
+ type Rect,
10
+ type ViewUpdate,
11
+ Decoration,
9
12
  EditorView,
10
- keymap,
11
13
  MatchDecorator,
12
- type Rect,
13
14
  ViewPlugin,
14
- type ViewUpdate,
15
15
  WidgetType,
16
16
  } from '@codemirror/view';
17
17
 
18
+ import { debounce } from '@dxos/async';
18
19
  import { invariant } from '@dxos/invariant';
19
20
 
20
- // 1. TODO(burdon): Make atomic (for tasklist also).
21
- // - https://discuss.codemirror.net/t/easily-track-remove-content-with-decorations/4606
22
- // - https://discuss.codemirror.net/t/creating-atomic-replace-decorations/2961
23
- // - https://codemirror.net/docs/ref/#state.EditorState%5EtransactionFilter (transaction filter: move, delete).
24
- // 2. TODO(burdon): Create/track threads in composer (change global state). Select/follow. Scroll with page.
25
- // - Separate from chat/search.
26
- // 3. TODO(burdon): Support multiple threads in sidebar?
27
- // 4. TODO(burdon): Anchor AI thread when creating section.
28
- // 5. TODO(burdon): Button to resolve thread to close comments.
29
-
30
- // 6. TODO(burdon): Perf: Update existing rangeset.
31
- // https://discuss.codemirror.net/t/rangeset-with-metadata-and-different-decorations/3874
32
-
33
- // TODO(burdon): Import note (performance):
34
- // Easily track & remove content with decorations
35
- // https://discuss.codemirror.net/t/easily-track-remove-content-with-decorations/4606
21
+ // TODO(burdon): Reconcile with theme.
22
+ const styles = EditorView.baseTheme({
23
+ '& .cm-bookmark': {
24
+ cursor: 'pointer',
25
+ margin: '4px',
26
+ padding: '4px',
27
+ backgroundColor: 'yellow',
28
+ },
29
+ '& .cm-bookmark-selected': {
30
+ backgroundColor: 'orange',
31
+ },
32
+ });
36
33
 
37
34
  class BookmarkWidget extends WidgetType {
38
- constructor(private readonly _pos: number, private readonly _id: string) {
35
+ constructor(private readonly _pos: number, private readonly _id: string, private readonly _handleClick: () => void) {
39
36
  super();
40
37
  invariant(this._id);
41
38
  }
@@ -44,43 +41,39 @@ class BookmarkWidget extends WidgetType {
44
41
  return this._id === other._id;
45
42
  }
46
43
 
47
- // TODO(burdon): Click to select.
48
44
  override toDOM() {
49
45
  const span = document.createElement('span');
50
46
  span.className = 'cm-bookmark';
51
- // https://emojifinder.com/comment
52
- span.textContent = '💬';
47
+ span.textContent = '§';
48
+ span.onclick = () => this._handleClick();
53
49
  return span;
54
50
  }
55
51
  }
56
52
 
57
- // TODO(burdon): Reconcile with theme.
58
- const styles = EditorView.baseTheme({
59
- '& .cm-bookmark': {
60
- cursor: 'pointer',
61
- margin: '4px',
62
- padding: '4px',
63
- backgroundColor: 'yellow',
64
- },
65
- });
53
+ type CommentsInfo = {
54
+ active?: string;
55
+ items?: { id: string; pos: number; location: Rect | null }[];
56
+ };
66
57
 
67
58
  export type CommentsOptions = {
68
59
  key?: string;
69
60
  onCreate?: () => string | void;
70
- onUpdate?: (info: { items: string[]; active: string; pos: number; location: Rect }) => void;
61
+ onUpdate?: (info: CommentsInfo) => void;
71
62
  };
72
63
 
73
64
  // https://www.markdownguide.org/extended-syntax/#footnotes
74
65
  // TODO(burdon): Record span in automerge model?
75
66
  // TODO(burdon): Extended markdown: https://www.markdownguide.org/extended-syntax
76
67
  export const comments = (options: CommentsOptions = {}): Extension => {
68
+ let active: string | undefined;
69
+
77
70
  const bookmarkMatcher = new MatchDecorator({
78
71
  regexp: /\[\^(\w+)\]/g,
79
72
  decoration: (match, view, pos) => {
80
73
  const id = match[1];
81
74
  return Decoration.replace({
82
75
  id,
83
- widget: new BookmarkWidget(pos, id),
76
+ widget: new BookmarkWidget(pos, id, () => handleUpdate({ active: id })),
84
77
  });
85
78
  },
86
79
  });
@@ -105,10 +98,14 @@ export const comments = (options: CommentsOptions = {}): Extension => {
105
98
  },
106
99
  );
107
100
 
101
+ const handleUpdate = debounce((data: CommentsInfo) => {
102
+ options.onUpdate?.(data);
103
+ }, 200);
104
+
108
105
  return [
109
106
  keymap.of([
110
107
  {
111
- key: options?.key ?? 'alt-meta-c',
108
+ key: options?.key ?? 'shift-meta-c',
112
109
  run: (view) => {
113
110
  // Insert footnote.
114
111
  const id = options.onCreate?.();
@@ -132,39 +129,41 @@ export const comments = (options: CommentsOptions = {}): Extension => {
132
129
 
133
130
  // Monitor cursor movement.
134
131
  EditorView.updateListener.of((update) => {
132
+ active = undefined;
133
+ if (!options.onUpdate) {
134
+ return;
135
+ }
136
+
135
137
  const view = update.view;
136
138
  const pos = view.state.selection.main.head;
137
139
 
138
140
  const decorations: { from: number; to: number; value: Decoration }[] = [];
139
141
  const rangeSet = view.plugin(bookmarks)?.bookmarks;
140
- rangeSet?.between(pos, pos + 1, (from, to, value) => {
141
- if (value.spec.widget) {
142
- decorations.push({ from, to, value });
142
+ let closest = Infinity;
143
+ // TODO(burdon): Handle multiple visible ranges in large documents.
144
+ const { from, to } = /* view.visibleRanges?.[0] ?? */ { from: 0, to: view.state.doc.length };
145
+ rangeSet?.between(from, to, (from, to, value) => {
146
+ decorations.push({ from, to, value });
147
+ const d = Math.min(Math.abs(pos - from), Math.abs(pos - to));
148
+ if (d < closest) {
149
+ // TODO(burdon): Update class of selected.
150
+ active = value.spec.id;
151
+ closest = d;
143
152
  }
144
153
  });
145
154
 
146
155
  if (decorations.length) {
147
- const {
148
- from,
149
- value: {
150
- spec: { id },
151
- },
152
- } = decorations[0];
153
- const location = view.coordsAtPos(from);
154
- if (location) {
155
- options.onUpdate?.({
156
- items: decorations.map(
157
- ({
158
- value: {
159
- spec: { id },
160
- },
161
- }) => id,
162
- ),
163
- active: id,
164
- pos,
165
- location,
166
- });
167
- }
156
+ handleUpdate({
157
+ active,
158
+ items: decorations.map(
159
+ ({
160
+ from,
161
+ value: {
162
+ spec: { id },
163
+ },
164
+ }) => ({ id: id as string, pos: from, location: view.coordsAtPos(from) }),
165
+ ),
166
+ });
168
167
  }
169
168
  }),
170
169
 
@@ -0,0 +1,69 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { keymap } from '@codemirror/view';
7
+
8
+ // TODO(burdon): Review https://github.com/sergeche/codemirror-movie?tab=readme-ov-file
9
+
10
+ export type DemoOptions = {
11
+ delay?: number;
12
+ items?: string[];
13
+ };
14
+
15
+ const defaultItems = ['hello world!', 'this is a test.', 'this is [DXOS](https://dxos.org)'];
16
+
17
+ /**
18
+ * Demo script.
19
+ * Configurable plugin that let's user cycle through pre-configured input script.
20
+ */
21
+ export const demo = ({ delay = 75, items = defaultItems }: DemoOptions = {}): Extension => {
22
+ let t: any;
23
+ let idx = 0;
24
+
25
+ return [
26
+ keymap.of([
27
+ {
28
+ // Reset.
29
+ key: "alt-meta-'",
30
+ run: (view) => {
31
+ clearTimeout(t);
32
+ idx = 0;
33
+ return true;
34
+ },
35
+ },
36
+ {
37
+ // Next prompt.
38
+ // TODO(burdon): Press 1-9 to select prompt?
39
+ key: "shift-meta-'",
40
+ run: (view) => {
41
+ clearTimeout(t);
42
+ // TODO(burdon): Add space if needed.
43
+ const text = items[idx++];
44
+ if (idx === items?.length) {
45
+ idx = 0;
46
+ }
47
+
48
+ let i = 0;
49
+ const insert = (d = 0) => {
50
+ t = setTimeout(() => {
51
+ const pos = view.state.selection.main.head;
52
+ view.dispatch({
53
+ changes: { from: pos, insert: text[i++] },
54
+ selection: { anchor: pos + 1 },
55
+ });
56
+
57
+ if (i < text.length) {
58
+ insert(Math.random() * delay * (text[i] === ' ' ? 2 : 1));
59
+ }
60
+ }, d);
61
+ };
62
+
63
+ insert();
64
+ return true;
65
+ },
66
+ },
67
+ ]),
68
+ ];
69
+ };