@dxos/react-ui-editor 0.8.2-main.fbd8ed0 → 0.8.2-staging.4d6ad0f

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 (180) hide show
  1. package/dist/lib/browser/index.mjs +1731 -926
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/testing/index.mjs +3 -64
  5. package/dist/lib/browser/testing/index.mjs.map +4 -4
  6. package/dist/lib/node/index.cjs +1912 -1111
  7. package/dist/lib/node/index.cjs.map +4 -4
  8. package/dist/lib/node/meta.json +1 -1
  9. package/dist/lib/node/testing/index.cjs +3 -75
  10. package/dist/lib/node/testing/index.cjs.map +4 -4
  11. package/dist/lib/node-esm/index.mjs +1731 -926
  12. package/dist/lib/node-esm/index.mjs.map +4 -4
  13. package/dist/lib/node-esm/meta.json +1 -1
  14. package/dist/lib/node-esm/testing/index.mjs +3 -64
  15. package/dist/lib/node-esm/testing/index.mjs.map +4 -4
  16. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  17. package/dist/types/src/components/EditorToolbar/index.d.ts +1 -1
  18. package/dist/types/src/components/EditorToolbar/index.d.ts.map +1 -1
  19. package/dist/types/src/components/EditorToolbar/util.d.ts +4 -6
  20. package/dist/types/src/components/EditorToolbar/util.d.ts.map +1 -1
  21. package/dist/types/src/components/Popover/RefDropdownMenu.d.ts +21 -0
  22. package/dist/types/src/components/Popover/RefDropdownMenu.d.ts.map +1 -0
  23. package/dist/types/src/{testing → components/Popover}/RefPopover.d.ts +1 -1
  24. package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -0
  25. package/dist/types/src/components/Popover/index.d.ts +3 -0
  26. package/dist/types/src/components/Popover/index.d.ts.map +1 -0
  27. package/dist/types/src/components/index.d.ts +1 -0
  28. package/dist/types/src/components/index.d.ts.map +1 -1
  29. package/dist/types/src/defaults.d.ts +2 -5
  30. package/dist/types/src/defaults.d.ts.map +1 -1
  31. package/dist/types/src/extensions/annotations.d.ts +4 -1
  32. package/dist/types/src/extensions/annotations.d.ts.map +1 -1
  33. package/dist/types/src/extensions/autocomplete.d.ts +1 -2
  34. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  35. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  36. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  37. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
  38. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
  39. package/dist/types/src/extensions/command/command.d.ts +1 -2
  40. package/dist/types/src/extensions/command/command.d.ts.map +1 -1
  41. package/dist/types/src/extensions/command/hint.d.ts +14 -2
  42. package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
  43. package/dist/types/src/extensions/command/index.d.ts +2 -0
  44. package/dist/types/src/extensions/command/index.d.ts.map +1 -1
  45. package/dist/types/src/extensions/command/menu.d.ts +7 -8
  46. package/dist/types/src/extensions/command/menu.d.ts.map +1 -1
  47. package/dist/types/src/extensions/command/state.d.ts +1 -1
  48. package/dist/types/src/extensions/command/state.d.ts.map +1 -1
  49. package/dist/types/src/extensions/command/typeahead.d.ts +17 -0
  50. package/dist/types/src/extensions/command/typeahead.d.ts.map +1 -0
  51. package/dist/types/src/extensions/comments.d.ts +2 -12
  52. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  53. package/dist/types/src/extensions/factories.d.ts +4 -0
  54. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  55. package/dist/types/src/extensions/index.d.ts +2 -0
  56. package/dist/types/src/extensions/index.d.ts.map +1 -1
  57. package/dist/types/src/extensions/json.d.ts +7 -0
  58. package/dist/types/src/extensions/json.d.ts.map +1 -0
  59. package/dist/types/src/extensions/markdown/{editorAction.d.ts → action.d.ts} +1 -1
  60. package/dist/types/src/extensions/markdown/action.d.ts.map +1 -0
  61. package/dist/types/src/extensions/markdown/bundle.d.ts +2 -1
  62. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  63. package/dist/types/src/extensions/markdown/index.d.ts +1 -2
  64. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  65. package/dist/types/src/extensions/markdown/styles.d.ts.map +1 -1
  66. package/dist/types/src/extensions/outliner/commands.d.ts +9 -0
  67. package/dist/types/src/extensions/outliner/commands.d.ts.map +1 -0
  68. package/dist/types/src/extensions/outliner/editor.d.ts +5 -0
  69. package/dist/types/src/extensions/outliner/editor.d.ts.map +1 -0
  70. package/dist/types/src/extensions/outliner/editor.test.d.ts +2 -0
  71. package/dist/types/src/extensions/outliner/editor.test.d.ts.map +1 -0
  72. package/dist/types/src/extensions/outliner/index.d.ts +3 -0
  73. package/dist/types/src/extensions/outliner/index.d.ts.map +1 -0
  74. package/dist/types/src/extensions/outliner/outliner.d.ts +10 -0
  75. package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -0
  76. package/dist/types/src/extensions/outliner/outliner.test.d.ts +2 -0
  77. package/dist/types/src/extensions/outliner/outliner.test.d.ts.map +1 -0
  78. package/dist/types/src/extensions/outliner/selection.d.ts +12 -0
  79. package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -0
  80. package/dist/types/src/extensions/outliner/tree.d.ts +79 -0
  81. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -0
  82. package/dist/types/src/extensions/outliner/tree.test.d.ts +2 -0
  83. package/dist/types/src/extensions/outliner/tree.test.d.ts.map +1 -0
  84. package/dist/types/src/stories/Command.stories.d.ts +7 -0
  85. package/dist/types/src/stories/Command.stories.d.ts.map +1 -0
  86. package/dist/types/src/stories/{TextEditorComments.stories.d.ts → Comments.stories.d.ts} +3 -3
  87. package/dist/types/src/stories/Comments.stories.d.ts.map +1 -0
  88. package/dist/types/src/stories/EditorToolbar.stories.d.ts +12 -0
  89. package/dist/types/src/stories/EditorToolbar.stories.d.ts.map +1 -0
  90. package/dist/types/src/stories/{TextEditorSpecial.stories.d.ts → Experimental.stories.d.ts} +3 -6
  91. package/dist/types/src/stories/Experimental.stories.d.ts.map +1 -0
  92. package/dist/types/src/stories/Markdown.stories.d.ts +46 -0
  93. package/dist/types/src/stories/Markdown.stories.d.ts.map +1 -0
  94. package/dist/types/src/stories/Outliner.stories.d.ts +26 -0
  95. package/dist/types/src/stories/Outliner.stories.d.ts.map +1 -0
  96. package/dist/types/src/stories/Preview.stories.d.ts +10 -0
  97. package/dist/types/src/stories/Preview.stories.d.ts.map +1 -0
  98. package/dist/types/src/stories/{TextEditorBasic.stories.d.ts → TextEditor.stories.d.ts} +9 -39
  99. package/dist/types/src/stories/TextEditor.stories.d.ts.map +1 -0
  100. package/dist/types/src/stories/{story-utils.d.ts → util.d.ts} +6 -6
  101. package/dist/types/src/stories/util.d.ts.map +1 -0
  102. package/dist/types/src/styles/theme.d.ts.map +1 -1
  103. package/dist/types/src/styles/tokens.d.ts.map +1 -1
  104. package/dist/types/src/testing/index.d.ts +1 -1
  105. package/dist/types/src/testing/index.d.ts.map +1 -1
  106. package/dist/types/src/testing/util.d.ts +2 -0
  107. package/dist/types/src/testing/util.d.ts.map +1 -0
  108. package/package.json +40 -34
  109. package/src/components/EditorToolbar/EditorToolbar.tsx +81 -57
  110. package/src/components/EditorToolbar/index.ts +7 -1
  111. package/src/components/EditorToolbar/util.ts +3 -4
  112. package/src/components/Popover/RefDropdownMenu.tsx +77 -0
  113. package/src/{testing → components/Popover}/RefPopover.tsx +5 -4
  114. package/src/components/Popover/index.ts +6 -0
  115. package/src/components/index.ts +1 -0
  116. package/src/defaults.ts +10 -13
  117. package/src/extensions/annotations.ts +41 -64
  118. package/src/extensions/autocomplete.ts +5 -6
  119. package/src/extensions/automerge/automerge.stories.tsx +2 -7
  120. package/src/extensions/automerge/automerge.test.tsx +3 -2
  121. package/src/extensions/automerge/sync.ts +3 -3
  122. package/src/extensions/awareness/awareness-provider.ts +4 -4
  123. package/src/extensions/awareness/awareness.ts +7 -7
  124. package/src/extensions/blast.ts +9 -9
  125. package/src/extensions/command/command.ts +1 -3
  126. package/src/extensions/command/hint.ts +7 -7
  127. package/src/extensions/command/index.ts +2 -0
  128. package/src/extensions/command/menu.ts +43 -49
  129. package/src/extensions/command/typeahead.ts +116 -0
  130. package/src/extensions/comments.ts +4 -69
  131. package/src/extensions/factories.ts +13 -0
  132. package/src/extensions/index.ts +2 -0
  133. package/src/extensions/json.ts +56 -0
  134. package/src/extensions/markdown/bundle.ts +13 -9
  135. package/src/extensions/markdown/decorate.ts +7 -7
  136. package/src/extensions/markdown/image.ts +2 -2
  137. package/src/extensions/markdown/index.ts +1 -2
  138. package/src/extensions/markdown/styles.ts +2 -1
  139. package/src/extensions/markdown/table.ts +3 -3
  140. package/src/extensions/outliner/commands.ts +242 -0
  141. package/src/extensions/outliner/editor.test.ts +33 -0
  142. package/src/extensions/outliner/editor.ts +180 -0
  143. package/src/extensions/outliner/index.ts +6 -0
  144. package/src/extensions/outliner/outliner.test.ts +99 -0
  145. package/src/extensions/outliner/outliner.ts +162 -0
  146. package/src/extensions/outliner/selection.ts +50 -0
  147. package/src/extensions/outliner/tree.test.ts +164 -0
  148. package/src/extensions/outliner/tree.ts +315 -0
  149. package/src/extensions/preview/preview.ts +5 -5
  150. package/src/stories/Command.stories.tsx +97 -0
  151. package/src/stories/{TextEditorComments.stories.tsx → Comments.stories.tsx} +13 -14
  152. package/src/{components/EditorToolbar → stories}/EditorToolbar.stories.tsx +26 -20
  153. package/src/stories/{TextEditorSpecial.stories.tsx → Experimental.stories.tsx} +9 -30
  154. package/src/stories/Markdown.stories.tsx +121 -0
  155. package/src/stories/Outliner.stories.tsx +108 -0
  156. package/src/stories/{TextEditorPreview.stories.tsx → Preview.stories.tsx} +46 -136
  157. package/src/stories/TextEditor.stories.tsx +256 -0
  158. package/src/stories/{story-utils.tsx → util.tsx} +21 -22
  159. package/src/styles/theme.ts +12 -5
  160. package/src/styles/tokens.ts +1 -2
  161. package/src/testing/index.ts +1 -1
  162. package/src/testing/util.ts +5 -0
  163. package/dist/types/src/components/EditorToolbar/EditorToolbar.stories.d.ts +0 -53
  164. package/dist/types/src/components/EditorToolbar/EditorToolbar.stories.d.ts.map +0 -1
  165. package/dist/types/src/components/EditorToolbar/comment.d.ts +0 -18
  166. package/dist/types/src/components/EditorToolbar/comment.d.ts.map +0 -1
  167. package/dist/types/src/extensions/markdown/editorAction.d.ts.map +0 -1
  168. package/dist/types/src/extensions/markdown/outliner.d.ts +0 -12
  169. package/dist/types/src/extensions/markdown/outliner.d.ts.map +0 -1
  170. package/dist/types/src/stories/TextEditorBasic.stories.d.ts.map +0 -1
  171. package/dist/types/src/stories/TextEditorComments.stories.d.ts.map +0 -1
  172. package/dist/types/src/stories/TextEditorPreview.stories.d.ts +0 -13
  173. package/dist/types/src/stories/TextEditorPreview.stories.d.ts.map +0 -1
  174. package/dist/types/src/stories/TextEditorSpecial.stories.d.ts.map +0 -1
  175. package/dist/types/src/stories/story-utils.d.ts.map +0 -1
  176. package/dist/types/src/testing/RefPopover.d.ts.map +0 -1
  177. package/src/components/EditorToolbar/comment.ts +0 -30
  178. package/src/extensions/markdown/outliner.ts +0 -235
  179. package/src/stories/TextEditorBasic.stories.tsx +0 -333
  180. /package/src/extensions/markdown/{editorAction.ts → action.ts} +0 -0
