@dxos/react-ui-editor 0.4.3-main.c3bfede → 0.4.3-main.d01b40e

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 (34) hide show
  1. package/dist/lib/browser/index.mjs +157 -118
  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 -3
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +0 -3
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +3 -3
  9. package/dist/types/src/components/TextEditor/automerge.stories.d.ts +3 -3
  10. package/dist/types/src/components/TextEditor/hooks.stories.d.ts +3 -3
  11. package/dist/types/src/components/Toolbar/Toolbar.d.ts +1 -1
  12. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  13. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +3 -3
  14. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  15. package/dist/types/src/extensions/index.d.ts +1 -0
  16. package/dist/types/src/extensions/index.d.ts.map +1 -1
  17. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  18. package/dist/types/src/extensions/modes.d.ts +11 -0
  19. package/dist/types/src/extensions/modes.d.ts.map +1 -0
  20. package/dist/types/src/index.d.ts +2 -2
  21. package/dist/types/src/index.d.ts.map +1 -1
  22. package/dist/types/src/translations.d.ts +3 -3
  23. package/package.json +25 -25
  24. package/src/components/TextEditor/MarkdownEditor.stories.tsx +10 -1
  25. package/src/components/TextEditor/TextEditor.tsx +29 -50
  26. package/src/components/Toolbar/Toolbar.tsx +81 -69
  27. package/src/extensions/comments.ts +5 -2
  28. package/src/extensions/index.ts +1 -0
  29. package/src/extensions/markdown/formatting.test.ts +1 -1
  30. package/src/extensions/markdown/formatting.ts +5 -2
  31. package/src/extensions/modes.ts +36 -0
  32. package/src/hooks/useActionHandler.ts +1 -1
  33. package/src/index.ts +3 -2
  34. package/src/translations.ts +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-editor",
3
- "version": "0.4.3-main.c3bfede",
3
+ "version": "0.4.3-main.d01b40e",
4
4
  "description": "Document editing experience within a DXOS shell.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -36,23 +36,23 @@
36
36
  "lodash.pick": "^4.4.0",
37
37
  "lodash.sortby": "^4.7.0",
38
38
  "style-mod": "^4.1.0",
39
- "@dxos/async": "0.4.3-main.c3bfede",
40
- "@dxos/automerge": "0.4.3-main.c3bfede",
41
- "@dxos/debug": "0.4.3-main.c3bfede",
42
- "@dxos/context": "0.4.3-main.c3bfede",
43
- "@dxos/echo-schema": "0.4.3-main.c3bfede",
44
- "@dxos/display-name": "0.4.3-main.c3bfede",
45
- "@dxos/protocols": "0.4.3-main.c3bfede",
46
- "@dxos/invariant": "0.4.3-main.c3bfede",
47
- "@dxos/react-async": "0.4.3-main.c3bfede",
48
- "@dxos/log": "0.4.3-main.c3bfede",
49
- "@dxos/react-ui": "0.4.3-main.c3bfede",
50
- "@dxos/react-ui-theme": "0.4.3-main.c3bfede",
51
- "@dxos/text-model": "0.4.3-main.c3bfede",
52
- "@dxos/util": "0.4.3-main.c3bfede"
39
+ "@dxos/async": "0.4.3-main.d01b40e",
40
+ "@dxos/automerge": "0.4.3-main.d01b40e",
41
+ "@dxos/context": "0.4.3-main.d01b40e",
42
+ "@dxos/debug": "0.4.3-main.d01b40e",
43
+ "@dxos/display-name": "0.4.3-main.d01b40e",
44
+ "@dxos/echo-schema": "0.4.3-main.d01b40e",
45
+ "@dxos/invariant": "0.4.3-main.d01b40e",
46
+ "@dxos/log": "0.4.3-main.d01b40e",
47
+ "@dxos/protocols": "0.4.3-main.d01b40e",
48
+ "@dxos/react-async": "0.4.3-main.d01b40e",
49
+ "@dxos/react-ui": "0.4.3-main.d01b40e",
50
+ "@dxos/react-ui-theme": "0.4.3-main.d01b40e",
51
+ "@dxos/text-model": "0.4.3-main.d01b40e",
52
+ "@dxos/util": "0.4.3-main.d01b40e"
53
53
  },
