@dxos/react-ui-editor 0.3.10 → 0.3.11-main.0e40963

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 (141) hide show
  1. package/dist/lib/browser/index.mjs +1971 -1744
  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 +14 -10
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +3 -2
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/index.d.ts +0 -2
  9. package/dist/types/src/components/TextEditor/index.d.ts.map +1 -1
  10. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -0
  11. package/dist/types/src/extensions/basic.d.ts.map +1 -0
  12. package/dist/types/src/extensions/blast.d.ts.map +1 -0
  13. package/dist/types/src/extensions/code.d.ts.map +1 -0
  14. package/dist/types/src/extensions/comments.d.ts +48 -0
  15. package/dist/types/src/extensions/comments.d.ts.map +1 -0
  16. package/dist/types/src/extensions/hr.d.ts +2 -0
  17. package/dist/types/src/extensions/hr.d.ts.map +1 -0
  18. package/dist/types/src/extensions/image.d.ts.map +1 -0
  19. package/dist/types/src/{components/TextEditor/extensions → extensions}/index.d.ts +3 -3
  20. package/dist/types/src/extensions/index.d.ts.map +1 -0
  21. package/dist/types/src/{components/TextEditor/extensions → extensions}/link.d.ts +1 -0
  22. package/dist/types/src/extensions/link.d.ts.map +1 -0
  23. package/dist/types/src/extensions/listener.d.ts +10 -0
  24. package/dist/types/src/extensions/listener.d.ts.map +1 -0
  25. package/dist/types/src/extensions/markdown/bundle.d.ts.map +1 -0
  26. package/dist/types/src/{components/TextEditor/extensions → extensions}/markdown/highlight.d.ts +0 -1
  27. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -0
  28. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -0
  29. package/dist/types/src/extensions/mention.d.ts.map +1 -0
  30. package/dist/types/src/extensions/mermaid.d.ts +3 -0
  31. package/dist/types/src/extensions/mermaid.d.ts.map +1 -0
  32. package/dist/types/src/extensions/table.d.ts.map +1 -0
  33. package/dist/types/src/extensions/tasklist.d.ts.map +1 -0
  34. package/dist/types/src/{components/TextEditor/extensions/demo.d.ts → extensions/typewriter.d.ts} +2 -3
  35. package/dist/types/src/extensions/typewriter.d.ts.map +1 -0
  36. package/dist/types/src/extensions/util.d.ts +5 -0
  37. package/dist/types/src/extensions/util.d.ts.map +1 -0
  38. package/dist/types/src/hooks/automerge/automerge.stories.d.ts +2 -3
  39. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -1
  40. package/dist/types/src/hooks/automerge/plugin.d.ts.map +1 -1
  41. package/dist/types/src/hooks/automerge/{PatchSemaphore.d.ts → semaphore.d.ts} +1 -1
  42. package/dist/types/src/hooks/automerge/semaphore.d.ts.map +1 -0
  43. package/dist/types/src/hooks/automerge/update-automerge.d.ts +6 -0
  44. package/dist/types/src/hooks/automerge/update-automerge.d.ts.map +1 -0
  45. package/dist/types/src/hooks/automerge/update-codemirror.d.ts +5 -0
  46. package/dist/types/src/hooks/automerge/update-codemirror.d.ts.map +1 -0
  47. package/dist/types/src/hooks/useTextModel.d.ts +14 -6
  48. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  49. package/dist/types/src/hooks/yjs/yjs.test.d.ts +2 -0
  50. package/dist/types/src/hooks/yjs/yjs.test.d.ts.map +1 -0
  51. package/dist/types/src/index.d.ts +3 -0
  52. package/dist/types/src/index.d.ts.map +1 -1
  53. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  54. package/dist/types/src/{components/TextEditor/themes → themes}/default.d.ts +5 -13
  55. package/dist/types/src/themes/default.d.ts.map +1 -0
  56. package/dist/types/src/themes/index.d.ts.map +1 -0
  57. package/package.json +24 -20
  58. package/src/components/TextEditor/MarkdownEditor.stories.tsx +130 -71
  59. package/src/components/TextEditor/TextEditor.stories.tsx +2 -2
  60. package/src/components/TextEditor/TextEditor.tsx +29 -25
  61. package/src/components/TextEditor/index.ts +0 -3
  62. package/src/{components/TextEditor/extensions → extensions}/code.ts +13 -13
  63. package/src/extensions/comments.ts +391 -0
  64. package/src/extensions/hr.ts +74 -0
  65. package/src/{components/TextEditor/extensions → extensions}/image.ts +3 -1
  66. package/src/{components/TextEditor/extensions → extensions}/index.ts +3 -3
  67. package/src/{components/TextEditor/extensions → extensions}/link.ts +66 -10
  68. package/src/extensions/listener.ts +35 -0
  69. package/src/{components/TextEditor/extensions → extensions}/markdown/highlight.ts +6 -9
  70. package/src/extensions/mermaid.ts +11 -0
  71. package/src/{components/TextEditor/extensions → extensions}/table.ts +14 -1
  72. package/src/{components/TextEditor/extensions/demo.ts → extensions/typewriter.ts} +2 -3
  73. package/src/extensions/util.ts +18 -0
  74. package/src/hooks/automerge/automerge.stories.tsx +2 -3
  75. package/src/hooks/automerge/plugin.ts +1 -1
  76. package/src/hooks/automerge/{PatchSemaphore.ts → semaphore.ts} +4 -3
  77. package/src/hooks/automerge/{translation/codemirror-to-automerge.ts → update-automerge.ts} +3 -3
  78. package/src/hooks/automerge/{translation/automerge-to-codemirror.ts → update-codemirror.ts} +2 -7
  79. package/src/hooks/useTextModel.ts +60 -22
  80. package/src/hooks/yjs/yjs.test.ts +59 -0
  81. package/src/index.ts +3 -0
  82. package/src/testing/replicator.ts +0 -1
  83. package/src/{components/TextEditor/themes → themes}/default.ts +61 -38
  84. package/dist/types/src/components/TextEditor/extensions/autocomplete.d.ts.map +0 -1
  85. package/dist/types/src/components/TextEditor/extensions/basic.d.ts.map +0 -1
  86. package/dist/types/src/components/TextEditor/extensions/blast.d.ts.map +0 -1
  87. package/dist/types/src/components/TextEditor/extensions/code.d.ts.map +0 -1
  88. package/dist/types/src/components/TextEditor/extensions/comments.d.ts +0 -18
  89. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +0 -1
  90. package/dist/types/src/components/TextEditor/extensions/demo.d.ts.map +0 -1
  91. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts +0 -10
  92. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts.map +0 -1
  93. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts +0 -24
  94. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts.map +0 -1
  95. package/dist/types/src/components/TextEditor/extensions/image.d.ts.map +0 -1
  96. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +0 -1
  97. package/dist/types/src/components/TextEditor/extensions/link.d.ts.map +0 -1
  98. package/dist/types/src/components/TextEditor/extensions/listener.d.ts +0 -9
  99. package/dist/types/src/components/TextEditor/extensions/listener.d.ts.map +0 -1
  100. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +0 -1
  101. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +0 -1
  102. package/dist/types/src/components/TextEditor/extensions/markdown/index.d.ts.map +0 -1
  103. package/dist/types/src/components/TextEditor/extensions/mention.d.ts.map +0 -1
  104. package/dist/types/src/components/TextEditor/extensions/table.d.ts.map +0 -1
  105. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts.map +0 -1
  106. package/dist/types/src/components/TextEditor/extensions/tooltip.d.ts +0 -12
  107. package/dist/types/src/components/TextEditor/extensions/tooltip.d.ts.map +0 -1
  108. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +0 -1
  109. package/dist/types/src/components/TextEditor/themes/index.d.ts.map +0 -1
  110. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts.map +0 -1
  111. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts +0 -5
  112. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts.map +0 -1
  113. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts +0 -6
  114. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts.map +0 -1
  115. package/dist/types/src/hooks/automerge/translation/index.d.ts +0 -3
  116. package/dist/types/src/hooks/automerge/translation/index.d.ts.map +0 -1
  117. package/src/components/TextEditor/extensions/comments.ts +0 -173
  118. package/src/components/TextEditor/extensions/experimental.tsx +0 -114
  119. package/src/components/TextEditor/extensions/highlight.ts +0 -128
  120. package/src/components/TextEditor/extensions/listener.ts +0 -23
  121. package/src/components/TextEditor/extensions/tooltip.tsx +0 -61
  122. package/src/hooks/automerge/translation/index.ts +0 -6
  123. /package/dist/types/src/{components/TextEditor/extensions → extensions}/autocomplete.d.ts +0 -0
  124. /package/dist/types/src/{components/TextEditor/extensions → extensions}/basic.d.ts +0 -0
  125. /package/dist/types/src/{components/TextEditor/extensions → extensions}/blast.d.ts +0 -0
  126. /package/dist/types/src/{components/TextEditor/extensions → extensions}/code.d.ts +0 -0
  127. /package/dist/types/src/{components/TextEditor/extensions → extensions}/image.d.ts +0 -0
  128. /package/dist/types/src/{components/TextEditor/extensions → extensions}/markdown/bundle.d.ts +0 -0
  129. /package/dist/types/src/{components/TextEditor/extensions → extensions}/markdown/index.d.ts +0 -0
  130. /package/dist/types/src/{components/TextEditor/extensions → extensions}/mention.d.ts +0 -0
  131. /package/dist/types/src/{components/TextEditor/extensions → extensions}/table.d.ts +0 -0
  132. /package/dist/types/src/{components/TextEditor/extensions → extensions}/tasklist.d.ts +0 -0
  133. /package/dist/types/src/{components/TextEditor/themes → themes}/index.d.ts +0 -0
  134. /package/src/{components/TextEditor/extensions → extensions}/autocomplete.ts +0 -0
  135. /package/src/{components/TextEditor/extensions → extensions}/basic.ts +0 -0
  136. /package/src/{components/TextEditor/extensions → extensions}/blast.ts +0 -0
  137. /package/src/{components/TextEditor/extensions → extensions}/markdown/bundle.ts +0 -0
  138. /package/src/{components/TextEditor/extensions → extensions}/markdown/index.ts +0 -0
  139. /package/src/{components/TextEditor/extensions → extensions}/mention.ts +0 -0
  140. /package/src/{components/TextEditor/extensions → extensions}/tasklist.ts +0 -0
  141. /package/src/{components/TextEditor/themes → themes}/index.ts +0 -0
