@dxos/react-ui-editor 0.3.11-main.5cbcf4e → 0.3.11-main.5e05862

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