54
54
  "devDependencies": {
55
- "@automerge/automerge-repo-network-broadcastchannel": "^1.1.0",
55
+ "@automerge/automerge-repo-network-broadcastchannel": "^1.1.1",
56
56
  "@phosphor-icons/react": "^2.0.5",
57
57
  "@preact/signals-react": "^1.3.6",
58
58
  "@testing-library/dom": "^8.17.1",
@@ -76,20 +76,20 @@
76
76
  "vite-plugin-top-level-await": "^1.4.1",
77
77
  "vite-plugin-wasm": "^3.3.0",
78
78
  "vitest": "^1.2.1",
79
- "@dxos/config": "0.4.3-main.c3bfede",
80
- "@dxos/echo-signals": "0.4.3-main.c3bfede",
81
- "@dxos/keyboard": "0.4.3-main.c3bfede",
82
- "@dxos/random": "0.4.3-main.c3bfede",
83
- "@dxos/echo-typegen": "0.4.3-main.c3bfede",
84
- "@dxos/react-client": "0.4.3-main.c3bfede",
85
- "@dxos/react-ui": "0.4.3-main.c3bfede",
86
- "@dxos/storybook-utils": "0.4.3-main.c3bfede"
79
+ "@dxos/config": "0.4.3-main.d01b40e",
80
+ "@dxos/echo-signals": "0.4.3-main.d01b40e",
81
+ "@dxos/echo-typegen": "0.4.3-main.d01b40e",
82
+ "@dxos/react-client": "0.4.3-main.d01b40e",
83
+ "@dxos/keyboard": "0.4.3-main.d01b40e",
84
+ "@dxos/react-ui": "0.4.3-main.d01b40e",
85
+ "@dxos/storybook-utils": "0.4.3-main.d01b40e",
86
+ "@dxos/random": "0.4.3-main.d01b40e"
87
87
  },
88
88
  "peerDependencies": {
89
89
  "@phosphor-icons/react": "^2.0.5",
90
90
  "react": "^18.2.0",
91
91
  "react-dom": "^18.2.0",
92
- "@dxos/react-client": "0.4.3-main.c3bfede"
92
+ "@dxos/react-client": "0.4.3-main.d01b40e"
93
93
  },
94
94
  "publishConfig": {
95
95
  "access": "public"
@@ -3,7 +3,6 @@
3
3
  //
4
4
 
5
5
  import '@dxosTheme';
6
-
7
6
  import { type EditorView } from '@codemirror/view';
8
7
  import { ArrowSquareOut } from '@phosphor-icons/react';
9
8
  import defaultsDeep from 'lodash.defaultsdeep';
@@ -37,6 +36,7 @@ import {
37
36
  useComments,
38
37
  formatting,
39
38
  annotations,
39
+ EditorModes,
40
40
  } from '../../extensions';
41
41
  import { type Comment, useTextModel } from '../../hooks';
42
42
  import translations from '../../translations';
@@ -390,6 +390,15 @@ export const HorizontalRule = {
390
390
  ),
391
391
  };
392
392
 
393
+ export const Vim = {
394
+ render: () => (
395
+ <Story
396
+ text={str('# Vim Mode', '', 'The distant future. The year 2000.', '', text.paragraphs)}
397
+ extensions={[defaults, EditorModes.vim]}
398
+ />
399
+ ),
400
+ };
401
+
393
402
  export const Annotations = {
394
403
  render: () => <Story text={str('# Annotations', '', large)} extensions={[annotations({ match: /volup/gi })]} />,
395
404
  };
@@ -4,13 +4,12 @@
4
4
 
5
5
  import { EditorState, type Extension, type StateEffect } from '@codemirror/state';
6
6
  import { EditorView } from '@codemirror/view';
7
- // import { useFocusableGroup } from '@fluentui/react-tabster';
8
- import { vim } from '@replit/codemirror-vim';
7
+ import { useFocusableGroup } from '@fluentui/react-tabster';
9
8
  import defaultsDeep from 'lodash.defaultsdeep';
