@dxos/react-ui-editor 0.3.10-main.3645708 → 0.3.10-main.396fa89

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 (118) hide show
  1. package/dist/lib/browser/index.mjs +1495 -489
  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 +26 -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 +3 -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 +3 -2
  11. package/dist/types/src/components/TextEditor/extensions/autocomplete.d.ts.map +1 -1
  12. package/dist/types/src/components/TextEditor/extensions/basic.d.ts +2 -1
  13. package/dist/types/src/components/TextEditor/extensions/basic.d.ts.map +1 -1
  14. package/dist/types/src/components/TextEditor/extensions/blast.d.ts +25 -0
  15. package/dist/types/src/components/TextEditor/extensions/blast.d.ts.map +1 -0
  16. package/dist/types/src/components/TextEditor/extensions/code.d.ts +2 -0
  17. package/dist/types/src/components/TextEditor/extensions/code.d.ts.map +1 -0
  18. package/dist/types/src/components/TextEditor/extensions/comments.d.ts +18 -0
  19. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +1 -0
  20. package/dist/types/src/components/TextEditor/extensions/demo.d.ts +11 -0
  21. package/dist/types/src/components/TextEditor/extensions/demo.d.ts.map +1 -0
  22. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts +1 -1
  23. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts.map +1 -1
  24. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts +24 -0
  25. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts.map +1 -0
  26. package/dist/types/src/components/TextEditor/extensions/image.d.ts +4 -0
  27. package/dist/types/src/components/TextEditor/extensions/image.d.ts.map +1 -0
  28. package/dist/types/src/components/TextEditor/extensions/index.d.ts +8 -0
  29. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +1 -1
  30. package/dist/types/src/components/TextEditor/extensions/link.d.ts.map +1 -1
  31. package/dist/types/src/components/TextEditor/extensions/listener.d.ts +4 -2
  32. package/dist/types/src/components/TextEditor/extensions/listener.d.ts.map +1 -1
  33. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts +10 -1
  34. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +1 -1
  35. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts +9 -3
  36. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +1 -1
  37. package/dist/types/src/components/TextEditor/extensions/mention.d.ts +6 -0
  38. package/dist/types/src/components/TextEditor/extensions/mention.d.ts.map +1 -0
  39. package/dist/types/src/components/TextEditor/extensions/table.d.ts +8 -0
  40. package/dist/types/src/components/TextEditor/extensions/table.d.ts.map +1 -0
  41. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts +2 -4
  42. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts.map +1 -1
  43. package/dist/types/src/components/TextEditor/themes/default.d.ts +8 -0
  44. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +1 -1
  45. package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/PatchSemaphore.d.ts +3 -0
  46. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts.map +1 -0
  47. package/dist/types/src/hooks/automerge/automerge.stories.d.ts +16 -0
  48. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -0
  49. package/dist/types/src/hooks/automerge/handle.d.ts.map +1 -0
  50. package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/index.d.ts +1 -1
  51. package/dist/types/src/hooks/automerge/index.d.ts.map +1 -0
  52. package/dist/types/src/hooks/automerge/plugin.d.ts.map +1 -0
  53. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts +5 -0
  54. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts.map +1 -0
  55. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts +6 -0
  56. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts.map +1 -0
  57. package/dist/types/src/hooks/automerge/translation/index.d.ts +3 -0
  58. package/dist/types/src/hooks/automerge/translation/index.d.ts.map +1 -0
  59. package/dist/types/src/hooks/index.d.ts +0 -1
  60. package/dist/types/src/hooks/index.d.ts.map +1 -1
  61. package/dist/types/src/hooks/useTextModel.d.ts +8 -3
  62. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  63. package/dist/types/src/{components/TextEditor → hooks/yjs}/yjs.stories.d.ts +2 -2
  64. package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +1 -0
  65. package/dist/types/src/styles/markdown.d.ts +3 -2
  66. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  67. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  68. package/package.json +22 -19
  69. package/src/components/TextEditor/MarkdownEditor.stories.tsx +161 -36
  70. package/src/components/TextEditor/TextEditor.stories.tsx +12 -1
  71. package/src/components/TextEditor/TextEditor.tsx +49 -24
  72. package/src/components/TextEditor/extensions/autocomplete.ts +9 -22
  73. package/src/components/TextEditor/extensions/basic.ts +6 -4
  74. package/src/components/TextEditor/extensions/blast.ts +373 -0
  75. package/src/components/TextEditor/extensions/code.ts +99 -0
  76. package/src/components/TextEditor/extensions/comments.ts +173 -0
  77. package/src/components/TextEditor/extensions/demo.ts +69 -0
  78. package/src/components/TextEditor/extensions/experimental.tsx +15 -35
  79. package/src/components/TextEditor/extensions/highlight.ts +128 -0
  80. package/src/components/TextEditor/extensions/image.ts +67 -0
  81. package/src/components/TextEditor/extensions/index.ts +8 -0
  82. package/src/components/TextEditor/extensions/link.ts +27 -27
  83. package/src/components/TextEditor/extensions/listener.ts +4 -3
  84. package/src/components/TextEditor/extensions/markdown/bundle.ts +46 -43
  85. package/src/components/TextEditor/extensions/markdown/highlight.ts +148 -107
  86. package/src/components/TextEditor/extensions/mention.ts +31 -0
  87. package/src/components/TextEditor/extensions/table.ts +119 -0
  88. package/src/components/TextEditor/extensions/tasklist.ts +89 -81
  89. package/src/components/TextEditor/themes/default.ts +112 -4
  90. package/src/{automerge/automerge-plugin → hooks/automerge}/PatchSemaphore.ts +12 -9
  91. package/src/{automerge → hooks/automerge}/automerge.stories.tsx +6 -3
  92. package/src/{automerge/automerge-plugin → hooks/automerge}/plugin.ts +1 -0
  93. package/src/{automerge/automerge-plugin/amToCodemirror.ts → hooks/automerge/translation/automerge-to-codemirror.ts} +10 -2
  94. package/src/{automerge/automerge-plugin/codeMirrorToAm.ts → hooks/automerge/translation/codemirror-to-automerge.ts} +7 -4
  95. package/src/hooks/automerge/translation/index.ts +6 -0
  96. package/src/hooks/index.ts +0 -1
  97. package/src/hooks/useTextModel.ts +58 -41
  98. package/src/{components/TextEditor → hooks/yjs}/yjs.stories.tsx +2 -2
  99. package/src/styles/markdown.ts +15 -8
  100. package/src/testing/replicator.ts +7 -7
  101. package/dist/types/src/automerge/automerge-plugin/PatchSemaphore.d.ts.map +0 -1
  102. package/dist/types/src/automerge/automerge-plugin/amToCodemirror.d.ts +0 -6
  103. package/dist/types/src/automerge/automerge-plugin/amToCodemirror.d.ts.map +0 -1
  104. package/dist/types/src/automerge/automerge-plugin/codeMirrorToAm.d.ts +0 -7
  105. package/dist/types/src/automerge/automerge-plugin/codeMirrorToAm.d.ts.map +0 -1
  106. package/dist/types/src/automerge/automerge-plugin/handle.d.ts.map +0 -1
  107. package/dist/types/src/automerge/automerge-plugin/index.d.ts.map +0 -1
  108. package/dist/types/src/automerge/automerge-plugin/plugin.d.ts.map +0 -1
  109. package/dist/types/src/automerge/automerge.stories.d.ts +0 -9
  110. package/dist/types/src/automerge/automerge.stories.d.ts.map +0 -1
  111. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  112. package/dist/types/src/hooks/useCollaboration.d.ts +0 -9
  113. package/dist/types/src/hooks/useCollaboration.d.ts.map +0 -1
  114. package/src/hooks/useCollaboration.ts +0 -45
  115. package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/handle.d.ts +0 -0
  116. package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/plugin.d.ts +0 -0
  117. package/src/{automerge/automerge-plugin → hooks/automerge}/handle.ts +0 -0
  118. package/src/{automerge/automerge-plugin → hooks/automerge}/index.ts +1 -1
