@dxos/ui-editor 0.0.0

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 (93) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +21 -0
  3. package/package.json +121 -0
  4. package/src/defaults.ts +34 -0
  5. package/src/extensions/annotations.ts +55 -0
  6. package/src/extensions/autocomplete/autocomplete.ts +151 -0
  7. package/src/extensions/autocomplete/index.ts +8 -0
  8. package/src/extensions/autocomplete/match.ts +46 -0
  9. package/src/extensions/autocomplete/placeholder.ts +117 -0
  10. package/src/extensions/autocomplete/typeahead.ts +87 -0
  11. package/src/extensions/automerge/automerge.test.tsx +76 -0
  12. package/src/extensions/automerge/automerge.ts +105 -0
  13. package/src/extensions/automerge/cursor.ts +28 -0
  14. package/src/extensions/automerge/defs.ts +31 -0
  15. package/src/extensions/automerge/index.ts +5 -0
  16. package/src/extensions/automerge/sync.ts +79 -0
  17. package/src/extensions/automerge/update-automerge.ts +50 -0
  18. package/src/extensions/automerge/update-codemirror.ts +115 -0
  19. package/src/extensions/autoscroll.ts +165 -0
  20. package/src/extensions/awareness/awareness-provider.ts +127 -0
  21. package/src/extensions/awareness/awareness.ts +315 -0
  22. package/src/extensions/awareness/index.ts +6 -0
  23. package/src/extensions/blast.ts +363 -0
  24. package/src/extensions/blocks.ts +131 -0
  25. package/src/extensions/bookmarks.ts +77 -0
  26. package/src/extensions/comments.ts +579 -0
  27. package/src/extensions/debug.ts +15 -0
  28. package/src/extensions/dnd.ts +39 -0
  29. package/src/extensions/factories.ts +284 -0
  30. package/src/extensions/focus.ts +36 -0
  31. package/src/extensions/folding.ts +63 -0
  32. package/src/extensions/hashtag.ts +68 -0
  33. package/src/extensions/index.ts +34 -0
  34. package/src/extensions/json.ts +57 -0
  35. package/src/extensions/listener.ts +32 -0
  36. package/src/extensions/markdown/action.ts +117 -0
  37. package/src/extensions/markdown/bundle.ts +105 -0
  38. package/src/extensions/markdown/changes.test.ts +26 -0
  39. package/src/extensions/markdown/changes.ts +149 -0
  40. package/src/extensions/markdown/debug.ts +44 -0
  41. package/src/extensions/markdown/decorate.ts +622 -0
  42. package/src/extensions/markdown/formatting.test.ts +498 -0
  43. package/src/extensions/markdown/formatting.ts +1265 -0
  44. package/src/extensions/markdown/highlight.ts +183 -0
  45. package/src/extensions/markdown/image.ts +118 -0
  46. package/src/extensions/markdown/index.ts +13 -0
  47. package/src/extensions/markdown/link.ts +50 -0
  48. package/src/extensions/markdown/parser.test.ts +75 -0
  49. package/src/extensions/markdown/styles.ts +135 -0
  50. package/src/extensions/markdown/table.ts +150 -0
  51. package/src/extensions/mention.ts +41 -0
  52. package/src/extensions/modal.ts +24 -0
  53. package/src/extensions/modes.ts +41 -0
  54. package/src/extensions/outliner/commands.ts +270 -0
  55. package/src/extensions/outliner/editor.test.ts +33 -0
  56. package/src/extensions/outliner/editor.ts +184 -0
  57. package/src/extensions/outliner/index.ts +7 -0
  58. package/src/extensions/outliner/menu.ts +128 -0
  59. package/src/extensions/outliner/outliner.test.ts +100 -0
  60. package/src/extensions/outliner/outliner.ts +167 -0
  61. package/src/extensions/outliner/selection.ts +50 -0
  62. package/src/extensions/outliner/tree.test.ts +168 -0
  63. package/src/extensions/outliner/tree.ts +317 -0
  64. package/src/extensions/preview/index.ts +5 -0
  65. package/src/extensions/preview/preview.ts +193 -0
  66. package/src/extensions/replacer.test.ts +75 -0
  67. package/src/extensions/replacer.ts +93 -0
  68. package/src/extensions/scrolling.ts +189 -0
  69. package/src/extensions/selection.ts +100 -0
  70. package/src/extensions/state.ts +7 -0
  71. package/src/extensions/submit.ts +62 -0
  72. package/src/extensions/tags/extended-markdown.test.ts +263 -0
  73. package/src/extensions/tags/extended-markdown.ts +78 -0
  74. package/src/extensions/tags/index.ts +7 -0
  75. package/src/extensions/tags/streamer.ts +243 -0
  76. package/src/extensions/tags/xml-tags.ts +507 -0
  77. package/src/extensions/tags/xml-util.test.ts +48 -0
  78. package/src/extensions/tags/xml-util.ts +93 -0
  79. package/src/extensions/typewriter.ts +68 -0
  80. package/src/index.ts +14 -0
  81. package/src/styles/index.ts +7 -0
  82. package/src/styles/markdown.ts +26 -0
  83. package/src/styles/theme.ts +293 -0
  84. package/src/styles/tokens.ts +17 -0
  85. package/src/types/index.ts +5 -0
  86. package/src/types/types.ts +32 -0
  87. package/src/util/cursor.ts +56 -0
  88. package/src/util/debug.ts +56 -0
  89. package/src/util/decorations.ts +21 -0
  90. package/src/util/dom.ts +36 -0
  91. package/src/util/facet.ts +13 -0
  92. package/src/util/index.ts +10 -0
  93. package/src/util/util.ts +29 -0
