@dxos/react-ui-editor 0.3.11-main.fbbdc2a → 0.3.11-main.fe789c1

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 (80) hide show
  1. package/dist/lib/browser/index.mjs +412 -516
  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 +9 -5
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.d.ts +12 -17
  7. package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -6
  9. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
  11. package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
  12. package/dist/types/src/components/TextEditor/yjs.stories.d.ts +1 -1
  13. package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
  14. package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
  15. package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
  16. package/dist/types/src/extensions/automerge/index.d.ts +1 -0
  17. package/dist/types/src/extensions/automerge/index.d.ts.map +1 -1
  18. package/dist/types/src/extensions/code.d.ts.map +1 -1
  19. package/dist/types/src/extensions/comments.d.ts +2 -2
  20. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  21. package/dist/types/src/{util → extensions}/cursor.d.ts +9 -1
  22. package/dist/types/src/extensions/cursor.d.ts.map +1 -0
  23. package/dist/types/src/extensions/hr.d.ts.map +1 -1
  24. package/dist/types/src/extensions/image.d.ts.map +1 -1
  25. package/dist/types/src/extensions/index.d.ts +1 -1
  26. package/dist/types/src/extensions/index.d.ts.map +1 -1
  27. package/dist/types/src/extensions/link.d.ts.map +1 -1
  28. package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
  29. package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
  30. package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
  31. package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
  32. package/dist/types/src/hooks/useTextModel.d.ts +0 -5
  33. package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
  34. package/dist/types/src/hooks/yjs.d.ts.map +1 -1
  35. package/dist/types/src/index.d.ts +3 -0
  36. package/dist/types/src/index.d.ts.map +1 -1
  37. package/dist/types/src/styles/markdown.d.ts.map +1 -1
  38. package/dist/types/src/styles/tokens.d.ts +1 -0
  39. package/dist/types/src/styles/tokens.d.ts.map +1 -1
  40. package/dist/types/src/themes/default.d.ts.map +1 -1
  41. package/dist/types/src/util.d.ts.map +1 -0
  42. package/package.json +23 -22
  43. package/src/components/TextEditor/MarkdownEditor.stories.tsx +25 -23
  44. package/src/components/TextEditor/TextEditor.stories.tsx +12 -46
  45. package/src/components/TextEditor/TextEditor.tsx +68 -64
  46. package/src/components/TextEditor/comments.stories.tsx +315 -0
  47. package/src/extensions/autocomplete.ts +18 -17
  48. package/src/extensions/automerge/automerge.ts +2 -2
  49. package/src/extensions/automerge/cursor.ts +2 -1
  50. package/src/extensions/automerge/index.ts +1 -0
  51. package/src/extensions/awareness.ts +25 -32
  52. package/src/extensions/code.ts +14 -7
  53. package/src/extensions/comments.ts +23 -119
  54. package/src/extensions/cursor.ts +46 -0
  55. package/src/extensions/hr.ts +4 -5
  56. package/src/extensions/image.ts +40 -22
  57. package/src/extensions/index.ts +1 -1
  58. package/src/extensions/link.ts +86 -88
  59. package/src/extensions/markdown/highlight.ts +3 -12
  60. package/src/extensions/table.ts +10 -4
  61. package/src/extensions/tasklist.ts +3 -5
  62. package/src/extensions/yjs/cursor.ts +2 -1
  63. package/src/extensions/yjs/yjs.ts +2 -2
  64. package/src/hooks/useTextModel.ts +4 -7
  65. package/src/hooks/yjs.ts +1 -23
  66. package/src/index.ts +3 -0
  67. package/src/styles/markdown.ts +0 -2
  68. package/src/styles/tokens.ts +3 -0
  69. package/src/themes/default.ts +52 -71
  70. package/dist/types/src/extensions/mermaid.d.ts +0 -3
  71. package/dist/types/src/extensions/mermaid.d.ts.map +0 -1
  72. package/dist/types/src/util/cursor.d.ts.map +0 -1
  73. package/dist/types/src/util/index.d.ts +0 -3
  74. package/dist/types/src/util/index.d.ts.map +0 -1
  75. package/dist/types/src/util/util.d.ts.map +0 -1
  76. package/src/extensions/mermaid.ts +0 -11
  77. package/src/util/cursor.ts +0 -29
  78. package/src/util/index.ts +0 -6
  79. /package/dist/types/src/{util/util.d.ts → util.d.ts} +0 -0
  80. /package/src/{util/util.ts → util.ts} +0 -0
