@dxos/react-ui-editor 0.8.3-main.672df60 → 0.8.3-main.7f5a14c
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 +868 -260
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +911 -297
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +868 -260
- 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/util.d.ts +2 -2
- package/dist/types/src/components/Popover/CommandMenu.d.ts +34 -0
- package/dist/types/src/components/Popover/CommandMenu.d.ts.map +1 -0
- package/dist/types/src/components/Popover/RefPopover.d.ts +19 -6
- package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -1
- package/dist/types/src/components/Popover/index.d.ts +1 -0
- package/dist/types/src/components/Popover/index.d.ts.map +1 -1
- package/dist/types/src/defaults.d.ts.map +1 -1
- package/dist/types/src/extensions/command/menu.d.ts +40 -0
- package/dist/types/src/extensions/command/menu.d.ts.map +1 -1
- package/dist/types/src/extensions/factories.d.ts +1 -0
- package/dist/types/src/extensions/factories.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +1 -0
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/placeholder.d.ts +4 -0
- package/dist/types/src/extensions/placeholder.d.ts.map +1 -0
- package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextEditor.d.ts +8 -9
- package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
- package/dist/types/src/stories/CommandMenu.stories.d.ts +12 -0
- package/dist/types/src/stories/CommandMenu.stories.d.ts.map +1 -0
- package/dist/types/src/stories/Preview.stories.d.ts.map +1 -1
- package/package.json +31 -28
- package/src/components/Popover/CommandMenu.tsx +279 -0
- package/src/components/Popover/RefPopover.tsx +44 -22
- package/src/components/Popover/index.ts +1 -0
- package/src/defaults.ts +1 -0
- package/src/extensions/command/menu.ts +306 -4
- package/src/extensions/factories.ts +4 -1
- package/src/extensions/index.ts +1 -0
- package/src/extensions/outliner/outliner.ts +0 -3
- package/src/extensions/placeholder.ts +82 -0
- package/src/extensions/preview/preview.ts +3 -6
- package/src/hooks/useTextEditor.ts +11 -12
- package/src/stories/CommandMenu.stories.tsx +143 -0
- package/src/stories/Preview.stories.tsx +32 -30
@@ -3,7 +3,15 @@
|
|
3
3
|
//
|
4
4
|
|
5
5
|
import { createContext } from '@radix-ui/react-context';
|
6
|
-
import React, {
|
6
|
+
import React, {
|
7
|
+
type PropsWithChildren,
|
8
|
+
useRef,
|
9
|
+
useState,
|
10
|
+
useEffect,
|
11
|
+
useCallback,
|
12
|
+
type RefObject,
|
13
|
+
forwardRef,
|
14
|
+
} from 'react';
|
7
15
|
|
8
16
|
import { addEventListener } from '@dxos/async';
|
9
17
|
import { type DxRefTag, type DxRefTagActivate } from '@dxos/lit-ui';
|
@@ -13,18 +21,45 @@ import { type PreviewLinkRef, type PreviewLinkTarget, type PreviewLookup } from
|
|
13
21
|
|
14
22
|
const customEventOptions = { capture: true, passive: false };
|
15
23
|
|
24
|
+
export type RefPopoverProps = PropsWithChildren<{
|
25
|
+
modal?: boolean;
|
26
|
+
open?: boolean;
|
27
|
+
onOpenChange?: (open: boolean) => void;
|
28
|
+
onActivate?: (event: DxRefTagActivate) => void;
|
29
|
+
}>;
|
30
|
+
|
31
|
+
export const RefPopover = forwardRef<DxRefTag | null, RefPopoverProps>(
|
32
|
+
({ children, open, onOpenChange, modal, onActivate }, ref) => {
|
33
|
+
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
34
|
+
|
35
|
+
useEffect(() => {
|
36
|
+
return rootRef && onActivate
|
37
|
+
? addEventListener(rootRef, 'dx-ref-tag-activate', onActivate, customEventOptions)
|
38
|
+
: undefined;
|
39
|
+
}, [rootRef, onActivate]);
|
40
|
+
|
41
|
+
return (
|
42
|
+
<Popover.Root open={open} onOpenChange={onOpenChange} modal={modal}>
|
43
|
+
<Popover.VirtualTrigger virtualRef={ref as unknown as RefObject<HTMLButtonElement>} />
|
44
|
+
<div role='none' className='contents' ref={setRootRef}>
|
45
|
+
{children}
|
46
|
+
</div>
|
47
|
+
</Popover.Root>
|
48
|
+
);
|
49
|
+
},
|
50
|
+
);
|
51
|
+
|
16
52
|
// Create a context for the dxn value.
|
17
53
|
type RefPopoverValue = Partial<{ link: PreviewLinkRef; target: PreviewLinkTarget; pending: boolean }>;
|
18
54
|
|
19
55
|
const REF_POPOVER = 'RefPopover';
|
20
56
|
const [RefPopoverContextProvider, useRefPopover] = createContext<RefPopoverValue>(REF_POPOVER, {});
|
21
57
|
|
22
|
-
type
|
58
|
+
type PreviewProviderProps = PropsWithChildren<{ onLookup?: PreviewLookup }>;
|
23
59
|
|
24
|
-
const
|
60
|
+
const PreviewProvider = ({ children, onLookup }: PreviewProviderProps) => {
|
25
61
|
const trigger = useRef<DxRefTag | null>(null);
|
26
62
|
const [value, setValue] = useState<RefPopoverValue>({});
|
27
|
-
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
28
63
|
const [open, setOpen] = useState(false);
|
29
64
|
|
30
65
|
const handleDxRefTagActivate = useCallback(
|
@@ -48,28 +83,15 @@ const RefPopoverProvider = ({ children, onLookup }: RefPopoverProviderProps) =>
|
|
48
83
|
[onLookup],
|
49
84
|
);
|
50
85
|
|
51
|
-
useEffect(() => {
|
52
|
-
return rootRef
|
53
|
-
? addEventListener(rootRef, 'dx-ref-tag-activate', handleDxRefTagActivate, customEventOptions)
|
54
|
-
: undefined;
|
55
|
-
}, [rootRef]);
|
56
|
-
|
57
86
|
return (
|
58
87
|
<RefPopoverContextProvider pending={value.pending} link={value.link} target={value.target}>
|
59
|
-
<
|
60
|
-
|
61
|
-
|
62
|
-
{children}
|
63
|
-
</div>
|
64
|
-
</Popover.Root>
|
88
|
+
<RefPopover ref={trigger} open={open} onOpenChange={setOpen} onActivate={handleDxRefTagActivate}>
|
89
|
+
{children}
|
90
|
+
</RefPopover>
|
65
91
|
</RefPopoverContextProvider>
|
66
92
|
);
|
67
93
|
};
|
68
94
|
|
69
|
-
export
|
70
|
-
Provider: RefPopoverProvider,
|
71
|
-
};
|
72
|
-
|
73
|
-
export { useRefPopover };
|
95
|
+
export { PreviewProvider, useRefPopover };
|
74
96
|
|
75
|
-
export type {
|
97
|
+
export type { PreviewProviderProps, RefPopoverValue };
|
package/src/defaults.ts
CHANGED
@@ -2,11 +2,18 @@
|
|
2
2
|
// Copyright 2024 DXOS.org
|
3
3
|
//
|
4
4
|
|
5
|
-
import {
|
5
|
+
import { RangeSetBuilder, StateField, StateEffect, Prec } from '@codemirror/state';
|
6
|
+
import { EditorView, ViewPlugin, type ViewUpdate, Decoration, keymap, type DecorationSet } from '@codemirror/view';
|
7
|
+
import { type RefObject, useCallback, useMemo, useRef, useState } from 'react';
|
6
8
|
|
7
9
|
import { type CleanupFn, addEventListener } from '@dxos/async';
|
10
|
+
import { type DxRefTag, type DxRefTagActivate } from '@dxos/lit-ui';
|
11
|
+
import { type MaybePromise } from '@dxos/util';
|
8
12
|
|
9
13
|
import { closeEffect, openEffect } from './action';
|
14
|
+
import { getItem, getNextItem, getPreviousItem, type CommandMenuGroup, type CommandMenuItem } from '../../components';
|
15
|
+
import { type Range } from '../../types';
|
16
|
+
import { multilinePlaceholder } from '../placeholder';
|
10
17
|
|
11
18
|
export type FloatingMenuOptions = {
|
12
19
|
icon?: string;
|
@@ -123,9 +130,6 @@ export const floatingMenu = (options: FloatingMenuOptions = {}) => [
|
|
123
130
|
'[data-has-focus] & .cm-ref-tag': {
|
124
131
|
opacity: '1',
|
125
132
|
},
|
126
|
-
'[data-is-attention-source] & .cm-ref-tag': {
|
127
|
-
opacity: '1',
|
128
|
-
},
|
129
133
|
'.cm-ref-tag button': {
|
130
134
|
display: 'grid',
|
131
135
|
alignItems: 'center',
|
@@ -135,3 +139,301 @@ export const floatingMenu = (options: FloatingMenuOptions = {}) => [
|
|
135
139
|
},
|
136
140
|
}),
|
137
141
|
];
|
142
|
+
|
143
|
+
type CommandState = {
|
144
|
+
trigger: string;
|
145
|
+
range: Range;
|
146
|
+
};
|
147
|
+
|
148
|
+
// State effects for managing command menu state.
|
149
|
+
export const commandRangeEffect = StateEffect.define<CommandState | null>();
|
150
|
+
|
151
|
+
// State field to track the active command menu range.
|
152
|
+
const commandMenuState = StateField.define<CommandState | null>({
|
153
|
+
create: () => null,
|
154
|
+
update: (value, tr) => {
|
155
|
+
let newValue = value;
|
156
|
+
|
157
|
+
for (const effect of tr.effects) {
|
158
|
+
if (effect.is(commandRangeEffect)) {
|
159
|
+
newValue = effect.value;
|
160
|
+
}
|
161
|
+
}
|
162
|
+
|
163
|
+
return newValue;
|
164
|
+
},
|
165
|
+
});
|
166
|
+
|
167
|
+
export type CommandMenuOptions = {
|
168
|
+
trigger: string | string[];
|
169
|
+
placeholder?: Parameters<typeof multilinePlaceholder>[0];
|
170
|
+
onArrowDown?: () => void;
|
171
|
+
onArrowUp?: () => void;
|
172
|
+
onDeactivate?: () => void;
|
173
|
+
onEnter?: () => void;
|
174
|
+
onTextChange?: (trigger: string, text: string) => void;
|
175
|
+
};
|
176
|
+
|
177
|
+
export const commandMenu = (options: CommandMenuOptions) => {
|
178
|
+
const commandMenuPlugin = ViewPlugin.fromClass(
|
179
|
+
class {
|
180
|
+
decorations: DecorationSet = Decoration.none;
|
181
|
+
|
182
|
+
constructor(readonly view: EditorView) {}
|
183
|
+
|
184
|
+
// TODO(wittjosiah): The decorations are repainted on every update, this occasionally causes menu to flicker.
|
185
|
+
update(update: ViewUpdate) {
|
186
|
+
const builder = new RangeSetBuilder<Decoration>();
|
187
|
+
const selection = update.view.state.selection.main;
|
188
|
+
const { range: activeRange, trigger } = update.view.state.field(commandMenuState) ?? {};
|
189
|
+
|
190
|
+
// Check if we should show the widget - only if cursor is within the active command range.
|
191
|
+
const shouldShowWidget = activeRange && selection.head >= activeRange.from && selection.head <= activeRange.to;
|
192
|
+
if (shouldShowWidget) {
|
193
|
+
// Create mark decoration that wraps the entire line content in a dx-ref-tag.
|
194
|
+
builder.add(
|
195
|
+
activeRange.from,
|
196
|
+
activeRange.to,
|
197
|
+
Decoration.mark({
|
198
|
+
tagName: 'dx-ref-tag',
|
199
|
+
class: 'cm-ref-tag',
|
200
|
+
attributes: {
|
201
|
+
'data-auto-trigger': 'true',
|
202
|
+
'data-trigger': trigger!,
|
203
|
+
},
|
204
|
+
}),
|
205
|
+
);
|
206
|
+
}
|
207
|
+
|
208
|
+
const activeRangeChanged = update.transactions.some((tr) =>
|
209
|
+
tr.effects.some((effect) => effect.is(commandRangeEffect)),
|
210
|
+
);
|
211
|
+
if (activeRange && activeRangeChanged && trigger) {
|
212
|
+
const content = update.view.state.sliceDoc(
|
213
|
+
activeRange.from + 1, // Skip the trigger character.
|
214
|
+
activeRange.to,
|
215
|
+
);
|
216
|
+
options.onTextChange?.(trigger, content);
|
217
|
+
}
|
218
|
+
|
219
|
+
this.decorations = builder.finish();
|
220
|
+
}
|
221
|
+
},
|
222
|
+
{
|
223
|
+
decorations: (v) => v.decorations,
|
224
|
+
},
|
225
|
+
);
|
226
|
+
|
227
|
+
const triggers = Array.isArray(options.trigger) ? options.trigger : [options.trigger];
|
228
|
+
const commandKeymap = keymap.of([
|
229
|
+
...triggers.map((trigger) => ({
|
230
|
+
key: trigger,
|
231
|
+
run: (view: EditorView) => {
|
232
|
+
const selection = view.state.selection.main;
|
233
|
+
const line = view.state.doc.lineAt(selection.head);
|
234
|
+
|
235
|
+
// Check if we should trigger the command menu:
|
236
|
+
// 1. Empty lines or at the beginning of a line
|
237
|
+
// 2. When there's a preceding space
|
238
|
+
const shouldTrigger =
|
239
|
+
line.text.trim() === '' ||
|
240
|
+
selection.head === line.from ||
|
241
|
+
(selection.head > line.from && line.text[selection.head - line.from - 1] === ' ');
|
242
|
+
|
243
|
+
if (shouldTrigger) {
|
244
|
+
view.dispatch({
|
245
|
+
changes: { from: selection.head, insert: trigger },
|
246
|
+
selection: { anchor: selection.head + 1, head: selection.head + 1 },
|
247
|
+
effects: commandRangeEffect.of({ trigger, range: { from: selection.head, to: selection.head + 1 } }),
|
248
|
+
});
|
249
|
+
return true;
|
250
|
+
}
|
251
|
+
|
252
|
+
return false;
|
253
|
+
},
|
254
|
+
})),
|
255
|
+
{
|
256
|
+
key: 'Enter',
|
257
|
+
run: (view) => {
|
258
|
+
const activeRange = view.state.field(commandMenuState)?.range;
|
259
|
+
if (activeRange) {
|
260
|
+
view.dispatch({ changes: { from: activeRange.from, to: activeRange.to, insert: '' } });
|
261
|
+
options.onEnter?.();
|
262
|
+
return true;
|
263
|
+
}
|
264
|
+
|
265
|
+
return false;
|
266
|
+
},
|
267
|
+
},
|
268
|
+
{
|
269
|
+
key: 'ArrowDown',
|
270
|
+
run: (view) => {
|
271
|
+
const activeRange = view.state.field(commandMenuState)?.range;
|
272
|
+
if (activeRange) {
|
273
|
+
options.onArrowDown?.();
|
274
|
+
return true;
|
275
|
+
}
|
276
|
+
|
277
|
+
return false;
|
278
|
+
},
|
279
|
+
},
|
280
|
+
{
|
281
|
+
key: 'ArrowUp',
|
282
|
+
run: (view) => {
|
283
|
+
const activeRange = view.state.field(commandMenuState)?.range;
|
284
|
+
if (activeRange) {
|
285
|
+
options.onArrowUp?.();
|
286
|
+
return true;
|
287
|
+
}
|
288
|
+
|
289
|
+
return false;
|
290
|
+
},
|
291
|
+
},
|
292
|
+
]);
|
293
|
+
|
294
|
+
// Listen for selection and document changes to clean up the command menu.
|
295
|
+
const updateListener = EditorView.updateListener.of((update) => {
|
296
|
+
const { trigger, range: activeRange } = update.view.state.field(commandMenuState) ?? {};
|
297
|
+
if (!activeRange || !trigger) {
|
298
|
+
return;
|
299
|
+
}
|
300
|
+
|
301
|
+
const selection = update.view.state.selection.main;
|
302
|
+
const firstChar = update.view.state.doc.sliceString(activeRange.from, activeRange.from + 1);
|
303
|
+
const shouldRemove =
|
304
|
+
firstChar !== trigger || // Trigger deleted.
|
305
|
+
selection.head < activeRange.from || // Cursor moved before the range.
|
306
|
+
selection.head > activeRange.to + 1; // Cursor moved after the range (+1 to handle selection changing before doc).
|
307
|
+
|
308
|
+
const nextRange = shouldRemove
|
309
|
+
? null
|
310
|
+
: update.docChanged
|
311
|
+
? { from: activeRange.from, to: selection.head }
|
312
|
+
: activeRange;
|
313
|
+
if (nextRange !== activeRange) {
|
314
|
+
update.view.dispatch({ effects: commandRangeEffect.of(nextRange ? { trigger, range: nextRange } : null) });
|
315
|
+
}
|
316
|
+
|
317
|
+
if (shouldRemove) {
|
318
|
+
options.onDeactivate?.();
|
319
|
+
}
|
320
|
+
});
|
321
|
+
|
322
|
+
return [
|
323
|
+
multilinePlaceholder(
|
324
|
+
options.placeholder ??
|
325
|
+
`Press '${Array.isArray(options.trigger) ? options.trigger[0] : options.trigger}' for commands`,
|
326
|
+
),
|
327
|
+
Prec.highest(commandKeymap),
|
328
|
+
updateListener,
|
329
|
+
commandMenuState,
|
330
|
+
commandMenuPlugin,
|
331
|
+
];
|
332
|
+
};
|
333
|
+
|
334
|
+
export type UseCommandMenuOptions = {
|
335
|
+
viewRef: RefObject<EditorView | undefined>;
|
336
|
+
trigger: string | string[];
|
337
|
+
placeholder?: Parameters<typeof multilinePlaceholder>[0];
|
338
|
+
getGroups: (trigger: string, query?: string) => MaybePromise<CommandMenuGroup[]>;
|
339
|
+
};
|
340
|
+
|
341
|
+
export const useCommandMenu = ({ viewRef, trigger, placeholder, getGroups }: UseCommandMenuOptions) => {
|
342
|
+
const triggerRef = useRef<DxRefTag | null>(null);
|
343
|
+
const currentRef = useRef<CommandMenuItem | null>(null);
|
344
|
+
const groupsRef = useRef<CommandMenuGroup[]>([]);
|
345
|
+
const [currentItem, setCurrentItem] = useState<string>();
|
346
|
+
const [open, setOpen] = useState(false);
|
347
|
+
const [_, update] = useState({});
|
348
|
+
|
349
|
+
const handleOpenChange = useCallback(
|
350
|
+
async (open: boolean, trigger?: string) => {
|
351
|
+
if (open && trigger) {
|
352
|
+
groupsRef.current = await getGroups(trigger);
|
353
|
+
}
|
354
|
+
setOpen(open);
|
355
|
+
if (!open) {
|
356
|
+
triggerRef.current = null;
|
357
|
+
setCurrentItem(undefined);
|
358
|
+
viewRef.current?.dispatch({ effects: [commandRangeEffect.of(null)] });
|
359
|
+
}
|
360
|
+
},
|
361
|
+
[getGroups],
|
362
|
+
);
|
363
|
+
|
364
|
+
const handleActivate = useCallback(
|
365
|
+
async (event: DxRefTagActivate) => {
|
366
|
+
const item = getItem(groupsRef.current, currentItem);
|
367
|
+
if (item) {
|
368
|
+
currentRef.current = item;
|
369
|
+
}
|
370
|
+
|
371
|
+
triggerRef.current = event.trigger;
|
372
|
+
const triggerKey = event.trigger.getAttribute('data-trigger');
|
373
|
+
if (!open && triggerKey) {
|
374
|
+
await handleOpenChange(true, triggerKey);
|
375
|
+
}
|
376
|
+
},
|
377
|
+
[open, handleOpenChange],
|
378
|
+
);
|
379
|
+
|
380
|
+
const handleSelect = useCallback((item: CommandMenuItem) => {
|
381
|
+
const view = viewRef.current;
|
382
|
+
if (!view) {
|
383
|
+
return;
|
384
|
+
}
|
385
|
+
|
386
|
+
const selection = view.state.selection.main;
|
387
|
+
void item.onSelect?.(view, selection.head);
|
388
|
+
}, []);
|
389
|
+
|
390
|
+
const serializedTrigger = Array.isArray(trigger) ? trigger.join(',') : trigger;
|
391
|
+
const _commandMenu = useMemo(
|
392
|
+
() =>
|
393
|
+
commandMenu({
|
394
|
+
trigger,
|
395
|
+
placeholder,
|
396
|
+
onArrowDown: () => {
|
397
|
+
setCurrentItem((currentItem) => {
|
398
|
+
const next = getNextItem(groupsRef.current, currentItem);
|
399
|
+
currentRef.current = next;
|
400
|
+
return next.id;
|
401
|
+
});
|
402
|
+
},
|
403
|
+
onArrowUp: () => {
|
404
|
+
setCurrentItem((currentItem) => {
|
405
|
+
const previous = getPreviousItem(groupsRef.current, currentItem);
|
406
|
+
currentRef.current = previous;
|
407
|
+
return previous.id;
|
408
|
+
});
|
409
|
+
},
|
410
|
+
onDeactivate: () => handleOpenChange(false),
|
411
|
+
onEnter: () => {
|
412
|
+
if (currentRef.current) {
|
413
|
+
handleSelect(currentRef.current);
|
414
|
+
}
|
415
|
+
},
|
416
|
+
onTextChange: async (trigger, text) => {
|
417
|
+
groupsRef.current = await getGroups(trigger, text);
|
418
|
+
const firstItem = groupsRef.current.filter((group) => group.items.length > 0)[0]?.items[0];
|
419
|
+
if (firstItem) {
|
420
|
+
setCurrentItem(firstItem.id);
|
421
|
+
currentRef.current = firstItem;
|
422
|
+
}
|
423
|
+
update({});
|
424
|
+
},
|
425
|
+
}),
|
426
|
+
[handleOpenChange, getGroups, serializedTrigger, placeholder],
|
427
|
+
);
|
428
|
+
|
429
|
+
return {
|
430
|
+
commandMenu: _commandMenu,
|
431
|
+
currentItem,
|
432
|
+
groupsRef,
|
433
|
+
ref: triggerRef,
|
434
|
+
open,
|
435
|
+
onActivate: handleActivate,
|
436
|
+
onOpenChange: setOpen,
|
437
|
+
onSelect: handleSelect,
|
438
|
+
};
|
439
|
+
};
|
@@ -34,6 +34,7 @@ import { hexToHue, isNotFalsy } from '@dxos/util';
|
|
34
34
|
import { automerge } from './automerge';
|
35
35
|
import { SpaceAwarenessProvider, awareness } from './awareness';
|
36
36
|
import { focus } from './focus';
|
37
|
+
import { editorGutter, editorMonospace } from '../defaults';
|
37
38
|
import { type ThemeStyles, defaultTheme } from '../styles';
|
38
39
|
|
39
40
|
//
|
@@ -62,6 +63,7 @@ export type BasicExtensionsOptions = {
|
|
62
63
|
lineNumbers?: boolean;
|
63
64
|
/** If false then do not set a max-width or side margin on the editor. */
|
64
65
|
lineWrapping?: boolean;
|
66
|
+
monospace?: boolean;
|
65
67
|
placeholder?: string;
|
66
68
|
/** If true user cannot edit the text, but they can still select and copy it. */
|
67
69
|
readOnly?: boolean;
|
@@ -107,8 +109,9 @@ export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extensio
|
|
107
109
|
props.focus && focus,
|
108
110
|
props.highlightActiveLine && highlightActiveLine(),
|
109
111
|
props.history && history(),
|
110
|
-
props.lineNumbers && lineNumbers(),
|
112
|
+
props.lineNumbers && [lineNumbers(), editorGutter],
|
111
113
|
props.lineWrapping && EditorView.lineWrapping,
|
114
|
+
props.monospace && editorMonospace,
|
112
115
|
props.placeholder && placeholder(props.placeholder),
|
113
116
|
props.readOnly !== undefined && EditorState.readOnly.of(props.readOnly),
|
114
117
|
props.scrollPastEnd && scrollPastEnd(),
|
package/src/extensions/index.ts
CHANGED
@@ -161,9 +161,6 @@ const decorations = () => [
|
|
161
161
|
'[data-has-focus] & .cm-list-item-selected': {
|
162
162
|
borderColor: 'var(--dx-separator)',
|
163
163
|
},
|
164
|
-
'[data-is-attention-source] & .cm-list-item-selected': {
|
165
|
-
borderColor: 'var(--dx-separator)',
|
166
|
-
},
|
167
164
|
}),
|
168
165
|
),
|
169
166
|
];
|
@@ -0,0 +1,82 @@
|
|
1
|
+
//
|
2
|
+
// Copyright 2025 DXOS.org
|
3
|
+
//
|
4
|
+
|
5
|
+
// Based on https://github.com/codemirror/view/blob/main/src/placeholder.ts
|
6
|
+
|
7
|
+
import { type Extension } from '@codemirror/state';
|
8
|
+
import { Decoration, EditorView, WidgetType, ViewPlugin } from '@codemirror/view';
|
9
|
+
|
10
|
+
import { clientRectsFor, flattenRect } from '../util';
|
11
|
+
|
12
|
+
class Placeholder extends WidgetType {
|
13
|
+
constructor(readonly content: string | HTMLElement | ((view: EditorView) => HTMLElement)) {
|
14
|
+
super();
|
15
|
+
}
|
16
|
+
|
17
|
+
toDOM(view: EditorView) {
|
18
|
+
const wrap = document.createElement('span');
|
19
|
+
wrap.className = 'cm-placeholder';
|
20
|
+
wrap.style.pointerEvents = 'none';
|
21
|
+
wrap.appendChild(
|
22
|
+
typeof this.content === 'string'
|
23
|
+
? document.createTextNode(this.content)
|
24
|
+
: typeof this.content === 'function'
|
25
|
+
? this.content(view)
|
26
|
+
: this.content.cloneNode(true),
|
27
|
+
);
|
28
|
+
wrap.setAttribute('aria-hidden', 'true');
|
29
|
+
return wrap;
|
30
|
+
}
|
31
|
+
|
32
|
+
override coordsAt(dom: HTMLElement) {
|
33
|
+
const rects = dom.firstChild ? clientRectsFor(dom.firstChild) : [];
|
34
|
+
if (!rects.length) {
|
35
|
+
return null;
|
36
|
+
}
|
37
|
+
const style = window.getComputedStyle(dom.parentNode as HTMLElement);
|
38
|
+
const rect = flattenRect(rects[0], style.direction !== 'rtl');
|
39
|
+
const lineHeight = parseInt(style.lineHeight);
|
40
|
+
if (rect.bottom - rect.top > lineHeight * 1.5) {
|
41
|
+
return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.top + lineHeight };
|
42
|
+
}
|
43
|
+
return rect;
|
44
|
+
}
|
45
|
+
|
46
|
+
override ignoreEvent() {
|
47
|
+
return false;
|
48
|
+
}
|
49
|
+
}
|
50
|
+
|
51
|
+
export function multilinePlaceholder(content: string | HTMLElement | ((view: EditorView) => HTMLElement)): Extension {
|
52
|
+
const plugin = ViewPlugin.fromClass(
|
53
|
+
class {
|
54
|
+
constructor(readonly view: EditorView) {}
|
55
|
+
|
56
|
+
declare update: () => void; // Kludge to convince TypeScript that this is a plugin value
|
57
|
+
|
58
|
+
get decorations() {
|
59
|
+
// Check if the active line (where cursor is) is empty
|
60
|
+
const activeLine = this.view.state.doc.lineAt(this.view.state.selection.main.head);
|
61
|
+
const isEmpty = activeLine.text.trim() === '';
|
62
|
+
|
63
|
+
if (!isEmpty || !content) {
|
64
|
+
return Decoration.none;
|
65
|
+
}
|
66
|
+
|
67
|
+
// Create widget decoration at the start of the current line
|
68
|
+
const lineStart = activeLine.from;
|
69
|
+
return Decoration.set([
|
70
|
+
Decoration.widget({
|
71
|
+
widget: new Placeholder(content),
|
72
|
+
side: 1,
|
73
|
+
}).range(lineStart),
|
74
|
+
]);
|
75
|
+
}
|
76
|
+
},
|
77
|
+
{ decorations: (v) => v.decorations },
|
78
|
+
);
|
79
|
+
return typeof content === 'string'
|
80
|
+
? [plugin, EditorView.contentAttributes.of({ 'aria-placeholder': content })]
|
81
|
+
: plugin;
|
82
|
+
}
|
@@ -77,12 +77,9 @@ export const preview = (options: PreviewOptions = {}): Extension => {
|
|
77
77
|
|
78
78
|
EditorView.theme({
|
79
79
|
'.cm-preview-block': {
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
borderRadius: '0.5rem',
|
84
|
-
background: 'var(--dx-modalSurface)',
|
85
|
-
border: '1px solid var(--dx-separator)',
|
80
|
+
'--dx-card-spacing-inline': 'var(--dx-trimMd)',
|
81
|
+
'--dx-card-spacing-block': 'var(--dx-trimMd)',
|
82
|
+
marginInline: 'calc(-1*var(--dx-trimMd))',
|
86
83
|
},
|
87
84
|
}),
|
88
85
|
];
|
@@ -22,15 +22,7 @@ import { getProviderValue, isNotFalsy, type MaybeProvider } from '@dxos/util';
|
|
22
22
|
import { type EditorSelection, documentId, createEditorStateTransaction, editorInputMode } from '../extensions';
|
23
23
|
import { debugDispatcher } from '../util';
|
24
24
|
|
25
|
-
|
26
|
-
// TODO(burdon): Rename.
|
27
|
-
parentRef: RefObject<HTMLDivElement>;
|
28
|
-
view?: EditorView;
|
29
|
-
focusAttributes?: TabsterTypes.TabsterDOMAttribute & {
|
30
|
-
tabIndex: 0;
|
31
|
-
onKeyUp: KeyboardEventHandler<HTMLDivElement>;
|
32
|
-
};
|
33
|
-
};
|
25
|
+
let instanceCount = 0;
|
34
26
|
|
35
27
|
export type CursorInfo = {
|
36
28
|
from: number;
|
@@ -41,11 +33,20 @@ export type CursorInfo = {
|
|
41
33
|
after?: string;
|
42
34
|
};
|
43
35
|
|
36
|
+
export type UseTextEditor = {
|
37
|
+
// TODO(burdon): Rename.
|
38
|
+
parentRef: RefObject<HTMLDivElement>;
|
39
|
+
view?: EditorView;
|
40
|
+
focusAttributes?: TabsterTypes.TabsterDOMAttribute & {
|
41
|
+
tabIndex: 0;
|
42
|
+
onKeyUp: KeyboardEventHandler<HTMLDivElement>;
|
43
|
+
};
|
44
|
+
};
|
45
|
+
|
44
46
|
export type UseTextEditorProps = Pick<EditorStateConfig, 'extensions'> & {
|
45
47
|
id?: string;
|
46
48
|
doc?: Text;
|
47
49
|
initialValue?: string;
|
48
|
-
className?: string;
|
49
50
|
autoFocus?: boolean;
|
50
51
|
scrollTo?: number;
|
51
52
|
selection?: EditorSelection;
|
@@ -53,8 +54,6 @@ export type UseTextEditorProps = Pick<EditorStateConfig, 'extensions'> & {
|
|
53
54
|
debug?: boolean;
|
54
55
|
};
|
55
56
|
|
56
|
-
let instanceCount = 0;
|
57
|
-
|
58
57
|
/**
|
59
58
|
* Creates codemirror text editor.
|
60
59
|
*/
|