@dxos/react-ui-editor 0.4.8-main.ec0899a → 0.4.8-main.fff1521

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 (48) hide show
  1. package/dist/lib/browser/index.mjs +449 -273
  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.map +1 -1
  16. package/dist/types/src/extensions/index.d.ts +1 -0
  17. package/dist/types/src/extensions/index.d.ts.map +1 -1
  18. package/dist/types/src/extensions/markdown/formatting.d.ts +7 -4
  19. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  20. package/dist/types/src/extensions/markdown/image.d.ts +6 -0
  21. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  22. package/dist/types/src/extensions/modes.d.ts +1 -0
  23. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  24. package/dist/types/src/extensions/state.d.ts +20 -0
  25. package/dist/types/src/extensions/state.d.ts.map +1 -0
  26. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  27. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  28. package/dist/types/src/hooks/useTextEditor.stories.d.ts +1 -0
  29. package/dist/types/src/hooks/useTextEditor.stories.d.ts.map +1 -1
  30. package/dist/types/src/translations.d.ts +1 -0
  31. package/dist/types/src/translations.d.ts.map +1 -1
  32. package/package.json +25 -27
  33. package/src/components/TextEditor/TextEditor.stories.tsx +16 -4
  34. package/src/components/TextEditor/TextEditor.tsx +13 -12
  35. package/src/components/Toolbar/Toolbar.stories.tsx +23 -24
  36. package/src/components/Toolbar/Toolbar.tsx +96 -35
  37. package/src/extensions/comments.ts +2 -2
  38. package/src/extensions/factories.ts +0 -3
  39. package/src/extensions/index.ts +1 -0
  40. package/src/extensions/markdown/formatting.test.ts +13 -13
  41. package/src/extensions/markdown/formatting.ts +91 -83
  42. package/src/extensions/markdown/image.ts +6 -0
  43. package/src/extensions/modes.ts +3 -1
  44. package/src/extensions/state.ts +90 -0
  45. package/src/hooks/useActionHandler.ts +5 -1
  46. package/src/hooks/useTextEditor.stories.tsx +3 -3
  47. package/src/hooks/useTextEditor.ts +3 -0
  48. package/src/translations.ts +1 -0
@@ -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,9 @@ 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 } from 'react';
19
+
20
+ import { useStateRef } from '@dxos/react-async';
19
21
 
20
22
  // Markdown refs:
21
23
  // https://github.github.com/gfm
@@ -56,7 +58,7 @@ export type Formatting = {
56
58
  listStyle: null | 'ordered' | 'bullet' | 'task';
57
59
  };
58
60
 
59
- export const compareFormatting = (a: Formatting, b: Formatting) =>
61
+ export const formattingEquals = (a: Formatting, b: Formatting) =>
60
62
  a.blockType === b.blockType &&
61
63
  a.strong === b.strong &&
62
64
  a.emphasis === b.emphasis &&
@@ -470,90 +472,94 @@ export const removeLink: StateCommand = ({ state, dispatch }) => {
470
472
  };
471
473
 
472
474
  // 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);
