@dxos/react-ui-editor 0.3.10-next.c3f2d1d → 0.3.10-next.cec8a55

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 (101) hide show
  1. package/dist/lib/browser/index.mjs +1270 -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 +11 -7
  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 +11 -0
  20. package/dist/types/src/components/TextEditor/extensions/demo.d.ts.map +1 -0
  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 +7 -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 +4 -2
  31. package/dist/types/src/components/TextEditor/extensions/listener.d.ts.map +1 -1
  32. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts +10 -1
  33. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +1 -1
  34. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts +9 -3
  35. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +1 -1
  36. package/dist/types/src/components/TextEditor/extensions/mention.d.ts +6 -0
  37. package/dist/types/src/components/TextEditor/extensions/mention.d.ts.map +1 -0
  38. package/dist/types/src/components/TextEditor/extensions/table.d.ts +8 -0
  39. package/dist/types/src/components/TextEditor/extensions/table.d.ts.map +1 -0
  40. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts +2 -1
  41. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts.map +1 -1
  42. package/dist/types/src/components/TextEditor/themes/default.d.ts +5 -0
  43. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +1 -1
  44. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts +3 -0
  45. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts.map +1 -1
  46. package/dist/types/src/hooks/automerge/automerge.stories.d.ts +8 -1
  47. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -1
  48. package/dist/types/src/hooks/automerge/index.d.ts +1 -1
  49. package/dist/types/src/hooks/automerge/index.d.ts.map +1 -1
  50. package/dist/types/src/hooks/automerge/plugin.d.ts.map +1 -1
  51. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts +5 -0
  52. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts.map +1 -0
  53. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts +6 -0
  54. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts.map +1 -0
  55. package/dist/types/src/hooks/automerge/translation/index.d.ts +3 -0
  56. package/dist/types/src/hooks/automerge/translation/index.d.ts.map +1 -0
  57. package/dist/types/src/hooks/useTextModel.d.ts +6 -0
  58. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  59. package/dist/types/src/{components/TextEditor → hooks/yjs}/yjs.stories.d.ts +2 -2
  60. package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +1 -0
  61. package/dist/types/src/styles/markdown.d.ts +3 -2
  62. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  63. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  64. package/package.json +23 -20
  65. package/src/components/TextEditor/MarkdownEditor.stories.tsx +128 -34
  66. package/src/components/TextEditor/TextEditor.stories.tsx +12 -1
  67. package/src/components/TextEditor/TextEditor.tsx +30 -9
  68. package/src/components/TextEditor/extensions/autocomplete.ts +5 -20
  69. package/src/components/TextEditor/extensions/basic.ts +6 -4
  70. package/src/components/TextEditor/extensions/blast.ts +373 -0
  71. package/src/components/TextEditor/extensions/code.ts +99 -0
  72. package/src/components/TextEditor/extensions/comments.ts +67 -67
  73. package/src/components/TextEditor/extensions/demo.ts +69 -0
  74. package/src/components/TextEditor/extensions/experimental.tsx +15 -36
  75. package/src/components/TextEditor/extensions/highlight.ts +128 -0
  76. package/src/components/TextEditor/extensions/image.ts +67 -0
  77. package/src/components/TextEditor/extensions/index.ts +7 -0
  78. package/src/components/TextEditor/extensions/link.ts +17 -25
  79. package/src/components/TextEditor/extensions/listener.ts +4 -4
  80. package/src/components/TextEditor/extensions/markdown/bundle.ts +45 -34
  81. package/src/components/TextEditor/extensions/markdown/highlight.ts +148 -108
  82. package/src/components/TextEditor/extensions/mention.ts +31 -0
  83. package/src/components/TextEditor/extensions/table.ts +119 -0
  84. package/src/components/TextEditor/extensions/tasklist.ts +42 -28
  85. package/src/components/TextEditor/themes/default.ts +98 -4
  86. package/src/hooks/automerge/PatchSemaphore.ts +12 -9
  87. package/src/hooks/automerge/automerge.stories.tsx +4 -2
  88. package/src/hooks/automerge/index.ts +1 -1
  89. package/src/hooks/automerge/plugin.ts +1 -0
  90. package/src/hooks/automerge/{amToCodemirror.ts → translation/automerge-to-codemirror.ts} +10 -2
  91. package/src/hooks/automerge/{codeMirrorToAm.ts → translation/codemirror-to-automerge.ts} +7 -4
  92. package/src/hooks/automerge/translation/index.ts +6 -0
  93. package/src/hooks/useTextModel.ts +9 -0
  94. package/src/{components/TextEditor → hooks/yjs}/yjs.stories.tsx +2 -2
  95. package/src/styles/markdown.ts +15 -8
  96. package/src/testing/replicator.ts +1 -0
  97. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  98. package/dist/types/src/hooks/automerge/amToCodemirror.d.ts +0 -6
  99. package/dist/types/src/hooks/automerge/amToCodemirror.d.ts.map +0 -1
  100. package/dist/types/src/hooks/automerge/codeMirrorToAm.d.ts +0 -7
  101. package/dist/types/src/hooks/automerge/codeMirrorToAm.d.ts.map +0 -1
