@dxos/react-ui-editor 0.3.10-main.5515dfe → 0.3.10-main.560f82b

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 (108) hide show
  1. package/dist/lib/browser/index.mjs +1049 -285
  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 +22 -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 +3 -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 +3 -2
  9. package/dist/types/src/components/TextEditor/extensions/autocomplete.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/extensions/basic.d.ts +2 -1
  11. package/dist/types/src/components/TextEditor/extensions/basic.d.ts.map +1 -1
  12. package/dist/types/src/components/TextEditor/extensions/blast.d.ts +25 -0
  13. package/dist/types/src/components/TextEditor/extensions/blast.d.ts.map +1 -0
  14. package/dist/types/src/components/TextEditor/extensions/comments.d.ts +18 -0
  15. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +1 -0
  16. package/dist/types/src/components/TextEditor/extensions/demo.d.ts +11 -0
  17. package/dist/types/src/components/TextEditor/extensions/demo.d.ts.map +1 -0
  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 +6 -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 +6 -2
  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 -4
  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 +8 -0
  37. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +1 -1
  38. package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/PatchSemaphore.d.ts +3 -0
  39. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts.map +1 -0
  40. package/dist/types/src/hooks/automerge/automerge.stories.d.ts +16 -0
  41. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -0
  42. package/dist/types/src/hooks/automerge/handle.d.ts.map +1 -0
  43. package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/index.d.ts +1 -1
  44. package/dist/types/src/hooks/automerge/index.d.ts.map +1 -0
  45. package/dist/types/src/hooks/automerge/plugin.d.ts.map +1 -0
  46. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts +5 -0
  47. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts.map +1 -0
  48. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts +6 -0
  49. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts.map +1 -0
  50. package/dist/types/src/hooks/automerge/translation/index.d.ts +3 -0
  51. package/dist/types/src/hooks/automerge/translation/index.d.ts.map +1 -0
  52. package/dist/types/src/hooks/index.d.ts +0 -1
  53. package/dist/types/src/hooks/index.d.ts.map +1 -1
  54. package/dist/types/src/hooks/useTextModel.d.ts +3 -4
  55. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  56. package/dist/types/src/{components/TextEditor → hooks/yjs}/yjs.stories.d.ts +1 -1
  57. package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +1 -0
  58. package/dist/types/src/styles/markdown.d.ts +3 -2
  59. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  60. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  61. package/package.json +20 -19
  62. package/src/components/TextEditor/MarkdownEditor.stories.tsx +137 -31
  63. package/src/components/TextEditor/TextEditor.tsx +52 -25
  64. package/src/components/TextEditor/extensions/autocomplete.ts +9 -22
  65. package/src/components/TextEditor/extensions/basic.ts +2 -1
  66. package/src/components/TextEditor/extensions/blast.ts +375 -0
  67. package/src/components/TextEditor/extensions/comments.ts +190 -0
  68. package/src/components/TextEditor/extensions/demo.ts +69 -0
  69. package/src/components/TextEditor/extensions/experimental.tsx +1 -0
  70. package/src/components/TextEditor/extensions/image.ts +68 -0
  71. package/src/components/TextEditor/extensions/index.ts +6 -0
  72. package/src/components/TextEditor/extensions/link.ts +25 -25
  73. package/src/components/TextEditor/extensions/listener.ts +4 -3
  74. package/src/components/TextEditor/extensions/markdown/bundle.ts +18 -18
  75. package/src/components/TextEditor/extensions/markdown/highlight.ts +118 -103
  76. package/src/components/TextEditor/extensions/mention.ts +31 -0
  77. package/src/components/TextEditor/extensions/table.ts +123 -0
  78. package/src/components/TextEditor/extensions/tasklist.ts +89 -81
  79. package/src/components/TextEditor/themes/default.ts +69 -2
  80. package/src/{automerge/automerge-plugin → hooks/automerge}/PatchSemaphore.ts +12 -9
  81. package/src/{automerge → hooks/automerge}/automerge.stories.tsx +5 -3
  82. package/src/{automerge/automerge-plugin → hooks/automerge}/plugin.ts +1 -0
  83. package/src/{automerge/automerge-plugin/amToCodemirror.ts → hooks/automerge/translation/automerge-to-codemirror.ts} +10 -2
  84. package/src/{automerge/automerge-plugin/codeMirrorToAm.ts → hooks/automerge/translation/codemirror-to-automerge.ts} +7 -4
  85. package/src/hooks/automerge/translation/index.ts +6 -0
  86. package/src/hooks/index.ts +0 -1
  87. package/src/hooks/useTextModel.ts +49 -41
  88. package/src/{components/TextEditor → hooks/yjs}/yjs.stories.tsx +1 -1
  89. package/src/styles/markdown.ts +14 -8
  90. package/src/testing/replicator.ts +6 -7
  91. package/dist/types/src/automerge/automerge-plugin/PatchSemaphore.d.ts.map +0 -1
  92. package/dist/types/src/automerge/automerge-plugin/amToCodemirror.d.ts +0 -6
  93. package/dist/types/src/automerge/automerge-plugin/amToCodemirror.d.ts.map +0 -1
  94. package/dist/types/src/automerge/automerge-plugin/codeMirrorToAm.d.ts +0 -7
  95. package/dist/types/src/automerge/automerge-plugin/codeMirrorToAm.d.ts.map +0 -1
  96. package/dist/types/src/automerge/automerge-plugin/handle.d.ts.map +0 -1
  97. package/dist/types/src/automerge/automerge-plugin/index.d.ts.map +0 -1
  98. package/dist/types/src/automerge/automerge-plugin/plugin.d.ts.map +0 -1
  99. package/dist/types/src/automerge/automerge.stories.d.ts +0 -9
  100. package/dist/types/src/automerge/automerge.stories.d.ts.map +0 -1
  101. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  102. package/dist/types/src/hooks/useCollaboration.d.ts +0 -9
  103. package/dist/types/src/hooks/useCollaboration.d.ts.map +0 -1
  104. package/src/hooks/useCollaboration.ts +0 -45
  105. package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/handle.d.ts +0 -0
  106. package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/plugin.d.ts +0 -0
  107. package/src/{automerge/automerge-plugin → hooks/automerge}/handle.ts +0 -0
  108. package/src/{automerge/automerge-plugin → hooks/automerge}/index.ts +1 -1
