@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.
- package/LICENSE +8 -0
- package/README.md +21 -0
- package/package.json +121 -0
- package/src/defaults.ts +34 -0
- package/src/extensions/annotations.ts +55 -0
- package/src/extensions/autocomplete/autocomplete.ts +151 -0
- package/src/extensions/autocomplete/index.ts +8 -0
- package/src/extensions/autocomplete/match.ts +46 -0
- package/src/extensions/autocomplete/placeholder.ts +117 -0
- package/src/extensions/autocomplete/typeahead.ts +87 -0
- package/src/extensions/automerge/automerge.test.tsx +76 -0
- package/src/extensions/automerge/automerge.ts +105 -0
- package/src/extensions/automerge/cursor.ts +28 -0
- package/src/extensions/automerge/defs.ts +31 -0
- package/src/extensions/automerge/index.ts +5 -0
- package/src/extensions/automerge/sync.ts +79 -0
- package/src/extensions/automerge/update-automerge.ts +50 -0
- package/src/extensions/automerge/update-codemirror.ts +115 -0
- package/src/extensions/autoscroll.ts +165 -0
- package/src/extensions/awareness/awareness-provider.ts +127 -0
- package/src/extensions/awareness/awareness.ts +315 -0
- package/src/extensions/awareness/index.ts +6 -0
- package/src/extensions/blast.ts +363 -0
- package/src/extensions/blocks.ts +131 -0
- package/src/extensions/bookmarks.ts +77 -0
- package/src/extensions/comments.ts +579 -0
- package/src/extensions/debug.ts +15 -0
- package/src/extensions/dnd.ts +39 -0
- package/src/extensions/factories.ts +284 -0
- package/src/extensions/focus.ts +36 -0
- package/src/extensions/folding.ts +63 -0
- package/src/extensions/hashtag.ts +68 -0
- package/src/extensions/index.ts +34 -0
- package/src/extensions/json.ts +57 -0
- package/src/extensions/listener.ts +32 -0
- package/src/extensions/markdown/action.ts +117 -0
- package/src/extensions/markdown/bundle.ts +105 -0
- package/src/extensions/markdown/changes.test.ts +26 -0
- package/src/extensions/markdown/changes.ts +149 -0
- package/src/extensions/markdown/debug.ts +44 -0
- package/src/extensions/markdown/decorate.ts +622 -0
- package/src/extensions/markdown/formatting.test.ts +498 -0
- package/src/extensions/markdown/formatting.ts +1265 -0
- package/src/extensions/markdown/highlight.ts +183 -0
- package/src/extensions/markdown/image.ts +118 -0
- package/src/extensions/markdown/index.ts +13 -0
- package/src/extensions/markdown/link.ts +50 -0
- package/src/extensions/markdown/parser.test.ts +75 -0
- package/src/extensions/markdown/styles.ts +135 -0
- package/src/extensions/markdown/table.ts +150 -0
- package/src/extensions/mention.ts +41 -0
- package/src/extensions/modal.ts +24 -0
- package/src/extensions/modes.ts +41 -0
- package/src/extensions/outliner/commands.ts +270 -0
- package/src/extensions/outliner/editor.test.ts +33 -0
- package/src/extensions/outliner/editor.ts +184 -0
- package/src/extensions/outliner/index.ts +7 -0
- package/src/extensions/outliner/menu.ts +128 -0
- package/src/extensions/outliner/outliner.test.ts +100 -0
- package/src/extensions/outliner/outliner.ts +167 -0
- package/src/extensions/outliner/selection.ts +50 -0
- package/src/extensions/outliner/tree.test.ts +168 -0
- package/src/extensions/outliner/tree.ts +317 -0
- package/src/extensions/preview/index.ts +5 -0
- package/src/extensions/preview/preview.ts +193 -0
- package/src/extensions/replacer.test.ts +75 -0
- package/src/extensions/replacer.ts +93 -0
- package/src/extensions/scrolling.ts +189 -0
- package/src/extensions/selection.ts +100 -0
- package/src/extensions/state.ts +7 -0
- package/src/extensions/submit.ts +62 -0
- package/src/extensions/tags/extended-markdown.test.ts +263 -0
- package/src/extensions/tags/extended-markdown.ts +78 -0
- package/src/extensions/tags/index.ts +7 -0
- package/src/extensions/tags/streamer.ts +243 -0
- package/src/extensions/tags/xml-tags.ts +507 -0
- package/src/extensions/tags/xml-util.test.ts +48 -0
- package/src/extensions/tags/xml-util.ts +93 -0
- package/src/extensions/typewriter.ts +68 -0
- package/src/index.ts +14 -0
- package/src/styles/index.ts +7 -0
- package/src/styles/markdown.ts +26 -0
- package/src/styles/theme.ts +293 -0
- package/src/styles/tokens.ts +17 -0
- package/src/types/index.ts +5 -0
- package/src/types/types.ts +32 -0
- package/src/util/cursor.ts +56 -0
- package/src/util/debug.ts +56 -0
- package/src/util/decorations.ts +21 -0
- package/src/util/dom.ts +36 -0
- package/src/util/facet.ts +13 -0
- package/src/util/index.ts +10 -0
- package/src/util/util.ts +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
Copyright (c) 2022 DXOS
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @dxos/react-ui-editor
|
|
2
|
+
|
|
3
|
+
Document editing experience within a DXOS shell.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm i @dxos/react-ui-editor
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## DXOS Resources
|
|
12
|
+
|
|
13
|
+
- [Website](https://dxos.org)
|
|
14
|
+
- [Developer Documentation](https://docs.dxos.org)
|
|
15
|
+
- Talk to us on [Discord](https://dxos.org/discord)
|
|
16
|
+
|
|
17
|
+
## Contributions
|
|
18
|
+
|
|
19
|
+
Your ideas, issues, and code are most welcome. Please take a look at our [community code of conduct](https://github.com/dxos/dxos/blob/main/CODE_OF_CONDUCT.md), the [issue guide](https://github.com/dxos/dxos/blob/main/CONTRIBUTING.md#submitting-issues), and the [PR contribution guide](https://github.com/dxos/dxos/blob/main/CONTRIBUTING.md#submitting-prs).
|
|
20
|
+
|
|
21
|
+
License: [MIT](./LICENSE) Copyright 2022 © DXOS
|
package/package.json
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dxos/ui-editor",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Text editor components.",
|
|
5
|
+
"homepage": "https://dxos.org",
|
|
6
|
+
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "DXOS.org",
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"source": "./src/index.ts",
|
|
14
|
+
"types": "./dist/types/src/index.d.ts",
|
|
15
|
+
"browser": "./dist/lib/browser/index.mjs",
|
|
16
|
+
"node": "./dist/lib/node-esm/index.mjs"
|
|
17
|
+
},
|
|
18
|
+
"./types": {
|
|
19
|
+
"source": "./src/types/index.ts",
|
|
20
|
+
"types": "./dist/types/src/types/index.d.ts",
|
|
21
|
+
"browser": "./dist/lib/browser/types/index.mjs",
|
|
22
|
+
"node": "./dist/lib/node-esm/types/index.mjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"types": "dist/types/src/index.d.ts",
|
|
26
|
+
"typesVersions": {
|
|
27
|
+
"*": {}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"src"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@automerge/automerge": "3.2.1",
|
|
35
|
+
"@codemirror/autocomplete": "^6.19.0",
|
|
36
|
+
"@codemirror/commands": "^6.8.1",
|
|
37
|
+
"@codemirror/lang-html": "^6.4.11",
|
|
38
|
+
"@codemirror/lang-javascript": "^6.2.4",
|
|
39
|
+
"@codemirror/lang-json": "^6.0.2",
|
|
40
|
+
"@codemirror/lang-markdown": "6.3.4",
|
|
41
|
+
"@codemirror/lang-xml": "^6.1.0",
|
|
42
|
+
"@codemirror/lang-yaml": "^6.1.2",
|
|
43
|
+
"@codemirror/language": "^6.11.3",
|
|
44
|
+
"@codemirror/language-data": "^6.5.1",
|
|
45
|
+
"@codemirror/lint": "6.8.5",
|
|
46
|
+
"@codemirror/search": "^6.5.11",
|
|
47
|
+
"@codemirror/state": "^6.5.2",
|
|
48
|
+
"@codemirror/theme-one-dark": "^6.1.3",
|
|
49
|
+
"@codemirror/view": "^6.38.4",
|
|
50
|
+
"@lezer/common": "^1.2.2",
|
|
51
|
+
"@lezer/generator": "^1.7.1",
|
|
52
|
+
"@lezer/highlight": "^1.2.1",
|
|
53
|
+
"@lezer/markdown": "^1.3.1",
|
|
54
|
+
"@replit/codemirror-vim": "^6.2.1",
|
|
55
|
+
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
|
56
|
+
"@uiw/codemirror-theme-vscode": "^4.25.2",
|
|
57
|
+
"ajv": "^8.17.1",
|
|
58
|
+
"codemirror": "^6.0.1",
|
|
59
|
+
"lib0": "^0.2.65",
|
|
60
|
+
"lodash.defaultsdeep": "^4.6.1",
|
|
61
|
+
"lodash.merge": "^4.6.2",
|
|
62
|
+
"lodash.sortby": "^4.7.0",
|
|
63
|
+
"style-mod": "^4.1.0",
|
|
64
|
+
"@dxos/app-graph": "0.8.3",
|
|
65
|
+
"@dxos/context": "0.8.3",
|
|
66
|
+
"@dxos/async": "0.8.3",
|
|
67
|
+
"@dxos/client": "0.8.3",
|
|
68
|
+
"@dxos/debug": "0.8.3",
|
|
69
|
+
"@dxos/invariant": "0.8.3",
|
|
70
|
+
"@dxos/echo-db": "0.8.3",
|
|
71
|
+
"@dxos/echo": "0.8.3",
|
|
72
|
+
"@dxos/live-object": "0.8.3",
|
|
73
|
+
"@dxos/log": "0.8.3",
|
|
74
|
+
"@dxos/protocols": "0.8.3",
|
|
75
|
+
"@dxos/display-name": "0.8.3",
|
|
76
|
+
"@dxos/ui-types": "0.0.0",
|
|
77
|
+
"@dxos/ui": "0.0.0",
|
|
78
|
+
"@dxos/ui-theme": "0.0.0",
|
|
79
|
+
"@dxos/lit-ui": "0.8.3",
|
|
80
|
+
"@dxos/util": "0.8.3"
|
|
81
|
+
},
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"@automerge/automerge": "3.2.1",
|
|
84
|
+
"@automerge/automerge-repo": "2.5.1",
|
|
85
|
+
"@automerge/automerge-repo-network-broadcastchannel": "2.5.1",
|
|
86
|
+
"@effect/platform": "0.93.6",
|
|
87
|
+
"@types/chai": "^4.2.15",
|
|
88
|
+
"@types/chai-dom": "^1.11.0",
|
|
89
|
+
"@types/lodash.defaultsdeep": "^4.6.6",
|
|
90
|
+
"@types/lodash.merge": "^4.6.6",
|
|
91
|
+
"@types/lodash.sortby": "^4.7.7",
|
|
92
|
+
"chai": "^4.4.1",
|
|
93
|
+
"chai-dom": "^1.11.0",
|
|
94
|
+
"effect": "3.19.11",
|
|
95
|
+
"happy-dom": "^13.3.1",
|
|
96
|
+
"jsdom": "^27.0.0",
|
|
97
|
+
"mocha": "^10.6.0",
|
|
98
|
+
"vite": "7.1.9",
|
|
99
|
+
"vite-plugin-top-level-await": "^1.6.0",
|
|
100
|
+
"vite-plugin-wasm": "^3.5.0",
|
|
101
|
+
"@dxos/echo": "0.8.3",
|
|
102
|
+
"@dxos/keyboard": "0.8.3",
|
|
103
|
+
"@dxos/echo-signals": "0.8.3",
|
|
104
|
+
"@dxos/config": "0.8.3",
|
|
105
|
+
"@dxos/random": "0.8.3",
|
|
106
|
+
"@dxos/storybook-utils": "0.8.3",
|
|
107
|
+
"@dxos/ui-theme": "0.0.0",
|
|
108
|
+
"@dxos/schema": "0.8.3"
|
|
109
|
+
},
|
|
110
|
+
"peerDependencies": {
|
|
111
|
+
"@effect/platform": "0.93.6",
|
|
112
|
+
"effect": "3.19.11",
|
|
113
|
+
"@dxos/ui-theme": "0.0.0"
|
|
114
|
+
},
|
|
115
|
+
"publishConfig": {
|
|
116
|
+
"access": "public"
|
|
117
|
+
},
|
|
118
|
+
"scripts": {
|
|
119
|
+
"vitest-ui": "vitest --ui"
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { mx } from '@dxos/ui-theme';
|
|
6
|
+
|
|
7
|
+
import { type ThemeExtensionsOptions } from './extensions';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CodeMirror content width.
|
|
11
|
+
* 40rem = 640px. Corresponds to initial plank width (Google docs, Stashpad, etc.)
|
|
12
|
+
* 50rem = 800px. Maximum content width for solo mode.
|
|
13
|
+
* NOTE: Max width - 4rem = 2rem left/right margin (or 2rem gutter plus 1rem left/right margin).
|
|
14
|
+
*/
|
|
15
|
+
export const editorWidth = '!mli-auto is-full max-is-[min(50rem,100%-4rem)]';
|
|
16
|
+
|
|
17
|
+
export const editorSlots: ThemeExtensionsOptions['slots'] = {
|
|
18
|
+
scroll: {
|
|
19
|
+
className: 'pbs-2',
|
|
20
|
+
},
|
|
21
|
+
content: {
|
|
22
|
+
className: editorWidth,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const editorWithToolbarLayout =
|
|
27
|
+
'grid grid-cols-1 grid-rows-[min-content_1fr] data-[toolbar=disabled]:grid-rows-[1fr] justify-center content-start overflow-hidden';
|
|
28
|
+
|
|
29
|
+
// NOTE: Padding is added to the editor to account for the focus ring (since otherwise the CM gutter will clip it)
|
|
30
|
+
export const stackItemContentEditorClassNames = (role?: string) =>
|
|
31
|
+
mx(
|
|
32
|
+
'p-0.5 dx-focus-ring-inset attention-surface data-[toolbar=disabled]:pbs-2',
|
|
33
|
+
role === 'section' ? '[&_.cm-scroller]:overflow-hidden [&_.cm-scroller]:min-bs-24' : 'min-bs-0',
|
|
34
|
+
);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension, RangeSetBuilder } from '@codemirror/state';
|
|
6
|
+
import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
|
7
|
+
|
|
8
|
+
const annotationMark = Decoration.mark({ class: 'cm-annotation' });
|
|
9
|
+
|
|
10
|
+
export type AnnotationOptions = {
|
|
11
|
+
match?: RegExp; // TODO(burdon): Update via hook (e.g., for search).
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
*
|
|
16
|
+
*/
|
|
17
|
+
export const annotations = ({ match }: AnnotationOptions = {}): Extension => {
|
|
18
|
+
return [
|
|
19
|
+
ViewPlugin.fromClass(
|
|
20
|
+
class {
|
|
21
|
+
decorations: DecorationSet = Decoration.none;
|
|
22
|
+
update(update: ViewUpdate) {
|
|
23
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
24
|
+
if (match) {
|
|
25
|
+
// Only process visible lines.
|
|
26
|
+
const { from, to } = update.view.viewport;
|
|
27
|
+
const text = update.state.doc.sliceString(from, to);
|
|
28
|
+
const matches = text.matchAll(match);
|
|
29
|
+
for (const m of matches) {
|
|
30
|
+
if (m.index !== undefined) {
|
|
31
|
+
// Adjust match position relative to viewport.
|
|
32
|
+
const start = from + m.index;
|
|
33
|
+
const end = start + m[0].length;
|
|
34
|
+
builder.add(start, end, annotationMark);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.decorations = builder.finish();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
decorations: (v) => v.decorations,
|
|
44
|
+
},
|
|
45
|
+
),
|
|
46
|
+
|
|
47
|
+
EditorView.theme({
|
|
48
|
+
'.cm-annotation': {
|
|
49
|
+
textDecoration: 'underline',
|
|
50
|
+
textDecorationStyle: 'wavy',
|
|
51
|
+
textDecorationColor: 'var(--dx-errorText)',
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
];
|
|
55
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type Extension, Prec } from '@codemirror/state';
|
|
6
|
+
import {
|
|
7
|
+
Decoration,
|
|
8
|
+
type DecorationSet,
|
|
9
|
+
EditorView,
|
|
10
|
+
ViewPlugin,
|
|
11
|
+
type ViewUpdate,
|
|
12
|
+
WidgetType,
|
|
13
|
+
keymap,
|
|
14
|
+
} from '@codemirror/view';
|
|
15
|
+
|
|
16
|
+
export type AutocompleteOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* Function that returns a list of suggestions based on the current text.
|
|
19
|
+
* @param text The current text before the cursor
|
|
20
|
+
* @returns Array of suggestion strings
|
|
21
|
+
*/
|
|
22
|
+
onSuggest?: (text: string) => string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates an autocomplete extension that shows inline suggestions.
|
|
27
|
+
* Pressing Tab will complete the suggestion.
|
|
28
|
+
*
|
|
29
|
+
* @deprecated Use typeahead.
|
|
30
|
+
*/
|
|
31
|
+
export const autocomplete = ({ onSuggest }: AutocompleteOptions = {}): Extension => {
|
|
32
|
+
const suggest = ViewPlugin.fromClass(
|
|
33
|
+
class {
|
|
34
|
+
_decorations: DecorationSet;
|
|
35
|
+
_currentSuggestion: string | null = null;
|
|
36
|
+
|
|
37
|
+
constructor(view: EditorView) {
|
|
38
|
+
this._decorations = this.computeDecorations(view);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
update(update: ViewUpdate) {
|
|
42
|
+
if (update.docChanged || update.selectionSet) {
|
|
43
|
+
this._decorations = this.computeDecorations(update.view);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private computeDecorations(view: EditorView): DecorationSet {
|
|
48
|
+
const text = view.state.doc.toString();
|
|
49
|
+
const suggestions = onSuggest?.(text) ?? [];
|
|
50
|
+
if (!suggestions.length) {
|
|
51
|
+
this._currentSuggestion = null;
|
|
52
|
+
return Decoration.none;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Get the first suggestion.
|
|
56
|
+
this._currentSuggestion = suggestions[0];
|
|
57
|
+
const suffix = this._currentSuggestion.slice(text.length);
|
|
58
|
+
if (!suffix) {
|
|
59
|
+
return Decoration.none;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Always show ghost text at the end of the document.
|
|
63
|
+
return Decoration.set([
|
|
64
|
+
Decoration.widget({
|
|
65
|
+
widget: new InlineSuggestionWidget(suffix),
|
|
66
|
+
side: 1,
|
|
67
|
+
}).range(view.state.doc.length),
|
|
68
|
+
]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
completeSuggestion(view: EditorView): boolean {
|
|
72
|
+
if (!this._currentSuggestion) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const text = view.state.doc.toString();
|
|
77
|
+
const suffix = this._currentSuggestion.slice(text.length);
|
|
78
|
+
if (!suffix) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
view.dispatch({
|
|
83
|
+
changes: {
|
|
84
|
+
from: view.state.doc.length,
|
|
85
|
+
insert: suffix,
|
|
86
|
+
},
|
|
87
|
+
selection: {
|
|
88
|
+
anchor: view.state.doc.length + suffix.length,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
decorations: (v) => v._decorations,
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return [
|
|
101
|
+
suggest,
|
|
102
|
+
EditorView.theme({
|
|
103
|
+
'.cm-inline-suggestion': {
|
|
104
|
+
opacity: 0.4,
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
Prec.highest(
|
|
109
|
+
keymap.of([
|
|
110
|
+
{
|
|
111
|
+
key: 'Tab',
|
|
112
|
+
preventDefault: true,
|
|
113
|
+
run: (view) => {
|
|
114
|
+
const plugin = view.plugin(suggest);
|
|
115
|
+
return plugin?.completeSuggestion(view) ?? false;
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
key: 'ArrowRight',
|
|
120
|
+
preventDefault: true,
|
|
121
|
+
run: (view) => {
|
|
122
|
+
// Only complete if cursor is at the end
|
|
123
|
+
if (view.state.selection.main.head !== view.state.doc.length) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const plugin = view.plugin(suggest);
|
|
128
|
+
return plugin?.completeSuggestion(view) ?? false;
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
]),
|
|
132
|
+
),
|
|
133
|
+
];
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
class InlineSuggestionWidget extends WidgetType {
|
|
137
|
+
constructor(private suffix: string) {
|
|
138
|
+
super();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
override eq(other: this) {
|
|
142
|
+
return this.suffix === other.suffix;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
override toDOM() {
|
|
146
|
+
const span = document.createElement('span');
|
|
147
|
+
span.textContent = this.suffix;
|
|
148
|
+
span.className = 'cm-inline-suggestion';
|
|
149
|
+
return span;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
export type CompoetionContext = { line: string };
|
|
6
|
+
|
|
7
|
+
export type CompletionOptions = {
|
|
8
|
+
default?: string;
|
|
9
|
+
minLength?: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Util to match current line to a static list of completions.
|
|
14
|
+
*/
|
|
15
|
+
export const staticCompletion =
|
|
16
|
+
(completions: string[], options: CompletionOptions = {}) =>
|
|
17
|
+
({ line }: CompoetionContext) => {
|
|
18
|
+
if (line.length === 0 && options.default) {
|
|
19
|
+
return options.default;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const parts = line.split(/\s+/).filter(Boolean);
|
|
23
|
+
if (parts.length) {
|
|
24
|
+
const str = parts.at(-1)!;
|
|
25
|
+
if (str.length >= (options.minLength ?? 0)) {
|
|
26
|
+
for (const completion of completions) {
|
|
27
|
+
const match = matchCompletion(completion, str);
|
|
28
|
+
if (match) {
|
|
29
|
+
return match;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const matchCompletion = (completion: string, str: string, minLength = 0): string | undefined => {
|
|
37
|
+
if (
|
|
38
|
+
str.length >= minLength &&
|
|
39
|
+
completion.length > str.length &&
|
|
40
|
+
completion.startsWith(str)
|
|
41
|
+
// TODO(burdon): If case insensitive, need to replace existing chars.
|
|
42
|
+
// completion.toLowerCase().startsWith(str.toLowerCase())
|
|
43
|
+
) {
|
|
44
|
+
return completion.slice(str.length);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
// Based on https://github.com/codemirror/view/blob/main/src/placeholder.ts
|
|
4
|
+
//
|
|
5
|
+
|
|
6
|
+
import { type Extension } from '@codemirror/state';
|
|
7
|
+
import { Decoration, EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view';
|
|
8
|
+
|
|
9
|
+
import { clientRectsFor, flattenRect } from '../../util';
|
|
10
|
+
|
|
11
|
+
type Content = string | HTMLElement | ((view: EditorView) => HTMLElement);
|
|
12
|
+
|
|
13
|
+
export type PlaceholderOptions = {
|
|
14
|
+
content: Content;
|
|
15
|
+
delay?: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Shows a transient placeholder at the current cursor position.
|
|
20
|
+
*/
|
|
21
|
+
export const placeholder = ({ content, delay = 3_000 }: PlaceholderOptions): Extension => {
|
|
22
|
+
const plugin = ViewPlugin.fromClass(
|
|
23
|
+
class {
|
|
24
|
+
_timeout: ReturnType<typeof setTimeout> | undefined;
|
|
25
|
+
_decorations = Decoration.none;
|
|
26
|
+
|
|
27
|
+
update(update: ViewUpdate) {
|
|
28
|
+
if (this._timeout) {
|
|
29
|
+
window.clearTimeout(this._timeout);
|
|
30
|
+
this._timeout = undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check if the active line (where cursor is) is empty.
|
|
34
|
+
const activeLine = update.view.state.doc.lineAt(update.view.state.selection.main.head);
|
|
35
|
+
const isEmpty = activeLine.text.trim() === '';
|
|
36
|
+
if (isEmpty) {
|
|
37
|
+
// Create widget decoration at the start of the current line.
|
|
38
|
+
const lineStart = activeLine.from;
|
|
39
|
+
this._timeout = setTimeout(() => {
|
|
40
|
+
this._decorations = Decoration.set([
|
|
41
|
+
Decoration.widget({
|
|
42
|
+
widget: new PlaceholderWidget(content),
|
|
43
|
+
side: 1,
|
|
44
|
+
}).range(lineStart),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
update.view.update([]);
|
|
48
|
+
}, delay);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this._decorations = Decoration.none;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
destroy() {
|
|
55
|
+
if (this._timeout) {
|
|
56
|
+
clearTimeout(this._timeout);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
provide: (plugin) => {
|
|
62
|
+
return [EditorView.decorations.of((view) => view.plugin(plugin)?._decorations ?? Decoration.none)];
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return typeof content === 'string'
|
|
68
|
+
? [plugin, EditorView.contentAttributes.of({ 'aria-placeholder': content })]
|
|
69
|
+
: plugin;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export class PlaceholderWidget extends WidgetType {
|
|
73
|
+
constructor(readonly content: Content) {
|
|
74
|
+
super();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
toDOM(view: EditorView) {
|
|
78
|
+
const wrap = document.createElement('span');
|
|
79
|
+
wrap.className = 'cm-placeholder';
|
|
80
|
+
wrap.style.pointerEvents = 'none';
|
|
81
|
+
wrap.setAttribute('aria-hidden', 'true');
|
|
82
|
+
wrap.appendChild(
|
|
83
|
+
typeof this.content === 'string'
|
|
84
|
+
? document.createTextNode(this.content)
|
|
85
|
+
: typeof this.content === 'function'
|
|
86
|
+
? this.content(view)
|
|
87
|
+
: this.content.cloneNode(true),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return wrap;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
override coordsAt(dom: HTMLElement) {
|
|
94
|
+
const rects = dom.firstChild ? clientRectsFor(dom.firstChild) : [];
|
|
95
|
+
if (!rects.length) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const style = getComputedStyle(dom.parentNode as HTMLElement);
|
|
100
|
+
const rect = flattenRect(rects[0], style.direction !== 'rtl');
|
|
101
|
+
const lineHeight = parseInt(style.lineHeight);
|
|
102
|
+
if (rect.bottom - rect.top > lineHeight * 1.5) {
|
|
103
|
+
return {
|
|
104
|
+
left: rect.left,
|
|
105
|
+
right: rect.right,
|
|
106
|
+
top: rect.top,
|
|
107
|
+
bottom: rect.top + lineHeight,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return rect;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
override ignoreEvent() {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2025 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { EditorSelection, type Extension, Prec, RangeSetBuilder } from '@codemirror/state';
|
|
6
|
+
import {
|
|
7
|
+
type Command,
|
|
8
|
+
Decoration,
|
|
9
|
+
type DecorationSet,
|
|
10
|
+
type EditorView,
|
|
11
|
+
ViewPlugin,
|
|
12
|
+
type ViewUpdate,
|
|
13
|
+
keymap,
|
|
14
|
+
} from '@codemirror/view';
|
|
15
|
+
|
|
16
|
+
import { type CompoetionContext } from './match';
|
|
17
|
+
import { PlaceholderWidget } from './placeholder';
|
|
18
|
+
|
|
19
|
+
// TODO(burdon): Option to complete only at end of line.
|
|
20
|
+
export type TypeaheadOptions = {
|
|
21
|
+
onComplete?: (context: CompoetionContext) => string | undefined;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Shows a completion placeholder.
|
|
26
|
+
*/
|
|
27
|
+
export const typeahead = ({ onComplete }: TypeaheadOptions = {}): Extension => {
|
|
28
|
+
let hint: string | undefined;
|
|
29
|
+
|
|
30
|
+
const complete: Command = (view: EditorView) => {
|
|
31
|
+
if (!hint) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const selection = view.state.selection.main;
|
|
36
|
+
view.dispatch({
|
|
37
|
+
changes: [{ from: selection.from, to: selection.to, insert: hint }],
|
|
38
|
+
selection: EditorSelection.cursor(selection.from + hint.length),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return [
|
|
45
|
+
ViewPlugin.fromClass(
|
|
46
|
+
class {
|
|
47
|
+
decorations: DecorationSet = Decoration.none;
|
|
48
|
+
update(update: ViewUpdate) {
|
|
49
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
50
|
+
const selection = update.view.state.selection.main;
|
|
51
|
+
const line = update.view.state.doc.lineAt(selection.from);
|
|
52
|
+
|
|
53
|
+
// TODO(burdon): Check at end of line and matches start of previous word.
|
|
54
|
+
// TODO(burdon): Context grammar.
|
|
55
|
+
if (selection.from === selection.to && selection.from === line.to) {
|
|
56
|
+
const str = update.state.sliceDoc(line.from, selection.from);
|
|
57
|
+
hint = onComplete?.({ line: str });
|
|
58
|
+
if (hint) {
|
|
59
|
+
builder.add(selection.from, selection.to, Decoration.widget({ widget: new PlaceholderWidget(hint) }));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.decorations = builder.finish();
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
decorations: (v) => v.decorations,
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
|
|
71
|
+
// Keys.
|
|
72
|
+
Prec.highest(
|
|
73
|
+
keymap.of([
|
|
74
|
+
{
|
|
75
|
+
key: 'Tab',
|
|
76
|
+
preventDefault: true,
|
|
77
|
+
run: complete,
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
key: 'ArrowRight',
|
|
81
|
+
preventDefault: true,
|
|
82
|
+
run: complete,
|
|
83
|
+
},
|
|
84
|
+
]),
|
|
85
|
+
),
|
|
86
|
+
];
|
|
87
|
+
};
|