@dxos/react-ui-editor 0.3.10-main.cbe7692 → 0.3.10-main.cc91b2d

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 (56) hide show
  1. package/dist/lib/browser/index.mjs +654 -412
  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 +6 -3
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  7. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +3 -0
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/TextEditor/extensions/basic.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/extensions/blast.d.ts +1 -1
  11. package/dist/types/src/components/TextEditor/extensions/blast.d.ts.map +1 -1
  12. package/dist/types/src/components/TextEditor/extensions/code.d.ts +2 -0
  13. package/dist/types/src/components/TextEditor/extensions/code.d.ts.map +1 -0
  14. package/dist/types/src/components/TextEditor/extensions/comments.d.ts +2 -2
  15. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +1 -1
  16. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts +1 -1
  17. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts.map +1 -1
  18. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts +24 -0
  19. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts.map +1 -0
  20. package/dist/types/src/components/TextEditor/extensions/index.d.ts +2 -0
  21. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +1 -1
  22. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts +8 -0
  23. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +1 -1
  24. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts +3 -1
  25. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +1 -1
  26. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +1 -1
  27. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -1
  28. package/dist/types/src/hooks/useTextModel.d.ts +6 -0
  29. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  30. package/dist/types/src/hooks/yjs/yjs.stories.d.ts +1 -1
  31. package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +1 -1
  32. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  33. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  34. package/package.json +22 -20
  35. package/src/components/TextEditor/MarkdownEditor.stories.tsx +51 -32
  36. package/src/components/TextEditor/TextEditor.stories.tsx +12 -1
  37. package/src/components/TextEditor/TextEditor.tsx +21 -23
  38. package/src/components/TextEditor/extensions/basic.ts +4 -3
  39. package/src/components/TextEditor/extensions/blast.ts +27 -29
  40. package/src/components/TextEditor/extensions/code.ts +99 -0
  41. package/src/components/TextEditor/extensions/comments.ts +29 -46
  42. package/src/components/TextEditor/extensions/experimental.tsx +15 -36
  43. package/src/components/TextEditor/extensions/highlight.ts +128 -0
  44. package/src/components/TextEditor/extensions/image.ts +4 -5
  45. package/src/components/TextEditor/extensions/index.ts +2 -0
  46. package/src/components/TextEditor/extensions/link.ts +2 -2
  47. package/src/components/TextEditor/extensions/markdown/bundle.ts +35 -32
  48. package/src/components/TextEditor/extensions/markdown/highlight.ts +37 -11
  49. package/src/components/TextEditor/extensions/table.ts +44 -48
  50. package/src/components/TextEditor/extensions/tasklist.ts +9 -9
  51. package/src/components/TextEditor/themes/default.ts +43 -2
  52. package/src/hooks/automerge/automerge.stories.tsx +1 -0
  53. package/src/hooks/useTextModel.ts +9 -0
  54. package/src/hooks/yjs/yjs.stories.tsx +1 -1
  55. package/src/styles/markdown.ts +5 -4
  56. package/src/testing/replicator.ts +1 -0
@@ -32,9 +32,15 @@ import {
32
32
  blast,
33
33
  demo,
34
34
  defaultOptions,
35
+ highlight,
36
+ code,
35
37
  } from './extensions';
36
38
  import { useTextModel } from '../../hooks';
37
39
 
40
+ // Extensions:
41
+ // TODO(burdon): Table of contents.
42
+ // TODO(burdon): Front-matter
43
+
38
44
  const str = (...lines: string[]) => lines.join('\n');
39
45
 
40
46
  const num = () => faker.number.int({ min: 0, max: 9999 }).toLocaleString();