@@ -56,9 +56,10 @@ export type TextEditorSlots = {
56
56
 
57
57
  export type TextEditorProps = {
58
58
  model: EditorModel;
59
+ readonly?: boolean;
60
+ editorMode?: EditorMode;
59
61
  extensions?: Extension[];
60
62
  slots?: TextEditorSlots;
61
- editorMode?: EditorMode;
62
63
  };
63
64
 
64
65
  /**
@@ -66,7 +67,7 @@ export type TextEditorProps = {
66
67
  * NOTE: Rather than adding properties, try to create extensions that can be reused.
67
68
  */
68
69
  export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
69
- ({ model, extensions = [], slots = defaultSlots, editorMode }, forwardedRef) => {
70
+ ({ model, readonly, editorMode, extensions = [], slots = defaultSlots }, forwardedRef) => {
70
71
  const { themeMode } = useThemeContext();
71
72
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
72
73
 
@@ -87,6 +88,12 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
87
88
  }
88
89
  }, [awareness, peer, themeMode]);
89
90
 
91
+ // TODO(burdon): Get from model.
92
+ // useHighlights(view, [
93
+ // { id: 'c-1', from: 0, to: 20 },
94
+ // { id: 'c-2', from: 100, to: 120 },
95
+ // ]);
96
+
90
97
  useEffect(() => {
91
98
  if (!root) {
92
99
  return;
@@ -95,10 +102,13 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
95
102
  const state = EditorState.create({
96
103
  doc: model.text(),
97
104
  extensions: [
105
+ readonly && EditorState.readOnly.of(readonly),
106
+
98
107
  // TODO(burdon): Factor out VIM mode?
99
108
  editorMode === 'vim' && vim(),
100
109
 
101
110
  // Theme.
111
+ // TODO(burdon): Make optional.
102
112
  EditorView.baseTheme(defaultTheme),
103
113
  EditorView.theme(slots?.editor?.theme ?? {}),
104
114
  // TODO(burdon): themeMode doesn't change in storybooks.
@@ -116,7 +126,17 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
116
126
  // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
117
127
  // Ideally this should not be hit except when changing between text objects.
118
128
  view?.destroy();
119
- setView(new EditorView({ state, parent: root }));
129
+ setView(
130
+ new EditorView({
131
+ state,
132
+ parent: root,
133
+ // NOTE: Uncomment to spy on all transactions.
134
+ // https://codemirror.net/docs/ref/#view.EditorView.dispatch
135
+ // dispatch: (transaction, view) => {
136
+ // view.update([transaction]);
137
+ // },
138
+ }),
139
+ );
120
140
  setState(state);
121
141
 
122
142
  return () => {
@@ -124,7 +144,7 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
124
144
  setView(undefined);
125
145
  setState(undefined);
126
146
  };
127
- }, [root, model, themeMode, editorMode]);
147
+ }, [root, model, themeMode, readonly, editorMode]);
128
148
 
129
149
  const handleKeyUp = useCallback(
130
150
  (event: KeyboardEvent) => {
@@ -157,15 +177,15 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
157
177
  },
158
178
  );
159
179
 
160
- // TODO(burdon): Set default text theme?
161
180
  export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
162
- ({ extensions = [], slots: _slots, ...props }, forwardedRef) => {
181
+ ({ readonly, extensions = [], slots: _slots, ...props }, forwardedRef) => {
163
182
  const { themeMode } = useThemeContext();
164
183
  const slots = defaultsDeep({}, _slots, defaultTextSlots);
165
184
  return (
166
185
  <BaseTextEditor
167
186
  ref={forwardedRef}
168
- extensions={[basicBundle({ themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
187
+ readonly={readonly}
188
+ extensions={[basicBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
169
189
  slots={slots}
170
190
  {...props}
171
191
  />
@@ -174,13 +194,14 @@ export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
174
194
  );
175
195
 
176
196
  export const MarkdownEditor = forwardRef<TextEditorRef, TextEditorProps>(
177
- ({ extensions = [], slots: _slots, ...props }, forwardedRef) => {
197
+ ({ readonly, extensions = [], slots: _slots, ...props }, forwardedRef) => {
178
198
  const { themeMode } = useThemeContext();
179
199
  const slots = defaultsDeep({}, _slots, defaultMarkdownSlots);
180
200
  return (
181
201
  <BaseTextEditor
182
202
  ref={forwardedRef}
183
- extensions={[markdownBundle({ themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
203
+ readonly={readonly}
204
+ extensions={[markdownBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
184
205
  slots={slots}
185
206
  {...props}
186
207
  />
@@ -2,23 +2,15 @@
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';
@@ -51,21 +43,14 @@ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
51
43
  // TODO(burdon): Optional decoration via addToOptions
52
44
  override: [
53
45
  (context: CompletionContext): CompletionResult | null => {
54
- const word = context.matchBefore(/\w*/);
55
- if (!word || (word.from === word.to && !context.explicit)) {
46
+ const match = context.matchBefore(/\w*/);
47
+ if (!match || (match.from === match.to && !context.explicit)) {
56
48
  return null;
57
49
  }
58
50
 
59
- // TODO(burdon): Option to convert to links?
60
51
  return {
61
- from: word.from,
62
- options: onSearch(word.text.toLowerCase()),
63
- // options: [
64
- // { label: 'apple', type: 'keyword' },
65
- // { label: 'amazon', type: 'keyword' },
66
- // { label: 'hello', type: 'variable', info: '(World)' },
67
- // { label: 'magic', type: 'text', apply: '⠁⭒*.✩.*⭒⠁', detail: 'macro' },
68
- // ],
52
+ from: match.from,
53
+ options: onSearch(match.text.toLowerCase()),
69
54
  };
70
55
  },
71
56
  ],
@@ -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
+ };