@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,1265 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2024 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { snippet } from '@codemirror/autocomplete';
|
|
6
|
+
import { syntaxTree } from '@codemirror/language';
|
|
7
|
+
import {
|
|
8
|
+
type ChangeSpec,
|
|
9
|
+
EditorSelection,
|
|
10
|
+
type EditorState,
|
|
11
|
+
type Extension,
|
|
12
|
+
type Line,
|
|
13
|
+
type StateCommand,
|
|
14
|
+
type Text,
|
|
15
|
+
} from '@codemirror/state';
|
|
16
|
+
import { EditorView, type ViewUpdate, keymap } from '@codemirror/view';
|
|
17
|
+
import { type SyntaxNode, type SyntaxNodeRef } from '@lezer/common';
|
|
18
|
+
|
|
19
|
+
import { debounceAndThrottle } from '@dxos/async';
|
|
20
|
+
|
|
21
|
+
// Markdown refs:
|
|
22
|
+
// https://github.github.com/gfm
|
|
23
|
+
// https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax
|
|
24
|
+
|
|
25
|
+
// Describes the formatting situation of the selection in an editor state.
|
|
26
|
+
// For inline styles `strong`, `emphasis`, `strikethrough`, and `code`,
|
|
27
|
+
// the field only holds true when *all* selected text has the style,
|
|
28
|
+
// or when the selection is a cursor inside such a style.
|
|
29
|
+
export type Formatting = Partial<{
|
|
30
|
+
blankLine: boolean;
|
|
31
|
+
// The type of the block at the selection.
|
|
32
|
+
// If multiple different block types are selected, this will hold null.
|
|
33
|
+
blockType:
|
|
34
|
+
| 'codeblock'
|
|
35
|
+
| 'heading1'
|
|
36
|
+
| 'heading2'
|
|
37
|
+
| 'heading3'
|
|
38
|
+
| 'heading4'
|
|
39
|
+
| 'heading5'
|
|
40
|
+
| 'heading6'
|
|
41
|
+
| 'paragraph'
|
|
42
|
+
| 'tablecell'
|
|
43
|
+
| null;
|
|
44
|
+
// Whether all selected text is wrapped in a blockquote.
|
|
45
|
+
blockQuote: boolean;
|
|
46
|
+
// Whether the selected text is strong.
|
|
47
|
+
strong: boolean;
|
|
48
|
+
// Whether the selected text is emphasized.
|
|
49
|
+
emphasis: boolean;
|
|
50
|
+
// Whether the selected text is stricken through.
|
|
51
|
+
strikethrough: boolean;
|
|
52
|
+
// Whether the selected text is inline code.
|
|
53
|
+
code: boolean;
|
|
54
|
+
// Whether there are links in the selected text.
|
|
55
|
+
link: boolean;
|
|
56
|
+
// If all selected blocks have the same (innermost) list style, that is indicated here.
|
|
57
|
+
listStyle: null | 'ordered' | 'bullet' | 'task';
|
|
58
|
+
}>;
|
|
59
|
+
|
|
60
|
+
export const formattingEquals = (a: Formatting, b: Formatting) =>
|
|
61
|
+
a.blockType === b.blockType &&
|
|
62
|
+
a.strong === b.strong &&
|
|
63
|
+
a.emphasis === b.emphasis &&
|
|
64
|
+
a.strikethrough === b.strikethrough &&
|
|
65
|
+
a.code === b.code &&
|
|
66
|
+
a.link === b.link &&
|
|
67
|
+
a.listStyle === b.listStyle &&
|
|
68
|
+
a.blockQuote === b.blockQuote;
|
|
69
|
+
|
|
70
|
+
export enum Inline {
|
|
71
|
+
Strong = 0,
|
|
72
|
+
Emphasis = 1,
|
|
73
|
+
Strikethrough = 2,
|
|
74
|
+
Code = 3,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export enum List {
|
|
78
|
+
Ordered,
|
|
79
|
+
Bullet,
|
|
80
|
+
Task,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//
|
|
84
|
+
// Headings
|
|
85
|
+
//
|
|
86
|
+
|
|
87
|
+
export const setHeading = (level: number): StateCommand => {
|
|
88
|
+
return ({ state, dispatch }) => {
|
|
89
|
+
const {
|
|
90
|
+
selection: { ranges },
|
|
91
|
+
doc,
|
|
92
|
+
} = state;
|
|
93
|
+
const changes: ChangeSpec[] = [];
|
|
94
|
+
let prevBlock = -1;
|
|
95
|
+
for (const range of ranges) {
|
|
96
|
+
let sawBlock = false;
|
|
97
|
+
syntaxTree(state).iterate({
|
|
98
|
+
from: range.from,
|
|
99
|
+
to: range.to,
|
|
100
|
+
enter: (node) => {
|
|
101
|
+
if (!Object.hasOwn(Textblocks, node.name) || prevBlock === node.from) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
sawBlock = true;
|
|
105
|
+
prevBlock = node.from;
|
|
106
|
+
const blockType = Textblocks[node.name];
|
|
107
|
+
const isHeading = /heading(\d)/.exec(blockType);
|
|
108
|
+
const curLevel = isHeading ? +isHeading[1] : node.name === 'Paragraph' ? 0 : -1;
|
|
109
|
+
if (curLevel < 0 || curLevel === level) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (curLevel === 0) {
|
|
113
|
+
changes.push({ from: node.from, insert: '#'.repeat(level) + ' ' });
|
|
114
|
+
} else if (node.name === 'SetextHeading1' || node.name === 'SetextHeading2') {
|
|
115
|
+
// Change Setext heading to regular one.
|
|
116
|
+
const nextLine = doc.lineAt(node.to);
|
|
117
|
+
if (level) {
|
|
118
|
+
changes.push({ from: node.from, insert: '#'.repeat(level) + ' ' });
|
|
119
|
+
}
|
|
120
|
+
changes.push({ from: nextLine.from - 1, to: nextLine.to });
|
|
121
|
+
} else {
|
|
122
|
+
// Adjust the level of an ATX heading.
|
|
123
|
+
if (level === 0) {
|
|
124
|
+
changes.push({ from: node.from, to: Math.min(node.to, node.from + curLevel + 1) });
|
|
125
|
+
} else if (level < curLevel) {
|
|
126
|
+
changes.push({ from: node.from, to: node.from + (curLevel - level) });
|
|
127
|
+
} else {
|
|
128
|
+
changes.push({ from: node.from, insert: '#'.repeat(level - curLevel) });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
let line;
|
|
134
|
+
if (!sawBlock && range.empty && level > 0 && !/\S/.test((line = state.doc.lineAt(range.from)).text)) {
|
|
135
|
+
changes.push({ from: line.from, to: line.to, insert: '#'.repeat(level) + ' ' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!changes.length) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const changeSet = state.changes(changes);
|
|
144
|
+
dispatch(
|
|
145
|
+
state.update({
|
|
146
|
+
changes: changeSet,
|
|
147
|
+
selection: state.selection.map(changeSet, 1),
|
|
148
|
+
userEvent: 'format.setHeading',
|
|
149
|
+
scrollIntoView: true,
|
|
150
|
+
}),
|
|
151
|
+
);
|
|
152
|
+
return true;
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
//
|
|
157
|
+
// Styles
|
|
158
|
+
//
|
|
159
|
+
|
|
160
|
+
export const setStyle = (type: Inline, enable: boolean): StateCommand => {
|
|
161
|
+
return ({ state, dispatch }) => {
|
|
162
|
+
const marker = inlineMarkerText(type);
|
|
163
|
+
const changes = state.changeByRange((range) => {
|
|
164
|
+
// Special case for markers directly around the cursor, which will often not be parsed as valid styling.
|
|
165
|
+
if (!enable && range.empty) {
|
|
166
|
+
const after = state.doc.sliceString(range.head, range.head + 6);
|
|
167
|
+
const found = after.indexOf(marker);
|
|
168
|
+
if (found >= 0 && /^[*~`]*$/.test(after.slice(0, found))) {
|
|
169
|
+
const before = state.doc.sliceString(range.head - 6, range.head);
|
|
170
|
+
if (
|
|
171
|
+
before.slice(before.length - found - marker.length, before.length - found) === marker &&
|
|
172
|
+
[...before.slice(before.length - found)].reverse().join('') === after.slice(0, found)
|
|
173
|
+
) {
|
|
174
|
+
return {
|
|
175
|
+
changes: [
|
|
176
|
+
{ from: range.head - marker.length - found, to: range.head - found },
|
|
177
|
+
{ from: range.head + found, to: range.head + found + marker.length },
|
|
178
|
+
],
|
|
179
|
+
range: EditorSelection.cursor(range.from - marker.length),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const changes: ChangeSpec[] = [];
|
|
186
|
+
// Used to add insertions that should happen *after* any other insertions at the same position.
|
|
187
|
+
const changesAtEnd: ChangeSpec[] = [];
|
|
188
|
+
let blockStart = -1;
|
|
189
|
+
let blockEnd = -1;
|
|
190
|
+
let startCovered: boolean | { from: number; to: number } = false;
|
|
191
|
+
let endCovered: boolean | { from: number; to: number } = false;
|
|
192
|
+
let { from, to } = range;
|
|
193
|
+
|
|
194
|
+
// Iterate the selected range. For each textblock, determine a start and end position,
|
|
195
|
+
// the overlap of the selected range and the block's extent, that should be styled/unstyled.
|
|
196
|
+
syntaxTree(state).iterate({
|
|
197
|
+
from,
|
|
198
|
+
to,
|
|
199
|
+
enter: (node) => {
|
|
200
|
+
const { name } = node;
|
|
201
|
+
if (Object.hasOwn(Textblocks, name) && Textblocks[name] !== 'codeblock') {
|
|
202
|
+
// Set up for this textblock.
|
|
203
|
+
blockStart = blockContentStart(node);
|
|
204
|
+
blockEnd = blockContentEnd(node, state.doc);
|
|
205
|
+
startCovered = endCovered = false;
|
|
206
|
+
} else if (name === 'Link' || (name === 'Image' && enable)) {
|
|
207
|
+
// If the range partially overlaps a link or image, expand it to cover it.
|
|
208
|
+
if (from < node.from && to > node.from && to <= node.to) {
|
|
209
|
+
to = node.to;
|
|
210
|
+
} else if (to > node.to && from >= node.from && from < node.to) {
|
|
211
|
+
from = node.from;
|
|
212
|
+
}
|
|
213
|
+
} else if (IgnoreInline.has(name) && enable) {
|
|
214
|
+
// Move endpoints out of markers.
|
|
215
|
+
if (node.from < from && node.to > from) {
|
|
216
|
+
if (to === from) {
|
|
217
|
+
to = node.to;
|
|
218
|
+
}
|
|
219
|
+
from = node.to;
|
|
220
|
+
}
|
|
221
|
+
if (node.from < to && node.to > to) {
|
|
222
|
+
to = node.from;
|
|
223
|
+
}
|
|
224
|
+
} else if (Object.hasOwn(InlineMarker, name)) {
|
|
225
|
+
// This is an inline marker node.
|
|
226
|
+
const markType = InlineMarker[name];
|
|
227
|
+
const size = inlineMarkerText(markType).length;
|
|
228
|
+
const openEnd = node.from + size;
|
|
229
|
+
const closeStart = node.to - size;
|
|
230
|
+
// Determine whether the start/end of the range is covered
|
|
231
|
+
// by this.
|
|
232
|
+
if (markType === type) {
|
|
233
|
+
if (openEnd <= from && closeStart >= from) {
|
|
234
|
+
startCovered =
|
|
235
|
+
!enable && openEnd === skipMarkers(from, node.node, -1, openEnd)
|
|
236
|
+
? { from: node.from, to: openEnd }
|
|
237
|
+
: true;
|
|
238
|
+
}
|
|
239
|
+
if (openEnd <= to && closeStart >= to) {
|
|
240
|
+
endCovered =
|
|
241
|
+
!enable && closeStart === skipMarkers(to, node.node, 1, closeStart)
|
|
242
|
+
? { from: closeStart, to: node.to }
|
|
243
|
+
: true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Marks of the same type in range, or any mark if we're adding code style, need to be removed.
|
|
247
|
+
if (markType === type || (type === Inline.Code && enable)) {
|
|
248
|
+
if (node.from >= from && openEnd <= to) {
|
|
249
|
+
changes.push({ from: node.from, to: openEnd });
|
|
250
|
+
if (markType !== type && closeStart >= to) {
|
|
251
|
+
// End marker outside, move start
|
|
252
|
+
changesAtEnd.push({
|
|
253
|
+
from: skipSpaces(Math.min(to, blockEnd), state.doc, 1, blockEnd),
|
|
254
|
+
insert: inlineMarkerText(markType),
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (closeStart >= from && node.to <= to) {
|
|
259
|
+
changes.push({ from: closeStart, to: node.to });
|
|
260
|
+
if (markType !== type && openEnd <= from) {
|
|
261
|
+
// Start marker outside, move end
|
|
262
|
+
changes.push({
|
|
263
|
+
from: skipSpaces(Math.max(from, blockStart), state.doc, -1, blockStart),
|
|
264
|
+
insert: inlineMarkerText(markType),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
leave: (node) => {
|
|
272
|
+
if (Object.hasOwn(Textblocks, node.name) && Textblocks[node.name] !== 'codeblock') {
|
|
273
|
+
// Finish opening/closing the marks for this textblock.
|
|
274
|
+
const rangeStart = Math.max(from, blockStart);
|
|
275
|
+
const rangeEnd = Math.min(to, blockEnd);
|
|
276
|
+
if (enable) {
|
|
277
|
+
if (!startCovered) {
|
|
278
|
+
changes.push({ from: rangeStart, insert: marker });
|
|
279
|
+
}
|
|
280
|
+
if (!endCovered) {
|
|
281
|
+
changes.push({ from: rangeEnd, insert: marker });
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
if (typeof startCovered === 'object') {
|
|
285
|
+
changes.push(startCovered);
|
|
286
|
+
} else if (startCovered) {
|
|
287
|
+
changes.push({ from: skipSpaces(rangeStart, state.doc, -1, blockStart), insert: marker });
|
|
288
|
+
}
|
|
289
|
+
if (typeof endCovered === 'object') {
|
|
290
|
+
changes.push(endCovered);
|
|
291
|
+
} else if (endCovered) {
|
|
292
|
+
changes.push({ from: skipSpaces(rangeEnd, state.doc, 1, blockEnd), insert: marker });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (blockStart < 0 && range.empty && enable && !/\S/.test(state.doc.lineAt(range.from).text)) {
|
|
300
|
+
return {
|
|
301
|
+
changes: { from: range.head, insert: marker + marker },
|
|
302
|
+
range: EditorSelection.cursor(range.head + marker.length),
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const changeSet = state.changes(changes.concat(changesAtEnd));
|
|
307
|
+
return {
|
|
308
|
+
changes: changeSet,
|
|
309
|
+
range:
|
|
310
|
+
range.empty && !changeSet.empty
|
|
311
|
+
? EditorSelection.cursor(range.head + marker.length)
|
|
312
|
+
: EditorSelection.range(changeSet.mapPos(range.from, 1), changeSet.mapPos(range.to, -1)),
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
dispatch(
|
|
317
|
+
state.update(changes, {
|
|
318
|
+
userEvent: enable ? 'format.style.add' : 'format.style.remove',
|
|
319
|
+
scrollIntoView: true,
|
|
320
|
+
}),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
return true;
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export const addStyle = (style: Inline): StateCommand => setStyle(style, true);
|
|
328
|
+
|
|
329
|
+
export const removeStyle = (style: Inline): StateCommand => setStyle(style, false);
|
|
330
|
+
|
|
331
|
+
export const toggleStyle = (style: Inline): StateCommand => {
|
|
332
|
+
return (arg) => {
|
|
333
|
+
const form = getFormatting(arg.state);
|
|
334
|
+
return setStyle(
|
|
335
|
+
style,
|
|
336
|
+
style === Inline.Strong
|
|
337
|
+
? !form.strong
|
|
338
|
+
: style === Inline.Emphasis
|
|
339
|
+
? !form.emphasis
|
|
340
|
+
: style === Inline.Strikethrough
|
|
341
|
+
? !form.strikethrough
|
|
342
|
+
: !form.code,
|
|
343
|
+
)(arg);
|
|
344
|
+
};
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
export const toggleStrong = toggleStyle(Inline.Strong);
|
|
348
|
+
export const toggleEmphasis = toggleStyle(Inline.Emphasis);
|
|
349
|
+
export const toggleStrikethrough = toggleStyle(Inline.Strikethrough);
|
|
350
|
+
export const toggleInlineCode = toggleStyle(Inline.Code);
|
|
351
|
+
|
|
352
|
+
const inlineMarkerText = (type: Inline) =>
|
|
353
|
+
type === Inline.Strong ? '**' : type === Inline.Strikethrough ? '~~' : type === Inline.Emphasis ? '*' : '`';
|
|
354
|
+
|
|
355
|
+
//
|
|
356
|
+
// Utils
|
|
357
|
+
//
|
|
358
|
+
|
|
359
|
+
const blockContentStart = (node: SyntaxNodeRef) => {
|
|
360
|
+
const atx = /^ATXHeading(\d)/.exec(node.name);
|
|
361
|
+
if (atx) {
|
|
362
|
+
return Math.min(node.to, node.from + +atx[1] + 1);
|
|
363
|
+
}
|
|
364
|
+
return node.from;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const blockContentEnd = (node: SyntaxNodeRef, doc: Text) => {
|
|
368
|
+
const setext = /^SetextHeading(\d)/.exec(node.name);
|
|
369
|
+
const lastLine = doc.lineAt(node.to);
|
|
370
|
+
if (setext || /^[\s>]*$/.exec(lastLine.text)) {
|
|
371
|
+
return lastLine.from - 1;
|
|
372
|
+
}
|
|
373
|
+
return node.to;
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const skipSpaces = (pos: number, doc: Text, dir: -1 | 1, limit?: number) => {
|
|
377
|
+
const line = doc.lineAt(pos);
|
|
378
|
+
while (pos !== limit && line.text[pos - line.from - (dir < 0 ? 1 : 0)] === ' ') {
|
|
379
|
+
pos += dir;
|
|
380
|
+
}
|
|
381
|
+
return pos;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const skipMarkers = (pos: number, tree: SyntaxNode, dir: -1 | 1, limit?: number) => {
|
|
385
|
+
for (;;) {
|
|
386
|
+
const next = tree.resolve(pos, dir);
|
|
387
|
+
if (!IgnoreInline.has(next.name)) {
|
|
388
|
+
return pos;
|
|
389
|
+
}
|
|
390
|
+
const moveTo = dir < 0 ? next.from : next.to;
|
|
391
|
+
if (limit != null && (dir < 0 ? moveTo < limit : moveTo > limit)) {
|
|
392
|
+
return pos;
|
|
393
|
+
}
|
|
394
|
+
pos = moveTo;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// TODO(burdon): Define and trigger snippets for codeblock, table, etc.
|
|
399
|
+
const snippets = {
|
|
400
|
+
codeblock: snippet(
|
|
401
|
+
[
|
|
402
|
+
//
|
|
403
|
+
'```#{}',
|
|
404
|
+
'',
|
|
405
|
+
'```',
|
|
406
|
+
].join('\n'),
|
|
407
|
+
),
|
|
408
|
+
table: snippet(
|
|
409
|
+
[
|
|
410
|
+
//
|
|
411
|
+
'| #{col1} | #{col2} |',
|
|
412
|
+
'| ---- | ---- |',
|
|
413
|
+
'| #{val1} | #{val2} |',
|
|
414
|
+
'| #{val3} | #{val4} |',
|
|
415
|
+
'',
|
|
416
|
+
].join('\n'),
|
|
417
|
+
),
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
//
|
|
421
|
+
// Table
|
|
422
|
+
//
|
|
423
|
+
|
|
424
|
+
export const insertTable = (view: EditorView) => {
|
|
425
|
+
const {
|
|
426
|
+
selection: { main },
|
|
427
|
+
doc,
|
|
428
|
+
} = view.state;
|
|
429
|
+
const { number } = doc.lineAt(main.anchor);
|
|
430
|
+
const { from } = doc.line(number);
|
|
431
|
+
|
|
432
|
+
snippets.table(view, null, from, from);
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
//
|
|
436
|
+
// Links
|
|
437
|
+
//
|
|
438
|
+
|
|
439
|
+
// For each link in the given range, remove the link markup
|
|
440
|
+
const removeLinkInner = (from: number, to: number, changes: ChangeSpec[], state: EditorState) => {
|
|
441
|
+
syntaxTree(state).iterate({
|
|
442
|
+
from,
|
|
443
|
+
to,
|
|
444
|
+
enter: (node) => {
|
|
445
|
+
if (node.name === 'Link' && node.from < to && node.to > from) {
|
|
446
|
+
node.node.cursor().iterate((node) => {
|
|
447
|
+
const { name } = node;
|
|
448
|
+
if (name === 'LinkMark' || name === 'LinkLabel') {
|
|
449
|
+
changes.push({ from: node.from, to: node.to });
|
|
450
|
+
} else if (name === 'LinkTitle' || name === 'URL') {
|
|
451
|
+
changes.push({ from: skipSpaces(node.from, state.doc, -1), to: skipSpaces(node.to, state.doc, 1) });
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Remove all links touching the selection
|
|
461
|
+
export const removeLink: StateCommand = ({ state, dispatch }) => {
|
|
462
|
+
const changes: ChangeSpec[] = [];
|
|
463
|
+
for (const { from, to } of state.selection.ranges) {
|
|
464
|
+
removeLinkInner(from, to, changes, state);
|
|
465
|
+
}
|
|
466
|
+
if (!changes) {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
dispatch(state.update({ changes, userEvent: 'format.link.remove', scrollIntoView: true }));
|
|
470
|
+
return true;
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
// Add link markup around the selection.
|
|
474
|
+
export const addLink = ({ url, image }: { url?: string; image?: boolean } = {}): StateCommand => {
|
|
475
|
+
return ({ state, dispatch }) => {
|
|
476
|
+
const changes = state.changeByRange((range) => {
|
|
477
|
+
let { from, to } = range;
|
|
478
|
+
const cutStyles: SyntaxNode[] = [];
|
|
479
|
+
let okay: boolean | null = null;
|
|
480
|
+
// Check whether this range is in a position where a link makes sense.
|
|
481
|
+
syntaxTree(state).iterate({
|
|
482
|
+
from,
|
|
483
|
+
to,
|
|
484
|
+
enter: (node) => {
|
|
485
|
+
if (Object.hasOwn(Textblocks, node.name)) {
|
|
486
|
+
// If the selection spans multiple textblocks or is in a code block, abort.
|
|
487
|
+
okay =
|
|
488
|
+
Textblocks[node.name] !== 'codeblock' &&
|
|
489
|
+
from >= blockContentStart(node) &&
|
|
490
|
+
to <= blockContentEnd(node, state.doc);
|
|
491
|
+
} else if (Object.hasOwn(InlineMarker, node.name)) {
|
|
492
|
+
// Look for inline styles that partially overlap the range.
|
|
493
|
+
// Expand the range over them if they start directly outside, otherwise mark them for later.
|
|
494
|
+
const sNode = node.node;
|
|
495
|
+
if (node.from < from && node.to <= to) {
|
|
496
|
+
if (sNode.firstChild!.to === from) {
|
|
497
|
+
from = node.from;
|
|
498
|
+
} else {
|
|
499
|
+
cutStyles.push(sNode);
|
|
500
|
+
}
|
|
501
|
+
} else if (node.from >= from && node.to > to) {
|
|
502
|
+
if (sNode.lastChild!.from === to) {
|
|
503
|
+
to = node.to;
|
|
504
|
+
} else {
|
|
505
|
+
cutStyles.push(sNode);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
if (okay === null) {
|
|
513
|
+
// No textblock found around selection. Check if the rest of the line is empty.
|
|
514
|
+
const line = state.doc.lineAt(from);
|
|
515
|
+
okay = to <= line.to && !/\S/.test(line.text.slice(from - line.from));
|
|
516
|
+
}
|
|
517
|
+
if (!okay) {
|
|
518
|
+
return { range };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const changes: ChangeSpec[] = [];
|
|
522
|
+
// Some changes must be moved to end of change array so that they are applied in the right order.
|
|
523
|
+
const changesAfter: ChangeSpec[] = [];
|
|
524
|
+
// Clear existing links.
|
|
525
|
+
removeLinkInner(from, to, changesAfter, state);
|
|
526
|
+
let cursorOffset = 1;
|
|
527
|
+
// Close and reopen inline styles that partially overlap the range.
|
|
528
|
+
for (const style of cutStyles) {
|
|
529
|
+
const type = InlineMarker[style.name];
|
|
530
|
+
const mark = inlineMarkerText(type);
|
|
531
|
+
if (style.from < from) {
|
|
532
|
+
// Extends before.
|
|
533
|
+
changes.push({ from: skipSpaces(from, state.doc, -1), insert: mark });
|
|
534
|
+
changesAfter.push({ from: skipSpaces(from, state.doc, 1, to), insert: mark });
|
|
535
|
+
} else {
|
|
536
|
+
changes.push({ from: skipSpaces(to, state.doc, -1, from), insert: mark });
|
|
537
|
+
const after = skipSpaces(to, state.doc, 1);
|
|
538
|
+
if (after === to) {
|
|
539
|
+
cursorOffset += mark.length;
|
|
540
|
+
}
|
|
541
|
+
changesAfter.push({ from: after, insert: mark });
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Add the link markup.
|
|
546
|
+
changes.push({ from, insert: image ? '` });
|
|
547
|
+
const changeSet = state.changes(changes.concat(changesAfter));
|
|
548
|
+
// Put the cursor between the title or parenthesis.
|
|
549
|
+
return {
|
|
550
|
+
changes: changeSet,
|
|
551
|
+
range: EditorSelection.cursor(changeSet.mapPos(to, 1) - cursorOffset - (url ? url.length + 2 : 0)),
|
|
552
|
+
};
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
if (changes.changes.empty) {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
dispatch(
|
|
560
|
+
state.update(changes, {
|
|
561
|
+
userEvent: 'format.link.add',
|
|
562
|
+
scrollIntoView: true,
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
return true;
|
|
566
|
+
};
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
//
|
|
570
|
+
// Lists
|
|
571
|
+
//
|
|
572
|
+
|
|
573
|
+
export const addList = (type: List): StateCommand => {
|
|
574
|
+
return ({ state, dispatch }) => {
|
|
575
|
+
let lastBlock = -1;
|
|
576
|
+
let counter = 1;
|
|
577
|
+
let first = true;
|
|
578
|
+
let parentColumn: number | null = null;
|
|
579
|
+
|
|
580
|
+
// Scan the syntax tree to locate textblocks that can be wrapped.
|
|
581
|
+
const blocks: { node: SyntaxNode; counter: number; parentColumn: number | null }[] = [];
|
|
582
|
+
for (const { from, to } of state.selection.ranges) {
|
|
583
|
+
syntaxTree(state).iterate({
|
|
584
|
+
from,
|
|
585
|
+
to,
|
|
586
|
+
enter: (node) => {
|
|
587
|
+
if ((Object.hasOwn(Textblocks, node.name) && node.name !== 'TableCell') || node.name === 'Table') {
|
|
588
|
+
if (first) {
|
|
589
|
+
// For the first block, see if it follows a list,
|
|
590
|
+
// so we can take indentation and numbering information from that one.
|
|
591
|
+
let before = node.node.prevSibling;
|
|
592
|
+
while (before && /Mark$/.test(before.name)) {
|
|
593
|
+
before = before.prevSibling;
|
|
594
|
+
}
|
|
595
|
+
if (before?.name === (type === List.Ordered ? 'OrderedList' : 'BulletList')) {
|
|
596
|
+
const item = before.lastChild!;
|
|
597
|
+
const itemLine = state.doc.lineAt(item.from);
|
|
598
|
+
const itemText = itemLine.text.slice(item.from - itemLine.from);
|
|
599
|
+
parentColumn = item.from - itemLine.from + /^\s*/.exec(itemText)![0].length;
|
|
600
|
+
if (type === List.Ordered) {
|
|
601
|
+
const mark = /^\s*(\d+)[.)]/.exec(itemText);
|
|
602
|
+
if (mark) {
|
|
603
|
+
parentColumn += mark[1].length;
|
|
604
|
+
counter = +mark[1] + 1;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
first = false;
|
|
609
|
+
}
|
|
610
|
+
if (node.from === lastBlock) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
lastBlock = node.from;
|
|
615
|
+
blocks.push({ node: node.node, counter, parentColumn });
|
|
616
|
+
counter++;
|
|
617
|
+
return false;
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
leave: (node) => {
|
|
621
|
+
// When exiting block-level markup, reset the indentation and counter.
|
|
622
|
+
if (node.name === 'BulletList' || node.name === 'OrderedList' || node.name === 'Blockquote') {
|
|
623
|
+
counter = 1;
|
|
624
|
+
parentColumn = null;
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!blocks.length) {
|
|
631
|
+
// Insert a new list item if the selection is empty.
|
|
632
|
+
const { from, to } = state.doc.lineAt(state.selection.main.anchor);
|
|
633
|
+
if (from === to) {
|
|
634
|
+
const insert = type === List.Bullet ? '- ' : type === List.Ordered ? '1. ' : '- [ ] ';
|
|
635
|
+
dispatch(
|
|
636
|
+
state.update({
|
|
637
|
+
changes: [
|
|
638
|
+
{
|
|
639
|
+
from,
|
|
640
|
+
insert,
|
|
641
|
+
},
|
|
642
|
+
],
|
|
643
|
+
selection: { anchor: from + insert.length },
|
|
644
|
+
userEvent: 'format.list.add',
|
|
645
|
+
scrollIntoView: true,
|
|
646
|
+
}),
|
|
647
|
+
);
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return false;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const changes: ChangeSpec[] = [];
|
|
655
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
656
|
+
const { node, counter, parentColumn } = blocks[i];
|
|
657
|
+
const nodeFrom = node.name === 'CodeBlock' ? node.from - 4 : node.from;
|
|
658
|
+
// Compute a padding based on whether we are after whitespace.
|
|
659
|
+
let padding = nodeFrom > 0 && !/\s/.test(state.doc.sliceString(nodeFrom - 1, nodeFrom)) ? 1 : 0;
|
|
660
|
+
// On ordered lists, the number is counted in the padding.
|
|
661
|
+
if (type === List.Ordered) {
|
|
662
|
+
padding += String(counter).length;
|
|
663
|
+
}
|
|
664
|
+
let line = state.doc.lineAt(nodeFrom);
|
|
665
|
+
const column = nodeFrom - line.from;
|
|
666
|
+
// Align to the list above if possible.
|
|
667
|
+
if (parentColumn !== null && parentColumn > column) {
|
|
668
|
+
padding = Math.max(padding, parentColumn - column);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
let mark;
|
|
672
|
+
if (type === List.Ordered) {
|
|
673
|
+
// Scan ahead to find the max number we're adding, adjust padding for that.
|
|
674
|
+
let max = counter;
|
|
675
|
+
for (let j = i + 1; j < blocks.length; j++) {
|
|
676
|
+
if (blocks[j].counter !== max + 1) {
|
|
677
|
+
break;
|
|
678
|
+
}
|
|
679
|
+
max++;
|
|
680
|
+
}
|
|
681
|
+
const num = String(counter);
|
|
682
|
+
padding = Math.max(String(max).length, padding);
|
|
683
|
+
mark = ' '.repeat(Math.max(0, padding - num.length)) + num + '. ';
|
|
684
|
+
} else {
|
|
685
|
+
mark = ' '.repeat(padding) + '- ' + (type === List.Task ? '[ ] ' : '');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
changes.push({ from: nodeFrom, insert: mark });
|
|
689
|
+
// Add indentation for the other lines in this block.
|
|
690
|
+
while (line.to < node.to) {
|
|
691
|
+
line = state.doc.lineAt(line.to + 1);
|
|
692
|
+
const open = /^[\s>]*/.exec(line.text)![0].length;
|
|
693
|
+
changes.push({ from: line.from + Math.min(open, column), insert: ' '.repeat(mark.length) });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// If we are inserting an ordered list and there is another one right after the last selected block,
|
|
698
|
+
// renumber that one to match the new order.
|
|
699
|
+
if (type === List.Ordered) {
|
|
700
|
+
const last = blocks[blocks.length - 1];
|
|
701
|
+
let next = last.node.nextSibling;
|
|
702
|
+
while (next && /Mark$/.test(next.name)) {
|
|
703
|
+
next = next.nextSibling;
|
|
704
|
+
}
|
|
705
|
+
if (next?.name === 'OrderedList') {
|
|
706
|
+
renumberListItems(next.firstChild, last.counter + 1, changes, state.doc);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const changeSet = state.changes(changes);
|
|
710
|
+
dispatch(
|
|
711
|
+
state.update({
|
|
712
|
+
changes: changeSet,
|
|
713
|
+
selection: state.selection.map(changeSet, 1),
|
|
714
|
+
userEvent: 'format.list.add',
|
|
715
|
+
scrollIntoView: true,
|
|
716
|
+
}),
|
|
717
|
+
);
|
|
718
|
+
return true;
|
|
719
|
+
};
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
export const removeList = (type: List): StateCommand => {
|
|
723
|
+
return ({ state, dispatch }) => {
|
|
724
|
+
let lastBlock = -1;
|
|
725
|
+
const changes: ChangeSpec[] = [];
|
|
726
|
+
const stack: string[] = [];
|
|
727
|
+
const targetNodeType = type === List.Ordered ? 'OrderedList' : type === List.Bullet ? 'BulletList' : 'TaskList';
|
|
728
|
+
// Scan the syntax tree to locate list items that can be unwrapped.
|
|
729
|
+
for (const { from, to } of state.selection.ranges) {
|
|
730
|
+
syntaxTree(state).iterate({
|
|
731
|
+
from,
|
|
732
|
+
to,
|
|
733
|
+
enter: (node) => {
|
|
734
|
+
const { name } = node;
|
|
735
|
+
if (name === 'BulletList' || name === 'OrderedList' || name === 'Blockquote') {
|
|
736
|
+
// Maintain block context.
|
|
737
|
+
stack.push(name);
|
|
738
|
+
} else if (name === 'Task' && stack[stack.length - 1] === 'BulletList') {
|
|
739
|
+
stack[stack.length - 1] = 'TaskList';
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
leave: (node) => {
|
|
743
|
+
const { name } = node;
|
|
744
|
+
if (name === 'BulletList' || name === 'OrderedList' || name === 'Blockquote') {
|
|
745
|
+
stack.pop();
|
|
746
|
+
} else if (name === 'ListItem' && stack[stack.length - 1] === targetNodeType && node.from !== lastBlock) {
|
|
747
|
+
lastBlock = node.from;
|
|
748
|
+
let line = state.doc.lineAt(node.from);
|
|
749
|
+
const mark = /^\s*(\d+[.)] |[-*+] (\[[ x]\] )?)/.exec(line.text.slice(node.from - line.from));
|
|
750
|
+
if (!mark) {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
const column = node.from - line.from;
|
|
754
|
+
// Delete the marker on the first line.
|
|
755
|
+
changes.push({ from: node.from, to: node.from + mark[0].length });
|
|
756
|
+
// and indentation on subsequent lines.
|
|
757
|
+
while (line.to < node.to) {
|
|
758
|
+
line = state.doc.lineAt(line.to + 1);
|
|
759
|
+
const open = /^[\s>]*/.exec(line.text)![0].length;
|
|
760
|
+
if (open > column) {
|
|
761
|
+
changes.push({ from: line.from + column, to: line.from + Math.min(column + mark[0].length, open) });
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
if (node.to >= to) {
|
|
765
|
+
renumberListItems(node.node.nextSibling, 1, changes, state.doc);
|
|
766
|
+
}
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
if (!changes.length) {
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
dispatch(
|
|
777
|
+
state.update({
|
|
778
|
+
changes,
|
|
779
|
+
userEvent: 'format.list.remove',
|
|
780
|
+
scrollIntoView: true,
|
|
781
|
+
}),
|
|
782
|
+
);
|
|
783
|
+
return true;
|
|
784
|
+
};
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
export const toggleList = (type: List): StateCommand => {
|
|
788
|
+
return (target) => {
|
|
789
|
+
const formatting = getFormatting(target.state);
|
|
790
|
+
const active =
|
|
791
|
+
formatting.listStyle === (type === List.Bullet ? 'bullet' : type === List.Ordered ? 'ordered' : 'task');
|
|
792
|
+
return (active ? removeList(type) : addList(type))(target);
|
|
793
|
+
};
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const renumberListItems = (item: SyntaxNode | null, counter: number, changes: ChangeSpec[], doc: Text) => {
|
|
797
|
+
for (; item; item = item.nextSibling) {
|
|
798
|
+
if (item.name === 'ListItem') {
|
|
799
|
+
const number = /(\s*)(\d+)[.)]/.exec(doc.sliceString(item.from, item.from + 10));
|
|
800
|
+
if (!number || +number[2] === counter) {
|
|
801
|
+
break;
|
|
802
|
+
}
|
|
803
|
+
const size = number[1].length + number[2].length;
|
|
804
|
+
const newNum = String(counter);
|
|
805
|
+
changes.push({ from: item.from + Math.max(0, size - newNum.length), to: item.from + size, insert: newNum });
|
|
806
|
+
counter++;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
//
|
|
812
|
+
// Block quotes
|
|
813
|
+
//
|
|
814
|
+
|
|
815
|
+
export const setBlockquote = (enable: boolean): StateCommand => {
|
|
816
|
+
return ({ state, dispatch }) => {
|
|
817
|
+
const lines: Line[] = [];
|
|
818
|
+
let lastBlock = -1;
|
|
819
|
+
for (const { from, to } of state.selection.ranges) {
|
|
820
|
+
const sawBlock = false;
|
|
821
|
+
syntaxTree(state).iterate({
|
|
822
|
+
from,
|
|
823
|
+
to,
|
|
824
|
+
enter: (node) => {
|
|
825
|
+
if (Object.hasOwn(Textblocks, node.name) || node.name === 'Table') {
|
|
826
|
+
if (node.from === lastBlock) {
|
|
827
|
+
return false;
|
|
828
|
+
}
|
|
829
|
+
lastBlock = node.from;
|
|
830
|
+
let line = state.doc.lineAt(node.from);
|
|
831
|
+
if (line.number > 1) {
|
|
832
|
+
const prevLine = state.doc.line(line.number - 1);
|
|
833
|
+
if (/^[>\s]*$/.test(prevLine.text)) {
|
|
834
|
+
if (!enable || (lines.length && lines[lines.length - 1].number === prevLine.number - 1)) {
|
|
835
|
+
lines.push(prevLine);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
for (;;) {
|
|
840
|
+
lines.push(line);
|
|
841
|
+
if (line.to >= node.to) {
|
|
842
|
+
break;
|
|
843
|
+
}
|
|
844
|
+
line = state.doc.line(line.number + 1);
|
|
845
|
+
}
|
|
846
|
+
if (!enable && line.number < state.doc.lines) {
|
|
847
|
+
const nextLine = state.doc.line(line.number + 1);
|
|
848
|
+
if (/^[>\s]*$/.test(nextLine.text)) {
|
|
849
|
+
lines.push(nextLine);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return false;
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
let line;
|
|
857
|
+
if (!sawBlock && enable && from === to && !/\S/.test((line = state.doc.lineAt(from)).text)) {
|
|
858
|
+
lines.push(line);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const changes: ChangeSpec[] = [];
|
|
863
|
+
for (const line of lines) {
|
|
864
|
+
if (enable) {
|
|
865
|
+
changes.push({ from: line.from, insert: /\S/.test(line.text) ? '> ' : '>' });
|
|
866
|
+
} else {
|
|
867
|
+
const quote = /((?:[\s>\-+*]|\d+[.)])*?)> ?/.exec(line.text);
|
|
868
|
+
if (quote) {
|
|
869
|
+
changes.push({ from: line.from + quote[1].length, to: line.from + quote[0].length });
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (!changes.length) {
|
|
874
|
+
return false;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const changeSet = state.changes(changes);
|
|
878
|
+
dispatch(
|
|
879
|
+
state.update({
|
|
880
|
+
changes: changeSet,
|
|
881
|
+
selection: state.selection.map(changeSet, 1),
|
|
882
|
+
userEvent: enable ? 'format.blockquote.add' : 'format.blockquote.remove',
|
|
883
|
+
scrollIntoView: true,
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
return true;
|
|
887
|
+
};
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
export const addBlockquote = setBlockquote(true);
|
|
891
|
+
|
|
892
|
+
export const removeBlockquote = setBlockquote(false);
|
|
893
|
+
|
|
894
|
+
export const toggleBlockquote: StateCommand = (target) => {
|
|
895
|
+
return (getFormatting(target.state).blockQuote ? removeBlockquote : addBlockquote)(target);
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
//
|
|
899
|
+
// Code block
|
|
900
|
+
//
|
|
901
|
+
|
|
902
|
+
export const addCodeblock: StateCommand = (target) => {
|
|
903
|
+
const { state, dispatch } = target;
|
|
904
|
+
const { selection } = state;
|
|
905
|
+
// If on a blank line, use the code block snippet.
|
|
906
|
+
if (selection.ranges.length === 1 && selection.main.empty) {
|
|
907
|
+
const { head } = selection.main;
|
|
908
|
+
const line = state.doc.lineAt(head);
|
|
909
|
+
if (!/\S/.test(line.text) && head === line.from) {
|
|
910
|
+
snippets.codeblock(target, null, line.from, line.to);
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Otherwise, wrap any selected blocks in triple backticks.
|
|
916
|
+
const ranges: { from: number; to: number }[] = [];
|
|
917
|
+
for (const { from, to } of selection.ranges) {
|
|
918
|
+
let blockFrom = from;
|
|
919
|
+
let blockTo = to;
|
|
920
|
+
syntaxTree(state).iterate({
|
|
921
|
+
from,
|
|
922
|
+
to,
|
|
923
|
+
enter: (node) => {
|
|
924
|
+
if (Object.hasOwn(Textblocks, node.name)) {
|
|
925
|
+
if (from >= node.from && to <= node.to) {
|
|
926
|
+
// Selection in a single block.
|
|
927
|
+
blockFrom = node.from;
|
|
928
|
+
blockTo = node.to;
|
|
929
|
+
} else {
|
|
930
|
+
// Expand to cover whole lines.
|
|
931
|
+
blockFrom = Math.min(blockFrom, state.doc.lineAt(node.from).from);
|
|
932
|
+
blockTo = Math.max(blockTo, state.doc.lineAt(node.to).to);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
},
|
|
936
|
+
});
|
|
937
|
+
if (ranges.length && ranges[ranges.length - 1].to >= blockFrom - 1) {
|
|
938
|
+
ranges[ranges.length - 1].to = blockTo;
|
|
939
|
+
} else {
|
|
940
|
+
ranges.push({ from: blockFrom, to: blockTo });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (!ranges.length) {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const changes: ChangeSpec[] = ranges.map(({ from, to }) => {
|
|
948
|
+
const column = from - state.doc.lineAt(from).from;
|
|
949
|
+
return [
|
|
950
|
+
{ from, insert: '```\n' + ' '.repeat(column) },
|
|
951
|
+
{ from: to, insert: '\n' + ' '.repeat(column) + '```' },
|
|
952
|
+
];
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
dispatch(
|
|
956
|
+
state.update({
|
|
957
|
+
changes,
|
|
958
|
+
userEvent: 'format.codeblock.add',
|
|
959
|
+
scrollIntoView: true,
|
|
960
|
+
}),
|
|
961
|
+
);
|
|
962
|
+
|
|
963
|
+
return true;
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
export const removeCodeblock: StateCommand = ({ state, dispatch }) => {
|
|
967
|
+
let lastBlock = -1;
|
|
968
|
+
|
|
969
|
+
// Find all code blocks, remove their markup.
|
|
970
|
+
const changes: ChangeSpec[] = [];
|
|
971
|
+
for (const { from, to } of state.selection.ranges) {
|
|
972
|
+
syntaxTree(state).iterate({
|
|
973
|
+
from,
|
|
974
|
+
to,
|
|
975
|
+
enter: (node) => {
|
|
976
|
+
if (Textblocks[node.name] === 'codeblock' && lastBlock !== node.from) {
|
|
977
|
+
lastBlock = node.from;
|
|
978
|
+
const firstLine = state.doc.lineAt(node.from);
|
|
979
|
+
if (node.name === 'FencedCode') {
|
|
980
|
+
changes.push({ from: node.from, to: firstLine.to + 1 + node.from - firstLine.from });
|
|
981
|
+
const lastLine = state.doc.lineAt(node.to);
|
|
982
|
+
if (/^([\s>]|[-*+] |\d+[).])*`+$/.test(lastLine.text)) {
|
|
983
|
+
changes.push({
|
|
984
|
+
from: lastLine.from - (lastLine.number === firstLine.number + 1 ? 0 : 1),
|
|
985
|
+
to: lastLine.to,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
} else {
|
|
989
|
+
// Indented code block.
|
|
990
|
+
const column = node.from - firstLine.from;
|
|
991
|
+
for (let line = firstLine; ; line = state.doc.line(line.number + 1)) {
|
|
992
|
+
changes.push({ from: line.from + column - 4, to: line.from + column });
|
|
993
|
+
if (line.to >= node.to) {
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
if (!changes.length) {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
dispatch(state.update({ changes, userEvent: 'format.codeblock.remove', scrollIntoView: true }));
|
|
1007
|
+
return true;
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
export const toggleCodeblock: StateCommand = (target) => {
|
|
1011
|
+
return (getFormatting(target.state).blockType === 'codeblock' ? removeCodeblock : addCodeblock)(target);
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
//
|
|
1015
|
+
// Formatting extension.
|
|
1016
|
+
//
|
|
1017
|
+
|
|
1018
|
+
export type FormattingOptions = {};
|
|
1019
|
+
|
|
1020
|
+
export const formattingKeymap = (_options: FormattingOptions = {}): Extension => {
|
|
1021
|
+
return [
|
|
1022
|
+
keymap.of([
|
|
1023
|
+
{
|
|
1024
|
+
key: 'meta-b',
|
|
1025
|
+
run: toggleStrong,
|
|
1026
|
+
},
|
|
1027
|
+
{
|
|
1028
|
+
key: 'meta-i',
|
|
1029
|
+
run: toggleEmphasis,
|
|
1030
|
+
},
|
|
1031
|
+
]),
|
|
1032
|
+
];
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
const InlineMarker: { [name: string]: number } = {
|
|
1036
|
+
Emphasis: Inline.Emphasis,
|
|
1037
|
+
StrongEmphasis: Inline.Strong,
|
|
1038
|
+
InlineCode: Inline.Code,
|
|
1039
|
+
Strikethrough: Inline.Strikethrough,
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const IgnoreInline = new Set([
|
|
1043
|
+
'Autolink',
|
|
1044
|
+
'CodeMark',
|
|
1045
|
+
'CodeText',
|
|
1046
|
+
'Comment',
|
|
1047
|
+
'EmphasisMark',
|
|
1048
|
+
'Hardbreak',
|
|
1049
|
+
'HeaderMark',
|
|
1050
|
+
'HTMLTag',
|
|
1051
|
+
'LinkMark',
|
|
1052
|
+
'ListMark',
|
|
1053
|
+
'ProcessingInstruction',
|
|
1054
|
+
'QuoteMark',
|
|
1055
|
+
'StrikethroughMark',
|
|
1056
|
+
'SubscriptMark',
|
|
1057
|
+
'SuperscriptMark',
|
|
1058
|
+
'TaskMarker',
|
|
1059
|
+
]);
|
|
1060
|
+
|
|
1061
|
+
const Textblocks: { [name: string]: NonNullable<Formatting['blockType']> } = {
|
|
1062
|
+
ATXHeading1: 'heading1',
|
|
1063
|
+
ATXHeading2: 'heading2',
|
|
1064
|
+
ATXHeading3: 'heading3',
|
|
1065
|
+
ATXHeading4: 'heading4',
|
|
1066
|
+
ATXHeading5: 'heading5',
|
|
1067
|
+
ATXHeading6: 'heading6',
|
|
1068
|
+
CodeBlock: 'codeblock',
|
|
1069
|
+
FencedCode: 'codeblock',
|
|
1070
|
+
Paragraph: 'paragraph',
|
|
1071
|
+
SetextHeading1: 'heading1',
|
|
1072
|
+
SetextHeading2: 'heading2',
|
|
1073
|
+
TableCell: 'tablecell',
|
|
1074
|
+
Task: 'paragraph',
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* Query the editor state for the active formatting at the selection.
|
|
1079
|
+
*/
|
|
1080
|
+
export const getFormatting = (state: EditorState): Formatting => {
|
|
1081
|
+
// These will track the formatting we've seen so far.
|
|
1082
|
+
// False indicates mixed block types.
|
|
1083
|
+
let blockType: Formatting['blockType'] | false = null;
|
|
1084
|
+
// Indexed by the Inline enum, tracks inline markup.
|
|
1085
|
+
// null = no text seen, true = all text had the mark, false = saw text without it.
|
|
1086
|
+
const inline: (boolean | null)[] = [null, null, null, null];
|
|
1087
|
+
let link: boolean = false;
|
|
1088
|
+
let blockQuote: boolean | null = null;
|
|
1089
|
+
// False indicates mixed list styles.
|
|
1090
|
+
let listStyle: Formatting['listStyle'] | null | false = null;
|
|
1091
|
+
|
|
1092
|
+
// Track block context for list/blockquote handling.
|
|
1093
|
+
const stack: ('BulletList' | 'OrderedList' | 'Blockquote' | 'TaskList')[] = [];
|
|
1094
|
+
// This is set when entering a textblock (paragraph, heading, etc.)
|
|
1095
|
+
// and cleared when exiting again. It is used to track inline style.
|
|
1096
|
+
// `active` holds an array that indicates, for the various style (`Inline` enum) whether they are currently active.
|
|
1097
|
+
let currentBlock: { pos: number; end: number; active: boolean[] } | null = null;
|
|
1098
|
+
// Advance over regular inline text. Will update `inline` depending on what styles are active.
|
|
1099
|
+
const advanceInline = (upto: number) => {
|
|
1100
|
+
if (!currentBlock) {
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
upto = Math.min(upto, currentBlock.end);
|
|
1104
|
+
if (upto <= currentBlock.pos) {
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
for (let i = 0; i < currentBlock.active.length; i++) {
|
|
1108
|
+
if (inline[i] === false) {
|
|
1109
|
+
continue;
|
|
1110
|
+
} else if (currentBlock.active[i]) {
|
|
1111
|
+
inline[i] = true;
|
|
1112
|
+
} else if (/\S/.test(state.doc.sliceString(currentBlock.pos, upto))) {
|
|
1113
|
+
inline[i] = false;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
currentBlock.pos = upto;
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
// Skip markup that shouldn't be treated as inline text for style-tracking purposes.
|
|
1120
|
+
const skipInline = (upto: number) => {
|
|
1121
|
+
if (currentBlock && upto > currentBlock.pos) {
|
|
1122
|
+
currentBlock.pos = Math.min(upto, currentBlock.end);
|
|
1123
|
+
}
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const { selection } = state;
|
|
1127
|
+
for (const range of selection.ranges) {
|
|
1128
|
+
if (range.empty && inline.some((v) => v === null)) {
|
|
1129
|
+
// Check for markers directly around the cursor (which, not being valid Markdown, the syntax tree won't pick up).
|
|
1130
|
+
const contextSize = Math.min(range.head, 6);
|
|
1131
|
+
const contextBefore = state.doc.sliceString(range.head - contextSize, range.head);
|
|
1132
|
+
let contextAfter = state.doc.sliceString(range.head, range.head + contextSize);
|
|
1133
|
+
for (let i = 0; i < contextSize; i++) {
|
|
1134
|
+
const ch = contextAfter[i];
|
|
1135
|
+
if (ch !== contextBefore[contextBefore.length - 1 - i] || !/[~`*]/.test(ch)) {
|
|
1136
|
+
contextAfter = contextAfter.slice(0, i);
|
|
1137
|
+
break;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
for (let i = 0; i < inline.length; i++) {
|
|
1141
|
+
const mark = inlineMarkerText(i);
|
|
1142
|
+
const found = contextAfter.indexOf(mark);
|
|
1143
|
+
if (found > -1) {
|
|
1144
|
+
contextAfter = contextAfter.slice(0, found) + contextAfter.slice(found + mark.length);
|
|
1145
|
+
if (inline[i] === null) {
|
|
1146
|
+
inline[i] = true;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
syntaxTree(state).iterate({
|
|
1153
|
+
from: range.from,
|
|
1154
|
+
to: range.to,
|
|
1155
|
+
enter: (node) => {
|
|
1156
|
+
advanceInline(node.from);
|
|
1157
|
+
const { name } = node;
|
|
1158
|
+
if (name === 'BulletList' || name === 'OrderedList' || name === 'Blockquote') {
|
|
1159
|
+
// Maintain block context.
|
|
1160
|
+
stack.push(name);
|
|
1161
|
+
} else if (name === 'Link') {
|
|
1162
|
+
link = true;
|
|
1163
|
+
} else if (Object.hasOwn(Textblocks, name) && (range.empty || node.to > range.from || node.from < range.to)) {
|
|
1164
|
+
if (name === 'Task' && stack[stack.length - 1] === 'BulletList') {
|
|
1165
|
+
stack[stack.length - 1] = 'TaskList';
|
|
1166
|
+
}
|
|
1167
|
+
const blockCode = Textblocks[name];
|
|
1168
|
+
if (blockType === null) {
|
|
1169
|
+
blockType = blockCode;
|
|
1170
|
+
} else if (blockType !== blockCode) {
|
|
1171
|
+
blockType = false;
|
|
1172
|
+
}
|
|
1173
|
+
if (blockCode !== 'codeblock' && inline.some((i) => i !== false)) {
|
|
1174
|
+
// Set up inline content tracking for non-code textblocks.
|
|
1175
|
+
currentBlock = {
|
|
1176
|
+
pos: Math.max(range.from, node.from),
|
|
1177
|
+
end: Math.min(range.to, node.to),
|
|
1178
|
+
active: [false, false, false, false],
|
|
1179
|
+
};
|
|
1180
|
+
}
|
|
1181
|
+
} else if (Object.hasOwn(InlineMarker, name) && currentBlock) {
|
|
1182
|
+
const index = InlineMarker[name];
|
|
1183
|
+
// Cursors selections always count as active.
|
|
1184
|
+
if (range.empty && inline[index] === null) {
|
|
1185
|
+
inline[index] = true;
|
|
1186
|
+
}
|
|
1187
|
+
currentBlock.active[index] = true;
|
|
1188
|
+
} else if (IgnoreInline.has(name)) {
|
|
1189
|
+
skipInline(node.to);
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1192
|
+
leave: (node) => {
|
|
1193
|
+
advanceInline(node.to);
|
|
1194
|
+
const { name } = node;
|
|
1195
|
+
if (name === 'BulletList' || name === 'OrderedList' || name === 'Blockquote') {
|
|
1196
|
+
// Track block context.
|
|
1197
|
+
stack.pop();
|
|
1198
|
+
} else if (Object.hasOwn(Textblocks, name)) {
|
|
1199
|
+
// Scan the stack for blockquote/list context.
|
|
1200
|
+
// Done at end of node because task lists aren't recognized until a task is seen
|
|
1201
|
+
let hasList: Formatting['listStyle'] | false = false;
|
|
1202
|
+
let hasQuote = false;
|
|
1203
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
1204
|
+
if (stack[i] === 'Blockquote') {
|
|
1205
|
+
hasQuote = true;
|
|
1206
|
+
} else if (!hasList) {
|
|
1207
|
+
hasList = stack[i] === 'TaskList' ? 'task' : stack[i] === 'BulletList' ? 'bullet' : 'ordered';
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
if (blockQuote === null) {
|
|
1211
|
+
blockQuote = hasQuote;
|
|
1212
|
+
} else if (!hasQuote && blockQuote) {
|
|
1213
|
+
blockQuote = false;
|
|
1214
|
+
}
|
|
1215
|
+
if (listStyle === null) {
|
|
1216
|
+
listStyle = hasList;
|
|
1217
|
+
} else if (listStyle !== hasList) {
|
|
1218
|
+
listStyle = false;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// End textblock.
|
|
1222
|
+
currentBlock = null;
|
|
1223
|
+
} else if (Object.hasOwn(InlineMarker, name) && currentBlock) {
|
|
1224
|
+
// Track markup in textblock.
|
|
1225
|
+
currentBlock.active[InlineMarker[name]] = false;
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const { from, to } = state.doc.lineAt(selection.main.anchor);
|
|
1232
|
+
const blankLine = from === to;
|
|
1233
|
+
|
|
1234
|
+
return {
|
|
1235
|
+
blankLine,
|
|
1236
|
+
blockType: blockType || null,
|
|
1237
|
+
blockQuote: blockQuote ?? false,
|
|
1238
|
+
code: inline[Inline.Code] ?? false,
|
|
1239
|
+
emphasis: inline[Inline.Emphasis] ?? false,
|
|
1240
|
+
strong: inline[Inline.Strong] ?? false,
|
|
1241
|
+
strikethrough: inline[Inline.Strikethrough] ?? false,
|
|
1242
|
+
link,
|
|
1243
|
+
listStyle: listStyle || null,
|
|
1244
|
+
};
|
|
1245
|
+
};
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Hook provides an extension to compute the current formatting state.
|
|
1249
|
+
*/
|
|
1250
|
+
export const formattingListener = (stateProvider: () => Formatting | undefined, delay = 100): Extension => {
|
|
1251
|
+
return EditorView.updateListener.of(
|
|
1252
|
+
debounceAndThrottle((update: ViewUpdate) => {
|
|
1253
|
+
if (update.docChanged || update.selectionSet) {
|
|
1254
|
+
const state = stateProvider();
|
|
1255
|
+
if (!state) {
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
Object.entries(getFormatting(update.state)).forEach(([key, active]) => {
|
|
1260
|
+
state[key as keyof Formatting] = active as any;
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
}, delay),
|
|
1264
|
+
);
|
|
1265
|
+
};
|