@dxos/react-ui-editor 0.4.5-main.f8d6c4e → 0.4.5

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 (58) hide show
  1. package/dist/lib/browser/index.mjs +464 -572
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts +7 -5
  5. package/dist/types/src/components/TextEditor/MarkdownEditor.stories.d.ts.map +1 -1
  6. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts +2 -0
  7. package/dist/types/src/components/TextEditor/TextEditor.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/TextEditor/automerge.stories.d.ts +2 -0
  9. package/dist/types/src/components/TextEditor/automerge.stories.d.ts.map +1 -1
  10. package/dist/types/src/components/TextEditor/hooks.stories.d.ts +2 -0
  11. package/dist/types/src/components/TextEditor/hooks.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  13. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +2 -0
  14. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  15. package/dist/types/src/extensions/cursor-line-margin.d.ts +8 -0
  16. package/dist/types/src/extensions/cursor-line-margin.d.ts.map +1 -0
  17. package/dist/types/src/extensions/index.d.ts +1 -0
  18. package/dist/types/src/extensions/index.d.ts.map +1 -1
  19. package/dist/types/src/extensions/markdown/decorate.d.ts +5 -0
  20. package/dist/types/src/extensions/markdown/decorate.d.ts.map +1 -0
  21. package/dist/types/src/extensions/markdown/formatting.d.ts.map +1 -1
  22. package/dist/types/src/extensions/markdown/index.d.ts +1 -4
  23. package/dist/types/src/extensions/markdown/index.d.ts.map +1 -1
  24. package/dist/types/src/extensions/markdown/link.d.ts +1 -11
  25. package/dist/types/src/extensions/markdown/link.d.ts.map +1 -1
  26. package/dist/types/src/index.d.ts +2 -0
  27. package/dist/types/src/index.d.ts.map +1 -1
  28. package/dist/types/src/styles/layout.d.ts +2 -2
  29. package/dist/types/src/styles/layout.d.ts.map +1 -1
  30. package/dist/types/src/translations.d.ts +2 -0
  31. package/dist/types/src/translations.d.ts.map +1 -1
  32. package/package.json +29 -26
  33. package/src/components/TextEditor/MarkdownEditor.stories.tsx +29 -37
  34. package/src/components/TextEditor/hooks.stories.tsx +5 -17
  35. package/src/components/Toolbar/Toolbar.stories.tsx +4 -7
  36. package/src/components/Toolbar/Toolbar.tsx +79 -47
  37. package/src/extensions/cursor-line-margin.ts +92 -0
  38. package/src/extensions/index.ts +1 -0
  39. package/src/extensions/markdown/decorate.ts +301 -0
  40. package/src/extensions/markdown/formatting.test.ts +17 -0
  41. package/src/extensions/markdown/formatting.ts +67 -76
  42. package/src/extensions/markdown/index.ts +1 -4
  43. package/src/extensions/markdown/link.ts +23 -185
  44. package/src/index.ts +2 -0
  45. package/src/styles/layout.ts +3 -2
  46. package/src/translations.ts +5 -3
  47. package/dist/types/src/extensions/markdown/code.d.ts +0 -2
  48. package/dist/types/src/extensions/markdown/code.d.ts.map +0 -1
  49. package/dist/types/src/extensions/markdown/heading.d.ts +0 -6
  50. package/dist/types/src/extensions/markdown/heading.d.ts.map +0 -1
  51. package/dist/types/src/extensions/markdown/hr.d.ts +0 -2
  52. package/dist/types/src/extensions/markdown/hr.d.ts.map +0 -1
  53. package/dist/types/src/extensions/markdown/tasklist.d.ts +0 -3
  54. package/dist/types/src/extensions/markdown/tasklist.d.ts.map +0 -1
  55. package/src/extensions/markdown/code.ts +0 -162
  56. package/src/extensions/markdown/heading.ts +0 -59
  57. package/src/extensions/markdown/hr.ts +0 -71
  58. package/src/extensions/markdown/tasklist.ts +0 -110
