@dxos/react-ui-editor 0.8.1-staging.5be625a → 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 (48) hide show
  1. package/dist/lib/browser/index.mjs +276 -140
  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 +310 -178
  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 +276 -140
  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 +1 -1
  27. package/dist/types/src/extensions/factories.d.ts.map +1 -1
  28. package/dist/types/src/{styles/stack-item-content-class-names.d.ts → fragments.d.ts} +1 -1
  29. package/dist/types/src/fragments.d.ts.map +1 -0
  30. package/dist/types/src/index.d.ts +0 -1
  31. package/dist/types/src/index.d.ts.map +1 -1
  32. package/dist/types/src/styles/theme.d.ts.map +1 -1
  33. package/package.json +27 -27
  34. package/src/InputMode.stories.tsx +4 -4
  35. package/src/TextEditor.stories.tsx +173 -59
  36. package/src/components/EditorToolbar/EditorToolbar.tsx +4 -5
  37. package/src/defaults.ts +12 -0
  38. package/src/extensions/command/command.ts +21 -2
  39. package/src/extensions/command/hint.ts +3 -0
  40. package/src/extensions/command/menu.ts +100 -0
  41. package/src/extensions/command/preview.ts +79 -0
  42. package/src/extensions/command/state.ts +9 -4
  43. package/src/extensions/comments.ts +6 -10
  44. package/src/extensions/factories.ts +3 -3
  45. package/src/{styles/stack-item-content-class-names.ts → fragments.ts} +3 -1
  46. package/src/index.ts +0 -4
  47. package/src/styles/theme.ts +5 -1
  48. package/dist/types/src/styles/stack-item-content-class-names.d.ts.map +0 -1
@@ -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
+ );
@@ -0,0 +1,79 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import {
7
+ type EditorState,
8
+ type Extension,
9
+ type RangeSet,
10
+ RangeSetBuilder,
11
+ StateField,
12
+ type Transaction,
13
+ } from '@codemirror/state';
14
+ import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
15
+
16
+ export type PreviewOptions = {
17
+ onRenderPreview: (el: HTMLElement, props: { url: string; text: string }) => void;
18
+ };
19
+
20
+ /**
21
+ * Create image decorations.
22
+ */
23
+ export const preview = (options: PreviewOptions): Extension => {
24
+ return [
25
+ StateField.define<DecorationSet>({
26
+ create: (state) => buildDecorations(state, options),
27
+ update: (_: RangeSet<Decoration>, tr: Transaction) => buildDecorations(tr.state, options),
28
+ // TODO(burdon): Make atomic.
29
+ provide: (field) => EditorView.decorations.from(field),
30
+ }),
31
+ ];
32
+ };
33
+
34
+ // TODO(burdon): Make atomic.
35
+ const buildDecorations = (state: EditorState, options: PreviewOptions) => {
36
+ const builder = new RangeSetBuilder<Decoration>();
37
+ syntaxTree(state).iterate({
38
+ enter: (node) => {
39
+ if (node.name === 'Link') {
40
+ const urlNode = node.node.getChild('URL');
41
+ if (urlNode) {
42
+ const text = state.sliceDoc(node.from + 1, urlNode.from - 2);
43
+ const url = state.sliceDoc(urlNode.from, urlNode.to);
44
+ builder.add(
45
+ node.from,
46
+ node.to,
47
+ Decoration.replace({
48
+ block: true, // Prevent cursor from entering.
49
+ widget: new PreviewWidget(options.onRenderPreview, url, text),
50
+ }),
51
+ );
52
+ }
53
+ }
54
+ },
55
+ });
56
+
57
+ return builder.finish();
58
+ };
59
+
60
+ class PreviewWidget extends WidgetType {
61
+ constructor(
62
+ readonly _onRenderPreview: PreviewOptions['onRenderPreview'],
63
+ readonly _url: string,
64
+ readonly _text: string,
65
+ ) {
66
+ super();
67
+ }
68
+
69
+ override eq(other: this) {
70
+ return this._url === (other as any as PreviewWidget)._url;
71
+ }
72
+
73
+ override toDOM(view: EditorView) {
74
+ const root = document.createElement('div');
75
+ root.classList.add('cm-preview');
76
+ this._onRenderPreview(root, { url: this._url, text: this._text });
77
+ return root;
78
+ }
79
+ }
@@ -4,10 +4,10 @@
4
4
 
