@dxos/react-ui-editor 0.3.10-main.dcfebef → 0.3.10-main.e38869c

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