@dxos/react-ui-editor 0.4.7-main.33db07e → 0.4.7-main.340e96b

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 (83) hide show
  1. package/dist/lib/browser/index.mjs +942 -742
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +9 -0
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  7. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +0 -9
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/TextEditor/index.d.ts +0 -2
  10. package/dist/types/src/components/TextEditor/index.d.ts.map +1 -1
  11. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  12. package/dist/types/src/{components/TextEditor → extensions/automerge}/automerge.stories.d.ts +4 -2
  13. package/dist/types/src/extensions/automerge/automerge.stories.d.ts.map +1 -0
  14. package/dist/types/src/extensions/command/command.d.ts +10 -0
  15. package/dist/types/src/extensions/command/command.d.ts.map +1 -0
  16. package/dist/types/src/extensions/command/hint.d.ts +7 -0
  17. package/dist/types/src/extensions/command/hint.d.ts.map +1 -0
  18. package/dist/types/src/extensions/command/index.d.ts +2 -0
  19. package/dist/types/src/extensions/command/index.d.ts.map +1 -0
  20. package/dist/types/src/extensions/command/state.d.ts +18 -0
  21. package/dist/types/src/extensions/command/state.d.ts.map +1 -0
  22. package/dist/types/src/extensions/cursor-line-margin.d.ts.map +1 -1
  23. package/dist/types/src/extensions/dnd.d.ts +3 -0
  24. package/dist/types/src/extensions/dnd.d.ts.map +1 -0
  25. package/dist/types/src/extensions/index.d.ts +2 -1
  26. package/dist/types/src/extensions/index.d.ts.map +1 -1
  27. package/dist/types/src/extensions/listener.d.ts +1 -1
  28. package/dist/types/src/extensions/listener.d.ts.map +1 -1
  29. package/dist/types/src/extensions/markdown/bundle.d.ts +2 -2
  30. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  31. package/dist/types/src/extensions/markdown/decorate.d.ts +3 -0
  32. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  33. package/dist/types/src/extensions/util/dom.d.ts +15 -0
  34. package/dist/types/src/extensions/util/dom.d.ts.map +1 -0
  35. package/dist/types/src/hooks/defs.d.ts +5 -11
  36. package/dist/types/src/hooks/defs.d.ts.map +1 -1
  37. package/dist/types/src/hooks/useEditorView.d.ts.map +1 -1
  38. package/dist/types/src/hooks/useTextEditor.d.ts +20 -5
  39. package/dist/types/src/hooks/useTextEditor.d.ts.map +1 -1
  40. package/dist/types/src/{components/TextEditor/hooks.stories.d.ts → hooks/useTextEditor.stories.d.ts} +1 -3
  41. package/dist/types/src/hooks/useTextEditor.stories.d.ts.map +1 -0
  42. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  43. package/dist/types/src/index.d.ts +1 -0
  44. package/dist/types/src/index.d.ts.map +1 -1
  45. package/dist/types/src/styles/index.d.ts +0 -1
  46. package/dist/types/src/styles/index.d.ts.map +1 -1
  47. package/dist/types/src/themes/default.d.ts.map +1 -1
  48. package/package.json +31 -31
  49. package/src/components/TextEditor/MarkdownEditor.stories.tsx +95 -8
  50. package/src/components/TextEditor/TextEditor.stories.tsx +0 -15
  51. package/src/components/TextEditor/TextEditor.tsx +11 -6
  52. package/src/components/TextEditor/index.ts +0 -3
  53. package/src/components/Toolbar/Toolbar.stories.tsx +2 -1
  54. package/src/{components/TextEditor → extensions/automerge}/automerge.stories.tsx +32 -37
  55. package/src/extensions/awareness.ts +3 -3
  56. package/src/extensions/command/command.ts +35 -0
  57. package/src/extensions/command/hint.ts +76 -0
  58. package/src/extensions/command/index.ts +5 -0
  59. package/src/extensions/command/state.ts +106 -0
  60. package/src/extensions/cursor-line-margin.ts +2 -1
  61. package/src/extensions/dnd.ts +15 -0
  62. package/src/extensions/index.ts +2 -1
  63. package/src/extensions/listener.ts +3 -3
  64. package/src/extensions/markdown/bundle.ts +4 -14
  65. package/src/extensions/markdown/decorate.ts +21 -14
  66. package/src/extensions/util/dom.ts +36 -0
  67. package/src/hooks/defs.ts +6 -20
  68. package/src/hooks/useActionHandler.ts +11 -11
  69. package/src/hooks/useEditorView.ts +0 -1
  70. package/src/{components/TextEditor/hooks.stories.tsx → hooks/useTextEditor.stories.tsx} +8 -8
  71. package/src/hooks/useTextEditor.ts +69 -14
  72. package/src/hooks/useTextModel.ts +20 -18
  73. package/src/index.ts +2 -0
  74. package/src/styles/index.ts +0 -1
  75. package/src/themes/default.ts +1 -0
  76. package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +0 -1
  77. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +0 -1
  78. package/dist/types/src/extensions/basic.d.ts +0 -9
  79. package/dist/types/src/extensions/basic.d.ts.map +0 -1
  80. package/dist/types/src/styles/cursors.d.ts +0 -5
  81. package/dist/types/src/styles/cursors.d.ts.map +0 -1
  82. package/src/extensions/basic.ts +0 -41
  83. package/src/styles/cursors.ts +0 -21
