@dxos/react-ui-editor 0.4.8-main.0602afb → 0.4.8-main.0d78178

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 (45) hide show
  1. package/dist/lib/browser/index.mjs +411 -260
  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 +10 -4
  9. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  10. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +3 -1
  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/factories.d.ts.map +1 -1
  15. package/dist/types/src/extensions/index.d.ts +1 -0
  16. package/dist/types/src/extensions/index.d.ts.map +1 -1
  17. package/dist/types/src/extensions/markdown/formatting.d.ts +4 -1
  18. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  19. package/dist/types/src/extensions/markdown/image.d.ts +6 -0
  20. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  21. package/dist/types/src/extensions/modes.d.ts +1 -0
  22. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  23. package/dist/types/src/extensions/state.d.ts +20 -0
  24. package/dist/types/src/extensions/state.d.ts.map +1 -0
  25. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  26. package/dist/types/src/hooks/useTextEditor.stories.d.ts +1 -0
  27. package/dist/types/src/hooks/useTextEditor.stories.d.ts.map +1 -1
  28. package/dist/types/src/translations.d.ts +1 -0
  29. package/dist/types/src/translations.d.ts.map +1 -1
  30. package/package.json +25 -27
  31. package/src/components/TextEditor/TextEditor.stories.tsx +16 -4
  32. package/src/components/TextEditor/TextEditor.tsx +11 -10
  33. package/src/components/Toolbar/Toolbar.stories.tsx +3 -2
  34. package/src/components/Toolbar/Toolbar.tsx +86 -32
  35. package/src/extensions/factories.ts +0 -3
  36. package/src/extensions/index.ts +1 -0
  37. package/src/extensions/markdown/decorate.ts +1 -1
  38. package/src/extensions/markdown/formatting.test.ts +13 -13
  39. package/src/extensions/markdown/formatting.ts +80 -76
  40. package/src/extensions/markdown/image.ts +6 -0
  41. package/src/extensions/modes.ts +3 -1
  42. package/src/extensions/state.ts +90 -0
  43. package/src/hooks/useActionHandler.ts +5 -1
  44. package/src/hooks/useTextEditor.stories.tsx +3 -3
  45. package/src/translations.ts +1 -0
@@ -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
@@ -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
 
@@ -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
  },