@dxos/react-ui-editor 0.3.10-next.e7dbe51 → 0.3.10

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 (101) hide show
  1. package/dist/lib/browser/index.mjs +1270 -372
  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 +23 -2
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +2 -1
  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 -0
  9. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/extensions/autocomplete.d.ts.map +1 -1
  11. package/dist/types/src/components/TextEditor/extensions/basic.d.ts +2 -1
  12. package/dist/types/src/components/TextEditor/extensions/basic.d.ts.map +1 -1
  13. package/dist/types/src/components/TextEditor/extensions/blast.d.ts +25 -0
  14. package/dist/types/src/components/TextEditor/extensions/blast.d.ts.map +1 -0
  15. package/dist/types/src/components/TextEditor/extensions/code.d.ts +2 -0
  16. package/dist/types/src/components/TextEditor/extensions/code.d.ts.map +1 -0
  17. package/dist/types/src/components/TextEditor/extensions/comments.d.ts +11 -7
  18. package/dist/types/src/components/TextEditor/extensions/comments.d.ts.map +1 -1
  19. package/dist/types/src/components/TextEditor/extensions/demo.d.ts +11 -0
  20. package/dist/types/src/components/TextEditor/extensions/demo.d.ts.map +1 -0
  21. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts +1 -1
  22. package/dist/types/src/components/TextEditor/extensions/experimental.d.ts.map +1 -1
  23. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts +24 -0
  24. package/dist/types/src/components/TextEditor/extensions/highlight.d.ts.map +1 -0
  25. package/dist/types/src/components/TextEditor/extensions/image.d.ts +4 -0
  26. package/dist/types/src/components/TextEditor/extensions/image.d.ts.map +1 -0
  27. package/dist/types/src/components/TextEditor/extensions/index.d.ts +7 -0
  28. package/dist/types/src/components/TextEditor/extensions/index.d.ts.map +1 -1
  29. package/dist/types/src/components/TextEditor/extensions/link.d.ts.map +1 -1
  30. package/dist/types/src/components/TextEditor/extensions/listener.d.ts +4 -2
  31. package/dist/types/src/components/TextEditor/extensions/listener.d.ts.map +1 -1
  32. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts +10 -1
  33. package/dist/types/src/components/TextEditor/extensions/markdown/bundle.d.ts.map +1 -1
  34. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts +9 -3
  35. package/dist/types/src/components/TextEditor/extensions/markdown/highlight.d.ts.map +1 -1
  36. package/dist/types/src/components/TextEditor/extensions/mention.d.ts +6 -0
  37. package/dist/types/src/components/TextEditor/extensions/mention.d.ts.map +1 -0
  38. package/dist/types/src/components/TextEditor/extensions/table.d.ts +8 -0
  39. package/dist/types/src/components/TextEditor/extensions/table.d.ts.map +1 -0
  40. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts +2 -1
  41. package/dist/types/src/components/TextEditor/extensions/tasklist.d.ts.map +1 -1
  42. package/dist/types/src/components/TextEditor/themes/default.d.ts +5 -0
  43. package/dist/types/src/components/TextEditor/themes/default.d.ts.map +1 -1
  44. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts +3 -0
  45. package/dist/types/src/hooks/automerge/PatchSemaphore.d.ts.map +1 -1
  46. package/dist/types/src/hooks/automerge/automerge.stories.d.ts +8 -1
  47. package/dist/types/src/hooks/automerge/automerge.stories.d.ts.map +1 -1
  48. package/dist/types/src/hooks/automerge/index.d.ts +1 -1
  49. package/dist/types/src/hooks/automerge/index.d.ts.map +1 -1
  50. package/dist/types/src/hooks/automerge/plugin.d.ts.map +1 -1
  51. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts +5 -0
  52. package/dist/types/src/hooks/automerge/translation/automerge-to-codemirror.d.ts.map +1 -0
  53. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts +6 -0
  54. package/dist/types/src/hooks/automerge/translation/codemirror-to-automerge.d.ts.map +1 -0
  55. package/dist/types/src/hooks/automerge/translation/index.d.ts +3 -0
  56. package/dist/types/src/hooks/automerge/translation/index.d.ts.map +1 -0
  57. package/dist/types/src/hooks/useTextModel.d.ts +6 -0
  58. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  59. package/dist/types/src/{components/TextEditor → hooks/yjs}/yjs.stories.d.ts +2 -2
  60. package/dist/types/src/hooks/yjs/yjs.stories.d.ts.map +1 -0
  61. package/dist/types/src/styles/markdown.d.ts +3 -2
  62. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  63. package/dist/types/src/testing/replicator.d.ts.map +1 -1
  64. package/package.json +23 -20
  65. package/src/components/TextEditor/MarkdownEditor.stories.tsx +128 -34
  66. package/src/components/TextEditor/TextEditor.stories.tsx +12 -1
  67. package/src/components/TextEditor/TextEditor.tsx +30 -9
  68. package/src/components/TextEditor/extensions/autocomplete.ts +5 -20
  69. package/src/components/TextEditor/extensions/basic.ts +6 -4
  70. package/src/components/TextEditor/extensions/blast.ts +373 -0
  71. package/src/components/TextEditor/extensions/code.ts +99 -0
  72. package/src/components/TextEditor/extensions/comments.ts +67 -67
  73. package/src/components/TextEditor/extensions/demo.ts +69 -0
  74. package/src/components/TextEditor/extensions/experimental.tsx +15 -36
  75. package/src/components/TextEditor/extensions/highlight.ts +128 -0
  76. package/src/components/TextEditor/extensions/image.ts +67 -0
  77. package/src/components/TextEditor/extensions/index.ts +7 -0
  78. package/src/components/TextEditor/extensions/link.ts +17 -25
  79. package/src/components/TextEditor/extensions/listener.ts +4 -4
  80. package/src/components/TextEditor/extensions/markdown/bundle.ts +45 -34
  81. package/src/components/TextEditor/extensions/markdown/highlight.ts +148 -108
  82. package/src/components/TextEditor/extensions/mention.ts +31 -0
  83. package/src/components/TextEditor/extensions/table.ts +119 -0
  84. package/src/components/TextEditor/extensions/tasklist.ts +42 -28
  85. package/src/components/TextEditor/themes/default.ts +98 -4
  86. package/src/hooks/automerge/PatchSemaphore.ts +12 -9
  87. package/src/hooks/automerge/automerge.stories.tsx +4 -2
  88. package/src/hooks/automerge/index.ts +1 -1
  89. package/src/hooks/automerge/plugin.ts +1 -0
  90. package/src/hooks/automerge/{amToCodemirror.ts → translation/automerge-to-codemirror.ts} +10 -2
  91. package/src/hooks/automerge/{codeMirrorToAm.ts → translation/codemirror-to-automerge.ts} +7 -4
  92. package/src/hooks/automerge/translation/index.ts +6 -0
  93. package/src/hooks/useTextModel.ts +9 -0
  94. package/src/{components/TextEditor → hooks/yjs}/yjs.stories.tsx +2 -2
  95. package/src/styles/markdown.ts +15 -8
  96. package/src/testing/replicator.ts +1 -0
  97. package/dist/types/src/components/TextEditor/yjs.stories.d.ts.map +0 -1
  98. package/dist/types/src/hooks/automerge/amToCodemirror.d.ts +0 -6
  99. package/dist/types/src/hooks/automerge/amToCodemirror.d.ts.map +0 -1
  100. package/dist/types/src/hooks/automerge/codeMirrorToAm.d.ts +0 -7
  101. package/dist/types/src/hooks/automerge/codeMirrorToAm.d.ts.map +0 -1