475
+ export const addLink =
476
+ ({ url, image }: { url?: string; image?: boolean } = {}): StateCommand =>
477
+ ({ state, dispatch }) => {
478
+ const changes = state.changeByRange((range) => {
479
+ let { from, to } = range;
480
+ const cutStyles: SyntaxNode[] = [];
481
+ let okay: boolean | null = null;
482
+ // Check whether this range is in a position where a link makes sense.
483
+ syntaxTree(state).iterate({
484
+ from,
485
+ to,
486
+ enter: (node) => {
487
+ if (Object.hasOwn(Textblocks, node.name)) {
488
+ // If the selection spans multiple textblocks or is in a code block, abort.
489
+ okay =
490
+ Textblocks[node.name] !== 'codeblock' &&
491
+ from >= blockContentStart(node) &&
492
+ to <= blockContentEnd(node, state.doc);
493
+ } else if (Object.hasOwn(InlineMarker, node.name)) {
494
+ // Look for inline styles that partially overlap the range.
495
+ // Expand the range over them if they start directly outside, otherwise mark them for later.
496
+ const sNode = node.node;
497
+ if (node.from < from && node.to <= to) {
498
+ if (sNode.firstChild!.to === from) {
499
+ from = node.from;
500
+ } else {
501
+ cutStyles.push(sNode);
502
+ }
503
+ } else if (node.from >= from && node.to > to) {
504
+ if (sNode.lastChild!.from === to) {
505
+ to = node.to;
506
+ } else {
507
+ cutStyles.push(sNode);
508
+ }
506
509
  }
507
510
  }
508
- }
509
- },
510
- });
511
+ },
512
+ });
511
513
 
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
- }
514
+ if (okay === null) {
515
+ // No textblock found around selection. Check if the rest of the line is empty.
516
+ const line = state.doc.lineAt(from);
517
+ okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
518
+ }
519
+ if (!okay) {
520
+ return { range };
521
+ }
520
522
 
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;
523
+ const changes: ChangeSpec[] = [];
524
+ // Some changes must be moved to end of change array so that they are applied in the right order
525
+ const changesAfter: ChangeSpec[] = [];
526
+ // Clear existing links.
527
+ removeLinkInner(from, to, changesAfter, state);
528
+ let cursorOffset = 1;
529
+ // Close and reopen inline styles that partially overlap the range.
530
+ for (const style of cutStyles) {
531
+ const type = InlineMarker[style.name];
532
+ const mark = inlineMarkerText(type);
533
+ if (style.from < from) {
534
+ // Extends before.
535
+ changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
536
+ changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
537
+ } else {
538
+ changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
539
+ const after = skipSpaces(to, state.doc, 1);
540
+ if (after === to) {
541
+ cursorOffset += mark.length;
542
+ }
543
+ changesAfter.push({ from: after, insert: mark });
540
544
  }
541
- changesAfter.push({ from: after, insert: mark });
542
545
  }
546
+
547
+ // Add the link markup.
548
+ changes.push({ from, insert: image ? '![' : '[' }, { from: to, insert: `](${url ?? ''})` });
549
+ const changeSet = state.changes(changes.concat(changesAfter));
550
+ // Put the cursor between the title or parenthesis.
551
+ return {
552
+ changes: changeSet,
553
+ range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset - (url ? url.length + 2 : 0)),
554
+ };
555
+ });
556
+ if (changes.changes.empty) {
557
+ return false;
543
558
  }
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
559
 
554
- dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
555
- return true;
556
- };
560
+ dispatch(state.update(changes, { userEvent: 'format.link.add', scrollIntoView: true }));
561
+ return true;
562
+ };
557
563
 
558
564
  //
559
565
  // Lists
