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