@@ -4,38 +4,35 @@
4
4
 
5
5
  import { type Extension } from '@codemirror/state';
6
6
  import {
7
- Decoration,
7
+ keymap,
8
8
  type DecorationSet,
9
+ type Rect,
10
+ type ViewUpdate,
11
+ Decoration,
9
12
  EditorView,
10
- keymap,
11
13
  MatchDecorator,
12
- type Rect,
13
14
  ViewPlugin,
14
- type ViewUpdate,
15
15
  WidgetType,
16
16
  } from '@codemirror/view';
17
17
 
18
+ import { debounce } from '@dxos/async';
18
19
  import { invariant } from '@dxos/invariant';
19
20
 
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
21
+ // TODO(burdon): Reconcile with theme.
22
+ const styles = EditorView.baseTheme({
23
+ '& .cm-bookmark': {
24
+ cursor: 'pointer',
25
+ margin: '4px',
26
+ padding: '4px',
27
+ backgroundColor: 'yellow',
28
+ },
29
+ '& .cm-bookmark-selected': {
30
+ backgroundColor: 'orange',
31
+ },
32
+ });
36
33
 
37
34
  class BookmarkWidget extends WidgetType {
38
- constructor(private readonly _pos: number, private readonly _id: string) {
35
+ constructor(private readonly _pos: number, private readonly _id: string, private readonly _handleClick: () => void) {
39
36
  super();
40
37
  invariant(this._id);
41
38
  }
@@ -44,43 +41,39 @@ class BookmarkWidget extends WidgetType {
44
41
  return this._id === other._id;
45
42
  }
46
43
 
47
- // TODO(burdon): Click to select.
48
44
  override toDOM() {
49
45
  const span = document.createElement('span');
50
46
  span.className = 'cm-bookmark';
51
- // https://emojifinder.com/comment
52
- span.textContent = '💬';
47
+ span.textContent = '§';
48
+ span.onclick = () => this._handleClick();
53
49
  return span;
54
50
  }
55
51
  }
56
52
 
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
- });
53
+ type CommentsInfo = {
54
+ active?: string;
55
+ items?: { id: string; pos: number; location: Rect | null }[];
56
+ };
66
57
 