@@ -2,35 +2,29 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- // TODO(burdon): Automcomplete: https://codemirror.net/5/doc/manual.html#addon_runmode
6
- // TODO(burdon): Modes: parallel parsing and decoration (e.g., associated with language).
7
- // TODO(burdon): Add-on: runmode: run lexer over content (with rendering codemirror).
8
- // https://codemirror.net/5/doc/manual.html#addon_runmode
9
- // TODO(burdon): Add-on: dialog.
10
- // TODO(burdon): Comments: https://codemirror.net/5/doc/manual.html#setBookmark
11
- // TODO(burdon): Split view: https://codemirror.net/examples/split
12
-
13
5
  // https://codemirror.net/examples/autocompletion
14
6
  // https://codemirror.net/docs/ref/#autocomplete.autocompletion
15
7
  // https://codemirror.net/docs/ref/#autocomplete.Completion
16
8
 
17
9
  import {
18
10
  autocompletion,
11
+ completionKeymap,
19
12
  type Completion,
20
13
  type CompletionContext,
21
- completionKeymap,
22
14
  type CompletionResult,
23
15
  } from '@codemirror/autocomplete';
24
16
  import { keymap } from '@codemirror/view';
25
17
 
18
+ export type AutocompleteResult = Completion;
19
+
26
20
  export type AutocompleteOptions = {
27
- getOptions: (text: string) => Completion[];
21
+ onSearch: (text: string) => Completion[];
28
22
  };
29
23
 
30
24
  /**
31
25
  * Autocomplete extension.
32
26
  */
