@dxos/react-ui-editor 0.8.1-staging.391c573 → 0.8.1-staging.9eaf14f

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 (55) hide show
  1. package/dist/lib/browser/index.mjs +383 -255
  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 +415 -290
  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 +383 -255
  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/InputMode.stories.d.ts +2 -2
  11. package/dist/types/src/TextEditor.stories.d.ts +5 -40
  12. package/dist/types/src/TextEditor.stories.d.ts.map +1 -1
  13. package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
  14. package/dist/types/src/defaults.d.ts +2 -0
  15. package/dist/types/src/defaults.d.ts.map +1 -1
  16. package/dist/types/src/extensions/command/command.d.ts +4 -2
  17. package/dist/types/src/extensions/command/command.d.ts.map +1 -1
  18. package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
  19. package/dist/types/src/extensions/command/menu.d.ts +12 -0
  20. package/dist/types/src/extensions/command/menu.d.ts.map +1 -0
  21. package/dist/types/src/extensions/command/preview.d.ts +12 -0
  22. package/dist/types/src/extensions/command/preview.d.ts.map +1 -0
  23. package/dist/types/src/extensions/command/state.d.ts.map +1 -1
  24. package/dist/types/src/extensions/comments.d.ts +3 -3
  25. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  26. package/dist/types/src/extensions/factories.d.ts +2 -1
  27. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  28. package/dist/types/src/extensions/folding.d.ts +2 -8
  29. package/dist/types/src/extensions/folding.d.ts.map +1 -1
  30. package/dist/types/src/extensions/selection.d.ts +6 -1
  31. package/dist/types/src/extensions/selection.d.ts.map +1 -1
  32. package/dist/types/src/{styles/stack-item-content-class-names.d.ts → fragments.d.ts} +1 -1
  33. package/dist/types/src/fragments.d.ts.map +1 -0
  34. package/dist/types/src/index.d.ts +0 -1
  35. package/dist/types/src/index.d.ts.map +1 -1
  36. package/dist/types/src/styles/theme.d.ts.map +1 -1
  37. package/package.json +27 -27
  38. package/src/InputMode.stories.tsx +4 -4
  39. package/src/TextEditor.stories.tsx +183 -61
  40. package/src/components/EditorToolbar/EditorToolbar.tsx +4 -5
  41. package/src/defaults.ts +12 -0
  42. package/src/extensions/command/command.ts +21 -2
  43. package/src/extensions/command/hint.ts +3 -0
  44. package/src/extensions/command/menu.ts +100 -0
  45. package/src/extensions/command/preview.ts +79 -0
  46. package/src/extensions/command/state.ts +9 -4
  47. package/src/extensions/comments.ts +6 -10
  48. package/src/extensions/factories.ts +4 -3
  49. package/src/extensions/folding.tsx +30 -73
  50. package/src/extensions/selection.ts +41 -21
  51. package/src/{styles/stack-item-content-class-names.ts → fragments.ts} +4 -2
  52. package/src/index.ts +0 -4
  53. package/src/styles/theme.ts +6 -1
  54. package/src/util/debug.ts +1 -1
  55. package/dist/types/src/styles/stack-item-content-class-names.d.ts.map +0 -1
@@ -4,12 +4,13 @@
4
4
 
5
5
  import '@dxos-theme';
6
6
 
7
+ import { type Completion } from '@codemirror/autocomplete';
7
8
  import { javascript } from '@codemirror/lang-javascript';
8
9
  import { markdown } from '@codemirror/lang-markdown';
9
10
  import { openSearchPanel } from '@codemirror/search';
10
11
  import { type Extension } from '@codemirror/state';
11
12
  import { type EditorView } from '@codemirror/view';
12
- import { ArrowSquareOut, X } from '@phosphor-icons/react';
13
+ import { X } from '@phosphor-icons/react';
13
14
  import { effect, useSignal } from '@preact/signals-react';
14
15
  import defaultsDeep from 'lodash.defaultsdeep';
15
16
  import React, { useEffect, useState, type FC, type KeyboardEvent } from 'react';
@@ -22,9 +23,9 @@ import { create } from '@dxos/live-object';
22
23
  import { log } from '@dxos/log';
23
24
  import { faker } from '@dxos/random';
24
25
  import { createDocAccessor, createObject } from '@dxos/react-client/echo';