@@ -6,22 +6,19 @@ import '@dxosTheme';
6
6
 
7
7
  import { BroadcastChannelNetworkAdapter } from '@automerge/automerge-repo-network-broadcastchannel';
8
8
  import '@preact/signals-react';
9
- import { EditorView } from '@codemirror/view'; // Register react integration.
10
9
  import React, { useEffect, useMemo, useState } from 'react';
11
10
 
12
11
  import { Repo } from '@dxos/automerge/automerge-repo';
13
- import { Filter } from '@dxos/echo-schema';
12
+ import { createDocAccessor, Filter } from '@dxos/echo-schema';
14
13
  import { type PublicKey } from '@dxos/keys';
15
14
  import { Expando, TextObject, useSpace } from '@dxos/react-client/echo';
16
- import { useIdentity } from '@dxos/react-client/halo';
17
15
  import { ClientRepeater } from '@dxos/react-client/testing';
16
+ import { useThemeContext } from '@dxos/react-ui';
18
17
  import { withTheme } from '@dxos/storybook-utils';
19
18
 
20
- import { MarkdownEditor } from './TextEditor';
21
- import { automerge, awareness, createBasicBundle } from '../../extensions';
22
- import { DocAccessor } from '../../extensions/automerge/defs';
23
- import { useTextEditor, useTextModel } from '../../hooks';
24
- import { defaultTheme } from '../../themes';
19
+ import { automerge } from './automerge';
20
+ import { DocAccessor } from './defs';
21
+ import { createBasicExtensions, createThemeExtensions, useTextEditor } from '../../hooks';
25
22
  import translations from '../../translations';
26
23
 
27
24
  const initialContent = 'Hello world!';