@@ -1057,7 +1063,7 @@ export const getFormatting = (state: EditorState): Formatting => {
1057
1063
  const inline: (boolean | null)[] = [null, null, null, null];
1058
1064
  let link: boolean = false;
1059
1065
  let blockQuote: boolean | null = null;
1060
- // False indicates mixed list styles
1066
+ // False indicates mixed list styles.
1061
1067
  let listStyle: Formatting['listStyle'] | null | false = null;
1062
1068
 
1063
1069
  // Track block context for list/blockquote handling.
@@ -1216,16 +1222,18 @@ export const getFormatting = (state: EditorState): Formatting => {
1216
1222
  };
1217
1223
 
1218
1224
  /**
1219
- * Hook computes the current formatting state.
1225
+ * Hook provides an extension to compute the current formatting state.
1220
1226
  */
1221
- export const useFormattingState = (): [Formatting | null, Extension] => {
1222
- const [state, setState] = useState<Formatting | null>(null);
1227
+ export const useFormattingState = (): [Formatting | undefined, Extension] => {
1228
+ const [state, setState, stateRef] = useStateRef<Formatting>();
1223
1229
  const observer = useMemo(
1224
1230
  () =>
1225
1231
  EditorView.updateListener.of((update) => {
1226
1232
  if (update.docChanged || update.selectionSet) {
1227
1233
  const newState = getFormatting(update.state);
1228
- if (!state || !compareFormatting(state, newState)) {
1234
+ if (!stateRef.current || !formattingEquals(stateRef.current, newState)) {
1235
+ // TODO(burdon): Error on first click on toolbar.
1236
+ // Warning: React has detected a change in the order of Hooks called by ForwardRef. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks
1229
1237
  setState(newState);
1230
1238
  }
1231
1239
  }
@@ -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 = {}) => {};
@@ -6,6 +6,8 @@ import { type Extension, Facet } from '@codemirror/state';
6
6
  import { keymap } from '@codemirror/view';
7
7
  import { vim } from '@replit/codemirror-vim';
8
8
 
9
+ export const focusEvent = 'focus.container';
10
+
9
11
  export type EditorMode = 'default' | 'vim' | undefined;
10
12
 
11
13
  export type EditorConfig = {
@@ -27,7 +29,7 @@ export const EditorModes: { [mode: string]: Extension } = {
27
29
  key: 'Alt-Escape',
28
30
  run: (view) => {
29
31
  // Focus container for tab navigation.
30
- view.dispatch({ userEvent: 'focus.container' });
32
+ view.dispatch({ userEvent: focusEvent });
31
33
  return true;
32
34
  },
33
35
  },
@@ -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':
@@ -67,7 +67,7 @@ const Story = ({ autoFocus, placeholder, doc, readonly }: StoryProps) => {
67
67
  <EditorModeToolbar editorMode={editorMode} setEditorMode={setEditorMode} />
68
68
  </Toolbar.Root>
69
69
  <div role='none' className='grow overflow-hidden'>
70
- <div role='textbox' className={mx(textBlockWidth, attentionSurface)} ref={parentRef} />
70
+ <div className={mx(textBlockWidth, attentionSurface)} ref={parentRef} />
71
71
  </div>
72
72
  </div>
73
73
  );
@@ -117,7 +117,7 @@ export default {
117
117
  export const Default = {
118
118
  render: () => {
119
119
  const { parentRef } = useTextEditor();
120
- return <div role='textbox' className={mx(textBlockWidth, attentionSurface)} ref={parentRef} />;
120
+ return <div className={mx(textBlockWidth, attentionSurface)} ref={parentRef} />;
121
121
  },
122
122
  };
123
123
 
@@ -127,7 +127,7 @@ export const Basic = {
127
127
  const { parentRef } = useTextEditor(() => ({
128
128
  extensions: [createBasicExtensions({ placeholder: 'Enter text...' }), createThemeExtensions({ themeMode })],
129
129
  }));
130
- return <div role='textbox' className={mx(textBlockWidth, attentionSurface)} ref={parentRef} />;
130
+ return <div className={mx(textBlockWidth, attentionSurface)} ref={parentRef} />;
131
131
  },
132
132
  };
133
133
 
@@ -33,6 +33,8 @@ export const useTextEditor = (cb: () => UseTextEditorProps = () => ({}), deps: D
33
33
  useEffect(() => {
34
34
  let view: EditorView;
35
35
  if (parentRef.current) {
36
+ log('create', { id });
37
+
36
38
  // https://codemirror.net/docs/ref/#state.EditorStateConfig
37
39
  const state = EditorState.create({
38
40
  doc,
@@ -69,6 +71,7 @@ export const useTextEditor = (cb: () => UseTextEditorProps = () => ({}), deps: D
69
71
  }
70
72
 
71
73
  return () => {
74
+ log('destroy', { id });
72
75
  view?.destroy();
73
76
  };
74
77
  }, deps);
@@ -19,6 +19,7 @@ export default [
19
19
  'blockquote label': 'Block quote',
20
20
  'codeblock label': 'Code block',
21
21
  'comment label': 'Create comment',
22
+ 'image label': 'Insert image',
22
23
  'heading label': 'Heading level',
23
24
  'table label': 'Create table',
24
25
  },