10
9
  import React, {
11
10
  type ComponentProps,
12
- type KeyboardEvent,
13
11
  forwardRef,
12
+ type KeyboardEventHandler,
14
13
  useCallback,
15
14
  useEffect,
16
15
  useImperativeHandle,
@@ -23,16 +22,12 @@ import { useThemeContext } from '@dxos/react-ui';
23
22
  import { focusRing } from '@dxos/react-ui-theme';
24
23
  import { isNotFalsy } from '@dxos/util';
25
24
 
26
- import { createBasicBundle, createMarkdownExtensions } from '../../extensions';
25
+ import { createBasicBundle, createMarkdownExtensions, editorMode } from '../../extensions';
27
26
  import { type EditorModel } from '../../hooks';
28
27
  import { type ThemeStyles } from '../../styles';
29
28
  import { defaultTheme, markdownTheme, textTheme } from '../../themes';
30
29
  import { logChanges } from '../../util';
31
30
 
32
- // TODO(burdon): Change to enum?
33
- export const EditorModes = ['default', 'vim'] as const;
34
- export type EditorMode = (typeof EditorModes)[number];
35
-
36
31
  export type CursorInfo = {
37
32
  from: number;
38
33
  to: number;
@@ -52,7 +47,6 @@ export type TextEditorSlots = {
52
47
  };
53
48
  };
54
49
 
55
- // TODO(burdon): Spellcheck?
56
50
  export type TextEditorProps = {
57
51
  model: EditorModel; // TODO(burdon): Optional (e.g., just provide content if readonly).
58
52
  readonly?: boolean; // TODO(burdon): Move into model.
@@ -60,7 +54,6 @@ export type TextEditorProps = {
60
54
  lineWrapping?: boolean;
61
55
  scrollTo?: StateEffect<any>; // TODO(burdon): Restore scroll position: scrollTo EditorView.scrollSnapshot().
62
56
  selection?: { anchor: number; head?: number };
63
- editorMode?: EditorMode; // TODO(burdon): Factor out.
64
57
  placeholder?: string;
65
58
  theme?: ThemeStyles;
66
59
  slots?: TextEditorSlots;
@@ -74,26 +67,10 @@ export type TextEditorProps = {
74
67
  // TODO(burdon): Replace with useTextEditor.
75
68
  export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
76
69
  (
77
- {
78
- model,
79
- readonly,
80
- autoFocus,
81
- scrollTo,
82
- selection,
83
- editorMode,
84
- theme,
85
- slots = defaultSlots,
86
- extensions = [],
87
- debug,
88
- },
70
+ { model, readonly, autoFocus, scrollTo, selection, theme, slots = defaultSlots, extensions = [], debug },
89
71
  forwardedRef,
90
72
  ) => {
91
- // TODO(burdon): Hook causes error even if properties are not spread into div.
92
- // Uncaught TypeError: Cannot read properties of undefined (reading 'relatedTarget')
93
- // Uses event.detail, which is deprecated (not event.details). At runtime the event has a property `details`.
94
- // https://github.com/microsoft/tabster/blob/master/src/State/FocusedElement.ts#L348 (e.detail.relatedTarget)
95
- // https://github.com/microsoft/keyborg/blob/49e49b2c3ba0a5f6cc518ac46825d7551def8109/src/FocusEvent.ts#L58
96
- // const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
73
+ const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
97
74
  const { themeMode } = useThemeContext();
98
75
 
99
76
  const rootRef = useRef<HTMLDivElement>(null);
@@ -143,6 +120,15 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
143
120
  EditorView.editorAttributes.of({ class: slots.editor?.className ?? '' }),
144
121
  EditorView.contentAttributes.of({ class: slots.content?.className ?? '' }),
145
122
 
123
+ // Focus.
124
+ EditorView.updateListener.of((update) => {
125
+ update.transactions.forEach((transaction) => {
126
+ if (transaction.isUserEvent('focus.container')) {
127
+ rootRef.current?.focus();
128
+ }
129
+ });
130
+ }),
131
+
146
132
  // State.
147
133
  EditorView.editable.of(!readonly),
148
134
  EditorState.readOnly.of(!!readonly),
@@ -151,9 +137,6 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
151
137
  // NOTE: This must come before user extensions.
152
138
  model.extension,
153
139
 
154
- // TODO(burdon): Factor out? (Requires special handling for Escape/Enter below).
155
- editorMode === 'vim' && vim(),
156
-
157
140
  // Custom.
158
141
  ...extensions,
159
142
  ].filter(isNotFalsy),
@@ -180,44 +163,40 @@ export const BaseTextEditor = forwardRef<EditorView, TextEditorProps>(
180
163
  view?.destroy();
181
164
  setView(newView);
182
165
 
166
+ // Remove tabster attribute (rely on custom keymap).
167
+ if (state.facet(editorMode).noTabster) {
168
+ rootRef.current.removeAttribute('data-tabster');
169
+ }
170
+
183
171
  return () => {
184
172
  newView?.destroy();
185
173
  setView(null);
186
174
  };
187
- }, [rootRef, model, readonly, editorMode, themeMode]);
175
+ }, [rootRef, model, readonly, themeMode]);
188
176
 
189
- // Handles tab/focus.
190
- // Pressing Escape focuses the outer div (to support tab navigation); pressing Enter refocuses the editor.
191
- // TODO(burdon): Convert to keymap?
192
- const handleKeyUp = useCallback(
193
- (event: KeyboardEvent) => {
194
- const { key, altKey, shiftKey, metaKey, ctrlKey } = event;
177
+ // Focus editor on Enter (e.g., when tabbing to this component).
178
+ const handleKeyUp = useCallback<KeyboardEventHandler<HTMLDivElement>>(
179
+ (event) => {
180
+ const { key } = event;
195
181
  switch (key) {
196
182
  case 'Enter': {
197
183
  view?.focus();
198
184
  break;
199
185
  }
200
-
201
- case 'Escape': {
202
- if (editorMode === 'vim' && (altKey || shiftKey || metaKey || ctrlKey)) {
203
- view?.focus();
204
- }
205
- break;
206
- }
207
186
  }
208
187
  },
209
- [view, editorMode],
188
+ [view],
210
189
  );
211
190
 
212
191
  return (
213
192
  <div
214
- key={model.id}
215
193
  role='none'
194
+ ref={rootRef}
195
+ key={model.id}
216
196
  tabIndex={0}
217
- {...slots.root}
218
- // {...(editorMode !== 'vim' && tabsterDOMAttribute)}
219
197
  onKeyUp={handleKeyUp}
220
- ref={rootRef}
198
+ {...slots.root}
199
+ {...tabsterDOMAttribute}
221
200
  />
222
201
  );
223
202
  },
@@ -23,7 +23,6 @@ import {
23
23
  TextHSix,
24
24
  TextItalic,
25
25
  Quotes,
26
- CaretDown,
27
26
  } from '@phosphor-icons/react';
28
27
  import { createContext } from '@radix-ui/react-context';
29
28
  import React, { type PropsWithChildren } from 'react';
@@ -65,7 +64,7 @@ export type ActionType =
65
64
  | 'link'
66
65
  | 'list-bullet'
67
66
  | 'list-ordered'
68
- | 'list-tasks'
67
+ | 'list-task'
69
68
  | 'mention'
70
69
  | 'prompt'
71
70
  | 'strikethrough'
@@ -99,12 +98,17 @@ const ToolbarRoot = ({ children, onAction, classNames, state }: ToolbarProps) =>
99
98
  );
100
99
  };
