@dxos/react-ui-editor 0.4.7-main.e015b9e → 0.4.7-main.e5c4f14

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 (44) hide show
  1. package/dist/lib/browser/index.mjs +392 -164
  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 +6 -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/extensions/command/command.d.ts +10 -0
  8. package/dist/types/src/extensions/command/command.d.ts.map +1 -0
  9. package/dist/types/src/extensions/command/hint.d.ts +7 -0
  10. package/dist/types/src/extensions/command/hint.d.ts.map +1 -0
  11. package/dist/types/src/extensions/command/index.d.ts +2 -0
  12. package/dist/types/src/extensions/command/index.d.ts.map +1 -0
  13. package/dist/types/src/extensions/command/state.d.ts +18 -0
  14. package/dist/types/src/extensions/command/state.d.ts.map +1 -0
  15. package/dist/types/src/extensions/dnd.d.ts +3 -0
  16. package/dist/types/src/extensions/dnd.d.ts.map +1 -0
  17. package/dist/types/src/extensions/index.d.ts +2 -0
  18. package/dist/types/src/extensions/index.d.ts.map +1 -1
  19. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -1
  20. package/dist/types/src/extensions/markdown/decorate.d.ts +1 -0
  21. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -1
  22. package/dist/types/src/extensions/util/dom.d.ts +15 -0
  23. package/dist/types/src/extensions/util/dom.d.ts.map +1 -0
  24. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  25. package/dist/types/src/styles/index.d.ts +0 -1
  26. package/dist/types/src/styles/index.d.ts.map +1 -1
  27. package/package.json +25 -25
  28. package/src/components/TextEditor/MarkdownEditor.stories.tsx +72 -4
  29. package/src/components/TextEditor/TextEditor.tsx +2 -0
  30. package/src/extensions/awareness.ts +3 -3
  31. package/src/extensions/command/command.ts +38 -0
  32. package/src/extensions/command/hint.ts +75 -0
  33. package/src/extensions/command/index.ts +5 -0
  34. package/src/extensions/command/state.ts +106 -0
  35. package/src/extensions/dnd.ts +15 -0
  36. package/src/extensions/index.ts +2 -0
  37. package/src/extensions/markdown/bundle.ts +0 -1
  38. package/src/extensions/markdown/decorate.ts +68 -31
  39. package/src/extensions/util/dom.ts +36 -0
  40. package/src/hooks/useTextModel.ts +12 -5
  41. package/src/styles/index.ts +0 -1
  42. package/dist/types/src/styles/cursors.d.ts +0 -5
  43. package/dist/types/src/styles/cursors.d.ts.map +0 -1
  44. package/src/styles/cursors.ts +0 -21
@@ -3,17 +3,19 @@
3
3
  //
4
4
 
5
5
  import '@dxosTheme';
6
+
6
7
  import { type EditorView } from '@codemirror/view';
7
- import { ArrowSquareOut } from '@phosphor-icons/react';
8
+ import { ArrowSquareOut, X } from '@phosphor-icons/react';
8
9
  import defaultsDeep from 'lodash.defaultsdeep';
9
- import React, { type FC, StrictMode, useRef, useState } from 'react';
10
+ import React, { type FC, type KeyboardEvent, StrictMode, useRef, useState } from 'react';
10
11
  import { createRoot } from 'react-dom/client';
11
12
 
12
13
  import { TextObject } from '@dxos/echo-schema';
13
14
  import { keySymbols, parseShortcut } from '@dxos/keyboard';
14
15
  import { PublicKey } from '@dxos/keys';
15
16
  import { faker } from '@dxos/random';
16
- import { getSize, mx, textBlockWidth } from '@dxos/react-ui-theme';
17
+ import { Button, DensityProvider, Input, ThemeProvider } from '@dxos/react-ui';
18
+ import { baseSurface, defaultTx, getSize, mx, textBlockWidth } from '@dxos/react-ui-theme';
17
19
  import { withTheme } from '@dxos/storybook-utils';
18
20
 
19
21
  import { MarkdownEditor, type TextEditorProps } from './TextEditor';
