@dxos/react-ui-editor 0.4.8-main.8cb190e → 0.4.8-main.8ec301e

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 (57) hide show
  1. package/dist/lib/browser/index.mjs +527 -305
  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/TextEditor.d.ts +2 -2
  5. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +2 -1
  7. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/Toolbar/Toolbar.d.ts +11 -5
  9. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  10. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +4 -2
  11. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  12. package/dist/types/src/extensions/automerge/automerge.stories.d.ts +1 -0
  13. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  14. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  15. package/dist/types/src/extensions/factories.d.ts +3 -0
  16. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  17. package/dist/types/src/extensions/index.d.ts +1 -0
  18. package/dist/types/src/extensions/index.d.ts.map +1 -1
  19. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  20. package/dist/types/src/extensions/markdown/formatting.d.ts +7 -4
  21. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  22. package/dist/types/src/extensions/markdown/image.d.ts +6 -0
  23. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  24. package/dist/types/src/extensions/modes.d.ts +2 -1
  25. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  26. package/dist/types/src/extensions/state.d.ts +20 -0
  27. package/dist/types/src/extensions/state.d.ts.map +1 -0
  28. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  29. package/dist/types/src/hooks/useDocAccessor.d.ts +4 -2
  30. package/dist/types/src/hooks/useDocAccessor.d.ts.map +1 -1
  31. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  32. package/dist/types/src/hooks/useTextEditor.stories.d.ts +1 -0
  33. package/dist/types/src/hooks/useTextEditor.stories.d.ts.map +1 -1
  34. package/dist/types/src/themes/default.d.ts.map +1 -1
  35. package/dist/types/src/translations.d.ts +1 -0
  36. package/dist/types/src/translations.d.ts.map +1 -1
  37. package/package.json +26 -24
  38. package/src/components/TextEditor/TextEditor.stories.tsx +16 -4
  39. package/src/components/TextEditor/TextEditor.tsx +13 -12
  40. package/src/components/Toolbar/Toolbar.stories.tsx +23 -24
  41. package/src/components/Toolbar/Toolbar.tsx +101 -35
  42. package/src/extensions/automerge/automerge.ts +4 -4
  43. package/src/extensions/comments.ts +2 -2
  44. package/src/extensions/factories.ts +30 -7
  45. package/src/extensions/index.ts +1 -0
  46. package/src/extensions/markdown/bundle.ts +8 -11
  47. package/src/extensions/markdown/formatting.test.ts +13 -13
  48. package/src/extensions/markdown/formatting.ts +95 -87
  49. package/src/extensions/markdown/image.ts +6 -0
  50. package/src/extensions/modes.ts +11 -2
  51. package/src/extensions/state.ts +90 -0
  52. package/src/hooks/useActionHandler.ts +5 -1
  53. package/src/hooks/useDocAccessor.ts +8 -2
  54. package/src/hooks/useTextEditor.stories.tsx +3 -3
  55. package/src/hooks/useTextEditor.ts +3 -0
  56. package/src/themes/default.ts +24 -8
  57. package/src/translations.ts +1 -0
