@dxos/react-ui-editor 0.3.10-main.11cdcf4 → 0.3.10-main.279fbbe

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 (74) hide show
  1. package/dist/lib/browser/index.mjs +319 -210
  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 +3 -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 +1 -0
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/extensions/autocomplete.d.ts +3 -2
  9. package/dist/types/src/components/TextEditor/extensions/autocomplete.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/extensions/comments.d.ts +14 -0
  11. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +1 -0
  12. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts.map +1 -1
  13. package/dist/types/src/components/TextEditor/extensions/index.d.ts +1 -0
  14. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +1 -1
  15. package/dist/types/src/components/TextEditor/extensions/link.d.ts.map +1 -1
  16. package/dist/types/src/components/TextEditor/extensions/listener.d.ts.map +1 -1
  17. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +1 -1
  18. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +1 -1
  19. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts +1 -4
  20. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts.map +1 -1
  21. package/dist/types/src/components/TextEditor/themes/default.d.ts +3 -0
  22. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +1 -1
  23. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts.map +1 -0
  24. package/dist/types/src/hooks/automerge/amToCodemirror.d.ts.map +1 -0
  25. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -0
  26. package/dist/types/src/hooks/automerge/codeMirrorToAm.d.ts.map +1 -0
  27. package/dist/types/src/hooks/automerge/handle.d.ts.map +1 -0
  28. package/dist/types/src/hooks/automerge/index.d.ts.map +1 -0
  29. package/dist/types/src/hooks/automerge/plugin.d.ts.map +1 -0
  30. package/dist/types/src/hooks/index.d.ts +0 -1
  31. package/dist/types/src/hooks/index.d.ts.map +1 -1
  32. package/dist/types/src/hooks/useTextModel.d.ts +3 -4
  33. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  34. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  35. package/package.json +19 -19
  36. package/src/components/TextEditor/MarkdownEditor.stories.tsx +37 -6
  37. package/src/components/TextEditor/TextEditor.tsx +21 -17
  38. package/src/components/TextEditor/extensions/autocomplete.ts +5 -3
  39. package/src/components/TextEditor/extensions/comments.ts +173 -0
  40. package/src/components/TextEditor/extensions/experimental.tsx +1 -0
  41. package/src/components/TextEditor/extensions/index.ts +1 -0
  42. package/src/components/TextEditor/extensions/link.ts +10 -2
  43. package/src/components/TextEditor/extensions/listener.ts +1 -0
  44. package/src/components/TextEditor/extensions/markdown/bundle.ts +6 -14
  45. package/src/components/TextEditor/extensions/markdown/highlight.ts +2 -1
  46. package/src/components/TextEditor/extensions/tasklist.ts +73 -79
  47. package/src/components/TextEditor/themes/default.ts +15 -0
  48. package/src/{automerge → hooks/automerge}/automerge.stories.tsx +2 -1
  49. package/src/hooks/index.ts +0 -1
  50. package/src/hooks/useTextModel.ts +49 -41
  51. package/src/testing/replicator.ts +6 -7
  52. package/dist/types/src/automerge/automerge-plugin/PatchSemaphore.d.ts.map +0 -1
  53. package/dist/types/src/automerge/automerge-plugin/amToCodemirror.d.ts.map +0 -1
  54. package/dist/types/src/automerge/automerge-plugin/codeMirrorToAm.d.ts.map +0 -1
  55. package/dist/types/src/automerge/automerge-plugin/handle.d.ts.map +0 -1
  56. package/dist/types/src/automerge/automerge-plugin/index.d.ts.map +0 -1
  57. package/dist/types/src/automerge/automerge-plugin/plugin.d.ts.map +0 -1
  58. package/dist/types/src/automerge/automerge.stories.d.ts.map +0 -1
  59. package/dist/types/src/hooks/useCollaboration.d.ts +0 -9
  60. package/dist/types/src/hooks/useCollaboration.d.ts.map +0 -1
  61. package/src/hooks/useCollaboration.ts +0 -45
  62. /package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/PatchSemaphore.d.ts +0 -0
  63. /package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/amToCodemirror.d.ts +0 -0
  64. /package/dist/types/src/{automerge → hooks/automerge}/automerge.stories.d.ts +0 -0
  65. /package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/codeMirrorToAm.d.ts +0 -0
  66. /package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/handle.d.ts +0 -0
  67. /package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/index.d.ts +0 -0
  68. /package/dist/types/src/{automerge/automerge-plugin → hooks/automerge}/plugin.d.ts +0 -0
  69. /package/src/{automerge/automerge-plugin → hooks/automerge}/PatchSemaphore.ts +0 -0
  70. /package/src/{automerge/automerge-plugin → hooks/automerge}/amToCodemirror.ts +0 -0
  71. /package/src/{automerge/automerge-plugin → hooks/automerge}/codeMirrorToAm.ts +0 -0
  72. /package/src/{automerge/automerge-plugin → hooks/automerge}/handle.ts +0 -0
  73. /package/src/{automerge/automerge-plugin → hooks/automerge}/index.ts +0 -0
  74. /package/src/{automerge/automerge-plugin → hooks/automerge}/plugin.ts +0 -0