@@ -24,6 +26,8 @@ import {
24
26
  decorateMarkdown,
25
27
  comments,
26
28
  defaultOptions,
29
+ dnd,
30
+ command,
27
31
  image,
28
32
  linkTooltip,
29
33
  mention,
@@ -33,6 +37,8 @@ import {
33
37
  formatting,
34
38
  annotations,
35
39
  EditorModes,
40
+ type CommandOptions,
41
+ type CommandAction,
36
42
  } from '../../extensions';
37
43
  import { type Comment, useTextModel } from '../../hooks';
38
44
  import translations from '../../translations';
@@ -238,7 +244,7 @@ const Story = ({ id = 'test', text, comments, placeholder = 'New document.', ...
238
244
  placeholder={placeholder}
239
245
  slots={{
240
246
  root: { className: mx(textBlockWidth, 'min-bs-dvh') },
241
- editor: { className: 'min-bs-dvh p-2 bg-white dark:bg-black' },
247
+ editor: { className: 'min-bs-dvh px-8 bg-white dark:bg-black' },
242
248
  }}
243
249
  {...props}
244
250
  />
@@ -353,6 +359,64 @@ export const Mention = {
353
359
  ),
354
360
  };
355
361
 
362
+ const CommandDialog: FC<{ onClose: (action?: CommandAction) => void }> = ({ onClose }) => {
363
+ const [text, setText] = useState('');
364
+ const handleInsert = () => {
365
+ onClose(text.length ? { insert: text + '\n' } : undefined);
366
+ };
367
+
368
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
369
+ switch (event.key) {
370
+ case 'Enter': {
371
+ handleInsert();
372
+ break;
373
+ }
374
+ case 'Escape': {
375
+ onClose();
376
+ break;
377
+ }
378
+ }
379
+ };
380
+
381
+ return (
382
+ <DensityProvider density='fine'>
383
+ <div className={mx('flex items-center p-2 gap-2 border rounded-md', baseSurface)}>
384
+ <Input.Root>
385
+ <Input.TextInput
386
+ autoFocus={true}
387
+ placeholder='Enter command.'
388
+ value={text}
389
+ onChange={({ target: { value } }) => setText(value)}
390
+ onKeyDown={handleKeyDown}
391
+ />
392
+ </Input.Root>
393
+ <Button variant='ghost' classNames='pli-0' onClick={() => onClose()}>
394
+ <X className={getSize(5)} />
395
+ </Button>
396
+ </div>
397
+ </DensityProvider>
398
+ );
399
+ };
400
+
401
+ const renderCommandDialog: CommandOptions['onRender'] = (el, onClose) => {
402
+ createRoot(el).render(
403
+ <StrictMode>
404
+ <ThemeProvider tx={defaultTx}>
405
+ <CommandDialog onClose={onClose} />
406
+ </ThemeProvider>
407
+ </StrictMode>,
408
+ );
409
+ };
410
+
411
+ export const Command = {
412
+ render: () => (
413
+ <Story
414
+ text={str('# Command', '')}
415
+ extensions={[command({ onRender: renderCommandDialog, onHint: () => 'Press / for commands.' })]}
416
+ />
417
+ ),
418
+ };
419
+
356
420
  export const Comments = {
357
421
  render: () => {
358
422
  const [_comments, setComments] = useState<Comment[]>([]);
@@ -402,6 +466,10 @@ export const Annotations = {
402
466
  render: () => <Story text={str('# Annotations', '', large)} extensions={[annotations({ match: /volup/gi })]} />,
403
467
  };
404
468
 
469
+ export const DND = {
470
+ render: () => <Story text={str('# DND', '')} extensions={[dnd()]} />,
471
+ };
472
+
405
473
  const typewriterItems = localStorage.getItem('dxos.org/plugin/markdown/typewriter')?.split(',');
406
474
 
407
475
  export const Typewriter = {
@@ -220,6 +220,7 @@ export const BaseTextEditor = forwardRef<EditorView | null, TextEditorProps>(
220
220
  },
221
221
  );
222
222
 
223
+ // TODO(burdon): Replace with hooks.
223
224
  export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
224
225
  (
225
226
  { readonly, placeholder, lineWrapping, theme = textTheme, slots, extensions: _extensions, ...props },
@@ -245,6 +246,7 @@ export const TextEditor = forwardRef<EditorView | null, TextEditorProps>(
245
246
  },
246
247
  );
247
248
 
249
+ // TODO(burdon): Replace with hooks.
248
250
  export const MarkdownEditor = forwardRef<EditorView | null, TextEditorProps>(
249
251
  ({ readonly, placeholder, theme = markdownTheme, slots, extensions: _extensions, ...props }, forwardedRef) => {
250
252
  const { themeMode } = useThemeContext();
@@ -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,38 @@
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, commandKeymap, commandState } from './state';
10
+
11
+ // TODO(burdon): Create knowledge base for notes.
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
+ // TODO(burdon): Insert text at cursor.
17
+
18
+ export type CommandAction = {
19
+ insert?: string;
20
+ };
21
+
22
+ export type CommandOptions = {
23
+ onRender: (el: HTMLElement, cb: (action?: CommandAction) => void) => void;
24
+ onHint: () => string | undefined;
25
+ };
26
+
27
+ export const command = (options: CommandOptions): Extension => {
28
+ return [
29
+ commandState,
30
+ keymap.of(commandKeymap),
31
+ hintViewPlugin(options),
32
+ // Close tooltip on focus change.
33
+ EditorView.focusChangeEffect.of((_, focusing) => {
34
+ return focusing ? closeEffect.of(null) : null;
35
+ }),
36
+ commandConfig.of(options),
37
+ ];
38
+ };
@@ -0,0 +1,75 @@
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): Show after delay or if blank line above?
61
+ if (selection.from === selection.to && line.from === line.to) {
62
+ const hint = onHint();
63
+ if (hint) {
64
+ builder.add(selection.from, selection.to, Decoration.widget({ widget: new CommandHint(hint) }));
65
+ }
66
+ }
67
+ }
68
+
69
+ this.deco = builder.finish();
70
+ }
71
+ },
72
+ {
73
+ provide: (plugin) => [EditorView.decorations.of((view) => view.plugin(plugin)?.deco ?? Decoration.none)],
74
+ },
75
+ );
@@ -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 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 commandKeymap: readonly KeyBinding[] = [
104
+ { key: '/', run: openCommand },
105
+ { key: 'Escape', run: closeCommand },
106
+ ];
@@ -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
+ };
@@ -8,9 +8,11 @@ export * from './automerge';
8
8
  export * from './awareness';