5
5
  import { StateEffect, StateField } from '@codemirror/state';
6
6
  import {
7
+ showTooltip,
7
8
  type Command,
8
9
  type EditorView,
9
10
  type KeyBinding,
10
- showTooltip,
11
11
  type Tooltip,
12
12
  type TooltipView,
13
13
  } from '@codemirror/view';
@@ -50,14 +50,17 @@ export const commandState = StateField.define<CommandState>({
50
50
  }
51
51
 
52
52
  // Render react component.
53
- options.onRender(dom, (action) => {
53
+ options.onRenderDialog(dom, (action) => {
54
54
  view.dispatch({ effects: closeEffect.of(null) });
55
55
  if (action?.insert?.length) {
56
+ // Insert into editor.
57
+ const text = action.insert + '\n';
56
58
  view.dispatch({
57
- changes: { from: pos, insert: action.insert },
58
- selection: { anchor: pos + action.insert.length },
59
+ changes: { from: pos, insert: text },
60
+ selection: { anchor: pos + text.length },
59
61
  });
60
62
  }
63
+
61
64
  // NOTE: Truncates text if set focus immediately.
62
65
  requestAnimationFrame(() => view.focus());
63
66
  });
@@ -88,6 +91,7 @@ export const openCommand: Command = (view: EditorView) => {
88
91
  return true;
89
92
  }
90
93
  }
94
+
91
95
  return false;
92
96
  };
93
97
 
@@ -96,6 +100,7 @@ export const closeCommand: Command = (view: EditorView) => {
96
100
  view.dispatch({ effects: closeEffect.of(null) });
97
101
  return true;
98
102
  }
103
+
99
104
  return false;
100
105
  };
101
106
 
@@ -4,12 +4,12 @@
4
4
 
5
5
  import { invertedEffects } from '@codemirror/commands';
6
6
  import {
7
+ type ChangeDesc,
8
+ type EditorState,
7
9
  type Extension,
8
10
  StateEffect,
9
11
  StateField,
10
12
  type Text,
11
- type ChangeDesc,
12
- type EditorState,
13
13
  } from '@codemirror/state';
