@dxos/react-ui-editor 0.8.2-main.f11618f → 0.8.2-staging.42af850

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 (247) hide show
  1. package/dist/lib/browser/index.mjs +4450 -3278
  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 +2701 -1528
  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 +4450 -3278
  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 +1 -1
  17. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  18. package/dist/types/src/components/EditorToolbar/blocks.d.ts +4 -3
  19. package/dist/types/src/components/EditorToolbar/blocks.d.ts.map +1 -1
  20. package/dist/types/src/components/EditorToolbar/formatting.d.ts +4 -3
  21. package/dist/types/src/components/EditorToolbar/formatting.d.ts.map +1 -1
  22. package/dist/types/src/components/EditorToolbar/headings.d.ts +4 -3
  23. package/dist/types/src/components/EditorToolbar/headings.d.ts.map +1 -1
  24. package/dist/types/src/components/EditorToolbar/{comment.d.ts → image.d.ts} +4 -5
  25. package/dist/types/src/components/EditorToolbar/image.d.ts.map +1 -0
  26. package/dist/types/src/components/EditorToolbar/index.d.ts +1 -1
  27. package/dist/types/src/components/EditorToolbar/index.d.ts.map +1 -1
  28. package/dist/types/src/components/EditorToolbar/lists.d.ts +4 -3
  29. package/dist/types/src/components/EditorToolbar/lists.d.ts.map +1 -1
  30. package/dist/types/src/components/EditorToolbar/search.d.ts +17 -0
  31. package/dist/types/src/components/EditorToolbar/search.d.ts.map +1 -0
  32. package/dist/types/src/components/EditorToolbar/util.d.ts +14 -22
  33. package/dist/types/src/components/EditorToolbar/util.d.ts.map +1 -1
  34. package/dist/types/src/components/EditorToolbar/view-mode.d.ts +4 -3
  35. package/dist/types/src/components/EditorToolbar/view-mode.d.ts.map +1 -1
  36. package/dist/types/src/components/Popover/RefDropdownMenu.d.ts +21 -0
  37. package/dist/types/src/components/Popover/RefDropdownMenu.d.ts.map +1 -0
  38. package/dist/types/src/{testing → components/Popover}/RefPopover.d.ts +1 -1
  39. package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -0
  40. package/dist/types/src/components/Popover/index.d.ts +3 -0
  41. package/dist/types/src/components/Popover/index.d.ts.map +1 -0
  42. package/dist/types/src/components/index.d.ts +1 -0
  43. package/dist/types/src/components/index.d.ts.map +1 -1
  44. package/dist/types/src/defaults.d.ts +2 -5
  45. package/dist/types/src/defaults.d.ts.map +1 -1
  46. package/dist/types/src/extensions/annotations.d.ts +4 -1
  47. package/dist/types/src/extensions/annotations.d.ts.map +1 -1
  48. package/dist/types/src/extensions/autocomplete.d.ts +1 -2
  49. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  50. package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
  51. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
  52. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  53. package/dist/types/src/extensions/automerge/defs.d.ts +1 -1
  54. package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
  55. package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
  56. package/dist/types/src/extensions/automerge/update-automerge.d.ts +1 -1
  57. package/dist/types/src/extensions/automerge/update-automerge.d.ts.map +1 -1
  58. package/dist/types/src/extensions/automerge/update-codemirror.d.ts +1 -1
  59. package/dist/types/src/extensions/automerge/update-codemirror.d.ts.map +1 -1
  60. package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
  61. package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
  62. package/dist/types/src/extensions/blast.d.ts.map +1 -1
  63. package/dist/types/src/extensions/command/command.d.ts +1 -2
  64. package/dist/types/src/extensions/command/command.d.ts.map +1 -1
  65. package/dist/types/src/extensions/command/hint.d.ts +14 -2
  66. package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
  67. package/dist/types/src/extensions/command/index.d.ts +2 -0
  68. package/dist/types/src/extensions/command/index.d.ts.map +1 -1
  69. package/dist/types/src/extensions/command/menu.d.ts +4 -14
  70. package/dist/types/src/extensions/command/menu.d.ts.map +1 -1
  71. package/dist/types/src/extensions/command/state.d.ts +1 -1
  72. package/dist/types/src/extensions/command/state.d.ts.map +1 -1
  73. package/dist/types/src/extensions/command/typeahead.d.ts +17 -0
  74. package/dist/types/src/extensions/command/typeahead.d.ts.map +1 -0
  75. package/dist/types/src/extensions/comments.d.ts +2 -12
  76. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  77. package/dist/types/src/extensions/debug.d.ts.map +1 -1
  78. package/dist/types/src/extensions/dnd.d.ts.map +1 -1
  79. package/dist/types/src/extensions/factories.d.ts +4 -0
  80. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  81. package/dist/types/src/extensions/folding.d.ts.map +1 -1
  82. package/dist/types/src/extensions/index.d.ts +2 -0
  83. package/dist/types/src/extensions/index.d.ts.map +1 -1
  84. package/dist/types/src/extensions/json.d.ts +7 -0
  85. package/dist/types/src/extensions/json.d.ts.map +1 -0
  86. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  87. package/dist/types/src/extensions/markdown/{editorAction.d.ts → action.d.ts} +1 -1
  88. package/dist/types/src/extensions/markdown/action.d.ts.map +1 -0
  89. package/dist/types/src/extensions/markdown/bundle.d.ts +2 -1
  90. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  91. package/dist/types/src/extensions/markdown/changes.d.ts.map +1 -1
  92. package/dist/types/src/extensions/markdown/debug.d.ts.map +1 -1
  93. package/dist/types/src/extensions/markdown/decorate.d.ts +1 -0
  94. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  95. package/dist/types/src/extensions/markdown/formatting.d.ts +1 -1
  96. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  97. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  98. package/dist/types/src/extensions/markdown/image.d.ts.map +1 -1
  99. package/dist/types/src/extensions/markdown/index.d.ts +1 -1
  100. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  101. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  102. package/dist/types/src/extensions/markdown/styles.d.ts.map +1 -1
  103. package/dist/types/src/extensions/markdown/table.d.ts.map +1 -1
  104. package/dist/types/src/extensions/mention.d.ts.map +1 -1
  105. package/dist/types/src/extensions/modes.d.ts +5 -5
  106. package/dist/types/src/extensions/modes.d.ts.map +1 -1
  107. package/dist/types/src/extensions/outliner/commands.d.ts +10 -0
  108. package/dist/types/src/extensions/outliner/commands.d.ts.map +1 -0
  109. package/dist/types/src/extensions/outliner/editor.d.ts +5 -0
  110. package/dist/types/src/extensions/outliner/editor.d.ts.map +1 -0
  111. package/dist/types/src/extensions/outliner/editor.test.d.ts +2 -0
  112. package/dist/types/src/extensions/outliner/editor.test.d.ts.map +1 -0
  113. package/dist/types/src/extensions/outliner/index.d.ts +4 -0
  114. package/dist/types/src/extensions/outliner/index.d.ts.map +1 -0
  115. package/dist/types/src/extensions/outliner/outliner.d.ts +13 -0
  116. package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -0
  117. package/dist/types/src/extensions/outliner/outliner.test.d.ts +2 -0
  118. package/dist/types/src/extensions/outliner/outliner.test.d.ts.map +1 -0
  119. package/dist/types/src/extensions/outliner/selection.d.ts +12 -0
  120. package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -0
  121. package/dist/types/src/extensions/outliner/tree.d.ts +79 -0
  122. package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -0
  123. package/dist/types/src/extensions/outliner/tree.test.d.ts +2 -0
  124. package/dist/types/src/extensions/outliner/tree.test.d.ts.map +1 -0
  125. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  126. package/dist/types/src/extensions/selection.d.ts.map +1 -1
  127. package/dist/types/src/extensions/typewriter.d.ts.map +1 -1
  128. package/dist/types/src/hooks/index.d.ts +0 -1
  129. package/dist/types/src/hooks/index.d.ts.map +1 -1
  130. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  131. package/dist/types/src/stories/Command.stories.d.ts +7 -0
  132. package/dist/types/src/stories/Command.stories.d.ts.map +1 -0
  133. package/dist/types/src/stories/{TextEditorComments.stories.d.ts → Comments.stories.d.ts} +3 -3
  134. package/dist/types/src/stories/Comments.stories.d.ts.map +1 -0
  135. package/dist/types/src/stories/EditorToolbar.stories.d.ts +12 -0
  136. package/dist/types/src/stories/EditorToolbar.stories.d.ts.map +1 -0
  137. package/dist/types/src/stories/{TextEditorSpecial.stories.d.ts → Experimental.stories.d.ts} +3 -6
  138. package/dist/types/src/stories/Experimental.stories.d.ts.map +1 -0
  139. package/dist/types/src/stories/Markdown.stories.d.ts +46 -0
  140. package/dist/types/src/stories/Markdown.stories.d.ts.map +1 -0
  141. package/dist/types/src/stories/Outliner.stories.d.ts +26 -0
  142. package/dist/types/src/stories/Outliner.stories.d.ts.map +1 -0
  143. package/dist/types/src/stories/Preview.stories.d.ts +10 -0
  144. package/dist/types/src/stories/Preview.stories.d.ts.map +1 -0
  145. package/dist/types/src/stories/{TextEditorBasic.stories.d.ts → TextEditor.stories.d.ts} +9 -36
  146. package/dist/types/src/stories/TextEditor.stories.d.ts.map +1 -0
  147. package/dist/types/src/stories/{story-utils.d.ts → util.d.ts} +6 -6
  148. package/dist/types/src/stories/util.d.ts.map +1 -0
  149. package/dist/types/src/styles/theme.d.ts.map +1 -1
  150. package/dist/types/src/styles/tokens.d.ts.map +1 -1
  151. package/dist/types/src/testing/index.d.ts +1 -1
  152. package/dist/types/src/testing/index.d.ts.map +1 -1
  153. package/dist/types/src/testing/util.d.ts +2 -0
  154. package/dist/types/src/testing/util.d.ts.map +1 -0
  155. package/dist/types/src/util/cursor.d.ts.map +1 -1
  156. package/dist/types/src/util/debug.d.ts.map +1 -1
  157. package/dist/types/src/util/dom.d.ts.map +1 -1
  158. package/dist/types/src/util/facet.d.ts.map +1 -1
  159. package/dist/types/src/util/react.d.ts.map +1 -1
  160. package/dist/types/tsconfig.tsbuildinfo +1 -1
  161. package/package.json +41 -31
  162. package/src/components/EditorToolbar/EditorToolbar.tsx +93 -70
  163. package/src/components/EditorToolbar/blocks.ts +27 -6
  164. package/src/components/EditorToolbar/formatting.ts +34 -7
  165. package/src/components/EditorToolbar/headings.ts +9 -8
  166. package/src/components/EditorToolbar/image.ts +16 -0
  167. package/src/components/EditorToolbar/index.ts +7 -1
  168. package/src/components/EditorToolbar/lists.ts +26 -7
  169. package/src/components/EditorToolbar/search.ts +19 -0
  170. package/src/components/EditorToolbar/util.ts +16 -17
  171. package/src/components/EditorToolbar/view-mode.ts +9 -8
  172. package/src/components/Popover/RefDropdownMenu.tsx +77 -0
  173. package/src/{testing → components/Popover}/RefPopover.tsx +5 -4
  174. package/src/components/Popover/index.ts +6 -0
  175. package/src/components/index.ts +1 -0
  176. package/src/defaults.ts +10 -13
  177. package/src/extensions/annotations.ts +41 -64
  178. package/src/extensions/autocomplete.ts +5 -6
  179. package/src/extensions/automerge/automerge.stories.tsx +11 -14
  180. package/src/extensions/automerge/automerge.test.tsx +6 -5
  181. package/src/extensions/automerge/automerge.ts +2 -2
  182. package/src/extensions/automerge/defs.ts +1 -2
  183. package/src/extensions/automerge/sync.ts +7 -7
  184. package/src/extensions/automerge/update-automerge.ts +1 -1
  185. package/src/extensions/automerge/update-codemirror.ts +3 -4
  186. package/src/extensions/awareness/awareness-provider.ts +4 -4
  187. package/src/extensions/awareness/awareness.ts +7 -7
  188. package/src/extensions/blast.ts +9 -9
  189. package/src/extensions/command/command.ts +1 -3
  190. package/src/extensions/command/hint.ts +7 -7
  191. package/src/extensions/command/index.ts +2 -0
  192. package/src/extensions/command/menu.ts +75 -50
  193. package/src/extensions/command/typeahead.ts +116 -0
  194. package/src/extensions/comments.ts +4 -69
  195. package/src/extensions/factories.ts +13 -0
  196. package/src/extensions/index.ts +2 -0
  197. package/src/extensions/json.ts +56 -0
  198. package/src/extensions/markdown/bundle.ts +13 -9
  199. package/src/extensions/markdown/changes.ts +3 -2
  200. package/src/extensions/markdown/decorate.ts +15 -14
  201. package/src/extensions/markdown/formatting.ts +4 -4
  202. package/src/extensions/markdown/image.ts +2 -2
  203. package/src/extensions/markdown/index.ts +1 -1
  204. package/src/extensions/markdown/styles.ts +4 -3
  205. package/src/extensions/markdown/table.ts +3 -3
  206. package/src/extensions/modes.ts +5 -6
  207. package/src/extensions/outliner/commands.ts +270 -0
  208. package/src/extensions/outliner/editor.test.ts +33 -0
  209. package/src/extensions/outliner/editor.ts +184 -0
  210. package/src/extensions/outliner/index.ts +7 -0
  211. package/src/extensions/outliner/outliner.test.ts +99 -0
  212. package/src/extensions/outliner/outliner.ts +168 -0
  213. package/src/extensions/outliner/selection.ts +50 -0
  214. package/src/extensions/outliner/tree.test.ts +164 -0
  215. package/src/extensions/outliner/tree.ts +315 -0
  216. package/src/extensions/preview/preview.ts +5 -5
  217. package/src/hooks/index.ts +0 -1
  218. package/src/stories/Command.stories.tsx +97 -0
  219. package/src/stories/{TextEditorComments.stories.tsx → Comments.stories.tsx} +13 -14
  220. package/src/stories/EditorToolbar.stories.tsx +96 -0
  221. package/src/stories/{TextEditorSpecial.stories.tsx → Experimental.stories.tsx} +9 -30
  222. package/src/stories/Markdown.stories.tsx +121 -0
  223. package/src/stories/Outliner.stories.tsx +108 -0
  224. package/src/stories/{TextEditorPreview.stories.tsx → Preview.stories.tsx} +46 -136
  225. package/src/stories/{TextEditorBasic.stories.tsx → TextEditor.stories.tsx} +78 -111
  226. package/src/stories/{story-utils.tsx → util.tsx} +28 -31
  227. package/src/styles/theme.ts +15 -5
  228. package/src/styles/tokens.ts +1 -2
  229. package/src/testing/index.ts +1 -1
  230. package/src/testing/util.ts +5 -0
  231. package/dist/types/src/components/EditorToolbar/comment.d.ts.map +0 -1
  232. package/dist/types/src/extensions/markdown/editorAction.d.ts.map +0 -1
  233. package/dist/types/src/hooks/useActionHandler.d.ts +0 -4
  234. package/dist/types/src/hooks/useActionHandler.d.ts.map +0 -1
  235. package/dist/types/src/stories/InputMode.stories.d.ts +0 -57
  236. package/dist/types/src/stories/InputMode.stories.d.ts.map +0 -1
  237. package/dist/types/src/stories/TextEditorBasic.stories.d.ts.map +0 -1
  238. package/dist/types/src/stories/TextEditorComments.stories.d.ts.map +0 -1
  239. package/dist/types/src/stories/TextEditorPreview.stories.d.ts +0 -13
  240. package/dist/types/src/stories/TextEditorPreview.stories.d.ts.map +0 -1
  241. package/dist/types/src/stories/TextEditorSpecial.stories.d.ts.map +0 -1
  242. package/dist/types/src/stories/story-utils.d.ts.map +0 -1
  243. package/dist/types/src/testing/RefPopover.d.ts.map +0 -1
  244. package/src/components/EditorToolbar/comment.ts +0 -23
  245. package/src/hooks/useActionHandler.ts +0 -12
  246. package/src/stories/InputMode.stories.tsx +0 -124
  247. /package/src/extensions/markdown/{editorAction.ts → action.ts} +0 -0