@@ -16,15 +16,13 @@ import React, {
16
16
  useImperativeHandle,
17
17
  useState,
18
18
  } from 'react';
19
- import { yCollab } from 'y-codemirror.next';
20
19
 
21
20
  import { generateName } from '@dxos/display-name';
22
21
  import { useThemeContext } from '@dxos/react-ui';
23
22
  import { getColorForValue, inputSurface, mx } from '@dxos/react-ui-theme';
24
- import { type YText } from '@dxos/text-model';
25
23
 
26
24
  import { basicBundle, markdownBundle } from './extensions';
27
- import { defaultTheme, textTheme } from './themes';
25
+ import { defaultTheme, markdownTheme, textTheme } from './themes';
28
26
  import { type EditorModel } from '../../hooks';
29
27
  import { type ThemeStyles } from '../../styles';
30
28
 
@@ -72,23 +70,22 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
72
70
  const { themeMode } = useThemeContext();
73
71
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
74
72
 
75
- // TODO(burdon): Factor out extension (remove logic from react-ui-editor).
76
- // const collaboration = useCollaboration(model, themeMode);
77
- const { provider, peer } = model;
73
+ const [root, setRoot] = useState<HTMLDivElement | null>(null);
74
+ const [state, setState] = useState<EditorState>();
75
+ const [view, setView] = useState<EditorView>();
76
+ useImperativeHandle(forwardedRef, () => ({ root, state, view }), [view, state, root]);
77
+
78
+ // TODO(burdon): Factor out?
79
+ const { awareness, peer } = model;
78
80
  useEffect(() => {
79
- if (provider && peer) {
80
- provider.awareness.setLocalStateField('user', {
81
+ if (awareness && peer) {
82
+ awareness.setLocalStateField('user', {
81
83
  name: peer.name ?? generateName(peer.id),
82
84
  color: getColorForValue({ value: peer.id, type: 'color' }),
83
85
  colorLight: getColorForValue({ value: peer.id, themeMode, type: 'highlight' }),
84
86
  });
85
87
  }
86
- }, [provider, peer, themeMode]);
87
-
88
- const [root, setRoot] = useState<HTMLDivElement | null>(null);
89
- const [state, setState] = useState<EditorState>();
90
- const [view, setView] = useState<EditorView>();
91
- useImperativeHandle(forwardedRef, () => ({ root, state, view }), [view, state, root]);
88
+ }, [awareness, peer, themeMode]);
92
89
 
93
90
  useEffect(() => {
94
91
  if (!root) {
@@ -108,7 +105,7 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
108
105
  EditorView.darkTheme.of(themeMode === 'dark'),
109
106
 
110
107
  // Storage and replication.
111
- yCollab(model.content as YText, model.provider?.awareness),
108
+ model.extension,
112
109
 
113
110
  // Custom.
114
111
  ...extensions,
@@ -127,7 +124,7 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
127
124
  setView(undefined);
128
125
  setState(undefined);
129
126
  };
130
- }, [root, model.content, themeMode, editorMode]);
127
+ }, [root, model, themeMode, editorMode]);
131
128
 
