@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.
Files changed (25) hide show
  1. package/dist/lib/browser/index.mjs +1178 -159
  2. package/dist/lib/browser/index.mjs.map +3 -3
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  5. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/Toolbar/Toolbar.d.ts +8 -4
  7. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  8. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +5 -3
  9. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  10. package/dist/types/src/extensions/comments.d.ts +5 -1
  11. package/dist/types/src/extensions/comments.d.ts.map +1 -1
  12. package/dist/types/src/extensions/markdown/formatting.d.ts +55 -10
  13. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  14. package/dist/types/src/extensions/markdown/formatting.test.d.ts +3 -0
  15. package/dist/types/src/extensions/markdown/formatting.test.d.ts.map +1 -0
  16. package/dist/types/src/hooks/useActionHandler.d.ts.map +1 -1
  17. package/package.json +24 -24
  18. package/src/components/TextEditor/MarkdownEditor.stories.tsx +3 -3
  19. package/src/components/TextEditor/hooks.stories.tsx +6 -1
  20. package/src/components/Toolbar/Toolbar.stories.tsx +16 -4
  21. package/src/components/Toolbar/Toolbar.tsx +128 -30
  22. package/src/extensions/comments.ts +2 -2
  23. package/src/extensions/markdown/formatting.test.ts +482 -0
  24. package/src/extensions/markdown/formatting.ts +1118 -69
  25. 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 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';
18
20
 
19
- export type FormattingOptions = {};
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): Command =>
23
- (view: EditorView) => {
88
+ (level: number): StateCommand =>
89
+ ({ state, dispatch }) => {
24
90
  const {
25
91
  selection: { ranges },
26
92
  doc,
27
- } = view.state;
28
- const changes = [];
93
+ } = state;
94
+ const changes: ChangeSpec[] = [];
95
+ let prevBlock = -1;
29
96
  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
- }
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
- view.dispatch({ changes });
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
- 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;
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
- // 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
- ],
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(['```#{lang}', '\t#{}', '```'].join('\n')),
355
+ codeblock: snippet(
356
+ [
357
+ //
358
+ '```#{}',
359
+ '#{}',
360
+ '```',
361
+ ].join('\n'),
362
+ ),
83
363
  table: snippet(
84
- ['| #{col1} | #{col2} |', '| ---- | ---- |', '| #{val1} | #{val2} |', '| #{val3} | #{val4} |'].join('\n'),
364
+ [
365
+ //
366
+ '| #{col1} | #{col2} |',
367
+ '| ---- | ---- |',
368
+ '| #{val1} | #{val2} |',
369
+ '| #{val3} | #{val4} |',
370
+ '',
371
+ ].join('\n'),
85
372
  ),
86
373
  };
87
374
 
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
- };
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
- export const toggleBold = toggleStyle('**');
109
- export const toggleItalic = toggleStyle('_');
110
- export const toggleStrikethrough = toggleStyle('~~');
390
+ //
391
+ // Links
392
+ //
111
393
 
112
- export const toggleList = (view: EditorView) => {};
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: toggleBold,
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
+ };