@@ -16,15 +16,13 @@ import React, {
16
16
  useImperativeHandle,
17
17
  useState,
18
18
  } from 'react';
19
- import { yCollab } from 'y-codemirror.next';
20
19
 
21
20
  import { generateName } from '@dxos/display-name';
22
21
  import { useThemeContext } from '@dxos/react-ui';
23
22
  import { getColorForValue, inputSurface, mx } from '@dxos/react-ui-theme';
24
- import { type YText } from '@dxos/text-model';
25
23
 
26
24
  import { basicBundle, markdownBundle } from './extensions';
27
- import { defaultTheme, textTheme } from './themes';
25
+ import { defaultTheme, markdownTheme, textTheme } from './themes';
28
26
  import { type EditorModel } from '../../hooks';
29
27
  import { type ThemeStyles } from '../../styles';
30
28
 
@@ -58,9 +56,10 @@ export type TextEditorSlots = {
58
56
 
59
57
  export type TextEditorProps = {
60
58
  model: EditorModel;
59
+ readonly?: boolean;
60
+ editorMode?: EditorMode;
61
61
  extensions?: Extension[];
62
62
  slots?: TextEditorSlots;
63
- editorMode?: EditorMode;
64
63
  };
65
64
 
66
65
  /**
@@ -68,27 +67,32 @@ export type TextEditorProps = {
68
67
  * NOTE: Rather than adding properties, try to create extensions that can be reused.
69
68
  */
70
69
  export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
71
- ({ model, extensions = [], slots = defaultSlots, editorMode }, forwardedRef) => {
70
+ ({ model, readonly, editorMode, extensions = [], slots = defaultSlots }, forwardedRef) => {
72
71
  const { themeMode } = useThemeContext();
73
72
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
74
73
 
75
- // TODO(burdon): Factor out extension (remove logic from react-ui-editor).
76
- // const collaboration = useCollaboration(model, themeMode);
77
- const { provider, peer } = model;
74
+ const [root, setRoot] = useState<HTMLDivElement | null>(null);
75
+ const [state, setState] = useState<EditorState>();
76
+ const [view, setView] = useState<EditorView>();
77
+ useImperativeHandle(forwardedRef, () => ({ root, state, view }), [view, state, root]);
78
+
79
+ // TODO(burdon): Factor out?
80
+ const { awareness, peer } = model;
78
81
  useEffect(() => {
79
- if (provider && peer) {
80
- provider.awareness.setLocalStateField('user', {
82
+ if (awareness && peer) {
83
+ awareness.setLocalStateField('user', {
81
84
  name: peer.name ?? generateName(peer.id),
82
85
  color: getColorForValue({ value: peer.id, type: 'color' }),
83
86
  colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
84
87
  });
85
88
  }
86
- }, [provider, peer, themeMode]);
89
+ }, [awareness, peer, themeMode]);
87
90
 
88
- const [root, setRoot] = useState<HTMLDivElement | null>(null);
89
- const [state, setState] = useState<EditorState>();
90
- const [view, setView] = useState<EditorView>();
91
- useImperativeHandle(forwardedRef, () => ({ root, state, view }), [view, state, root]);
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
+ // ]);
92
96
 
93
97
  useEffect(() => {
94
98
  if (!root) {
@@ -98,17 +102,20 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
98
102
  const state = EditorState.create({
99
103
  doc: model.text(),
100
104
  extensions: [
105
+ readonly && EditorState.readOnly.of(readonly),
106
+
101
107
  // TODO(burdon): Factor out VIM mode?
102
108
  editorMode === 'vim' && vim(),
103
109
 
104
110
  // Theme.
111
+ // TODO(burdon): Make optional.
105
112
  EditorView.baseTheme(defaultTheme),
106
113
  EditorView.theme(slots?.editor?.theme ?? {}),
107
114
  // TODO(burdon): themeMode doesn't change in storybooks.
108
115
  EditorView.darkTheme.of(themeMode === 'dark'),
109
116
 
110
117
  // Storage and replication.
111
- yCollab(model.content as YText, model.provider?.awareness),
118
+ model.extension,
112
119
 
113
120
  // Custom.
114
121
  ...extensions,
@@ -119,7 +126,17 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
119
126
  // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
120
127
  // Ideally this should not be hit except when changing between text objects.
121
128
  view?.destroy();
122
- 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
+ );
123
140
  setState(state);
124
141
 
125
142
  return () => {
@@ -127,7 +144,7 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
127
144
  setView(undefined);
128
145
  setState(undefined);
129
146
  };
130
- }, [root, model.content, themeMode, editorMode]);
147
+ }, [root, model, themeMode, readonly, editorMode]);
131
148
 
132
149
  const handleKeyUp = useCallback(
133
150
  (event: KeyboardEvent) => {
@@ -160,15 +177,15 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
160
177
  },
161
178
  );
162
179
 
163
- // TODO(burdon): Set default text theme?
164
180
  export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
165
- ({ extensions = [], slots: _slots, ...props }, forwardedRef) => {
181
+ ({ readonly, extensions = [], slots: _slots, ...props }, forwardedRef) => {
166
182
  const { themeMode } = useThemeContext();
167
183
  const slots = defaultsDeep({}, _slots, defaultTextSlots);
168
184
  return (
169
185
  <BaseTextEditor
170
186
  ref={forwardedRef}
171
- extensions={[basicBundle({ themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
187
+ readonly={readonly}
188
+ extensions={[basicBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
172
189
  slots={slots}
173
190
  {...props}
174
191
  />
@@ -177,13 +194,14 @@ export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
177
194
  );
178
195
 
179
196
  export const MarkdownEditor = forwardRef<TextEditorRef, TextEditorProps>(
180
- ({ extensions = [], slots: _slots, ...props }, forwardedRef) => {
197
+ ({ readonly, extensions = [], slots: _slots, ...props }, forwardedRef) => {
181
198
  const { themeMode } = useThemeContext();
182
- const slots = defaultsDeep({}, _slots, defaultSlots);
199
+ const slots = defaultsDeep({}, _slots, defaultMarkdownSlots);
183
200
  return (
184
201
  <BaseTextEditor
185
202
  ref={forwardedRef}
186
- extensions={[markdownBundle({ themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
203
+ readonly={readonly}
204
+ extensions={[markdownBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
187
205
  slots={slots}
188
206
  {...props}
189
207
  />
@@ -203,3 +221,10 @@ export const defaultTextSlots: TextEditorSlots = {
203
221
  theme: textTheme,
204
222
  },
205
223
  };
224
+
225
+ export const defaultMarkdownSlots: TextEditorSlots = {
226
+ ...defaultSlots,
227
+ editor: {
228
+ theme: markdownTheme,
229
+ },
230
+ };
@@ -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
  ],
@@ -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
+ };