101
100
 
102
- type ToolbarButtonProps = ToolbarToggleGroupItemProps & {
101
+ const buttonStyles = 'min-bs-0 p-2';
102
+ const iconStyles = getSize(5);
103
+
104
+ type ButtonProps = {
105
+ type: ActionType;
103
106
  Icon: Icon;
107
+ getState: (state: Formatting) => boolean;
108
+ disabled?: (state: Formatting) => boolean;
104
109
  };
105
110
 
106
- const buttonStyles = 'min-bs-0 p-2';
107
- const iconStyles = getSize(4);
111
+ type ToolbarButtonProps = ToolbarToggleGroupItemProps & { Icon: Icon };
108
112
 
109
113
  const ToolbarButton = ({ Icon, children, ...props }: ToolbarButtonProps) => {
110
114
  return (
@@ -118,7 +122,7 @@ const ToolbarButton = ({ Icon, children, ...props }: ToolbarButtonProps) => {
118
122
  const ToolbarSeparator = () => <div role='separator' className='grow' />;
119
123
 
120
124
  const MarkdownHeading = () => {
121
- const { t } = useTranslation(translationKey);
125
+ // const { t } = useTranslation(translationKey);
122
126
  const { onAction, state } = useToolbarContext('MarkdownFormatting');
123
127
  const blockType = state ? state.blockType : 'paragraph';
124
128
  const header = blockType && /heading(\d)/.exec(blockType);
@@ -130,21 +134,30 @@ const MarkdownHeading = () => {
130
134
  value={value ?? '0'}
131
135
  onValueChange={(value) => onAction?.({ type: 'heading', data: parseInt(value) })}
132
136
  >
133
- <Select.Trigger asChild>
134
- <NaturalToolbar.Button variant='ghost' classNames={buttonStyles}>
135
- <span className='sr-only'>{t('heading label')}</span>
136
- <HeadingIcon className={iconStyles} />
137
- <CaretDown className={getSize(2)} weight='bold' />
138
- </NaturalToolbar.Button>
139
- </Select.Trigger>
137
+ <Select.TriggerButton>
138
+ <HeadingIcon className={iconStyles} />
139
+ {/* TODO(burdon): Incorrectly positioned. */}
140
+ {/* <NaturalToolbar.Button variant='ghost' classNames={buttonStyles}> */}
141
+ {/* <span className='sr-only'>{t('heading label')}</span> */}
142
+ {/* <HeadingIcon className={iconStyles} /> */}
143
+ {/* <CaretDown className={getSize(2)} weight='bold' /> */}
144
+ {/* </NaturalToolbar.Button> */}
145
+ </Select.TriggerButton>
140
146
  <Select.Portal>
141
147
  <Select.Content>
142
148
  <Select.ScrollUpButton />
143
149
  <Select.Viewport>
144
- <Select.Option value='0'>Paragraph</Select.Option>
150
+ <Select.Option value='0'>
151
+ <Paragraph className={iconStyles} />
152
+ </Select.Option>
145
153
  {[1, 2, 3, 4, 5, 6].map((level) => (
146
154
  <Select.Option key={level} value={String(level)}>
147
- Heading {level}
155
+ {level === 1 && <TextHOne className={iconStyles} />}
156
+ {level === 2 && <TextHTwo className={iconStyles} />}
157
+ {level === 3 && <TextHThree className={iconStyles} />}
158
+ {level === 4 && <TextHFour className={iconStyles} />}
159
+ {level === 5 && <TextHFive className={iconStyles} />}
160
+ {level === 6 && <TextHSix className={iconStyles} />}
148
161
  </Select.Option>
149
162
  ))}
150
163
  </Select.Viewport>
@@ -156,14 +169,12 @@ const MarkdownHeading = () => {
156
169
  );
157
170
  };
158
171
 
159
- const markdownStyles = [
160
- { key: 'strong' as const, Icon: TextB },
161
- { key: 'emphasis' as const, Icon: TextItalic },
162
- { key: 'strikethrough' as const, Icon: TextStrikethrough },
163
- { key: 'code' as const, Icon: Code },
164
- { key: 'link' as const, Icon: Link },
165
- // <ToolbarButton Icon={At} title='Mention' onClick={() => ({ type: 'mention' })} />
166
- // <ToolbarButton Icon={Image} title='Image' onClick={() => ({ type: 'image' })} />
172
+ const markdownStyles: ButtonProps[] = [
173
+ { type: 'strong', Icon: TextB, getState: (state) => state.strong },
174
+ { type: 'emphasis', Icon: TextItalic, getState: (state) => state.emphasis },
175
+ { type: 'strikethrough', Icon: TextStrikethrough, getState: (state) => state.strikethrough },
176
+ { type: 'code', Icon: Code, getState: (state) => state.code },
177
+ { type: 'link', Icon: Link, getState: (state) => state.link },
167
178
  ];
168
179
 
169
180
  const MarkdownStyles = () => {
@@ -173,27 +184,27 @@ const MarkdownStyles = () => {
173
184
  return (
174
185
  <NaturalToolbar.ToggleGroup
175
186
  type='multiple'
176
- value={markdownStyles.filter(({ key }) => !!state?.[key]).map(({ key }) => key)}
187
+ value={markdownStyles.filter(({ getState }) => state && getState(state)).map(({ type }) => type)}
177
188
  >
178
- {markdownStyles.map(({ key, Icon }) => (
189
+ {markdownStyles.map(({ type, getState, Icon }) => (
179
190
  <ToolbarButton
180
- key={key}
181
- value={key}
191
+ key={type}
192
+ value={type}
182
193
  Icon={Icon}
183
194
  disabled={state?.blockType === 'codeblock'}
184
- onClick={() => onAction?.({ type: key, data: state ? !state[key] : null })}
195
+ onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
185
196
  >
186
- {t(`${key} label`)}
197
+ {t(`${type} label`)}
187
198
  </ToolbarButton>
188
199
  ))}
189
200
  </NaturalToolbar.ToggleGroup>
190
201
  );
191
202
  };
192
203
 
193
- const markdownLists = [
194
- { key: 'bullet' as const, Icon: ListBullets, actionType: 'list-bullet' as const },
195
- { key: 'ordered' as const, Icon: ListNumbers, actionType: 'list-ordered' as const },
196
- { key: 'task' as const, Icon: ListChecks, actionType: 'list-tasks' as const },
204
+ const markdownLists: ButtonProps[] = [
205
+ { type: 'list-bullet', Icon: ListBullets, getState: (state) => state.listStyle === 'bullet' },
206
+ { type: 'list-ordered', Icon: ListNumbers, getState: (state) => state.listStyle === 'ordered' },
207
+ { type: 'list-task', Icon: ListChecks, getState: (state) => state.listStyle === 'task' },
197
208
  ];
198
209
 
199
210
  const MarkdownLists = () => {
@@ -201,62 +212,58 @@ const MarkdownLists = () => {
201
212
  const { t } = useTranslation(translationKey);
202
213
  return (
203
214
  <NaturalToolbar.ToggleGroup type='single' value={state?.listStyle ?? undefined}>
204
- {markdownLists.map(({ key, Icon, actionType }) => (
215
+ {markdownLists.map(({ type, getState, Icon }) => (
205
216
  <ToolbarButton
206
- key={key}
207
- value={key}
217
+ key={type}
218
+ value={type}
208
219
  Icon={Icon}
209
- onClick={() => onAction?.({ type: actionType, data: state ? state.listStyle !== key : null })}
220
+ onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
210
221
  >
211
- {t(`${key} label`)}
222
+ {t(`${type} label`)}
212
223
  </ToolbarButton>
213
224
  ))}
214
225
  </NaturalToolbar.ToggleGroup>
215
226
  );
216
227
  };
217
228
 
218
- const markdownBlocks = [
229
+ const markdownBlocks: ButtonProps[] = [
219
230
  {
220
- key: 'blockquote' as const,
231
+ type: 'blockquote',
221
232
  Icon: Quotes,
222
- pressed: (state: Formatting | null) => state?.blockQuote,
223
- action: (state: Formatting | null) => ({ type: 'blockquote' as const, data: state ? !state.blockQuote : null }),
224
- disabled: (state: Formatting | null) => false,
233
+ getState: (state) => state.blockQuote,
225
234
  },
226
235
  {
227
- key: 'codeblock' as const,
236
+ type: 'codeblock',
228
237
  Icon: CodeBlock,
229
- pressed: (state: Formatting | null) => state?.blockType === 'codeblock',
230
- action: (state: Formatting | null) => ({ type: 'blockquote' as const, data: state ? !state.blockQuote : null }),
231
- disabled: (state: Formatting | null) => !state?.blankLine,
238
+ getState: (state) => state.blockQuote,
239
+ disabled: (state) => !state.blankLine,
240
+ },
241
+ {
242
+ type: 'table',
243
+ Icon: Table,
244
+ getState: (state) => state.blockType === 'tablecell',
245
+ disabled: (state) => !state.blankLine,
232
246
  },
233
247
  ];
234
248
 
235
249
  const MarkdownBlocks = () => {
236
250
  const { onAction, state } = useToolbarContext('MarkdownStyles');
237
251
  const { t } = useTranslation(translationKey);
252
+ const value = markdownBlocks.find(({ getState }) => state && getState(state));
238
253
  return (
239
- <>
240
- <NaturalToolbar.ToggleGroup
241
- type='multiple'
242
- value={markdownBlocks.filter(({ pressed }) => pressed(state)).map(({ key }) => key)}
243
- >
244
- {markdownBlocks.map(({ key, Icon, action }) => (
245
- <ToolbarButton key={key} value={key} Icon={Icon} onClick={() => onAction?.(action(state))}>
246
- {t(`${key} label`)}
247
- </ToolbarButton>
248
- ))}
249
- </NaturalToolbar.ToggleGroup>
250
- <NaturalToolbar.Button
251
- variant='ghost'
252
- disabled={!state?.blankLine}
253
- onClick={() => onAction?.({ type: 'table' })}
254
- classNames={buttonStyles}
255
- >
256
- <Table className={iconStyles} />
257
- <span className='sr-only'>{t('table label')}</span>
258
- </NaturalToolbar.Button>
259
- </>
254
+ <NaturalToolbar.ToggleGroup type='single' value={value?.type}>
255
+ {markdownBlocks.map(({ type, disabled, getState, Icon }) => (
256
+ <ToolbarButton
257
+ key={type}
258
+ value={type}
259
+ Icon={Icon}
260
+ disabled={!state || disabled?.(state)}
261
+ onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
262
+ >
263
+ {t(`${type} label`)}
264
+ </ToolbarButton>
265
+ ))}
266
+ </NaturalToolbar.ToggleGroup>
260
267
  );
261
268
  };
262
269
 
@@ -273,7 +280,12 @@ const MarkdownExtended = () => {
273
280
  const { onAction } = useToolbarContext('MarkdownStyles');
274
281
  const { t } = useTranslation(translationKey);
275
282
  return (
276
- <NaturalToolbar.Button variant='ghost' onClick={() => onAction?.({ type: 'comment' })} classNames={buttonStyles}>
283
+ <NaturalToolbar.Button
284
+ variant='ghost'
285
+ data-testid='editor.toolbar.comment'
286
+ onClick={() => onAction?.({ type: 'comment' })}
287
+ classNames={buttonStyles}
288
+ >
277
289
  <ChatText className={iconStyles} />
278
290
  <span className='sr-only'>{t('comment label')}</span>
279
291
  </NaturalToolbar.Button>
@@ -36,8 +36,11 @@ const styles = EditorView.baseTheme({
36
36
  },
37
37
  });
38
38
 
39
- const commentMark = Decoration.mark({ class: 'cm-comment' });
40
- const commentCurrentMark = Decoration.mark({ class: 'cm-comment-current' });
39
+ const commentMark = Decoration.mark({ class: 'cm-comment', attributes: { 'data-testid': 'cm-comment' } });
40
+ const commentCurrentMark = Decoration.mark({
41
+ class: 'cm-comment-current',
42
+ attributes: { 'data-testid': 'cm-comment' },
43
+ });
41
44
 
42
45
  type CommentState = {
43
46
  comment: Comment;
@@ -13,5 +13,6 @@ export * from './cursor';
13
13
  export * from './listener';
14
14
  export * from './markdown';
15
15
  export * from './mention';
16
+ export * from './modes';
16
17
  export * from './outliner';
17
18
  export * from './typewriter';
@@ -370,7 +370,7 @@ describe('removeBlockquote', () => {
370
370
  });
371
371
 
372
372
  describe('addCodeblock', () => {
373
- testCommand('add a code block to a blank line', '{}', addCodeblock, '```{}\n\n```');
373
+ testCommand('add a code block to a blank line', '{}', addCodeblock, '```{}\n```');
374
374
 
375
375
  testCommand(
376
376
  'can turn a paragraph into a code block',
@@ -356,7 +356,6 @@ const snippets = {
356
356
  [
357
357
  //
358
358
  '```#{}',
359
- '#{}',
360
359
  '```',
361
360
  ].join('\n'),
362
361
  ),
@@ -515,6 +514,7 @@ export const addLink: StateCommand = ({ state, dispatch }) => {
515
514
  // Lists
516
515
  //
517
516
 
517
+ // TODO(burdon): Cursor is positioned incorrectly.
518
518
  export const addList =
519
519
  (type: List): StateCommand =>
520
520
  ({ state, dispatch }) => {
@@ -576,18 +576,21 @@ export const addList =
576
576
  // Insert a new list item if the selection is empty.
577
577
  const { from, to } = state.doc.lineAt(state.selection.main.anchor);
578
578
  if (from === to) {
579
+ const insert = type === List.Bullet ? '- ' : type === List.Ordered ? '1. ' : '- [ ] ';
579
580
  dispatch(
580
581
  state.update({
581
582
  changes: [
582
583
  {
583
584
  from,
584
- insert: type === List.Bullet ? '- ' : type === List.Ordered ? '1. ' : '- [ ] ',
585
+ insert,
585
586
  },
586
587
  ],
588
+ selection: { anchor: from + insert.length },
587
589
  userEvent: 'format.list.add',
588
590
  scrollIntoView: true,
589
591
  }),
590
592
  );
593
+
591
594
  return true;
592
595
  }
593
596
 
@@ -0,0 +1,36 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension, Facet } from '@codemirror/state';
6
+ import { keymap } from '@codemirror/view';
7
+ import { vim } from '@replit/codemirror-vim';
8
+
9
+ export type EditorMode = 'default' | 'vim' | undefined;
10
+
11
+ export type EditorConfig = {
12
+ type: string;
13
+ noTabster?: boolean;
14
+ };
15
+
16
+ export const editorMode = Facet.define<EditorConfig, EditorConfig>({
17
+ combine: (modes) => modes[0] ?? {},
18
+ });
19
+
20
+ export const EditorModes: { [mode: string]: Extension } = {
21
+ default: [],
22
+ vim: [
23
+ vim(),
24
+ editorMode.of({ type: 'vim', noTabster: true }),
25
+ keymap.of([
26
+ {
27
+ key: 'Alt-Escape',
28
+ run: (view) => {
29
+ // Focus container for tab navigation.
30
+ view.dispatch({ userEvent: 'focus.container' });
31
+ return true;
32
+ },
33
+ },
34
+ ]),
35
+ ],
36
+ };
@@ -54,7 +54,7 @@ export const useActionHandler = (view?: EditorView | null): ToolbarProps['onActi
54
54
 
55
55
  case 'list-ordered':
56
56
  case 'list-bullet':
57
- case 'list-tasks':
57
+ case 'list-task':
58
58
  listType =
59
59
  action.type === 'list-ordered' ? List.Ordered : action.type === 'list-bullet' ? List.Bullet : List.Task;
60
60
  (action.data === false
package/src/index.ts CHANGED
@@ -2,10 +2,11 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
+ export { type Extension, type EditorState } from '@codemirror/state';
6
+ export { type EditorView, keymap } from '@codemirror/view';
7
+
5
8
  export { TextKind } from '@dxos/protocols/proto/dxos/echo/model/text';
6
9
  export { Doc, YText, YXmlFragment } from '@dxos/text-model';
7
- export { type Extension } from '@codemirror/state';
8
- export { type EditorView, keymap } from '@codemirror/view';
9
10
 
10
11
  export * from './components';
11
12
  export * from './extensions';