@dxos/react-ui-editor 0.8.3-main.7f5a14c → 0.8.3-staging.0fa589b
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 +371 -375
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +502 -511
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +371 -375
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
- package/dist/types/src/components/Popover/RefDropdownMenu.d.ts.map +1 -1
- package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -1
- package/dist/types/src/defaults.d.ts +0 -1
- package/dist/types/src/defaults.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -1
- package/dist/types/src/extensions/command/action.d.ts.map +1 -1
- package/dist/types/src/extensions/command/command-menu.d.ts +20 -0
- package/dist/types/src/extensions/command/command-menu.d.ts.map +1 -0
- package/dist/types/src/extensions/command/command.d.ts.map +1 -1
- package/dist/types/src/extensions/command/floating-menu.d.ts +7 -0
- package/dist/types/src/extensions/command/floating-menu.d.ts.map +1 -0
- package/dist/types/src/extensions/command/hint.d.ts +5 -2
- package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
- package/dist/types/src/extensions/command/index.d.ts +3 -1
- package/dist/types/src/extensions/command/index.d.ts.map +1 -1
- package/dist/types/src/extensions/command/placeholder.d.ts +10 -0
- package/dist/types/src/extensions/command/placeholder.d.ts.map +1 -0
- 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/useCommandMenu.d.ts +26 -0
- package/dist/types/src/extensions/command/useCommandMenu.d.ts.map +1 -0
- package/dist/types/src/extensions/index.d.ts +0 -1
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner/tree.d.ts.map +1 -1
- package/dist/types/src/extensions/preview/preview.d.ts +12 -19
- package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
- package/dist/types/src/stories/CommandMenu.stories.d.ts +5 -4
- package/dist/types/src/stories/CommandMenu.stories.d.ts.map +1 -1
- package/dist/types/src/stories/Preview.stories.d.ts.map +1 -1
- package/dist/types/src/util/dom.d.ts +5 -0
- package/dist/types/src/util/dom.d.ts.map +1 -1
- package/dist/types/src/util/react.d.ts +2 -4
- package/dist/types/src/util/react.d.ts.map +1 -1
- package/package.json +31 -31
- package/src/components/EditorToolbar/EditorToolbar.tsx +5 -9
- package/src/components/Popover/RefDropdownMenu.tsx +5 -3
- package/src/components/Popover/RefPopover.tsx +5 -3
- package/src/defaults.ts +0 -6
- package/src/extensions/automerge/automerge.stories.tsx +5 -5
- package/src/extensions/command/action.ts +9 -2
- package/src/extensions/command/command-menu.ts +210 -0
- package/src/extensions/command/command.ts +8 -8
- package/src/extensions/command/floating-menu.ts +133 -0
- package/src/extensions/command/hint.ts +29 -9
- package/src/extensions/command/index.ts +3 -1
- package/src/extensions/command/placeholder.ts +113 -0
- package/src/extensions/command/state.ts +1 -2
- package/src/extensions/command/useCommandMenu.ts +118 -0
- package/src/extensions/index.ts +0 -1
- package/src/extensions/markdown/bundle.ts +0 -2
- package/src/extensions/outliner/tree.test.ts +13 -10
- package/src/extensions/outliner/tree.ts +5 -3
- package/src/extensions/preview/preview.ts +11 -86
- package/src/stories/Command.stories.tsx +1 -1
- package/src/stories/CommandMenu.stories.tsx +35 -19
- package/src/stories/Preview.stories.tsx +134 -57
- package/src/stories/components/util.tsx +2 -2
- package/src/util/dom.ts +20 -0
- package/src/util/react.tsx +3 -20
- package/dist/types/src/extensions/command/menu.d.ts +0 -47
- package/dist/types/src/extensions/command/menu.d.ts.map +0 -1
- package/dist/types/src/extensions/placeholder.d.ts +0 -4
- package/dist/types/src/extensions/placeholder.d.ts.map +0 -1
- package/src/extensions/command/menu.ts +0 -439
- package/src/extensions/placeholder.ts +0 -82
@@ -0,0 +1,210 @@
|
|
1
|
+
//
|
2
|
+
// Copyright 2024 DXOS.org
|
3
|
+
//
|
4
|
+
|
5
|
+
import { RangeSetBuilder, StateField, StateEffect, Prec } from '@codemirror/state';
|
6
|
+
import { EditorView, ViewPlugin, type ViewUpdate, Decoration, keymap, type DecorationSet } from '@codemirror/view';
|
7
|
+
|
8
|
+
import { placeholder, type PlaceholderOptions } from './placeholder';
|
9
|
+
import { type Range } from '../../types';
|
10
|
+
|
11
|
+
export type CommandMenuOptions = {
|
12
|
+
trigger: string | string[];
|
13
|
+
placeholder?: Partial<PlaceholderOptions>;
|
14
|
+
|
15
|
+
// TODO(burdon): Replace with onKey?
|
16
|
+
onClose?: () => void;
|
17
|
+
onArrowDown?: () => void;
|
18
|
+
onArrowUp?: () => void;
|
19
|
+
onEnter?: () => void;
|
20
|
+
|
21
|
+
onTextChange?: (trigger: string, text: string) => void;
|
22
|
+
};
|
23
|
+
|
24
|
+
export const commandMenu = (options: CommandMenuOptions) => {
|
25
|
+
const commandMenuPlugin = ViewPlugin.fromClass(
|
26
|
+
class {
|
27
|
+
decorations: DecorationSet = Decoration.none;
|
28
|
+
|
29
|
+
constructor(readonly view: EditorView) {}
|
30
|
+
|
31
|
+
// TODO(wittjosiah): The decorations are repainted on every update, this occasionally causes menu to flicker.
|
32
|
+
update(update: ViewUpdate) {
|
33
|
+
const builder = new RangeSetBuilder<Decoration>();
|
34
|
+
const selection = update.view.state.selection.main;
|
35
|
+
const { range: activeRange, trigger } = update.view.state.field(commandMenuState) ?? {};
|
36
|
+
|
37
|
+
// Check if we should show the widget - only if cursor is within the active command range.
|
38
|
+
const shouldShowWidget = activeRange && selection.head >= activeRange.from && selection.head <= activeRange.to;
|
39
|
+
if (shouldShowWidget) {
|
40
|
+
// Create mark decoration that wraps the entire line content in a dx-ref-tag.
|
41
|
+
builder.add(
|
42
|
+
activeRange.from,
|
43
|
+
activeRange.to,
|
44
|
+
Decoration.mark({
|
45
|
+
tagName: 'dx-ref-tag',
|
46
|
+
class: 'cm-ref-tag',
|
47
|
+
attributes: {
|
48
|
+
'data-auto-trigger': 'true',
|
49
|
+
'data-trigger': trigger!,
|
50
|
+
},
|
51
|
+
}),
|
52
|
+
);
|
53
|
+
}
|
54
|
+
|
55
|
+
const activeRangeChanged = update.transactions.some((tr) =>
|
56
|
+
tr.effects.some((effect) => effect.is(commandRangeEffect)),
|
57
|
+
);
|
58
|
+
if (activeRange && activeRangeChanged && trigger) {
|
59
|
+
const content = update.view.state.sliceDoc(
|
60
|
+
activeRange.from + 1, // Skip the trigger character.
|
61
|
+
activeRange.to,
|
62
|
+
);
|
63
|
+
options.onTextChange?.(trigger, content);
|
64
|
+
}
|
65
|
+
|
66
|
+
this.decorations = builder.finish();
|
67
|
+
}
|
68
|
+
},
|
69
|
+
{
|
70
|
+
decorations: (v) => v.decorations,
|
71
|
+
},
|
72
|
+
);
|
73
|
+
|
74
|
+
const triggers = Array.isArray(options.trigger) ? options.trigger : [options.trigger];
|
75
|
+
|
76
|
+
const commandKeymap = keymap.of([
|
77
|
+
...triggers.map((trigger) => ({
|
78
|
+
key: trigger,
|
79
|
+
preventDefault: true,
|
80
|
+
run: (view: EditorView) => {
|
81
|
+
const selection = view.state.selection.main;
|
82
|
+
const line = view.state.doc.lineAt(selection.head);
|
83
|
+
|
84
|
+
// Check if we should trigger the command menu:
|
85
|
+
// 1. Empty lines or at the beginning of a line
|
86
|
+
// 2. When there's a preceding space
|
87
|
+
if (
|
88
|
+
line.text.trim() === '' ||
|
89
|
+
selection.head === line.from ||
|
90
|
+
(selection.head > line.from && line.text[selection.head - line.from - 1] === ' ')
|
91
|
+
) {
|
92
|
+
// Insert and select the trigger.
|
93
|
+
view.dispatch({
|
94
|
+
changes: { from: selection.head, insert: trigger },
|
95
|
+
selection: { anchor: selection.head + 1, head: selection.head + 1 },
|
96
|
+
effects: commandRangeEffect.of({ trigger, range: { from: selection.head, to: selection.head + 1 } }),
|
97
|
+
});
|
98
|
+
|
99
|
+
return true;
|
100
|
+
}
|
101
|
+
|
102
|
+
return false;
|
103
|
+
},
|
104
|
+
})),
|
105
|
+
{
|
106
|
+
key: 'Enter',
|
107
|
+
run: (view) => {
|
108
|
+
const activeRange = view.state.field(commandMenuState)?.range;
|
109
|
+
if (activeRange) {
|
110
|
+
view.dispatch({ changes: { from: activeRange.from, to: activeRange.to, insert: '' } });
|
111
|
+
options.onEnter?.();
|
112
|
+
return true;
|
113
|
+
}
|
114
|
+
|
115
|
+
return false;
|
116
|
+
},
|
117
|
+
},
|
118
|
+
{
|
119
|
+
key: 'ArrowDown',
|
120
|
+
run: (view) => {
|
121
|
+
const activeRange = view.state.field(commandMenuState)?.range;
|
122
|
+
if (activeRange) {
|
123
|
+
options.onArrowDown?.();
|
124
|
+
return true;
|
125
|
+
}
|
126
|
+
|
127
|
+
return false;
|
128
|
+
},
|
129
|
+
},
|
130
|
+
{
|
131
|
+
key: 'ArrowUp',
|
132
|
+
run: (view) => {
|
133
|
+
const activeRange = view.state.field(commandMenuState)?.range;
|
134
|
+
if (activeRange) {
|
135
|
+
options.onArrowUp?.();
|
136
|
+
return true;
|
137
|
+
}
|
138
|
+
|
139
|
+
return false;
|
140
|
+
},
|
141
|
+
},
|
142
|
+
]);
|
143
|
+
|
144
|
+
// Listen for selection and document changes to clean up the command menu.
|
145
|
+
const updateListener = EditorView.updateListener.of((update) => {
|
146
|
+
const { trigger, range: activeRange } = update.view.state.field(commandMenuState) ?? {};
|
147
|
+
if (!activeRange || !trigger) {
|
148
|
+
return;
|
149
|
+
}
|
150
|
+
|
151
|
+
const selection = update.view.state.selection.main;
|
152
|
+
const firstChar = update.view.state.doc.sliceString(activeRange.from, activeRange.from + 1);
|
153
|
+
const shouldRemove =
|
154
|
+
firstChar !== trigger || // Trigger deleted.
|
155
|
+
selection.head < activeRange.from || // Cursor moved before the range.
|
156
|
+
selection.head > activeRange.to + 1; // Cursor moved after the range (+1 to handle selection changing before doc).
|
157
|
+
|
158
|
+
const nextRange = shouldRemove
|
159
|
+
? null
|
160
|
+
: update.docChanged
|
161
|
+
? { from: activeRange.from, to: selection.head }
|
162
|
+
: activeRange;
|
163
|
+
if (nextRange !== activeRange) {
|
164
|
+
update.view.dispatch({ effects: commandRangeEffect.of(nextRange ? { trigger, range: nextRange } : null) });
|
165
|
+
}
|
166
|
+
|
167
|
+
// TODO(burdon): Should delete if user presses escape? How else to insert the trigger character?
|
168
|
+
if (shouldRemove) {
|
169
|
+
options.onClose?.();
|
170
|
+
}
|
171
|
+
});
|
172
|
+
|
173
|
+
return [
|
174
|
+
Prec.highest(commandKeymap),
|
175
|
+
placeholder(
|
176
|
+
Object.assign(
|
177
|
+
{
|
178
|
+
content: `Press '${Array.isArray(options.trigger) ? options.trigger[0] : options.trigger}' for commands`,
|
179
|
+
},
|
180
|
+
options.placeholder,
|
181
|
+
),
|
182
|
+
),
|
183
|
+
updateListener,
|
184
|
+
commandMenuState,
|
185
|
+
commandMenuPlugin,
|
186
|
+
];
|
187
|
+
};
|
188
|
+
|
189
|
+
type CommandState = {
|
190
|
+
trigger: string;
|
191
|
+
range: Range;
|
192
|
+
};
|
193
|
+
|
194
|
+
// State effects for managing command menu state.
|
195
|
+
export const commandRangeEffect = StateEffect.define<CommandState | null>();
|
196
|
+
|
197
|
+
// State field to track the active command menu range.
|
198
|
+
const commandMenuState = StateField.define<CommandState | null>({
|
199
|
+
create: () => null,
|
200
|
+
update: (value, tr) => {
|
201
|
+
let newValue = value;
|
202
|
+
for (const effect of tr.effects) {
|
203
|
+
if (effect.is(commandRangeEffect)) {
|
204
|
+
newValue = effect.value;
|
205
|
+
}
|
206
|
+
}
|
207
|
+
|
208
|
+
return newValue;
|
209
|
+
},
|
210
|
+
});
|
@@ -2,11 +2,13 @@
|
|
2
2
|
// Copyright 2024 DXOS.org
|
3
3
|
//
|
4
4
|
|
5
|
-
import { type Extension } from '@codemirror/state';
|
5
|
+
import { Prec, type Extension } from '@codemirror/state';
|
6
6
|
import { EditorView, keymap } from '@codemirror/view';
|
7
7
|
|
8
|
+
import { isNonNullable } from '@dxos/util';
|
9
|
+
|
8
10
|
import { closeEffect, commandKeyBindings } from './action';
|
9
|
-
import {
|
11
|
+
import { hint, type HintOptions } from './hint';
|
10
12
|
import { commandConfig, commandState, type PopupOptions } from './state';
|
11
13
|
|
12
14
|
// TODO(burdon): Create knowledge base for CM notes and ideas.
|
@@ -18,17 +20,15 @@ export type CommandOptions = Partial<PopupOptions & HintOptions>;
|
|
18
20
|
|
19
21
|
export const command = (options: CommandOptions = {}): Extension => {
|
20
22
|
return [
|
21
|
-
keymap.of(commandKeyBindings),
|
23
|
+
Prec.highest(keymap.of(commandKeyBindings)),
|
22
24
|
commandConfig.of(options),
|
23
25
|
commandState,
|
24
|
-
options.onHint
|
25
|
-
EditorView.focusChangeEffect.of((_, focusing) =>
|
26
|
-
return focusing ? closeEffect.of(null) : null;
|
27
|
-
}),
|
26
|
+
options.onHint && hint(options),
|
27
|
+
EditorView.focusChangeEffect.of((_, focusing) => (focusing ? closeEffect.of(null) : null)),
|
28
28
|
EditorView.theme({
|
29
29
|
'.cm-tooltip': {
|
30
30
|
background: 'transparent',
|
31
31
|
},
|
32
32
|
}),
|
33
|
-
];
|
33
|
+
].filter(isNonNullable);
|
34
34
|
};
|
@@ -0,0 +1,133 @@
|
|
1
|
+
//
|
2
|
+
// Copyright 2024 DXOS.org
|
3
|
+
//
|
4
|
+
|
5
|
+
import { EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
6
|
+
|
7
|
+
import { type CleanupFn, addEventListener } from '@dxos/async';
|
8
|
+
|
9
|
+
import { closeEffect, openEffect } from './action';
|
10
|
+
|
11
|
+
export type FloatingMenuOptions = {
|
12
|
+
icon?: string;
|
13
|
+
height?: number;
|
14
|
+
padding?: number;
|
15
|
+
};
|
16
|
+
|
17
|
+
export const floatingMenu = (options: FloatingMenuOptions = {}) => [
|
18
|
+
ViewPlugin.fromClass(
|
19
|
+
class {
|
20
|
+
view: EditorView;
|
21
|
+
tag: HTMLElement;
|
22
|
+
rafId?: number | null;
|
23
|
+
cleanup?: CleanupFn;
|
24
|
+
|
25
|
+
constructor(view: EditorView) {
|
26
|
+
this.view = view;
|
27
|
+
|
28
|
+
// Position context.
|
29
|
+
const container = view.scrollDOM;
|
30
|
+
if (getComputedStyle(container).position === 'static') {
|
31
|
+
container.style.position = 'relative';
|
32
|
+
}
|
33
|
+
|
34
|
+
{
|
35
|
+
const icon = document.createElement('dx-icon');
|
36
|
+
icon.setAttribute('icon', options.icon ?? 'ph--dots-three-vertical--regular');
|
37
|
+
const button = document.createElement('button');
|
38
|
+
button.appendChild(icon);
|
39
|
+
|
40
|
+
this.tag = document.createElement('dx-ref-tag');
|
41
|
+
this.tag.classList.add('cm-ref-tag');
|
42
|
+
this.tag.appendChild(button);
|
43
|
+
}
|
44
|
+
|
45
|
+
container.appendChild(this.tag);
|
46
|
+
|
47
|
+
// Listen for scroll events.
|
48
|
+
const handler = () => this.scheduleUpdate();
|
49
|
+
this.cleanup = addEventListener(container, 'scroll', handler);
|
50
|
+
this.scheduleUpdate();
|
51
|
+
}
|
52
|
+
|
53
|
+
destroy() {
|
54
|
+
this.cleanup?.();
|
55
|
+
this.tag.remove();
|
56
|
+
if (this.rafId != null) {
|
57
|
+
cancelAnimationFrame(this.rafId);
|
58
|
+
}
|
59
|
+
}
|
60
|
+
|
61
|
+
update(update: ViewUpdate) {
|
62
|
+
this.tag.dataset.focused = update.view.hasFocus ? 'true' : 'false';
|
63
|
+
if (!update.view.hasFocus) {
|
64
|
+
return;
|
65
|
+
}
|
66
|
+
|
67
|
+
// TODO(burdon): Timer to fade in/out.
|
68
|
+
if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(openEffect)))) {
|
69
|
+
this.tag.style.display = 'none';
|
70
|
+
this.tag.classList.add('opacity-10');
|
71
|
+
} else if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(closeEffect)))) {
|
72
|
+
this.tag.style.display = 'block';
|
73
|
+
} else if (
|
74
|
+
update.docChanged ||
|
75
|
+
update.focusChanged ||
|
76
|
+
update.geometryChanged ||
|
77
|
+
update.selectionSet ||
|
78
|
+
update.viewportChanged
|
79
|
+
) {
|
80
|
+
this.scheduleUpdate();
|
81
|
+
}
|
82
|
+
}
|
83
|
+
|
84
|
+
updateButtonPosition() {
|
85
|
+
const { x, width } = this.view.contentDOM.getBoundingClientRect();
|
86
|
+
|
87
|
+
const pos = this.view.state.selection.main.head;
|
88
|
+
const line = this.view.lineBlockAt(pos);
|
89
|
+
const coords = this.view.coordsAtPos(line.from);
|
90
|
+
if (!coords) {
|
91
|
+
return;
|
92
|
+
}
|
93
|
+
|
94
|
+
const lineHeight = coords.bottom - coords.top;
|
95
|
+
const dy = (lineHeight - (options.height ?? 32)) / 2;
|
96
|
+
|
97
|
+
const offsetTop = coords.top + dy;
|
98
|
+
const offsetLeft = x + width + (options.padding ?? 8);
|
99
|
+
|
100
|
+
this.tag.style.top = `${offsetTop}px`;
|
101
|
+
this.tag.style.left = `${offsetLeft}px`;
|
102
|
+
this.tag.style.display = 'block';
|
103
|
+
}
|
104
|
+
|
105
|
+
scheduleUpdate() {
|
106
|
+
if (this.rafId != null) {
|
107
|
+
cancelAnimationFrame(this.rafId);
|
108
|
+
}
|
109
|
+
|
110
|
+
this.rafId = requestAnimationFrame(this.updateButtonPosition.bind(this));
|
111
|
+
}
|
112
|
+
},
|
113
|
+
),
|
114
|
+
|
115
|
+
EditorView.theme({
|
116
|
+
'.cm-ref-tag': {
|
117
|
+
position: 'fixed',
|
118
|
+
padding: '0',
|
119
|
+
border: 'none',
|
120
|
+
opacity: '0',
|
121
|
+
},
|
122
|
+
'[data-has-focus] & .cm-ref-tag': {
|
123
|
+
opacity: '1',
|
124
|
+
},
|
125
|
+
'.cm-ref-tag button': {
|
126
|
+
display: 'grid',
|
127
|
+
alignItems: 'center',
|
128
|
+
justifyContent: 'center',
|
129
|
+
width: '2rem',
|
130
|
+
height: '2rem',
|
131
|
+
},
|
132
|
+
}),
|
133
|
+
];
|
@@ -1,5 +1,6 @@
|
|
1
1
|
//
|
2
2
|
// Copyright 2024 DXOS.org
|
3
|
+
// Based on https://github.com/codemirror/view/blob/main/src/placeholder.ts
|
3
4
|
//
|
4
5
|
|
5
6
|
import { RangeSetBuilder } from '@codemirror/state';
|
@@ -9,37 +10,56 @@ import { commandState } from './state';
|
|
9
10
|
import { clientRectsFor, flattenRect } from '../../util';
|
10
11
|
|
11
12
|
export type HintOptions = {
|
12
|
-
|
13
|
+
delay?: number;
|
14
|
+
onHint?: () => string | undefined;
|
13
15
|
};
|
14
16
|
|
15
|
-
export const
|
16
|
-
ViewPlugin.fromClass(
|
17
|
+
export const hint = ({ delay = 3_000, onHint }: HintOptions) => {
|
18
|
+
return ViewPlugin.fromClass(
|
17
19
|
class {
|
18
20
|
decorations = Decoration.none;
|
21
|
+
timeout: ReturnType<typeof setTimeout> | undefined;
|
22
|
+
|
19
23
|
update(update: ViewUpdate) {
|
24
|
+
if (this.timeout) {
|
25
|
+
clearTimeout(this.timeout);
|
26
|
+
this.timeout = undefined;
|
27
|
+
}
|
28
|
+
|
20
29
|
const builder = new RangeSetBuilder<Decoration>();
|
21
30
|
const cState = update.view.state.field(commandState, false);
|
22
31
|
if (!cState?.tooltip) {
|
23
32
|
const selection = update.view.state.selection.main;
|
24
33
|
const line = update.view.state.doc.lineAt(selection.from);
|
25
34
|
// Only show if blank line.
|
26
|
-
// TODO(burdon): Clashes with placeholder if pos === 0.
|
27
|
-
// TODO(burdon): Show after delay or if blank line above?
|
28
35
|
if (selection.from === selection.to && line.from === line.to) {
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
36
|
+
// Set timeout to add decoration after delay.
|
37
|
+
this.timeout = setTimeout(() => {
|
38
|
+
const hint = onHint?.();
|
39
|
+
if (hint) {
|
40
|
+
const builder = new RangeSetBuilder<Decoration>();
|
41
|
+
builder.add(selection.from, selection.to, Decoration.widget({ widget: new Hint(hint) }));
|
42
|
+
this.decorations = builder.finish();
|
43
|
+
update.view.update([]);
|
44
|
+
}
|
45
|
+
}, delay);
|
33
46
|
}
|
34
47
|
}
|
35
48
|
|
36
49
|
this.decorations = builder.finish();
|
37
50
|
}
|
51
|
+
|
52
|
+
destroy() {
|
53
|
+
if (this.timeout) {
|
54
|
+
clearTimeout(this.timeout);
|
55
|
+
}
|
56
|
+
}
|
38
57
|
},
|
39
58
|
{
|
40
59
|
provide: (plugin) => [EditorView.decorations.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none)],
|
41
60
|
},
|
42
61
|
);
|
62
|
+
};
|
43
63
|
|
44
64
|
export class Hint extends WidgetType {
|
45
65
|
constructor(readonly content: string | HTMLElement) {
|
@@ -0,0 +1,113 @@
|
|
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, WidgetType, ViewPlugin, type ViewUpdate } 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
|
+
delay?: number;
|
15
|
+
content: Content;
|
16
|
+
};
|
17
|
+
|
18
|
+
export const placeholder = ({ delay = 3_000, content }: PlaceholderOptions): Extension => {
|
19
|
+
const plugin = ViewPlugin.fromClass(
|
20
|
+
class {
|
21
|
+
decorations = Decoration.none;
|
22
|
+
timeout: ReturnType<typeof setTimeout> | undefined;
|
23
|
+
|
24
|
+
update(update: ViewUpdate) {
|
25
|
+
if (this.timeout) {
|
26
|
+
window.clearTimeout(this.timeout);
|
27
|
+
this.timeout = undefined;
|
28
|
+
}
|
29
|
+
|
30
|
+
// Check if the active line (where cursor is) is empty.
|
31
|
+
const activeLine = update.view.state.doc.lineAt(update.view.state.selection.main.head);
|
32
|
+
const isEmpty = activeLine.text.trim() === '';
|
33
|
+
if (isEmpty) {
|
34
|
+
// Create widget decoration at the start of the current line.
|
35
|
+
const lineStart = activeLine.from;
|
36
|
+
this.timeout = setTimeout(() => {
|
37
|
+
this.decorations = Decoration.set([
|
38
|
+
Decoration.widget({
|
39
|
+
widget: new Placeholder(content),
|
40
|
+
side: 1,
|
41
|
+
}).range(lineStart),
|
42
|
+
]);
|
43
|
+
|
44
|
+
update.view.update([]);
|
45
|
+
}, delay);
|
46
|
+
}
|
47
|
+
|
48
|
+
this.decorations = Decoration.none;
|
49
|
+
}
|
50
|
+
|
51
|
+
destroy() {
|
52
|
+
if (this.timeout) {
|
53
|
+
clearTimeout(this.timeout);
|
54
|
+
}
|
55
|
+
}
|
56
|
+
},
|
57
|
+
{
|
58
|
+
provide: (plugin) => {
|
59
|
+
return [EditorView.decorations.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none)];
|
60
|
+
},
|
61
|
+
},
|
62
|
+
);
|
63
|
+
|
64
|
+
return typeof content === 'string'
|
65
|
+
? [plugin, EditorView.contentAttributes.of({ 'aria-placeholder': content })]
|
66
|
+
: plugin;
|
67
|
+
};
|
68
|
+
|
69
|
+
class Placeholder extends WidgetType {
|
70
|
+
constructor(readonly content: Content) {
|
71
|
+
super();
|
72
|
+
}
|
73
|
+
|
74
|
+
toDOM(view: EditorView) {
|
75
|
+
const wrap = document.createElement('span');
|
76
|
+
wrap.className = 'cm-placeholder';
|
77
|
+
wrap.style.pointerEvents = 'none';
|
78
|
+
wrap.appendChild(
|
79
|
+
typeof this.content === 'string'
|
80
|
+
? document.createTextNode(this.content)
|
81
|
+
: typeof this.content === 'function'
|
82
|
+
? this.content(view)
|
83
|
+
: this.content.cloneNode(true),
|
84
|
+
);
|
85
|
+
wrap.setAttribute('aria-hidden', 'true');
|
86
|
+
return wrap;
|
87
|
+
}
|
88
|
+
|
89
|
+
override coordsAt(dom: HTMLElement) {
|
90
|
+
const rects = dom.firstChild ? clientRectsFor(dom.firstChild) : [];
|
91
|
+
if (!rects.length) {
|
92
|
+
return null;
|
93
|
+
}
|
94
|
+
|
95
|
+
const style = window.getComputedStyle(dom.parentNode as HTMLElement);
|
96
|
+
const rect = flattenRect(rects[0], style.direction !== 'rtl');
|
97
|
+
const lineHeight = parseInt(style.lineHeight);
|
98
|
+
if (rect.bottom - rect.top > lineHeight * 1.5) {
|
99
|
+
return {
|
100
|
+
left: rect.left,
|
101
|
+
right: rect.right,
|
102
|
+
top: rect.top,
|
103
|
+
bottom: rect.top + lineHeight,
|
104
|
+
};
|
105
|
+
}
|
106
|
+
|
107
|
+
return rect;
|
108
|
+
}
|
109
|
+
|
110
|
+
override ignoreEvent() {
|
111
|
+
return false;
|
112
|
+
}
|
113
|
+
}
|
@@ -17,7 +17,7 @@ export type PopupOptions = {
|
|
17
17
|
};
|
18
18
|
|
19
19
|
type CommandState = {
|
20
|
-
tooltip?: Tooltip
|
20
|
+
tooltip?: Tooltip;
|
21
21
|
};
|
22
22
|
|
23
23
|
export const commandState = StateField.define<CommandState>({
|
@@ -38,7 +38,6 @@ export const commandState = StateField.define<CommandState>({
|
|
38
38
|
strictSide: true,
|
39
39
|
create: (view: EditorView) => {
|
40
40
|
const root = document.createElement('div');
|
41
|
-
|
42
41
|
const tooltipView: TooltipView = {
|
43
42
|
dom: root,
|
44
43
|
mount: (view: EditorView) => {
|