33
- export const autocomplete = ({ getOptions }: AutocompleteOptions) => {
27
+ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
34
28
  return [
35
29
  // https://codemirror.net/docs/ref/#view.keymap
36
30
  // https://discuss.codemirror.net/t/how-can-i-replace-the-default-autocompletion-keymap-v6/3322
@@ -49,21 +43,14 @@ export const autocomplete = ({ getOptions }: AutocompleteOptions) => {
49
43
  // TODO(burdon): Optional decoration via addToOptions
50
44
  override: [
51
45
  (context: CompletionContext): CompletionResult | null => {
52
- const word = context.matchBefore(/\w*/);
53
- if (!word || (word.from === word.to && !context.explicit)) {
46
+ const match = context.matchBefore(/\w*/);
47
+ if (!match || (match.from === match.to && !context.explicit)) {
54
48
  return null;
55
49
  }
56
50
 
57
- // TODO(burdon): Option to convert to links?
58
51
  return {
59
- from: word.from,
60
- options: getOptions(word.text.toLowerCase()),
61
- // options: [
62
- // { label: 'apple', type: 'keyword' },
63
- // { label: 'amazon', type: 'keyword' },
64
- // { label: 'hello', type: 'variable', info: '(World)' },
65
- // { label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
66
- // ],
52
+ from: match.from,
53
+ options: onSearch(match.text.toLowerCase()),
67
54
  };
68
55
  },
69
56
  ],
@@ -11,11 +11,12 @@ import { EditorView, placeholder } from '@codemirror/view';
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
  [
20
21
  // TODO(burdon): Compare with minimalSetup.
21
22
  // https://codemirror.net/docs/ref/#codemirror.minimalSetup
@@ -0,0 +1,375 @@
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
+ maxParticles: 200,
29
+ particleGravity: 0.08,
30
+ particleAlphaFadeout: 0.996,
31
+ particleSize: { min: 2, max: 8 },
32
+ particleNumRange: { min: 5, max: 10 },
33
+ particleVelocityRange: { x: [-1, 1], y: [-3.5, -1.5] },
34
+ particleShrinkRate: 0.95,
35
+ shakeIntensity: 5,
36
+ };
37
+
38
+ export const blast = (options: Partial<BlastOptions>): Extension => {
39
+ let blaster: Blaster | undefined;
40
+ let last = 0;
41
+
42
+ const getPoint = (view: EditorView, pos: number) => {
43
+ const { left: x = 0, top: y = 0 } = view.coordsForChar(pos) ?? {};
44
+ const element = document.elementFromPoint(x, y);
45
+
46
+ const { offsetLeft: left = 0, offsetTop: top = 0 } = view.scrollDOM.parentElement ?? {};
47
+ const point = { x: x - left, y: y - top };
48
+
49
+ return { element, point };
50
+ };
51
+
52
+ return [
53
+ // Cursor moved.
54
+ EditorView.updateListener.of((update) => {
55
+ // NOTE: The MarkdownEditor may recreated the EditorView.
56
+ if (blaster?.node !== update.view.scrollDOM) {
57
+ if (blaster) {
58
+ blaster.destroy();
59
+ }
60
+
61
+ blaster = new Blaster(update.view.scrollDOM, defaultsDeep({}, options, defaultOptions));
62
+ blaster.initialize();
63
+ blaster.start(); // TODO(burdon): Stop/clean-up.
64
+ } else {
65
+ blaster.resize();
66
+ }
67
+
68
+ const current = update.view.state.selection.main.head;
69
+ if (current !== last) {
70
+ last = current;
71
+ // TODO(burdon): Null if end of line.
72
+ const { element, point } = getPoint(update.view, current);
73
+ if (element && point) {
74
+ blaster.spawn({ element, point });
75
+ }
76
+ }
77
+ }),
78
+
79
+ // Document changed.
80
+ StateField.define({
81
+ create: () => null,
82
+ update: (_value, transaction) => {
83
+ if (blaster && transaction.docChanged) {
84
+ // TODO(burdon): Only on delete.
85
+ const view = EditorView.findFromDOM(blaster.node)!;
86
+ const { element, point } = getPoint(view, transaction.selection!.main.head);
87
+ if (element && point) {
88
+ blaster.spawn({ element, point });
89
+ }
90
+ }
91
+
92
+ return null;
93
+ },
94
+ }),
95
+
96
+ keymap.of([
97
+ {
98
+ any: (view, event) => {
99
+ if (blaster && event.key === 'Enter' && event.shiftKey) {
100
+ blaster.shake({ time: 0.4 });
101
+ }
102
+
103
+ return false;
104
+ },
105
+ },
106
+ ]),
107
+ ];
108
+ };
109
+
110
+ //
111
+ // Blaster (no CM deps).
112
+ //
113
+
114
+ class Blaster {
115
+ _canvas: HTMLCanvasElement | undefined;
116
+ _ctx: CanvasRenderingContext2D | undefined | null;
117
+
118
+ private readonly _effect: Effect;
119
+
120
+ _running = false;
121
+ _lastTime: number | undefined;
122
+ _shakeTime = 0;
123
+ _shakeTimeMax = 0;
124
+
125
+ _particles: Particle[] = [];
126
+ _particlePointer = 0;
127
+
128
+ _lastPoint = { x: 0, y: 0 };
129
+
130
+ constructor(private readonly _node: HTMLElement, private readonly _options: BlastOptions) {
131
+ this._effect = this._options.effect === 1 ? new Effect1(_options) : new Effect2(_options);
132
+ }
133
+
134
+ get node() {
135
+ return this._node;
136
+ }
137
+
138
+ initialize() {
139
+ // console.log('initialize');
140
+ invariant(!this._canvas && !this._ctx);
141
+
142
+ this._canvas = document.createElement('canvas');
143
+ this._canvas.id = 'code-blast-canvas';
144
+ this._canvas.style.position = 'absolute';
145
+ this._canvas.style.zIndex = '0';
146
+ this._canvas.style.pointerEvents = 'none';
147
+ // this._canvas.style.border = '2px solid red';
148
+
149
+ this._ctx = this._canvas.getContext('2d');
150
+
151
+ const parent = this._node.parentElement?.parentElement;
152
+ parent?.appendChild(this._canvas);
153
+
154
+ this.resize();
155
+ }
156
+
157
+ destroy() {
158
+ this.stop();
159
+ // console.log('destroy');
160
+ if (this._canvas) {
161
+ this._canvas.remove();
162
+ this._canvas = undefined;
163
+ this._ctx = undefined;
164
+ }
165
+ }
166
+
167
+ resize() {
168
+ if (this._node.parentElement && this._canvas) {
169
+ const { offsetLeft: x, offsetTop: y, offsetWidth: width, offsetHeight: height } = this._node.parentElement;
170
+ this._canvas.style.top = `${y}px`;
171
+ this._canvas.style.left = `${x}px`;
172
+ this._canvas.width = width;
173
+ this._canvas.height = height;
174
+ }
175
+ }
176
+
177
+ start() {
178
+ // console.log('start');
179
+ invariant(this._canvas && this._ctx);
180
+ this._running = true;
181
+ this.loop();
182
+ }
183
+
184
+ stop() {
185
+ // console.log('stop');
186
+ this._running = false;
187
+ this._node.style.transform = 'translate(0px, 0px)';
188
+ }
189
+
190
+ loop() {
191
+ if (!this._running || !this._canvas || !this._ctx) {
192
+ return;
193
+ }
194
+
195
+ this._ctx.clearRect(0, 0, this._canvas.width ?? 0, this._canvas.height ?? 0);
196
+
197
+ // Calculate the delta from the previous frame.
198
+ const now = new Date().getTime();
199
+ this._lastTime ??= now;
200
+ const dt = (now - this._lastTime) / 1000;
201
+ this._lastTime = now;
202
+
203
+ if (this._shakeTime > 0) {
204
+ this._shakeTime -= dt;
205
+ const magnitude = (this._shakeTime / this._shakeTimeMax) * this._options.shakeIntensity;
206
+ const shakeX = random(-magnitude, magnitude);
207
+ const shakeY = random(-magnitude, magnitude);
208
+ this._node!.style.transform = `translate(${shakeX}px,${shakeY}px)`;
209
+ }
210
+
211
+ this.drawParticles();
212
+
213
+ requestAnimationFrame(this.loop.bind(this));
214
+ }
215
+
216
+ shake = throttle<{ time: number }>(({ time }) => {
217
+ this._shakeTime = this._shakeTimeMax;
218
+ this._shakeTimeMax = time;
219
+ }, 100);
220
+
221
+ spawn = throttle<{ element: Element; point: { x: number; y: number } }>(({ element, point }) => {
222
+ const color = getRGBComponents(element, this._options.color);
223
+ const numParticles = random(this._options.particleNumRange.min, this._options.particleNumRange.max);
224
+ const dir = this._lastPoint.x === point.x ? 0 : this._lastPoint.x < point.x ? 1 : -1;
225
+ this._lastPoint = point;
226
+ for (let i = numParticles; i--; i > 0) {
227
+ this._particles[this._particlePointer] = this._effect.create(point.x - dir * 16, point.y, color);
228
+ this._particlePointer = (this._particlePointer + 1) % this._options.maxParticles;
229
+ }
230
+ }, 100);
231
+
232
+ drawParticles() {
233
+ for (let i = this._particles.length; i--; i > 0) {
234
+ const particle = this._particles[i];
235
+ if (!particle) {
236
+ continue;
237
+ }
238
+
239
+ if (particle.alpha < 0.01 || particle.size <= 0.5) {
240
+ continue;
241
+ }
242
+
243
+ this._effect.update(this._ctx!, particle);
244
+ }
245
+ }
246
+ }
247
+
248
+ //
249
+ // Effects
250
+ //
251
+
252
+ type Particle = {
253
+ x: number;
254
+ y: number;
255
+ vx: number;
256
+ vy: number;
257
+ size: number;
258
+ color: (string | number)[];
259
+ alpha: number;
260
+ theta?: number;
261
+ drag?: number;
262
+ wander?: number;
263
+ };
264
+
265
+ abstract class Effect {
266
+ constructor(protected readonly _options: BlastOptions) {}
267
+ abstract create(x: number, y: number, color: Particle['color']): Particle;
268
+ abstract update(ctx: CanvasRenderingContext2D, particle: Particle): void;
269
+ }
270
+
271
+ class Effect1 extends Effect {
272
+ create(x: number, y: number, color: Particle['color']) {
273
+ return {
274
+ x,
275
+ y: y + 10,
276
+ vx: random(this._options.particleVelocityRange.x[0], this._options.particleVelocityRange.x[1]),
277
+ vy: random(this._options.particleVelocityRange.y[0], this._options.particleVelocityRange.y[1]),
278
+ size: random(this._options.particleSize.min, this._options.particleSize.max),
279
+ color,
280
+ alpha: 1,
281
+ };
282
+ }
283
+
284
+ update(ctx: CanvasRenderingContext2D, particle: Particle) {
285
+ particle.vy += this._options.particleGravity;
286
+ particle.x += particle.vx;
287
+ particle.y += particle.vy;
288
+ particle.alpha *= this._options.particleAlphaFadeout;
289
+
290
+ ctx.fillStyle = `rgba(${particle.color[0]},${particle.color[1]},${particle.color[2]},${particle.alpha})`;
291
+ ctx.fillRect(Math.round(particle.x - 1), Math.round(particle.y - 1), particle.size, particle.size);
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Based on Soulwire's demo.
297
+ * http://codepen.io/soulwire/pen/foktm
298
+ */
299
+ class Effect2 extends Effect {
300
+ create(x: number, y: number, color: Particle['color']) {
301
+ return {
302
+ x,
303
+ y: y + 10,
304
+ vx: random(this._options.particleVelocityRange.x[0], this._options.particleVelocityRange.x[1]),
305
+ vy: random(this._options.particleVelocityRange.y[0], this._options.particleVelocityRange.y[1]),
306
+ size: random(this._options.particleSize.min, this._options.particleSize.max),
307
+ color,
308
+ alpha: 1,
309
+ theta: (random(0, 360) * Math.PI) / 180,
310
+ drag: 0.92,
311
+ wander: 0.15,
312
+ };
313
+ }
314
+
315
+ update(ctx: CanvasRenderingContext2D, particle: Particle) {
316
+ particle.vy += this._options.particleGravity;
317
+ particle.x += particle.vx;
318
+ particle.y += particle.vy;
319
+ particle.vx *= particle.drag!;
320
+ particle.vy *= particle.drag!;
321
+ particle.theta! += random(-0.5, 0.5);
322
+ particle.vx += Math.sin(particle.theta!) * 0.1;
323
+ particle.vy += Math.cos(particle.theta!) * 0.1;
324
+ particle.size *= this._options.particleShrinkRate;
325
+ particle.alpha *= this._options.particleAlphaFadeout;
326
+
327
+ ctx.fillStyle = `rgba(${particle.color[0]},${particle.color[1]},${particle.color[2]},${particle.alpha})`;
328
+ ctx.beginPath();
329
+ ctx.arc(Math.round(particle.x - 1), Math.round(particle.y - 1), particle.size, 0, 2 * Math.PI);
330
+ ctx.fill();
331
+ }
332
+ }
333
+
334
+ //
335
+ // Utils
336
+ //
337
+
338
+ function throttle<T>(callback: (arg: T) => void, limit: number): (arg: T) => void {
339
+ let wait = false;
340
+ return function (...args: any[]) {
341
+ if (!wait) {
342
+ // @ts-ignore
343
+ callback.apply(this, args);
344
+ wait = true;
345
+ setTimeout(() => {
346
+ wait = false;
347
+ }, limit);
348
+ }
349
+ };
350
+ }
351
+
352
+ const getRGBComponents = (node: Element, color: BlastOptions['color']): Particle['color'] => {
353
+ if (typeof color === 'function') {
354
+ return color();
355
+ }
356
+
357
+ const bgColor = getComputedStyle(node).color;
358
+ if (bgColor) {
359
+ const x = bgColor.match(/(\d+), (\d+), (\d+)/)?.slice(1);
360
+ if (x) {
361
+ return x;
362
+ }
363
+ }
364
+
365
+ return [50, 50, 50];
366
+ };
367
+
368
+ const random = (min: number, max: number) => {
369
+ if (!max) {
370
+ max = min;
371
+ min = 0;
372
+ }
373
+
374
+ return min + ~~(Math.random() * (max - min + 1));
375
+ };
@@ -0,0 +1,190 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import {
7
+ keymap,
8
+ type DecorationSet,
9
+ type Rect,
10
+ type ViewUpdate,
11
+ Decoration,
12
+ EditorView,
13
+ MatchDecorator,
14
+ ViewPlugin,
15
+ WidgetType,
16
+ } from '@codemirror/view';
17
+
18
+ import { debounce } from '@dxos/async';
19
+ import { invariant } from '@dxos/invariant';
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
37
+
38
+ class BookmarkWidget extends WidgetType {
39
+ constructor(private readonly _pos: number, private readonly _id: string) {
40
+ super();
41
+ invariant(this._id);
42
+ }
43
+
44
+ override eq(other: BookmarkWidget) {
45
+ return this._id === other._id;
46
+ }
47
+
48
+ override toDOM() {
49
+ const span = document.createElement('span');
50
+ span.className = 'cm-bookmark';
51
+ // TODO(burdon): Call out to react?
52
+ // https://emojifinder.com/comment
53
+ span.textContent = '💬';
54
+ return span;
55
+ }
56
+ }
57
+
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
+ type CommentsInfo = {
72
+ active?: string;
73
+ items: { id: string; pos: number; location: Rect | null }[];
74
+ };
75
+
76
+ export type CommentsOptions = {
77
+ key?: string;
78
+ onCreate?: () => string | void;
79
+ onUpdate?: (info: CommentsInfo) => void;
80
+ };
81
+
82
+ // https://www.markdownguide.org/extended-syntax/#footnotes
83
+ // TODO(burdon): Record span in automerge model?
84
+ // TODO(burdon): Extended markdown: https://www.markdownguide.org/extended-syntax
85
+ export const comments = (options: CommentsOptions = {}): Extension => {
86
+ let active: string | undefined;
87
+
88
+ const bookmarkMatcher = new MatchDecorator({
89
+ regexp: /\[\^(\w+)\]/g,
90
+ decoration: (match, view, pos) => {
91
+ const id = match[1];
92
+ return Decoration.replace({
93
+ id,
94
+ widget: new BookmarkWidget(pos, id),
95
+ });
96
+ },
97
+ });
98
+
99
+ const bookmarks = ViewPlugin.fromClass(
100
+ class {
101
+ bookmarks: DecorationSet;
102
+ constructor(view: EditorView) {
103
+ this.bookmarks = bookmarkMatcher.createDeco(view);
104
+ }
105
+
106
+ update(update: ViewUpdate) {
107
+ this.bookmarks = bookmarkMatcher.updateDeco(update, this.bookmarks);
108
+ }
109
+ },
110
+ {
111
+ decorations: (instance) => instance.bookmarks,
112
+ provide: (plugin) =>
113
+ EditorView.atomicRanges.of((view) => {
114
+ return view.plugin(plugin)?.bookmarks || Decoration.none;
115
+ }),
116
+ },
117
+ );
118
+
119
+ const doUpdate = debounce((data: CommentsInfo) => {
120
+ options.onUpdate?.(data);
121
+ }, 200);
122
+
123
+ return [
124
+ keymap.of([
125
+ {
126
+ key: options?.key ?? 'shift-meta-c',
127
+ run: (view) => {
128
+ // Insert footnote.
129
+ const id = options.onCreate?.();
130
+ if (id) {
131
+ const pos = view.state.selection.main.head;
132
+ const tag = `[^${id}]`;
133
+ view.dispatch({
134
+ changes: { from: pos, insert: tag },
135
+ selection: { anchor: pos + tag.length },
136
+ });
137
+
138
+ return true;
139
+ }
140
+
141
+ return false;
142
+ },
143
+ },
144
+ ]),
145
+
146
+ bookmarks,
147
+
148
+ // Monitor cursor movement.
149
+ EditorView.updateListener.of((update) => {
150
+ active = undefined;
151
+ if (!options.onUpdate) {
152
+ return;
153
+ }
154
+
155
+ const view = update.view;
156
+ const pos = view.state.selection.main.head;
157
+
158
+ const decorations: { from: number; to: number; value: Decoration }[] = [];
159
+ const rangeSet = view.plugin(bookmarks)?.bookmarks;
160
+ let closest = Infinity;
161
+ // TODO(burdon): Always shows entire document?
162
+ const { from, to } = view.visibleRanges?.[0] ?? { from: 0, to: view.state.doc.length };
163
+ rangeSet?.between(from, to, (from, to, value) => {
164
+ decorations.push({ from, to, value });
165
+ const d = Math.min(Math.abs(pos - from), Math.abs(pos - to));
166
+ if (d < closest) {
167
+ // TODO(burdon): Update class of selected.
168
+ active = value.spec.id;
169
+ closest = d;
170
+ }
171
+ });
172
+
173
+ if (decorations.length) {
174
+ doUpdate({
175
+ active,
176
+ items: decorations.map(
177
+ ({
178
+ from,
179
+ value: {
180
+ spec: { id },
181
+ },
182
+ }) => ({ id: id as string, pos: from, location: view.coordsAtPos(from) }),
183
+ ),
184
+ });
185
+ }
186
+ }),
187
+
188
+ styles,
189
+ ];
190
+ };