@dxos/ui-editor 0.0.0
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/LICENSE +8 -0
- package/README.md +21 -0
- package/package.json +121 -0
- package/src/defaults.ts +34 -0
- package/src/extensions/annotations.ts +55 -0
- package/src/extensions/autocomplete/autocomplete.ts +151 -0
- package/src/extensions/autocomplete/index.ts +8 -0
- package/src/extensions/autocomplete/match.ts +46 -0
- package/src/extensions/autocomplete/placeholder.ts +117 -0
- package/src/extensions/autocomplete/typeahead.ts +87 -0
- package/src/extensions/automerge/automerge.test.tsx +76 -0
- package/src/extensions/automerge/automerge.ts +105 -0
- package/src/extensions/automerge/cursor.ts +28 -0
- package/src/extensions/automerge/defs.ts +31 -0
- package/src/extensions/automerge/index.ts +5 -0
- package/src/extensions/automerge/sync.ts +79 -0
- package/src/extensions/automerge/update-automerge.ts +50 -0
- package/src/extensions/automerge/update-codemirror.ts +115 -0
- package/src/extensions/autoscroll.ts +165 -0
- package/src/extensions/awareness/awareness-provider.ts +127 -0
- package/src/extensions/awareness/awareness.ts +315 -0
- package/src/extensions/awareness/index.ts +6 -0
- package/src/extensions/blast.ts +363 -0
- package/src/extensions/blocks.ts +131 -0
- package/src/extensions/bookmarks.ts +77 -0
- package/src/extensions/comments.ts +579 -0
- package/src/extensions/debug.ts +15 -0
- package/src/extensions/dnd.ts +39 -0
- package/src/extensions/factories.ts +284 -0
- package/src/extensions/focus.ts +36 -0
- package/src/extensions/folding.ts +63 -0
- package/src/extensions/hashtag.ts +68 -0
- package/src/extensions/index.ts +34 -0
- package/src/extensions/json.ts +57 -0
- package/src/extensions/listener.ts +32 -0
- package/src/extensions/markdown/action.ts +117 -0
- package/src/extensions/markdown/bundle.ts +105 -0
- package/src/extensions/markdown/changes.test.ts +26 -0
- package/src/extensions/markdown/changes.ts +149 -0
- package/src/extensions/markdown/debug.ts +44 -0
- package/src/extensions/markdown/decorate.ts +622 -0
- package/src/extensions/markdown/formatting.test.ts +498 -0
- package/src/extensions/markdown/formatting.ts +1265 -0
- package/src/extensions/markdown/highlight.ts +183 -0
- package/src/extensions/markdown/image.ts +118 -0
- package/src/extensions/markdown/index.ts +13 -0
- package/src/extensions/markdown/link.ts +50 -0
- package/src/extensions/markdown/parser.test.ts +75 -0
- package/src/extensions/markdown/styles.ts +135 -0
- package/src/extensions/markdown/table.ts +150 -0
- package/src/extensions/mention.ts +41 -0
- package/src/extensions/modal.ts +24 -0
- package/src/extensions/modes.ts +41 -0
- package/src/extensions/outliner/commands.ts +270 -0
- package/src/extensions/outliner/editor.test.ts +33 -0
- package/src/extensions/outliner/editor.ts +184 -0
- package/src/extensions/outliner/index.ts +7 -0
- package/src/extensions/outliner/menu.ts +128 -0
- package/src/extensions/outliner/outliner.test.ts +100 -0
- package/src/extensions/outliner/outliner.ts +167 -0
- package/src/extensions/outliner/selection.ts +50 -0
- package/src/extensions/outliner/tree.test.ts +168 -0
- package/src/extensions/outliner/tree.ts +317 -0
- package/src/extensions/preview/index.ts +5 -0
- package/src/extensions/preview/preview.ts +193 -0
- package/src/extensions/replacer.test.ts +75 -0
- package/src/extensions/replacer.ts +93 -0
- package/src/extensions/scrolling.ts +189 -0
- package/src/extensions/selection.ts +100 -0
- package/src/extensions/state.ts +7 -0
- package/src/extensions/submit.ts +62 -0
- package/src/extensions/tags/extended-markdown.test.ts +263 -0
- package/src/extensions/tags/extended-markdown.ts +78 -0
- package/src/extensions/tags/index.ts +7 -0
- package/src/extensions/tags/streamer.ts +243 -0
- package/src/extensions/tags/xml-tags.ts +507 -0
- package/src/extensions/tags/xml-util.test.ts +48 -0
- package/src/extensions/tags/xml-util.ts +93 -0
- package/src/extensions/typewriter.ts +68 -0
- package/src/index.ts +14 -0
- package/src/styles/index.ts +7 -0
- package/src/styles/markdown.ts +26 -0
- package/src/styles/theme.ts +293 -0
- package/src/styles/tokens.ts +17 -0
- package/src/types/index.ts +5 -0
- package/src/types/types.ts +32 -0
- package/src/util/cursor.ts +56 -0
- package/src/util/debug.ts +56 -0
- package/src/util/decorations.ts +21 -0
- package/src/util/dom.ts +36 -0
- package/src/util/facet.ts +13 -0
- package/src/util/index.ts +10 -0
- package/src/util/util.ts +29 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { syntaxTree } from '@codemirror/language';
|
|
6
|
+
import { type EditorState, Prec, RangeSetBuilder, StateEffect } from '@codemirror/state';
|
|
7
|
+
import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate, WidgetType } from '@codemirror/view';
|
|
8
|
+
import { type SyntaxNodeRef } from '@lezer/common';
|
|
9
|
+
|
|
10
|
+
import { invariant } from '@dxos/invariant';
|
|
11
|
+
import { mx } from '@dxos/ui-theme';
|
|
12
|
+
|
|
13
|
+
import { type HeadingLevel, markdownTheme } from '../../styles';
|
|
14
|
+
import { type RenderCallback } from '../../types';
|
|
15
|
+
import { wrapWithCatch } from '../../util';
|
|
16
|
+
|
|
17
|
+
import { adjustChanges } from './changes';
|
|
18
|
+
import { image } from './image';
|
|
19
|
+
import { bulletListIndentationWidth, formattingStyles, orderedListIndentationWidth } from './styles';
|
|
20
|
+
import { table } from './table';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Unicode characters.
|
|
24
|
+
* NOTE: Depends on font.
|
|
25
|
+
* https://www.compart.com/en/unicode (nice resource).
|
|
26
|
+
* https://en.wikipedia.org/wiki/List_of_Unicode_characters
|
|
27
|
+
*/
|
|
28
|
+
const Unicode = {
|
|
29
|
+
emDash: '\u2014',
|
|
30
|
+
bullet: '\u2022',
|
|
31
|
+
bulletSmall: '\u2219',
|
|
32
|
+
bulletSquare: '\u2b1d',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
//
|
|
36
|
+
// Widgets
|
|
37
|
+
//
|
|
38
|
+
|
|
39
|
+
class HorizontalRuleWidget extends WidgetType {
|
|
40
|
+
override toDOM(): HTMLSpanElement {
|
|
41
|
+
const el = document.createElement('span');
|
|
42
|
+
el.className = 'cm-hr';
|
|
43
|
+
return el;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class LinkButton extends WidgetType {
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly url: string,
|
|
50
|
+
private readonly render: RenderCallback<{ url: string }>,
|
|
51
|
+
) {
|
|
52
|
+
super();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override eq(other: this) {
|
|
56
|
+
return this.url === other.url;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// TODO(burdon): Create icon and link directly without react?
|
|
60
|
+
override toDOM(view: EditorView) {
|
|
61
|
+
const el = document.createElement('span');
|
|
62
|
+
this.render(el, { url: this.url }, view);
|
|
63
|
+
return el;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class CheckboxWidget extends WidgetType {
|
|
68
|
+
constructor(private _checked: boolean) {
|
|
69
|
+
super();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
override eq(other: this) {
|
|
73
|
+
return this._checked === other._checked;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
override ignoreEvent() {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
override toDOM(view: EditorView) {
|
|
81
|
+
const input = document.createElement('input');
|
|
82
|
+
input.className = 'cm-task-checkbox dx-checkbox';
|
|
83
|
+
input.type = 'checkbox';
|
|
84
|
+
input.tabIndex = -1;
|
|
85
|
+
input.checked = this._checked;
|
|
86
|
+
if (view.state.readOnly) {
|
|
87
|
+
input.setAttribute('disabled', 'true');
|
|
88
|
+
} else {
|
|
89
|
+
input.onmousedown = (event: Event) => {
|
|
90
|
+
// Could be beginning of line.
|
|
91
|
+
const line = view.state.doc.lineAt(view.posAtDOM(span));
|
|
92
|
+
const text = view.state.sliceDoc(line.from, line.to);
|
|
93
|
+
const match = text.match(/^\s*- (\[[xX ]]).*/);
|
|
94
|
+
if (match) {
|
|
95
|
+
const [, checked] = match;
|
|
96
|
+
const pos = line.from + text.indexOf(checked);
|
|
97
|
+
this._checked = checked !== '[ ]';
|
|
98
|
+
view.dispatch({
|
|
99
|
+
changes: { from: pos + 1, to: pos + 2, insert: this._checked ? ' ' : 'x' },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
event.preventDefault();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const span = document.createElement('span');
|
|
108
|
+
span.className = 'cm-task';
|
|
109
|
+
span.appendChild(input);
|
|
110
|
+
return span;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class TextWidget extends WidgetType {
|
|
115
|
+
constructor(
|
|
116
|
+
private readonly text: string,
|
|
117
|
+
private readonly className?: string,
|
|
118
|
+
) {
|
|
119
|
+
super();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
override toDOM(): HTMLSpanElement {
|
|
123
|
+
const el = document.createElement('span');
|
|
124
|
+
if (this.className) {
|
|
125
|
+
el.className = this.className;
|
|
126
|
+
}
|
|
127
|
+
el.innerText = this.text;
|
|
128
|
+
return el;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const hide = Decoration.replace({});
|
|
133
|
+
const blockQuote = Decoration.line({ class: 'cm-blockquote' });
|
|
134
|
+
const fencedCodeLine = Decoration.line({ class: 'cm-code cm-codeblock-line' });
|
|
135
|
+
const fencedCodeLineFirst = Decoration.line({ class: mx('cm-code cm-codeblock-line', 'cm-codeblock-start') });
|
|
136
|
+
const fencedCodeLineLast = Decoration.line({ class: mx('cm-code cm-codeblock-line', 'cm-codeblock-end') });
|
|
137
|
+
const commentBlockLine = fencedCodeLine;
|
|
138
|
+
const commentBlockLineFirst = fencedCodeLineFirst;
|
|
139
|
+
const commentBlockLineLast = fencedCodeLineLast;
|
|
140
|
+
const horizontalRule = Decoration.replace({ widget: new HorizontalRuleWidget() });
|
|
141
|
+
const checkedTask = Decoration.replace({ widget: new CheckboxWidget(true) });
|
|
142
|
+
const uncheckedTask = Decoration.replace({ widget: new CheckboxWidget(false) });
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Checks if cursor is inside text.
|
|
146
|
+
*/
|
|
147
|
+
const editingRange = (state: EditorState, range: { from: number; to: number }, focus: boolean) => {
|
|
148
|
+
const {
|
|
149
|
+
readOnly,
|
|
150
|
+
selection: {
|
|
151
|
+
main: { head },
|
|
152
|
+
},
|
|
153
|
+
} = state;
|
|
154
|
+
return focus && !readOnly && head >= range.from && head <= range.to;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const autoHideTags = new Set([
|
|
158
|
+
'CodeMark',
|
|
159
|
+
'CodeInfo',
|
|
160
|
+
'EmphasisMark',
|
|
161
|
+
'StrikethroughMark',
|
|
162
|
+
'SubscriptMark',
|
|
163
|
+
'SuperscriptMark',
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Markdown list level.
|
|
168
|
+
*/
|
|
169
|
+
type NumberingLevel = { type: string; from: number; to: number; level: number; number: number };
|
|
170
|
+
|
|
171
|
+
const buildDecorations = (view: EditorView, options: DecorateOptions, focus: boolean) => {
|
|
172
|
+
const decoRanges: { from: number; to: number; deco: Decoration }[] = [];
|
|
173
|
+
const atomicDecoRanges: { from: number; to: number; deco: Decoration }[] = [];
|
|
174
|
+
const { state } = view;
|
|
175
|
+
|
|
176
|
+
// Header numbering.
|
|
177
|
+
// TODO(burdon): Pre-parse headers to allow virtualization.
|
|
178
|
+
const headerLevels: (NumberingLevel | null)[] = [];
|
|
179
|
+
const getHeaderLevels = (node: SyntaxNodeRef, level: number): (NumberingLevel | null)[] => {
|
|
180
|
+
invariant(level > 0);
|
|
181
|
+
if (level > headerLevels.length) {
|
|
182
|
+
const len = headerLevels.length;
|
|
183
|
+
headerLevels.length = level;
|
|
184
|
+
headerLevels.fill(null, len);
|
|
185
|
+
headerLevels[level - 1] = { type: node.name, from: node.from, to: node.to, level, number: 0 };
|
|
186
|
+
} else {
|
|
187
|
+
headerLevels.splice(level);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return headerLevels.slice(0, level);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
// List numbering and indentation.
|
|
194
|
+
const listLevels: NumberingLevel[] = [];
|
|
195
|
+
const enterList = (node: SyntaxNodeRef) => {
|
|
196
|
+
listLevels.push({ type: node.name, from: node.from, to: node.to, level: listLevels.length, number: 0 });
|
|
197
|
+
};
|
|
198
|
+
const leaveList = () => {
|
|
199
|
+
listLevels.pop();
|
|
200
|
+
};
|
|
201
|
+
const getCurrentListLevel = (): NumberingLevel => {
|
|
202
|
+
invariant(listLevels.length);
|
|
203
|
+
return listLevels[listLevels.length - 1];
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// let count = 0;
|
|
207
|
+
const enterNode = (node: SyntaxNodeRef) => {
|
|
208
|
+
// console.log(`[${count++}]`, { node: node.name, from: node.from, to: node.to });
|
|
209
|
+
switch (node.name) {
|
|
210
|
+
// ATXHeading > HeaderMark > Paragraph
|
|
211
|
+
// NOTE: Numbering requires processing the entire document since otherwise only the visible range will be
|
|
212
|
+
// processed and the numbering will be incorrect.
|
|
213
|
+
case 'ATXHeading1':
|
|
214
|
+
case 'ATXHeading2':
|
|
215
|
+
case 'ATXHeading3':
|
|
216
|
+
case 'ATXHeading4':
|
|
217
|
+
case 'ATXHeading5':
|
|
218
|
+
case 'ATXHeading6': {
|
|
219
|
+
const level = parseInt(node.name['ATXHeading'.length]) as HeadingLevel;
|
|
220
|
+
const headers = getHeaderLevels(node, level);
|
|
221
|
+
if (options.numberedHeadings?.from !== undefined) {
|
|
222
|
+
const header = headers[level - 1];
|
|
223
|
+
// TODO(burdon): Header will be missing if headers are out of order (e.g., ## header then # header).
|
|
224
|
+
if (header) {
|
|
225
|
+
header.number++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const editing = editingRange(state, node, focus);
|
|
230
|
+
if (editing) {
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const mark = node.node.firstChild!;
|
|
235
|
+
if (mark?.name === 'HeaderMark') {
|
|
236
|
+
const { from, to = 6 } = options.numberedHeadings ?? {};
|
|
237
|
+
const text = state.sliceDoc(node.from, node.to);
|
|
238
|
+
const len = text.match(/[#\s]+/)![0].length;
|
|
239
|
+
if (!from || level < from || level > to) {
|
|
240
|
+
atomicDecoRanges.push({ from: mark.from, to: mark.from + len, deco: hide });
|
|
241
|
+
} else {
|
|
242
|
+
// TODO(burdon): Number format/style.
|
|
243
|
+
const num =
|
|
244
|
+
headers
|
|
245
|
+
.slice(from - 1)
|
|
246
|
+
.map((level) => level?.number ?? 0)
|
|
247
|
+
.join('.') + ' ';
|
|
248
|
+
|
|
249
|
+
if (num.length) {
|
|
250
|
+
atomicDecoRanges.push({
|
|
251
|
+
from: mark.from,
|
|
252
|
+
to: mark.from + len,
|
|
253
|
+
deco: Decoration.replace({
|
|
254
|
+
widget: new TextWidget(num, markdownTheme.heading(level)),
|
|
255
|
+
}),
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
//
|
|
265
|
+
// Lists.
|
|
266
|
+
// [BulletList | OrderedList] > (ListItem > ListMark) > (Task > TaskMarker)?
|
|
267
|
+
//
|
|
268
|
+
|
|
269
|
+
case 'BulletList':
|
|
270
|
+
case 'OrderedList': {
|
|
271
|
+
enterList(node);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'ListItem': {
|
|
276
|
+
const line = state.doc.lineAt(node.from);
|
|
277
|
+
|
|
278
|
+
// Set indentation.
|
|
279
|
+
const list = getCurrentListLevel();
|
|
280
|
+
const width = list.type === 'OrderedList' ? orderedListIndentationWidth : bulletListIndentationWidth;
|
|
281
|
+
const offset = (options?.listPaddingLeft ?? 0) + ((list.level ?? 0) + 1) * width;
|
|
282
|
+
if (node.from === line.to - 1) {
|
|
283
|
+
// Abort if only the hyphen is typed.
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Add line decoration for the continuation indent.
|
|
288
|
+
// TODO(burdon): Bug if indentation is more than one indentation unit (e.g., 4 spaces) from the previous line.
|
|
289
|
+
decoRanges.push({
|
|
290
|
+
from: line.from,
|
|
291
|
+
to: line.from,
|
|
292
|
+
deco: Decoration.line({
|
|
293
|
+
class: 'cm-list-item',
|
|
294
|
+
attributes: {
|
|
295
|
+
style: `padding-left: ${offset}px; text-indent: -${width}px;`,
|
|
296
|
+
},
|
|
297
|
+
}),
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
case 'ListMark': {
|
|
304
|
+
const list = getCurrentListLevel();
|
|
305
|
+
|
|
306
|
+
// Look-ahead for task marker.
|
|
307
|
+
// NOTE: Requires space to exist (otherwise the text is parsed as the start of a link).
|
|
308
|
+
const next = tree.resolve(node.to + 1, 1);
|
|
309
|
+
if (next?.name === 'TaskMarker') {
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// TODO(burdon): Option to make hierarchical; or a), i), etc.
|
|
314
|
+
const label = list.type === 'OrderedList' ? `${++list.number}.` : Unicode.bulletSmall;
|
|
315
|
+
const line = state.doc.lineAt(node.from);
|
|
316
|
+
const to = state.doc.sliceString(node.to, node.to + 1) === ' ' ? node.to + 1 : node.to;
|
|
317
|
+
atomicDecoRanges.push({
|
|
318
|
+
from: line.from,
|
|
319
|
+
to,
|
|
320
|
+
deco: Decoration.replace({
|
|
321
|
+
widget: new TextWidget(
|
|
322
|
+
label,
|
|
323
|
+
list.type === 'OrderedList' ? 'cm-list-mark cm-list-mark-ordered' : 'cm-list-mark cm-list-mark-bullet',
|
|
324
|
+
),
|
|
325
|
+
}),
|
|
326
|
+
});
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
case 'TaskMarker': {
|
|
331
|
+
const checked = state.doc.sliceString(node.from + 1, node.to - 1) === 'x';
|
|
332
|
+
// Check if the next character is a space and if so, include it in the replacement.
|
|
333
|
+
const line = state.doc.lineAt(node.from);
|
|
334
|
+
const to = state.doc.sliceString(node.to, node.to + 1) === ' ' ? node.to + 1 : node.to;
|
|
335
|
+
atomicDecoRanges.push({ from: line.from, to, deco: checked ? checkedTask : uncheckedTask });
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
//
|
|
340
|
+
// Blockquote > QuoteMark > Paragraph
|
|
341
|
+
//
|
|
342
|
+
|
|
343
|
+
case 'Blockquote': {
|
|
344
|
+
const editing = editingRange(state, node, focus);
|
|
345
|
+
const quoteMark = node.node.getChild('QuoteMark');
|
|
346
|
+
const paragraph = node.node.getChild('Paragraph');
|
|
347
|
+
if (!editing && quoteMark && paragraph) {
|
|
348
|
+
atomicDecoRanges.push({ from: quoteMark.from, to: paragraph.from, deco: hide });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const block of view.viewportLineBlocks) {
|
|
352
|
+
if (block.to < node.from) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (block.from > node.to) {
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
decoRanges.push({ from: block.from, to: block.from, deco: blockQuote });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
//
|
|
366
|
+
// CommentBlock
|
|
367
|
+
//
|
|
368
|
+
|
|
369
|
+
case 'CommentBlock': {
|
|
370
|
+
const editing = editingRange(state, node, focus);
|
|
371
|
+
for (const block of view.viewportLineBlocks) {
|
|
372
|
+
if (block.to < node.from) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (block.from > node.to) {
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const isFirst = block.from <= node.from;
|
|
380
|
+
const isLast = block.to >= node.to && /^(\s>)*-->$/.test(state.doc.sliceString(block.from, block.to));
|
|
381
|
+
|
|
382
|
+
decoRanges.push({
|
|
383
|
+
from: block.from,
|
|
384
|
+
to: block.from,
|
|
385
|
+
deco: isFirst ? commentBlockLineFirst : isLast ? commentBlockLineLast : commentBlockLine,
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
if (!editing && (isFirst || isLast)) {
|
|
389
|
+
atomicDecoRanges.push({ from: block.from, to: block.to, deco: hide });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
//
|
|
396
|
+
// FencedCode > CodeMark > [CodeInfo] > CodeText > CodeMark
|
|
397
|
+
//
|
|
398
|
+
|
|
399
|
+
case 'FencedCode': {
|
|
400
|
+
for (const block of view.viewportLineBlocks) {
|
|
401
|
+
if (block.to < node.from) {
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (block.from > node.to) {
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const first = block.from <= node.from;
|
|
409
|
+
const last = block.to >= node.to && /```$/.test(state.doc.sliceString(block.from, block.to));
|
|
410
|
+
decoRanges.push({
|
|
411
|
+
from: block.from,
|
|
412
|
+
to: block.from,
|
|
413
|
+
deco: first ? fencedCodeLineFirst : last ? fencedCodeLineLast : fencedCodeLine,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const editing = editingRange(state, node, focus);
|
|
417
|
+
if (!editing && (first || last)) {
|
|
418
|
+
atomicDecoRanges.push({ from: block.from, to: block.to, deco: hide });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
//
|
|
425
|
+
// Link > [LinkMark, URL]
|
|
426
|
+
//
|
|
427
|
+
|
|
428
|
+
case 'Link': {
|
|
429
|
+
const marks = node.node.getChildren('LinkMark');
|
|
430
|
+
const urlNode = node.node.getChild('URL');
|
|
431
|
+
const editing = editingRange(state, node, focus);
|
|
432
|
+
if (urlNode && marks.length >= 2) {
|
|
433
|
+
const url = state.sliceDoc(urlNode.from, urlNode.to);
|
|
434
|
+
if (options.skip?.({ name: 'Link', url })) {
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
if (!editing) {
|
|
438
|
+
atomicDecoRanges.push({ from: node.from, to: marks[0].to, deco: hide });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
decoRanges.push({
|
|
442
|
+
from: marks[0].to,
|
|
443
|
+
to: marks[1].from,
|
|
444
|
+
deco: Decoration.mark({
|
|
445
|
+
tagName: 'a',
|
|
446
|
+
attributes: {
|
|
447
|
+
class: 'cm-link',
|
|
448
|
+
href: url,
|
|
449
|
+
rel: 'noreferrer',
|
|
450
|
+
target: '_blank',
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (!editing) {
|
|
456
|
+
atomicDecoRanges.push({
|
|
457
|
+
from: marks[1].from,
|
|
458
|
+
to: node.to,
|
|
459
|
+
deco: options.renderLinkButton
|
|
460
|
+
? Decoration.replace({ widget: new LinkButton(url, options.renderLinkButton) })
|
|
461
|
+
: hide,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
//
|
|
469
|
+
// HR
|
|
470
|
+
//
|
|
471
|
+
|
|
472
|
+
case 'HorizontalRule': {
|
|
473
|
+
if (!editingRange(state, node, focus)) {
|
|
474
|
+
decoRanges.push({ from: node.from, to: node.to, deco: horizontalRule });
|
|
475
|
+
}
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
default: {
|
|
480
|
+
if (autoHideTags.has(node.name)) {
|
|
481
|
+
if (!editingRange(state, node.node.parent!, focus)) {
|
|
482
|
+
atomicDecoRanges.push({ from: node.from, to: node.to, deco: hide });
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const leaveNode = (node: SyntaxNodeRef) => {
|
|
490
|
+
switch (node.name) {
|
|
491
|
+
case 'BulletList':
|
|
492
|
+
case 'OrderedList': {
|
|
493
|
+
leaveList();
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const tree = syntaxTree(state);
|
|
500
|
+
if (options.numberedHeadings?.from === undefined) {
|
|
501
|
+
for (const { from, to } of view.visibleRanges) {
|
|
502
|
+
tree.iterate({
|
|
503
|
+
from,
|
|
504
|
+
to,
|
|
505
|
+
enter: wrapWithCatch(enterNode, 'decorate.enter'),
|
|
506
|
+
leave: wrapWithCatch(leaveNode, 'decorate.leave'),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
} else {
|
|
510
|
+
// NOTE: If line numbering then we must iterate from the start of document.
|
|
511
|
+
tree.iterate({
|
|
512
|
+
enter: wrapWithCatch(enterNode, 'decorate.enter'),
|
|
513
|
+
leave: wrapWithCatch(leaveNode, 'decorate.leave'),
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Sort and build decoration sets.
|
|
518
|
+
const sortRanges = (a: { from: number; to: number }, b: { from: number; to: number }) =>
|
|
519
|
+
a.from - b.from || a.to - b.to;
|
|
520
|
+
|
|
521
|
+
decoRanges.sort(sortRanges);
|
|
522
|
+
atomicDecoRanges.sort(sortRanges);
|
|
523
|
+
|
|
524
|
+
const deco = new RangeSetBuilder<Decoration>();
|
|
525
|
+
for (const { from, to, deco: d } of decoRanges) {
|
|
526
|
+
deco.add(from, to, d);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const atomicDeco = new RangeSetBuilder<Decoration>();
|
|
530
|
+
for (const { from, to, deco: d } of atomicDecoRanges) {
|
|
531
|
+
atomicDeco.add(from, to, d);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return {
|
|
535
|
+
deco: deco.finish(),
|
|
536
|
+
atomicDeco: atomicDeco.finish(),
|
|
537
|
+
};
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const forceUpdate = StateEffect.define<null>();
|
|
541
|
+
|
|
542
|
+
export type NodeData = { name: 'Link'; url: string } | { name: 'Image'; url: string };
|
|
543
|
+
|
|
544
|
+
export interface DecorateOptions {
|
|
545
|
+
/**
|
|
546
|
+
* Prevents triggering decorations as the cursor moves through the document.
|
|
547
|
+
*/
|
|
548
|
+
selectionChangeDelay?: number;
|
|
549
|
+
numberedHeadings?: { from: number; to?: number };
|
|
550
|
+
// TODO(burdon): Additional padding for each line.
|
|
551
|
+
listPaddingLeft?: number;
|
|
552
|
+
// TODO(burdon): Use consistently.
|
|
553
|
+
skip?: (node: NodeData) => boolean;
|
|
554
|
+
// TODO(burdon): Remove.
|
|
555
|
+
renderLinkButton?: RenderCallback<{ url: string }>;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export const decorateMarkdown = (options: DecorateOptions = {}) => {
|
|
559
|
+
return [
|
|
560
|
+
ViewPlugin.fromClass(
|
|
561
|
+
class {
|
|
562
|
+
deco: DecorationSet;
|
|
563
|
+
atomicDeco: DecorationSet;
|
|
564
|
+
pendingUpdate?: NodeJS.Timeout;
|
|
565
|
+
|
|
566
|
+
constructor(view: EditorView) {
|
|
567
|
+
({ deco: this.deco, atomicDeco: this.atomicDeco } = buildDecorations(view, options, view.hasFocus));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
update(update: ViewUpdate) {
|
|
571
|
+
if (
|
|
572
|
+
update.docChanged ||
|
|
573
|
+
update.viewportChanged ||
|
|
574
|
+
update.focusChanged ||
|
|
575
|
+
update.transactions.some((tr) => tr.effects.some((effect) => effect.is(forceUpdate))) ||
|
|
576
|
+
(update.selectionSet && !options.selectionChangeDelay)
|
|
577
|
+
) {
|
|
578
|
+
({ deco: this.deco, atomicDeco: this.atomicDeco } = buildDecorations(
|
|
579
|
+
update.view,
|
|
580
|
+
options,
|
|
581
|
+
update.view.hasFocus,
|
|
582
|
+
));
|
|
583
|
+
|
|
584
|
+
this.clearUpdate();
|
|
585
|
+
} else if (update.selectionSet) {
|
|
586
|
+
this.scheduleUpdate(update.view);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Defer update in case moving through the document.
|
|
591
|
+
scheduleUpdate(view: EditorView) {
|
|
592
|
+
this.clearUpdate();
|
|
593
|
+
this.pendingUpdate = setTimeout(() => {
|
|
594
|
+
view.dispatch({ effects: forceUpdate.of(null) });
|
|
595
|
+
}, options.selectionChangeDelay);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
clearUpdate() {
|
|
599
|
+
if (this.pendingUpdate) {
|
|
600
|
+
clearTimeout(this.pendingUpdate);
|
|
601
|
+
this.pendingUpdate = undefined;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
destroy() {
|
|
606
|
+
this.clearUpdate();
|
|
607
|
+
}
|
|
608
|
+
},
|
|
609
|
+
{
|
|
610
|
+
provide: (plugin) => [
|
|
611
|
+
Prec.low(EditorView.decorations.of((view) => view.plugin(plugin)?.deco ?? Decoration.none)),
|
|
612
|
+
EditorView.decorations.of((view) => view.plugin(plugin)?.atomicDeco ?? Decoration.none),
|
|
613
|
+
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.atomicDeco ?? Decoration.none),
|
|
614
|
+
],
|
|
615
|
+
},
|
|
616
|
+
),
|
|
617
|
+
image(),
|
|
618
|
+
table(),
|
|
619
|
+
adjustChanges(),
|
|
620
|
+
formattingStyles,
|
|
621
|
+
];
|
|
622
|
+
};
|