@dxos/react-ui-editor 0.3.10-main.92d1f2c → 0.3.10-main.9bbf73f

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 (51) hide show
  1. package/dist/lib/browser/index.mjs +381 -174
  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 +1 -1
  15. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +1 -1
  16. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts +24 -0
  17. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts.map +1 -0
  18. package/dist/types/src/components/TextEditor/extensions/index.d.ts +2 -0
  19. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +1 -1
  20. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts +8 -0
  21. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +1 -1
  22. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +1 -1
  23. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +1 -1
  24. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -1
  25. package/dist/types/src/hooks/useTextModel.d.ts +6 -0
  26. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  27. package/dist/types/src/hooks/yjs/yjs.stories.d.ts +1 -1
  28. package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +1 -1
  29. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  30. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  31. package/package.json +22 -20
  32. package/src/components/TextEditor/MarkdownEditor.stories.tsx +41 -24
  33. package/src/components/TextEditor/TextEditor.stories.tsx +12 -1
  34. package/src/components/TextEditor/TextEditor.tsx +18 -1
  35. package/src/components/TextEditor/extensions/basic.ts +4 -3
  36. package/src/components/TextEditor/extensions/blast.ts +14 -28
  37. package/src/components/TextEditor/extensions/code.ts +99 -0
  38. package/src/components/TextEditor/extensions/comments.ts +9 -8
  39. package/src/components/TextEditor/extensions/experimental.tsx +1 -1
  40. package/src/components/TextEditor/extensions/highlight.ts +128 -0
  41. package/src/components/TextEditor/extensions/index.ts +2 -0
  42. package/src/components/TextEditor/extensions/markdown/bundle.ts +17 -17
  43. package/src/components/TextEditor/extensions/markdown/highlight.ts +1 -0
  44. package/src/components/TextEditor/extensions/table.ts +2 -5
  45. package/src/components/TextEditor/extensions/tasklist.ts +1 -1
  46. package/src/components/TextEditor/themes/default.ts +43 -2
  47. package/src/hooks/automerge/automerge.stories.tsx +1 -0
  48. package/src/hooks/useTextModel.ts +9 -0
  49. package/src/hooks/yjs/yjs.stories.tsx +1 -1
  50. package/src/styles/markdown.ts +5 -4
  51. 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
  '',
