@dxos/react-ui-editor 0.8.3-main.672df60 → 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 +981 -377
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +1025 -420
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +981 -377
- 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/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/RefDropdownMenu.d.ts.map +1 -1
- 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 +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/{menu.d.ts → floating-menu.d.ts} +1 -1
- 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/factories.d.ts +1 -0
- package/dist/types/src/extensions/factories.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/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 +13 -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/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 -28
- package/src/components/EditorToolbar/EditorToolbar.tsx +5 -9
- package/src/components/Popover/CommandMenu.tsx +279 -0
- package/src/components/Popover/RefDropdownMenu.tsx +5 -3
- package/src/components/Popover/RefPopover.tsx +46 -22
- package/src/components/Popover/index.ts +1 -0
- package/src/defaults.ts +1 -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/{menu.ts → floating-menu.ts} +0 -4
- 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/factories.ts +4 -1
- package/src/extensions/markdown/bundle.ts +0 -2
- package/src/extensions/outliner/outliner.ts +0 -3
- package/src/extensions/outliner/tree.test.ts +13 -10
- package/src/extensions/outliner/tree.ts +5 -3
- package/src/extensions/preview/preview.ts +11 -89
- package/src/hooks/useTextEditor.ts +11 -12
- package/src/stories/Command.stories.tsx +1 -1
- package/src/stories/CommandMenu.stories.tsx +159 -0
- package/src/stories/Preview.stories.tsx +157 -78
- 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.map +0 -1
@@ -0,0 +1,279 @@
|
|
1
|
+
//
|
2
|
+
// Copyright 2025 DXOS.org
|
3
|
+
//
|
4
|
+
|
5
|
+
import { type EditorView } from '@codemirror/view';
|
6
|
+
import React, { useCallback, useEffect, useRef } from 'react';
|
7
|
+
|
8
|
+
import { Icon, type Label, Popover, toLocalizedString, useThemeContext, useTranslation } from '@dxos/react-ui';
|
9
|
+
import { type MaybePromise } from '@dxos/util';
|
10
|
+
|
11
|
+
import { commandRangeEffect } from '../../extensions';
|
12
|
+
|
13
|
+
export type CommandMenuGroup = {
|
14
|
+
id: string;
|
15
|
+
label?: Label;
|
16
|
+
items: CommandMenuItem[];
|
17
|
+
};
|
18
|
+
|
19
|
+
export type CommandMenuItem = {
|
20
|
+
id: string;
|
21
|
+
label: Label;
|
22
|
+
icon?: string;
|
23
|
+
onSelect?: (view: EditorView, head: number) => MaybePromise<void>;
|
24
|
+
};
|
25
|
+
|
26
|
+
export type CommandMenuProps = {
|
27
|
+
groups: CommandMenuGroup[];
|
28
|
+
currentItem?: string;
|
29
|
+
onSelect: (item: CommandMenuItem) => void;
|
30
|
+
};
|
31
|
+
|
32
|
+
// NOTE: Not using DropdownMenu because the command menu needs to manage focus explicitly.
|
33
|
+
export const CommandMenu = ({ groups, currentItem, onSelect }: CommandMenuProps) => {
|
34
|
+
const { tx } = useThemeContext();
|
35
|
+
const groupsWithItems = groups.filter((group) => group.items.length > 0);
|
36
|
+
return (
|
37
|
+
<Popover.Portal>
|
38
|
+
<Popover.Content
|
39
|
+
align='start'
|
40
|
+
onOpenAutoFocus={(event) => event.preventDefault()}
|
41
|
+
classNames={tx('menu.content', 'menu--exotic-unfocusable', { elevation: 'positioned' }, [
|
42
|
+
'max-h-[300px] overflow-y-auto',
|
43
|
+
])}
|
44
|
+
>
|
45
|
+
<Popover.Viewport classNames={tx('menu.viewport', 'menu__viewport--exotic-unfocusable', {})}>
|
46
|
+
<ul>
|
47
|
+
{groupsWithItems.map((group, index) => (
|
48
|
+
<React.Fragment key={group.id}>
|
49
|
+
<CommandGroup group={group} currentItem={currentItem} onSelect={onSelect} />
|
50
|
+
{index < groupsWithItems.length - 1 && <div className={tx('menu.separator', 'menu__item', {})} />}
|
51
|
+
</React.Fragment>
|
52
|
+
))}
|
53
|
+
</ul>
|
54
|
+
</Popover.Viewport>
|
55
|
+
</Popover.Content>
|
56
|
+
</Popover.Portal>
|
57
|
+
);
|
58
|
+
};
|
59
|
+
|
60
|
+
const CommandGroup = ({
|
61
|
+
group,
|
62
|
+
currentItem,
|
63
|
+
onSelect,
|
64
|
+
}: {
|
65
|
+
group: CommandMenuGroup;
|
66
|
+
currentItem?: string;
|
67
|
+
onSelect: (item: CommandMenuItem) => void;
|
68
|
+
}) => {
|
69
|
+
const { tx } = useThemeContext();
|
70
|
+
const { t } = useTranslation();
|
71
|
+
return (
|
72
|
+
<>
|
73
|
+
{group.label && (
|
74
|
+
<div className={tx('menu.groupLabel', 'menu__group__label', {})}>
|
75
|
+
<span>{toLocalizedString(group.label, t)}</span>
|
76
|
+
</div>
|
77
|
+
)}
|
78
|
+
{group.items.map((item) => (
|
79
|
+
<CommandItem key={item.id} item={item} current={currentItem === item.id} onSelect={onSelect} />
|
80
|
+
))}
|
81
|
+
</>
|
82
|
+
);
|
83
|
+
};
|
84
|
+
|
85
|
+
const CommandItem = ({
|
86
|
+
item,
|
87
|
+
current,
|
88
|
+
onSelect,
|
89
|
+
}: {
|
90
|
+
item: CommandMenuItem;
|
91
|
+
current: boolean;
|
92
|
+
onSelect: (item: CommandMenuItem) => void;
|
93
|
+
}) => {
|
94
|
+
const ref = useRef<HTMLLIElement>(null);
|
95
|
+
const { tx } = useThemeContext();
|
96
|
+
const { t } = useTranslation();
|
97
|
+
const handleSelect = useCallback(() => onSelect(item), [item, onSelect]);
|
98
|
+
|
99
|
+
useEffect(() => {
|
100
|
+
if (current && ref.current) {
|
101
|
+
ref.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
102
|
+
}
|
103
|
+
}, [current]);
|
104
|
+
|
105
|
+
return (
|
106
|
+
<li
|
107
|
+
ref={ref}
|
108
|
+
className={tx('menu.item', 'menu__item--exotic-unfocusable', {}, [current && 'bg-hoverSurface'])}
|
109
|
+
onClick={handleSelect}
|
110
|
+
>
|
111
|
+
{item.icon && <Icon icon={item.icon} size={5} />}
|
112
|
+
<span className='grow truncate'>{toLocalizedString(item.label, t)}</span>
|
113
|
+
</li>
|
114
|
+
);
|
115
|
+
};
|
116
|
+
|
117
|
+
// TODO(wittjosiah): Factor out into a separate file.
|
118
|
+
|
119
|
+
//
|
120
|
+
// Helpers
|
121
|
+
//
|
122
|
+
|
123
|
+
export const getItem = (groups: CommandMenuGroup[], id?: string): CommandMenuItem | undefined => {
|
124
|
+
return groups.flatMap((group) => group.items).find((item) => item.id === id);
|
125
|
+
};
|
126
|
+
|
127
|
+
export const getNextItem = (groups: CommandMenuGroup[], id?: string): CommandMenuItem => {
|
128
|
+
const items = groups.flatMap((group) => group.items);
|
129
|
+
const index = items.findIndex((item) => item.id === id);
|
130
|
+
return items[(index + 1) % items.length];
|
131
|
+
};
|
132
|
+
|
133
|
+
export const getPreviousItem = (groups: CommandMenuGroup[], id?: string): CommandMenuItem => {
|
134
|
+
const items = groups.flatMap((group) => group.items);
|
135
|
+
const index = items.findIndex((item) => item.id === id);
|
136
|
+
return items[(index - 1 + items.length) % items.length];
|
137
|
+
};
|
138
|
+
|
139
|
+
export const filterItems = (
|
140
|
+
groups: CommandMenuGroup[],
|
141
|
+
filter: (item: CommandMenuItem) => boolean,
|
142
|
+
): CommandMenuGroup[] => {
|
143
|
+
return groups.map((group) => ({
|
144
|
+
...group,
|
145
|
+
items: group.items.filter(filter),
|
146
|
+
}));
|
147
|
+
};
|
148
|
+
|
149
|
+
export const insertAtCursor = (view: EditorView, head: number, insert: string) => {
|
150
|
+
view.dispatch({
|
151
|
+
changes: { from: head, to: head, insert },
|
152
|
+
selection: { anchor: head + insert.length, head: head + insert.length },
|
153
|
+
});
|
154
|
+
};
|
155
|
+
|
156
|
+
/**
|
157
|
+
* If the cursor is at the start of a line, insert the text at the cursor.
|
158
|
+
* Otherwise, insert the text on a new line.
|
159
|
+
*/
|
160
|
+
export const insertAtLineStart = (view: EditorView, head: number, insert: string) => {
|
161
|
+
const line = view.state.doc.lineAt(head);
|
162
|
+
if (line.from === head) {
|
163
|
+
insertAtCursor(view, head, insert);
|
164
|
+
} else {
|
165
|
+
insert = '\n' + insert;
|
166
|
+
view.dispatch({
|
167
|
+
changes: { from: line.to, to: line.to, insert },
|
168
|
+
selection: { anchor: line.to + insert.length, head: line.to + insert.length },
|
169
|
+
});
|
170
|
+
}
|
171
|
+
};
|
172
|
+
|
173
|
+
export const coreSlashCommands: CommandMenuGroup = {
|
174
|
+
id: 'markdown',
|
175
|
+
label: 'Markdown',
|
176
|
+
items: [
|
177
|
+
{
|
178
|
+
id: 'heading-1',
|
179
|
+
label: 'Heading 1',
|
180
|
+
icon: 'ph--text-h-one--regular',
|
181
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '# '),
|
182
|
+
},
|
183
|
+
{
|
184
|
+
id: 'heading-2',
|
185
|
+
label: 'Heading 2',
|
186
|
+
icon: 'ph--text-h-two--regular',
|
187
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '## '),
|
188
|
+
},
|
189
|
+
{
|
190
|
+
id: 'heading-3',
|
191
|
+
label: 'Heading 3',
|
192
|
+
icon: 'ph--text-h-three--regular',
|
193
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '### '),
|
194
|
+
},
|
195
|
+
{
|
196
|
+
id: 'heading-4',
|
197
|
+
label: 'Heading 4',
|
198
|
+
icon: 'ph--text-h-four--regular',
|
199
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '#### '),
|
200
|
+
},
|
201
|
+
{
|
202
|
+
id: 'heading-5',
|
203
|
+
label: 'Heading 5',
|
204
|
+
icon: 'ph--text-h-five--regular',
|
205
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '##### '),
|
206
|
+
},
|
207
|
+
{
|
208
|
+
id: 'heading-6',
|
209
|
+
label: 'Heading 6',
|
210
|
+
icon: 'ph--text-h-six--regular',
|
211
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '###### '),
|
212
|
+
},
|
213
|
+
{
|
214
|
+
id: 'bullet-list',
|
215
|
+
label: 'Bullet List',
|
216
|
+
icon: 'ph--list-bullets--regular',
|
217
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '- '),
|
218
|
+
},
|
219
|
+
{
|
220
|
+
id: 'numbered-list',
|
221
|
+
label: 'Numbered List',
|
222
|
+
icon: 'ph--list-numbers--regular',
|
223
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '1. '),
|
224
|
+
},
|
225
|
+
{
|
226
|
+
id: 'task-list',
|
227
|
+
label: 'Task List',
|
228
|
+
icon: 'ph--list-checks--regular',
|
229
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '- [ ] '),
|
230
|
+
},
|
231
|
+
{
|
232
|
+
id: 'quote',
|
233
|
+
label: 'Quote',
|
234
|
+
icon: 'ph--quotes--regular',
|
235
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '> '),
|
236
|
+
},
|
237
|
+
{
|
238
|
+
id: 'code-block',
|
239
|
+
label: 'Code Block',
|
240
|
+
icon: 'ph--code-block--regular',
|
241
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '```\n\n```'),
|
242
|
+
},
|
243
|
+
{
|
244
|
+
id: 'table',
|
245
|
+
label: 'Table',
|
246
|
+
icon: 'ph--table--regular',
|
247
|
+
onSelect: (view, head) => insertAtLineStart(view, head, '| | | |\n|---|---|---|\n| | | |'),
|
248
|
+
},
|
249
|
+
],
|
250
|
+
};
|
251
|
+
|
252
|
+
export const linkSlashCommands: CommandMenuGroup = {
|
253
|
+
id: 'link',
|
254
|
+
label: 'Link',
|
255
|
+
items: [
|
256
|
+
{
|
257
|
+
id: 'inline-link',
|
258
|
+
label: 'Inline link',
|
259
|
+
icon: 'ph--link--regular',
|
260
|
+
onSelect: (view, head) =>
|
261
|
+
view.dispatch({
|
262
|
+
changes: { from: head, insert: '@' },
|
263
|
+
selection: { anchor: head + 1, head: head + 1 },
|
264
|
+
effects: commandRangeEffect.of({ trigger: '@', range: { from: head, to: head + 1 } }),
|
265
|
+
}),
|
266
|
+
},
|
267
|
+
{
|
268
|
+
id: 'block-embed',
|
269
|
+
label: 'Block embed',
|
270
|
+
icon: 'ph--lego--regular',
|
271
|
+
onSelect: (view, head) =>
|
272
|
+
view.dispatch({
|
273
|
+
changes: { from: head, insert: '@@' },
|
274
|
+
selection: { anchor: head + 2, head: head + 2 },
|
275
|
+
effects: commandRangeEffect.of({ trigger: '@', range: { from: head, to: head + 2 } }),
|
276
|
+
}),
|
277
|
+
},
|
278
|
+
],
|
279
|
+
};
|
@@ -51,9 +51,11 @@ const RefDropdownMenuProvider = ({ children, onLookup }: RefDropdownMenuProvider
|
|
51
51
|
);
|
52
52
|
|
53
53
|
useEffect(() => {
|
54
|
-
|
55
|
-
|
56
|
-
|
54
|
+
if (!rootRef) {
|
55
|
+
return;
|
56
|
+
}
|
57
|
+
|
58
|
+
return addEventListener(rootRef, 'dx-ref-tag-activate' as any, handleDxRefTagActivate, customEventOptions);
|
57
59
|
}, [rootRef]);
|
58
60
|
|
59
61
|
return (
|
@@ -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,47 @@ 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
|
+
if (!rootRef || !onActivate) {
|
37
|
+
return;
|
38
|
+
}
|
39
|
+
|
40
|
+
return addEventListener(rootRef, 'dx-ref-tag-activate' as any, onActivate, customEventOptions);
|
41
|
+
}, [rootRef, onActivate]);
|
42
|
+
|
43
|
+
return (
|
44
|
+
<Popover.Root open={open} onOpenChange={onOpenChange} modal={modal}>
|
45
|
+
<Popover.VirtualTrigger virtualRef={ref as unknown as RefObject<HTMLButtonElement>} />
|
46
|
+
<div role='none' className='contents' ref={setRootRef}>
|
47
|
+
{children}
|
48
|
+
</div>
|
49
|
+
</Popover.Root>
|
50
|
+
);
|
51
|
+
},
|
52
|
+
);
|
53
|
+
|
16
54
|
// Create a context for the dxn value.
|
17
55
|
type RefPopoverValue = Partial<{ link: PreviewLinkRef; target: PreviewLinkTarget; pending: boolean }>;
|
18
56
|
|
19
57
|
const REF_POPOVER = 'RefPopover';
|
20
58
|
const [RefPopoverContextProvider, useRefPopover] = createContext<RefPopoverValue>(REF_POPOVER, {});
|
21
59
|
|
22
|
-
type
|
60
|
+
type PreviewProviderProps = PropsWithChildren<{ onLookup?: PreviewLookup }>;
|
23
61
|
|
24
|
-
const
|
62
|
+
const PreviewProvider = ({ children, onLookup }: PreviewProviderProps) => {
|
25
63
|
const trigger = useRef<DxRefTag | null>(null);
|
26
64
|
const [value, setValue] = useState<RefPopoverValue>({});
|
27
|
-
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
28
65
|
const [open, setOpen] = useState(false);
|
29
66
|
|
30
67
|
const handleDxRefTagActivate = useCallback(
|
@@ -48,28 +85,15 @@ const RefPopoverProvider = ({ children, onLookup }: RefPopoverProviderProps) =>
|
|
48
85
|
[onLookup],
|
49
86
|
);
|
50
87
|
|
51
|
-
useEffect(() => {
|
52
|
-
return rootRef
|
53
|
-
? addEventListener(rootRef, 'dx-ref-tag-activate', handleDxRefTagActivate, customEventOptions)
|
54
|
-
: undefined;
|
55
|
-
}, [rootRef]);
|
56
|
-
|
57
88
|
return (
|
58
89
|
<RefPopoverContextProvider pending={value.pending} link={value.link} target={value.target}>
|
59
|
-
<
|
60
|
-
|
61
|
-
|
62
|
-
{children}
|
63
|
-
</div>
|
64
|
-
</Popover.Root>
|
90
|
+
<RefPopover ref={trigger} open={open} onOpenChange={setOpen} onActivate={handleDxRefTagActivate}>
|
91
|
+
{children}
|
92
|
+
</RefPopover>
|
65
93
|
</RefPopoverContextProvider>
|
66
94
|
);
|
67
95
|
};
|
68
96
|
|
69
|
-
export
|
70
|
-
Provider: RefPopoverProvider,
|
71
|
-
};
|
72
|
-
|
73
|
-
export { useRefPopover };
|
97
|
+
export { PreviewProvider, useRefPopover };
|
74
98
|
|
75
|
-
export type {
|
99
|
+
export type { PreviewProviderProps, RefPopoverValue };
|
package/src/defaults.ts
CHANGED
@@ -28,6 +28,7 @@ export const editorSlots: ThemeExtensionsOptions['slots'] = {
|
|
28
28
|
|
29
29
|
export const editorGutter = EditorView.theme({
|
30
30
|
'.cm-gutters': {
|
31
|
+
background: 'var(--dx-baseSurface)',
|
31
32
|
paddingRight: '1rem',
|
32
33
|
},
|
33
34
|
});
|
@@ -46,9 +47,3 @@ export const stackItemContentEditorClassNames = (role?: string) =>
|
|
46
47
|
'attention-surface dx-focus-ring-inset data-[toolbar=disabled]:pbs-2',
|
47
48
|
role === 'section' ? '[&_.cm-scroller]:overflow-hidden [&_.cm-scroller]:min-bs-24' : 'min-bs-0',
|
48
49
|
);
|
49
|
-
|
50
|
-
export const stackItemContentToolbarClassNames = (role?: string) =>
|
51
|
-
mx(
|
52
|
-
'relative z-[1] flex is-full bg-toolbarSurface border-be border-subduedSeparator',
|
53
|
-
role === 'section' && 'sticky block-start-0 -mbe-px min-is-0',
|
54
|
-
);
|
@@ -10,8 +10,8 @@ import { Repo } from '@automerge/automerge-repo';
|
|
10
10
|
import { BroadcastChannelNetworkAdapter } from '@automerge/automerge-repo-network-broadcastchannel';
|
11
11
|
import React, { useEffect, useState } from 'react';
|
12
12
|
|
13
|
-
import {
|
14
|
-
import { DocAccessor,
|
13
|
+
import { Obj, Ref, Type } from '@dxos/echo';
|
14
|
+
import { DocAccessor, createDocAccessor, useQuery, useSpace, type Space, Query } from '@dxos/react-client/echo';
|
15
15
|
import { useIdentity, type Identity } from '@dxos/react-client/halo';
|
16
16
|
import { ClientRepeater, type ClientRepeatedComponentProps } from '@dxos/react-client/testing';
|
17
17
|
import { useThemeContext } from '@dxos/react-ui';
|
@@ -100,7 +100,7 @@ const EchoStory = ({ spaceKey }: ClientRepeatedComponentProps) => {
|
|
100
100
|
const identity = useIdentity();
|
101
101
|
const space = useSpace(spaceKey);
|
102
102
|
const [source, setSource] = useState<DocAccessor>();
|
103
|
-
const objects = useQuery(space, Query.type(Expando, { type: 'test' }));
|
103
|
+
const objects = useQuery(space, Query.type(Type.Expando, { type: 'test' }));
|
104
104
|
|
105
105
|
useEffect(() => {
|
106
106
|
if (!source && objects.length) {
|
@@ -128,9 +128,9 @@ export const WithEcho = {
|
|
128
128
|
createSpace
|
129
129
|
onSpaceCreated={async ({ space }) => {
|
130
130
|
space.db.add(
|
131
|
-
|
131
|
+
Obj.make(Type.Expando, {
|
132
132
|
type: 'test',
|
133
|
-
content:
|
133
|
+
content: Ref.make(Obj.make(Type.Expando, { content: initialContent })),
|
134
134
|
}),
|
135
135
|
);
|
136
136
|
}}
|
@@ -44,6 +44,13 @@ export const closeCommand: Command = (view: EditorView) => {
|
|
44
44
|
};
|
45
45
|
|
46
46
|
export const commandKeyBindings: readonly KeyBinding[] = [
|
47
|
-
{
|
48
|
-
|
47
|
+
{
|
48
|
+
key: '/',
|
49
|
+
preventDefault: true,
|
50
|
+
run: openCommand,
|
51
|
+
},
|
52
|
+
{
|
53
|
+
key: 'Escape',
|
54
|
+
run: closeCommand,
|
55
|
+
},
|
49
56
|
];
|
@@ -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
|
+
});
|