132
129
  const handleKeyUp = useCallback(
133
130
  (event: KeyboardEvent) => {
@@ -179,7 +176,7 @@ export const TextEditor = forwardRef<TextEditorRef, TextEditorProps>(
179
176
  export const MarkdownEditor = forwardRef<TextEditorRef, TextEditorProps>(
180
177
  ({ extensions = [], slots: _slots, ...props }, forwardedRef) => {
181
178
  const { themeMode } = useThemeContext();
182
- const slots = defaultsDeep({}, _slots, defaultSlots);
179
+ const slots = defaultsDeep({}, _slots, defaultMarkdownSlots);
183
180
  return (
184
181
  <BaseTextEditor
185
182
  ref={forwardedRef}
@@ -203,3 +200,10 @@ export const defaultTextSlots: TextEditorSlots = {
203
200
  theme: textTheme,
204
201
  },
205
202
  };
203
+
204
+ export const defaultMarkdownSlots: TextEditorSlots = {
205
+ ...defaultSlots,
206
+ editor: {
207
+ theme: markdownTheme,
208
+ },
209
+ };
@@ -23,14 +23,16 @@ import {
23
23
  } from '@codemirror/autocomplete';
24
24
  import { keymap } from '@codemirror/view';
25
25
 
26
+ export type AutocompleteResult = Completion;
27
+
26
28
  export type AutocompleteOptions = {
27
- getOptions: (text: string) => Completion[];
29
+ onSearch: (text: string) => Completion[];
28
30
  };
29
31
 
30
32
  /**
31
33
  * Autocomplete extension.
32
34
  */
33
- export const autocomplete = ({ getOptions }: AutocompleteOptions) => {
35
+ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
34
36
  return [
35
37
  // https://codemirror.net/docs/ref/#view.keymap
36
38
  // https://discuss.codemirror.net/t/how-can-i-replace-the-default-autocompletion-keymap-v6/3322
@@ -57,7 +59,7 @@ export const autocomplete = ({ getOptions }: AutocompleteOptions) => {
57
59
  // TODO(burdon): Option to convert to links?
58
60
  return {
59
61
  from: word.from,
60
- options: getOptions(word.text.toLowerCase()),
62
+ options: onSearch(word.text.toLowerCase()),
61
63
  // options: [
62
64
  // { label: 'apple', type: 'keyword' },
63
65
  // { label: 'amazon', type: 'keyword' },
@@ -0,0 +1,173 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import {
7
+ Decoration,
8
+ type DecorationSet,
9
+ EditorView,
10
+ keymap,
11
+ MatchDecorator,
12
+ type Rect,
13
+ ViewPlugin,
14
+ type ViewUpdate,
15
+ WidgetType,
16
+ } from '@codemirror/view';
17
+
18
+ import { invariant } from '@dxos/invariant';
19
+
20
+ // 1. TODO(burdon): Make atomic (for tasklist also).
21
+ // - https://discuss.codemirror.net/t/easily-track-remove-content-with-decorations/4606
22
+ // - https://discuss.codemirror.net/t/creating-atomic-replace-decorations/2961
23
+ // - https://codemirror.net/docs/ref/#state.EditorState%5EtransactionFilter (transaction filter: move, delete).
24
+ // 2. TODO(burdon): Create/track threads in composer (change global state). Select/follow. Scroll with page.
25
+ // - Separate from chat/search.
26
+ // 3. TODO(burdon): Support multiple threads in sidebar?
27
+ // 4. TODO(burdon): Anchor AI thread when creating section.
28
+ // 5. TODO(burdon): Button to resolve thread to close comments.
29
+
30
+ // 6. TODO(burdon): Perf: Update existing rangeset.
31
+ // https://discuss.codemirror.net/t/rangeset-with-metadata-and-different-decorations/3874
32
+
33
+ // TODO(burdon): Import note (performance):
34
+ // Easily track & remove content with decorations
35
+ // https://discuss.codemirror.net/t/easily-track-remove-content-with-decorations/4606
36
+
37
+ class BookmarkWidget extends WidgetType {
38
+ constructor(private readonly _pos: number, private readonly _id: string) {
39
+ super();
40
+ invariant(this._id);
41
+ }
42
+
43
+ override eq(other: BookmarkWidget) {
44
+ return this._id === other._id;
45
+ }
46
+
47
+ // TODO(burdon): Click to select.
48
+ override toDOM() {
49
+ const span = document.createElement('span');
50
+ span.className = 'cm-bookmark';
51
+ // https://emojifinder.com/comment
52
+ span.textContent = '💬';
53
+ return span;
54
+ }
55
+ }
56
+
57
+ // TODO(burdon): Reconcile with theme.
58
+ const styles = EditorView.baseTheme({
59
+ '& .cm-bookmark': {
60
+ cursor: 'pointer',
61
+ margin: '4px',
62
+ padding: '4px',
63
+ backgroundColor: 'yellow',
64
+ },
65
+ });
66
+
67
+ export type CommentsOptions = {
68
+ key?: string;
69
+ onCreate?: () => string | void;
70
+ onUpdate?: (info: { items: string[]; active: string; pos: number; location: Rect }) => void;
71
+ };
72
+
73
+ // https://www.markdownguide.org/extended-syntax/#footnotes
74
+ // TODO(burdon): Record span in automerge model?
75
+ // TODO(burdon): Extended markdown: https://www.markdownguide.org/extended-syntax
76
+ export const comments = (options: CommentsOptions = {}): Extension => {
77
+ const bookmarkMatcher = new MatchDecorator({
78
+ regexp: /\[\^(\w+)\]/g,
79
+ decoration: (match, view, pos) => {
80
+ const id = match[1];
81
+ return Decoration.replace({
82
+ id,
83
+ widget: new BookmarkWidget(pos, id),
84
+ });
85
+ },
86
+ });
87
+
88
+ const bookmarks = ViewPlugin.fromClass(
89
+ class {
90
+ bookmarks: DecorationSet;
91
+ constructor(view: EditorView) {
92
+ this.bookmarks = bookmarkMatcher.createDeco(view);
93
+ }
94
+
95
+ update(update: ViewUpdate) {
96
+ this.bookmarks = bookmarkMatcher.updateDeco(update, this.bookmarks);
97
+ }
98
+ },
99
+ {
100
+ decorations: (instance) => instance.bookmarks,
101
+ provide: (plugin) =>
102
+ EditorView.atomicRanges.of((view) => {
103
+ return view.plugin(plugin)?.bookmarks || Decoration.none;
104
+ }),
105
+ },
106
+ );
107
+
108
+ return [
109
+ keymap.of([
110
+ {
111
+ key: options?.key ?? 'alt-meta-c',
112
+ run: (view) => {
113
+ // Insert footnote.
114
+ const id = options.onCreate?.();
115
+ if (id) {
116
+ const pos = view.state.selection.main.head;
117
+ const tag = `[^${id}]`;
118
+ view.dispatch({
119
+ changes: { from: pos, insert: tag },
120
+ selection: { anchor: pos + tag.length },
121
+ });
122
+
123
+ return true;
124
+ }
125
+
126
+ return false;
127
+ },
128
+ },
129
+ ]),
130
+
131
+ bookmarks,
132
+
133
+ // Monitor cursor movement.
134
+ EditorView.updateListener.of((update) => {
135
+ const view = update.view;
136
+ const pos = view.state.selection.main.head;
137
+
138
+ const decorations: { from: number; to: number; value: Decoration }[] = [];
139
+ const rangeSet = view.plugin(bookmarks)?.bookmarks;
140
+ rangeSet?.between(pos, pos + 1, (from, to, value) => {
141
+ if (value.spec.widget) {
142
+ decorations.push({ from, to, value });
143
+ }
144
+ });
145
+
146
+ if (decorations.length) {
147
+ const {
148
+ from,
149
+ value: {
150
+ spec: { id },
151
+ },
152
+ } = decorations[0];
153
+ const location = view.coordsAtPos(from);
154
+ if (location) {
155
+ options.onUpdate?.({
156
+ items: decorations.map(
157
+ ({
158
+ value: {
159
+ spec: { id },
160
+ },
161
+ }) => id,
162
+ ),
163
+ active: id,
164
+ pos,
165
+ location,
166
+ });
167
+ }
168
+ }
169
+ }),
170
+
171
+ styles,
172
+ ];
173
+ };
@@ -19,6 +19,7 @@ import {
19
19
  // TODO(burdon): Add-on: dialog.
20
20
  // TODO(burdon): Comments: https://codemirror.net/5/doc/manual.html#setBookmark
21
21
  // TODO(burdon): Split view: https://codemirror.net/examples/split
22
+ // TODO(burdon): https://codemirror.net/5/demo/simplemode.html
22
23
 
23
24
  // https://codemirror.net/examples/autocompletion
24
25
  // https://codemirror.net/docs/ref/#autocomplete.autocompletion
@@ -4,6 +4,7 @@
4
4
 
5
5
  export * from './autocomplete';
6
6
  export * from './basic';
7
+ export * from './comments';
7
8
  export * from './link';
8
9
  export * from './listener';
9
10
  export * from './markdown';
@@ -77,6 +77,11 @@ class LinkText extends WidgetType {
77
77
  }
78
78
  }
79
79
 
80
+ /**
81
+ * Range sets provide a data structure that can hold a collection of tagged,
82
+ * possibly overlapping ranges in such a way that they can efficiently be mapped though document changes.
83
+ * https://codemirror.net/docs/ref/#state
84
+ */
80
85
  const update = (state: EditorState, options: LinkOptions) => {
81
86
  const builder = new RangeSetBuilder();
82
87
  const cursor = state.selection.main.head;
@@ -89,6 +94,9 @@ const update = (state: EditorState, options: LinkOptions) => {
89
94
 
90
95
  const urlNode = node.node.getChild('URL');
91
96
  const url = urlNode ? state.sliceDoc(urlNode.from, urlNode.to) : '';
97
+ if (!url) {
98
+ return false;
99
+ }
92
100
 
93
101
  if (cursor < node.from || cursor > node.to) {
94
102
  builder.add(
@@ -109,9 +117,9 @@ const update = (state: EditorState, options: LinkOptions) => {
109
117
  }),
110
118
  );
111
119
  }
112
- }
113
120
 
114
- return true;
121
+ return false;
122
+ }
115
123
  },
116
124
  });
117
125
 
@@ -9,6 +9,7 @@ export type TextListener = (text: string) => void;
9
9
  /**
10
10
  * Based on https://github.com/codemirror/dev/issues/44#issuecomment-789093799
11
11
  */
12
+ // TODO(burdon): Expose options.
12
13
  export const listener = (onChange: TextListener) =>
13
14
  StateField.define({
14
15
  create: () => null,
@@ -3,17 +3,10 @@
3
3
  //
4
4
 
5
5
  import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
6
- import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
6
+ import { defaultKeymap, history, indentWithTab } from '@codemirror/commands';
7
7
  import { markdownLanguage, markdown } from '@codemirror/lang-markdown';
8
- import {
9
- bracketMatching,
10
- defaultHighlightStyle,
11
- foldKeymap,
12
- indentOnInput,
13
- syntaxHighlighting,
14
- } from '@codemirror/language';
8
+ import { bracketMatching, defaultHighlightStyle, indentOnInput, syntaxHighlighting } from '@codemirror/language';
15
9
  import { languages } from '@codemirror/language-data';
16
- import { lintKeymap } from '@codemirror/lint';
17
10
  import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
18
11
  import { EditorState, type Extension } from '@codemirror/state';
19
12
  import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
@@ -50,7 +43,6 @@ export const markdownBundle = ({ themeMode, placeholder: _placeholder }: Markdow
50
43
  EditorState.allowMultipleSelections.of(true),
51
44
  EditorView.lineWrapping,
52
45
 
53
- // autocompletion(),
54
46
  crosshairCursor(),
55
47
  dropCursor(),
56
48
  drawSelection(),
@@ -62,13 +54,13 @@ export const markdownBundle = ({ themeMode, placeholder: _placeholder }: Markdow
62
54
  indentOnInput(),
63
55
  rectangularSelection(),
64
56
 
57
+ // TODO(burdon): Review.
65
58
  keymap.of([
66
59
  ...closeBracketsKeymap,
67
- // ...completionKeymap,
68
60
  ...defaultKeymap,
69
- ...foldKeymap,
70
- ...historyKeymap,
71
- ...lintKeymap,
61
+ // ...foldKeymap,
62
+ // ...historyKeymap,
63
+ // ...lintKeymap,
72
64
  ...searchKeymap,
73
65
  indentWithTab,
74
66
  ]),
@@ -134,10 +134,11 @@ export const markdownHighlightStyle = HighlightStyle.define(
134
134
  class: codeMark,
135
135
  },
136
136
 
137
- // The `markdown` extension configures extensions for `lezer` to parse markdown tokens (incl. below).
137
+ // NOTE: The `markdown` extension configures extensions for `lezer` to parse markdown tokens (incl. below).
138
138
  // However, since `codeLanguages` is also defined, the `lezer` will not parse fenced code blocks,
139
139
  // when a language is specified. In this case, the syntax highlighting extensions will colorize
140
140
  // the code, but all other CSS properties will be inherited.
141
+ // IMPORTANT: Therefore, the fenced code block will use the base editor font.
141
142
  {
142
143
  tag: [markdownTags.CodeText, markdownTags.InlineCode],
143
144
  class: code,
@@ -2,112 +2,106 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { syntaxTree } from '@codemirror/language';
6
5
  import {
7
- type EditorState,
8
- type Extension,
9
- type RangeSet,
10
- RangeSetBuilder,
11
- StateField,
12
- type Transaction,
13
- } from '@codemirror/state';
14
- import { EditorView, Decoration, WidgetType } from '@codemirror/view';
6
+ EditorView,
7
+ Decoration,
8
+ WidgetType,
9
+ MatchDecorator,
10
+ ViewPlugin,
11
+ type DecorationSet,
12
+ type ViewUpdate,
13
+ } from '@codemirror/view';
15
14
 
16
15
  class CheckboxWidget extends WidgetType {
17
- constructor(private readonly _pos: number, private readonly _getCursor: () => number, private _checked: boolean) {
16
+ constructor(
17
+ private readonly _pos: number,
18
+ private _checked: boolean,
19
+ private readonly _indent: number,
20
+ private readonly _onCheck: (check: boolean) => void,
21
+ ) {
18
22
  super();
19
23
  }
20
24
 
21
25
  override eq(other: CheckboxWidget) {
22
- // Uniqueness based on position; also checks state.
23
- // TODO(burdon): Is this efficient? Check life-cycle.
24
- return this._pos === other._pos && this._checked === other._checked;
26
+ return this._pos === other._pos && this._checked === other._checked && this._indent === other._indent;
25
27
  }
26
28
 
27
29
  toDOM(view: EditorView) {
28
- // console.log('toDom', this._pos);
29
30
  const wrap = document.createElement('span');
30
- wrap.setAttribute('aria-hidden', 'true');
31
31
  wrap.className = 'cm-task-item';
32
+ wrap.setAttribute('aria-hidden', 'true');
33
+ wrap.style.setProperty('margin-left', this._indent * 24 + 'px');
32
34
 
33
- const box = wrap.appendChild(document.createElement('input'));
34
- box.type = 'checkbox';
35
- box.checked = this._checked;
36
- box.onclick = (event) => {
37
- this._checked = !isChecked(view.state, this._pos);
38
- view.dispatch({
39
- changes: {
40
- from: this._pos + 1,
41
- to: this._pos + 2,
42
- insert: this._checked ? 'x' : ' ',
43
- },
44
- // TODO(burdon): Restore cursor position? More useful to move to end of line (can indent).
45
- selection: {
46
- anchor: this._pos + 4,
47
- // anchor: this._getCursor(),
48
- },
49
- });
35
+ const input = wrap.appendChild(document.createElement('input'));
36
+ input.type = 'checkbox';
37
+ input.checked = this._checked;
38
+ input.onchange = (event: Event) => {
39
+ this._onCheck((event.target as any).checked);
50
40
  return true;
51
41
  };
52
42
 
53
43
  return wrap;
54
44
  }
55
45
 
56
- // TODO(burdon): Remove listener?
57
- // override destroy() {
58
- // console.log('destroy', this._pos);
59
- // }
60
-
61
46
  override ignoreEvent() {
62
47
  return false;
63
48
  }
64
49
  }
65
50
 
66
- const isChecked = (state: EditorState, pos: number) => {
67
- return state.sliceDoc(pos, pos + 3).toLowerCase() === '[x]';
68
- };
51
+ // TODO(burdon): Reconcile with theme.
52
+ const styles = EditorView.baseTheme({
53
+ '& .cm-task-item': {
54
+ paddingLeft: '4px',
55
+ paddingRight: '8px',
56
+ },
57
+ });
69
58
 
70
- export const statefield = (): Extension => {
71
- let lastPosition: number = 0;
72
- const listener = EditorView.updateListener.of((update) => {
73
- lastPosition = update.startState.selection.main.head;
59
+ export const tasklist = () => {
60
+ // TODO(burdon): Matcher isn't as precise as syntax tree.
61
+ // Allows for indents to be greater than AST would allow.
62
+ const taskMatcher = new MatchDecorator({
63
+ regexp: /^(\s*)- \[([ xX])\]\s/g,
64
+ decoration: (match, view, pos) => {
65
+ const indent = Math.floor(match[1].length / 2);
66
+ const checked = match[2] === 'x' || match[2] === 'X';
67
+ return Decoration.replace({
68
+ widget: new CheckboxWidget(pos, checked, indent, (checked) => {
69
+ const idx = pos + match[0].indexOf('[') + 1;
70
+ view.dispatch({
71
+ changes: {
72
+ from: idx,
73
+ to: idx + 1,
74
+ insert: checked ? 'x' : ' ',
75
+ },
76
+ // TODO(burdon): Restore cursor position? More useful to move to end of line (can indent).
77
+ selection: {
78
+ anchor: idx + 3,
79
+ },
80
+ });
81
+ }),
82
+ });
83
+ },
74
84
  });
75
85
 
76
- const checkbox = (state: EditorState) => {
77
- const builder = new RangeSetBuilder();
78
- syntaxTree(state).iterate({
79
- enter: (node) => {
80
- // console.log(node.name, node.from, node.to);
81
- if (node.name === 'TaskMarker') {
82
- builder.add(
83
- node.from,
84
- node.to,
85
- Decoration.replace({
86
- widget: new CheckboxWidget(node.from, () => lastPosition, isChecked(state, node.from)),
87
- }),
88
- );
89
- }
90
- },
91
- });
86
+ const tasks = ViewPlugin.fromClass(
87
+ class {
88
+ tasks: DecorationSet;
89
+ constructor(view: EditorView) {
90
+ this.tasks = taskMatcher.createDeco(view);
91
+ }
92
92
 
93
- return builder.finish();
94
- };
93
+ update(update: ViewUpdate) {
94
+ this.tasks = taskMatcher.updateDeco(update, this.tasks);
95
+ }
96
+ },
97
+ {
98
+ decorations: (instance) => instance.tasks,
99
+ provide: (plugin) =>
100
+ EditorView.atomicRanges.of((view) => {
101
+ return view.plugin(plugin)?.tasks || Decoration.none;
102
+ }),
103
+ },
104
+ );
95
105
 
96
- return [
97
- listener,
98
- StateField.define<RangeSet<any>>({
99
- create: (state) => checkbox(state),
100
- update: (_: RangeSet<any>, tr: Transaction) => checkbox(tr.state),
101
- provide: (field) => EditorView.decorations.from(field),
102
- }),
103
- ];
106
+ return [tasks, styles];
104
107
  };
105
-
106
- // TODO(burdon): Reconcile with theme.
107
- export const styles = EditorView.baseTheme({
108
- '& .cm-task-item': {
109
- padding: '0 4px',
110
- },
111
- });
112
-
113
- export const tasklist = () => [statefield(), styles];
@@ -159,6 +159,18 @@ export const textTheme: {
159
159
  '& .cm-scroller': {
160
160
  fontFamily: get(tokens, 'fontFamily.body', []).join(','),
161
161
  },
162
+ '& .cm-placeholder': {
163
+ fontFamily: get(tokens, 'fontFamily.body', []).join(','),
164
+ },
165
+ };
166
+
167
+ export const markdownTheme: {
168
+ [selector: string]: StyleSpec;
169
+ } = {
170
+ // NOTE: Must leave base font family as is (i.e., monospace) due to fenced code blocks.
171
+ '& .cm-placeholder': {
172
+ fontFamily: get(tokens, 'fontFamily.body', []).join(','),
173
+ },
162
174
  };
163
175
 
164
176
  export const codeTheme: {
@@ -167,4 +179,7 @@ export const codeTheme: {
167
179
  '& .cm-scroller': {
168
180
  fontFamily: get(tokens, 'fontFamily.mono', []).join(','),
169
181
  },
182
+ '& .cm-placeholder': {
183
+ fontFamily: get(tokens, 'fontFamily.mono', []).join(','),
184
+ },
170
185
  };
@@ -12,7 +12,8 @@ import React, { useEffect, useRef, useState } from 'react';
12
12
  import { type Prop } from '@dxos/automerge/automerge';
13
13
  import { type DocHandle, Repo } from '@dxos/automerge/automerge-repo';
14
14
 
15
- import { type IDocHandle, automergePlugin } from './automerge-plugin';
15
+ import { type IDocHandle } from './handle';
16
+ import { automergePlugin } from './plugin';
16
17
 
17
18
  type EditorProps = {
18
19
  handle: IDocHandle;
@@ -2,6 +2,5 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export * from './useCollaboration';
6
5
  export * from './useTextModel';
7
6
  export * from './yjs';