@@ -96,11 +103,11 @@ const text = {
96
103
  table: str(
97
104
  '# Table',
98
105
  '',
99
- `| ${faker.lorem.word()} | ${faker.lorem.word()} | ${faker.lorem.word()} |`,
100
- '|---|---|---|',
101
- `| ${num()} | ${num()} | ${num()} |`,
102
- `| ${num()} | ${num()} | ${num()} |`,
103
- `| ${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)} |`,
104
111
  '', // TODO(burdon): Possible GFM parsing bug if no newline?
105
112
  ),
106
113
 
@@ -111,6 +118,8 @@ const text = {
111
118
  ),
112
119
 
113
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(
@@ -139,6 +148,7 @@ const document = str(
139
148
  '---',
140
149
  text.image,
141
150
  '',
151
+ text.footer,
142
152
  );
143
153
 
144
154
  const links = [
@@ -174,6 +184,7 @@ const onRender: LinkOptions['onRender'] = (el, url) => {
174
184
  );
175
185
  };
176
186
 
187
+ // TODO(burdon): Pass in model.
177
188
  const Story = ({
178
189
  text,
179
190
  automerge,
@@ -206,14 +217,15 @@ export default {
206
217
  };
207
218
 
208
219
  const extensions = [
209
- link({ onRender }),
210
- tooltip({ onHover }),
211
- tasklist(),
212
- table(),
213
- image(),
214
220
  autocomplete({
215
221
  onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
216
222
  }),
223
+ code(),
224
+ image(),
225
+ link({ onRender }),
226
+ table(),
227
+ tasklist(),
228
+ tooltip({ onHover }),
217
229
  ];
218
230
 
219
231
  export const Default = {
@@ -224,30 +236,30 @@ export const Readonly = {
224
236
  render: () => <Story text={document} extensions={extensions} readonly />,
225
237
  };
226
238
 
227
- export const Simple = {
228
- render: () => <Story text={document} />,
229
- };
230
-
231
239
  export const Tooltips = {
232
- render: () => <Story text={str(text.links, '')} extensions={[tooltip({ onHover })]} />,
240
+ render: () => <Story text={str(text.links, text.footer)} extensions={[tooltip({ onHover })]} />,
233
241
  };
234
242
 
235
243
  export const Links = {
236
- render: () => <Story text={str(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 />,
237
249
  };
238
250
 
239
251
  export const Table = {
240
- render: () => <Story text={str(text.table, '')} extensions={[table()]} />,
252
+ render: () => <Story text={str(text.table, text.footer)} extensions={[table()]} />,
241
253
  };
242
254
 
243
255
  export const Image = {
244
- render: () => <Story text={str(text.image, '', '')} readonly extensions={[image()]} />,
256
+ render: () => <Story text={str(text.image, text.footer)} readonly extensions={[image()]} />,
245
257
  };
246
258
 
247
259
  export const TaskList = {
248
260
  render: () => (
249
261
  <Story
250
- text={str(text.tasks, '', text.list)}
262
+ text={str(text.tasks, '', text.list, text.footer)}
251
263
  extensions={[
252
264
  tasklist(),
253
265
  listener({
@@ -263,7 +275,7 @@ export const TaskList = {
263
275
  export const Autocomplete = {
264
276
  render: () => (
265
277
  <Story
266
- text={str('# Autocomplete', '', '', '', '', '', '')}
278
+ text={str('# Autocomplete', '', 'Press CTRL-SPACE', text.footer)}
267
279
  extensions={[
268
280
  link({ onRender }),
269
281
  autocomplete({
@@ -279,7 +291,7 @@ const names = ['adam', 'alice', 'alison', 'bob', 'carol', 'charlie', 'sayuri', '
279
291
  export const Mention = {
280
292
  render: () => (
281
293
  <Story
282
- text={str('# Mention', '', '', '', '', '', '')}
294
+ text={str('# Mention', '', 'Type @...', text.footer)}
283
295
  extensions={[
284
296
  mention({
285
297
  onSearch: (text) => names.filter((name) => name.toLowerCase().startsWith(text.toLowerCase())),
@@ -293,14 +305,15 @@ const mark = () => `[^${PublicKey.random().toHex()}]`;
293
305
  export const Comments = {
294
306
  render: () => (
295
307
  <Story
296
- text={str(mark(), '', text.paragraphs, mark(), '', mark(), '')}
308
+ text={str(text.paragraphs, mark(), '', mark(), text.footer)}
297
309
  extensions={[
298
310
  comments({
299
311
  onCreate: () => PublicKey.random().toHex(),
300
312
  onUpdate: (info) => {
301
- console.log('update', info);
313
+ // console.log('update', info);
302
314
  },
303
315
  }),
316
+ highlight(),
304
317
  ]}
305
318
  />
306
319
  ),
@@ -321,7 +334,7 @@ export const Diagnostics = {
321
334
  };
322
335
 
323
336
  export const Demo = {
324
- render: () => <Story text={text.paragraphs} extensions={[demo()]} />,
337
+ render: () => <Story text={str(text.paragraphs, text.footer)} extensions={[demo()]} />,
325
338
  };
326
339
 
327
340
  export const Blast = {
@@ -348,3 +361,7 @@ export const Blast = {
348
361
  />
349
362
  ),
350
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
  };
@@ -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 () => {
@@ -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
 
@@ -36,7 +36,7 @@ export const defaultOptions: BlastOptions = {
36
36
  shakeIntensity: 5,
37
37
  };
38
38
 
39
- export const blast = (options: Partial<BlastOptions>): Extension => {
39
+ export const blast = (options: Partial<BlastOptions> = defaultOptions): Extension => {
40
40
  let blaster: Blaster | undefined;
41
41
  let last = 0;
42
42
 
@@ -77,39 +77,25 @@ export const blast = (options: Partial<BlastOptions>): Extension => {
77
77
  blaster.resize();
78
78
  }
79
79
 
80
- const current = update.view.state.selection.main.head;
80
+ const current = update.state.selection.main.head;
81
81
  if (current !== last) {
82
82
  last = current;
83
83
  // TODO(burdon): Null if end of line.
84
- const { element, point } = getPoint(update.view, current);
84
+ const { element, point } = getPoint(update.view, current - 1);
85
85
  if (element && point) {
86
86
  blaster.spawn({ element, point });
87
87
  }
88
88
  }
89
89
  }),
90
90
 
91
- // Document changed.
92
- StateField.define({
93
- create: () => null,
94
- update: (_value, transaction) => {
95
- if (blaster && transaction.docChanged) {
96
- // TODO(burdon): Only on delete.
97
- const view = EditorView.findFromDOM(blaster.node)!;
98
- const { element, point } = getPoint(view, transaction.selection!.main.head);
99
- if (element && point) {
100
- blaster.spawn({ element, point });
101
- }
102
- }
103
-
104
- return null;
105
- },
106
- }),
107
-
108
91
  keymap.of([
109
92
  {
110
- any: (view, event) => {
111
- if (blaster && event.key === 'Enter' && event.shiftKey) {
112
- 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
+ }
113
99
  }
114
100
 
115
101
  return false;
@@ -124,11 +110,11 @@ export const blast = (options: Partial<BlastOptions>): Extension => {
124
110
  //
125
111
 
126
112
  class Blaster {
113
+ private readonly _effect: Effect;
114
+
127
115
  _canvas: HTMLCanvasElement | undefined;
128
116
  _ctx: CanvasRenderingContext2D | undefined | null;
129
117
 
130
- private readonly _effect: Effect;
131
-
132
118
  _running = false;
133
119
  _lastTime: number | undefined;
134
120
  _shakeTime = 0;
@@ -156,7 +142,7 @@ class Blaster {
156
142
  this._canvas.style.position = 'absolute';
157
143
  this._canvas.style.zIndex = '0';
158
144
  this._canvas.style.pointerEvents = 'none';
159
- // this._canvas.style.border = '2px solid red';
145
+ // this._canvas.style.border = '2px dashed red';
160
146
 
161
147
  this._ctx = this._canvas.getContext('2d');
162
148
 
@@ -226,7 +212,7 @@ class Blaster {
226
212
  }
227
213
 
228
214
  shake = throttle<{ time: number }>(({ time }) => {
229
- this._shakeTime = this._shakeTimeMax;
215
+ this._shakeTime = this._shakeTimeMax || time;
230
216
  this._shakeTimeMax = time;
231
217
  }, 100);
232
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
+ };
@@ -57,7 +57,7 @@ type CommentsInfo = {
57
57
 
58
58
  export type CommentsOptions = {
59
59
  key?: string;
60
- onCreate?: () => string | void;
60
+ onCreate?: (from: number, to: number) => string | void;
61
61
  onUpdate?: (info: CommentsInfo) => void;
62
62
  };
63
63
 
@@ -108,13 +108,14 @@ export const comments = (options: CommentsOptions = {}): Extension => {
108
108
  key: options?.key ?? 'shift-meta-c',
109
109
  run: (view) => {
110
110
  // Insert footnote.
111
- const id = options.onCreate?.();
111
+ const { from, to, head } = view.state.selection.main;
112
+ const id = options.onCreate?.(from, to);
112
113
  if (id) {
113
- const pos = view.state.selection.main.head;
114
114
  const tag = `[^${id}]`;
115
+ // TODO(burdon): Add selection.
115
116
  view.dispatch({
116
- changes: { from: pos, insert: tag },
117
- selection: { anchor: pos + tag.length },
117
+ changes: { from: head, insert: tag },
118
+ selection: { anchor: head + tag.length },
118
119
  });
119
120
 
120
121
  return true;
@@ -134,14 +135,14 @@ export const comments = (options: CommentsOptions = {}): Extension => {
134
135
  return;
135
136
  }
136
137
 
137
- const view = update.view;
138
- const pos = view.state.selection.main.head;
138
+ const { view, state } = update;
139
+ const pos = state.selection.main.head;
139
140
 
140
141
  const decorations: { from: number; to: number; value: Decoration }[] = [];
141
142
  const rangeSet = view.plugin(bookmarks)?.bookmarks;
142
143
  let closest = Infinity;
143
144
  // TODO(burdon): Handle multiple visible ranges in large documents.
144
- const { from, to } = /* view.visibleRanges?.[0] ?? */ { from: 0, to: view.state.doc.length };
145
+ const { from, to } = /* view.visibleRanges?.[0] ?? */ { from: 0, to: state.doc.length };
145
146
  rangeSet?.between(from, to, (from, to, value) => {
146
147
  decorations.push({ from, to, value });
147
148
  const d = Math.min(Math.abs(pos - from), Math.abs(pos - to));
@@ -92,7 +92,7 @@ export function hyperLinkExtension() {
92
92
  }
93
93
  },
94
94
  {
95
- decorations: (view) => view.decorations,
95
+ decorations: (value) => value.decorations,
96
96
  eventHandlers: {
97
97
  // TODO(burdon): CLick to expand link.
98
98
  // https://discuss.codemirror.net/t/decorator-iteration-on-click-event/4226/2
@@ -0,0 +1,128 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension, StateEffect, StateField } from '@codemirror/state';
6
+ import { Decoration, EditorView } from '@codemirror/view';
7
+ import sortBy from 'lodash.sortby';
8
+ import { useEffect } from 'react';
9
+
10
+ import { type DocumentRange } from '../../../hooks';
11
+
12
+ // TODO(burdon): Detect click, or cursor proximity.
13
+
14
+ // TODO(burdon): Reconcile with theme.
15
+ const styles = EditorView.baseTheme({
16
+ '& .cm-highlight': {
17
+ backgroundColor: 'yellow', // TODO(burdon): Reconcile with tailwind theme.
18
+ },
19
+ '& .cm-highlight::before': {
20
+ content: ']',
21
+ },
22
+ '& .cm-highlight-active': {
23
+ backgroundColor: 'lime',
24
+ },
25
+ });
26
+
27
+ const marks = {
28
+ highlight: Decoration.mark({ class: 'cm-highlight' }),
29
+ HighlightActive: Decoration.mark({ class: 'cm-highlight-active' }),
30
+ };
31
+
32
+ type HighlightRange = DocumentRange & { active?: boolean };
33
+
34
+ // TODO(burdon): Make configurable to support multiple layers.
35
+ const setHighlights = StateEffect.define<HighlightRange[]>();
36
+
37
+ /**
38
+ * Update field set.
39
+ */
40
+ // TODO(burdon): Reconcile range from data model, vs. in-markdown characters.
41
+ export const useHighlights = (view: EditorView | undefined, ranges: HighlightRange[] = []) => {
42
+ useEffect(() => {
43
+ view?.dispatch({
44
+ effects: setHighlights.of(ranges),
45
+ });
46
+ }, [view, ranges]);
47
+ };
48
+
49
+ /**
50
+ * State field that tracks highlight ranges.
51
+ */
52
+ export const highlightStateField = StateField.define<HighlightRange[]>({
53
+ create: () => [],
54
+ update: (threads, tr) => {
55
+ for (const effect of tr.effects) {
56
+ if (effect.is(setHighlights)) {
57
+ return effect.value;
58
+ }
59
+ }
60
+
61
+ return threads;
62
+ },
63
+ });
64
+
65
+ /**
66
+ * Decorate ranges.
67
+ */
68
+ // TODO(burdon): Pass in field to make reusable (e.g., comments, search).
69
+ export const highlightDecorations = EditorView.decorations.compute([highlightStateField], (state) => {
70
+ const selectionRanges = state.field(highlightStateField);
71
+ const decorations =
72
+ sortBy(selectionRanges ?? [], (selection) => selection.from)?.flatMap((selection) => {
73
+ const range = { from: selection.from, to: selection.to + 1 };
74
+ if (selection.to > selection.from) {
75
+ if (selection.active) {
76
+ return marks.HighlightActive.range(range.from, range.to);
77
+ } else {
78
+ return marks.highlight.range(range.from, range.to);
79
+ }
80
+ } else {
81
+ return [];
82
+ }
83
+ }) ?? [];
84
+
85
+ return Decoration.set(decorations);
86
+ });
87
+
88
+ // TODO(burdon): Use current range to create comment.
89
+ // TODO(burdon): Allow/prevent overlapping?
90
+
91
+ /**
92
+ * Track cursor entering and existing ranges.
93
+ */
94
+ const listener = (options: HighlightOptions) => {
95
+ return EditorView.updateListener.of((update) => {
96
+ const { view, state } = update;
97
+ const { /* from, to, */ head } = state.selection.main;
98
+ const ranges = state.field(highlightStateField);
99
+
100
+ // TODO(burdon): Modify range if editing.
101
+ // console.log('::::', update);
102
+
103
+ // Set active.
104
+ let mod = false;
105
+ const newRanges = ranges.map((range) => {
106
+ const active = head >= range.from && head <= range.to;
107
+ if (active !== range.active) {
108
+ mod = true;
109
+ return { ...range, active };
110
+ } else {
111
+ return range;
112
+ }
113
+ });
114
+
115
+ if (mod) {
116
+ view.dispatch({ effects: setHighlights.of(newRanges) });
117
+ options?.onChange?.(newRanges.find((range) => range.active)?.id);
118
+ }
119
+ });
120
+ };
121
+
122
+ export type HighlightOptions = {
123
+ onChange?: (active?: string) => {};
124
+ };
125
+
126
+ export const highlight = (options: HighlightOptions = {}): Extension => {
127
+ return [highlightStateField, highlightDecorations, listener(options), styles];
128
+ };
@@ -5,8 +5,10 @@
5
5
  export * from './autocomplete';
6
6
  export * from './basic';
7
7
  export * from './blast';
8
+ export * from './code';
8
9
  export * from './demo';
9
10
  export * from './comments';
11
+ export * from './highlight';
10
12
  export * from './image';
11
13
  export * from './link';
12
14
  export * from './listener';