@@ -0,0 +1,56 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { json, jsonParseLinter } from '@codemirror/lang-json';
6
+ import { type LintSource, linter } from '@codemirror/lint';
7
+ import { type Extension } from '@codemirror/state';
8
+ import Ajv, { type ValidateFunction } from 'ajv';
9
+
10
+ import { type JsonSchemaType } from '@dxos/echo-schema';
11
+
12
+ export type JsonExtensionsOptions = {
13
+ schema?: JsonSchemaType;
14
+ };
15
+
16
+ export const createJsonExtensions = ({ schema }: JsonExtensionsOptions = {}): Extension => {
17
+ let lintSource: LintSource = jsonParseLinter();
18
+ if (schema) {
19
+ const ajv = new Ajv({ allErrors: false });
20
+ const validate = ajv.compile(schema);
21
+ lintSource = schemaLinter(validate);
22
+ }
23
+
24
+ return [json(), linter(lintSource)];
25
+ };
26
+
27
+ const schemaLinter =
28
+ (validate: ValidateFunction): LintSource =>
29
+ (view) => {
30
+ try {
31
+ const jsonText = view.state.doc.toString();
32
+ const jsonData = JSON.parse(jsonText);
33
+ const valid = validate(jsonData);
34
+ if (valid) {
35
+ return [];
36
+ }
37
+
38
+ return (
39
+ validate.errors?.map((err: any) => ({
40
+ from: 0,
41
+ to: jsonText.length,
42
+ severity: 'error',
43
+ message: `${err.instancePath || '(root)'} ${err.message}`,
44
+ })) ?? []
45
+ );
46
+ } catch (err: unknown) {
47
+ return [
48
+ {
49
+ from: 0,
50
+ to: view.state.doc.length,
51
+ severity: 'error',
52
+ message: 'Invalid JSON: ' + (err as Error).message,
53
+ },
54
+ ];
55
+ }
56
+ };
@@ -12,11 +12,13 @@ import { type Extension } from '@codemirror/state';
12
12
  import { keymap } from '@codemirror/view';