67
58
  export type CommentsOptions = {
68
59
  key?: string;
69
- onCreate?: () => string | void;
70
- onUpdate?: (info: { items: string[]; active: string; pos: number; location: Rect }) => void;
60
+ onCreate?: (from: number, to: number) => string | void;
61
+ onUpdate?: (info: CommentsInfo) => void;
71
62
  };
72
63
 
73
64
  // https://www.markdownguide.org/extended-syntax/#footnotes
74
65
  // TODO(burdon): Record span in automerge model?
75
66
  // TODO(burdon): Extended markdown: https://www.markdownguide.org/extended-syntax
76
67
  export const comments = (options: CommentsOptions = {}): Extension => {
68
+ let active: string | undefined;
69
+
77
70
  const bookmarkMatcher = new MatchDecorator({
78
71
  regexp: /\[\^(\w+)\]/g,
79
72
  decoration: (match, view, pos) => {
80
73
  const id = match[1];
81
74
  return Decoration.replace({
82
75
  id,
83
- widget: new BookmarkWidget(pos, id),
76
+ widget: new BookmarkWidget(pos, id, () => handleUpdate({ active: id })),
84
77
  });
85
78
  },
86
79
  });
@@ -105,19 +98,24 @@ export const comments = (options: CommentsOptions = {}): Extension => {
105
98
  },
106
99
  );
107
100
 