@@ -21,10 +21,10 @@ import { generateName } from '@dxos/display-name';
21
21
  import { useThemeContext } from '@dxos/react-ui';
22
22
  import { getColorForValue, inputSurface, mx } from '@dxos/react-ui-theme';
23
23
 
24
- import { basicBundle, markdownBundle } from './extensions';
25
- import { defaultTheme, markdownTheme, textTheme } from './themes';
26
- import { type EditorModel } from '../../hooks';
24
+ import { basicBundle, markdownBundle, setCommentRange } from '../../extensions';
25
+ import { type CommentRange, type EditorModel } from '../../hooks';
27
26
  import { type ThemeStyles } from '../../styles';
27
+ import { defaultTheme, markdownTheme, textTheme } from '../../themes';
28
28
 
29
29
  export const EditorModes = ['default', 'vim'] as const;
30
30
  export type EditorMode = (typeof EditorModes)[number];
@@ -56,9 +56,10 @@ export type TextEditorSlots = {
56
56
 
57
57
  export type TextEditorProps = {
58
58
  model: EditorModel;
59
- readonly?: boolean;
60
- editorMode?: EditorMode;
59
+ readonly?: boolean; // TODO(burdon): Move into model.
60
+ comments?: CommentRange[]; // TODO(burdon): Move into extension.
61
61
  extensions?: Extension[];
62
+ editorMode?: EditorMode;
62
63
  slots?: TextEditorSlots;
63
64
  };
64
65
 
@@ -67,13 +68,13 @@ export type TextEditorProps = {
67
68
  * NOTE: Rather than adding properties, try to create extensions that can be reused.
68
69
  */
69
70
  export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
70
- ({ model, readonly, editorMode, extensions = [], slots = defaultSlots }, forwardedRef) => {
71
+ ({ model, readonly, comments, extensions = [], editorMode, slots = defaultSlots }, forwardedRef) => {
71
72
  const { themeMode } = useThemeContext();
72
73
  const tabsterDOMAttribute = useFocusableGroup({ tabBehavior: 'limited' });
73
-
74
74
  const [root, setRoot] = useState<HTMLDivElement | null>(null);
75
- const [state, setState] = useState<EditorState>();
76
- const [view, setView] = useState<EditorView>();
75
+ const [{ state = undefined, view = undefined } = {}, setEditor] = useState<
76
+ { state?: EditorState; view?: EditorView } | undefined
77
+ >();
77
78
  useImperativeHandle(forwardedRef, () => ({ root, state, view }), [view, state, root]);
78
79
 
79
80
  // TODO(burdon): Factor out?
@@ -88,30 +89,34 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
88
89
  }
89
90
  }, [awareness, peer, themeMode]);
90
91
 
91
- // TODO(burdon): Get from model.
92
- // useHighlights(view, [
93
- // { id: 'c-1', from: 0, to: 20 },
94
- // { id: 'c-2', from: 100, to: 120 },
95
- // ]);
92
+ // TODO(burdon): Factor out as extension.
93
+ useEffect(() => {
94
+ if (view && comments?.length) {
95
+ view.dispatch({
96
+ effects: setCommentRange.of({ model, comments }),
97
+ });
98
+ }
99
+ }, [view, comments]);
96
100
 
97
101
  useEffect(() => {
98
102
  if (!root) {
99
103
  return;
100
104
  }
101
105
 
106
+ // TODO(burdon): Remember cursor position.
107
+ // https://codemirror.net/docs/ref/#state.EditorStateConfig
102
108
  const state = EditorState.create({
103
109
  doc: model.text(),
104
110
  extensions: [
105
111
  readonly && EditorState.readOnly.of(readonly),
106
112
 
107
- // TODO(burdon): Factor out VIM mode?
113
+ // TODO(burdon): Factor out VIM mode? (manage via MarkdownPlugin).
108
114
  editorMode === 'vim' && vim(),
109
115
 
110
116
  // Theme.
111
- // TODO(burdon): Make optional.
117
+ // TODO(burdon): Make theme configurable.
112
118
  EditorView.baseTheme(defaultTheme),
113
119
  EditorView.theme(slots?.editor?.theme ?? {}),
114
- // TODO(burdon): themeMode doesn't change in storybooks.
115
120
  EditorView.darkTheme.of(themeMode === 'dark'),
116
121
 
117
122
  // Storage and replication.
@@ -124,10 +129,11 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
124
129
 
125
130
  // NOTE: This repaints the editor.
126
131
  // If the new state is derived from the old state, it will likely not be visible other than the cursor resetting.
127
- // Ideally this should not be hit except when changing between text objects.
132
+ // Ideally this should not happen except when changing between text objects.
128
133
  view?.destroy();
129
- setView(
130
- new EditorView({
134
+ setEditor({
135
+ state,
136
+ view: new EditorView({
131
137
  state,
132
138
  parent: root,
133
139
  // NOTE: Uncomment to spy on all transactions.
@@ -136,15 +142,13 @@ export const BaseTextEditor = forwardRef<TextEditorRef, TextEditorProps>(
136
142
  // view.update([transaction]);
137
143
  // },
138
144
  }),
139
- );
140
- setState(state);
145
+ });
141
146
 
142
147
  return () => {
143
148
  view?.destroy();
144
- setView(undefined);
145
- setState(undefined);
149
+ setEditor(undefined);
146
150
  };
147
- }, [root, model, themeMode, readonly, editorMode]);
151
+ }, [root, model, readonly, editorMode, themeMode]);
148
152
 
149
153
  const handleKeyUp = useCallback(
150
154
  (event: KeyboardEvent) => {
@@ -5,7 +5,4 @@
5
5
  export { tags } from '@lezer/highlight';
6
6
  export { type Extension } from '@codemirror/state';
7
7
 
8
- export * from './extensions';
9
- export * from './themes';
10
-
11
8
  export * from './TextEditor';
@@ -15,34 +15,38 @@ import get from 'lodash.get';
15
15
 
16
16
  import { mx } from '@dxos/react-ui-theme';
17
17
 
18
- import { tokens } from '../../../styles';
18
+ import { tokens } from '../styles';
19
19
 
20
20
  const CODE_REGEX = /```[\s\S]*?```/gs;
21
21
 
22
22
  // TODO(burdon): Reconcile with theme.
23
23
  const styles = EditorView.baseTheme({
24
- '& .cm-code-block': {
25
- display: 'block',
26
- paddingInline: '4px !important',
24
+ '& .cm-codeblock': {
25
+ fontFamily: get(tokens, 'fontFamily.mono', []).join(','),
27
26
  },
28
- '&light .cm-code-block': {
27
+ '&light [aria-readonly="true"] .cm-codeblock': {
29
28
  background: get(tokens, 'extend.colors.neutral.50'),
30
29
  },
31
- '&dark .cm-code-block': {
30
+ '&dark [aria-readonly="true"] .cm-codeblock': {
32
31
  background: get(tokens, 'extend.colors.neutral.850'),
33
32
  },
34
- '& .cm-code-block-first': {
33
+ '& [aria-readonly="true"] .cm-codeblock': {
34
+ display: 'block',
35
+ paddingInline: '4px !important',
36
+ },
37
+ '& [aria-readonly="true"] .cm-codeblock-first': {
35
38
  paddingTop: '4px !important',
36
39
  borderTopLeftRadius: '4px',
37
40
  borderTopRightRadius: '4px',
38
41
  },
39
- '& .cm-code-block-last': {
42
+ '& [aria-readonly="true"] .cm-codeblock-last': {
40
43
  paddingBottom: '4px !important',
41
44
  borderBottomLeftRadius: '4px',
42
45
  borderBottomRightRadius: '4px',
43
46
  },
44
47
  });
45
48
 
49
+ // TODO(burdon): Start from EditorView.lineBlockAt(pos).
46
50
  const getLineRange = (lines: BlockInfo[], from: number, to: number) => {
47
51
  const start = lines.findIndex((line) => line.from >= from);
48
52
  const end = lines.findIndex((line) => line.to >= to);
@@ -62,11 +66,7 @@ export const code = () => {
62
66
  const block = blocks[i];
63
67
  decorations.push(
64
68
  Decoration.line({
65
- class: mx(
66
- 'cm-code-block',
67
- i === range[0] && 'cm-code-block-first',
68
- i === range[1] && 'cm-code-block-last',
69
- ),
69
+ class: mx('cm-codeblock', i === range[0] && 'cm-codeblock-first', i === range[1] && 'cm-codeblock-last'),
70
70
  }).range(block.from),
71
71
  );
72
72
  }
@@ -0,0 +1,391 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension, StateEffect, StateField } from '@codemirror/state';
6
+ import {
7
+ hoverTooltip,
8
+ keymap,
9
+ type Command,
10
+ type DecorationSet,
11
+ type ViewUpdate,
12
+ Decoration,
13
+ EditorView,
14
+ MatchDecorator,
15
+ ViewPlugin,
16
+ WidgetType,
17
+ type Rect,
18
+ } from '@codemirror/view';
19
+ import sortBy from 'lodash.sortby';
20
+
21
+ import { debounce } from '@dxos/async';
22
+ import { invariant } from '@dxos/invariant';
23
+ import { log } from '@dxos/log';
24
+ import { nonNullable } from '@dxos/util';
25
+
26
+ import { callbackWrapper } from './util';
27
+ import { type CommentRange, type EditorModel, modelState, type Range } from '../hooks';
28
+
29
+ // TODO(burdon): Handle scroll to.
30
+ // TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
31
+ // TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
32
+
33
+ // TODO(burdon): Reconcile with theme.
34
+ const styles = EditorView.baseTheme({
35
+ '& .cm-bookmark': {
36
+ cursor: 'pointer',
37
+ margin: '4px',
38
+ padding: '4px',
39
+ backgroundColor: 'yellow',
40
+ },
41
+ '& .cm-bookmark-selected': {
42
+ backgroundColor: 'orange',
43
+ },
44
+ '& .cm-comment': {
45
+ backgroundColor: 'yellow',
46
+ },
47
+ '& .cm-comment-active': {
48
+ backgroundColor: 'orange',
49
+ },
50
+ });
51
+
52
+ const marks = {
53
+ highlight: Decoration.mark({ class: 'cm-comment' }),
54
+ highlightActive: Decoration.mark({ class: 'cm-comment-active' }),
55
+ };
56
+
57
+ // TODO(burdon): Should this be part of state?
58
+ type CommentSelected = {
59
+ active?: string;
60
+ closest?: string;
61
+ };
62
+
63
+ // TODO(burdon): Rename.
64
+ type ExtendedCommentRange = Range &
65
+ CommentRange & {
66
+ // TODO(burdon): Not part of state; just required for callback.
67
+ location?: Rect | null;
68
+ };
69
+
70
+ export type CommentsState = CommentSelected & {
71
+ ranges: ExtendedCommentRange[];
72
+ };
73
+
74
+ export const setCommentRange = StateEffect.define<{ model: EditorModel; comments: CommentRange[] }>();
75
+
76
+ const setSelection = StateEffect.define<CommentSelected>();
77
+
78
+ const setCommentState = StateEffect.define<CommentsState>();
79
+
80
+ /**
81
+ * State field (reducer) that tracks comment ranges.
82
+ * The ranges are tracked as codemirror ranges (i.e., not relative YJS/Automerge positions), and
83
+ * therefore must be updated when the document changes.
84
+ */
85
+ const commentsStateField = StateField.define<CommentsState>({
86
+ create: () => ({ ranges: [] }),
87
+ update: (value, tr) => {
88
+ for (const effect of tr.effects) {
89
+ // Update selection.
90
+ if (effect.is(setSelection)) {
91
+ return { ...value, ...effect.value };
92
+ }
93
+
94
+ // Update range from store.
95
+ if (effect.is(setCommentRange)) {
96
+ const { model, comments } = effect.value;
97
+ const ranges: ExtendedCommentRange[] = comments
98
+ .map((comment) => {
99
+ const range = model.getRangeFromCursor!(comment.cursor);
100
+ return range && { ...comment, ...range };
101
+ })
102
+ .filter(nonNullable);
103
+
104
+ return { ...value, ranges };
105
+ }
106
+
107
+ // Update entire state.
108
+ if (effect.is(setCommentState)) {
109
+ return effect.value;
110
+ }
111
+ }
112
+
113
+ return value;
114
+ },
115
+ });
116
+
117
+ /**
118
+ * Decorate ranges.
119
+ */
120
+ const highlightDecorations = EditorView.decorations.compute([commentsStateField], (state) => {
121
+ const { active, ranges } = state.field(commentsStateField);
122
+ const decorations =
123
+ sortBy(ranges ?? [], (range) => range.from)
124
+ ?.flatMap((selection) => {
125
+ // TODO(burdon): Invalid range (e.g., deleted).
126
+ if (selection.from === selection.to) {
127
+ return undefined;
128
+ }
129
+
130
+ const range = { from: selection.from, to: selection.to };
131
+ if (selection.id === active) {
132
+ return marks.highlightActive.range(range.from, range.to);
133
+ } else {
134
+ return marks.highlight.range(range.from, range.to);
135
+ }
136
+ })
137
+ .filter(nonNullable) ?? [];
138
+
139
+ return Decoration.set(decorations);
140
+ });
141
+
142
+ /**
143
+ * Optional bookmark widget at the start/end of the selection.
144
+ * May correspond to a markdown bookmark.
145
+ */
146
+ class BookmarkWidget extends WidgetType {
147
+ constructor(
148
+ private readonly _anchor: number,
149
+ private readonly _id: string,
150
+ private readonly _handleClick?: () => void,
151
+ ) {
152
+ super();
153
+ invariant(this._id);
154
+ }
155
+
156
+ override eq(other: BookmarkWidget) {
157
+ return this._anchor === other._anchor && this._id === other._id;
158
+ }
159
+
160
+ override toDOM() {
161
+ const span = document.createElement('span');
162
+ span.className = 'cm-bookmark';
163
+ span.textContent = '※';
164
+ if (this._handleClick) {
165
+ span.onclick = () => this._handleClick!();
166
+ }
167
+ return span;
168
+ }
169
+ }
170
+
171
+ export type CommentsOptions = {
172
+ key?: string;
173
+ footnote?: boolean;
174
+ /**
175
+ * Called to create a new thread and return the thread id.
176
+ */
177
+ onCreate?: (range: string) => string | undefined;
178
+ /**
179
+ * Called to notify which thread is currently closest to the cursor.
180
+ */
181
+ onSelect?: (state: CommentsState) => void;
182
+ /**
183
+ * Called to render tooltip.
184
+ */
185
+ onHover?: (el: Element) => void;
186
+ };
187
+
188
+ /**
189
+ * Comment threads.
190
+ * 1). Updates the EditorModel to store relative selections for a set of comments threads.
191
+ * Since the selections are relative, they do not need to be updated when the document is edited.
192
+ * 2). Implements a StateField to track absolute selections corresponding to the comments (i.e., when the document is edited).
193
+ * 3). Creates decoration marks to apply classes to each selection.
194
+ * 4). Tracks the current cursor position to:
195
+ * a). Update the decoration to show if the cursor is within a current selection.
196
+ * b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
197
+ * 5). Optionally, inserts a markdown footnote when creating a comment thread.
198
+ * 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
199
+ */
200
+ export const comments = (options: CommentsOptions = {}): Extension => {
201
+ const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
202
+
203
+ /**
204
+ * Create comment thread action.
205
+ */
206
+ const createCommentThread: Command = (view) => {
207
+ invariant(options.onCreate);
208
+ const { head, from, to } = view.state.selection.main;
209
+ if (from === to) {
210
+ return false;
211
+ }
212
+
213
+ const model = view.state.field(modelState);
214
+ const relPos = model?.getCursorFromRange?.({ from, to });
215
+ if (relPos) {
216
+ // Create thread via callback.
217
+ const id = callbackWrapper(options.onCreate)(relPos);
218
+ if (id) {
219
+ // Update range.
220
+ view.dispatch({
221
+ effects: setSelection.of({ active: id }),
222
+ selection: { anchor: from },
223
+ });
224
+
225
+ // Insert footnote.
226
+ if (options.footnote) {
227
+ const tag = `[^${id}]`;
228
+ view.dispatch({
229
+ changes: { from: head, insert: tag },
230
+ selection: { anchor: head + tag.length },
231
+ });
232
+ }
233
+
234
+ return true;
235
+ }
236
+ }
237
+
238
+ return false;
239
+ };
240
+
241
+ /**
242
+ * Bookmark widget decoration.
243
+ */
244
+ const bookmarksViewPlugin = ViewPlugin.fromClass(
245
+ class BookmarkViewPlugin {
246
+ // Match markdown footnotes (option).
247
+ // https://www.markdownguide.org/extended-syntax/#footnotes
248
+ static bookmarkMatcher = new MatchDecorator({
249
+ regexp: /\[\^(\w+)\]/g,
250
+ decoration: (match, view, pos) => {
251
+ const id = match[1];
252
+ return Decoration.replace({
253
+ id,
254
+ widget: new BookmarkWidget(pos, id),
255
+ });
256
+ },
257
+ });
258
+
259
+ bookmarks: DecorationSet;
260
+ constructor(view: EditorView) {
261
+ this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.createDeco(view);
262
+ }
263
+
264
+ update(update: ViewUpdate) {
265
+ this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.updateDeco(update, this.bookmarks);
266
+ }
267
+ },
268
+ {
269
+ decorations: (instance) => instance.bookmarks,
270
+ provide: (plugin) =>
271
+ EditorView.atomicRanges.of((view) => {
272
+ return view.plugin(plugin)?.bookmarks || Decoration.none;
273
+ }),
274
+ },
275
+ );
276
+
277
+ return [
278
+ bookmarksViewPlugin,
279
+ commentsStateField,
280
+ highlightDecorations,
281
+ styles,
282
+
283
+ //
284
+ // Keymap.
285
+ //
286
+ options.onCreate
287
+ ? keymap.of([
288
+ {
289
+ key: options?.key ?? "meta-'",
290
+ run: callbackWrapper(createCommentThread),
291
+ },
292
+ ])
293
+ : [],
294
+
295
+ //
296
+ // Hover tooltip (for key shortcut hints, etc.)
297
+ // TODO(burdon): Factor out to generic hints extension for current selection/line.
298
+ //
299
+ options.onHover
300
+ ? hoverTooltip(
301
+ (view, pos) => {
302
+ const selection = view.state.selection.main;
303
+ if (selection && pos >= selection.from && pos <= selection.to) {
304
+ return {
305
+ pos: selection.from,
306
+ end: selection.to,
307
+ above: true,
308
+ create: () => {
309
+ // TODO(burdon): Dispatch to react callback to render (or use SSR)?
310
+ const el = document.createElement('div');
311
+ options.onHover?.(el);
312
+ return { dom: el, offset: { x: 0, y: 8 } };
313
+ },
314
+ };
315
+ }
316
+
317
+ return null;
318
+ },
319
+ { hideOnChange: true, hoverTime: 1000 },
320
+ )
321
+ : [],
322
+
323
+ //
324
+ // Monitor cursor movement and text updates.
325
+ // TODO(burdon): Is there a better (finer grained) way to do this?
326
+ //
327
+ EditorView.updateListener.of(({ view, state, startState, changes }) => {
328
+ //
329
+ // Test if need to recompute indexed range if document changes before the end of the range.
330
+ //
331
+ {
332
+ let mod = false;
333
+ const model = state.field(modelState);
334
+ const { active, ranges } = state.field(commentsStateField);
335
+ changes.iterChanges((from, to, from2, to2) => {
336
+ invariant(model);
337
+ ranges.forEach((range) => {
338
+ if (from2 === to2) {
339
+ const newRange = model.getRangeFromCursor!(range.cursor);
340
+ if (!newRange || newRange.to - newRange.from === 0) {
341
+ // TODO(burdon): Delete range if empty.
342
+ log.info('deleted comment', { thread: range.id });
343
+ }
344
+ }
345
+
346
+ if (from <= range.to) {
347
+ const newRange = model.getRangeFromCursor!(range.cursor);
348
+ Object.assign(range, newRange);
349
+ mod = true;
350
+ }
351
+ });
352
+ });
353
+
354
+ if (mod) {
355
+ view.dispatch({ effects: setCommentState.of({ active, ranges }) });
356
+ }
357
+ }
358
+
359
+ //
360
+ // Track the current selection.
361
+ //
362
+ {
363
+ const { head } = state.selection.main;
364
+
365
+ let min = Infinity;
366
+ const { active, closest, ranges } = state.field(commentsStateField);
367
+ const selected: CommentSelected = { active: undefined, closest: undefined };
368
+ ranges.forEach((comment) => {
369
+ const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
370
+ if (head >= comment.from && head <= comment.to) {
371
+ selected.active = comment.id;
372
+ }
373
+ if (d < min) {
374
+ selected.closest = comment.id;
375
+ min = d;
376
+ }
377
+ });
378
+
379
+ if (selected.active !== active || selected.closest !== closest) {
380
+ view.dispatch({ effects: setSelection.of(selected) });
381
+
382
+ // Update callback.
383
+ handleSelect({
384
+ ...selected,
385
+ ranges: ranges.map((range) => ({ ...range, location: view.coordsAtPos(range.from) })),
386
+ });
387
+ }
388
+ }
389
+ }),
390
+ ];
391
+ };
@@ -0,0 +1,74 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import {
6
+ Decoration,
7
+ type DecorationSet,
8
+ EditorView,
9
+ MatchDecorator,
10
+ ViewPlugin,
11
+ type ViewUpdate,
12
+ WidgetType,
13
+ } from '@codemirror/view';
14
+ import get from 'lodash.get';
15
+
16
+ import { tokens } from '../styles';
17
+
18
+ // TODO(burdon): Reconcile with theme.
19
+ const styles = EditorView.baseTheme({
20
+ '& .cm-hr': {
21
+ // TODO(burdon): ???
22
+ // Note that block-level decorations should not have vertical margins,
23
+ borderBottom: `1px solid ${get(tokens, 'extend.colors.neutral.200')}`,
24
+ },
25
+ });
26
+
27
+ class HorizontalRuleWidget extends WidgetType {
28
+ constructor(readonly _pos: number) {
29
+ super();
30
+ }
31
+
32
+ override eq(other: WidgetType) {
33
+ return this._pos === (other as any as HorizontalRuleWidget)._pos;
34
+ }
35
+
36
+ toDOM(view: EditorView) {
37
+ // TODO(burdon): Create <hr> element? Does this clash with markdown parser?
38
+ const el = document.createElement('div');
39
+ el.className = 'cm-hr';
40
+ return el;
41
+ }
42
+ }
43
+
44
+ const placeholderMatcher = new MatchDecorator({
45
+ regexp: /^---$/g,
46
+ decoration: (match, view, pos) =>
47
+ Decoration.replace({
48
+ widget: new HorizontalRuleWidget(pos),
49
+ }),
50
+ });
51
+
52
+ export const hr = () => [
53
+ styles,
54
+ ViewPlugin.fromClass(
55
+ class {
56
+ rules: DecorationSet;
57
+ constructor(view: EditorView) {
58
+ this.rules = placeholderMatcher.createDeco(view);
59
+ }
60
+
61
+ update(update: ViewUpdate) {
62
+ this.rules = placeholderMatcher.updateDeco(update, this.rules);
63
+ }
64
+ },
65
+ {
66
+ decorations: (instance) => instance.rules,
67
+ // TODO(burdon): Is this really atomic (cursor can move into ---).
68
+ provide: (plugin) =>
69
+ EditorView.atomicRanges.of((view) => {
70
+ return view.plugin(plugin)?.rules || Decoration.none;
71
+ }),
72
+ },
73
+ ),
74
+ ];
@@ -38,6 +38,7 @@ const update = (state: EditorState, options: ImageOptions) => {
38
38
  hide ? node.from : node.to,
39
39
  node.to,
40
40
  Decoration.replace({
41
+ block: true, // Prevent cursor from entering.
41
42
  widget: new ImageWidget(url),
42
43
  }),
43
44
  );
@@ -54,8 +55,9 @@ class ImageWidget extends WidgetType {
54
55
  super();
55
56
  }
56
57
 
58
+ // TODO(burdon): Does this need to be unique for each position?
57
59
  override eq(other: WidgetType) {
58
- return this._url === (other as any as ImageWidget)?._url;
60
+ return this._url === (other as any as ImageWidget)._url;
59
61
  }
60
62
 
61
63
  toDOM(view: EditorView) {
@@ -6,14 +6,14 @@ export * from './autocomplete';
6
6
  export * from './basic';
7
7
  export * from './blast';
8
8
  export * from './code';
9
- export * from './demo';
10
9
  export * from './comments';
11
- export * from './highlight';
10
+ export * from './hr';
12
11
  export * from './image';
13
12
  export * from './link';
14
13
  export * from './listener';
15
14
  export * from './markdown';
16
15
  export * from './mention';
16
+ export * from './mermaid';
17
17
  export * from './table';
18
18
  export * from './tasklist';
19
- export * from './tooltip';
19
+ export * from './typewriter';