@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.
- package/dist/lib/browser/index.mjs +1731 -926
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/browser/testing/index.mjs +3 -64
- package/dist/lib/browser/testing/index.mjs.map +4 -4
- package/dist/lib/node/index.cjs +1912 -1111
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node/testing/index.cjs +3 -75
- package/dist/lib/node/testing/index.cjs.map +4 -4
- package/dist/lib/node-esm/index.mjs +1731 -926
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/lib/node-esm/testing/index.mjs +3 -64
- package/dist/lib/node-esm/testing/index.mjs.map +4 -4
- package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/index.d.ts +1 -1
- package/dist/types/src/components/EditorToolbar/index.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/util.d.ts +4 -6
- package/dist/types/src/components/EditorToolbar/util.d.ts.map +1 -1
- package/dist/types/src/components/Popover/RefDropdownMenu.d.ts +21 -0
- package/dist/types/src/components/Popover/RefDropdownMenu.d.ts.map +1 -0
- package/dist/types/src/{testing → components/Popover}/RefPopover.d.ts +1 -1
- package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -0
- package/dist/types/src/components/Popover/index.d.ts +3 -0
- package/dist/types/src/components/Popover/index.d.ts.map +1 -0
- package/dist/types/src/components/index.d.ts +1 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/defaults.d.ts +2 -5
- package/dist/types/src/defaults.d.ts.map +1 -1
- package/dist/types/src/extensions/annotations.d.ts +4 -1
- package/dist/types/src/extensions/annotations.d.ts.map +1 -1
- package/dist/types/src/extensions/autocomplete.d.ts +1 -2
- package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/sync.d.ts.map +1 -1
- package/dist/types/src/extensions/awareness/awareness-provider.d.ts.map +1 -1
- package/dist/types/src/extensions/awareness/awareness.d.ts.map +1 -1
- package/dist/types/src/extensions/command/command.d.ts +1 -2
- package/dist/types/src/extensions/command/command.d.ts.map +1 -1
- package/dist/types/src/extensions/command/hint.d.ts +14 -2
- package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
- package/dist/types/src/extensions/command/index.d.ts +2 -0
- package/dist/types/src/extensions/command/index.d.ts.map +1 -1
- package/dist/types/src/extensions/command/menu.d.ts +7 -8
- package/dist/types/src/extensions/command/menu.d.ts.map +1 -1
- package/dist/types/src/extensions/command/state.d.ts +1 -1
- package/dist/types/src/extensions/command/state.d.ts.map +1 -1
- package/dist/types/src/extensions/command/typeahead.d.ts +17 -0
- package/dist/types/src/extensions/command/typeahead.d.ts.map +1 -0
- package/dist/types/src/extensions/comments.d.ts +2 -12
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/factories.d.ts +4 -0
- package/dist/types/src/extensions/factories.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +2 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/json.d.ts +7 -0
- package/dist/types/src/extensions/json.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/{editorAction.d.ts → action.d.ts} +1 -1
- package/dist/types/src/extensions/markdown/action.d.ts.map +1 -0
- package/dist/types/src/extensions/markdown/bundle.d.ts +2 -1
- package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/index.d.ts +1 -2
- package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/styles.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner/commands.d.ts +9 -0
- package/dist/types/src/extensions/outliner/commands.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/editor.d.ts +5 -0
- package/dist/types/src/extensions/outliner/editor.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/editor.test.d.ts +2 -0
- package/dist/types/src/extensions/outliner/editor.test.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/index.d.ts +3 -0
- package/dist/types/src/extensions/outliner/index.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/outliner.d.ts +10 -0
- package/dist/types/src/extensions/outliner/outliner.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/outliner.test.d.ts +2 -0
- package/dist/types/src/extensions/outliner/outliner.test.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/selection.d.ts +12 -0
- package/dist/types/src/extensions/outliner/selection.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/tree.d.ts +79 -0
- package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -0
- package/dist/types/src/extensions/outliner/tree.test.d.ts +2 -0
- package/dist/types/src/extensions/outliner/tree.test.d.ts.map +1 -0
- package/dist/types/src/stories/Command.stories.d.ts +7 -0
- package/dist/types/src/stories/Command.stories.d.ts.map +1 -0
- package/dist/types/src/stories/{TextEditorComments.stories.d.ts → Comments.stories.d.ts} +3 -3
- package/dist/types/src/stories/Comments.stories.d.ts.map +1 -0
- package/dist/types/src/stories/EditorToolbar.stories.d.ts +12 -0
- package/dist/types/src/stories/EditorToolbar.stories.d.ts.map +1 -0
- package/dist/types/src/stories/{TextEditorSpecial.stories.d.ts → Experimental.stories.d.ts} +3 -6
- package/dist/types/src/stories/Experimental.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Markdown.stories.d.ts +46 -0
- package/dist/types/src/stories/Markdown.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Outliner.stories.d.ts +26 -0
- package/dist/types/src/stories/Outliner.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Preview.stories.d.ts +10 -0
- package/dist/types/src/stories/Preview.stories.d.ts.map +1 -0
- package/dist/types/src/stories/{TextEditorBasic.stories.d.ts → TextEditor.stories.d.ts} +9 -39
- package/dist/types/src/stories/TextEditor.stories.d.ts.map +1 -0
- package/dist/types/src/stories/{story-utils.d.ts → util.d.ts} +6 -6
- package/dist/types/src/stories/util.d.ts.map +1 -0
- package/dist/types/src/styles/theme.d.ts.map +1 -1
- package/dist/types/src/styles/tokens.d.ts.map +1 -1
- package/dist/types/src/testing/index.d.ts +1 -1
- package/dist/types/src/testing/index.d.ts.map +1 -1
- package/dist/types/src/testing/util.d.ts +2 -0
- package/dist/types/src/testing/util.d.ts.map +1 -0
- package/package.json +40 -34
- package/src/components/EditorToolbar/EditorToolbar.tsx +81 -57
- package/src/components/EditorToolbar/index.ts +7 -1
- package/src/components/EditorToolbar/util.ts +3 -4
- package/src/components/Popover/RefDropdownMenu.tsx +77 -0
- package/src/{testing → components/Popover}/RefPopover.tsx +5 -4
- package/src/components/Popover/index.ts +6 -0
- package/src/components/index.ts +1 -0
- package/src/defaults.ts +10 -13
- package/src/extensions/annotations.ts +41 -64
- package/src/extensions/autocomplete.ts +5 -6
- package/src/extensions/automerge/automerge.stories.tsx +2 -7
- package/src/extensions/automerge/automerge.test.tsx +3 -2
- package/src/extensions/automerge/sync.ts +3 -3
- package/src/extensions/awareness/awareness-provider.ts +4 -4
- package/src/extensions/awareness/awareness.ts +7 -7
- package/src/extensions/blast.ts +9 -9
- package/src/extensions/command/command.ts +1 -3
- package/src/extensions/command/hint.ts +7 -7
- package/src/extensions/command/index.ts +2 -0
- package/src/extensions/command/menu.ts +43 -49
- package/src/extensions/command/typeahead.ts +116 -0
- package/src/extensions/comments.ts +4 -69
- package/src/extensions/factories.ts +13 -0
- package/src/extensions/index.ts +2 -0
- package/src/extensions/json.ts +56 -0
- package/src/extensions/markdown/bundle.ts +13 -9
- package/src/extensions/markdown/decorate.ts +7 -7
- package/src/extensions/markdown/image.ts +2 -2
- package/src/extensions/markdown/index.ts +1 -2
- package/src/extensions/markdown/styles.ts +2 -1
- package/src/extensions/markdown/table.ts +3 -3
- package/src/extensions/outliner/commands.ts +242 -0
- package/src/extensions/outliner/editor.test.ts +33 -0
- package/src/extensions/outliner/editor.ts +180 -0
- package/src/extensions/outliner/index.ts +6 -0
- package/src/extensions/outliner/outliner.test.ts +99 -0
- package/src/extensions/outliner/outliner.ts +162 -0
- package/src/extensions/outliner/selection.ts +50 -0
- package/src/extensions/outliner/tree.test.ts +164 -0
- package/src/extensions/outliner/tree.ts +315 -0
- package/src/extensions/preview/preview.ts +5 -5
- package/src/stories/Command.stories.tsx +97 -0
- package/src/stories/{TextEditorComments.stories.tsx → Comments.stories.tsx} +13 -14
- package/src/{components/EditorToolbar → stories}/EditorToolbar.stories.tsx +26 -20
- package/src/stories/{TextEditorSpecial.stories.tsx → Experimental.stories.tsx} +9 -30
- package/src/stories/Markdown.stories.tsx +121 -0
- package/src/stories/Outliner.stories.tsx +108 -0
- package/src/stories/{TextEditorPreview.stories.tsx → Preview.stories.tsx} +46 -136
- package/src/stories/TextEditor.stories.tsx +256 -0
- package/src/stories/{story-utils.tsx → util.tsx} +21 -22
- package/src/styles/theme.ts +12 -5
- package/src/styles/tokens.ts +1 -2
- package/src/testing/index.ts +1 -1
- package/src/testing/util.ts +5 -0
- package/dist/types/src/components/EditorToolbar/EditorToolbar.stories.d.ts +0 -53
- package/dist/types/src/components/EditorToolbar/EditorToolbar.stories.d.ts.map +0 -1
- package/dist/types/src/components/EditorToolbar/comment.d.ts +0 -18
- package/dist/types/src/components/EditorToolbar/comment.d.ts.map +0 -1
- package/dist/types/src/extensions/markdown/editorAction.d.ts.map +0 -1
- package/dist/types/src/extensions/markdown/outliner.d.ts +0 -12
- package/dist/types/src/extensions/markdown/outliner.d.ts.map +0 -1
- package/dist/types/src/stories/TextEditorBasic.stories.d.ts.map +0 -1
- package/dist/types/src/stories/TextEditorComments.stories.d.ts.map +0 -1
- package/dist/types/src/stories/TextEditorPreview.stories.d.ts +0 -13
- package/dist/types/src/stories/TextEditorPreview.stories.d.ts.map +0 -1
- package/dist/types/src/stories/TextEditorSpecial.stories.d.ts.map +0 -1
- package/dist/types/src/stories/story-utils.d.ts.map +0 -1
- package/dist/types/src/testing/RefPopover.d.ts.map +0 -1
- package/src/components/EditorToolbar/comment.ts +0 -30
- package/src/extensions/markdown/outliner.ts +0 -235
- package/src/stories/TextEditorBasic.stories.tsx +0 -333
- /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 = (
|
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
|
-
|
61
|
-
|
61
|
+
keymap.of(
|
62
|
+
[
|
63
|
+
// https://codemirror.net/docs/ref/#commands.indentWithTab
|
64
|
+
options.indentWithTab !== false && indentWithTab,
|
62
65
|
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
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 './
|
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-
|
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
|
+
];
|