@@ -0,0 +1,315 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import '@dxosTheme';
6
+
7
+ import { type EditorView, keymap } from '@codemirror/view';
8
+ import { faker } from '@faker-js/faker';
9
+ import { Check } from '@phosphor-icons/react';
10
+ import React, { type FC, useEffect, useMemo, useRef, useState } from 'react';
11
+
12
+ import { TextObject } from '@dxos/echo-schema';
13
+ import { PublicKey } from '@dxos/keys';
14
+ import { Button, DensityProvider } from '@dxos/react-ui';
15
+ import { fixedInsetFlexLayout, mx } from '@dxos/react-ui-theme';
16
+ import { withTheme } from '@dxos/storybook-utils';
17
+
18
+ import { MarkdownEditor, TextEditor } from './TextEditor';
19
+ import { comments, type CommentsOptions, Cursor } from '../../extensions';
20
+ import { type CommentRange, type Range, useTextModel } from '../../hooks';
21
+
22
+ faker.seed(101);
23
+
24
+ //
25
+ // Editor
26
+ //
27
+
28
+ const Editor: FC<{
29
+ item: { text: TextObject };
30
+ commentSelected?: string;
31
+ commentRanges: CommentRange[];
32
+ onCreateComment: CommentsOptions['onCreate'];
33
+ onSelectComment: CommentsOptions['onSelect'];
34
+ }> = ({ item, commentSelected, commentRanges, onCreateComment, onSelectComment }) => {
35
+ const model = useTextModel({ text: item.text });
36
+ const editorRef = useRef<EditorView>(null);
37
+ const [selected, setSelected] = useState<string>();
38
+ useEffect(() => {
39
+ if (!editorRef.current?.hasFocus && commentSelected !== selected) {
40
+ const thread = commentRanges.find((range) => range.id === commentSelected);
41
+ if (thread) {
42
+ const { cursor } = thread;
43
+ const range = Cursor.getRangeFromCursor(editorRef.current!.state.facet(Cursor.converter), cursor);
44
+ if (range) {
45
+ // TODO(burdon): Scroll selection to center of screen?
46
+ editorRef.current?.dispatch({ selection: { anchor: range.from }, scrollIntoView: true });
47
+ }
48
+ }
49
+
50
+ setSelected(commentSelected);
51
+ }
52
+ }, [selected, commentRanges, commentSelected]);
53
+
54
+ const extensions = useMemo(() => {
55
+ return [
56
+ comments({
57
+ onCreate: onCreateComment,
58
+ onSelect: onSelectComment,
59
+ }),
60
+ ];
61
+ }, []);
62
+
63
+ if (!model) {
64
+ return null;
65
+ }
66
+
67
+ // TODO(burdon): Highlight currently selected comment.
68
+ return (
69
+ <div className='flex grow overflow-y-scroll'>
70
+ <MarkdownEditor ref={editorRef} model={model} comments={commentRanges} extensions={extensions} />
71
+ </div>
72
+ );
73
+ };
74
+
75
+ //
76
+ // Comments thread
77
+ //
78
+
79
+ type CommentThread = {
80
+ id: string;
81
+ range: CommentRange;
82
+ yPos?: number;
83
+ selection?: Range;
84
+ messages: TextObject[];
85
+ };
86
+
87
+ const Thread: FC<{
88
+ thread: CommentThread;
89
+ selected: boolean;
90
+ onSelect: () => void;
91
+ onResolve: () => void;
92
+ }> = ({ thread, selected, onSelect, onResolve }) => {
93
+ const [item, setItem] = useState({ text: new TextObject() });
94
+ const model = useTextModel({ text: item.text });
95
+ const editorRef = useRef<EditorView>(null);
96
+
97
+ // TODO(burdon): Hack to focus (view is recreated when text object changes).
98
+ const focus = () => {
99
+ setTimeout(() => {
100
+ editorRef.current?.focus();
101
+ }, 100);
102
+ };
103
+
104
+ const containerRef = useRef<HTMLDivElement>(null);
105
+ useEffect(() => {
106
+ if (selected) {
107
+ containerRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' });
108
+ if (thread.messages.length === 0) {
109
+ focus();
110
+ }
111
+ }
112
+ }, [selected]);
113
+
114
+ const handleCreateMessage = () => {
115
+ const text = model?.text().trim();
116
+ if (text?.length) {
117
+ thread.messages.push(item.text);
118
+ setItem({ text: new TextObject() });
119
+ focus();
120
+ }
121
+ };
122
+
123
+ if (!model) {
124
+ return null;
125
+ }
126
+
127
+ return (
128
+ <div className={mx('flex flex-col m-1 rounded shadow divide-y bg-white', selected && 'ring')}>
129
+ <div className='flex p-2 text-xs font-mono gap-2 text-neutral-500 font-thin'>
130
+ <span>id:{thread.id.slice(0, 4)}</span>
131
+ <span>from:{thread.selection?.from}</span>
132
+ <span>to:{thread.selection?.to}</span>
133
+ <span>y:{thread.yPos}</span>
134
+ </div>
135
+
136
+ {thread.messages.map((message, i) => (
137
+ // TODO(burdon): Fix default editor padding so content doesn't jump on creating message.
138
+ <div key={i} className='p-2' onClick={() => onSelect()}>
139
+ {message.text}
140
+ </div>
141
+ ))}
142
+
143
+ <div ref={containerRef} onClick={() => onSelect()} className='flex py-1'>
144
+ <div className='grow'>
145
+ <TextEditor
146
+ ref={editorRef}
147
+ model={model}
148
+ placeholder={'Enter comment...'}
149
+ extensions={[
150
+ keymap.of([
151
+ {
152
+ key: 'Enter',
153
+ run: () => {
154
+ handleCreateMessage();
155
+ return true;
156
+ },
157
+ },
158
+ ]),
159
+ ]}
160
+ />
161
+ </div>
162
+ <Button variant='ghost' classNames='px-1 mr-1' title='Resolve' onClick={onResolve}>
163
+ <Check />
164
+ </Button>
165
+ </div>
166
+ </div>
167
+ );
168
+ };
169
+
170
+ const Sidebar: FC<{
171
+ threads: CommentThread[];
172
+ selected?: string;
173
+ onSelect: (thread: string) => void;
174
+ onResolve: (thread: string) => void;
175
+ }> = ({ threads, selected, onSelect, onResolve }) => {
176
+ // Sort by y-position.
177
+ const sortedThreads = useMemo(() => {
178
+ const sorted = [...threads];
179
+ return sorted.sort(({ yPos: a = Infinity }, { yPos: b = Infinity }) => {
180
+ return a < b ? -1 : a > b ? 1 : 0;
181
+ });
182
+ }, [threads]);
183
+
184
+ return (
185
+ <DensityProvider density='fine'>
186
+ <div className='flex flex-col grow overflow-y-scroll py-4 gap-4 pr-4'>
187
+ {sortedThreads.map((thread) => (
188
+ <Thread
189
+ key={thread.id}
190
+ thread={thread}
191
+ selected={thread.id === selected}
192
+ onSelect={() => onSelect(thread.id)}
193
+ onResolve={() => onResolve(thread.id)}
194
+ />
195
+ ))}
196
+ </div>
197
+ </DensityProvider>
198
+ );
199
+ };
200
+
201
+ //
202
+ // Story container.
203
+ //
204
+
205
+ type StoryProps = {
206
+ text?: string;
207
+ autoCreate?: boolean;
208
+ };
209
+
210
+ const Story = ({ text, autoCreate }: StoryProps) => {
211
+ const [item] = useState({ text: new TextObject(text) });
212
+ const [threads, setThreads] = useState<CommentThread[]>([]);
213
+ const commentRanges = useMemo(() => threads.map((thread) => thread.range), [threads]);
214
+ const [selected, setSelected] = useState<string>();
215
+
216
+ // Filter by visibility.
217
+ const visibleThreads = useMemo(() => threads.filter((thread) => thread.yPos !== undefined), [threads]);
218
+
219
+ const handleCreateComment: CommentsOptions['onCreate'] = (cursor, location) => {
220
+ const id = PublicKey.random().toHex();
221
+ setThreads((threads) => [
222
+ ...threads,
223
+ {
224
+ id,
225
+ range: { id, cursor },
226
+ yPos: location ? Math.floor(location.top) : undefined,
227
+ messages: autoCreate
228
+ ? faker.helpers.multiple(() => new TextObject(faker.lorem.sentence()), { count: { min: 2, max: 5 } })
229
+ : [],
230
+ },
231
+ ]);
232
+ setSelected(id);
233
+ return id;
234
+ };
235
+
236
+ // TODO(burdon): Scroll sidebar on select.
237
+ const handleSelectComment: CommentsOptions['onSelect'] = ({ active, closest, ranges }) => {
238
+ setThreads((threads) =>
239
+ threads.map((thread) => {
240
+ const range = ranges.find((range) => range.id === thread.range.id);
241
+ if (range) {
242
+ thread.yPos = range.location ? Math.floor(range.location.top) : undefined;
243
+ thread.selection = { from: range.from, to: range.to };
244
+ }
245
+
246
+ return thread;
247
+ }),
248
+ );
249
+
250
+ setSelected(active ?? closest);
251
+ };
252
+
253
+ const handleSelectThread = (id: string) => {
254
+ setSelected(id);
255
+ };
256
+
257
+ const handleResolveThread = (id: string) => {
258
+ setThreads((threads) => [...threads.filter((thread) => thread.id !== id)]);
259
+ setSelected(undefined);
260
+ };
261
+
262
+ return (
263
+ <div className={mx(fixedInsetFlexLayout, 'bg-neutral-100')}>
264
+ <div className='flex justify-center h-full gap-8'>
265
+ <div className='flex flex-col h-full w-[600px]'>
266
+ <Editor
267
+ item={item}
268
+ commentSelected={selected}
269
+ commentRanges={commentRanges}
270
+ onCreateComment={handleCreateComment}
271
+ onSelectComment={handleSelectComment}
272
+ />
273
+ </div>
274
+
275
+ <div className='flex flex-col h-full w-[300px]'>
276
+ <Sidebar
277
+ threads={visibleThreads}
278
+ selected={selected}
279
+ onSelect={handleSelectThread}
280
+ onResolve={handleResolveThread}
281
+ />
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
286
+ };
287
+
288
+ export default {
289
+ title: 'react-ui-editor/comments',
290
+ component: MarkdownEditor,
291
+ decorators: [withTheme],
292
+ render: (args: StoryProps) => <Story {...args} />,
293
+ };
294
+
295
+ const str = (...lines: string[]) => lines.join('\n');
296
+
297
+ const document = str(
298
+ '# Comments',
299
+ '',
300
+ str(...faker.helpers.multiple(() => [faker.lorem.paragraph({ min: 5, max: 10 }), ''], { count: 20 }).flat()),
301
+ '',
302
+ );
303
+
304
+ export const Default = {
305
+ args: {
306
+ text: document,
307
+ },
308
+ };
309
+
310
+ export const Demo = {
311
+ args: {
312
+ text: document,
313
+ autoCreate: true,
314
+ },
315
+ };
@@ -13,6 +13,7 @@ import {
13
13
  type CompletionContext,
14
14
  type CompletionResult,
15
15
  } from '@codemirror/autocomplete';
16
+ import { markdownLanguage } from '@codemirror/lang-markdown';
16
17
  import { keymap } from '@codemirror/view';
17
18
 
18
19
  export type AutocompleteResult = Completion;
@@ -28,32 +29,32 @@ export const autocomplete = ({ onSearch }: AutocompleteOptions) => {
28
29
  return [
29
30
  // https://codemirror.net/docs/ref/#view.keymap
30
31
  // https://discuss.codemirror.net/t/how-can-i-replace-the-default-autocompletion-keymap-v6/3322
32
+ // TODO(burdon): Set custom keymap.
31
33
  keymap.of(completionKeymap),
32
34
 
33
35
  // https://codemirror.net/examples/autocompletion
34
36
  // https://codemirror.net/docs/ref/#autocomplete.autocompletion
35
37
  autocompletion({
36
- // TODO(burdon): Set custom keymap.
37
- // defaultKeymap: false,
38
+ defaultKeymap: false,
38
39
 
39
40
  // Don't start unless key press.
40
41
  activateOnTyping: false,
42
+ }),
41
43
 
42
- // TODO(burdon): Option to create new page?
43
- // TODO(burdon): Optional decoration via addToOptions
44
- override: [
45
- (context: CompletionContext): CompletionResult | null => {
46
- const match = context.matchBefore(/\w*/);
47
- if (!match || (match.from === match.to && !context.explicit)) {
48
- return null;
49
- }
50
-
51
- return {
52
- from: match.from,
53
- options: onSearch(match.text.toLowerCase()),
54
- };
55
- },
56
- ],
44
+ // TODO(burdon): Option to create new page?
45
+ // TODO(burdon): Optional decoration via addToOptions
46
+ markdownLanguage.data.of({
47
+ autocomplete: (context: CompletionContext): CompletionResult | null => {
48
+ const match = context.matchBefore(/\w*/);
49
+ if (!match || (match.from === match.to && !context.explicit)) {
50
+ return null;
51
+ }
52
+
53
+ return {
54
+ from: match.from,
55
+ options: onSearch(match.text.toLowerCase()),
56
+ };
57
+ },
57
58
  }),
58
59
  ];
59
60
  };
@@ -13,7 +13,7 @@ import { type Prop } from '@dxos/automerge/automerge';
13
13
  import { cursorConverter } from './cursor';
14
14
  import { effectType, type IDocHandle, isReconcileTx, type Value } from './defs';
15
15
  import { PatchSemaphore } from './semaphore';
16
- import { CursorConverter } from '../../util';
16
+ import { Cursor } from '../cursor';
17
17
 
18
18
  const semaphoreFacet = Facet.define<PatchSemaphore, PatchSemaphore>({
19
19
  combine: (values) => values.at(-1)!, // Take last.
@@ -85,7 +85,7 @@ export const automerge = ({ handle, path }: AutomergeOptions): Extension => {
85
85
 
86
86
  return {
87
87
  extension: [
88
- CursorConverter.of(cursorConverter(handle, path)),
88
+ Cursor.converter.of(cursorConverter(handle, path)),
89
89
  semaphoreFacet.of(semaphore),
90
90
  stateField,
91
91
  viewPlugin,
@@ -8,7 +8,7 @@ import type { Prop } from '@dxos/automerge/automerge';
8
8
  import { next as automerge } from '@dxos/automerge/automerge';
9
9
 
10
10
  import { type IDocHandle } from './defs';
11
- import { type CursorConverter } from '../../util';
11
+ import { type CursorConverter } from '../cursor';
12
12
 
13
13
  export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConverter => ({
14
14
  // TODO(burdon): Handle assoc to associate with a previous character.
@@ -26,6 +26,7 @@ export const cursorConverter = (handle: IDocHandle, path: Prop[]): CursorConvert
26
26
  // NOTE: Slice is needed because getCursor mutates the array.
27
27
  return automerge.getCursor(doc, path.slice(), pos);
28
28
  },
29
+
29
30
  fromCursor: (cursor) => {
30
31
  if (cursor === '') {
31
32
  return 0;
@@ -4,3 +4,4 @@
4
4
 
5
5
  export { automerge } from './automerge';
6
6
  export { type IDocHandle } from './defs';
7
+ export { cursorConverter } from './cursor';
@@ -16,7 +16,7 @@ import {
16
16
  import { Event } from '@dxos/async';
17
17
  import { Context } from '@dxos/context';
18
18
 
19
- import { CursorConverter } from '../util';
19
+ import { Cursor, type CursorConverter } from './cursor';
20
20
 
21
21
  export interface AwarenessProvider {
22
22
  remoteStateChange: Event<void>;
@@ -91,7 +91,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
91
91
  private _lastHead?: number = undefined;
92
92
 
93
93
  constructor(view: EditorView) {
94
- this._cursorConverter = view.state.facet(CursorConverter);
94
+ this._cursorConverter = view.state.facet(Cursor.converter);
95
95
  this._provider = view.state.facet(AwarenessProvider);
96
96
  this._provider.open();
97
97
  this._provider.remoteStateChange.on(this._ctx, () => {
@@ -156,7 +156,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
156
156
  to: end,
157
157
  value: Decoration.mark({
158
158
  attributes: { style: `background-color: ${lightColor}` },
159
- class: 'cm-ySelection',
159
+ class: 'cm-collab-selection',
160
160
  }),
161
161
  });
162
162
  } else {
@@ -166,7 +166,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
166
166
  to: startLine.from + startLine.length,
167
167
  value: Decoration.mark({
168
168
  attributes: { style: `background-color: ${color}` },
169
- class: 'cm-ySelection',
169
+ class: 'cm-collab-selection',
170
170
  }),
171
171
  });
172
172
 
@@ -176,7 +176,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
176
176
  to: end,
177
177
  value: Decoration.mark({
178
178
  attributes: { style: `background-color: ${color}` },
179
- class: 'cm-ySelection',
179
+ class: 'cm-collab-selection',
180
180
  }),
181
181
  });
182
182
 
@@ -186,7 +186,7 @@ export class RemoteSelectionsDecorator implements PluginValue {
186
186
  from: linePos,
187
187
  to: linePos,
188
188
  value: Decoration.line({
189
- attributes: { style: `background-color: ${color}`, class: 'cm-yLineSelection' },
189
+ attributes: { style: `background-color: ${color}`, class: 'cm-collab-selectionLine' },
190
190
  }),
191
191
  });
192
192
  }
@@ -208,24 +208,22 @@ export class RemoteSelectionsDecorator implements PluginValue {
208
208
  }
209
209
 
210
210
  class RemoteCaretWidget extends WidgetType {
211
- constructor(public name: string, public color: string) {
211
+ constructor(private readonly _name: string, private readonly _color: string) {
212
212
  super();
213
- this.name = name;
214
- this.color = color;
215
213
  }
216
214
 
217
- toDOM(): HTMLElement {
215
+ override toDOM(): HTMLElement {
218
216
  const span = document.createElement('span');
219
- span.className = 'cm-ySelectionCaret';
220
- span.style.backgroundColor = this.color;
221
- span.style.borderColor = this.color;
217
+ span.className = 'cm-collab-selectionCaret';
218
+ span.style.backgroundColor = this._color;
219
+ span.style.borderColor = this._color;
222
220
 
223
221
  const dot = document.createElement('div');
224
- dot.className = 'cm-ySelectionCaretDot';
222
+ dot.className = 'cm-collab-selectionCaretDot';
225
223
 
226
224
  const info = document.createElement('div');
227
- info.className = 'cm-ySelectionInfo';
228
- info.innerText = this.name;
225
+ info.className = 'cm-collab-selectionInfo';
226
+ info.innerText = this._name;
229
227
 
230
228
  span.appendChild(document.createTextNode('\u2060'));
231
229
  span.appendChild(dot);
@@ -236,18 +234,14 @@ class RemoteCaretWidget extends WidgetType {
236
234
  return span;
237
235
  }
238
236
 
239
- override eq(widget: this) {
240
- return widget.color === this.color;
241
- }
242
-
243
- compare(widget: this) {
244
- return widget.color === this.color;
245
- }
246
-
247
237
  override updateDOM() {
248
238
  return false;
249
239
  }
250
240
 
241
+ override eq(widget: this) {
242
+ return widget._color === this._color;
243
+ }
244
+
251
245
  override get estimatedHeight() {
252
246
  return -1;
253
247
  }
@@ -257,14 +251,13 @@ class RemoteCaretWidget extends WidgetType {
257
251
  }
258
252
  }
259
253
 
260
- // TODO(burdon): Rename prefix (y for yjs?)
261
254
  const styles = EditorView.baseTheme({
262
- '.cm-ySelection': {},
263
- '.cm-yLineSelection': {
255
+ '.cm-collab-selection': {},
256
+ '.cm-collab-selectionLine': {
264
257
  padding: 0,
265
258
  margin: '0px 2px 0px 4px',
266
259
  },
267
- '.cm-ySelectionCaret': {
260
+ '.cm-collab-selectionCaret': {
268
261
  position: 'relative',
269
262
  borderLeft: '1px solid black',
270
263
  borderRight: '1px solid black',
@@ -273,7 +266,7 @@ const styles = EditorView.baseTheme({
273
266
  boxSizing: 'border-box',
274
267
  display: 'inline',
275
268
  },
276
- '.cm-ySelectionCaretDot': {
269
+ '.cm-collab-selectionCaretDot': {
277
270
  borderRadius: '50%',
278
271
  position: 'absolute',
279
272
  width: '.4em',
@@ -284,11 +277,11 @@ const styles = EditorView.baseTheme({
284
277
  transition: 'transform .3s ease-in-out',
285
278
  boxSizing: 'border-box',
286
279
  },
287
- '.cm-ySelectionCaret:hover > .cm-ySelectionCaretDot': {
280
+ '.cm-collab-selectionCaret:hover > .cm-collab-selectionCaretDot': {
288
281
  transformOrigin: 'bottom center',
289
282
  transform: 'scale(0)',
290
283
  },
291
- '.cm-ySelectionInfo': {
284
+ '.cm-collab-selectionInfo': {
292
285
  position: 'absolute',
293
286
  top: '-1.05em',
294
287
  left: '-1px',
@@ -309,7 +302,7 @@ const styles = EditorView.baseTheme({
309
302
  transitionDelay: '0s',
310
303
  whiteSpace: 'nowrap',
311
304
  },
312
- '.cm-ySelectionCaret:hover > .cm-ySelectionInfo': {
305
+ '.cm-collab-selectionCaret:hover > .cm-collab-selectionInfo': {
313
306
  opacity: 1,
314
307
  transitionDelay: '0s',
315
308
  },
@@ -11,24 +11,23 @@ import {
11
11
  Decoration,
12
12
  type BlockInfo,
13
13
  } from '@codemirror/view';
14
- import get from 'lodash.get';
15
14
 
16
15
  import { mx } from '@dxos/react-ui-theme';
17
16
 
18
- import { tokens } from '../styles';
17
+ import { getToken } from '../styles';
19
18
 
20
19
  const CODE_REGEX = /```[\s\S]*?```/gs;
21
20
 
22
21
  // TODO(burdon): Reconcile with theme.
23
22
  const styles = EditorView.baseTheme({
24
23
  '& .cm-codeblock': {
25
- fontFamily: get(tokens, 'fontFamily.mono', []).join(','),
24
+ fontFamily: getToken('fontFamily.mono', []).join(','),
26
25
  },
27
26
  '&light [aria-readonly="true"] .cm-codeblock': {
28
- background: get(tokens, 'extend.colors.neutral.50'),
27
+ background: getToken('extend.colors.neutral.50'),
29
28
  },
30
29
  '&dark [aria-readonly="true"] .cm-codeblock': {
31
- background: get(tokens, 'extend.colors.neutral.850'),
30
+ background: getToken('extend.colors.neutral.850'),
32
31
  },
33
32
  '& [aria-readonly="true"] .cm-codeblock': {
34
33
  display: 'block',
@@ -56,7 +55,7 @@ const getLineRange = (lines: BlockInfo[], from: number, to: number) => {
56
55
  export const code = () => {
57
56
  const getDecorations = (view: EditorView) => {
58
57
  const decorations: Range<Decoration>[] = [];
59
- if (view.state.readOnly) {
58
+ if (!view.hasFocus || view.state.readOnly) {
60
59
  const text = view.state.doc.sliceString(0);
61
60
  const matches = text.matchAll(CODE_REGEX);
62
61
  const blocks = view.viewportLineBlocks;
@@ -79,13 +78,21 @@ export const code = () => {
79
78
  return [
80
79
  ViewPlugin.fromClass(
81
80
  class {
81
+ hasFocus = false;
82
82
  decorations: DecorationSet;
83
83
  constructor(view: EditorView) {
84
84
  this.decorations = getDecorations(view);
85
85
  }
86
86
 
87
87
  update(update: ViewUpdate) {
88
- if (update.docChanged || update.viewportChanged) {
88
+ if (
89
+ // TODO(burdon): Generalize for other extensions.
90
+ this.hasFocus !== update.view.hasFocus ||
91
+ update.startState.readOnly !== update.view.state.readOnly ||
92
+ update.docChanged ||
93
+ update.viewportChanged
94
+ ) {
95
+ this.hasFocus = update.view.hasFocus;
89
96
  this.decorations = getDecorations(update.view);
90
97
  }
91
98
  }