@@ -60,16 +60,16 @@ export const automerge = (accessor: DocAccessor): Extension => {
60
60
  ViewPlugin.fromClass(
61
61
  class {
62
62
  constructor(private readonly _view: EditorView) {
63
- accessor.handle.addListener('change', this._handleChange.bind(this));
63
+ accessor.handle.addListener('change', this._handleChange);
64
64
  }
65
65
 
66
66
  destroy() {
67
- accessor.handle.removeListener('change', this._handleChange.bind(this));
67
+ accessor.handle.removeListener('change', this._handleChange);
68
68
  }
69
69
 
70
- _handleChange() {
70
+ readonly _handleChange = () => {
71
71
  syncer.reconcile(this._view, false);
72
- }
72
+ };
73
73
  },
74
74
  ),
75
75
 
@@ -508,14 +508,14 @@ export const focusComment = (view: EditorView, id: string, center = true) => {
508
508
  /**
509
509
  * Update comments state field.
510
510
  */
511
- export const useComments = (view: EditorView | null | undefined, id: string, comments: Comment[] = []) => {
511
+ export const useComments = (view: EditorView | null | undefined, id: string, comments?: Comment[]) => {
512
512
  useEffect(() => {
513
513
  if (view) {
514
514
  // Check same document.
515
515
  // NOTE: Hook might be called before editor state is updated.
516
516
  if (id === view.state.facet(documentId)) {
517
517
  view.dispatch({
518
- effects: setComments.of({ id, comments }),
518
+ effects: setComments.of({ id, comments: comments ?? [] }),
519
519
  });
520
520
  }
521
521
  }
@@ -2,9 +2,10 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { closeBrackets } from '@codemirror/autocomplete';
6
- import { history } from '@codemirror/commands';
5
+ import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
6
+ import { history, historyKeymap, indentWithTab, standardKeymap } from '@codemirror/commands';
7
7
  import { bracketMatching } from '@codemirror/language';
8
+ import { searchKeymap } from '@codemirror/search';
8
9
  import { EditorState, type Extension } from '@codemirror/state';
9
10
  import {
10
11
  EditorView,
@@ -12,6 +13,8 @@ import {
12
13
  drawSelection,
13
14
  dropCursor,
14
15
  highlightActiveLine,
16
+ highlightSpecialChars,
17
+ keymap,
15
18
  lineNumbers,
16
19
  placeholder,
17
20
  scrollPastEnd,
@@ -32,8 +35,6 @@ import { awareness, SpaceAwarenessProvider } from './awareness';
32
35
  import { type ThemeStyles } from '../styles';
33
36
  import { defaultTheme } from '../themes';
34
37
 
35
- // TODO(burdon): Move into extensions folder.
36
-
37
38
  //
38
39
  // Basic
39
40
  //
@@ -41,8 +42,8 @@ import { defaultTheme } from '../themes';
41
42
  /**
42
43
  * https://codemirror.net/docs/extensions
43
44
  * https://github.com/codemirror/basic-setup
45
+ * https://github.com/codemirror/basic-setup/blob/main/src/codemirror.ts
44
46
  */
45
- // TODO(burdon): Reconcile with createMarkdownExtensions.
46
47
  export type BasicExtensionsOptions = {
47
48
  allowMultipleSelections?: boolean;
48
49
  bracketMatching?: boolean;
@@ -53,21 +54,25 @@ export type BasicExtensionsOptions = {
53
54
  editable?: boolean;
54
55
  highlightActiveLine?: boolean;
55
56
  history?: boolean;
57
+ indentWithTab?: boolean;
56
58
  lineNumbers?: boolean;
57
59
  lineWrapping?: boolean;
58
60
  placeholder?: string;
59
61
  readonly?: boolean;
62
+ search?: boolean;
60
63
  scrollPastEnd?: boolean;
61
64
  tabSize?: number;
62
65
  };
63
66
 
64
67
  const defaults: BasicExtensionsOptions = {
68
+ allowMultipleSelections: true,
65
69
  bracketMatching: true,
66
70
  closeBrackets: true,
67
71
  drawSelection: true,
68
72
  editable: true,
69
73
  history: true,
70
74
  lineWrapping: true,
75
+ search: true,
71
76
  };
72
77
 
73
78
  export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extension => {
@@ -85,6 +90,7 @@ export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extensio
85
90
  props.dropCursor && dropCursor(),
86
91
  props.drawSelection && drawSelection(),
87
92
  props.highlightActiveLine && highlightActiveLine(),
93
+ highlightSpecialChars(),
88
94
  props.history && history(),
89
95
  props.lineNumbers && lineNumbers(),
90
96
  props.lineWrapping && EditorView.lineWrapping,
@@ -92,6 +98,24 @@ export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extensio
92
98
  props.readonly && [EditorState.readOnly.of(true), EditorView.editable.of(false)],
93
99
  props.scrollPastEnd && scrollPastEnd(),
94
100
  props.tabSize && EditorState.tabSize.of(props.tabSize),
101
+
102
+ // https://codemirror.net/docs/ref/#view.KeyBinding
103
+ keymap.of(
104
+ [
105
+ // https://codemirror.net/docs/ref/#commands.standardKeymap
106
+ ...standardKeymap,
107
+
108
+ // https://codemirror.net/docs/ref/#commands.indentWithTab
109
+ props.indentWithTab && indentWithTab,
110
+
111
+ // https://codemirror.net/docs/ref/#autocomplete.closeBracketsKeymap
112
+ ...(props.closeBrackets ? closeBracketsKeymap : []),
113
+ // https://codemirror.net/docs/ref/#commands.historyKeymap
114
+ ...(props.history ? historyKeymap : []),
115
+ // https://codemirror.net/docs/ref/#search.searchKeymap
116
+ ...(props.search ? searchKeymap : []),
117
+ ].filter(isNotFalsy),
118
+ ),
95
119
  ].filter(isNotFalsy);
96
120
  };
97
121
 
@@ -135,12 +159,11 @@ export const createThemeExtensions = ({ theme, themeMode, slots: _slots }: Theme
135
159
 
136
160
  export type DataExtensionsProps = {
137
161
  id: string;
138
- text: DocAccessor; // TODO(burdon): Rename content.
162
+ text: DocAccessor;
139
163
  space?: Space;
140
164
  identity?: Identity | null;
141
165
  };
142
166
 
143
- // TODO(burdon): Factor out automerge defs and extension (not hook).
144
167
  // TODO(burdon): Move out of react-ui-editor (remove echo deps).
145
168
  export const createDataExtensions = ({ id, text, space, identity }: DataExtensionsProps): Extension[] => {
146
169
  const extensions: Extension[] = [automerge(text)];
@@ -18,5 +18,6 @@ export * from './listener';
18
18
  export * from './markdown';
19
19
  export * from './mention';
20
20
  export * from './modes';
21
+ export * from './state';
21
22
  export * from './types';
22
23
  export * from './typewriter';
@@ -2,12 +2,12 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { closeBracketsKeymap } from '@codemirror/autocomplete';
6
- import { historyKeymap, indentWithTab, standardKeymap } from '@codemirror/commands';
5
+ import { completionKeymap } from '@codemirror/autocomplete';
6
+ import { defaultKeymap, indentWithTab } from '@codemirror/commands';
7
7
  import { markdownLanguage, markdown } from '@codemirror/lang-markdown';
8
8
  import { defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
9
9
  import { languages } from '@codemirror/language-data';
10
- import { searchKeymap } from '@codemirror/search';
10
+ import { lintKeymap } from '@codemirror/lint';
11
11
  import { type Extension } from '@codemirror/state';
12
12
  import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
13
13
  import { keymap } from '@codemirror/view';
@@ -60,14 +60,11 @@ export const createMarkdownExtensions = ({ themeMode }: MarkdownBundleOptions =
60
60
  keymap.of([
61
61
  // https://codemirror.net/docs/ref/#commands.indentWithTab
62
62
  indentWithTab,
63
- // https://codemirror.net/docs/ref/#autocomplete.closeBracketsKeymap
64
- ...closeBracketsKeymap,
65
- // https://codemirror.net/docs/ref/#commands.historyKeymap
66
- ...historyKeymap,
67
- // https://codemirror.net/docs/ref/#search.searchKeymap
68
- ...searchKeymap,
69
- // https://codemirror.net/docs/ref/#commands.standardKeymap
70
- ...standardKeymap,
63
+
64
+ // https://codemirror.net/docs/ref/#commands.defaultKeymap
65
+ ...defaultKeymap,
66
+ ...completionKeymap,
67
+ ...lintKeymap,
71
68
  ]),
72
69
  ];
73
70
  };
@@ -9,20 +9,20 @@ import { expect } from 'chai';
9
9
  import { describe, test } from '@dxos/test';
10
10
 
11
11
  import {
12
- setHeading,
12
+ addBlockquote,
13
+ addCodeblock,
14
+ addLink,
15
+ addList,
13
16
  addStyle,
17
+ getFormatting,
14
18
  removeStyle,
15
- addLink,
16
19
  removeLink,
17
- addList,
18
20
  removeList,
19
- addBlockquote,
20
21
  removeBlockquote,
21
- addCodeblock,
22
22
  removeCodeblock,
23
+ setHeading,
23
24
  Inline,
24
25
  List,
25
- getFormatting,
26
26
  type Formatting,
27
27
  } from './formatting';
28
28
 
@@ -204,22 +204,22 @@ describe('removeStyle', () => {
204
204
  });
205
205
 
206
206
  describe('addLink', () => {
207
- testCommand('adds a link', '{}', addLink, '[]({})');
207
+ testCommand('adds a link', '{}', addLink(), '[]({})');
208
208
 
209
- testCommand('adds a link around text', 'hello {world}', addLink, 'hello [world]({})');
209
+ testCommand('adds a link around text', 'hello {world}', addLink(), 'hello [world]({})');
210
210
 
211
- testCommand('clears existing links', '[hello {world}](foo)', addLink, 'hello [world]({})');
211
+ testCommand('clears existing links', '[hello {world}](foo)', addLink(), 'hello [world]({})');
212
212
 
213
- testCommand('does nothing across blocks', '{one\n\ntwo}', addLink, null);
213
+ testCommand('does nothing across blocks', '{one\n\ntwo}', addLink(), null);
214
214
 
215
- testCommand('does nothing in code blocks', '```\n{one}\n```', addLink, null);
215
+ testCommand('does nothing in code blocks', '```\n{one}\n```', addLink(), null);
216
216
 
217
- testCommand('patches up overlapping styles before', '*foo {bar* baz}', addLink, '*foo* [*bar* baz]({})');
217
+ testCommand('patches up overlapping styles before', '*foo {bar* baz}', addLink(), '*foo* [*bar* baz]({})');
218
218
 
219
219
  testCommand(
220
220
  'patches up overlapping styles after',
221
221
  'one {two ~~three} four~~',
222
- addLink,
222
+ addLink(),
223
223
  'one [two ~~three~~]({}) ~~four~~',
224
224
  );
225
225
  });
@@ -15,7 +15,7 @@ import {
15
15
  } from '@codemirror/state';
16
16
  import { EditorView, keymap } from '@codemirror/view';
17
17
  import { type SyntaxNodeRef, type SyntaxNode } from '@lezer/common';
18
- import { useState, useMemo } from 'react';
18
+ import { useMemo, useState } from 'react';
19
19
 
20
20
  // Markdown refs:
21
21
  // https://github.github.com/gfm
@@ -56,7 +56,7 @@ export type Formatting = {
56
56
  listStyle: null | 'ordered' | 'bullet' | 'task';
57
57
  };
58
58
 
59
- export const compareFormatting = (a: Formatting, b: Formatting) =>
59
+ export const formattingEquals = (a: Formatting, b: Formatting) =>
60
60
  a.blockType === b.blockType &&
61
61
  a.strong === b.strong &&
62
62
  a.emphasis === b.emphasis &&
@@ -470,90 +470,94 @@ export const removeLink: StateCommand = ({ state, dispatch }) => {
470
470
  };
471
471
 
472
472
  // Add link markup around the selection
473
- export const addLink: StateCommand = ({ state, dispatch }) => {
474
- const changes = state.changeByRange((range) => {
475
- let { from, to } = range;
476
- const cutStyles: SyntaxNode[] = [];
477
- let okay: boolean | null = null;
478
- // Check whether this range is in a position where a link makes sense
479
- syntaxTree(state).iterate({
480
- from,
481
- to,
482
- enter: (node) => {
483
- if (Object.hasOwn(Textblocks, node.name)) {
484
- // If the selection spans multiple textblocks or is in a
485
- // code block, abort
486
- okay =
487
- Textblocks[node.name] !== 'codeblock' &&
488
- from >= blockContentStart(node) &&
489
- to <= blockContentEnd(node, state.doc);
490
- } else if (Object.hasOwn(InlineMarker, node.name)) {
491
- // Look for inline styles that partially overlap the range.
492
- // Expand the range over them if they start directly
493
- // outside, otherwise mark them for later
494
- const sNode = node.node;
495
- if (node.from < from && node.to <= to) {
496
- if (sNode.firstChild!.to === from) {
497
- from = node.from;
498
- } else {
499
- cutStyles.push(sNode);
500
- }
501
- } else if (node.from >= from && node.to > to) {
502
- if (sNode.lastChild!.from === to) {
503
- to = node.to;
504
- } else {
505
- cutStyles.push(sNode);
473
+ export const addLink =
474
+ ({ url, image }: { url?: string; image?: boolean } = {}): StateCommand =>
475
+ ({ state, dispatch }) => {
476
+ const changes = state.changeByRange((range) => {
477
+ let { from, to } = range;
478
+ const cutStyles: SyntaxNode[] = [];
479
+ let okay: boolean | null = null;
480
+ // Check whether this range is in a position where a link makes sense.
481
+ syntaxTree(state).iterate({
482
+ from,
483
+ to,
484
+ enter: (node) => {
485
+ if (Object.hasOwn(Textblocks, node.name)) {
486
+ // If the selection spans multiple textblocks or is in a code block, abort.
487
+ okay =
488
+ Textblocks[node.name] !== 'codeblock' &&
489
+ from >= blockContentStart(node) &&
490
+ to <= blockContentEnd(node, state.doc);
491
+ } else if (Object.hasOwn(InlineMarker, node.name)) {
492
+ // Look for inline styles that partially overlap the range.
493
+ // Expand the range over them if they start directly outside, otherwise mark them for later.
494
+ const sNode = node.node;
495
+ if (node.from < from && node.to <= to) {
496
+ if (sNode.firstChild!.to === from) {
497
+ from = node.from;
498
+ } else {
499
+ cutStyles.push(sNode);
500
+ }
501
+ } else if (node.from >= from && node.to > to) {
502
+ if (sNode.lastChild!.from === to) {
503
+ to = node.to;
504
+ } else {
505
+ cutStyles.push(sNode);
506
+ }
506
507
  }
507
508
  }
508
- }
509
- },
510
- });
509
+ },
510
+ });
511
511
 
512
- if (okay === null) {
513
- // No textblock found around selection. Check if the rest of the line is empty.
514
- const line = state.doc.lineAt(from);
515
- okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
516
- }
517
- if (!okay) {
518
- return { range };
519
- }
512
+ if (okay === null) {
513
+ // No textblock found around selection. Check if the rest of the line is empty.
514
+ const line = state.doc.lineAt(from);
515
+ okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
516
+ }
517
+ if (!okay) {
518
+ return { range };
519
+ }
520
520
 
521
- const changes: ChangeSpec[] = [];
522
- // Some changes must be moved to end of change array so that they are applied in the right order
523
- const changesAfter: ChangeSpec[] = [];
524
- // Clear existing links.
525
- removeLinkInner(from, to, changesAfter, state);
526
- let cursorOffset = 1;
527
- // Close and reopen inline styles that partially overlap the range.
528
- for (const style of cutStyles) {
529
- const type = InlineMarker[style.name];
530
- const mark = inlineMarkerText(type);
531
- if (style.from < from) {
532
- // Extends before.
533
- changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
534
- changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
535
- } else {
536
- changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
537
- const after = skipSpaces(to, state.doc, 1);
538
- if (after === to) {
539
- cursorOffset += mark.length;
521
+ const changes: ChangeSpec[] = [];
522
+ // Some changes must be moved to end of change array so that they are applied in the right order
523
+ const changesAfter: ChangeSpec[] = [];
524
+ // Clear existing links.
525
+ removeLinkInner(from, to, changesAfter, state);
526
+ let cursorOffset = 1;
527
+ // Close and reopen inline styles that partially overlap the range.
528
+ for (const style of cutStyles) {
529
+ const type = InlineMarker[style.name];
530
+ const mark = inlineMarkerText(type);
531
+ if (style.from < from) {
532
+ // Extends before.
533
+ changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
534
+ changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
535
+ } else {
536
+ changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
537
+ const after = skipSpaces(to, state.doc, 1);
538
+ if (after === to) {
539
+ cursorOffset += mark.length;
540
+ }
541
+ changesAfter.push({ from: after, insert: mark });
540
542
  }
541
- changesAfter.push({ from: after, insert: mark });
542
543
  }
544
+
545
+ // Add the link markup.
546
+ changes.push({ from, insert: image ? '![' : '[' }, { from: to, insert: `](${url ?? ''})` });
547
+ const changeSet = state.changes(changes.concat(changesAfter));
548
+ // Put the cursor between the title or parenthesis.
549
+ return {
550
+ changes: changeSet,
551
+ range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset - (url ? url.length + 2 : 0)),
552
+ };
553
+ });
554
+ if (changes.changes.empty) {
555
+ return false;
543
556
  }
544
- // Add the link markup.
545
- changes.push({ from, insert: '[' }, { from: to, insert: ']()' });
546
- const changeSet = state.changes(changes.concat(changesAfter));
547
- // Put the cursor between the parenthesis.
548
- return { changes: changeSet, range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset) };
549
- });
550
- if (changes.changes.empty) {
551
- return false;
552
- }
553
557
 
554
- dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
555
- return true;
556
- };
558
+ dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
559
+ return true;
560
+ };
557
561
 
558
562
  //
559
563
  // Lists
@@ -1057,7 +1061,7 @@ export const getFormatting = (state: EditorState): Formatting => {
1057
1061
  const inline: (boolean | null)[] = [null, null, null, null];
1058
1062
  let link: boolean = false;
1059
1063
  let blockQuote: boolean | null = null;
1060
- // False indicates mixed list styles
1064
+ // False indicates mixed list styles.
1061
1065
  let listStyle: Formatting['listStyle'] | null | false = null;
1062
1066
 
1063
1067
  // Track block context for list/blockquote handling.
@@ -1216,21 +1220,25 @@ export const getFormatting = (state: EditorState): Formatting => {
1216
1220
  };
1217
1221
 
1218
1222
  /**
1219
- * Hook computes the current formatting state.
1223
+ * Hook provides an extension to compute the current formatting state.
1220
1224
  */
1221
- export const useFormattingState = (): [Formatting | null, Extension] => {
1222
- const [state, setState] = useState<Formatting | null>(null);
1225
+ export const useFormattingState = (): [Formatting | undefined, Extension] => {
1226
+ const [state, setState] = useState<Formatting>();
1227
+
1223
1228
  const observer = useMemo(
1224
1229
  () =>
1225
1230
  EditorView.updateListener.of((update) => {
1226
1231
  if (update.docChanged || update.selectionSet) {
1227
- const newState = getFormatting(update.state);
1228
- if (!state || !compareFormatting(state, newState)) {
1229
- setState(newState);
1230
- }
1232
+ setState((prevState) => {
1233
+ const newState = getFormatting(update.state);
1234
+ if (!prevState || !formattingEquals(prevState, newState)) {
1235
+ return newState;
1236
+ }
1237
+ return prevState;
1238
+ });
1231
1239
  }
1232
1240
  }),
1233
- [],
1241
+ [setState],
1234
1242
  );
1235
1243
 
1236
1244
  return [state, observer];
@@ -97,3 +97,9 @@ class ImageWidget extends WidgetType {
97
97
  return img;
98
98
  }
99
99
  }
100
+
101
+ export type ImageUploadOptions = {
102
+ onSelect: () => { url: string };
103
+ };
104
+
105
+ export const imageUpload = (options: ImageOptions = {}) => {};
@@ -5,8 +5,11 @@
5
5
  import { type Extension, Facet } from '@codemirror/state';
6
6
  import { keymap } from '@codemirror/view';
7
7
  import { vim } from '@replit/codemirror-vim';
8
+ import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
8
9
 
9
- export type EditorMode = 'default' | 'vim' | undefined;
10
+ export const focusEvent = 'focus.container';
11
+
12
+ export type EditorMode = 'default' | 'vim' | 'vscode' | undefined;
10
13
 
11
14
  export type EditorConfig = {
12
15
  type: string;
@@ -19,7 +22,13 @@ export const editorMode = Facet.define<EditorConfig, EditorConfig>({
19
22
 
20
23
  export const EditorModes: { [mode: string]: Extension } = {
21
24
  default: [],
25
+ vscode: [
26
+ // https://github.com/replit/codemirror-vscode-keymap
27
+ editorMode.of({ type: 'vscode' }),
28
+ keymap.of(vscodeKeymap),
29
+ ],
22
30
  vim: [
31
+ // https://github.com/replit/codemirror-vim
23
32
  vim(),
24
33
  editorMode.of({ type: 'vim', noTabster: true }),
25
34
  keymap.of([
@@ -27,7 +36,7 @@ export const EditorModes: { [mode: string]: Extension } = {
27
36
  key: 'Alt-Escape',
28
37
  run: (view) => {
29
38
  // Focus container for tab navigation.
30
- view.dispatch({ userEvent: 'focus.container' });
39
+ view.dispatch({ userEvent: focusEvent });
31
40
  return true;
32
41
  },
33
42
  },
@@ -0,0 +1,90 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension, Transaction } from '@codemirror/state';
6
+ import { EditorView, keymap } from '@codemirror/view';
7
+
8
+ import { debounce } from '@dxos/async';
9
+ import { invariant } from '@dxos/invariant';
10
+ import { isNotFalsy } from '@dxos/util';
11
+
12
+ import { documentId } from './doc';
13
+
14
+ const scrollAnnotation = 'dxos.org/cm/scrolling';
15
+
16
+ // NOTE: Serializable.
17
+ export type SelectionState = {
18
+ scrollTo: {
19
+ from: number;
20
+ };
21
+ selection: {
22
+ anchor: number;
23
+ head?: number;
24
+ };
25
+ };
26
+
27
+ export type StateOptions = {
28
+ setState: (id: string, state: SelectionState) => void;
29
+ getState: (id: string) => SelectionState | undefined;
30
+ };
31
+
32
+ const keyPrefix = 'dxos.org/react-ui-editor/state';
33
+ export const localStorageStateStoreAdapter: StateOptions = {
34
+ setState: (id, state) => {
35
+ invariant(id);
36
+ localStorage.setItem(`${keyPrefix}/${id}`, JSON.stringify(state));
37
+ },
38
+ getState: (id) => {
39
+ invariant(id);
40
+ const state = localStorage.getItem(`${keyPrefix}/${id}`);
41
+ return state ? JSON.parse(state) : undefined;
42
+ },
43
+ };
44
+
45
+ /**
46
+ * Track scrolling and selection state to be restored when switching to document.
47
+ */
48
+ export const state = ({ getState, setState }: Partial<StateOptions> = {}): Extension => {
49
+ const setStateDebounced = debounce(setState!, 1_000);
50
+
51
+ return [
52
+ // TODO(burdon): Track scrolling (currently only updates when cursor moves).
53
+ EditorView.updateListener.of(({ view, changes, transactions }) => {
54
+ // TODO(burdon): Don't react to initial scroll.
55
+ const id = view.state.facet(documentId);
56
+ if (!id || transactions.some((tr) => tr.isUserEvent(scrollAnnotation))) {
57
+ return;
58
+ }
59
+
60
+ if (setState) {
61
+ const { top } = view.dom.getBoundingClientRect();
62
+ const pos = view.posAtCoords({ x: 0, y: top });
63
+ if (pos !== null) {
64
+ const { anchor, head } = view.state.selection.main;
65
+ setStateDebounced(id, {
66
+ scrollTo: { from: pos, yMargin: 0 },
67
+ selection: { anchor, head },
68
+ });
69
+ }
70
+ }
71
+ }),
72
+ getState &&
73
+ keymap.of([
74
+ {
75
+ key: 'ctrl-r', // TODO(burdon): Setting to jump back to bookmark.
76
+ run: (view) => {
77
+ const state = getState(view.state.facet(documentId));
78
+ if (state) {
79
+ view.dispatch({
80
+ effects: EditorView.scrollIntoView(state.scrollTo.from, { yMargin: 0 }),
81
+ selection: state.selection,
82
+ annotations: Transaction.userEvent.of(scrollAnnotation),
83
+ });
84
+ }
85
+ return true;
86
+ },
87
+ },
88
+ ]),
89
+ ].filter(isNotFalsy);
90
+ };
@@ -75,7 +75,11 @@ export const useActionHandler = (view?: EditorView | null): ToolbarProps['onActi
75
75
  break;
76
76
 
77
77
  case 'link':
78
- (action.data === false ? removeLink : addLink)(view);
78
+ (action.data === false ? removeLink : addLink())(view);
79
+ break;
80
+
81
+ case 'image':
82
+ addLink({ url: action.data, image: true })(view);
79
83
  break;
80
84
 
81
85
  case 'comment':
@@ -4,11 +4,17 @@
4
4
 
5
5
  import { useMemo } from 'react';
6
6
 
7
- import { createDocAccessor, type DocAccessor, getTextContent, type TextObject } from '@dxos/echo-schema';
7
+ import {
8
+ createDocAccessor,
9
+ type DocAccessor,
10
+ getTextContent,
11
+ type TextObject,
12
+ type EchoReactiveObject,
13
+ } from '@dxos/echo-schema';
8
14
 
9
15
  // TODO(burdon): Factor out.
10
16
  export const useDocAccessor = <T = any>(
11
- text: TextObject,
17
+ text: TextObject | EchoReactiveObject<{ content: string }>,
12
18
  ): { id: string; doc: string | undefined; accessor: DocAccessor<T> } => {
13
19
  return useMemo(
14
20
  () => ({