@@ -0,0 +1,184 @@
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 + 1;
109
+ if (deleteLine) {
110
+ return;
111
+ }
112
+
113
+ // if (!deleteLine && (!startItem || fromA < startItem.contentRange.from || toA > startItem.contentRange.to)) {
114
+ // cancel = true;
115
+ // return;
116
+ // }
117
+
118
+ // Check valid item.
119
+ const currentItem = tree.find(tr.state.selection.main.from);
120
+ if (!currentItem?.contentRange) {
121
+ cancel = true;
122
+ return;
123
+ }
124
+
125
+ // Detect and cancel replacement of task marker with continuation indent.
126
+ // Task markers are atomic so will be deleted when backspace is pressed.
127
+ // The markdown extension inserts 2 or 6 spaces when deleting a list or task marker in order to create a continuation.
128
+ // - [ ] <- backspace here deletes the task marker.
129
+ // - [ ] <- backspace here inserts 6 spaces (creates continuation).
130
+ // - [ ] <- backspace here deletes the task marker.
131
+ const start = line.from + (match?.[0]?.length ?? 0);
132
+ const replace = start === toA && toA - fromA === insert.length;
133
+ if (replace) {
134
+ changes.push({ from: line.from - 1, to: toA });
135
+ return;
136
+ }
137
+
138
+ // Detect deletion of marker.
139
+ if (fromB === toB) {
140
+ if (toA === line.to) {
141
+ const line = tr.state.doc.lineAt(fromA);
142
+ if (line.text.match(/^\s*$/)) {
143
+ if (line.from === 0) {
144
+ // Don't delete first line.
145
+ cancel = true;
146
+ return;
147
+ } else {
148
+ // Delete indent and marker.
149
+ changes.push({ from: line.from - 1, to: toA });
150
+ return;
151
+ }
152
+ }
153
+ }
154
+ return;
155
+ }
156
+
157
+ // Prevent newline if line is empty.
158
+ const item = tree.find(fromA);
159
+ if (item?.contentRange.from === item?.contentRange.to && fromA === toA) {
160
+ cancel = true;
161
+ return;
162
+ }
163
+
164
+ log('change', {
165
+ item,
166
+ line: { from: line.from, to: line.to },
167
+ a: [fromA, toA],
168
+ b: [fromB, toB],
169
+ insert: { text: insert.toString(), length: insert.length },
170
+ });
171
+ }
172
+ });
173
+
174
+ if (changes.length > 0) {
175
+ log('modified,', { changes });
176
+ return [{ changes }];
177
+ } else if (cancel) {
178
+ log('cancel');
179
+ return [];
180
+ }
181
+
182
+ return tr;
183
+ }),
184
+ ];
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ export * from './commands';
6
+ export * from './outliner';
7
+ export * from './tree';
@@ -0,0 +1,99 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { EditorSelection, EditorState } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { indentItemLess, indentItemMore, moveItemDown, moveItemUp } from './commands';
10
+ import { listItemToString, outlinerTree, treeFacet } from './tree';
11
+ import { str } from '../../testing';
12
+ import { createMarkdownExtensions } from '../markdown';
13
+
14
+ const lines = [
15
+ '- [ ] 1',
16
+ '- [ ] 2',
17
+ ' - [ ] 2.1',
18
+ ' - [ ] 2.2',
19
+ ' - 2.2.1',
20
+ ' - 2.2.2',
21
+ ' - 2.2.3',
22
+ ' - [ ] 2.3',
23
+ '- [ ] 3',
24
+ ];
25
+
26
+ const getPos = (line: number) => {
27
+ return lines.slice(0, line).reduce((acc, line) => acc + line.length + 1, 0);
28
+ };
29
+
30
+ const extensions = [createMarkdownExtensions(), outlinerTree()];
31
+
32
+ // TODO(burdon): Flaky.
33
+ describe.runIf(!process.env.CI)('outliner', () => {
34
+ const state = EditorState.create({ doc: str(...lines), extensions });
35
+
36
+ test('sanity', ({ expect }) => {
37
+ const tree = state.facet(treeFacet);
38
+ let i = 0;
39
+ tree.traverse((item, level) => {
40
+ const pos = getPos(i++);
41
+ expect(item.lineRange.from).toBe(pos);
42
+ console.log(listItemToString(item, level), pos);
43
+ });
44
+ });
45
+
46
+ test('indent', ({ expect }) => {
47
+ const view = new EditorView({ state });
48
+ const pos = getPos(1);
49
+
50
+ {
51
+ const tree = view.state.facet(treeFacet);
52
+ const item = tree.find(pos);
53
+ expect(item?.level).toBe(0);
54
+ }
55
+
56
+ view.dispatch({ selection: EditorSelection.cursor(pos) });
57
+ indentItemMore(view);
58
+
59
+ {
60
+ const tree = view.state.facet(treeFacet);
61
+ const item = tree.find(pos);
62
+ expect(item?.level).toBe(1);
63
+ }
64
+ });
65
+
66
+ test('unindent', ({ expect }) => {
67
+ const view = new EditorView({ state });
68
+ const pos = getPos(2);
69
+
70
+ {
71
+ const tree = view.state.facet(treeFacet);
72
+ const item = tree.find(pos);
73
+ expect(item?.level).toBe(1);
74
+ }
75
+
76
+ view.dispatch({ selection: EditorSelection.cursor(pos) });
77
+ indentItemLess(view);
78
+
79
+ {
80
+ const tree = view.state.facet(treeFacet);
81
+ const item = tree.find(pos);
82
+ expect(item?.level).toBe(0);
83
+ }
84
+ });
85
+
86
+ test('move down', ({ expect }) => {
87
+ const view = new EditorView({ state });
88
+ view.dispatch({ selection: EditorSelection.cursor(getPos(0)) });
89
+ moveItemDown(view);
90
+ expect(view.state.doc.sliceString(0, view.state.doc.length)).toBe(str(...lines.slice(1, 8), lines[0], lines[8]));
91
+ });
92
+
93
+ test('move up', ({ expect }) => {
94
+ const view = new EditorView({ state });
95
+ view.dispatch({ selection: EditorSelection.cursor(getPos(8)) });
96
+ moveItemUp(view);
97
+ expect(view.state.doc.sliceString(0, view.state.doc.length)).toBe(str(lines[0], lines[8], ...lines.slice(1, 8)));
98
+ });
99
+ });
@@ -0,0 +1,168 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type EditorState, type Extension, Prec, type Range } from '@codemirror/state';
6
+ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
7
+
8
+ import { mx } from '@dxos/react-ui-theme';
9
+
10
+ import { commands } from './commands';
11
+ import { editor } from './editor';
12
+ import { selectionCompartment, selectionFacet, selectionEquals } from './selection';
13
+ import { outlinerTree, treeFacet } from './tree';
14
+ import { floatingMenu } from '../command';
15
+ import { decorateMarkdown } from '../markdown';
16
+
17
+ // ISSUES:
18
+ // TODO(burdon): Remove requirement for continuous lines to be indented (so that user's can't accidentally delete them and break the layout).
19
+ // TODO(burdon): Prevent unterminated fenced code from breaking subsequent items ("firewall" markdown parsing within each item?)
20
+ // TODO(burdon): What if a different editor "breaks" the layout?
21
+ // TODO(burdon): Check Automerge recognizes text that is moved/indented (e.g., concurrent editing item while being moved).
22
+
23
+ // NEXT:
24
+ // TODO(burdon): Update selection when adding/removing items.
25
+ // TODO(burdon): When selecting across items, select entire items (don't show selection that spans the gaps).
26
+ // TODO(burdon): Handle backspace at start of line (or empty line).
27
+ // TODO(burdon): Convert to task object and insert link (menu button).
28
+ // TODO(burdon): Smart Cut-and-paste.
29
+ // TODO(burdon): DND.
30
+
31
+ export type OutlinerProps = {
32
+ showSelected?: boolean;
33
+ };
34
+
35
+ /**
36
+ * Outliner extension.
37
+ * - Stores outline as a standard markdown document with task and list markers.
38
+ * - Supports continuation lines and rich formatting (with Shift+Enter).
39
+ * - Constrains editor to outline structure.
40
+ * - Supports smart cut-and-paste.
41
+ */
42
+ export const outliner = (options: OutlinerProps = {}): Extension => [
43
+ // Commands.
44
+ Prec.highest(commands()),
45
+
46
+ // Selection.
47
+ selectionCompartment.of(selectionFacet.of([])),
48
+
49
+ // State.
50
+ outlinerTree(),
51
+
52
+ // Filter and possibly modify changes.
53
+ editor(),
54
+
55
+ // Floating menu.
56
+ floatingMenu(),
57
+
58
+ // Line decorations.
59
+ decorations(options),
60
+
61
+ // Default markdown decorations.
62
+ decorateMarkdown({ listPaddingLeft: 8 }),
63
+
64
+ // Researve space for menu.
65
+ EditorView.contentAttributes.of({ class: 'is-full !mr-[3rem]' }),
66
+ ];
67
+
68
+ /**
69
+ * Line decorations (for border and selection).
70
+ */
71
+ const decorations = (options: OutlinerProps) => [
72
+ ViewPlugin.fromClass(
73
+ class {
74
+ decorations: DecorationSet = Decoration.none;
75
+ constructor(view: EditorView) {
76
+ this.updateDecorations(view.state, view);
77
+ }
78
+
79
+ update(update: ViewUpdate) {
80
+ const selectionChanged = !selectionEquals(
81
+ update.state.facet(selectionFacet),
82
+ update.startState.facet(selectionFacet),
83
+ );
84
+
85
+ if (
86
+ update.focusChanged ||
87
+ update.docChanged ||
88
+ update.viewportChanged ||
89
+ update.selectionSet ||
90
+ selectionChanged
91
+ ) {
92
+ this.updateDecorations(update.state, update.view);
93
+ }
94
+ }
95
+
96
+ private updateDecorations(state: EditorState, { viewport: { from, to }, hasFocus }: EditorView) {
97
+ const selection = state.facet(selectionFacet);
98
+ const tree = state.facet(treeFacet);
99
+ const current = tree.find(state.selection.ranges[state.selection.mainIndex]?.from);
100
+ const doc = state.doc;
101
+
102
+ const decorations: Range<Decoration>[] = [];
103
+ for (let lineNum = doc.lineAt(from).number; lineNum <= doc.lineAt(to).number; lineNum++) {
104
+ const line = doc.line(lineNum);
105
+ const item = tree.find(line.from);
106
+ if (item) {
107
+ const lineFrom = doc.lineAt(item.contentRange.from);
108
+ const lineTo = doc.lineAt(item.contentRange.to);
109
+ const isSelected = selection.includes(item.index) || item === current;
110
+ decorations.push(
111
+ Decoration.line({
112
+ class: mx(
113
+ 'cm-list-item',
114
+ lineFrom.number === line.number && 'cm-list-item-start',
115
+ lineTo.number === line.number && 'cm-list-item-end',
116
+ isSelected && (hasFocus ? 'cm-list-item-focused' : 'cm-list-item-selected'),
117
+ ),
118
+ }).range(line.from, line.from),
119
+ );
120
+ }
121
+ }
122
+
123
+ this.decorations = Decoration.set(decorations);
124
+ }
125
+ },
126
+ {
127
+ decorations: (v) => v.decorations,
128
+ },
129
+ ),
130
+
131
+ // Theme.
132
+ EditorView.theme(
133
+ Object.assign({
134
+ '.cm-list-item': {
135
+ borderLeftWidth: '1px',
136
+ borderRightWidth: '1px',
137
+ paddingLeft: '32px',
138
+ borderColor: 'transparent',
139
+ },
140
+ '.cm-list-item.cm-codeblock-start': {
141
+ borderRadius: '0',
142
+ },
143
+
144
+ '.cm-list-item-start': {
145
+ borderTopWidth: '1px',
146
+ borderTopLeftRadius: '4px',
147
+ borderTopRightRadius: '4px',
148
+ paddingTop: '4px',
149
+ marginTop: '2px',
150
+ },
151
+
152
+ '.cm-list-item-end': {
153
+ borderBottomWidth: '1px',
154
+ borderBottomLeftRadius: '4px',
155
+ borderBottomRightRadius: '4px',
156
+ paddingBottom: '4px',
157
+ marginBottom: '2px',
158
+ },
159
+
160
+ '.cm-list-item-selected': {
161
+ borderColor: options.showSelected ? 'var(--dx-separator)' : undefined,
162
+ },
163
+ '.cm-list-item-focused': {
164
+ borderColor: 'var(--dx-accentFocusIndicator)',
165
+ },
166
+ }),
167
+ ),
168
+ ];
@@ -0,0 +1,50 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { Compartment, type EditorState, Facet, type SelectionRange } from '@codemirror/state';
6
+ import { type EditorView, type Command } from '@codemirror/view';
7
+
8
+ import { treeFacet } from './tree';
9
+
10
+ export type Selection = number[];
11
+
12
+ export const getSelection = (state: EditorState): SelectionRange => state.selection.main;
13
+
14
+ export const selectionEquals = (a: number[], b: number[]) => a.length === b.length && a.every((i) => b.includes(i));
15
+
16
+ export const selectionFacet = Facet.define<Selection, Selection>({
17
+ combine: (values) => values[0],
18
+ });
19
+
20
+ export const selectionCompartment = new Compartment();
21
+
22
+ export const selectNone: Command = (view: EditorView) => {
23
+ view.dispatch({
24
+ effects: selectionCompartment.reconfigure(selectionFacet.of([])),
25
+ });
26
+
27
+ return true;
28
+ };
29
+
30
+ export const selectAll: Command = (view: EditorView) => {
31
+ const tree = view.state.facet(treeFacet);
32
+ const selection = view.state.facet(selectionFacet);
33
+ const items: Selection = [];
34
+ tree.traverse((item) => items.push(item.index));
35
+ view.dispatch({
36
+ effects: selectionCompartment.reconfigure(selectionFacet.of(selectionEquals(selection, items) ? [] : items)),
37
+ });
38
+
39
+ return true;
40
+ };
41
+
42
+ // TODO(burdon): Implement.
43
+ export const selectUp: Command = (view: EditorView) => {
44
+ return true;
45
+ };
46
+
47
+ // TODO(burdon): Implement.
48
+ export const selectDown: Command = (view: EditorView) => {
49
+ return true;
50
+ };
@@ -0,0 +1,164 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
6
+ import { EditorState } from '@codemirror/state';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { outlinerTree, treeFacet, listItemToString, type Item } from './tree';
10
+ import { str } from '../../testing';
11
+ import { type Range } from '../../types';
12
+
13
+ const lines = [
14
+ '- [ ] 1',
15
+ '- [ ] 2',
16
+ ' - [ ] 2.1',
17
+ ' - [ ] 2.2',
18
+ ' - 2.2.1',
19
+ ' - 2.2.2',
20
+ ' - 2.2.3',
21
+ ' - [ ] 2.3',
22
+ '- [ ] 3',
23
+ ];
24
+
25
+ const getPos = (line: number) => {
26
+ return lines.slice(0, line).reduce((acc, line) => acc + line.length + 1, 0);
27
+ };
28
+
29
+ const extensions = [markdown({ base: markdownLanguage }), outlinerTree()];
30
+
31
+ describe('tree (boundary conditions)', () => {
32
+ test('empty', ({ expect }) => {
33
+ const state = EditorState.create({ doc: str(''), extensions });
34
+ const tree = state.facet(treeFacet);
35
+ expect(tree).to.exist;
36
+ });
37
+
38
+ test('content range', ({ expect }) => {
39
+ const state = EditorState.create({ doc: '- [ ] A', extensions });
40
+ const tree = state.facet(treeFacet);
41
+ console.log(JSON.stringify(tree, null, 2));
42
+ expect(tree.toJSON()).to.deep.eq({
43
+ type: 'root',
44
+ index: -1,
45
+ level: -1,
46
+ lineRange: { from: 0, to: -1 },
47
+ contentRange: { from: 0, to: -1 },
48
+ children: [
49
+ {
50
+ type: 'task',
51
+ index: 0,
52
+ level: 0,
53
+ lineRange: { from: 0, to: 7 },
54
+ contentRange: { from: 6, to: 7 },
55
+ children: [],
56
+ },
57
+ ],
58
+ });
59
+
60
+ const item = tree.find(0);
61
+ expect(item?.contentRange).to.include({ from: 6, to: state.doc.length });
62
+ });
63
+
64
+ test('empty continuation', ({ expect }) => {
65
+ const state = EditorState.create({ doc: str('- [ ] A', ' '), extensions });
66
+ const tree = state.facet(treeFacet);
67
+ tree.traverse((item, level) => {
68
+ console.log(listItemToString(item, level));
69
+ });
70
+ });
71
+ });
72
+
73
+ describe('tree (advanced)', () => {
74
+ const state = EditorState.create({ doc: str(...lines), extensions });
75
+
76
+ test('traverse', ({ expect }) => {
77
+ const tree = state.facet(treeFacet);
78
+ let count = 0;
79
+ tree.traverse((item, level) => {
80
+ console.log(listItemToString(item, level));
81
+ count++;
82
+ });
83
+ expect(count).toBe(9);
84
+ });
85
+
86
+ test('continguous', ({ expect }) => {
87
+ const tree = state.facet(treeFacet);
88
+ const ranges: Range[] = [];
89
+ tree.traverse((item) => {
90
+ ranges.push(item.lineRange);
91
+ console.log(listItemToString(item));
92
+ });
93
+
94
+ // Check no gaps between ranges.
95
+ expect(ranges[0].from).toBe(0);
96
+ expect(ranges[ranges.length - 1].to).toBe(state.doc.length);
97
+ for (let i = 0; i < ranges.length - 1; i++) {
98
+ const current = ranges[i];
99
+ const next = ranges[i + 1];
100
+ expect(current.to + 1).toBe(next.from);
101
+ }
102
+ });
103
+
104
+ test('find', ({ expect }) => {
105
+ const tree = state.facet(treeFacet);
106
+
107
+ expect(tree.find(0)).to.include({ type: 'task' });
108
+ expect(tree.find(state.doc.length)).to.include({ type: 'task' });
109
+
110
+ expect(tree.find(getPos(1))).to.include({ type: 'task' });
111
+ expect(tree.find(getPos(1))).toBe(tree.find(getPos(1) + 4));
112
+ expect(tree.find(getPos(5))).to.include({ type: 'bullet' });
113
+ });
114
+
115
+ test('siblings', ({ expect }) => {
116
+ const tree = state.facet(treeFacet);
117
+ const items: Item[] = [];
118
+ tree.traverse((item) => {
119
+ items.push(item);
120
+ });
121
+
122
+ expect(items[0].nextSibling).toBe(items[1]);
123
+ expect(items[1].prevSibling).toBe(items[0]);
124
+
125
+ expect(items[1].nextSibling).toBe(items[8]);
126
+ expect(items[8].prevSibling).toBe(items[1]);
127
+ });
128
+
129
+ test('next/previous', ({ expect }) => {
130
+ const tree = state.facet(treeFacet);
131
+ const items: Item[] = [];
132
+ tree.traverse((item) => {
133
+ items.push(item);
134
+ });
135
+
136
+ expect(tree.prev(items[0])).not.to.exist;
137
+ expect(tree.next(items[items.length - 1])).not.to.exist;
138
+
139
+ for (let i = 0; i < items.length - 1; i++) {
140
+ const current = items[i];
141
+ const next = items[i + 1];
142
+ expect(tree.next(current)?.index).toEqual(next.index);
143
+ expect(tree.prev(next)?.index).toEqual(current.index);
144
+ }
145
+ });
146
+
147
+ test('lastDescendant', ({ expect }) => {
148
+ const tree = state.facet(treeFacet);
149
+ {
150
+ const item = tree.find(getPos(0))!;
151
+ expect(tree.lastDescendant(item).index).toBe(item.index);
152
+ }
153
+ {
154
+ const item = tree.find(getPos(1))!;
155
+ const last = tree.find(getPos(7))!;
156
+ expect(tree.lastDescendant(item).index).toBe(last.index);
157
+ }
158
+ {
159
+ const item = tree.find(getPos(3))!;
160
+ const last = tree.find(getPos(6))!;
161
+ expect(tree.lastDescendant(item).index).toBe(last.index);
162
+ }
163
+ });
164
+ });