@@ -78,6 +84,7 @@ const text = {
78
84
  '```tsx',
79
85
  'const Component = () => {',
80
86
  ' const x = 100;',
87
+ '',
81
88
  ' return () => <div>Test</div>;',
82
89
  '};',
83
90
  '',
@@ -93,24 +100,26 @@ const text = {
93
100
  '',
94
101
  ),
95
102
 
96
- headings: str(
97
- ...[1, 2, 3, 4, 5, 6].map((level) => ['#'.repeat(level) + ` Heading ${level}`, faker.lorem.sentences(), '']).flat(),
98
- ),
99
-
100
- paragraphs: str(...faker.helpers.multiple(() => [faker.lorem.paragraph(), ''], { count: 3 }).flat()),
101
-
102
103
  table: str(
103
104
  '# Table',
104
105
  '',
105
- `| ${faker.lorem.word()} | ${faker.lorem.word()} | ${faker.lorem.word()} |`,
106
- '|---|---|---|',
107
- `| ${num()} | ${num()} | ${num()} |`,
108
- `| ${num()} | ${num()} | ${num()} |`,
109
- `| ${num()} | ${num()} | ${num()} |`,
106
+ `| ${faker.lorem.word().padStart(8)} | ${faker.lorem.word().padStart(8)} | ${faker.lorem.word().padStart(8)} |`,
107
+ '|----------|----------|----------|',
108
+ `| ${num().padStart(8)} | ${num().padStart(8)} | ${num().padStart(8)} |`,
109
+ `| ${num().padStart(8)} | ${num().padStart(8)} | ${num().padStart(8)} |`,
110
+ `| ${num().padStart(8)} | ${num().padStart(8)} | ${num().padStart(8)} |`,
110
111
  '', // TODO(burdon): Possible GFM parsing bug if no newline?
111
112
  ),
112
113
 
113
114
  image: str('# Image', '', '![dxos](https://pbs.twimg.com/profile_banners/1268328127673044992/1684766689/1500x500)'),
115
+
116
+ headings: str(
117
+ ...[1, 2, 3, 4, 5, 6].map((level) => ['#'.repeat(level) + ` Heading ${level}`, faker.lorem.sentences(), '']).flat(),
118
+ ),
119
+
120
+ paragraphs: str(...faker.helpers.multiple(() => [faker.lorem.paragraph(), ''], { count: 3 }).flat()),
121
+
122
+ footer: str('', '', '', '', '')
114
123
  };
115
124
 