@@ -0,0 +1,301 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import { RangeSetBuilder, type EditorState } from '@codemirror/state';
7
+ import { EditorView, Decoration, type DecorationSet, WidgetType, ViewPlugin, type ViewUpdate } from '@codemirror/view';
8
+
9
+ import { mx } from '@dxos/react-ui-theme';
10
+
11
+ import { getToken } from '../../styles';
12
+
13
+ /**
14
+ * Widget for first/last line of code block (read-only).
15
+ */
16
+ // TODO(burdon): Add copy-to-clipboard button.
17
+ class FencedCodeBoundary extends WidgetType {
18
+ constructor(private readonly _className: string) {
19
+ super();
20
+ }
21
+
22
+ override toDOM() {
23
+ const el = document.createElement('span');
24
+ el.innerHTML = ' ';
25
+ el.className = mx('cm-code cm-codeblock', this._className);
26
+ return el;
27
+ }
28
+ }
29
+
30
+ class HorizontalRuleWidget extends WidgetType {
31
+ override toDOM() {
32
+ const el = document.createElement('span');
33
+ el.className = 'cm-hr';
34
+ return el;
35
+ }
36
+ }
37
+
38
+ class LinkButton extends WidgetType {
39
+ constructor(
40
+ private readonly url: string,
41
+ private readonly render: (el: Element, url: string) => void,
42
+ ) {
43
+ super();
44
+ }
45
+
46
+ override eq(other: this) {
47
+ return this.url === other.url;
48
+ }
49
+
50
+ override toDOM(view: EditorView) {
51
+ const el = document.createElement('span');
52
+ this.render(el, this.url);
53
+ return el;
54
+ }
55
+ }
56
+
57
+ class CheckboxWidget extends WidgetType {
58
+ constructor(private _checked: boolean) {
59
+ super();
60
+ }
61
+
62
+ override eq(other: this) {
63
+ return this._checked === other._checked;
64
+ }
65
+
66
+ override toDOM(view: EditorView) {
67
+ const input = document.createElement('input');
68
+ input.className = 'cm-task-checkbox';
69
+ input.type = 'checkbox';
70
+ input.checked = this._checked;
71
+ if (view.state.readOnly) {
72
+ input.setAttribute('disabled', 'true');
73
+ } else {
74
+ input.onmousedown = (event: Event) => {
75
+ const pos = view.posAtDOM(input);
76
+ const text = view.state.sliceDoc(pos, pos + 3);
77
+ if (text === (this._checked ? '[x]' : '[ ]')) {
78
+ view.dispatch({
79
+ changes: { from: pos + 1, to: pos + 2, insert: this._checked ? ' ' : 'x' },
80
+ });
81
+ event.preventDefault();
82
+ }
83
+ };
84
+ }
85
+ return input;
86
+ }
87
+
88
+ override ignoreEvent() {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ const hide = Decoration.replace({});
94
+ const fencedCodeTop = Decoration.replace({ widget: new FencedCodeBoundary('cm-codeblock-end cm-codeblock-first') });
95
+ const fencedCodeBottom = Decoration.replace({ widget: new FencedCodeBoundary('cm-codeblock-end cm-codeblock-last') });
96
+ const horizontalRule = Decoration.replace({ widget: new HorizontalRuleWidget() });
97
+ const checkedTask = Decoration.replace({ widget: new CheckboxWidget(true) });
98
+ const uncheckedTask = Decoration.replace({ widget: new CheckboxWidget(false) });
99
+
100
+ const editingRange = (state: EditorState, range: { from: number; to: number }) => {
101
+ const {
102
+ readOnly,
103
+ selection: {
104
+ main: { head },
105
+ },
106
+ } = state;
107
+ return !readOnly && head >= range.from && head <= range.to;
108
+ };
109
+
110
+ const MarksByParent = new Set(['CodeMark', 'EmphasisMark', 'StrikethroughMark', 'SubscriptMark', 'SuperscriptMark']);
111
+
112
+ const buildDecorations = (view: EditorView, options: DecorateOptions): DecorationSet => {
113
+ const builder = new RangeSetBuilder<Decoration>();
114
+ const { state } = view;
115
+
116
+ for (const { from, to } of view.visibleRanges) {
117
+ syntaxTree(state).iterate({
118
+ from,
119
+ to,
120
+ enter: (node) => {
121
+ if (node.name === 'FencedCode') {
122
+ const editing = editingRange(state, node);
123
+ // FencedCode > CodeMark > [CodeInfo] > CodeText > CodeMark
124
+ for (const block of view.viewportLineBlocks) {
125
+ if (block.to < node.from) {
126
+ continue;
127
+ }
128
+ if (block.from > node.to) {
129
+ break;
130
+ }
131
+ const first = block.from <= node.from;
132
+ const last = block.to >= node.to;
133
+ if (!editing && (first || last)) {
134
+ builder.add(block.from, block.to, first ? fencedCodeTop : fencedCodeBottom);
135
+ } else {
136
+ builder.add(
137
+ block.from,
138
+ block.from,
139
+ Decoration.line({
140
+ class: mx('cm-code cm-codeblock-line', first && 'cm-codeblock-first', last && 'cm-codeblock-last'),
141
+ }),
142
+ );
143
+ }
144
+ }
145
+ return false;
146
+ } else if (node.name === 'Link') {
147
+ const marks = node.node.getChildren('LinkMark');
148
+ const urlNode = node.node.getChild('URL');
149
+ const editing = editingRange(state, node);
150
+ if (urlNode && marks.length >= 2) {
151
+ const url = state.sliceDoc(urlNode.from, urlNode.to);
152
+ if (!editing) {
153
+ builder.add(node.from, marks[0].to, hide);
154
+ }
155
+ builder.add(
156
+ marks[0].to,
157
+ marks[1].from,
158
+ Decoration.mark({
159
+ tagName: 'a',
160
+ attributes: {
161
+ class: 'cm-link',
162
+ href: url,
163
+ rel: 'noreferrer',
164
+ target: '_blank',
165
+ },
166
+ }),
167
+ );
168
+ if (!editing) {
169
+ builder.add(
170
+ marks[1].from,
171
+ node.to,
172
+ options.renderLinkButton
173
+ ? Decoration.replace({
174
+ widget: new LinkButton(url, options.renderLinkButton),
175
+ })
176
+ : hide,
177
+ );
178
+ }
179
+ }
180
+ } else if (node.name === 'HeaderMark') {
181
+ const parent = node.node.parent!;
182
+ if (/^ATX/.test(parent.name) && !editingRange(state, state.doc.lineAt(node.from))) {
183
+ const next = state.doc.sliceString(node.to, node.to + 1);
184
+ builder.add(node.from, node.to + (next === ' ' ? 1 : 0), hide);
185
+ }
186
+ } else if (node.name === 'HorizontalRule') {
187
+ if (!editingRange(state, node)) {
188
+ builder.add(node.from, node.to, horizontalRule);
189
+ }
190
+ } else if (node.name === 'TaskMarker') {
191
+ // Check if cursor is inside text.
192
+ if (!editingRange(state, node)) {
193
+ const checked = state.doc.sliceString(node.from + 1, node.to - 1) === 'x';
194
+ builder.add(node.from - 2, node.from - 1, Decoration.mark({ class: 'cm-task' }));
195
+ builder.add(node.from, node.to, checked ? checkedTask : uncheckedTask);
196
+ }
197
+ } else if (MarksByParent.has(node.name)) {
198
+ if (!editingRange(state, node.node.parent!)) {
199
+ builder.add(node.from, node.to, hide);
200
+ }
201
+ }
202
+ },
203
+ });
204
+ }
205
+
206
+ return builder.finish();
207
+ };
208
+
209
+ export interface DecorateOptions {
210
+ renderLinkButton?: (el: Element, url: string) => void;
211
+ }
212
+
213
+ export const decorateMarkdown = (options: DecorateOptions = {}) => {
214
+ return [
215
+ ViewPlugin.fromClass(
216
+ class {
217
+ decorations: DecorationSet;
218
+ constructor(view: EditorView) {
219
+ this.decorations = buildDecorations(view, options);
220
+ }
221
+
222
+ update(update: ViewUpdate) {
223
+ if (update.docChanged || update.selectionSet || update.viewportChanged) {
224
+ this.decorations = buildDecorations(update.view, options);
225
+ }
226
+ }
227
+ },
228
+ {
229
+ decorations: (value) => value.decorations,
230
+ },
231
+ ),
232
+ formattingStyles,
233
+ ];
234
+ };
235
+
236
+ const formattingStyles = EditorView.baseTheme({
237
+ '& .cm-code': {
238
+ fontFamily: getToken('fontFamily.mono', []).join(','),
239
+ },
240
+ '& .cm-codeblock-line': {
241
+ paddingInline: '1rem !important',
242
+ },
243
+
244
+ '& .cm-codeblock-end': {
245
+ display: 'inline-block',
246
+ width: '100%',
247
+ position: 'relative',
248
+ '&::after': {
249
+ position: 'absolute',
250
+ inset: 0,
251
+ content: '""',
252
+ },
253
+ },
254
+ '& .cm-codeblock-first': {
255
+ '&::after': {
256
+ borderTopLeftRadius: '.5rem',
257
+ borderTopRightRadius: '.5rem',
258
+ },
259
+ },
260
+ '& .cm-codeblock-last': {
261
+ '&::after': {
262
+ borderBottomLeftRadius: '.5rem',
263
+ borderBottomRightRadius: '.5rem',
264
+ },
265
+ },
266
+
267
+ '&light .cm-codeblock-line, &light .cm-activeLine.cm-codeblock-line': {
268
+ background: getToken('extend.semanticColors.input.light'),
269
+ mixBlendMode: 'darken',
270
+ },
271
+ '&dark .cm-codeblock-line, &dark .cm-activeLine.cm-codeblock-line': {
272
+ background: getToken('extend.semanticColors.input.dark'),
273
+ mixBlendMode: 'lighten',
274
+ },
275
+ '&light .cm-codeblock-first, &light .cm-codeblock-last': {
276
+ mixBlendMode: 'darken',
277
+ '&::after': {
278
+ background: getToken('extend.semanticColors.input.light'),
279
+ },
280
+ },
281
+ '&dark .cm-codeblock-first, &dark .cm-codeblock-last': {
282
+ mixBlendMode: 'lighten',
283
+ '&::after': {
284
+ background: getToken('extend.semanticColors.input.dark'),
285
+ },
286
+ },
287
+ '& .cm-hr': {
288
+ display: 'inline-block',
289
+ width: '100%',
290
+ height: '0',
291
+ verticalAlign: 'middle',
292
+ borderTop: `1px solid ${getToken('extend.colors.neutral.200')}`,
293
+ },
294
+ '& .cm-task': {
295
+ color: getToken('extend.colors.blue.500'),
296
+ },
297
+ '& .cm-task-checkbox': {
298
+ marginLeft: '4px',
299
+ marginRight: '4px',
300
+ },
301
+ });
@@ -101,6 +101,8 @@ describe('setHeading', () => {
101
101
  testCommand('can add a heading inside block markup', '> - {one\n> - two}\n', setHeading(1), '> - # one\n> - # two\n');
102
102
 
103
103
  testCommand('can remove a heading inside block markup', '1. # one{}', setHeading(0), '1. one');
104
+
105
+ testCommand('can add a heading to a blank line', 'one\n\n{}', setHeading(1), 'one\n\n# {}');
104
106
  });
105
107
 
106
108
  describe('addStyle', () => {
@@ -158,6 +160,8 @@ describe('addStyle', () => {
158
160
  em,
159
161
  '*[one {two](x) ![three} four](y)* five',
160
162
  );
163
+
164
+ testCommand('can add a style to an empty line', '{}', em, '*{}*');
161
165
  });
162
166
 
163
167
  describe('removeStyle', () => {
@@ -188,6 +192,15 @@ describe('removeStyle', () => {
188
192
  );
189
193
 
190
194
  testCommand('can shrink existing styles', '*one {two three} four*', em, '*one* {two three} *four*');
195
+
196
+ testCommand('can remove strong from a partially emphasized text', '**{one*two*}**', str, '{one*two*}');
197
+
198
+ testCommand(
199
+ 'can remove strong from a partially emphasized text with markers outside',
200
+ '***{o*ne*two}***',
201
+ str,
202
+ '*{o*ne*two}*',
203
+ );
191
204
  });
192
205
 
193
206
  describe('addLink', () => {
@@ -284,6 +297,8 @@ describe('addList', () => {
284
297
  );
285
298
 
286
299
  testCommand("doesn't renumber lists with a different parent", '> one{}\n\n1. two', ordered, '> 1. one\n\n1. two');
300
+
301
+ testCommand('can add a list to an empty line', '{}', ordered, '1. {}');
287
302
  });
288
303
 
289
304
  describe('removeList', () => {
@@ -345,6 +360,8 @@ describe('addBlockquote', () => {
345
360
  addBlockquote,
346
361
  '> one\n> two\n>\n> # three\n\nfour',
347
362
  );
363
+
364
+ testCommand('can add a blockquote to an empty line', '{}', addBlockquote, '>{}');
348
365
  });
349
366
 
350
367
  describe('removeBlockquote', () => {
@@ -7,14 +7,13 @@ import { syntaxTree } from '@codemirror/language';
7
7
  import {
8
8
  type Extension,
9
9
  type StateCommand,
10
- RangeSetBuilder,
11
10
  type EditorState,
12
11
  type ChangeSpec,
13
12
  type Text,
14
13
  EditorSelection,
15
14
  type Line,
16
15
  } from '@codemirror/state';
17
- import { Decoration, type DecorationSet, EditorView, keymap, ViewPlugin, type ViewUpdate } from '@codemirror/view';
16
+ import { EditorView, keymap } from '@codemirror/view';
18
17
  import { type SyntaxNodeRef, type SyntaxNode } from '@lezer/common';
19
18
  import { useState, useMemo } from 'react';
20
19
 
@@ -94,6 +93,7 @@ export const setHeading =
94
93
  const changes: ChangeSpec[] = [];
95
94
  let prevBlock = -1;
96
95
  for (const range of ranges) {
96
+ let sawBlock = false;
97
97
  syntaxTree(state).iterate({
98
98
  from: range.from,
99
99
  to: range.to,
@@ -101,6 +101,7 @@ export const setHeading =
101
101
  if (!Object.hasOwn(Textblocks, node.name) || prevBlock === node.from) {
102
102
  return;
103
103
  }
104
+ sawBlock = true;
104
105
  prevBlock = node.from;
105
106
  const blockType = Textblocks[node.name];
106
107
  const isHeading = /heading(\d)/.exec(blockType);
@@ -129,13 +130,25 @@ export const setHeading =
129
130
  }
130
131
  },
131
132
  });
133
+ let line;
134
+ if (!sawBlock && range.empty && level > 0 && !/\S/.test((line = state.doc.lineAt(range.from)).text)) {
135
+ changes.push({ from: line.from, to: line.to, insert: '#'.repeat(level) + ' ' });
136
+ }
132
137
  }
133
138
 
134
139
  if (!changes.length) {
135
140
  return false;
136
141
  }
137
142
 
138
- dispatch(state.update({ changes, userEvent: 'format.setHeading', scrollIntoView: true }));
143
+ const changeSet = state.changes(changes);
144
+ dispatch(
145
+ state.update({
146
+ changes: changeSet,
147
+ selection: state.selection.map(changeSet, 1),
148
+ userEvent: 'format.setHeading',
149
+ scrollIntoView: true,
150
+ }),
151
+ );
139
152
  return true;
140
153
  };
141
154
 
@@ -174,8 +187,8 @@ export const setStyle =
174
187
  const changesAtEnd: ChangeSpec[] = [];
175
188
  let blockStart = -1;
176
189
  let blockEnd = -1;
177
- let startCovered: boolean | 'adjacent' = false;
178
- let endCovered: boolean | 'adjacent' = false;
190
+ let startCovered: boolean | { from: number; to: number } = false;
191
+ let endCovered: boolean | { from: number; to: number } = false;
179
192
  let { from, to } = range;
180
193
  // Iterate the selected range. For each textblock, determine a
181
194
  // start and end position, the overlap of the selected range and
@@ -218,10 +231,16 @@ export const setStyle =
218
231
  // by this.
219
232
  if (markType === type) {
220
233
  if (openEnd <= from && closeStart >= from) {
221
- startCovered = openEnd === from ? 'adjacent' : true;
234
+ startCovered =
235
+ !enable && openEnd === skipMarkers(from, node.node, -1, openEnd)
236
+ ? { from: node.from, to: openEnd }
237
+ : true;
222
238
  }
223
239
  if (openEnd <= to && closeStart >= to) {
224
- endCovered = closeStart === to ? 'adjacent' : true;
240
+ endCovered =
241
+ !enable && closeStart === skipMarkers(to, node.node, 1, closeStart)
242
+ ? { from: closeStart, to: node.to }
243
+ : true;
225
244
  }
226
245
  }
227
246
  // Marks of the same type in range, or any mark if we're
@@ -263,13 +282,13 @@ export const setStyle =
263
282
  changes.push({ from: rangeEnd, insert: marker });
264
283
  }
265
284
  } else {
266
- if (startCovered === 'adjacent') {
267
- changes.push({ from: from - marker.length, to: from });
285
+ if (typeof startCovered === 'object') {
286
+ changes.push(startCovered);
268
287
  } else if (startCovered) {
269
288
  changes.push({ from: skipSpaces(rangeStart, state.doc, -1, blockStart), insert: marker });
270
289
  }
271
- if (endCovered === 'adjacent') {
272
- changes.push({ from: to, to: to + marker.length });
290
+ if (typeof endCovered === 'object') {
291
+ changes.push(endCovered);
273
292
  } else if (endCovered) {
274
293
  changes.push({ from: skipSpaces(rangeEnd, state.doc, 1, blockEnd), insert: marker });
275
294
  }
@@ -277,6 +296,12 @@ export const setStyle =
277
296
  }
278
297
  },
279
298
  });
299
+ if (blockStart < 0 && range.empty && enable && !/\S/.test(state.doc.lineAt(range.from).text)) {
300
+ return {
301
+ changes: { from: range.head, insert: marker + marker },
302
+ range: EditorSelection.cursor(range.head + marker.length),
303
+ };
304
+ }
280
305
  const changeSet = state.changes(changes.concat(changesAtEnd));
281
306
  return {
282
307
  changes: changeSet,
@@ -350,6 +375,20 @@ const skipSpaces = (pos: number, doc: Text, dir: -1 | 1, limit?: number) => {
350
375
  return pos;
351
376
  };
352
377
 
378
+ const skipMarkers = (pos: number, tree: SyntaxNode, dir: -1 | 1, limit?: number) => {
379
+ for (;;) {
380
+ const next = tree.resolve(pos, dir);
381
+ if (!IgnoreInline.has(next.name)) {
382
+ return pos;
383
+ }
384
+ const moveTo = dir < 0 ? next.from : next.to;
385
+ if (limit != null && (dir < 0 ? moveTo < limit : moveTo > limit)) {
386
+ return pos;
387
+ }
388
+ pos = moveTo;
389
+ }
390
+ };
391
+
353
392
  // TODO(burdon): Define and trigger snippets for codeblock, table, etc.
354
393
  const snippets = {
355
394
  codeblock: snippet(
@@ -515,7 +554,6 @@ export const addLink: StateCommand = ({ state, dispatch }) => {
515
554
  // Lists
516
555
  //
517
556
 
518
- // TODO(burdon): Cursor is positioned incorrectly.
519
557
  export const addList =
520
558
  (type: List): StateCommand =>
521
559
  ({ state, dispatch }) => {
@@ -654,7 +692,15 @@ export const addList =
654
692
  }
655
693
  }
656
694
 
657
- dispatch(state.update({ changes, userEvent: 'format.list.add', scrollIntoView: true }));
695
+ const changeSet = state.changes(changes);
696
+ dispatch(
697
+ state.update({
698
+ changes: changeSet,
699
+ selection: state.selection.map(changeSet, 1),
700
+ userEvent: 'format.list.add',
701
+ scrollIntoView: true,
702
+ }),
703
+ );
658
704
  return true;
659
705
  };
660
706
 
@@ -751,6 +797,7 @@ export const setBlockquote =
751
797
  const lines: Line[] = [];
752
798
  let lastBlock = -1;
753
799
  for (const { from, to } of state.selection.ranges) {
800
+ const sawBlock = false;
754
801
  syntaxTree(state).iterate({
755
802
  from,
756
803
  to,
@@ -786,6 +833,10 @@ export const setBlockquote =
786
833
  }
787
834
  },
788
835
  });
836
+ let line;
837
+ if (!sawBlock && enable && from === to && !/\S/.test((line = state.doc.lineAt(from)).text)) {
838
+ lines.push(line);
839
+ }
789
840
  }
790
841
 
791
842
  const changes: ChangeSpec[] = [];
@@ -803,9 +854,11 @@ export const setBlockquote =
803
854
  return false;
804
855
  }
805
856
 
857
+ const changeSet = state.changes(changes);
806
858
  dispatch(
807
859
  state.update({
808
- changes,
860
+ changes: changeSet,
861
+ selection: state.selection.map(changeSet, 1),
809
862
  userEvent: enable ? 'format.blockquote.add' : 'format.blockquote.remove',
810
863
  scrollIntoView: true,
811
864
  }),
@@ -942,68 +995,6 @@ export const formatting = (options: FormattingOptions = {}): Extension => {
942
995
  run: toggleStrong,
943
996
  },
944
997
  ]),