25
- import { Button, Input, useThemeContext } from '@dxos/react-ui';
26
- import { baseSurface, getSize, mx } from '@dxos/react-ui-theme';
27
- import { withLayout, withTheme } from '@dxos/storybook-utils';
26
+ import { Button, Icon, IconButton, Input, ThemeProvider, useThemeContext } from '@dxos/react-ui';
27
+ import { baseSurface, defaultTx, getSize, mx } from '@dxos/react-ui-theme';
28
+ import { type Meta, withLayout, withTheme } from '@dxos/storybook-utils';
28
29
 
29
30
  import { editorContent, editorGutter, editorMonospace } from './defaults';
30
31
  import {
@@ -202,7 +203,7 @@ const text = str(
202
203
  '=== LAST LINE ===',
203
204
  );
204
205
 
205
- const links = [
206
+ const links: Completion[] = [
206
207
  { label: 'DXOS', apply: '[DXOS](https://dxos.org)' },
207
208
  { label: 'GitHub', apply: '[DXOS GitHub](https://github.com/dxos)' },
208
209
  { label: 'Automerge', apply: '[Automerge](https://automerge.org/)' },
@@ -215,16 +216,6 @@ const names = ['adam', 'alice', 'alison', 'bob', 'carol', 'charlie', 'sayuri', '
215
216
  const hover =
216
217
  'rounded-sm text-baseText text-primary-600 hover:text-primary-500 dark:text-primary-300 hover:dark:text-primary-200';
217
218
 
218
- const renderLinkTooltip = (el: Element, url: string) => {
219
- const web = new URL(url);
220
- createRoot(el).render(
221
- <a href={url} target='_blank' rel='noreferrer' className={hover}>
222
- {web.origin}
223
- <ArrowSquareOut weight='bold' className={mx(getSize(4), 'inline-block leading-none mis-1')} />
224
- </a>,
225
- );
226
- };
227
-
228
219
  const Key: FC<{ char: string }> = ({ char }) => (
229
220
  <span className='flex justify-center items-center w-[24px] h-[24px] rounded text-xs bg-neutral-200 text-black'>
230
221
  {char}
@@ -244,11 +235,25 @@ const onCommentsHover: CommentsOptions['onHover'] = (el, shortcut) => {
244
235
  );
245
236
  };
246
237
 
238
+ const renderLinkTooltip = (el: Element, url: string) => {
239
+ const web = new URL(url);
240
+ createRoot(el).render(
241
+ <ThemeProvider tx={defaultTx}>
242
+ <a href={url} target='_blank' rel='noreferrer' className={mx(hover, 'flex items-center gap-2')}>
243
+ {web.origin}
244
+ <Icon icon='ph--arrow-square-out--regular' size={4} />
245
+ </a>
246
+ </ThemeProvider>,
247
+ );
248
+ };
249
+
247
250
  const renderLinkButton = (el: Element, url: string) => {
248
251
  createRoot(el).render(
249
- <a href={url} target='_blank' rel='noreferrer' className={hover}>
250
- <ArrowSquareOut weight='bold' className={mx(getSize(4), 'inline-block leading-none mis-1 mb-[2px]')} />
251
- </a>,
252
+ <ThemeProvider tx={defaultTx}>
253
+ <a href={url} target='_blank' rel='noreferrer' className={mx(hover)}>
254
+ <Icon icon='ph--arrow-square-out--regular' size={4} classNames='inline-block mis-1 mb-[3px]' />
255
+ </a>
256
+ </ThemeProvider>,
252
257
  );
253
258
  };
254
259
 
@@ -262,7 +267,7 @@ type StoryProps = {
262
267
  id?: string;
263
268
  debug?: DebugMode;
264
269
  text?: string;
265
- readonly?: boolean;
270
+ readOnly?: boolean;
266
271
  placeholder?: string;
267
272
  lineNumbers?: boolean;
268
273
  onReady?: (view: EditorView) => void;
@@ -273,7 +278,7 @@ const DefaultStory = ({
273
278
  debug,
274
279
  text,
275
280
  extensions,
276
- readonly,
281
+ readOnly,
277
282
  placeholder = 'New document.',
278
283
  scrollTo,
279
284
  selection,
@@ -289,7 +294,7 @@ const DefaultStory = ({
289
294
  initialValue: text,
290
295
  extensions: [
291
296
  createDataExtensions({ id, text: createDocAccessor(object, ['content']) }),
292
- createBasicExtensions({ readonly, placeholder, lineNumbers, scrollPastEnd: true }),
297
+ createBasicExtensions({ readOnly, placeholder, lineNumbers, scrollPastEnd: true }),
293
298
  createMarkdownExtensions({ themeMode }),
294
299
  createThemeExtensions({
295
300
  themeMode,
@@ -335,26 +340,22 @@ const DefaultStory = ({
335
340
  );
336
341
  };
337
342
 
338
- export default {
343
+ const meta: Meta<typeof DefaultStory> = {
339
344
  title: 'ui/react-ui-editor/TextEditor',
340
345
  decorators: [withTheme, withLayout({ fullscreen: true })],
341
346
  render: DefaultStory,
342
347
  parameters: { translations, layout: 'fullscreen' },
343
348
  };
344
349
 
350
+ export default meta;
351
+
345
352
  const defaultExtensions: Extension[] = [
346
- autocomplete({
347
- onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
348
- }),
349
353
  decorateMarkdown({ renderLinkButton, selectionChangeDelay: 100 }),
350
354
  formattingKeymap(),
351
355
  linkTooltip(renderLinkTooltip),
352
356
  ];
353
357
 
354
358
  const allExtensions: Extension[] = [
355
- autocomplete({
356
- onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
357
- }),
358
359
  decorateMarkdown({ numberedHeadings: { from: 2, to: 4 }, renderLinkButton, selectionChangeDelay: 100 }),
359
360
  formattingKeymap(),
360
361
  linkTooltip(renderLinkTooltip),
@@ -363,26 +364,50 @@ const allExtensions: Extension[] = [
363
364
  folding(),
364
365
  ];
365
366
 
367
+ //
368
+ // Default
369
+ //
370
+
366
371
  export const Default = {
367
372
  render: () => <DefaultStory text={text} extensions={defaultExtensions} />,
368
373
  };
369
374
 
375
+ //
376
+ // Everything
377
+ //
378
+
370
379
  export const Everything = {
371
380
  render: () => <DefaultStory text={text} extensions={allExtensions} selection={{ anchor: 99, head: 110 }} />,
372
381
  };
373
382
 
383
+ //
384
+ // Empty
385
+ //
386
+
374
387
  export const Empty = {
375
388
  render: () => <DefaultStory extensions={defaultExtensions} />,
376
389
  };
377
390
 
391
+ //
392
+ // Readonly
393
+ //
394
+
378
395
  export const Readonly = {
379
- render: () => <DefaultStory text={text} extensions={defaultExtensions} readonly />,
396
+ render: () => <DefaultStory text={text} extensions={defaultExtensions} readOnly />,
380
397
  };
381
398
 
399
+ //
400
+ // No Extensions
401
+ //
402
+
382
403
  export const NoExtensions = {
383
404
  render: () => <DefaultStory text={text} />,
384
405
  };
385
406
 
407
+ //
408
+ // Vim
409
+ //
410
+
386
411
  export const Vim = {
387
412
  render: () => (
388
413
  <DefaultStory
@@ -409,14 +434,22 @@ const headings = str(
409
434
  .flat(),
410
435
  );
411
436
 
412
- const global: Record<string, EditorSelectionState> = {};
437
+ const global = new Map<string, EditorSelectionState>();
413
438
 
414
439
  export const Folding = {
415
440
  render: () => <DefaultStory text={text} extensions={[folding()]} />,
416
441
  };
417
442
 
418
443
  export const Scrolling = {
419
- render: () => <DefaultStory text={str('# Large Document', '', longText)} extensions={selectionState(global)} />,
444
+ render: () => (
445
+ <DefaultStory
446
+ text={str('# Large Document', '', longText)}
447
+ extensions={selectionState({
448
+ setState: (id, state) => global.set(id, state),
449
+ getState: (id) => global.get(id),
450
+ })}
451
+ />
452
+ ),
420
453
  };
421
454
 
422
455
  export const ScrollingWithImages = {
@@ -503,6 +536,10 @@ export const Table = {
503
536
  render: () => <DefaultStory text={str(content.table, content.footer)} extensions={[decorateMarkdown(), table()]} />,
504
537
  };
505
538
 
539
+ //
540
+ // Commented out
541
+ //
542
+
506
543
  export const CommentedOut = {
507
544
  render: () => (
508
545
  <DefaultStory
@@ -516,6 +553,10 @@ export const CommentedOut = {
516
553
  ),
517
554
  };
518
555
 
556
+ //
557
+ // Typescript
558
+ //
559
+
519
560
  export const Typescript = {
520
561
  render: () => (
521
562
  <DefaultStory
@@ -537,13 +578,19 @@ export const Autocomplete = {
537
578
  extensions={[
538
579
  decorateMarkdown({ renderLinkButton }),
539
580
  autocomplete({
540
- onSearch: (text) => links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase())),
581
+ onSearch: (text) => {
582
+ return links.filter(({ label }) => label.toLowerCase().includes(text.toLowerCase()));
583
+ },
541
584
  }),
542
585
  ]}
543
586
  />
544
587
  ),
545
588
  };
546
589
 
590
+ //
591
+ // Mention
592
+ //
593
+
547
594
  export const Mention = {
548
595
  render: () => (
549
596
  <DefaultStory
@@ -557,6 +604,10 @@ export const Mention = {
557
604
  ),
558
605
  };
559
606
 
607
+ //
608
+ // Search
609
+ //
610
+
560
611
  export const Search = {
561
612
  render: () => (
562
613
  <DefaultStory
@@ -567,10 +618,65 @@ export const Search = {
567
618
  ),
568
619
  };
569
620
 
621
+ //
622
+ // Command
623
+ //
624
+
625
+ export const Command = {
626
+ render: () => (
627
+ <DefaultStory
628
+ text={str('# Command', '', '', '[Test](dxn:queue:data:123)', '', '', '')}
629
+ extensions={[
630
+ command({
631
+ onHint: () => 'Press / for commands.',
632
+ onRenderMenu: (el, onClick) => {
633
+ renderRoot(
634
+ el,
635
+ <ThemeProvider tx={defaultTx}>
636
+ <Button classNames='p-1 aspect-square' onClick={onClick}>
637
+ <Icon icon={'ph--sparkle--regular'} size={5} />
638
+ </Button>
639
+ </ThemeProvider>,
640
+ );
641
+ },
642
+ onRenderDialog: (el, onClose) => {
643
+ renderRoot(el, <CommandDialog onClose={onClose} />);
644
+ },
645
+ onRenderPreview: (el, { text }) => {
646
+ faker.seed(text.length);
647
+ const data = Array.from({ length: 2 }, () => faker.lorem.sentences(2));
648
+ renderRoot(
649
+ el,
650
+ <ThemeProvider tx={defaultTx}>
651
+ <div className='flex flex-col gap-2'>
652
+ <div className='flex items-center gap-4'>
653
+ <div className='grow truncate'>
654
+ <span className='text-xs text-subdued mie-2'>Prompt</span>
655
+ {text}
656
+ </div>
657
+ <div className='flex gap-1'>
658
+ <IconButton classNames='text-green-500' label='Apply' icon={'ph--check--regular'} />
659
+ <IconButton classNames='text-red-500' label='Cancel' icon={'ph--x--regular'} />
660
+ </div>
661
+ </div>
662
+ <div>{data.join('\n\n')}</div>
663
+ </div>
664
+ </ThemeProvider>,
665
+ );
666
+ },
667
+ }),
668
+ ]}
669
+ />
670
+ ),
671
+ };
672
+
570
673
  const CommandDialog = ({ onClose }: { onClose: (action?: CommandAction) => void }) => {
571
674
  const [text, setText] = useState('');
572
675
  const handleInsert = () => {
573
- onClose(text.length ? { insert: text + '\n' } : undefined);
676
+ // TODO(burdon): Use queue ref.
677
+ const link = `[${text}](dxn:queue:data:123)`;
678
+ console.log({ link });
679
+ onClose(text.length ? { insert: link } : undefined);
574
680
  };
575
681
 
576
682
  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
@@ -587,38 +693,34 @@ const CommandDialog = ({ onClose }: { onClose: (action?: CommandAction) => void
587
693
  };
588
694
 
589
695
  return (
590
- <div className={mx('flex items-center p-2 gap-2 border rounded-md', baseSurface)}>
591
- <Input.Root>
592
- <Input.TextInput
593
- autoFocus={true}
594
- placeholder='Enter command.'
595
- value={text}
596
- onChange={({ target: { value } }) => setText(value)}
597
- onKeyDown={handleKeyDown}
598
- />
599
- </Input.Root>
600
- <Button variant='ghost' classNames='pli-0' onClick={() => onClose()}>
601
- <X className={getSize(5)} />
602
- </Button>
696
+ <div className='flex w-full justify-center'>
697
+ <div
698
+ className={mx(
699
+ 'flex w-full p-2 gap-2 items-center border border-separator rounded-md',
700
+ editorContent,
701
+ baseSurface,
702
+ )}
703
+ >
704
+ <Input.Root>
705
+ <Input.TextInput
706
+ autoFocus={true}
707
+ placeholder='Ask a question...'
708
+ value={text}
709
+ onChange={({ target: { value } }) => setText(value)}
710
+ onKeyDown={handleKeyDown}
711
+ />
712
+ </Input.Root>
713
+ <Button variant='ghost' classNames='pli-0' onClick={() => onClose()}>
714
+ <X className={getSize(5)} />
715
+ </Button>
716
+ </div>
603
717
  </div>
604
718
  );
605
719
  };
606
720
 
607
- export const Command = {
608
- render: () => (
609
- <DefaultStory
610
- text={str('# Command', '')}
611
- extensions={[
612
- command({
613
- onRender: (el, onClose) => {
614
- renderRoot(el, <CommandDialog onClose={onClose} />);
615
- },
616
- onHint: () => 'Press / for commands.',
617
- }),
618
- ]}
619
- />
620
- ),
621
- };
721
+ //
722
+ // Comments
723
+ //
622
724
 
623
725
  export const Comments = {
624
726
  render: () => {
@@ -660,12 +762,20 @@ export const Comments = {
660
762
  },
661
763
  };
662
764
 
765
+ //
766
+ // Annotations
767
+ //
768
+
663
769
  export const Annotations = {
664
770
  render: () => (
665
771
  <DefaultStory text={str('# Annotations', '', longText)} extensions={[annotations({ match: /volup/gi })]} />
666
772
  ),
667
773
  };
668
774
 
775
+ //
776
+ // DND
777
+ //
778
+
669
779
  export const DND = {
670
780
  render: () => (
671
781
  <DefaultStory
@@ -681,6 +791,10 @@ export const DND = {
681
791
  ),
682
792
  };
683
793
 
794
+ //
795
+ // Listener
796
+ //
797
+
684
798
  export const Listener = {
685
799
  render: () => (
686
800
  <DefaultStory
@@ -699,6 +813,10 @@ export const Listener = {
699
813
  ),
700
814
  };
701
815
 
816
+ //
817
+ // Typewriter
818
+ //
819
+
702
820
  const typewriterItems = localStorage.getItem('dxos.org/plugin/markdown/typewriter')?.split(',');
703
821
 
704
822
  export const Typewriter = {
@@ -710,6 +828,10 @@ export const Typewriter = {
710
828
  ),
711
829
  };
712
830
 
831
+ //
832
+ // Blast
833
+ //
834
+
713
835
  export const Blast = {
714
836
  render: () => (
715
837
  <DefaultStory
@@ -7,11 +7,11 @@ import React, { useCallback } from 'react';
7
7
  import { type NodeArg } from '@dxos/app-graph';
8
8
  import { ElevationProvider } from '@dxos/react-ui';
9
9
  import {
10
- ToolbarMenu,
11
- MenuProvider,
12
10
  type MenuActionHandler,
13
- useMenuActions,
11
+ MenuProvider,
12
+ ToolbarMenu,
14
13
  createGapSeparator,
14
+ useMenuActions,
15
15
  } from '@dxos/react-ui-menu';
16
16
  import { textBlockWidth } from '@dxos/react-ui-theme';
17
17
 
@@ -27,7 +27,7 @@ import {
27
27
  editorToolbarSearch,
28
28
  } from './util';
29
29
  import { createViewMode } from './viewMode';
30
- import { stackItemContentToolbarClassNames } from '../../styles/stack-item-content-class-names';
30
+ import { stackItemContentToolbarClassNames } from '../../fragments';
31
31
 
32
32
  const createToolbar = ({
33
33
  state,
@@ -86,7 +86,6 @@ const createToolbar = ({
86
86
 
87
87
  const useEditorToolbarActionGraph = ({ onAction, ...props }: EditorToolbarProps) => {
88
88
  const menuCreator = useCallback(() => createToolbar(props), [props]);
89
-
90
89
  const { resolveGroupItems } = useMenuActions(menuCreator);
91
90
 
92
91
  return { resolveGroupItems, onAction: onAction as MenuActionHandler };
package/src/defaults.ts CHANGED
@@ -41,3 +41,15 @@ export const editorMonospace = EditorView.theme({
41
41
 
42
42
  export const editorWithToolbarLayout =
43
43
  'grid grid-cols-1 grid-rows-[min-content_1fr] data-[toolbar=disabled]:grid-rows-[1fr] justify-center content-start overflow-hidden';
44
+
45
+ export const stackItemContentEditorClassNames = (role?: string) =>
46
+ mx(
47
+ 'attention-surface dx-focus-ring-inset data-[toolbar=disabled]:pbs-2',
48
+ role === 'section' ? '[&_.cm-scroller]:overflow-hidden [&_.cm-scroller]:min-bs-24' : 'min-bs-0',
49
+ );
50
+
51
+ export const stackItemContentToolbarClassNames = (role?: string) =>
52
+ mx(
53
+ 'attention-surface is-full border-be !border-separator',
54
+ role === 'section' && 'sticky block-start-0 z-[1] -mbe-px min-is-0',
55
+ );
@@ -6,6 +6,8 @@ import { type Extension } from '@codemirror/state';
6
6
  import { EditorView, keymap } from '@codemirror/view';
7
7
 
8
8
  import { hintViewPlugin } from './hint';
9
+ import { floatingMenu } from './menu';
10
+ import { preview, type PreviewOptions } from './preview';
9
11
  import { closeEffect, commandConfig, commandKeyBindings, commandState } from './state';
10
12
 
11
13
  // TODO(burdon): Create knowledge base for CM notes and ideas.
@@ -13,23 +15,40 @@ import { closeEffect, commandConfig, commandKeyBindings, commandState } from './
13
15
  // https://github.com/saminzadeh/codemirror-extension-inline-suggestion
14
16
  // https://github.com/ChromeDevTools/devtools-frontend/blob/main/front_end/ui/components/text_editor/config.ts#L370
15
17
 
18
+ // TODO(burdon): Discriminated union.
16
19
  export type CommandAction = {
17
20
  insert?: string;
18
21
  };
19
22
 
20
23
  export type CommandOptions = {
21
- onRender: (el: HTMLElement, cb: (action?: CommandAction) => void) => void;
22
24
  onHint: () => string | undefined;
23
- };
25
+ onRenderDialog: (el: HTMLElement, cb: (action?: CommandAction) => void) => void;
26
+ onRenderMenu: (el: HTMLElement, cb: () => void) => void;
27
+ } & Pick<PreviewOptions, 'onRenderPreview'>;
24
28
 
25
29
  export const command = (options: CommandOptions): Extension => {
26
30
  return [
27
31
  commandConfig.of(options),
28
32
  commandState,
29
33
  keymap.of(commandKeyBindings),
34
+ preview(options),
35
+ floatingMenu(options),
30
36
  hintViewPlugin(options),
31
37
  EditorView.focusChangeEffect.of((_, focusing) => {
32
38
  return focusing ? closeEffect.of(null) : null;
33
39
  }),
40
+ EditorView.theme({
41
+ '.cm-tooltip': {
42
+ background: 'transparent',
43
+ },
44
+ '.cm-preview': {
45
+ marginLeft: '-1rem',
46
+ marginRight: '-1rem',
47
+ padding: '1rem',
48
+ borderRadius: '1rem',
49
+ background: 'var(--dx-modalSurface)',
50
+ border: '1px solid var(--dx-separator)',
51
+ },
52
+ }),
34
53
  ];
35
54
  };
@@ -24,6 +24,7 @@ class CommandHint extends WidgetType {
24
24
  } else {
25
25
  wrap.setAttribute('aria-hidden', 'true');
26
26
  }
27
+
27
28
  return wrap;
28
29
  }
29
30
 
@@ -32,12 +33,14 @@ class CommandHint extends WidgetType {
32
33
  if (!rects.length) {
33
34
  return null;
34
35
  }
36
+
35
37
  const style = window.getComputedStyle(dom.parentNode as HTMLElement);
36
38
  const rect = flattenRect(rects[0], style.direction !== 'rtl');
37
39
  const lineHeight = parseInt(style.lineHeight);
38
40
  if (rect.bottom - rect.top > lineHeight * 1.5) {
39
41
  return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.top + lineHeight };
40
42
  }
43
+
41
44
  return rect;
42
45
  }
43
46
 
@@ -0,0 +1,100 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type BlockInfo, type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
6
+
7
+ import { type CommandOptions } from './command';
8
+ import { closeEffect, openCommand, openEffect } from './state';
9
+
10
+ // TODO(burdon): Trigger completion on click.
11
+ // TODO(burdon): Hide when dialog is open.
12
+ export const floatingMenu = (options: CommandOptions) =>
13
+ ViewPlugin.fromClass(
14
+ class {
15
+ button: HTMLElement;
16
+ view: EditorView;
17
+ rafId: number | null = null;
18
+
19
+ constructor(view: EditorView) {
20
+ this.view = view;
21
+
22
+ // Position context: scrollDOM
23
+ const container = view.scrollDOM;
24
+ if (getComputedStyle(container).position === 'static') {
25
+ container.style.position = 'relative';
26
+ }
27
+
28
+ // Render menu externally.
29
+ this.button = document.createElement('div');
30
+ this.button.style.position = 'absolute';
31
+ this.button.style.zIndex = '10';
32
+ this.button.style.display = 'none';
33
+
34
+ options.onRenderMenu(this.button, () => {
35
+ openCommand(view);
36
+ });
37
+ container.appendChild(this.button);
38
+
39
+ // Listen for scroll events.
40
+ container.addEventListener('scroll', this.scheduleUpdate);
41
+ this.scheduleUpdate();
42
+ }
43
+
44
+ update(update: ViewUpdate) {
45
+ // TODO(burdon): Timer to fade in/out.
46
+ if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(openEffect)))) {
47
+ this.button.style.display = 'none';
48
+ } else if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(closeEffect)))) {
49
+ this.button.style.display = 'block';
50
+ } else if (update.selectionSet || update.viewportChanged || update.docChanged || update.geometryChanged) {
51
+ this.scheduleUpdate();
52
+ }
53
+ }
54
+
55
+ scheduleUpdate() {
56
+ if (this.rafId != null) {
57
+ cancelAnimationFrame(this.rafId);
58
+ }
59
+ this.rafId = requestAnimationFrame(() => this.updateButtonPosition());
60
+ }
61
+
62
+ updateButtonPosition() {
63
+ const pos = this.view.state.selection.main.head;
64
+ const lineBlock: BlockInfo = this.view.lineBlockAt(pos);
65
+ const domInfo = this.view.domAtPos(lineBlock.from);
66
+
67
+ // Find nearest HTMLElement for the line block
68
+ let node: Node | null = domInfo.node;
69
+ while (node && !(node instanceof HTMLElement)) {
70
+ node = node.parentNode;
71
+ }
72
+
73
+ if (!node) {
74
+ this.button.style.display = 'none';
75
+ return;
76
+ }
77
+
78
+ const lineRect = (node as HTMLElement).getBoundingClientRect();
79
+ const containerRect = this.view.scrollDOM.getBoundingClientRect();
80
+
81
+ // Account for scroll and padding/margin in scrollDOM.
82
+ const offsetTop = lineRect.top - containerRect.top + this.view.scrollDOM.scrollTop;
83
+ const offsetLeft = this.view.scrollDOM.clientWidth + this.view.scrollDOM.scrollLeft - lineRect.x;
84
+
85
+ // TODO(burdon): Position is incorrect if cursor is in fenced code block.
86
+ // console.log('offsetTop', lineRect, containerRect);
87
+
88
+ this.button.style.top = `${offsetTop}px`;
89
+ this.button.style.left = `${offsetLeft}px`;
90
+ this.button.style.display = 'block';
91
+ }
92
+
93
+ destroy() {
94
+ this.button.remove();
95
+ if (this.rafId != null) {
96
+ cancelAnimationFrame(this.rafId);
97
+ }
98
+ }
99
+ },
100
+ );