@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.
Files changed (45) hide show
  1. package/dist/lib/browser/index.mjs +868 -260
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +911 -297
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +868 -260
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/components/EditorToolbar/util.d.ts +2 -2
  11. package/dist/types/src/components/Popover/CommandMenu.d.ts +34 -0
  12. package/dist/types/src/components/Popover/CommandMenu.d.ts.map +1 -0
  13. package/dist/types/src/components/Popover/RefPopover.d.ts +19 -6
  14. package/dist/types/src/components/Popover/RefPopover.d.ts.map +1 -1
  15. package/dist/types/src/components/Popover/index.d.ts +1 -0
  16. package/dist/types/src/components/Popover/index.d.ts.map +1 -1
  17. package/dist/types/src/defaults.d.ts.map +1 -1
  18. package/dist/types/src/extensions/command/menu.d.ts +40 -0
  19. package/dist/types/src/extensions/command/menu.d.ts.map +1 -1
  20. package/dist/types/src/extensions/factories.d.ts +1 -0
  21. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  22. package/dist/types/src/extensions/index.d.ts +1 -0
  23. package/dist/types/src/extensions/index.d.ts.map +1 -1
  24. package/dist/types/src/extensions/placeholder.d.ts +4 -0
  25. package/dist/types/src/extensions/placeholder.d.ts.map +1 -0
  26. package/dist/types/src/extensions/preview/preview.d.ts.map +1 -1
  27. package/dist/types/src/hooks/useTextEditor.d.ts +8 -9
  28. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  29. package/dist/types/src/stories/CommandMenu.stories.d.ts +12 -0
  30. package/dist/types/src/stories/CommandMenu.stories.d.ts.map +1 -0
  31. package/dist/types/src/stories/Preview.stories.d.ts.map +1 -1
  32. package/package.json +31 -28
  33. package/src/components/Popover/CommandMenu.tsx +279 -0
  34. package/src/components/Popover/RefPopover.tsx +44 -22
  35. package/src/components/Popover/index.ts +1 -0
  36. package/src/defaults.ts +1 -0
  37. package/src/extensions/command/menu.ts +306 -4
  38. package/src/extensions/factories.ts +4 -1
  39. package/src/extensions/index.ts +1 -0
  40. package/src/extensions/outliner/outliner.ts +0 -3
  41. package/src/extensions/placeholder.ts +82 -0
  42. package/src/extensions/preview/preview.ts +3 -6
  43. package/src/hooks/useTextEditor.ts +11 -12
  44. package/src/stories/CommandMenu.stories.tsx +143 -0
  45. 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, { type PropsWithChildren, useRef, useState, useEffect, useCallback, type RefObject } from '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 RefPopoverProviderProps = PropsWithChildren<{ onLookup?: PreviewLookup }>;
58
+ type PreviewProviderProps = PropsWithChildren<{ onLookup?: PreviewLookup }>;
23
59
 
24
- const RefPopoverProvider = ({ children, onLookup }: RefPopoverProviderProps) => {
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
- <Popover.Root open={open} onOpenChange={setOpen}>
60
- <Popover.VirtualTrigger virtualRef={trigger as unknown as RefObject<HTMLButtonElement>} />
61
- <div role='none' className='contents' ref={setRootRef}>
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 const RefPopover = {
70
- Provider: RefPopoverProvider,
71
- };
72
-
73
- export { useRefPopover };
95
+ export { PreviewProvider, useRefPopover };
74
96
 
75
- export type { RefPopoverProviderProps, RefPopoverValue };
97
+ export type { PreviewProviderProps, RefPopoverValue };
@@ -2,5 +2,6 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
+ export * from './CommandMenu';
5
6
  export * from './RefPopover';
6
7
  export * from './RefDropdownMenu';
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
  });
@@ -2,11 +2,18 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
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(),
@@ -21,6 +21,7 @@ export * from './markdown';
21
21
  export * from './mention';
22
22
  export * from './modes';
23
23
  export * from './outliner';
24
+ export * from './placeholder';
24
25
  export * from './preview';
25
26
  export * from './selection';
26
27
  export * from './typewriter';
@@ -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
- marginLeft: '-1rem',
81
- marginRight: '-1rem',
82
- padding: '1rem',
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
- export type UseTextEditor = {
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
  */