9
9
  export * from './basic';
10
10
  export * from './blast';
11
+ export * from './command';
11
12
  export * from './comments';
12
13
  export * from './cursor';
13
14
  export * from './cursor-line-margin';
15
+ export * from './dnd';
14
16
  export * from './listener';
15
17
  export * from './markdown';
16
18
  export * from './mention';
@@ -36,7 +36,6 @@ export type MarkdownBundleOptions = {
36
36
  * https://codemirror.net/docs/community
37
37
  * https://codemirror.net/docs/ref/#codemirror.basicSetup
38
38
  */
39
- // TODO(burdon): Add Composer here: https://codemirror.net/docs/community
40
39
  export const createMarkdownExtensions = ({
41
40
  themeMode,
42
41
  placeholder: _placeholder,
@@ -3,7 +3,7 @@
3
3
  //
4
4
 
5
5
  import { syntaxTree } from '@codemirror/language';
6
- import { RangeSetBuilder, type EditorState } from '@codemirror/state';
6
+ import { RangeSetBuilder, type EditorState, StateEffect } from '@codemirror/state';
7
7
  import { EditorView, Decoration, type DecorationSet, WidgetType, ViewPlugin, type ViewUpdate } from '@codemirror/view';
8
8
 
9
9
  import { mx } from '@dxos/react-ui-theme';
@@ -81,20 +81,21 @@ const horizontalRule = Decoration.replace({ widget: new HorizontalRuleWidget() }
81
81
  const checkedTask = Decoration.replace({ widget: new CheckboxWidget(true) });
82
82
  const uncheckedTask = Decoration.replace({ widget: new CheckboxWidget(false) });
83
83
 
84
- const editingRange = (state: EditorState, range: { from: number; to: number }) => {
84
+ const editingRange = (state: EditorState, range: { from: number; to: number }, focus: boolean) => {
85
85
  const {
86
86
  readOnly,
87
87
  selection: {
88
88
  main: { head },
89
89
  },
90
90
  } = state;
91
- return !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']);
95
95
 
96
- const buildDecorations = (view: EditorView, options: DecorateOptions): DecorationSet => {
97
- const builder = new RangeSetBuilder<Decoration>();
96
+ const buildDecorations = (view: EditorView, options: DecorateOptions, focus: boolean) => {
97
+ const deco = new RangeSetBuilder<Decoration>();
98
+ const atomicDeco = new RangeSetBuilder<Decoration>();
98
99
  const { state } = view;
99
100
 
100
101
  for (const { from, to } of view.visibleRanges) {
@@ -103,7 +104,7 @@ const buildDecorations = (view: EditorView, options: DecorateOptions): Decoratio
103
104
  to,
104
105
  enter: (node) => {
105
106
  if (node.name === 'FencedCode') {
106
- const editing = editingRange(state, node);
107
+ const editing = editingRange(state, node, focus);
107
108
  // FencedCode > CodeMark > [CodeInfo] > CodeText > CodeMark
108
109
  for (const block of view.viewportLineBlocks) {
109
110
  if (block.to < node.from) {
@@ -114,26 +115,22 @@ const buildDecorations = (view: EditorView, options: DecorateOptions): Decoratio
114
115
  }
115
116
  const first = block.from <= node.from;
116
117
  const last = block.to >= node.to;
117
- builder.add(
118
- block.from,
119
- block.from,
120
- first ? fencedCodeLineFirst : last ? fencedCodeLineLast : fencedCodeLine,
121
- );
118
+ deco.add(block.from, block.from, first ? fencedCodeLineFirst : last ? fencedCodeLineLast : fencedCodeLine);
122
119
  if (!editing && (first || last)) {
123
- builder.add(block.from, block.to, hide);
120
+ atomicDeco.add(block.from, block.to, hide);
124
121
  }
125
122
  }
126
123
  return false;
127
124
  } else if (node.name === 'Link') {
128
125
  const marks = node.node.getChildren('LinkMark');
129
126
  const urlNode = node.node.getChild('URL');
130
- const editing = editingRange(state, node);
127
+ const editing = editingRange(state, node, focus);
131
128
  if (urlNode && marks.length >= 2) {
132
129
  const url = state.sliceDoc(urlNode.from, urlNode.to);
133
130
  if (!editing) {
134
- builder.add(node.from, marks[0].to, hide);
131
+ atomicDeco.add(node.from, marks[0].to, hide);
135
132
  }
136
- builder.add(
133
+ deco.add(
137
134
  marks[0].to,
138
135
  marks[1].from,
139
136
  Decoration.mark({
@@ -147,7 +144,7 @@ const buildDecorations = (view: EditorView, options: DecorateOptions): Decoratio
147
144
  }),
148
145
  );
149
146
  if (!editing) {
150
- builder.add(
147
+ atomicDeco.add(
151
148
  marks[1].from,
152
149
  node.to,
153
150
  options.renderLinkButton
@@ -160,54 +157,94 @@ const buildDecorations = (view: EditorView, options: DecorateOptions): Decoratio
160
157
  }
161
158
  } else if (node.name === 'HeaderMark') {
162
159
  const parent = node.node.parent!;
163
- if (/^ATX/.test(parent.name) && !editingRange(state, state.doc.lineAt(node.from))) {
160
+ if (/^ATX/.test(parent.name) && !editingRange(state, state.doc.lineAt(node.from), focus)) {
164
161
  const next = state.doc.sliceString(node.to, node.to + 1);
165
- builder.add(node.from, node.to + (next === ' ' ? 1 : 0), hide);
162
+ atomicDeco.add(node.from, node.to + (next === ' ' ? 1 : 0), hide);
166
163
  }
167
164
  } else if (node.name === 'HorizontalRule') {
168
- if (!editingRange(state, node)) {
169
- builder.add(node.from, node.to, horizontalRule);
165
+ if (!editingRange(state, node, focus)) {
166
+ deco.add(node.from, node.to, horizontalRule);
170
167
  }
171
168
  } else if (node.name === 'TaskMarker') {
172
169
  // Check if cursor is inside text.
173
- if (!editingRange(state, node)) {
170
+ if (!editingRange(state, node, focus)) {
174
171
  const checked = state.doc.sliceString(node.from + 1, node.to - 1) === 'x';
175
- builder.add(node.from - 2, node.from - 1, Decoration.mark({ class: 'cm-task' }));
176
- builder.add(node.from, node.to, checked ? checkedTask : uncheckedTask);
172
+ atomicDeco.add(node.from - 2, node.from - 1, Decoration.mark({ class: 'cm-task' }));
173
+ atomicDeco.add(node.from, node.to, checked ? checkedTask : uncheckedTask);
177
174
  }
178
175
  } else if (MarksByParent.has(node.name)) {
179
- if (!editingRange(state, node.node.parent!)) {
180
- builder.add(node.from, node.to, hide);
176
+ if (!editingRange(state, node.node.parent!, focus)) {
177
+ atomicDeco.add(node.from, node.to, hide);
181
178
  }
182
179
  }
183
180
  },
184
181
  });
185
182
  }
186
183
 
187
- return builder.finish();
184
+ return { deco: deco.finish(), atomicDeco: atomicDeco.finish() };
188
185
  };
189
186
 
190
187
  export interface DecorateOptions {
191
188
  renderLinkButton?: (el: Element, url: string) => void;
189
+ selectionChangeDelay?: number;
192
190
  }
193
191
 
192
+ const forceUpdate = StateEffect.define<null>();
193
+
194
194
  export const decorateMarkdown = (options: DecorateOptions = {}) => {
195
195
  return [
196
196
  ViewPlugin.fromClass(
197
197
  class {
198
- decorations: DecorationSet;
198
+ pendingUpdate = -1;
199
+ deco: DecorationSet;
200
+ atomicDeco: DecorationSet;
201
+
199
202
  constructor(view: EditorView) {
200
- this.decorations = buildDecorations(view, options);
203
+ ({ deco: this.deco, atomicDeco: this.atomicDeco } = buildDecorations(view, options, view.hasFocus));
201
204
  }
202
205
 
203
206
  update(update: ViewUpdate) {
204
- if (update.docChanged || update.selectionSet || update.viewportChanged) {
205
- this.decorations = buildDecorations(update.view, options);
207
+ if (
208
+ update.docChanged ||
209
+ update.viewportChanged ||
210
+ update.focusChanged ||
211
+ update.transactions.some((tr) => tr.effects.some((e) => e.is(forceUpdate)))
212
+ ) {
213
+ ({ deco: this.deco, atomicDeco: this.atomicDeco } = buildDecorations(
214
+ update.view,
215
+ options,
216
+ update.view.hasFocus,
217
+ ));
218
+ this.clearUpdate();
219
+ } else if (update.selectionSet) {
220
+ this.scheduleUpdate(update.view);
221
+ }
222
+ }
223
+
224
+ clearUpdate() {
225
+ if (this.pendingUpdate > -1) {
226
+ window.clearTimeout(this.pendingUpdate);
227
+ this.pendingUpdate = -1;
206
228
  }
207
229
  }
230
+
231
+ scheduleUpdate(view: EditorView) {
232
+ this.clearUpdate();
233
+ this.pendingUpdate = window.setTimeout(() => {
234
+ view.dispatch({ effects: forceUpdate.of(null) });
235
+ }, options.selectionChangeDelay ?? 400);
236
+ }
237
+
238
+ destroy() {
239
+ this.clearUpdate();
240
+ }
208
241
  },
209
242
  {
210
- decorations: (value) => value.decorations,
243
+ provide: (plugin) => [
244
+ EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomicDeco ?? Decoration.none),
245
+ EditorView.decorations.of((view) => view.plugin(plugin)?.deco ?? Decoration.none),
246
+ EditorView.decorations.of((view) => view.plugin(plugin)?.atomicDeco ?? Decoration.none),
247
+ ],
211
248
  },
212
249
  ),
213
250
  formattingStyles,
@@ -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
+ };