945
- styling(),
946
- ];
947
- };
948
-
949
- const styling = (): Extension => {
950
- const buildDecorations = (view: EditorView): DecorationSet => {
951
- const builder = new RangeSetBuilder<Decoration>();
952
- const { state } = view;
953
- const cursor = state.selection.main.head;
954
-
955
- // TODO(burdon): Bug if '***foo***' (since StrongEmphasis is nested inside EmphasisMark).
956
- // Ranges must be added sorted by `from` position and `startSide`.
957
- const replace = (node: SyntaxNodeRef, marks: SyntaxNode[]) => {
958
- if (cursor <= node.from || cursor >= node.to) {
959
- for (const mark of marks) {
960
- builder.add(mark.from, mark.to, Decoration.replace({}));
961
- }
962
- }
963
- };
964
-
965
- for (const { from, to } of view.visibleRanges) {
966
- syntaxTree(state).iterate({
967
- enter: (node) => {
968
- switch (node.name) {
969
- case 'Emphasis':
970
- case 'StrongEmphasis': {
971
- const marks = node.node.getChildren('EmphasisMark');
972
- replace(node, marks);
973
- break;
974
- }
975
-
976
- case 'Strikethrough': {
977
- const marks = node.node.getChildren('StrikethroughMark');
978
- replace(node, marks);
979
- break;
980
- }
981
- }
982
- },
983
- from,
984
- to,
985
- });
986
- }
987
-
988
- return builder.finish();
989
- };
990
-
991
- return [
992
- ViewPlugin.fromClass(
993
- class {
994
- decorations: DecorationSet;
995
- constructor(view: EditorView) {
996
- this.decorations = buildDecorations(view);
997
- }
998
-
999
- update(update: ViewUpdate) {
1000
- this.decorations = buildDecorations(update.view);
1001
- }
1002
- },
1003
- {
1004
- decorations: (value) => value.decorations,
1005
- },
1006
- ),
1007
998
  ];
1008
999
  };
1009
1000
 
@@ -3,12 +3,9 @@
3
3
  //
4
4
 
5
5
  export * from './bundle';
6
- export * from './code';
6
+ export * from './decorate';
7
7
  export * from './formatting';
8
- export * from './heading';
9
8
  export * from './highlight';
10
- export * from './hr';
11
9
  export * from './image';
12
10
  export * from './link';
13
11
  export * from './table';
14
- export * from './tasklist';