@@ -32,24 +29,24 @@ type TestObject = {
32
29
 
33
30
  type EditorProps = {
34
31
  source: DocAccessor;
32
+ autoFocus?: boolean;
35
33
  };
36
34
 
37
- const Editor = ({ source }: EditorProps) => {
35
+ const Editor = ({ source, autoFocus }: EditorProps) => {
36
+ const { themeMode } = useThemeContext();
38
37
  const extensions = useMemo(
39
38
  () => [
40
- EditorView.baseTheme(defaultTheme),
41
- EditorView.editorAttributes.of({ class: 'p-2 bg-white' }),
42
- createBasicBundle({ placeholder: 'Type here...' }),
39
+ createBasicExtensions({ placeholder: 'Type here...' }),
40
+ createThemeExtensions({ themeMode, slots: { editor: { className: 'p-2 bg-white dark:bg-black' } } }),
43
41
  automerge(source),
44
- awareness(),
45
42
  ],
46
- [source],
43
+ [themeMode, source],
47
44
  );
48
45
 
49
46
  const { parentRef } = useTextEditor({
50
- autoFocus: true,
51
47
  doc: DocAccessor.getValue(source),
52
48
  extensions,
49
+ autoFocus,
53
50
  });
54
51
 
55
52
  return <div ref={parentRef} />;
@@ -61,12 +58,8 @@ const Story = () => {
61
58
 
62
59
  useEffect(() => {
63
60
  queueMicrotask(async () => {
64
- const repo1 = new Repo({
65
- network: [new BroadcastChannelNetworkAdapter()],
66
- });
67
- const repo2 = new Repo({
68
- network: [new BroadcastChannelNetworkAdapter()],
69
- });
61
+ const repo1 = new Repo({ network: [new BroadcastChannelNetworkAdapter()] });
62
+ const repo2 = new Repo({ network: [new BroadcastChannelNetworkAdapter()] });
70
63
 
71
64
  const object1 = repo1.create();
72
65
  object1.change((doc: TestObject) => {
@@ -86,8 +79,8 @@ const Story = () => {
86
79
  }
87
80
 
88
81
  return (
89
- <div role='none' className='grid grid-cols-2 bs-full is-full gap-2'>
90
- <Editor source={object1} />
82
+ <div role='none' className='grid grid-cols-2 bs-full is-full divide-x divide-neutral-500'>
83
+ <Editor source={object1} autoFocus />
91
84
  <Editor source={object2} />
92
85
  </div>
93
86
  );
@@ -96,36 +89,39 @@ const Story = () => {
96
89
  export default {
97
90
  title: 'react-ui-editor/Automerge',
98
91
  component: Editor,
92
+ decorators: [withTheme],
99
93
  render: () => <Story />,
100
94
  parameters: { translations, layout: 'fullscreen' },
101
95
  };
102
96
 
103
- export const Default = {};
104
-
105
97
  const EchoStory = ({ spaceKey }: { spaceKey: PublicKey }) => {
106
- const identity = useIdentity();
98
+ // TODO(burdon): Test identity.
99
+ // const identity = useIdentity();
107
100
  const space = useSpace(spaceKey);
108
- // TODO(dmaretskyi): useQuery doesn't work.
109
- const [obj] = space?.db.query(Filter.from({ type: 'test' })).objects ?? [];
110
- const model = useTextModel({
111
- identity,
112
- space,
113
- text: obj?.content,
114
- });
115
-
116
- if (!model) {
101
+ const source = useMemo<DocAccessor | undefined>(() => {
102
+ const { objects = [] } = space?.db.query(Filter.from({ type: 'test' })) ?? {};
103
+ if (objects.length) {
104
+ return createDocAccessor(objects[0].content);
105
+ }
106
+ }, [space]);
107
+
108
+ if (!source) {
117
109
  return null;
118
110
  }
119
111
 
120
- return <MarkdownEditor model={model} />;
112
+ return <Editor source={source} />;
121
113
  };
122
114
 
115
+ export const Default = {};
116
+
117
+ // TODO(burdon): Feed already added error.
123
118
  export const WithEcho = {
124
119
  decorators: [withTheme],
125
120
  render: () => {
126
121
  return (
127
122
  <ClientRepeater
128
123
  count={2}
124
+ component={EchoStory}
129
125
  createSpace
130
126
  onCreateSpace={async (space) => {
131
127
  space.db.add(
@@ -135,7 +131,6 @@ export const WithEcho = {
135
131
  }),
136
132
  );
137
133
  }}
138
- component={EchoStory}
139
134
  />
140
135
  );
141
136
  },
@@ -164,7 +164,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
164
164
  from: start,
165
165
  to: startLine.from + startLine.length,
166
166
  value: Decoration.mark({
167
- attributes: { style: `background-color: ${color}` },
167
+ attributes: { style: `background-color: ${lightColor}` },
168
168
  class: 'cm-collab-selection',
169
169
  }),
170
170
  });
@@ -174,7 +174,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
174
174
  from: endLine.from,
175
175
  to: end,
176
176
  value: Decoration.mark({
177
- attributes: { style: `background-color: ${color}` },
177
+ attributes: { style: `background-color: ${lightColor}` },
178
178
  class: 'cm-collab-selection',
179
179
  }),
180
180
  });
@@ -185,7 +185,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
185
185
  from: linePos,
186
186
  to: linePos,
187
187
  value: Decoration.line({
188
- attributes: { style: `background-color: ${color}`, class: 'cm-collab-selectionLine' },
188
+ attributes: { style: `background-color: ${lightColor}`, class: 'cm-collab-selectionLine' },
189
189
  }),
190
190
  });
191
191
  }
@@ -0,0 +1,35 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { EditorView, keymap } from '@codemirror/view';
7
+
8
+ import { hintViewPlugin } from './hint';
9
+ import { closeEffect, commandConfig, commandKeyBindings, commandState } from './state';
10
+
11
+ // TODO(burdon): Create knowledge base for CM notes and ideas.
12
+ // https://discuss.codemirror.net/t/inline-code-hints-like-vscode/5533/4
13
+ // https://github.com/saminzadeh/codemirror-extension-inline-suggestion
14
+ // https://github.com/ChromeDevTools/devtools-frontend/blob/main/front_end/ui/components/text_editor/config.ts#L370
15
+
16
+ export type CommandAction = {
17
+ insert?: string;
18
+ };
19
+
20
+ export type CommandOptions = {
21
+ onRender: (el: HTMLElement, cb: (action?: CommandAction) => void) => void;
22
+ onHint: () => string | undefined;
23
+ };
24
+
25
+ export const command = (options: CommandOptions): Extension => {
26
+ return [
27
+ commandConfig.of(options),
28
+ commandState,
29
+ keymap.of(commandKeyBindings),
30
+ hintViewPlugin(options),
31
+ EditorView.focusChangeEffect.of((_, focusing) => {
32
+ return focusing ? closeEffect.of(null) : null;
33
+ }),
34
+ ];
35
+ };
@@ -0,0 +1,76 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { RangeSetBuilder } from '@codemirror/state';
6
+ import { Decoration, EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view';
7
+
8
+ import { type CommandOptions } from './command';
9
+ import { commandState } from './state';
10
+ import { clientRectsFor, flattenRect } from '../util/dom';
11
+
12
+ class CommandHint extends WidgetType {
13
+ constructor(readonly content: string | HTMLElement) {
14
+ super();
15
+ }
16
+
17
+ toDOM() {
18
+ const wrap = document.createElement('span');
19
+ wrap.className = 'cm-placeholder';
20
+ wrap.style.pointerEvents = 'none';
21
+ wrap.appendChild(typeof this.content === 'string' ? document.createTextNode(this.content) : this.content);
22
+ if (typeof this.content === 'string') {
23
+ wrap.setAttribute('aria-label', 'placeholder ' + this.content);
24
+ } else {
25
+ wrap.setAttribute('aria-hidden', 'true');
26
+ }
27
+ return wrap;
28
+ }
29
+
30
+ override coordsAt(dom: HTMLElement) {
31
+ const rects = dom.firstChild ? clientRectsFor(dom.firstChild) : [];
32
+ if (!rects.length) {
33
+ return null;
34
+ }
35
+ const style = window.getComputedStyle(dom.parentNode as HTMLElement);
36
+ const rect = flattenRect(rects[0], style.direction !== 'rtl');
37
+ const lineHeight = parseInt(style.lineHeight);
38
+ if (rect.bottom - rect.top > lineHeight * 1.5) {
39
+ return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.top + lineHeight };
40
+ }
41
+ return rect;
42
+ }
43
+
44
+ override ignoreEvent() {
45
+ return false;
46
+ }
47
+ }
48
+
49
+ export const hintViewPlugin = ({ onHint }: CommandOptions) =>
50
+ ViewPlugin.fromClass(
51
+ class {
52
+ deco = Decoration.none;
53
+ update(update: ViewUpdate) {
54
+ const builder = new RangeSetBuilder<Decoration>();
55
+ const cState = update.view.state.field(commandState, false);
56
+ if (!cState?.tooltip) {
57
+ const selection = update.view.state.selection.main;
58
+ const line = update.view.state.doc.lineAt(selection.from);
59
+ // Only show if blank line.
60
+ // TODO(burdon): Clashes with placeholder if pos === 0.
61
+ // TODO(burdon): Show after delay or if blank line above?
62
+ if (selection.from === selection.to && line.from === line.to) {
63
+ const hint = onHint();
64
+ if (hint) {
65
+ builder.add(selection.from, selection.to, Decoration.widget({ widget: new CommandHint(hint) }));
66
+ }
67
+ }
68
+ }
69
+
70
+ this.deco = builder.finish();
71
+ }
72
+ },
73
+ {
74
+ provide: (plugin) => [EditorView.decorations.of((view) => view.plugin(plugin)?.deco ?? Decoration.none)],
75
+ },
76
+ );
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './command';
@@ -0,0 +1,106 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { Facet, StateEffect, StateField } from '@codemirror/state';
6
+ import {
7
+ type Command,
8
+ type EditorView,
9
+ type KeyBinding,
10
+ showTooltip,
11
+ type Tooltip,
12
+ type TooltipView,
13
+ } from '@codemirror/view';
14
+
15
+ import { type CommandOptions } from './command';
16
+
17
+ type CommandState = {
18
+ tooltip?: Tooltip | null;
19
+ };
20
+
21
+ export const commandConfig = Facet.define<CommandOptions, Required<CommandOptions>>({
22
+ combine: (providers) => providers[0],
23
+ });
24
+
25
+ export const commandState = StateField.define<CommandState>({
26
+ create: () => ({}),
27
+ update: (state, tr) => {
28
+ for (const effect of tr.effects) {
29
+ if (effect.is(closeEffect)) {
30
+ return {};
31
+ }
32
+
33
+ if (effect.is(openEffect)) {
34
+ const options = tr.state.facet(commandConfig);
35
+ const { pos, fullWidth } = effect.value;
36
+ const tooltip: Tooltip = {
37
+ pos,
38
+ above: false,
39
+ arrow: false,
40
+ strictSide: true,
41
+ create: (view: EditorView) => {
42
+ const dom = document.createElement('div');
43
+ const tooltipView: TooltipView = {
44
+ dom,
45
+ mount: (view: EditorView) => {
46
+ if (fullWidth) {
47
+ const parent = dom.parentElement!;
48
+ const { paddingLeft, paddingRight } = window.getComputedStyle(parent);
49
+ const widthWithoutPadding = parent.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight);
50
+ dom.style.width = `${widthWithoutPadding}px`;
51
+ }
52
+
53
+ // Render react component.
54
+ options.onRender(dom, (action) => {
55
+ view.dispatch({ effects: closeEffect.of(null) });
56
+ if (action?.insert?.length) {
57
+ view.dispatch({
58
+ changes: { from: pos, insert: action.insert },
59
+ selection: { anchor: pos + action.insert.length },
60
+ });
61
+ }
62
+ // TODO(burdon): Truncates text if set focus immediately.
63
+ setTimeout(() => view.focus());
64
+ });
65
+ },
66
+ };
67
+
68
+ return tooltipView;
69
+ },
70
+ };
71
+ return { tooltip };
72
+ }
73
+ }
74
+
75
+ return state;
76
+ },
77
+ provide: (f) => [showTooltip.from(f, (value) => value.tooltip ?? null)],
78
+ });
79
+
80
+ export const openEffect = StateEffect.define<{ pos: number; fullWidth?: boolean }>();
81
+ export const closeEffect = StateEffect.define<null>();
82
+
83
+ export const openCommand: Command = (view: EditorView) => {
84
+ if (view.state.field(commandState, false)) {
85
+ const selection = view.state.selection.main;
86
+ const line = view.state.doc.lineAt(selection.from);
87
+ if (line.from === selection.from && line.from === line.to) {
88
+ view.dispatch({ effects: openEffect.of({ pos: selection.anchor, fullWidth: true }) });
89
+ return true;
90
+ }
91
+ }
92
+ return false;
93
+ };
94
+
95
+ export const closeCommand: Command = (view: EditorView) => {
96
+ if (view.state.field(commandState, false)) {
97
+ view.dispatch({ effects: closeEffect.of(null) });
98
+ return true;
99
+ }
100
+ return false;
101
+ };
102
+
103
+ export const commandKeyBindings: readonly KeyBinding[] = [
104
+ { key: '/', run: openCommand },
105
+ { key: 'Escape', run: closeCommand },
106
+ ];
@@ -18,7 +18,8 @@ export const cursorLineMarginFacet = Facet.define<number, number>({
18
18
 
19
19
  const annotation = 'cursorLineMargin';
20
20
  const lineAtPos = (s: EditorState, pos: number) => s.doc.lineAt(pos).number;
21
- // seems to the best approximation of CM5's `cursorScrollMargin`
21
+
22
+ // Seems to the best approximation of CM5's `cursorScrollMargin`
22
23
  // https://discuss.codemirror.net/t/cursorscrollmargin-for-v6/7448
23
24
  export const cursorLineMargin: Extension = EditorView.updateListener.of(
24
25
  ({ transactions, state, selectionSet, startState, view }) => {
@@ -0,0 +1,15 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import type { Extension } from '@codemirror/state';
6
+ import { EditorView } from '@codemirror/view';
7
+
8
+ // TODO(burdon): DND experiment.
9
+ export const dnd = (): Extension => {
10
+ return EditorView.domEventHandlers({
11
+ drop: (event, view) => {
12
+ console.log(event);
13
+ },
14
+ });
15
+ };
@@ -6,11 +6,12 @@ export * from './annotations';
6
6
  export * from './autocomplete';
7
7
  export * from './automerge';
8
8
  export * from './awareness';
9
- export * from './basic';
10
9
  export * from './blast';
10
+ export * from './command';
11
11
  export * from './comments';
12
12
  export * from './cursor';
13
13
  export * from './cursor-line-margin';
14
+ export * from './dnd';
14
15
  export * from './listener';
15
16
  export * from './markdown';
16
17
  export * from './mention';
@@ -6,7 +6,7 @@ import { type Extension } from '@codemirror/state';
6
6
  import { EditorView } from '@codemirror/view';
7
7
 
8
8
  export type ListenerOptions = {
9
- onFocus?: (focused: boolean) => void;
9
+ onFocus?: (focusing: boolean) => void;
10
10
  onChange?: (text: string) => void;
11
11
  };
12
12
 
@@ -18,8 +18,8 @@ export const listener = ({ onFocus, onChange }: ListenerOptions): Extension => {
18
18
 
19
19
  onFocus &&
20
20
  extensions.push(
21
- EditorView.focusChangeEffect.of((_, focused) => {
22
- onFocus(focused);
21
+ EditorView.focusChangeEffect.of((_, focusing) => {
22
+ onFocus(focusing);
23
23
  return null;
24
24
  }),
25
25
  );
@@ -10,17 +10,9 @@ import { languages } from '@codemirror/language-data';
10
10
  import { searchKeymap } from '@codemirror/search';
11
11
  import { EditorState, type Extension } from '@codemirror/state';
12
12
  import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
13
- import {
14
- crosshairCursor,
15
- drawSelection,
16
- dropCursor,
17
- EditorView,
18
- highlightActiveLine,
19
- keymap,
20
- placeholder,
21
- } from '@codemirror/view';
13
+ import { EditorView, drawSelection, dropCursor, keymap, placeholder } from '@codemirror/view';
22
14
 
23
- import type { ThemeMode } from '@dxos/react-ui';
15
+ import { type ThemeMode } from '@dxos/react-ui';
24
16
  import { isNotFalsy } from '@dxos/util';
25
17
 
26
18
  import { markdownHighlightStyle, markdownTagsExtensions } from './highlight';
@@ -28,7 +20,7 @@ import { type TextEditorProps } from '../../components';
28
20
 
29
21
  export type MarkdownBundleOptions = {
30
22
  themeMode?: ThemeMode;
31
- } & Pick<TextEditorProps, 'placeholder' | 'lineWrapping'>;
23
+ } & Pick<TextEditorProps, 'lineWrapping' | 'placeholder'>;
32
24
 
33
25
  /**
34
26
  * Markdown bundle.
@@ -42,17 +34,15 @@ export const createMarkdownExtensions = ({
42
34
  lineWrapping = true,
43
35
  }: MarkdownBundleOptions = {}): Extension[] => {
44
36
  return [
37
+ // TODO(burdon): Reconcile with createBasicExtensions.
45
38
  lineWrapping && EditorView.lineWrapping,
46
39
  EditorState.allowMultipleSelections.of(true),
47
40
  EditorState.tabSize.of(2),
48
41
 
49
- // https://github.com/codemirror/basic-setup
50
42
  bracketMatching(),
51
43
  closeBrackets(),
52
- crosshairCursor(),
53
44
  dropCursor(),
54
45
  drawSelection(),
55
- highlightActiveLine(),
56
46
  history(),
57
47
  indentOnInput(),
58
48
  _placeholder && placeholder(_placeholder),
@@ -88,7 +88,7 @@ const editingRange = (state: EditorState, range: { from: number; to: number }, f
88
88
  main: { head },
89
89
  },
90
90
  } = state;
91
- return focus && !readOnly && head >= range.from && head < range.to;
91
+ return focus && !readOnly && head >= range.from && head <= range.to;
92
92
  };
93
93
 
94
94
  const MarksByParent = new Set(['CodeMark', 'EmphasisMark', 'StrikethroughMark', 'SubscriptMark', 'SuperscriptMark']);
@@ -114,7 +114,7 @@ const buildDecorations = (view: EditorView, options: DecorateOptions, focus: boo
114
114
  break;
115
115
  }
116
116
  const first = block.from <= node.from;
117
- const last = block.to >= node.to;
117
+ const last = block.to >= node.to && /^(\s>)*```$/.test(state.doc.sliceString(block.from, block.to));
118
118
  deco.add(block.from, block.from, first ? fencedCodeLineFirst : last ? fencedCodeLineLast : fencedCodeLine);
119
119
  if (!editing && (first || last)) {
120
120
  atomicDeco.add(block.from, block.to, hide);
@@ -186,6 +186,9 @@ const buildDecorations = (view: EditorView, options: DecorateOptions, focus: boo
186
186
 
187
187
  export interface DecorateOptions {
188
188
  renderLinkButton?: (el: Element, url: string) => void;
189
+ /**
190
+ * Prevents triggering decorations as the cursor moves through the document.
191
+ */
189
192
  selectionChangeDelay?: number;
190
193
  }
191
194
 
@@ -195,9 +198,9 @@ export const decorateMarkdown = (options: DecorateOptions = {}) => {
195
198
  return [
196
199
  ViewPlugin.fromClass(
197
200
  class {
198
- pendingUpdate = -1;
199
201
  deco: DecorationSet;
200
202
  atomicDeco: DecorationSet;
203
+ pendingUpdate?: NodeJS.Timeout;
201
204
 
202
205
  constructor(view: EditorView) {
203
206
  ({ deco: this.deco, atomicDeco: this.atomicDeco } = buildDecorations(view, options, view.hasFocus));
@@ -208,31 +211,35 @@ export const decorateMarkdown = (options: DecorateOptions = {}) => {
208
211
  update.docChanged ||
209
212
  update.viewportChanged ||
210
213
  update.focusChanged ||
211
- update.transactions.some((tr) => tr.effects.some((e) => e.is(forceUpdate)))
214
+ update.transactions.some((tr) => tr.effects.some((e) => e.is(forceUpdate))) ||
215
+ (update.selectionSet && !options.selectionChangeDelay)
212
216
  ) {
213
217
  ({ deco: this.deco, atomicDeco: this.atomicDeco } = buildDecorations(
214
218
  update.view,
215
219
  options,
216
220
  update.view.hasFocus,
217
221
  ));
222
+
218
223
  this.clearUpdate();
219
224
  } else if (update.selectionSet) {
220
225
  this.scheduleUpdate(update.view);
221
226
  }
222
227
  }
223
228
 
224
- clearUpdate() {
225
- if (this.pendingUpdate > -1) {
226
- window.clearTimeout(this.pendingUpdate);
227
- this.pendingUpdate = -1;
228
- }
229
- }
230
-
229
+ // TODO(burdon): BUG: If the cursor is at the end of a link at the end of a line,
230
+ // the cursor will float in space or be in the wrong position after the decoration is applied.
231
231
  scheduleUpdate(view: EditorView) {
232
232
  this.clearUpdate();
233
- this.pendingUpdate = window.setTimeout(() => {
233
+ this.pendingUpdate = setTimeout(() => {
234
234
  view.dispatch({ effects: forceUpdate.of(null) });
235
- }, options.selectionChangeDelay ?? 400);
235
+ }, options.selectionChangeDelay);
236
+ }
237
+
238
+ clearUpdate() {
239
+ if (this.pendingUpdate) {
240
+ clearTimeout(this.pendingUpdate);
241
+ this.pendingUpdate = undefined;
242
+ }
236
243
  }
237
244
 
238
245
  destroy() {
@@ -242,8 +249,8 @@ export const decorateMarkdown = (options: DecorateOptions = {}) => {
242
249
  {
243
250
  provide: (plugin) => [
244
251
  EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomicDeco ?? Decoration.none),
245
- EditorView.decorations.of((view) => view.plugin(plugin)?.deco ?? Decoration.none),
246
252
  EditorView.decorations.of((view) => view.plugin(plugin)?.atomicDeco ?? Decoration.none),
253
+ EditorView.decorations.of((view) => view.plugin(plugin)?.deco ?? Decoration.none),
247
254
  ],
248
255
  },
249
256
  ),
@@ -0,0 +1,36 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ // Copied from @codemirror/view
6
+
7
+ export interface Rect {
8
+ readonly left: number;
9
+ readonly right: number;
10
+ readonly top: number;
11
+ readonly bottom: number;
12
+ }
13
+
14
+ export const flattenRect = (rect: Rect, left: boolean) => {
15
+ const x = left ? rect.left : rect.right;
16
+ return { left: x, right: x, top: rect.top, bottom: rect.bottom };
17
+ };
18
+
19
+ let scratchRange: Range | null;
20
+
21
+ export const textRange = (node: Text, from: number, to = from) => {
22
+ const range = scratchRange || (scratchRange = document.createRange());
23
+ range.setEnd(node, to);
24
+ range.setStart(node, from);
25
+ return range;
26
+ };
27
+
28
+ export const clientRectsFor = (dom: Node) => {
29
+ if (dom.nodeType === 3) {
30
+ return textRange(dom as Text, 0, dom.nodeValue!.length).getClientRects();
31
+ } else if (dom.nodeType === 1) {
32
+ return (dom as HTMLElement).getClientRects();
33
+ } else {
34
+ return [] as any as DOMRectList;
35
+ }
36
+ };
package/src/hooks/defs.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { type Extension, StateField } from '@codemirror/state';
5
+ import { type Extension } from '@codemirror/state';
6
6
 
7
7
  import { type DocAccessor } from '@dxos/echo-schema';
8
8
 
@@ -18,27 +18,13 @@ export type Comment = {
18
18
  cursor?: string;
19
19
  };
20
20
 
21
+ /**
22
+ * @deprecated
23
+ */
24
+ // TODO(burdon): Remove.
21
25
  export type EditorModel = {
22
26
  id: string;
23
27
  text: () => string;
24
- extension?: Extension;
25
-
26
- // TODO(burdon): Remove.
27
28
  content: string | DocAccessor;
28
-
29
- // TODO(burdon): Remove.
30
- peer?: {
31
- id: string;
32
- name?: string;
33
- };
29
+ extension?: Extension;
34
30
  };
35
-
36
- /**
37
- * State field makes the model available to other extensions.
38
- * @deprecated
39
- */
40
- // TODO(burdon): Remove.
41
- export const modelState = StateField.define<EditorModel | undefined>({
42
- create: () => undefined,
43
- update: (model) => model,
44
- });