@dxos/react-ui-editor 0.3.11-main.008b7c6 → 0.3.11-main.00a28cb
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.
- package/dist/lib/browser/index.mjs +558 -629
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +12 -5
- package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.d.ts +12 -17
- package/dist/types/src/components/TextEditor/TextEditor.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +6 -6
- package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/TextEditor/comments.stories.d.ts +26 -0
- package/dist/types/src/components/TextEditor/comments.stories.d.ts.map +1 -0
- package/dist/types/src/components/TextEditor/yjs.stories.d.ts +1 -1
- package/dist/types/src/extensions/autocomplete.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/automerge.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts +1 -1
- package/dist/types/src/extensions/automerge/cursor.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/defs.d.ts +4 -4
- package/dist/types/src/extensions/automerge/defs.d.ts.map +1 -1
- package/dist/types/src/extensions/automerge/semaphore.d.ts +4 -9
- package/dist/types/src/extensions/automerge/semaphore.d.ts.map +1 -1
- package/dist/types/src/extensions/code.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +14 -5
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/{util → extensions}/cursor.d.ts +9 -1
- package/dist/types/src/extensions/cursor.d.ts.map +1 -0
- package/dist/types/src/extensions/hr.d.ts.map +1 -1
- package/dist/types/src/extensions/image.d.ts.map +1 -1
- package/dist/types/src/extensions/index.d.ts +2 -1
- package/dist/types/src/extensions/index.d.ts.map +1 -1
- package/dist/types/src/extensions/link.d.ts.map +1 -1
- package/dist/types/src/extensions/markdown/highlight.d.ts.map +1 -1
- package/dist/types/src/extensions/outliner.d.ts +4 -0
- package/dist/types/src/extensions/outliner.d.ts.map +1 -0
- package/dist/types/src/extensions/tasklist.d.ts +5 -1
- package/dist/types/src/extensions/tasklist.d.ts.map +1 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts +1 -1
- package/dist/types/src/extensions/yjs/cursor.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +1 -1
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useTextModel.d.ts +0 -5
- package/dist/types/src/hooks/useTextModel.d.ts.map +1 -1
- package/dist/types/src/hooks/yjs.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +3 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/styles/markdown.d.ts.map +1 -1
- package/dist/types/src/styles/tokens.d.ts +1 -0
- package/dist/types/src/styles/tokens.d.ts.map +1 -1
- package/dist/types/src/themes/default.d.ts.map +1 -1
- package/dist/types/src/util.d.ts.map +1 -0
- package/package.json +25 -24
- package/src/components/TextEditor/MarkdownEditor.stories.tsx +41 -37
- package/src/components/TextEditor/TextEditor.stories.tsx +12 -46
- package/src/components/TextEditor/TextEditor.tsx +71 -67
- package/src/components/TextEditor/comments.stories.tsx +355 -0
- package/src/extensions/autocomplete.ts +18 -17
- package/src/extensions/automerge/automerge.ts +12 -14
- package/src/extensions/automerge/cursor.ts +2 -1
- package/src/extensions/automerge/defs.ts +13 -9
- package/src/extensions/automerge/semaphore.ts +10 -12
- package/src/extensions/awareness.ts +27 -33
- package/src/extensions/code.ts +28 -23
- package/src/extensions/comments.ts +131 -144
- package/src/extensions/cursor.ts +46 -0
- package/src/extensions/hr.ts +10 -12
- package/src/extensions/image.ts +40 -22
- package/src/extensions/index.ts +2 -1
- package/src/extensions/link.ts +86 -88
- package/src/extensions/markdown/highlight.ts +3 -12
- package/src/extensions/outliner.ts +12 -0
- package/src/extensions/table.ts +10 -4
- package/src/extensions/tasklist.ts +54 -79
- package/src/extensions/yjs/cursor.ts +2 -1
- package/src/extensions/yjs/yjs.test.ts +1 -1
- package/src/extensions/yjs/yjs.ts +2 -2
- package/src/hooks/index.ts +2 -1
- package/src/hooks/useTextModel.ts +4 -7
- package/src/hooks/yjs.ts +1 -23
- package/src/index.ts +3 -0
- package/src/styles/markdown.ts +0 -2
- package/src/styles/tokens.ts +3 -0
- package/src/themes/default.ts +51 -78
- package/dist/types/src/extensions/mermaid.d.ts +0 -3
- package/dist/types/src/extensions/mermaid.d.ts.map +0 -1
- package/dist/types/src/util/cursor.d.ts.map +0 -1
- package/dist/types/src/util/index.d.ts +0 -3
- package/dist/types/src/util/index.d.ts.map +0 -1
- package/dist/types/src/util/util.d.ts.map +0 -1
- package/src/extensions/mermaid.ts +0 -11
- package/src/util/cursor.ts +0 -29
- package/src/util/index.ts +0 -6
- /package/dist/types/src/{util/util.d.ts → util.d.ts} +0 -0
- /package/src/{util/util.ts → util.ts} +0 -0
|
@@ -2,51 +2,27 @@
|
|
|
2
2
|
// Copyright 2023 DXOS.org
|
|
3
3
|
//
|
|
4
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 get from 'lodash.get';
|
|
5
|
+
import { type Extension, StateEffect, StateField, type Text } from '@codemirror/state';
|
|
6
|
+
import { hoverTooltip, keymap, type Command, Decoration, EditorView, type Rect } from '@codemirror/view';
|
|
20
7
|
import sortBy from 'lodash.sortby';
|
|
21
8
|
|
|
22
9
|
import { debounce } from '@dxos/async';
|
|
23
10
|
import { invariant } from '@dxos/invariant';
|
|
24
|
-
import { log } from '@dxos/log';
|
|
25
11
|
import { nonNullable } from '@dxos/util';
|
|
26
12
|
|
|
13
|
+
import { semaphoreFacet } from './automerge/defs';
|
|
14
|
+
import { Cursor } from './cursor';
|
|
27
15
|
import { type CommentRange, type EditorModel, type Range } from '../hooks';
|
|
28
|
-
import {
|
|
29
|
-
import { callbackWrapper
|
|
30
|
-
|
|
31
|
-
// TODO(burdon): Handle delete, cut, copy, and paste (separately) text that includes comment range.
|
|
32
|
-
// TODO(burdon): Consider breaking into separate plugin (since not standalone)? Like mermaid?
|
|
16
|
+
import { getToken } from '../styles';
|
|
17
|
+
import { callbackWrapper } from '../util';
|
|
33
18
|
|
|
34
19
|
// TODO(burdon): Reconcile with theme.
|
|
35
20
|
const styles = EditorView.baseTheme({
|
|
36
|
-
'& .cm-bookmark': {
|
|
37
|
-
cursor: 'pointer',
|
|
38
|
-
margin: '4px',
|
|
39
|
-
padding: '4px',
|
|
40
|
-
backgroundColor: 'yellow',
|
|
41
|
-
},
|
|
42
|
-
'& .cm-bookmark-selected': {
|
|
43
|
-
backgroundColor: 'orange',
|
|
44
|
-
},
|
|
45
21
|
'& .cm-comment': {
|
|
46
|
-
backgroundColor:
|
|
22
|
+
backgroundColor: getToken('extend.colors.yellow.50'),
|
|
47
23
|
},
|
|
48
24
|
'& .cm-comment-active': {
|
|
49
|
-
backgroundColor:
|
|
25
|
+
backgroundColor: getToken('extend.colors.yellow.100'),
|
|
50
26
|
},
|
|
51
27
|
});
|
|
52
28
|
|
|
@@ -81,7 +57,7 @@ export const setFocus = (view: EditorView, thread: string) => {
|
|
|
81
57
|
view.dispatch({
|
|
82
58
|
effects: setSelection.of({ active: thread }),
|
|
83
59
|
selection: { anchor: range.from },
|
|
84
|
-
scrollIntoView: true,
|
|
60
|
+
scrollIntoView: true, // TODO(burdon): Scroll to y-position (or center of screen?)
|
|
85
61
|
});
|
|
86
62
|
};
|
|
87
63
|
|
|
@@ -93,13 +69,12 @@ const setCommentState = StateEffect.define<CommentsState>();
|
|
|
93
69
|
|
|
94
70
|
/**
|
|
95
71
|
* State field (reducer) that tracks comment ranges.
|
|
96
|
-
* The ranges are tracked as
|
|
97
|
-
* therefore must be updated when the document changes.
|
|
72
|
+
* The ranges are tracked as Automerge cursors from which the absolute indexed ranges can be computed.
|
|
98
73
|
*/
|
|
99
74
|
const commentsStateField = StateField.define<CommentsState>({
|
|
100
75
|
create: () => ({ ranges: [] }),
|
|
101
76
|
update: (value, tr) => {
|
|
102
|
-
const cursorConverter = tr.state.facet(
|
|
77
|
+
const cursorConverter = tr.state.facet(Cursor.converter);
|
|
103
78
|
|
|
104
79
|
for (const effect of tr.effects) {
|
|
105
80
|
// Update selection.
|
|
@@ -112,7 +87,8 @@ const commentsStateField = StateField.define<CommentsState>({
|
|
|
112
87
|
const { comments } = effect.value;
|
|
113
88
|
const ranges: ExtendedCommentRange[] = comments
|
|
114
89
|
.map((comment) => {
|
|
115
|
-
const range = getRangeFromCursor(cursorConverter, comment.cursor);
|
|
90
|
+
const range = Cursor.getRangeFromCursor(cursorConverter, comment.cursor);
|
|
91
|
+
// console.log('update', JSON.stringify({ range, cursor: comment.cursor }, undefined, 2));
|
|
116
92
|
return range && { ...comment, ...range };
|
|
117
93
|
})
|
|
118
94
|
.filter(nonNullable);
|
|
@@ -155,42 +131,23 @@ const highlightDecorations = EditorView.decorations.compute([commentsStateField]
|
|
|
155
131
|
return Decoration.set(decorations);
|
|
156
132
|
});
|
|
157
133
|
|
|
158
|
-
/**
|
|
159
|
-
* Optional bookmark widget at the start/end of the selection.
|
|
160
|
-
* May correspond to a markdown bookmark.
|
|
161
|
-
*/
|
|
162
|
-
class BookmarkWidget extends WidgetType {
|
|
163
|
-
constructor(
|
|
164
|
-
private readonly _anchor: number,
|
|
165
|
-
private readonly _id: string,
|
|
166
|
-
private readonly _handleClick?: () => void,
|
|
167
|
-
) {
|
|
168
|
-
super();
|
|
169
|
-
invariant(this._id);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
override eq(other: BookmarkWidget) {
|
|
173
|
-
return this._anchor === other._anchor && this._id === other._id;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
override toDOM() {
|
|
177
|
-
const span = document.createElement('span');
|
|
178
|
-
span.className = 'cm-bookmark';
|
|
179
|
-
span.textContent = '※';
|
|
180
|
-
if (this._handleClick) {
|
|
181
|
-
span.onclick = () => this._handleClick!();
|
|
182
|
-
}
|
|
183
|
-
return span;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
134
|
export type CommentsOptions = {
|
|
135
|
+
/**
|
|
136
|
+
* Key shortcut to create a new thread.
|
|
137
|
+
*/
|
|
188
138
|
key?: string;
|
|
189
|
-
footnote?: boolean;
|
|
190
139
|
/**
|
|
191
140
|
* Called to create a new thread and return the thread id.
|
|
192
141
|
*/
|
|
193
|
-
onCreate?: (
|
|
142
|
+
onCreate?: (cursor: string, location?: Rect | null) => string | undefined;
|
|
143
|
+
/**
|
|
144
|
+
* Selection cut/deleted.
|
|
145
|
+
*/
|
|
146
|
+
onDelete?: (thread: string) => void;
|
|
147
|
+
/**
|
|
148
|
+
* Called when a comment is moved.
|
|
149
|
+
*/
|
|
150
|
+
onUpdate?: (thread: string, cursor: string) => void;
|
|
194
151
|
/**
|
|
195
152
|
* Called to notify which thread is currently closest to the cursor.
|
|
196
153
|
*/
|
|
@@ -198,7 +155,88 @@ export type CommentsOptions = {
|
|
|
198
155
|
/**
|
|
199
156
|
* Called to render tooltip.
|
|
200
157
|
*/
|
|
201
|
-
onHover?: (el: Element) => void;
|
|
158
|
+
onHover?: (el: Element, shortcut: string) => void;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// TODO(burdon): Handle cut/restore via undo (need to integrate with history?)
|
|
162
|
+
const trackPastedComments = (onUpdate: NonNullable<CommentsOptions['onUpdate']>) => {
|
|
163
|
+
// Cut/deleted comments.
|
|
164
|
+
// Tracks indexed selections within text.
|
|
165
|
+
let tracked: { text: Text; comments: { id: string; from: number; to: number }[] } | null = null;
|
|
166
|
+
|
|
167
|
+
// Track cut or copy (enables cut-and-paste and copy-delete-paste to restore comment selection).
|
|
168
|
+
const handleTrack = (event: Event, view: EditorView) => {
|
|
169
|
+
const comments = view.state.field(commentsStateField);
|
|
170
|
+
const { main } = view.state.selection;
|
|
171
|
+
const selectedRanges = comments.ranges.filter(
|
|
172
|
+
(range) => range.from >= main.from && range.to <= main.to && range.from < range.to,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (!selectedRanges.length) {
|
|
176
|
+
tracked = null;
|
|
177
|
+
} else {
|
|
178
|
+
tracked = {
|
|
179
|
+
text: view.state.doc.slice(main.from, main.to),
|
|
180
|
+
comments: selectedRanges.map((range) => ({
|
|
181
|
+
id: range.id,
|
|
182
|
+
from: range.from - main.from,
|
|
183
|
+
to: range.to - main.from,
|
|
184
|
+
})),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// console.log('track', JSON.stringify({ tracked }, undefined, 2));
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return [
|
|
192
|
+
EditorView.domEventHandlers({
|
|
193
|
+
cut: handleTrack,
|
|
194
|
+
copy: handleTrack,
|
|
195
|
+
}),
|
|
196
|
+
|
|
197
|
+
// Handle paste.
|
|
198
|
+
EditorView.updateListener.of(({ view, state, transactions }) => {
|
|
199
|
+
if (tracked) {
|
|
200
|
+
const paste = transactions.find((tr) => tr.isUserEvent('input.paste'));
|
|
201
|
+
if (paste) {
|
|
202
|
+
let found = -1;
|
|
203
|
+
paste.changes.iterChanges((fromA, toA, fromB, toB, text) => {
|
|
204
|
+
if (text.eq(tracked!.text)) {
|
|
205
|
+
for (let i = transactions.indexOf(paste!) + 1; i < transactions.length; i++) {
|
|
206
|
+
fromB = transactions[i].changes.mapPos(fromB);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
found = fromB;
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (found > -1) {
|
|
214
|
+
const comments = tracked.comments;
|
|
215
|
+
|
|
216
|
+
// Sync before recomputing cursor/range.
|
|
217
|
+
// TODO(burdon): Generalize sync facet to decouple from automerge?
|
|
218
|
+
view.state.facet(semaphoreFacet).reconcile(view);
|
|
219
|
+
|
|
220
|
+
const active = state.field(commentsStateField).ranges;
|
|
221
|
+
for (const moved of comments) {
|
|
222
|
+
if (active.some((range) => range.id === moved.id && range.from === range.to)) {
|
|
223
|
+
const range = {
|
|
224
|
+
from: found + moved.from,
|
|
225
|
+
to: found + moved.to,
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const cursor = Cursor.getCursorFromRange(state.facet(Cursor.converter), range);
|
|
229
|
+
// console.log('paste', JSON.stringify({ moved, range, cursor }, undefined, 2));
|
|
230
|
+
onUpdate(moved.id, cursor);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
tracked = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}),
|
|
239
|
+
];
|
|
202
240
|
};
|
|
203
241
|
|
|
204
242
|
/**
|
|
@@ -210,28 +248,29 @@ export type CommentsOptions = {
|
|
|
210
248
|
* 4). Tracks the current cursor position to:
|
|
211
249
|
* a). Update the decoration to show if the cursor is within a current selection.
|
|
212
250
|
* b). Calls a handler to indicate which is the closest selection (e.g., to update the thread sidebar).
|
|
213
|
-
* 5). Optionally,
|
|
214
|
-
* 6). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
251
|
+
* 5). Optionally, implements a hoverTooltip to show hints when creating a selection range.
|
|
215
252
|
*/
|
|
216
253
|
export const comments = (options: CommentsOptions = {}): Extension => {
|
|
254
|
+
const { key: shortcut = "meta-'" } = options;
|
|
255
|
+
|
|
217
256
|
const handleSelect = debounce((state: CommentsState) => options.onSelect?.(state), 200);
|
|
218
257
|
|
|
219
258
|
/**
|
|
220
259
|
* Create comment thread action.
|
|
221
260
|
*/
|
|
222
261
|
const createCommentThread: Command = (view) => {
|
|
223
|
-
const cursorConverter = view.state.facet(
|
|
262
|
+
const cursorConverter = view.state.facet(Cursor.converter);
|
|
224
263
|
|
|
225
264
|
invariant(options.onCreate);
|
|
226
|
-
const {
|
|
265
|
+
const { from, to } = view.state.selection.main;
|
|
227
266
|
if (from === to) {
|
|
228
267
|
return false;
|
|
229
268
|
}
|
|
230
269
|
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
270
|
+
const cursor = Cursor.getCursorFromRange(cursorConverter, { from, to });
|
|
271
|
+
if (cursor) {
|
|
233
272
|
// Create thread via callback.
|
|
234
|
-
const id = callbackWrapper(options.onCreate)(
|
|
273
|
+
const id = callbackWrapper(options.onCreate)(cursor, view.coordsAtPos(from));
|
|
235
274
|
if (id) {
|
|
236
275
|
// Update range.
|
|
237
276
|
view.dispatch({
|
|
@@ -239,15 +278,6 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
239
278
|
selection: { anchor: from },
|
|
240
279
|
});
|
|
241
280
|
|
|
242
|
-
// Insert footnote.
|
|
243
|
-
if (options.footnote) {
|
|
244
|
-
const tag = `[^${id}]`;
|
|
245
|
-
view.dispatch({
|
|
246
|
-
changes: { from: head, insert: tag },
|
|
247
|
-
selection: { anchor: head + tag.length },
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
281
|
return true;
|
|
252
282
|
}
|
|
253
283
|
}
|
|
@@ -255,44 +285,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
255
285
|
return false;
|
|
256
286
|
};
|
|
257
287
|
|
|
258
|
-
/**
|
|
259
|
-
* Bookmark widget decoration.
|
|
260
|
-
*/
|
|
261
|
-
const bookmarksViewPlugin = ViewPlugin.fromClass(
|
|
262
|
-
class BookmarkViewPlugin {
|
|
263
|
-
// Match markdown footnotes (option).
|
|
264
|
-
// https://www.markdownguide.org/extended-syntax/#footnotes
|
|
265
|
-
static bookmarkMatcher = new MatchDecorator({
|
|
266
|
-
regexp: /\[\^(\w+)\]/g,
|
|
267
|
-
decoration: (match, view, pos) => {
|
|
268
|
-
const id = match[1];
|
|
269
|
-
return Decoration.replace({
|
|
270
|
-
id,
|
|
271
|
-
widget: new BookmarkWidget(pos, id),
|
|
272
|
-
});
|
|
273
|
-
},
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
bookmarks: DecorationSet;
|
|
277
|
-
constructor(view: EditorView) {
|
|
278
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.createDeco(view);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
update(update: ViewUpdate) {
|
|
282
|
-
this.bookmarks = BookmarkViewPlugin.bookmarkMatcher.updateDeco(update, this.bookmarks);
|
|
283
|
-
}
|
|
284
|
-
},
|
|
285
|
-
{
|
|
286
|
-
decorations: (instance) => instance.bookmarks,
|
|
287
|
-
provide: (plugin) =>
|
|
288
|
-
EditorView.atomicRanges.of((view) => {
|
|
289
|
-
return view.plugin(plugin)?.bookmarks || Decoration.none;
|
|
290
|
-
}),
|
|
291
|
-
},
|
|
292
|
-
);
|
|
293
|
-
|
|
294
288
|
return [
|
|
295
|
-
bookmarksViewPlugin,
|
|
296
289
|
commentsStateField,
|
|
297
290
|
highlightDecorations,
|
|
298
291
|
styles,
|
|
@@ -303,7 +296,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
303
296
|
options.onCreate
|
|
304
297
|
? keymap.of([
|
|
305
298
|
{
|
|
306
|
-
key:
|
|
299
|
+
key: shortcut,
|
|
307
300
|
run: callbackWrapper(createCommentThread),
|
|
308
301
|
},
|
|
309
302
|
])
|
|
@@ -323,9 +316,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
323
316
|
end: selection.to,
|
|
324
317
|
above: true,
|
|
325
318
|
create: () => {
|
|
326
|
-
// TODO(burdon): Dispatch to react callback to render (or use SSR)?
|
|
327
319
|
const el = document.createElement('div');
|
|
328
|
-
options.onHover
|
|
320
|
+
options.onHover!(el, shortcut);
|
|
329
321
|
return { dom: el, offset: { x: 0, y: 8 } };
|
|
330
322
|
},
|
|
331
323
|
};
|
|
@@ -333,7 +325,11 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
333
325
|
|
|
334
326
|
return null;
|
|
335
327
|
},
|
|
336
|
-
{
|
|
328
|
+
{
|
|
329
|
+
// TODO(burdon): Hide on change triggered immediately?
|
|
330
|
+
// hideOnChange: true,
|
|
331
|
+
hoverTime: 1_000,
|
|
332
|
+
},
|
|
337
333
|
)
|
|
338
334
|
: [],
|
|
339
335
|
|
|
@@ -342,7 +338,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
342
338
|
// TODO(burdon): Is there a better (finer grained) way to do this?
|
|
343
339
|
//
|
|
344
340
|
EditorView.updateListener.of(({ view, state, changes }) => {
|
|
345
|
-
const cursorConverter = view.state.facet(
|
|
341
|
+
const cursorConverter = view.state.facet(Cursor.converter);
|
|
346
342
|
|
|
347
343
|
//
|
|
348
344
|
// Test if need to recompute indexed range if document changes before the end of the range.
|
|
@@ -351,17 +347,17 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
351
347
|
let mod = false;
|
|
352
348
|
const { active, ranges } = state.field(commentsStateField);
|
|
353
349
|
changes.iterChanges((from, to, from2, to2) => {
|
|
350
|
+
// TODO(burdon): Skip deleted (tracked) ranges.
|
|
354
351
|
ranges.forEach((range) => {
|
|
355
352
|
if (from2 === to2) {
|
|
356
|
-
const newRange = getRangeFromCursor(cursorConverter, range.cursor);
|
|
353
|
+
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
357
354
|
if (!newRange || newRange.to - newRange.from === 0) {
|
|
358
|
-
|
|
359
|
-
log.info('deleted comment', { thread: range.id });
|
|
355
|
+
options.onDelete?.(range.id);
|
|
360
356
|
}
|
|
361
357
|
}
|
|
362
358
|
|
|
363
359
|
if (from <= range.to) {
|
|
364
|
-
const newRange = getRangeFromCursor(cursorConverter, range.cursor);
|
|
360
|
+
const newRange = Cursor.getRangeFromCursor(cursorConverter, range.cursor);
|
|
365
361
|
Object.assign(range, newRange);
|
|
366
362
|
mod = true;
|
|
367
363
|
}
|
|
@@ -382,6 +378,8 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
382
378
|
let min = Infinity;
|
|
383
379
|
const { active, closest, ranges } = state.field(commentsStateField);
|
|
384
380
|
const selected: CommentSelected = { active: undefined, closest: undefined };
|
|
381
|
+
|
|
382
|
+
// TODO(burdon): Skip deleted (tracked) ranges.
|
|
385
383
|
ranges.forEach((comment) => {
|
|
386
384
|
const d = Math.min(Math.abs(head - comment.from), Math.abs(head - comment.to));
|
|
387
385
|
if (head >= comment.from && head <= comment.to) {
|
|
@@ -404,18 +402,7 @@ export const comments = (options: CommentsOptions = {}): Extension => {
|
|
|
404
402
|
}
|
|
405
403
|
}
|
|
406
404
|
}),
|
|
407
|
-
];
|
|
408
|
-
};
|
|
409
405
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
const to = cursorConverter.toCursor(range.to, -1);
|
|
413
|
-
return [from, to].join(':');
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
const getRangeFromCursor = (cursorConverter: CursorConverter, cursor: string) => {
|
|
417
|
-
const parts = cursor.split(':');
|
|
418
|
-
const from = cursorConverter.fromCursor(parts[0]);
|
|
419
|
-
const to = cursorConverter.fromCursor(parts[1]);
|
|
420
|
-
return from && to ? { from, to } : undefined;
|
|
406
|
+
options.onUpdate ? trackPastedComments(options.onUpdate) : [],
|
|
407
|
+
];
|
|
421
408
|
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { Facet } from '@codemirror/state';
|
|
6
|
+
|
|
7
|
+
import type { Range } from '../hooks';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts indexes into the text document into stable peer-independent cursors.
|
|
11
|
+
*
|
|
12
|
+
* See:
|
|
13
|
+
* - https://automerge.org/automerge/api-docs/js/functions/next.getCursor.html
|
|
14
|
+
* - https://github.com/yjs/yjs?tab=readme-ov-file#relative-positions
|
|
15
|
+
*
|
|
16
|
+
* @param {assoc} number Negative values will associate the cursor with the previous character
|
|
17
|
+
* while positive - with the next one.
|
|
18
|
+
*/
|
|
19
|
+
export interface CursorConverter {
|
|
20
|
+
toCursor(position: number, assoc?: -1 | 1 | undefined): string;
|
|
21
|
+
fromCursor(cursor: string): number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const defaultCursorConverter: CursorConverter = {
|
|
25
|
+
toCursor: (position) => position.toString(),
|
|
26
|
+
fromCursor: (cursor) => parseInt(cursor),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export class Cursor {
|
|
30
|
+
static readonly converter = Facet.define<CursorConverter, CursorConverter>({
|
|
31
|
+
combine: (providers) => providers[0] ?? defaultCursorConverter,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
static readonly getCursorFromRange = (cursorConverter: CursorConverter, range: Range) => {
|
|
35
|
+
const from = cursorConverter.toCursor(range.from);
|
|
36
|
+
const to = cursorConverter.toCursor(range.to, -1);
|
|
37
|
+
return [from, to].join(':');
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
static readonly getRangeFromCursor = (cursorConverter: CursorConverter, cursor: string) => {
|
|
41
|
+
const parts = cursor.split(':');
|
|
42
|
+
const from = cursorConverter.fromCursor(parts[0]);
|
|
43
|
+
const to = cursorConverter.fromCursor(parts[1]);
|
|
44
|
+
return from !== undefined && to !== undefined ? { from, to } : undefined;
|
|
45
|
+
};
|
|
46
|
+
}
|
package/src/extensions/hr.ts
CHANGED
|
@@ -11,16 +11,15 @@ import {
|
|
|
11
11
|
type ViewUpdate,
|
|
12
12
|
WidgetType,
|
|
13
13
|
} from '@codemirror/view';
|
|
14
|
-
import get from 'lodash.get';
|
|
15
14
|
|
|
16
|
-
import {
|
|
15
|
+
import { getToken } from '../styles';
|
|
17
16
|
|
|
18
17
|
// TODO(burdon): Reconcile with theme.
|
|
19
18
|
const styles = EditorView.baseTheme({
|
|
20
19
|
'& .cm-hr': {
|
|
21
20
|
// TODO(burdon): ???
|
|
22
21
|
// Note that block-level decorations should not have vertical margins,
|
|
23
|
-
borderBottom: `1px solid ${
|
|
22
|
+
borderBottom: `1px solid ${getToken('extend.colors.neutral.200')}`,
|
|
24
23
|
},
|
|
25
24
|
});
|
|
26
25
|
|
|
@@ -29,20 +28,24 @@ class HorizontalRuleWidget extends WidgetType {
|
|
|
29
28
|
super();
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
override eq(other:
|
|
31
|
+
override eq(other: this) {
|
|
33
32
|
return this._pos === (other as any as HorizontalRuleWidget)._pos;
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
toDOM(view: EditorView) {
|
|
37
|
-
// TODO(burdon): Create <hr> element? Does this clash with markdown parser?
|
|
35
|
+
override toDOM(view: EditorView) {
|
|
38
36
|
const el = document.createElement('div');
|
|
39
37
|
el.className = 'cm-hr';
|
|
40
38
|
return el;
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
41
|
|
|
42
|
+
// TODO(burdon): Like Tasklist, allow cursor to move into range.
|
|
43
|
+
|
|
44
|
+
// NOTE: Without a blank line before this markup will treat the previous line as a heading.
|
|
45
|
+
// https://www.markdownguide.org/basic-syntax/#horizontal-rules
|
|
44
46
|
const placeholderMatcher = new MatchDecorator({
|
|
45
|
-
regexp:
|
|
47
|
+
// regexp: /(?<=\n\n)---/gs,
|
|
48
|
+
regexp: /^---$/gs,
|
|
46
49
|
decoration: (match, view, pos) =>
|
|
47
50
|
Decoration.replace({
|
|
48
51
|
widget: new HorizontalRuleWidget(pos),
|
|
@@ -64,11 +67,6 @@ export const hr = () => [
|
|
|
64
67
|
},
|
|
65
68
|
{
|
|
66
69
|
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
70
|
},
|
|
73
71
|
),
|
|
74
72
|
];
|
package/src/extensions/image.ts
CHANGED
|
@@ -3,28 +3,45 @@
|
|
|
3
3
|
//
|
|
4
4
|
|
|
5
5
|
import { syntaxTree } from '@codemirror/language';
|
|
6
|
-
import {
|
|
7
|
-
|
|
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';
|
|
6
|
+
import { type EditorState, type Extension, StateField, type Transaction, type Range } from '@codemirror/state';
|
|
7
|
+
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
|
15
8
|
|
|
16
9
|
export type ImageOptions = {};
|
|
17
10
|
|
|
18
11
|
export const image = (options: ImageOptions = {}): Extension => {
|
|
19
|
-
return StateField.define<
|
|
20
|
-
create: (state) =>
|
|
21
|
-
|
|
12
|
+
return StateField.define<DecorationSet>({
|
|
13
|
+
create: (state) => {
|
|
14
|
+
return Decoration.set(buildDecorations(0, state.doc.length, state));
|
|
15
|
+
},
|
|
16
|
+
update: (value: DecorationSet, tr: Transaction) => {
|
|
17
|
+
if (!tr.docChanged && !tr.selection) {
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
// Find range of changes and cursor changes.
|
|
21
|
+
const cursor = tr.state.selection.main.head;
|
|
22
|
+
const oldCursor = tr.changes.mapPos(tr.startState.selection.main.head);
|
|
23
|
+
let from = Math.min(cursor, oldCursor);
|
|
24
|
+
let to = Math.max(cursor, oldCursor);
|
|
25
|
+
tr.changes.iterChangedRanges((fromA, toA, fromB, toB) => {
|
|
26
|
+
from = Math.min(from, fromB);
|
|
27
|
+
to = Math.max(to, toB);
|
|
28
|
+
});
|
|
29
|
+
// Expand to cover lines
|
|
30
|
+
from = tr.state.doc.lineAt(from).from;
|
|
31
|
+
to = tr.state.doc.lineAt(to).to;
|
|
32
|
+
return value.map(tr.changes).update({
|
|
33
|
+
filterFrom: from,
|
|
34
|
+
filterTo: to,
|
|
35
|
+
filter: () => false,
|
|
36
|
+
add: buildDecorations(from, to, tr.state),
|
|
37
|
+
});
|
|
38
|
+
},
|
|
22
39
|
provide: (field) => EditorView.decorations.from(field),
|
|
23
40
|
});
|
|
24
41
|
};
|
|
25
42
|
|
|
26
|
-
const
|
|
27
|
-
const
|
|
43
|
+
const buildDecorations = (from: number, to: number, state: EditorState) => {
|
|
44
|
+
const decorations: Range<Decoration>[] = [];
|
|
28
45
|
const cursor = state.selection.main.head;
|
|
29
46
|
|
|
30
47
|
syntaxTree(state).iterate({
|
|
@@ -34,20 +51,20 @@ const update = (state: EditorState, options: ImageOptions) => {
|
|
|
34
51
|
if (urlNode) {
|
|
35
52
|
const hide = state.readOnly || cursor < node.from || cursor > node.to;
|
|
36
53
|
const url = state.sliceDoc(urlNode.from, urlNode.to);
|
|
37
|
-
|
|
38
|
-
hide ? node.from : node.to,
|
|
39
|
-
node.to,
|
|
54
|
+
decorations.push(
|
|
40
55
|
Decoration.replace({
|
|
41
56
|
block: true, // Prevent cursor from entering.
|
|
42
57
|
widget: new ImageWidget(url),
|
|
43
|
-
}),
|
|
58
|
+
}).range(hide ? node.from : node.to, node.to),
|
|
44
59
|
);
|
|
45
60
|
}
|
|
46
61
|
}
|
|
47
62
|
},
|
|
63
|
+
from,
|
|
64
|
+
to,
|
|
48
65
|
});
|
|
49
66
|
|
|
50
|
-
return
|
|
67
|
+
return decorations;
|
|
51
68
|
};
|
|
52
69
|
|
|
53
70
|
class ImageWidget extends WidgetType {
|
|
@@ -55,15 +72,16 @@ class ImageWidget extends WidgetType {
|
|
|
55
72
|
super();
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
|
|
59
|
-
override eq(other: WidgetType) {
|
|
75
|
+
override eq(other: this) {
|
|
60
76
|
return this._url === (other as any as ImageWidget)._url;
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
toDOM(view: EditorView) {
|
|
79
|
+
override toDOM(view: EditorView) {
|
|
64
80
|
const img = document.createElement('img');
|
|
65
81
|
img.setAttribute('src', this._url);
|
|
66
82
|
img.setAttribute('class', 'cm-image');
|
|
83
|
+
// Images are hidden until successfully loaded to avoid flickering effects.
|
|
84
|
+
img.onload = () => img.classList.add('cm-loaded-image');
|
|
67
85
|
return img;
|
|
68
86
|
}
|
|
69
87
|
}
|
package/src/extensions/index.ts
CHANGED
|
@@ -9,13 +9,14 @@ export * from './basic';
|
|
|
9
9
|
export * from './blast';
|
|
10
10
|
export * from './code';
|
|
11
11
|
export * from './comments';
|
|
12
|
+
export * from './cursor';
|
|
12
13
|
export * from './hr';
|
|
13
14
|
export * from './image';
|
|
14
15
|
export * from './link';
|
|
15
16
|
export * from './listener';
|
|
16
17
|
export * from './markdown';
|
|
17
18
|
export * from './mention';
|
|
18
|
-
export * from './
|
|
19
|
+
export * from './outliner';
|
|
19
20
|
export * from './table';
|
|
20
21
|
export * from './tasklist';
|
|
21
22
|
export * from './typewriter';
|