13
13
 
14
14
  import { type ThemeMode } from '@dxos/react-ui';
15
+ import { isNotFalsy } from '@dxos/util';
15
16
 
16
17
  import { markdownHighlightStyle, markdownTagsExtensions } from './highlight';
17
18
 
18
19
  export type MarkdownBundleOptions = {
19
20
  themeMode?: ThemeMode;
21
+ indentWithTab?: boolean;
20
22
  };
21
23
 
22
24
  /**
@@ -27,7 +29,7 @@ export type MarkdownBundleOptions = {
27
29
  * https://codemirror.net/docs/community
28
30
  * https://codemirror.net/docs/ref/#codemirror.basicSetup
29
31
  */
30
- export const createMarkdownExtensions = ({ themeMode }: MarkdownBundleOptions = {}): Extension[] => {
32
+ export const createMarkdownExtensions = (options: MarkdownBundleOptions = {}): Extension[] => {
31
33
  return [
32
34
  // Main extension.
33
35
  // https://github.com/codemirror/lang-markdown
@@ -56,14 +58,16 @@ export const createMarkdownExtensions = ({ themeMode }: MarkdownBundleOptions =
56
58
  // Custom styles.
57
59
  syntaxHighlighting(markdownHighlightStyle()),
58
60
 
59
- keymap.of([
60
- // https://codemirror.net/docs/ref/#commands.indentWithTab
61
- indentWithTab,
61
+ keymap.of(
62
+ [
63
+ // https://codemirror.net/docs/ref/#commands.indentWithTab
64
+ options.indentWithTab !== false && indentWithTab,
62
65
 
63
- // https://codemirror.net/docs/ref/#commands.defaultKeymap
64
- ...defaultKeymap,
65
- ...completionKeymap,
66
- ...lintKeymap,
67
- ]),
66
+ // https://codemirror.net/docs/ref/#commands.defaultKeymap
67
+ ...defaultKeymap,
68
+ ...completionKeymap,
69
+ ...lintKeymap,
70
+ ].filter(isNotFalsy),
71
+ ),
68
72
  ];
69
73
  };
@@ -36,7 +36,7 @@ const Unicode = {
36
36
  //
37
37
 
38
38
  class HorizontalRuleWidget extends WidgetType {
39
- override toDOM() {
39
+ override toDOM(): HTMLSpanElement {
40
40
  const el = document.createElement('span');
41
41
  el.className = 'cm-hr';
42
42
  return el;
@@ -51,12 +51,12 @@ class LinkButton extends WidgetType {
51
51
  super();
52
52
  }
53
53
 
54
- override eq(other: this) {
54
+ override eq(other: this): boolean {
55
55
  return this.url === other.url;
56
56
  }
57
57
 
58
58
  // TODO(burdon): Create icon and link directly without react?
59
- override toDOM(view: EditorView) {
59
+ override toDOM(view: EditorView): HTMLSpanElement {
60
60
  const el = document.createElement('span');
61
61
  this.render(el, { url: this.url }, view);
62
62
  return el;
@@ -68,11 +68,11 @@ class CheckboxWidget extends WidgetType {
68
68
  super();
69
69
  }
70
70
 
71
- override eq(other: this) {
71
+ override eq(other: this): boolean {
72
72
  return this._checked === other._checked;
73
73
  }
74
74
 
75
- override toDOM(view: EditorView) {
75
+ override toDOM(view: EditorView): HTMLSpanElement {
76
76
  const input = document.createElement('input');
77
77
  input.className = 'cm-task-checkbox dx-checkbox';
78
78
  input.type = 'checkbox';
@@ -105,7 +105,7 @@ class CheckboxWidget extends WidgetType {
105
105
  return span;
106
106
  }
107
107
 
108
- override ignoreEvent() {
108
+ override ignoreEvent(): boolean {
109
109
  return false;
110
110
  }
111
111
  }
@@ -118,7 +118,7 @@ class TextWidget extends WidgetType {
118
118
  super();
119
119
  }
120
120
 
121
- override toDOM() {
121
+ override toDOM(): HTMLSpanElement {
122
122
  const el = document.createElement('span');
123
123
  if (this.className) {
124
124
  el.className = this.className;
@@ -99,11 +99,11 @@ class ImageWidget extends WidgetType {
99
99
  super();
100
100
  }
101
101
 
102
- override eq(other: this) {
102
+ override eq(other: this): boolean {
103
103
  return this._url === other._url;
104
104
  }
105
105
 
106
- override toDOM(view: EditorView) {
106
+ override toDOM(view: EditorView): HTMLImageElement {
107
107
  const img = document.createElement('img');
108
108
  img.setAttribute('src', this._url);
109
109
  img.setAttribute('class', 'cm-image');
@@ -2,7 +2,7 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export * from './editorAction';
5
+ export * from './action';
6
6
  export * from './bundle';
7
7
  export * from './debug';
8
8
  export * from './decorate';
@@ -10,5 +10,4 @@ export * from './formatting';
10
10
  export * from './highlight';
11
11
  export * from './image';
12
12
  export * from './link';
13
- export * from './outliner';
14
13
  export * from './table';
@@ -72,8 +72,9 @@ export const formattingStyles = EditorView.theme({
72
72
  * Task list.
73
73
  */
74
74
  '& .cm-task': {
75
- display: 'inline-block',
75
+ display: 'inline-flex',
76
76
  width: `${bulletListIndentationWidth}px`,
77
+ height: '20px',
77
78
  },
78
79
  '& .cm-task-checkbox': {
79
80
  display: 'grid',
@@ -106,14 +106,14 @@ class TableWidget extends WidgetType {
106
106
  super();
107
107
  }
108
108
 
109
- override eq(other: this) {
109
+ override eq(other: this): boolean {
110
110
  return (
111
111
  this._table.header?.join() === other._table.header?.join() &&
112
112
  this._table.rows?.join() === other._table.rows?.join()
113
113
  );
114
114
  }
115
115
 
116
- override toDOM(view: EditorView) {
116
+ override toDOM(view: EditorView): HTMLDivElement {
117
117
  const div = document.createElement('div');
118
118
  const table = div.appendChild(document.createElement('table'));
119
119
 
@@ -138,7 +138,7 @@ class TableWidget extends WidgetType {
138
138
  return div;
139
139
  }
140
140
 
141
- override ignoreEvent(e: Event) {
141
+ override ignoreEvent(e: Event): boolean {
142
142
  return !/^mouse/.test(e.type);
143
143
  }
144
144
  }
@@ -0,0 +1,242 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { indentMore } from '@codemirror/commands';
6
+ import { getIndentUnit } from '@codemirror/language';
7
+ import { type ChangeSpec, EditorSelection, type Extension } from '@codemirror/state';
8
+ import { type Command, type EditorView, keymap } from '@codemirror/view';
9
+
10
+ import { getSelection, selectAll, selectDown, selectNone, selectUp } from './selection';
11
+ import { getRange, treeFacet } from './tree';
12
+
13
+ //
14
+ // Indentation comnmands.
15
+ //
16
+
17
+ export const indentItemMore: Command = (view: EditorView) => {
18
+ const pos = getSelection(view.state).from;
19
+ const tree = view.state.facet(treeFacet);
20
+ const current = tree.find(pos);
21
+ if (current) {
22
+ const previous = tree.prev(current);
23
+ if (previous && current.level <= previous.level) {
24
+ // TODO(burdon): Indent descendants?
25
+ indentMore(view);
26
+ }
27
+ }
28
+
29
+ return true;
30
+ };
31
+
32
+ export const indentItemLess: Command = (view: EditorView) => {
33
+ const pos = getSelection(view.state).from;
34
+ const tree = view.state.facet(treeFacet);
35
+ const current = tree.find(pos);
36
+ if (current) {
37
+ if (current.level > 0) {
38
+ // Unindent current line and all descendants.
39
+ // NOTE: The markdown extension doesn't provide an indentation service.
40
+ const indentUnit = getIndentUnit(view.state);
41
+ const changes: ChangeSpec[] = [];
42
+ tree.traverse(current, (item) => {
43
+ const line = view.state.doc.lineAt(item.lineRange.from);
44
+ changes.push({ from: line.from, to: line.from + indentUnit });
45
+ });
46
+
47
+ if (changes.length > 0) {
48
+ view.dispatch({ changes });
49
+ }
50
+ }
51
+ }
52
+
53
+ return true;
54
+ };
55
+
56
+ //
57
+ // Moving commands.
58
+ //
59
+
60
+ export const moveItemDown: Command = (view: EditorView) => {
61
+ const pos = getSelection(view.state)?.from;
62
+ const tree = view.state.facet(treeFacet);
63
+ const current = tree.find(pos);
64
+ if (current && current.nextSibling) {
65
+ const next = current.nextSibling;
66
+ const currentContent = view.state.doc.sliceString(...getRange(tree, current));
67
+ const nextContent = view.state.doc.sliceString(...getRange(tree, next));
68
+ const changes: ChangeSpec[] = [
69
+ {
70
+ from: current.lineRange.from,
71
+ to: current.lineRange.from + currentContent.length,
72
+ insert: nextContent,
73
+ },
74
+ {
75
+ from: next.lineRange.from,
76
+ to: next.lineRange.from + nextContent.length,
77
+ insert: currentContent,
78
+ },
79
+ ];
80
+
81
+ view.dispatch({
82
+ changes,
83
+ selection: EditorSelection.cursor(pos + nextContent.length + 1),
84
+ scrollIntoView: true,
85
+ });
86
+ }
87
+
88
+ return true;
89
+ };
90
+
91
+ export const moveItemUp: Command = (view: EditorView) => {
92
+ const pos = getSelection(view.state)?.from;
93
+ const tree = view.state.facet(treeFacet);
94
+ const current = tree.find(pos);
95
+ if (current && current.prevSibling) {
96
+ const prev = current.prevSibling;
97
+ const currentContent = view.state.doc.sliceString(...getRange(tree, current));
98
+ const prevContent = view.state.doc.sliceString(...getRange(tree, prev));
99
+ const changes: ChangeSpec[] = [
100
+ {
101
+ from: prev.lineRange.from,
102
+ to: prev.lineRange.from + prevContent.length,
103
+ insert: currentContent,
104
+ },
105
+ {
106
+ from: current.lineRange.from,
107
+ to: current.lineRange.from + currentContent.length,
108
+ insert: prevContent,
109
+ },
110
+ ];
111
+
112
+ view.dispatch({
113
+ changes,
114
+ selection: EditorSelection.cursor(pos - prevContent.length - 1),
115
+ scrollIntoView: true,
116
+ });
117
+ }
118
+
119
+ return true;
120
+ };
121
+
122
+ //
123
+ // Misc commands.
124
+ //
125
+
126
+ export const toggleTask: Command = (view: EditorView) => {
127
+ const pos = getSelection(view.state)?.from;
128
+ const tree = view.state.facet(treeFacet);
129
+ const current = tree.find(pos);
130
+ if (current) {
131
+ const type = current.type === 'task' ? 'bullet' : 'task';
132
+ const indent = ' '.repeat(getIndentUnit(view.state) * current.level);
133
+ view.dispatch({
134
+ changes: [
135
+ {
136
+ from: current.lineRange.from,
137
+ to: current.contentRange.from,
138
+ insert: indent + (type === 'task' ? '- [ ] ' : '- '),
139
+ },
140
+ ],
141
+ });
142
+ }
143
+
144
+ return true;
145
+ };
146
+
147
+ export const commands = (): Extension =>
148
+ keymap.of([
149
+ //
150
+ // Indentation.
151
+ //
152
+ {
153
+ key: 'Tab',
154
+ preventDefault: true,
155
+ run: indentItemMore,
156
+ shift: indentItemLess,
157
+ },
158
+
159
+ //
160
+ // Continuation.
161
+ //
162
+ {
163
+ key: 'Enter',
164
+ shift: (view) => {
165
+ const pos = getSelection(view.state).from;
166
+ const insert = '\n '; // TODO(burdon): Fix parsing.
167
+ view.dispatch({
168
+ changes: [{ from: pos, to: pos, insert }],
169
+ selection: EditorSelection.cursor(pos + insert.length),
170
+ });
171
+ return true;
172
+ },
173
+ },
174
+
175
+ //
176
+ // Navigation.
177
+ //
178
+ {
179
+ key: 'ArrowDown',
180
+ // Jump to next item (default moves to end of currentline).
181
+ run: (view) => {
182
+ const tree = view.state.facet(treeFacet);
183
+ const item = tree.find(getSelection(view.state).from);
184
+ if (
185
+ item &&
186
+ view.state.doc.lineAt(item.lineRange.to).number - view.state.doc.lineAt(item.lineRange.from).number === 0
187
+ ) {
188
+ const next = tree.next(item);
189
+ if (next) {
190
+ view.dispatch({ selection: EditorSelection.cursor(next.contentRange.from) });
191
+ return true;
192
+ }
193
+ }
194
+
195
+ return false;
196
+ },
197
+ },
198
+
199
+ //
200
+ // Line selection.
201
+ // TODO(burdon): Shortcut to select current item?
202
+ //
203
+ {
204
+ key: 'Mod-a',
205
+ preventDefault: true,
206
+ run: selectAll,
207
+ },
208
+ {
209
+ key: 'Escape',
210
+ preventDefault: true,
211
+ run: selectNone,
212
+ },
213
+ {
214
+ key: 'ArrowUp',
215
+ shift: selectUp,
216
+ },
217
+ {
218
+ key: 'ArrowDown',
219
+ shift: selectDown,
220
+ },
221
+
222
+ //
223
+ // Move.
224
+ //
225
+ {
226
+ key: 'Alt-ArrowDown',
227
+ preventDefault: true,
228
+ run: moveItemDown,
229
+ },
230
+ {
231
+ key: 'Alt-ArrowUp',
232
+ preventDefault: true,
233
+ run: moveItemUp,
234
+ },
235
+ //
236
+ // Misc.
237
+ //
238
+ {
239
+ key: 'Alt-t',
240
+ run: toggleTask,
241
+ },
242
+ ]);
@@ -0,0 +1,33 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
6
+ import { EditorSelection, EditorState } from '@codemirror/state';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { editor } from './editor';
10
+ import { outlinerTree, treeFacet } from './tree';
11
+
12
+ const extensions = [markdown({ base: markdownLanguage }), outlinerTree(), editor()];
13
+
14
+ describe('editor', () => {
15
+ test('empty', ({ expect }) => {
16
+ const state = EditorState.create({ extensions });
17
+ const tree = state.facet(treeFacet);
18
+ expect(tree).to.exist;
19
+ });
20
+
21
+ test('prevent moving out of range', ({ expect }) => {
22
+ const state = EditorState.create({ doc: '- [ ] ', extensions });
23
+ const spec = state.update({ selection: EditorSelection.cursor(1) });
24
+ expect(spec.state.selection.ranges[0].from).to.eq(6);
25
+ });
26
+
27
+ test.skip('prevent deleting task marker', ({ expect }) => {
28
+ const state = EditorState.create({ doc: '- [ ] ', extensions });
29
+ state.update({ selection: EditorSelection.cursor(6) });
30
+ const spec = state.update({ changes: { from: 5, to: 6 } });
31
+ expect(spec.state.doc.toString()).to.eq('- [ ] ');
32
+ });
33
+ });
@@ -0,0 +1,180 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type ChangeSpec, EditorSelection, EditorState } from '@codemirror/state';
6
+ import { type EditorView, ViewPlugin } from '@codemirror/view';
7
+
8
+ import { log } from '@dxos/log';
9
+
10
+ import { getSelection } from './selection';
11
+ import { treeFacet } from './tree';
12
+
13
+ const LIST_ITEM_REGEX = /^\s*- (\[ \]|\[x\])? /;
14
+
15
+ /**
16
+ * Initialize empty document.
17
+ */
18
+ const initialize = () => {
19
+ return ViewPlugin.fromClass(
20
+ class {
21
+ constructor(view: EditorView) {
22
+ const first = view.state.doc.lineAt(0);
23
+ const text = view.state.sliceDoc(first.from, first.to);
24
+ const match = text.match(LIST_ITEM_REGEX);
25
+ if (!match) {
26
+ setTimeout(() => {
27
+ const insert = '- [ ] ';
28
+ view.dispatch({
29
+ changes: [{ from: 0, to: 0, insert }],
30
+ selection: EditorSelection.cursor(insert.length),
31
+ });
32
+ });
33
+ }
34
+ }
35
+ },
36
+ );
37
+ };
38
+
39
+ /**
40
+ * Handle cursor movement, selection, and editing.
41
+ */
42
+ export const editor = () => [
43
+ initialize(),
44
+
45
+ EditorState.transactionFilter.of((tr) => {
46
+ const tree = tr.state.facet(treeFacet);
47
+
48
+ //
49
+ // Check cursor is in a valid position.
50
+ //
51
+ if (!tr.docChanged) {
52
+ const current = getSelection(tr.state).from;
53
+ if (current != null) {
54
+ const currentItem = tree.find(current);
55
+ if (!currentItem) {
56
+ return [];
57
+ }
58
+
59
+ // Check if outside of editable range.
60
+ if (current < currentItem.contentRange.from || current > currentItem.contentRange.to) {
61
+ const prev = getSelection(tr.startState).from;
62
+ const prevItem = prev != null ? tree.find(prev) : undefined;
63
+ if (!prevItem) {
64
+ return [{ selection: EditorSelection.cursor(currentItem.contentRange.from) }];
65
+ } else {
66
+ if (currentItem.index < prevItem.index) {
67
+ // Moving line up.
68
+ return [{ selection: EditorSelection.cursor(currentItem.contentRange.to) }];
69
+ } else if (currentItem.index > prevItem.index) {
70
+ // Moving line down.
71
+ return [{ selection: EditorSelection.cursor(currentItem.contentRange.from) }];
72
+ } else {
73
+ // Moving left.
74
+ if (current < prev) {
75
+ if (currentItem.index === 0) {
76
+ // At start of the list.
77
+ return [];
78
+ } else {
79
+ // Go to previous line.
80
+ return [{ selection: EditorSelection.cursor(currentItem.lineRange.from - 1) }];
81
+ }
82
+ } else {
83
+ // Go to end of line.
84
+ return [{ selection: EditorSelection.cursor(currentItem.contentRange.to) }];
85
+ }
86
+ }
87
+ }
88
+ }
89
+ }
90
+
91
+ return tr;
92
+ }
93
+
94
+ //
95
+ // Validate changes that don't break the tree.
96
+ //
97
+ let cancel = false;
98
+ const changes: ChangeSpec[] = [];
99
+ tr.changes.iterChanges((fromA, toA, fromB, toB, insert) => {
100
+ const line = tr.startState.doc.lineAt(fromA);
101
+ const match = line.text.match(LIST_ITEM_REGEX);
102
+ if (match) {
103
+ // Check cursor was in a valid position.
104
+ // const startTree = tr.startState.facet(treeFacet);
105
+ // const startItem = startTree.find(tr.startState.selection.main.from);
106
+
107
+ // Check if entire line was deleted (which is ok).
108
+ // const deleteLine = fromA === startItem?.lineRange.from && toA === startItem?.lineRange.to;
109
+ // if (!deleteLine && (!startItem || fromA < startItem.contentRange.from || toA > startItem.contentRange.to)) {
110
+ // cancel = true;
111
+ // return;
112
+ // }
113
+
114
+ // Check valid item.
115
+ const currentItem = tree.find(tr.state.selection.main.from);
116
+ if (!currentItem?.contentRange) {
117
+ cancel = true;
118
+ return;
119
+ }
120
+
121
+ // Detect and cancel replacement of task marker with continuation indent.
122
+ // Task markers are atomic so will be deleted when backspace is pressed.
123
+ // The markdown extension inserts 2 or 6 spaces when deleting a list or task marker in order to create a continuation.
124
+ // - [ ] <- backspace here deletes the task marker.
125
+ // - [ ] <- backspace here inserts 6 spaces (creates continuation).
126
+ // - [ ] <- backspace here deletes the task marker.
127
+ const start = line.from + (match?.[0]?.length ?? 0);
128
+ const replace = start === toA && toA - fromA === insert.length;
129
+ if (replace) {
130
+ changes.push({ from: line.from - 1, to: toA });
131
+ return;
132
+ }
133
+
134
+ // Detect deletion of marker.
135
+ if (fromB === toB) {
136
+ if (toA === line.to) {
137
+ const line = tr.state.doc.lineAt(fromA);
138
+ if (line.text.match(/^\s*$/)) {
139
+ if (line.from === 0) {
140
+ // Don't delete first line.
141
+ cancel = true;
142
+ return;
143
+ } else {
144
+ // Delete indent and marker.
145
+ changes.push({ from: line.from - 1, to: toA });
146
+ return;
147
+ }
148
+ }
149
+ }
150
+ return;
151
+ }
152
+
153
+ // Prevent newline if line is empty.
154
+ const item = tree.find(fromA);
155
+ if (item?.contentRange.from === item?.contentRange.to && fromA === toA) {
156
+ cancel = true;
157
+ return;
158
+ }
159
+
160
+ log('change', {
161
+ item,
162
+ line: { from: line.from, to: line.to },
163
+ a: [fromA, toA],
164
+ b: [fromB, toB],
165
+ insert: { text: insert.toString(), length: insert.length },
166
+ });
167
+ }
168
+ });
169
+
170
+ if (changes.length > 0) {
171
+ log('modified,', { changes });
172
+ return [{ changes }];
173
+ } else if (cancel) {
174
+ log('cancel');
175
+ return [];
176
+ }
177
+
178
+ return tr;
179
+ }),
180
+ ];
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './outliner';
6
+ export * from './tree';