14
14
  import {
15
15
  hoverTooltip,
@@ -24,7 +24,7 @@ import {
24
24
  import sortBy from 'lodash.sortby';
25
25
  import { useEffect, useMemo } from 'react';
26
26
 
27
- import { debounce, type UnsubscribeCallback } from '@dxos/async';
27
+ import { debounce, type CleanupFn } from '@dxos/async';
28
28
  import { type ReactiveObject } from '@dxos/live-object';
29
29
  import { log } from '@dxos/log';
30
30
  import { isNonNullable } from '@dxos/util';
@@ -181,6 +181,7 @@ const handleCommentClick = EditorView.domEventHandlers({
181
181
  return false;
182
182
  },
183
183
  });
184
+
184
185
  //
185
186
  // Cut-and-paste.
186
187
  //
@@ -575,12 +576,7 @@ const hasActiveSelection = (state: EditorState): boolean => {
575
576
  class ExternalCommentSync implements PluginValue {
576
577
  private readonly unsubscribe: () => void;
577
578
 
578
- constructor(
579
- view: EditorView,
580
- id: string,
581
- subscribe: (sink: () => void) => UnsubscribeCallback,
582
- getComments: () => Comment[],
583
- ) {
579
+ constructor(view: EditorView, id: string, subscribe: (sink: () => void) => CleanupFn, getComments: () => Comment[]) {
584
580
  const updateComments = () => {
585
581
  const comments = getComments();
586
582
  if (id === view.state.facet(documentId)) {
@@ -599,7 +595,7 @@ class ExternalCommentSync implements PluginValue {
599
595
  // TODO(burdon): Needs comment.
600
596
  export const createExternalCommentSync = (
601
597
  id: string,
602
- subscribe: (sink: () => void) => UnsubscribeCallback,
598
+ subscribe: (sink: () => void) => CleanupFn,
603
599
  getComments: () => Comment[],
604
600
  ): Extension =>
605
601
  ViewPlugin.fromClass(
@@ -62,7 +62,7 @@ export type BasicExtensionsOptions = {
62
62
  lineWrapping?: boolean;
63
63
  placeholder?: string;
64
64
  /** If true user cannot edit the text, but they can still select and copy it. */
65
- readonly?: boolean;
65
+ readOnly?: boolean;
66
66
  search?: boolean;
67
67
  scrollPastEnd?: boolean;
68
68
  standardKeymap?: boolean;
@@ -74,7 +74,6 @@ const defaultBasicOptions: BasicExtensionsOptions = {
74
74
  bracketMatching: true,
75
75
  closeBrackets: true,
76
76
  drawSelection: true,
77
- editable: true,
78
77
  focus: true,
79
78
  history: true,
80
79
  keymap: 'standard',
@@ -102,13 +101,14 @@ export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extensio
102
101
  props.closeBrackets && closeBrackets(),
103
102
  props.dropCursor && dropCursor(),
104
103
  props.drawSelection && drawSelection({ cursorBlinkRate: 1_200 }),
104
+ props.editable !== undefined && EditorView.editable.of(props.editable),
105
105
  props.focus && focus,
106
106
  props.highlightActiveLine && highlightActiveLine(),
107
107
  props.history && history(),
108
108
  props.lineNumbers && lineNumbers(),
109
109
  props.lineWrapping && EditorView.lineWrapping,
110
110
  props.placeholder && placeholder(props.placeholder),
111
- props.readonly && [EditorState.readOnly.of(true), EditorView.editable.of(false)],
111
+ props.readOnly !== undefined && EditorState.readOnly.of(props.readOnly),
112
112
  props.scrollPastEnd && scrollPastEnd(),
113
113
  props.tabSize && EditorState.tabSize.of(props.tabSize),
114
114
 
@@ -4,9 +4,11 @@
4
4
 
5
5
  import { mx } from '@dxos/react-ui-theme';
6
6
 
7
+ // TODO(burdon): Move this to a common plugin.
8
+
7
9
  export const stackItemContentEditorClassNames = (role?: string) =>
8
10
  mx(
9
- 'dx-focus-ring-inset data-[toolbar=disabled]:pbs-2 attention-surface',
11
+ 'attention-surface dx-focus-ring-inset data-[toolbar=disabled]:pbs-2',
10
12
  role === 'section' ? '[&_.cm-scroller]:overflow-hidden [&_.cm-scroller]:min-bs-24' : 'min-bs-0',
11
13
  );
12
14
 
package/src/index.ts CHANGED
@@ -14,10 +14,6 @@ export * from './components';
14
14
  export * from './defaults';
15
15
  export * from './extensions';
16
16
  export * from './hooks';
17
- export {
18
- stackItemContentEditorClassNames,
19
- stackItemContentToolbarClassNames,
20
- } from './styles/stack-item-content-class-names';
21
17
  export * from './types';
22
18
  export * from './util';
23
19
 
@@ -77,6 +77,10 @@ export const defaultTheme: ThemeStyles = {
77
77
  background: 'transparent',
78
78
  },
79
79
  '.cm-gutter': {},
80
+ '.cm-gutter.cm-lineNumbers': {
81
+ paddingRight: '4px',
82
+ borderRight: '1px solid var(--dx-separator)',
83
+ },
80
84
  '.cm-gutter.cm-lineNumbers .cm-gutterElement': {
81
85
  minWidth: '40px',
82
86
  alignContent: 'center',
@@ -86,7 +90,7 @@ export const defaultTheme: ThemeStyles = {
86
90
  */
87
91
  '.cm-gutterElement': {
88
92
  alignItems: 'center',
89
- fontSize: '16px',
93
+ fontSize: '12px',
90
94
  },
91
95
 
92
96
  /**
@@ -1 +0,0 @@
1
- {"version":3,"file":"stack-item-content-class-names.d.ts","sourceRoot":"","sources":["../../../../src/styles/stack-item-content-class-names.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,gCAAgC,UAAW,MAAM,WAI3D,CAAC;AAEJ,eAAO,MAAM,iCAAiC,UAAW,MAAM,WAI5D,CAAC"}