116
125
  const document = str(
@@ -138,6 +147,8 @@ const document = str(
138
147
  text.table,
139
148
  '---',
140
149
  text.image,
150
+ '',
151
+ text.footer,
141
152
  );
142
153
 
143
154
  const links = [
@@ -173,6 +184,7 @@ const onRender: LinkOptions['onRender'] = (el, url) => {
173
184
  );
174
185
  };
175
186
 
187
+ // TODO(burdon): Pass in model.
176
188
  const Story = ({
177
189
  text,
178
190
  automerge,
@@ -205,14 +217,15 @@ export default {
205
217
  };
206
218
 
207
219
  const extensions = [
208
- link({ onRender }),
209
- tooltip({ onHover }),
210
- tasklist(),
211
- table(),
212
- image(),
213
220
  autocomplete({
214
221
  onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
215
222
  }),
223
+ code(),
224
+ image(),
225
+ link({ onRender }),
226
+ table(),
227
+ tasklist(),
228
+ tooltip({ onHover }),
216
229
  ];
217
230
 
218
231
  export const Default = {
@@ -223,32 +236,30 @@ export const Readonly = {
223
236
  render: () => <Story text={document} extensions={extensions} readonly />,
224
237
  };
225
238
 
226
- export const Simple = {
227
- render: () => <Story text={document} />,
228
- };
229
-
230
239
  export const Tooltips = {
231
- render: () => <Story text={text.links} extensions={[tooltip({ onHover })]} />,
240
+ render: () => <Story text={str(text.links, text.footer)} extensions={[tooltip({ onHover })]} />,
232
241
  };
233
242
 
234
243
  export const Links = {
235
- render: () => <Story text={text.links} extensions={[link({ onRender })]} />,
244
+ render: () => <Story text={str(text.links, text.footer)} extensions={[link({ onRender })]} />,
245
+ };
246
+
247
+ export const Code = {
248
+ render: () => <Story text={str(text.code, text.footer)} extensions={[code()]} readonly />,
236
249
  };
237
250
 
238
251
  export const Table = {
239
- render: () => (
240
- <Story text={str('This is a table', '', text.table, '', text.table, '', '')} readonly extensions={[table()]} />
241
- ),
252
+ render: () => <Story text={str(text.table, text.footer)} extensions={[table()]} />,
242
253
  };
243
254
 
244
255
  export const Image = {
245
- render: () => <Story text={str('This is an image', '', text.image, '', '')} readonly extensions={[image()]} />,
256
+ render: () => <Story text={str(text.image, text.footer)} readonly extensions={[image()]} />,
246
257
  };
247
258
 
248
259
  export const TaskList = {
249
260
  render: () => (
250
261
  <Story
251
- text={str(text.tasks, '', text.list)}
262
+ text={str(text.tasks, '', text.list, text.footer)}
252
263
  extensions={[
253
264
  tasklist(),
254
265
  listener({
@@ -264,7 +275,7 @@ export const TaskList = {
264
275
  export const Autocomplete = {
265
276
  render: () => (
266
277
  <Story
267
- text={str('# Autocomplete', '', '', '', '', '', '')}
278
+ text={str('# Autocomplete', '', 'Press CTRL-SPACE', text.footer)}
268
279
  extensions={[
269
280
  link({ onRender }),
270
281
  autocomplete({
@@ -280,7 +291,7 @@ const names = ['adam', 'alice', 'alison', 'bob', 'carol', 'charlie', 'sayuri', '
280
291
  export const Mention = {
281
292
  render: () => (
282
293
  <Story
283
- text={str('# Mention', '', '', '', '', '', '')}
294
+ text={str('# Mention', '', 'Type @...', text.footer)}
284
295
  extensions={[
285
296
  mention({
286
297
  onSearch: (text) => names.filter((name) => name.toLowerCase().startsWith(text.toLowerCase())),
@@ -294,14 +305,15 @@ const mark = () => `[^${PublicKey.random().toHex()}]`;
294
305
  export const Comments = {
295
306
  render: () => (
296
307
  <Story
297
- text={str(mark(), '', text.paragraphs, mark(), '', mark(), '')}
308
+ text={str(text.paragraphs, mark(), '', mark(), text.footer)}
298
309
  extensions={[
299
310
  comments({
300
311
  onCreate: () => PublicKey.random().toHex(),
301
312
  onUpdate: (info) => {
302
- console.log('update', info);
313
+ // console.log('update', info);
303
314
  },
304
315
  }),
316
+ highlight(),
305
317
  ]}
306
318
  />
307
319
  ),
@@ -322,7 +334,7 @@ export const Diagnostics = {
322
334
  };
323
335
 
324
336
  export const Demo = {
325
- render: () => <Story text={text.paragraphs} extensions={[demo()]} />,
337
+ render: () => <Story text={str(text.paragraphs, text.footer)} extensions={[demo()]} />,
326
338
  };
327
339
 
328
340
  export const Blast = {
@@ -330,6 +342,9 @@ export const Blast = {
330
342
  <Story
331
343
  text={str(text.paragraphs, text.code, text.paragraphs)}
332
344
  extensions={[
345
+ demo({
346
+ items: localStorage.getItem('dxos.composer.extension.demo')?.split(','),
347
+ }),
333
348
  blast(
334
349
  defaultsDeep(
335
350
  {
@@ -346,3 +361,7 @@ export const Blast = {
346
361
  />
347
362
  ),
348
363
  };
364
+
365
+ export const NoExtensions = {
366
+ render: () => <Story text={document} />,
367
+ };
@@ -57,6 +57,17 @@ export default {
57
57
  };
58
58
 
59
59
  export const Default = {
60
+ render: () => (
61
+ <Story
62
+ slots={defaultsDeep(
63
+ { editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
64
+ defaultSlots,
65
+ )}
66
+ />
67
+ ),
68
+ };
69
+
70
+ export const Text = {
60
71
  render: () => (
61
72
  <Story
62
73
  text={initialText}
@@ -71,12 +82,12 @@ export const Default = {
71
82
  export const Automerge = {
72
83
  render: () => (
73
84
  <Story
85
+ automerge
74
86
  text={initialText}
75
87
  slots={defaultsDeep(
76
88
  { editor: { theme: textTheme, placeholder: 'Enter text...' } } satisfies TextEditorSlots,
77
89
  defaultSlots,
78
90
  )}
79
- automerge
80
91
  />
81
92
  ),
82
93
  };
@@ -21,7 +21,7 @@ import { generateName } from '@dxos/display-name';
21
21
  import { useThemeContext } from '@dxos/react-ui';
22
22
  import { getColorForValue, inputSurface, mx } from '@dxos/react-ui-theme';
23
23
 
24
- import { basicBundle, demo, markdownBundle } from './extensions';
24
+ import { basicBundle, markdownBundle } from './extensions';
25
25
  import { defaultTheme, markdownTheme, textTheme } from './themes';
26
26
  import { type EditorModel } from '../../hooks';
27
27
  import { type ThemeStyles } from '../../styles';
@@ -88,6 +88,12 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
88
88
  }
89
89
  }, [awareness, peer, themeMode]);
90
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
+
91
97
  useEffect(() => {
92
98
  if (!root) {
93
99
  return;
@@ -102,6 +108,7 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
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.
@@ -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 () => {
@@ -160,17 +177,6 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
160
177
  },
161
178
  );
162
179
 
163
- // TODO(burdon): Allow plugins to set extensions (factory).
164
- const maybeDebug = (): Extension => {
165
- // TODO(burdon): Parse JSON script format (with key bindings?)
166
- const items = localStorage.getItem('dxos.composer.demo');
167
- if (items) {
168
- return demo({ items: items.split(',') });
169
- }
170
-
171
- return [];
172
- };
173
-
174
180
  export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
175
181
  ({ readonly, extensions = [], slots: _slots, ...props }, forwardedRef) => {
176
182
  const { themeMode } = useThemeContext();
@@ -179,11 +185,7 @@ export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
179
185
  <BaseTextEditor
180
186
  ref={forwardedRef}
181
187
  readonly={readonly}
182
- extensions={[
183
- basicBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }),
184
- maybeDebug(),
185
- ...extensions,
186
- ]}
188
+ extensions={[basicBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
187
189
  slots={slots}
188
190
  {...props}
189
191
  />
@@ -199,11 +201,7 @@ export const MarkdownEditor = forwardRef<TextEditorRef, TextEditorProps>(
199
201
  <BaseTextEditor
200
202
  ref={forwardedRef}
201
203
  readonly={readonly}
202
- extensions={[
203
- markdownBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }),
204
- maybeDebug(),
205
- ...extensions,
206
- ]}
204
+ extensions={[markdownBundle({ readonly, themeMode, placeholder: slots?.editor?.placeholder }), ...extensions]}
207
205
  slots={slots}
208
206
  {...props}
209
207
  />
@@ -6,7 +6,7 @@ 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
 
@@ -18,12 +18,13 @@ export type BasicBundleOptions = {
18
18
 
19
19
  export const basicBundle = ({ readonly, themeMode, placeholder: _placeholder }: BasicBundleOptions): Extension[] =>
20
20
  [
21
+ EditorView.lineWrapping,
22
+
21
23
  // TODO(burdon): Compare with minimalSetup.
22
24
  // https://codemirror.net/docs/ref/#codemirror.minimalSetup
23
25
  bracketMatching(),
24
26
  closeBrackets(),
25
- EditorView.lineWrapping,
26
-
27
+ drawSelection(),
27
28
  _placeholder && placeholder(_placeholder),
28
29
 
29
30
  // TODO(burdon): Is this required?
@@ -5,7 +5,7 @@
5
5
  // https://github.com/chinchang/code-blast-codemirror/blob/master/code-blast.js
6
6
  //
7
7
 
8
- import { type Extension, StateField } from '@codemirror/state';
8
+ import { type Extension } from '@codemirror/state';
9
9
  import { EditorView, keymap } from '@codemirror/view';
10
10
  import defaultsDeep from 'lodash.defaultsdeep';
11
11
 
@@ -25,6 +25,7 @@ export type BlastOptions = {
25
25
  };
26
26
 
27
27
  export const defaultOptions: BlastOptions = {
28
+ effect: 2,
28
29
  maxParticles: 200,
29
30
  particleGravity: 0.08,
30
31
  particleAlphaFadeout: 0.996,
@@ -35,7 +36,7 @@ export const defaultOptions: BlastOptions = {
35
36
  shakeIntensity: 5,
36
37
  };
37
38
 
38
- export const blast = (options: Partial<BlastOptions>): Extension => {
39
+ export const blast = (options: Partial<BlastOptions> = defaultOptions): Extension => {
39
40
  let blaster: Blaster | undefined;
40
41
  let last = 0;
41
42
 
@@ -58,46 +59,43 @@ export const blast = (options: Partial<BlastOptions>): Extension => {
58
59
  blaster.destroy();
59
60
  }
60
61
 
61
- blaster = new Blaster(update.view.scrollDOM, defaultsDeep({}, options, defaultOptions));
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
+ );
62
74
  blaster.initialize();
63
75
  blaster.start(); // TODO(burdon): Stop/clean-up.
64
76
  } else {
65
77
  blaster.resize();
66
78
  }
67
79
 
68
- const current = update.view.state.selection.main.head;
80
+ const current = update.state.selection.main.head;
69
81
  if (current !== last) {
70
82
  last = current;
71
83
  // TODO(burdon): Null if end of line.
72
- const { element, point } = getPoint(update.view, current);
84
+ const { element, point } = getPoint(update.view, current - 1);
73
85
  if (element && point) {
74
86
  blaster.spawn({ element, point });
75
87
  }
76
88
  }
77
89
  }),
78
90
 
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
91
  keymap.of([
97
92
  {
98
- any: (view, event) => {
99
- if (blaster && event.key === 'Enter' && event.shiftKey) {
100
- blaster.shake({ time: 0.4 });
93
+ any: (_, event) => {
94
+ if (blaster) {
95
+ if (event.key === 'Enter' && event.shiftKey) {
96
+ blaster.shake({ time: 0.8 });
97
+ return true;
98
+ }
101
99
  }
102
100
 
103
101
  return false;
@@ -112,11 +110,11 @@ export const blast = (options: Partial<BlastOptions>): Extension => {
112
110
  //
113
111
 
114
112
  class Blaster {
113
+ private readonly _effect: Effect;
114
+
115
115
  _canvas: HTMLCanvasElement | undefined;
116
116
  _ctx: CanvasRenderingContext2D | undefined | null;
117
117
 
118
- private readonly _effect: Effect;
119
-
120
118
  _running = false;
121
119
  _lastTime: number | undefined;
122
120
  _shakeTime = 0;
@@ -144,7 +142,7 @@ class Blaster {
144
142
  this._canvas.style.position = 'absolute';
145
143
  this._canvas.style.zIndex = '0';
146
144
  this._canvas.style.pointerEvents = 'none';
147
- // this._canvas.style.border = '2px solid red';
145
+ // this._canvas.style.border = '2px dashed red';
148
146
 
149
147
  this._ctx = this._canvas.getContext('2d');
150
148
 
@@ -214,7 +212,7 @@ class Blaster {
214
212
  }
215
213
 
216
214
  shake = throttle<{ time: number }>(({ time }) => {
217
- this._shakeTime = this._shakeTimeMax;
215
+ this._shakeTime = this._shakeTimeMax || time;
218
216
  this._shakeTimeMax = time;
219
217
  }, 100);
220
218
 
@@ -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
+ };