101
+ const handleUpdate = debounce((data: CommentsInfo) => {
102
+ options.onUpdate?.(data);
103
+ }, 200);
104
+
108
105
  return [
109
106
  keymap.of([
110
107
  {
111
- key: options?.key ?? 'alt-meta-c',
108
+ key: options?.key ?? 'shift-meta-c',
112
109
  run: (view) => {
113
110
  // Insert footnote.
114
- const id = options.onCreate?.();
111
+ const { from, to, head } = view.state.selection.main;
112
+ const id = options.onCreate?.(from, to);
115
113
  if (id) {
116
- const pos = view.state.selection.main.head;
117
114
  const tag = `[^${id}]`;
115
+ // TODO(burdon): Add selection.
118
116
  view.dispatch({
119
- changes: { from: pos, insert: tag },
120
- selection: { anchor: pos + tag.length },
117
+ changes: { from: head, insert: tag },
118
+ selection: { anchor: head + tag.length },
121
119
  });
122
120
 
123
121
  return true;
@@ -132,39 +130,41 @@ export const comments = (options: CommentsOptions = {}): Extension => {
132
130
 
133
131
  // Monitor cursor movement.
134
132
  EditorView.updateListener.of((update) => {
135
- const view = update.view;
136
- const pos = view.state.selection.main.head;
133
+ active = undefined;
134
+ if (!options.onUpdate) {
135
+ return;
136
+ }
137
+
138
+ const { view, state } = update;
139
+ const pos = state.selection.main.head;
137
140
 
138
141
  const decorations: { from: number; to: number; value: Decoration }[] = [];
139
142
  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
+ let closest = Infinity;
144
+ // TODO(burdon): Handle multiple visible ranges in large documents.
145
+ const { from, to } = /* view.visibleRanges?.[0] ?? */ { from: 0, to: state.doc.length };
146
+ rangeSet?.between(from, to, (from, to, value) => {
147
+ decorations.push({ from, to, value });
148
+ const d = Math.min(Math.abs(pos - from), Math.abs(pos - to));
149
+ if (d < closest) {
150
+ // TODO(burdon): Update class of selected.
151
+ active = value.spec.id;
152
+ closest = d;
143
153
  }
144
154
  });
145
155
 
146
156
  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
- }
157
+ handleUpdate({
158
+ active,
159
+ items: decorations.map(
160
+ ({
161
+ from,
162
+ value: {
163
+ spec: { id },
164
+ },
165
+ }) => ({ id: id as string, pos: from, location: view.coordsAtPos(from) }),
166
+ ),
167
+ });
168
168
  }
169
169
  }),
170
170
 
@@ -0,0 +1,69 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { keymap } from '@codemirror/view';
7
+
8
+ // TODO(burdon): Review https://github.com/sergeche/codemirror-movie?tab=readme-ov-file
9
+
10
+ export type DemoOptions = {
11
+ delay?: number;
12
+ items?: string[];
13
+ };
14
+
15
+ const defaultItems = ['hello world!', 'this is a test.', 'this is [DXOS](https://dxos.org)'];
16
+
17
+ /**
18
+ * Demo script.
19
+ * Configurable plugin that let's user cycle through pre-configured input script.
20
+ */
21
+ export const demo = ({ delay = 75, items = defaultItems }: DemoOptions = {}): Extension => {
22
+ let t: any;
23
+ let idx = 0;
24
+
25
+ return [
26
+ keymap.of([
27
+ {
28
+ // Reset.
29
+ key: "alt-meta-'",
30
+ run: (view) => {
31
+ clearTimeout(t);
32
+ idx = 0;
33
+ return true;
34
+ },
35
+ },
36
+ {
37
+ // Next prompt.
38
+ // TODO(burdon): Press 1-9 to select prompt?
39
+ key: "shift-meta-'",
40
+ run: (view) => {
41
+ clearTimeout(t);
42
+ // TODO(burdon): Add space if needed.
43
+ const text = items[idx++];
44
+ if (idx === items?.length) {
45
+ idx = 0;
46
+ }
47
+
48
+ let i = 0;
49
+ const insert = (d = 0) => {
50
+ t = setTimeout(() => {
51
+ const pos = view.state.selection.main.head;
52
+ view.dispatch({
53
+ changes: { from: pos, insert: text[i++] },
54
+ selection: { anchor: pos + 1 },
55
+ });
56
+
57
+ if (i < text.length) {
58
+ insert(Math.random() * delay * (text[i] === ' ' ? 2 : 1));
59
+ }
60
+ }, d);
61
+ };
62
+
63
+ insert();
64
+ return true;
65
+ },
66
+ },
67
+ ]),
68
+ ];
69
+ };
@@ -13,28 +13,21 @@ import {
13
13
  type ViewUpdate,
14
14
  } from '@codemirror/view';
15
15
 
16
- // TODO(burdon): Modes: parallel parsing and decoration (e.g., associated with language).
17
- // TODO(burdon): Add-on: runmode: run lexer over content (with rendering codemirror).
18
- // https://codemirror.net/5/doc/manual.html#addon_runmode
19
- // TODO(burdon): Add-on: dialog.
20
- // TODO(burdon): Comments: https://codemirror.net/5/doc/manual.html#setBookmark
21
- // TODO(burdon): Split view: https://codemirror.net/examples/split
22
- // TODO(burdon): https://codemirror.net/5/demo/simplemode.html
23
-
24
- // https://codemirror.net/examples/autocompletion
25
- // https://codemirror.net/docs/ref/#autocomplete.autocompletion
26
- // https://codemirror.net/docs/ref/#autocomplete.Completion
27
-
28
- // TODO(burdon): Hint to customize?
29
- // https://codemirror.net/examples/autocompletion
30
-
31
- // Notes: Can't edit widget content (or cursor nav through them).
32
- // - https://discuss.codemirror.net/t/focusing-inputs-within-widgets/5178/7
33
- // Widgets intentionally always get set to contenteditable=false,
34
- // or they would become part of CodeMirror’s editable content element.
35
- // You should be able to introduce new contenteditable=true child elements inside of them.
16
+ // TODO(burdon): Reconcile with theme.
17
+ export const styles = EditorView.baseTheme({
18
+ '.cm-hyperlink-label': {
19
+ cursor: 'pointer',
20
+ textDecoration: 'underline',
21
+ },
22
+ '.cm-hyperlink-bracket': {
23
+ opacity: 0.2,
24
+ },
25
+ '.cm-hyperlink-url': {
26
+ opacity: 0.5,
27
+ fontFamily: 'monospace',
28
+ },
29
+ });
36
30
 
37
- // TODO(burdon): URL match?
38
31
  const markdownLinkRegexp = /\[([^\]]+)]\(([^)]+)\)(!?)/gi;
39
32
 
40
33
  /**
@@ -99,7 +92,7 @@ export function hyperLinkExtension() {
99
92
  }
100
93
  },
101
94
  {
102
- decorations: (view) => view.decorations,
95
+ decorations: (value) => value.decorations,
103
96
  eventHandlers: {
104
97
  // TODO(burdon): CLick to expand link.
105
98
  // https://discuss.codemirror.net/t/decorator-iteration-on-click-event/4226/2
@@ -118,18 +111,4 @@ export function hyperLinkExtension() {
118
111
  );
119
112
  }
120
113
 
121
- export const styles = EditorView.baseTheme({
122
- '.cm-hyperlink-label': {
123
- cursor: 'pointer',
124
- textDecoration: 'underline',
125
- },
126
- '.cm-hyperlink-bracket': {
127
- opacity: 0.2,
128
- },
129
- '.cm-hyperlink-url': {
130
- opacity: 0.5,
131
- fontFamily: 'monospace',
132
- },
133
- });
134
-
135
114
  export const hyperlinkWidget: Extension = [hyperLinkExtension(), styles];
@@ -0,0 +1,128 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Extension, StateEffect, StateField } from '@codemirror/state';
6
+ import { Decoration, EditorView } from '@codemirror/view';
7
+ import sortBy from 'lodash.sortby';
8
+ import { useEffect } from 'react';
9
+
10
+ import { type DocumentRange } from '../../../hooks';
11
+
12
+ // TODO(burdon): Detect click, or cursor proximity.
13
+
14
+ // TODO(burdon): Reconcile with theme.
15
+ const styles = EditorView.baseTheme({
16
+ '& .cm-highlight': {
17
+ backgroundColor: 'yellow', // TODO(burdon): Reconcile with tailwind theme.
18
+ },
19
+ '& .cm-highlight::before': {
20
+ content: ']',
21
+ },
22
+ '& .cm-highlight-active': {
23
+ backgroundColor: 'lime',
24
+ },
25
+ });
26
+
27
+ const marks = {
28
+ highlight: Decoration.mark({ class: 'cm-highlight' }),
29
+ HighlightActive: Decoration.mark({ class: 'cm-highlight-active' }),
30
+ };
31
+
32
+ type HighlightRange = DocumentRange & { active?: boolean };
33
+
34
+ // TODO(burdon): Make configurable to support multiple layers.
35
+ const setHighlights = StateEffect.define<HighlightRange[]>();
36
+
37
+ /**
38
+ * Update field set.
39
+ */
40
+ // TODO(burdon): Reconcile range from data model, vs. in-markdown characters.
41
+ export const useHighlights = (view: EditorView | undefined, ranges: HighlightRange[] = []) => {
42
+ useEffect(() => {
43
+ view?.dispatch({
44
+ effects: setHighlights.of(ranges),
45
+ });
46
+ }, [view, ranges]);
47
+ };
48
+
49
+ /**
50
+ * State field that tracks highlight ranges.
51
+ */
52
+ export const highlightStateField = StateField.define<HighlightRange[]>({
53
+ create: () => [],
54
+ update: (threads, tr) => {
55
+ for (const effect of tr.effects) {
56
+ if (effect.is(setHighlights)) {
57
+ return effect.value;
58
+ }
59
+ }
60
+
61
+ return threads;
62
+ },
63
+ });
64
+
65
+ /**
66
+ * Decorate ranges.
67
+ */
68
+ // TODO(burdon): Pass in field to make reusable (e.g., comments, search).
69
+ export const highlightDecorations = EditorView.decorations.compute([highlightStateField], (state) => {
70
+ const selectionRanges = state.field(highlightStateField);
71
+ const decorations =
72
+ sortBy(selectionRanges ?? [], (selection) => selection.from)?.flatMap((selection) => {
73
+ const range = { from: selection.from, to: selection.to + 1 };
74
+ if (selection.to > selection.from) {
75
+ if (selection.active) {
76
+ return marks.HighlightActive.range(range.from, range.to);
77
+ } else {
78
+ return marks.highlight.range(range.from, range.to);
79
+ }
80
+ } else {
81
+ return [];
82
+ }
83
+ }) ?? [];
84
+
85
+ return Decoration.set(decorations);
86
+ });
87
+
88
+ // TODO(burdon): Use current range to create comment.
89
+ // TODO(burdon): Allow/prevent overlapping?
90
+
91
+ /**
92
+ * Track cursor entering and existing ranges.
93
+ */
94
+ const listener = (options: HighlightOptions) => {
95
+ return EditorView.updateListener.of((update) => {
96
+ const { view, state } = update;
97
+ const { /* from, to, */ head } = state.selection.main;
98
+ const ranges = state.field(highlightStateField);
99
+
100
+ // TODO(burdon): Modify range if editing.
101
+ // console.log('::::', update);
102
+
103
+ // Set active.
104
+ let mod = false;
105
+ const newRanges = ranges.map((range) => {
106
+ const active = head >= range.from && head <= range.to;
107
+ if (active !== range.active) {
108
+ mod = true;
109
+ return { ...range, active };
110
+ } else {
111
+ return range;
112
+ }
113
+ });
114
+
115
+ if (mod) {
116
+ view.dispatch({ effects: setHighlights.of(newRanges) });
117
+ options?.onChange?.(newRanges.find((range) => range.active)?.id);
118
+ }
119
+ });
120
+ };
121
+
122
+ export type HighlightOptions = {
123
+ onChange?: (active?: string) => {};
124
+ };
125
+
126
+ export const highlight = (options: HighlightOptions = {}): Extension => {
127
+ return [highlightStateField, highlightDecorations, listener(options), styles];
128
+ };
@@ -0,0 +1,67 @@
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, EditorView, WidgetType } from '@codemirror/view';
15
+
16
+ export type ImageOptions = {};
17
+
18
+ export const image = (options: ImageOptions = {}): Extension => {
19
+ return StateField.define<RangeSet<any>>({
20
+ create: (state) => update(state, options),
21
+ update: (_: RangeSet<any>, tr: Transaction) => update(tr.state, options),
22
+ provide: (field) => EditorView.decorations.from(field),
23
+ });
24
+ };
25
+
26
+ const update = (state: EditorState, options: ImageOptions) => {
27
+ const builder = new RangeSetBuilder();
28
+ const cursor = state.selection.main.head;
29
+
30
+ syntaxTree(state).iterate({
31
+ enter: (node) => {
32
+ if (node.name === 'Image') {
33
+ const urlNode = node.node.getChild('URL');
34
+ if (urlNode) {
35
+ const hide = state.readOnly || cursor < node.from || cursor > node.to;
36
+ const url = state.sliceDoc(urlNode.from, urlNode.to);
37
+ builder.add(
38
+ hide ? node.from : node.to,
39
+ node.to,
40
+ Decoration.replace({
41
+ widget: new ImageWidget(url),
42
+ }),
43
+ );
44
+ }
45
+ }
46
+ },
47
+ });
48
+
49
+ return builder.finish();
50
+ };
51
+
52
+ class ImageWidget extends WidgetType {
53
+ constructor(readonly _url: string) {
54
+ super();
55
+ }
56
+
57
+ override eq(other: WidgetType) {
58
+ return this._url === (other as any as ImageWidget)?._url;
59
+ }
60
+
61
+ toDOM(view: EditorView) {
62
+ const img = document.createElement('img');
63
+ img.setAttribute('src', this._url);
64
+ img.setAttribute('class', 'cm-image');
65
+ return img;
66
+ }
67
+ }
@@ -4,9 +4,16 @@
4
4
 
5
5
  export * from './autocomplete';
6
6
  export * from './basic';
7
+ export * from './blast';
8
+ export * from './code';
9
+ export * from './demo';
7
10
  export * from './comments';
11
+ export * from './highlight';
12
+ export * from './image';
8
13
  export * from './link';
9
14
  export * from './listener';
10
15
  export * from './markdown';
16
+ export * from './mention';
17
+ export * from './table';
11
18
  export * from './tasklist';
12
19
  export * from './tooltip';
@@ -18,13 +18,22 @@ import { Decoration, EditorView, WidgetType } from '@codemirror/view';
18
18
  // https://codemirror.net/try/?c=aW1wb3J0IHsgYmFzaWNTZXR1cCwgRWRpdG9yVmlldyB9IGZyb20gImNvZGVtaXJyb3IiCmltcG9ydCB7IG1hcmtkb3duIH0gZnJvbSAiQGNvZGVtaXJyb3IvbGFuZy1tYXJrZG93biIKaW1wb3J0IHsgc3ludGF4VHJlZSB9IGZyb20gIkBjb2RlbWlycm9yL2xhbmd1YWdlIjsKaW1wb3J0IHsgRWRpdG9yU3RhdGUsIFJhbmdlU2V0QnVpbGRlciwgU3RhdGVGaWVsZCB9IGZyb20gIkBjb2RlbWlycm9yL3N0YXRlIjsKaW1wb3J0IHsgRGVjb3JhdGlvbiwgV2lkZ2V0VHlwZSB9IGZyb20gIkBjb2RlbWlycm9yL3ZpZXciOwoKY2xhc3MgTGluayBleHRlbmRzIFdpZGdldFR5cGUgewogIGNvbnN0cnVjdG9yKHRleHQsIHVybCkgewogICAgc3VwZXIoKQogICAgdGhpcy50ZXh0ID0gdGV4dAogICAgdGhpcy51cmwgPSB1cmwKICB9CiAgZXEob3RoZXIpIHsgcmV0dXJuIHRoaXMudGV4dCA9PSBvdGhlci50ZXh0ICYmIHRoaXMudXJsID09IG90aGVyLnVybCB9CiAgdG9ET00oKSB7CiAgICBsZXQgbGluayA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoImEiKQogICAgbGluay50ZXh0Q29udGVudCA9IHRoaXMudGV4dAogICAgbGluay5ocmVmID0gdGhpcy51cmwKICAgIHJldHVybiBsaW5rOwogIH0KfQoKbGV0IGRlY29yYXRpb25zRmllbGQgPSBTdGF0ZUZpZWxkLmRlZmluZSh7CiAgY3JlYXRlKCkgewogICAgcmV0dXJuIERlY29yYXRpb24ubm9uZTsKICB9LAogIHVwZGF0ZShfLCB0cikgewogICAgY29uc3QgYnVpbGRlciA9IG5ldyBSYW5nZVNldEJ1aWxkZXIoKTsKICAgIGxldCBjdXJzb3IgPSB0ci5zdGF0ZS5zZWxlY3Rpb24ubWFpbi5oZWFkOwogICAgc3ludGF4VHJlZSh0ci5zdGF0ZSkuaXRlcmF0ZSh7CiAgICAgIGVudGVyOiAobm9kZSkgPT4gewogICAgICAgIGlmICgoY3Vyc29yIDwgbm9kZS5mcm9tIHx8IGN1cnNvciA+IG5vZGUudG8pICYmIG5vZGUubmFtZSA9PSAiTGluayIpIHsKICAgICAgICAgIGxldCBtYXJrcyA9IG5vZGUubm9kZS5nZXRDaGlsZHJlbigiTGlua01hcmsiKTsKICAgICAgICAgIGxldCB0ZXh0ID0gbWFya3MubGVuZ3RoID49IDIgPyB0ci5zdGF0ZS5zbGljZURvYyhtYXJrc1swXS50bywgbWFya3NbMV0uZnJvbSkgOiAiIgoKICAgICAgICAgIGxldCB1cmxOb2RlID0gbm9kZS5ub2RlLmdldENoaWxkKCJVUkwiKTsKICAgICAgICAgIGxldCB1cmwgPSB1cmxOb2RlID8gdHIuc3RhdGUuc2xpY2VEb2ModXJsTm9kZS5mcm9tLCB1cmxOb2RlLnRvKSA6ICIiCiAgICAgICAgICBidWlsZGVyLmFkZChub2RlLmZyb20sIG5vZGUudG8sIERlY29yYXRpb24ucmVwbGFjZSh7CiAgICAgICAgICAgIHdpZGdldDogbmV3IExpbmsodGV4dCwgdXJsKSwKICAgICAgICAgIH0pLAogICAgICAgICAgICAgICAgICAgICApOwogICAgICAgICAgcmV0dXJuIGZhbHNlOwogICAgICAgIH0KICAgICAgICByZXR1cm4gdHJ1ZTsKICAgICAgfSwKICAgIH0pOwogICAgcmV0dXJuIGJ1aWxkZXIuZmluaXNoKCk7CiAgfSwKICBwcm92aWRlOiAoZikgPT4gRWRpdG9yVmlldy5kZWNvcmF0aW9ucy5mcm9tKGYpLAp9KTsKCmxldCB2aWV3ID0gbmV3IEVkaXRvclZpZXcoewogIGRvYzogIiMgSGVsbG8gV29ybGRcblxuW1Rlc3RdKGh0dHBzOi8vZXhhbXBsZS5jb20pXG5cblxuIiwKICBleHRlbnNpb25zOiBbZGVjb3JhdGlvbnNGaWVsZCwgbWFya2Rvd24oKV0sCiAgcGFyZW50OiBkb2N1bWVudC5ib2R5LAp9KTsKCnZpZXcuZm9jdXMoKTs=
19
19
  // https://discuss.codemirror.net/t/link-widget-is-clicked-only-if-editor-is-out-of-focus/7433
20
20
 
21
- // TODO(burdon): Adapt to make all markdown editable?
21
+ export type LinkOptions = {
22
+ link?: boolean;
23
+ onRender?: (el: Element, url: string) => void;
24
+ };
22
25
 
23
- // TODO(burdon): Research overlays.
24
- // https://stackoverflow.com/questions/26933344/codemirror-detect-and-create-links-inside-editor
25
- // https://codemirror.net/5/doc/manual.html#addOverlay
26
- // https://codemirror.net/5/demo/simplemode.html
27
- // https://codemirror.net/5/demo/search.html (Search dialog).
26
+ /**
27
+ * Creates a state field to replace AST elements with a hyperlink widget.
28
+ * https://codemirror.net/docs/ref/#state.StateField
29
+ */
30
+ export const link = (options: LinkOptions = {}): Extension => {
31
+ return StateField.define<RangeSet<any>>({
32
+ create: (state) => update(state, options),
33
+ update: (_: RangeSet<any>, tr: Transaction) => update(tr.state, options),
34
+ provide: (field) => EditorView.decorations.from(field),
35
+ });
36
+ };
28
37
 
29
38
  class LinkButton extends WidgetType {
30
39
  constructor(
@@ -85,20 +94,20 @@ class LinkText extends WidgetType {
85
94
  const update = (state: EditorState, options: LinkOptions) => {
86
95
  const builder = new RangeSetBuilder();
87
96
  const cursor = state.selection.main.head;
97
+
88
98
  syntaxTree(state).iterate({
89
99
  enter: (node) => {
90
100
  // Check if cursor is inside text.
91
101
  if (node.name === 'Link') {
92
102
  const marks = node.node.getChildren('LinkMark');
93
103
  const text = marks.length >= 2 ? state.sliceDoc(marks[0].to, marks[1].from) : '';
94
-
95
104
  const urlNode = node.node.getChild('URL');
96
105
  const url = urlNode ? state.sliceDoc(urlNode.from, urlNode.to) : '';
97
106
  if (!url) {
98
107
  return false;
99
108
  }
100
109
 
101
- if (cursor < node.from || cursor > node.to) {
110
+ if (state.readOnly || cursor < node.from || cursor > node.to) {
102
111
  builder.add(
103
112
  node.from,
104
113
  node.to,
@@ -125,20 +134,3 @@ const update = (state: EditorState, options: LinkOptions) => {
125
134
 
126
135
  return builder.finish();
127
136
  };
128
-
129
- export type LinkOptions = {
130
- link?: boolean;
131
- onRender?: (el: Element, url: string) => void;
132
- };
133
-
134
- /**
135
- * Creates a state field to replace AST elements with a hyperlink widget.
136
- * https://codemirror.net/docs/ref/#state.StateField
137
- */
138
- export const link = (options: LinkOptions = {}): Extension => {
139
- return StateField.define<RangeSet<any>>({
140
- create: (state) => update(state, options),
141
- update: (_: RangeSet<any>, tr: Transaction) => update(tr.state, options),
142
- provide: (field) => EditorView.decorations.from(field),
143
- });
144
- };
@@ -4,14 +4,13 @@
4
4
 
5
5
  import { StateField } from '@codemirror/state';
6
6
 
7
- export type TextListener = (text: string) => void;
7
+ export type ListenerOptions = { onChange: (text: string) => void };
8
8
 
9
9
  /**
10
10
  * Based on https://github.com/codemirror/dev/issues/44#issuecomment-789093799
11
11
  */
12
- // TODO(burdon): Expose options.
13
- export const listener = (onChange: TextListener) =>
14
- StateField.define({
12
+ export const listener = ({ onChange }: ListenerOptions) => {
13
+ return StateField.define({
15
14
  create: () => null,
16
15
  update: (_value, transaction) => {
17
16
  if (transaction.docChanged && onChange) {
@@ -21,3 +20,4 @@ export const listener = (onChange: TextListener) =>
21
20
  return null;
22
21
  },
23
22
  });
23
+ };