@@ -0,0 +1,150 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import {
7
+ type EditorState,
8
+ type Extension,
9
+ type RangeSet,
10
+ RangeSetBuilder,
11
+ StateField,
12
+ type Transaction,
13
+ } from '@codemirror/state';
14
+ import { Decoration, EditorView, WidgetType } from '@codemirror/view';
15
+
16
+ // TODO(burdon): Snippet to create basic table.
17
+ // https://codemirror.net/docs/ref/#autocomplete.snippet
18
+ // TODO(burdon): Advanced formatting (left/right/center).
19
+ // TODO(burdon): Editor to auto balance columns.
20
+
21
+ export type TableOptions = {};
22
+
23
+ /**
24
+ * GFM tables.
25
+ * https://github.github.com/gfm/#tables-extension
26
+ */
27
+ export const table = (options: TableOptions = {}): Extension => {
28
+ return StateField.define<RangeSet<Decoration>>({
29
+ create: (state) => update(state, options),
30
+ update: (_: RangeSet<Decoration>, tr: Transaction) => update(tr.state, options),
31
+ provide: (field) => EditorView.decorations.from(field),
32
+ });
33
+ };
34
+
35
+ type Table = {
36
+ from: number;
37
+ to: number;
38
+ header?: string[];
39
+ rows?: string[][];
40
+ };
41
+
42
+ const update = (state: EditorState, _options: TableOptions) => {
43
+ const builder = new RangeSetBuilder<Decoration>();
44
+ const cursor = state.selection.main.head;
45
+
46
+ const tables: Table[] = [];
47
+ const getTable = () => tables[tables.length - 1];
48
+ const getRow = () => {
49
+ const table = getTable();
50
+ return table.rows?.[table.rows.length - 1];
51
+ };
52
+
53
+ // Parse table.
54
+ syntaxTree(state).iterate({
55
+ enter: (node) => {
56
+ // Check if cursor is inside text.
57
+ switch (node.name) {
58
+ case 'Table': {
59
+ tables.push({ from: node.from, to: node.to });
60
+ break;
61
+ }
62
+ case 'TableHeader': {
63
+ getTable().header = [];
64
+ break;
65
+ }
66
+ case 'TableRow': {
67
+ (getTable().rows ??= []).push([]);
68
+ break;
69
+ }
70
+ case 'TableCell': {
71
+ const row = getRow();
72
+ if (row) {
73
+ row.push(state.sliceDoc(node.from, node.to));
74
+ } else {
75
+ getTable().header?.push(state.sliceDoc(node.from, node.to));
76
+ }
77
+ break;
78
+ }
79
+ }
80
+ },
81
+ });
82
+
83
+ tables.forEach((table) => {
84
+ const replace = state.readOnly || cursor < table.from || cursor > table.to;
85
+ if (replace) {
86
+ builder.add(
87
+ table.from,
88
+ table.to,
89
+ Decoration.replace({
90
+ block: true,
91
+ widget: new TableWidget(table),
92
+ }),
93
+ );
94
+ } else {
95
+ // Add class for styling.
96
+ // TODO(burdon): Apply to each line?
97
+ builder.add(
98
+ table.from,
99
+ table.to,
100
+ Decoration.mark({
101
+ class: 'cm-table',
102
+ }),
103
+ );
104
+ }
105
+ });
106
+
107
+ return builder.finish();
108
+ };
109
+
110
+ class TableWidget extends WidgetType {
111
+ constructor(readonly _table: Table) {
112
+ super();
113
+ }
114
+
115
+ override eq(other: this) {
116
+ return (
117
+ this._table.header?.join() === other._table.header?.join() &&
118
+ this._table.rows?.join() === other._table.rows?.join()
119
+ );
120
+ }
121
+
122
+ override ignoreEvent(e: Event): boolean {
123
+ return !/^mouse/.test(e.type);
124
+ }
125
+
126
+ override toDOM(_view: EditorView) {
127
+ const div = document.createElement('div');
128
+ const table = div.appendChild(document.createElement('table'));
129
+
130
+ const header = table.appendChild(document.createElement('thead'));
131
+ const tr = header.appendChild(document.createElement('tr'));
132
+ this._table.header?.forEach((cell) => {
133
+ const th = document.createElement('th');
134
+ th.setAttribute('class', 'cm-table-head');
135
+ tr.appendChild(th).textContent = cell;
136
+ });
137
+
138
+ const body = table.appendChild(document.createElement('tbody'));
139
+ this._table.rows?.forEach((row) => {
140
+ const tr = body.appendChild(document.createElement('tr'));
141
+ row.forEach((cell) => {
142
+ const td = document.createElement('td');
143
+ td.setAttribute('class', 'cm-table-cell');
144
+ tr.appendChild(td).textContent = cell;
145
+ });
146
+ });
147
+
148
+ return div;
149
+ }
150
+ }
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type CompletionContext, type CompletionResult, autocompletion } from '@codemirror/autocomplete';
6
+ import type { Extension } from '@codemirror/state';
7
+
8
+ import { log } from '@dxos/log';
9
+
10
+ export type MentionOptions = {
11
+ debug?: boolean;
12
+ onSearch: (text: string) => string[];
13
+ };
14
+
15
+ // TODO(burdon): Can only have a single autocompletion. Merge configuration with autocomplete.
16
+ export const mention = ({ debug, onSearch }: MentionOptions): Extension => {
17
+ return autocompletion({
18
+ // TODO(burdon): Not working.
19
+ activateOnTyping: true,
20
+ // activateOnTypingDelay: 100,
21
+ // selectOnOpen: true,
22
+ closeOnBlur: !debug,
23
+ // defaultKeymap: false,
24
+ icons: false,
25
+ override: [
26
+ (context: CompletionContext): CompletionResult | null => {
27
+ log.info('completion context', { context });
28
+
29
+ const match = context.matchBefore(/@(\w+)?/);
30
+ if (!match || (match.from === match.to && !context.explicit)) {
31
+ return null;
32
+ }
33
+
34
+ return {
35
+ from: match.from,
36
+ options: onSearch(match.text.slice(1).toLowerCase()).map((value) => ({ label: `@${value}` })),
37
+ };
38
+ },
39
+ ],
40
+ });
41
+ };
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { StateEffect, StateField } from '@codemirror/state';
6
+
7
+ export const modalStateEffect = StateEffect.define<boolean>();
8
+
9
+ /**
10
+ * Determines if a modal dialog (e.g., popover) is active.
11
+ */
12
+ export const modalStateField = StateField.define<boolean>({
13
+ create: () => false,
14
+ update: (value, tr) => {
15
+ let newValue = value;
16
+ for (const effect of tr.effects) {
17
+ if (effect.is(modalStateEffect)) {
18
+ newValue = effect.value;
19
+ }
20
+ }
21
+
22
+ return newValue;
23
+ },
24
+ });
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { keymap } from '@codemirror/view';
7
+ import { vim } from '@replit/codemirror-vim';
8
+ import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
9
+
10
+ import { singleValueFacet } from '../util';
11
+
12
+ export type EditorInputConfig = {
13
+ type?: string;
14
+ ignoreEscape?: boolean;
15
+ };
16
+
17
+ export const editorInputMode = singleValueFacet<EditorInputConfig>({});
18
+
19
+ export const InputModeExtensions: { [mode: string]: Extension } = {
20
+ default: [],
21
+ vscode: [
22
+ // https://github.com/replit/codemirror-vscode-keymap
23
+ editorInputMode.of({ type: 'vscode' }),
24
+ keymap.of(vscodeKeymap),
25
+ ],
26
+ vim: [
27
+ // https://github.com/replit/codemirror-vim
28
+ vim(),
29
+ editorInputMode.of({ type: 'vim', ignoreEscape: true }),
30
+ keymap.of([
31
+ {
32
+ key: 'Alt-Escape',
33
+ run: (view) => {
34
+ // Focus container for tab navigation.
35
+ view.dom.parentElement?.focus();
36
+ return true;
37
+ },
38
+ },
39
+ ]),
40
+ ],
41
+ };
@@ -0,0 +1,270 @@
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 deleteItem: Command = (view: EditorView) => {
127
+ const tree = view.state.facet(treeFacet);
128
+ const pos = getSelection(view.state).from;
129
+ const current = tree.find(pos);
130
+ if (current) {
131
+ view.dispatch({
132
+ selection: EditorSelection.cursor(current.lineRange.from),
133
+ changes: [
134
+ {
135
+ from: current.lineRange.from,
136
+ to: Math.min(current.lineRange.to + 1, view.state.doc.length),
137
+ },
138
+ ],
139
+ });
140
+ }
141
+
142
+ return true;
143
+ };
144
+
145
+ export const toggleTask: Command = (view: EditorView) => {
146
+ const tree = view.state.facet(treeFacet);
147
+ const pos = getSelection(view.state)?.from;
148
+ const current = tree.find(pos);
149
+ if (current) {
150
+ const type = current.type === 'task' ? 'bullet' : 'task';
151
+ const indent = ' '.repeat(getIndentUnit(view.state) * current.level);
152
+ view.dispatch({
153
+ changes: [
154
+ {
155
+ from: current.lineRange.from,
156
+ to: current.contentRange.from,
157
+ insert: indent + (type === 'task' ? '- [ ] ' : '- '),
158
+ },
159
+ ],
160
+ });
161
+ }
162
+
163
+ return true;
164
+ };
165
+
166
+ export const commands = (): Extension =>
167
+ keymap.of([
168
+ //
169
+ // Indentation.
170
+ //
171
+ {
172
+ key: 'Tab',
173
+ preventDefault: true,
174
+ run: indentItemMore,
175
+ shift: indentItemLess,
176
+ },
177
+
178
+ //
179
+ // Continuation.
180
+ //
181
+ {
182
+ key: 'Enter',
183
+ shift: (view) => {
184
+ const pos = getSelection(view.state).from;
185
+ const insert = '\n '; // TODO(burdon): Fix parsing.
186
+ view.dispatch({
187
+ changes: [{ from: pos, to: pos, insert }],
188
+ selection: EditorSelection.cursor(pos + insert.length),
189
+ });
190
+ return true;
191
+ },
192
+ },
193
+
194
+ //
195
+ // Navigation.
196
+ //
197
+ {
198
+ key: 'ArrowDown',
199
+ // Jump to next item (default moves to end of currentline).
200
+ run: (view) => {
201
+ const tree = view.state.facet(treeFacet);
202
+ const item = tree.find(getSelection(view.state).from);
203
+ if (
204
+ item &&
205
+ view.state.doc.lineAt(item.lineRange.to).number - view.state.doc.lineAt(item.lineRange.from).number === 0
206
+ ) {
207
+ const next = tree.next(item);
208
+ if (next) {
209
+ view.dispatch({ selection: EditorSelection.cursor(next.contentRange.from) });
210
+ return true;
211
+ }
212
+ }
213
+
214
+ return false;
215
+ },
216
+ },
217
+
218
+ //
219
+ // Line selection.
220
+ // TODO(burdon): Shortcut to select current item?
221
+ //
222
+ {
223
+ key: 'Mod-a',
224
+ preventDefault: true,
225
+ run: selectAll,
226
+ },
227
+ {
228
+ key: 'Escape',
229
+ preventDefault: true,
230
+ run: selectNone,
231
+ },
232
+ {
233
+ key: 'ArrowUp',
234
+ shift: selectUp,
235
+ },
236
+ {
237
+ key: 'ArrowDown',
238
+ shift: selectDown,
239
+ },
240
+
241
+ //
242
+ // Move.
243
+ //
244
+ {
245
+ key: 'Alt-ArrowDown',
246
+ preventDefault: true,
247
+ run: moveItemDown,
248
+ },
249
+ {
250
+ key: 'Alt-ArrowUp',
251
+ preventDefault: true,
252
+ run: moveItemUp,
253
+ },
254
+ //
255
+ // Delete.
256
+ //
257
+ {
258
+ key: 'Mod-Backspace',
259
+ preventDefault: true,
260
+ run: deleteItem,
261
+ },
262
+ //
263
+ // Misc.
264
+ //
265
+ {
266
+ key: 'Alt-t',
267
+ preventDefault: true,
268
+ run: toggleTask,
269
+ },
270
+ ]);
@@ -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
+ });