mrmd-editor 0.7.1 → 0.8.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 (58) hide show
  1. package/package.json +3 -1
  2. package/src/commands.js +112 -4
  3. package/src/comment-syntax.js +364 -39
  4. package/src/config/handlers.js +1 -2
  5. package/src/config/schema.js +46 -4
  6. package/src/document-template.js +2236 -0
  7. package/src/frontmatter-updater.js +204 -74
  8. package/src/grammar.js +758 -0
  9. package/src/index.js +1074 -55
  10. package/src/keymap.js +11 -2
  11. package/src/markdown/block-decorations.js +108 -5
  12. package/src/markdown/facets.js +37 -0
  13. package/src/markdown/html-inline.js +9 -5
  14. package/src/markdown/index.js +13 -3
  15. package/src/markdown/inline-commands.js +256 -0
  16. package/src/markdown/inline-model.js +578 -0
  17. package/src/markdown/inline-state.js +103 -0
  18. package/src/markdown/renderer.js +219 -12
  19. package/src/markdown/styles.js +290 -3
  20. package/src/markdown/widgets/alert-title.js +10 -8
  21. package/src/markdown/widgets/frontmatter.js +0 -6
  22. package/src/markdown/widgets/index.js +1 -0
  23. package/src/markdown/widgets/list-marker.js +29 -0
  24. package/src/markdown/wysiwyg.js +1158 -0
  25. package/src/mrp-types.js +2 -0
  26. package/src/output-widget.js +532 -18
  27. package/src/page-view-pagination.js +127 -0
  28. package/src/runtime-lsp.js +1757 -150
  29. package/src/section-controls/commands.js +617 -0
  30. package/src/section-controls/index.js +63 -0
  31. package/src/section-controls/plugin.js +165 -0
  32. package/src/section-controls/widgets.js +936 -0
  33. package/src/shell/ai-menu.js +11 -0
  34. package/src/shell/components/context-panel.js +572 -0
  35. package/src/shell/components/status-bar.js +10 -2
  36. package/src/shell/layouts/studio.js +206 -14
  37. package/src/shell/orchestrator-client.js +69 -0
  38. package/src/spellcheck.js +166 -0
  39. package/src/tables/README.md +97 -0
  40. package/src/tables/commands/insert-linked-table.js +122 -0
  41. package/src/tables/commands/open-table-workspace.js +43 -0
  42. package/src/tables/index.js +24 -0
  43. package/src/tables/jobs/client.js +158 -0
  44. package/src/tables/parsing/anchors.js +82 -0
  45. package/src/tables/parsing/linked-table-blocks.js +61 -0
  46. package/src/tables/state/linked-table-state.js +68 -0
  47. package/src/tables/widgets/linked-table-source-banner.js +77 -0
  48. package/src/tables/widgets/linked-table-widget.js +256 -0
  49. package/src/tables/workspace/controller.js +616 -0
  50. package/src/term-pty-client.js +51 -2
  51. package/src/term-widget.js +43 -3
  52. package/src/widgets/theme-utils.js +24 -16
  53. package/src/widgets/theme.js +1015 -1
  54. package/src/runtime-codelens/detector.js +0 -279
  55. package/src/runtime-codelens/index.js +0 -76
  56. package/src/runtime-codelens/plugin.js +0 -142
  57. package/src/runtime-codelens/styles.js +0 -184
  58. package/src/runtime-codelens/widgets.js +0 -216
@@ -0,0 +1,1158 @@
1
+ /**
2
+ * WYSIWYG mode support for the markdown editor.
3
+ *
4
+ * Provides:
5
+ * - atomic protection around markdown syntax markers
6
+ * - transaction filtering to avoid accidental syntax corruption
7
+ * - WYSIWYG-native key layer (Backspace demotion/merge, Enter continuation, Mod-B/I/`)
8
+ * - code block protection (no backspace out of code, no editing fence lines)
9
+ * - proper bold/italic/code toggling (unwrap when already formatted)
10
+ *
11
+ * @module markdown/wysiwyg
12
+ */
13
+
14
+ import { EditorState, Transaction } from '@codemirror/state';
15
+ import { Decoration, EditorView, ViewPlugin, WidgetType, keymap } from '@codemirror/view';
16
+ import { syntaxTree } from '@codemirror/language';
17
+ import { sourceModeFacet, wysiwygModeFacet } from './facets.js';
18
+ import {
19
+ findDelimitedRange as sharedFindDelimitedRange,
20
+ getLineInlineModel,
21
+ inlineClassForMark,
22
+ markToSyntax,
23
+ syntaxToMark,
24
+ } from './inline-model.js';
25
+ import { toggleInlineMark } from './inline-commands.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function isWysiwygActive(state) {
32
+ return state.facet(wysiwygModeFacet) && !state.facet(sourceModeFacet);
33
+ }
34
+
35
+ function pushRange(ranges, from, to) {
36
+ if (from >= to) return;
37
+ ranges.push({ from, to });
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Protected-range collection
42
+ // ---------------------------------------------------------------------------
43
+
44
+ function collectProtectedRanges(state, from = 0, to = state.doc.length) {
45
+ const doc = state.doc;
46
+ const ranges = [];
47
+
48
+ syntaxTree(state).iterate({
49
+ from,
50
+ to,
51
+ enter: (node) => {
52
+ if (
53
+ node.name === 'HeaderMark' ||
54
+ node.name === 'EmphasisMark' ||
55
+ node.name === 'StrikethroughMark' ||
56
+ node.name === 'QuoteMark' ||
57
+ node.name === 'ListMark'
58
+ ) {
59
+ pushRange(ranges, node.from, node.to);
60
+ return;
61
+ }
62
+
63
+ if (node.name === 'CodeMark') {
64
+ const text = doc.sliceString(node.from, node.to);
65
+ pushRange(ranges, node.from, node.to);
66
+
67
+ // For fenced code blocks, also protect the full fence line so the user
68
+ // cannot accidentally edit the language/info string.
69
+ if (text.length >= 3) {
70
+ const line = doc.lineAt(node.from);
71
+ pushRange(ranges, line.from, line.to);
72
+ }
73
+ return;
74
+ }
75
+
76
+ // Links and images are rendered as widgets – protect full syntax range.
77
+ if (node.name === 'Link' || node.name === 'Image') {
78
+ pushRange(ranges, node.from, node.to);
79
+ return false;
80
+ }
81
+
82
+ if (node.name === 'FencedCode') {
83
+ const startLine = doc.lineAt(node.from);
84
+ const endLine = doc.lineAt(node.to);
85
+ pushRange(ranges, startLine.from, startLine.to);
86
+ pushRange(ranges, endLine.from, endLine.to);
87
+ return false;
88
+ }
89
+ },
90
+ });
91
+
92
+ // Protect wiki-link raw syntax when rendered as widgets.
93
+ const startLine = doc.lineAt(from).number;
94
+ const endLine = doc.lineAt(Math.max(from, to)).number;
95
+ const wikiRegex = /\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|([^\]]+))?\]\]/g;
96
+ for (let i = startLine; i <= endLine; i++) {
97
+ const line = doc.line(i);
98
+ wikiRegex.lastIndex = 0;
99
+ let match;
100
+ while ((match = wikiRegex.exec(line.text)) !== null) {
101
+ pushRange(ranges, line.from + match.index, line.from + match.index + match[0].length);
102
+ }
103
+ }
104
+
105
+ // Sort by from position (wiki-link ranges appended after tree ranges may be out of order)
106
+ ranges.sort((a, b) => a.from - b.from || a.to - b.to);
107
+ return ranges;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Atomic ranges plugin
112
+ // ---------------------------------------------------------------------------
113
+
114
+ const wysiwygAtomicPlugin = ViewPlugin.fromClass(
115
+ class {
116
+ constructor() {
117
+ this.decorations = Decoration.none;
118
+ }
119
+
120
+ update() {
121
+ this.decorations = Decoration.none;
122
+ }
123
+ },
124
+ {
125
+ decorations: (v) => v.decorations,
126
+ }
127
+ );
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // Code-fence header widget
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /** Tiny invisible widget used to replace the closing ``` fence line. */
134
+ class CodeFenceCloseWidget extends WidgetType {
135
+ eq() { return true; }
136
+ toDOM() {
137
+ const el = document.createElement('span');
138
+ el.className = 'cm-wysiwyg-code-fence-close';
139
+ el.setAttribute('aria-hidden', 'true');
140
+ return el;
141
+ }
142
+ ignoreEvent() { return true; }
143
+ }
144
+
145
+ class CodeFenceHeaderWidget extends WidgetType {
146
+ constructor(lang, blockFrom, blockTo) {
147
+ super();
148
+ this.lang = lang;
149
+ this.blockFrom = blockFrom;
150
+ this.blockTo = blockTo;
151
+ }
152
+
153
+ eq(other) {
154
+ return other.lang === this.lang && other.blockFrom === this.blockFrom && other.blockTo === this.blockTo;
155
+ }
156
+
157
+ toDOM(view) {
158
+ const wrapper = document.createElement('div');
159
+ wrapper.className = 'cm-wysiwyg-code-header';
160
+
161
+ // Language label (editable on click)
162
+ const langEl = document.createElement('span');
163
+ langEl.className = 'cm-wysiwyg-code-header-lang';
164
+ langEl.textContent = this.lang || 'plain text';
165
+ langEl.title = 'Click to change language';
166
+
167
+ const blockFrom = this.blockFrom;
168
+ const blockTo = this.blockTo;
169
+ const editorView = view;
170
+
171
+ langEl.addEventListener('mousedown', (e) => {
172
+ e.stopPropagation();
173
+ e.preventDefault();
174
+
175
+ const input = document.createElement('input');
176
+ input.type = 'text';
177
+ input.className = 'cm-wysiwyg-code-header-lang-input';
178
+ input.value = langEl.textContent === 'plain text' ? '' : langEl.textContent;
179
+ input.placeholder = 'language';
180
+
181
+ const commit = () => {
182
+ const newLang = input.value.trim();
183
+ if (input.parentNode) {
184
+ input.parentNode.replaceChild(langEl, input);
185
+ }
186
+ langEl.textContent = newLang || 'plain text';
187
+
188
+ const doc = editorView.state.doc;
189
+ const fenceLine = doc.lineAt(blockFrom);
190
+ const fenceMatch = fenceLine.text.match(/^(`{3,})(.*)/);
191
+ if (fenceMatch) {
192
+ editorView.dispatch({
193
+ changes: { from: fenceLine.from, to: fenceLine.to, insert: fenceMatch[1] + (newLang || '') },
194
+ userEvent: 'input.wysiwyg.change-lang',
195
+ });
196
+ }
197
+ };
198
+
199
+ input.addEventListener('blur', commit);
200
+ input.addEventListener('keydown', (ev) => {
201
+ if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
202
+ if (ev.key === 'Escape') { ev.preventDefault(); input.value = ''; input.blur(); }
203
+ });
204
+
205
+ langEl.parentNode.replaceChild(input, langEl);
206
+ // Defer focus so the input is in the DOM
207
+ requestAnimationFrame(() => { input.focus(); input.select(); });
208
+ });
209
+
210
+ wrapper.appendChild(langEl);
211
+
212
+ // ⋯ menu button with dropdown
213
+ const menuWrap = document.createElement('span');
214
+ menuWrap.className = 'cm-wysiwyg-code-header-menu-wrap';
215
+
216
+ const menuBtn = document.createElement('button');
217
+ menuBtn.className = 'cm-wysiwyg-code-header-btn';
218
+ menuBtn.title = 'Code block options';
219
+ menuBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>';
220
+ menuWrap.appendChild(menuBtn);
221
+
222
+ // Dropdown menu (hidden by default)
223
+ const menu = document.createElement('div');
224
+ menu.className = 'cm-wysiwyg-code-header-dropdown';
225
+ menu.style.display = 'none';
226
+
227
+ const makeItem = (label, icon, action) => {
228
+ const item = document.createElement('button');
229
+ item.className = 'cm-wysiwyg-code-header-dropdown-item';
230
+ item.innerHTML = icon + '<span>' + label + '</span>';
231
+ item.addEventListener('mousedown', (e) => {
232
+ e.stopPropagation();
233
+ e.preventDefault();
234
+ menu.style.display = 'none';
235
+ action();
236
+ });
237
+ return item;
238
+ };
239
+
240
+ // Copy
241
+ menu.appendChild(makeItem('Copy code',
242
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>',
243
+ () => {
244
+ const doc = editorView.state.doc;
245
+ const startLine = doc.lineAt(blockFrom);
246
+ const endLine = doc.lineAt(blockTo);
247
+ const codeFrom = startLine.to + 1;
248
+ const codeTo = endLine.from > 0 ? endLine.from - 1 : endLine.from;
249
+ if (codeFrom < codeTo) {
250
+ navigator.clipboard?.writeText(doc.sliceString(codeFrom, codeTo));
251
+ }
252
+ }));
253
+
254
+ // Delete
255
+ menu.appendChild(makeItem('Delete block',
256
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>',
257
+ () => {
258
+ const doc = editorView.state.doc;
259
+ let delFrom = blockFrom;
260
+ let delTo = Math.min(blockTo, doc.length);
261
+ if (delFrom > 0 && doc.sliceString(delFrom - 1, delFrom) === '\n') delFrom--;
262
+ if (delTo < doc.length && doc.sliceString(delTo, delTo + 1) === '\n') delTo++;
263
+ editorView.dispatch({
264
+ changes: { from: delFrom, to: delTo, insert: '' },
265
+ userEvent: 'delete.wysiwyg.delete-codeblock',
266
+ });
267
+ }));
268
+
269
+ menuWrap.appendChild(menu);
270
+
271
+ // Toggle menu on button click
272
+ menuBtn.addEventListener('mousedown', (e) => {
273
+ e.stopPropagation();
274
+ e.preventDefault();
275
+ const isOpen = menu.style.display !== 'none';
276
+ menu.style.display = isOpen ? 'none' : '';
277
+ if (!isOpen) {
278
+ // Close on outside click
279
+ const close = (ev) => {
280
+ if (!menuWrap.contains(ev.target)) {
281
+ menu.style.display = 'none';
282
+ document.removeEventListener('mousedown', close, true);
283
+ }
284
+ };
285
+ // Defer so this mousedown doesn't immediately close it
286
+ requestAnimationFrame(() => {
287
+ document.addEventListener('mousedown', close, true);
288
+ });
289
+ }
290
+ });
291
+
292
+ wrapper.appendChild(menuWrap);
293
+
294
+ return wrapper;
295
+ }
296
+
297
+ ignoreEvent(event) {
298
+ // Let mousedown through so our buttons work, but ignore everything else
299
+ return event.type !== 'mousedown';
300
+ }
301
+ }
302
+
303
+ // ---------------------------------------------------------------------------
304
+ // Fence decorations plugin
305
+ // ---------------------------------------------------------------------------
306
+
307
+ const wysiwygFencePlugin = ViewPlugin.fromClass(
308
+ class {
309
+ constructor(view) {
310
+ this.decorations = this.build(view);
311
+ }
312
+
313
+ update(update) {
314
+ const modeChanged =
315
+ update.startState.facet(wysiwygModeFacet) !== update.state.facet(wysiwygModeFacet) ||
316
+ update.startState.facet(sourceModeFacet) !== update.state.facet(sourceModeFacet);
317
+
318
+ if (update.docChanged || update.viewportChanged || modeChanged) {
319
+ this.decorations = this.build(update.view);
320
+ }
321
+ }
322
+
323
+ build(view) {
324
+ if (!isWysiwygActive(view.state)) return Decoration.none;
325
+
326
+ const decorations = [];
327
+ const doc = view.state.doc;
328
+ syntaxTree(view.state).iterate({
329
+ from: view.viewport.from,
330
+ to: view.viewport.to,
331
+ enter: (node) => {
332
+ if (node.name !== 'FencedCode') return;
333
+
334
+ const startLine = doc.lineAt(node.from);
335
+ const endLine = doc.lineAt(node.to);
336
+ const firstText = startLine.text.trim();
337
+ const lang = firstText.replace(/^`{3,}/, '').trim();
338
+
339
+ decorations.push(
340
+ Decoration.line({ class: 'cm-wysiwyg-code-fence-line cm-wysiwyg-code-fence-start' }).range(startLine.from),
341
+ Decoration.replace({ widget: new CodeFenceHeaderWidget(lang, node.from, node.to) }).range(startLine.from, startLine.to),
342
+ );
343
+ // Closing fence — only add decorations if the end is distinct from the start
344
+ if (endLine.from !== startLine.from) {
345
+ decorations.push(
346
+ Decoration.line({ class: 'cm-wysiwyg-code-fence-line cm-wysiwyg-code-fence-end' }).range(endLine.from),
347
+ Decoration.replace({ widget: new CodeFenceCloseWidget() }).range(endLine.from, endLine.to),
348
+ );
349
+ }
350
+
351
+ return false;
352
+ },
353
+ });
354
+
355
+ return Decoration.set(decorations, true);
356
+ }
357
+ },
358
+ { decorations: (v) => v.decorations }
359
+ );
360
+
361
+ // ---------------------------------------------------------------------------
362
+ // Styles
363
+ // ---------------------------------------------------------------------------
364
+
365
+ const wysiwygStyles = EditorView.theme({
366
+ '.cm-md-wysiwyg-atomic': {
367
+ pointerEvents: 'none',
368
+ },
369
+
370
+ // ---- Code block styling ----
371
+
372
+ '.cm-wysiwyg-code-fence-line': {
373
+ backgroundColor: 'color-mix(in srgb, var(--widget-surface, #f5f5f5) 85%, transparent)',
374
+ },
375
+ '.cm-wysiwyg-code-fence-line.cm-wysiwyg-code-fence-end': {
376
+ fontSize: '0 !important',
377
+ lineHeight: '0 !important',
378
+ padding: '0 !important',
379
+ minHeight: '0 !important',
380
+ height: '4px !important',
381
+ borderBottom: '1px solid color-mix(in srgb, var(--widget-border, #ddd) 60%, transparent)',
382
+ borderRadius: '0 0 6px 6px',
383
+ overflow: 'hidden',
384
+ },
385
+ '.cm-wysiwyg-code-fence-close': {
386
+ display: 'none',
387
+ },
388
+
389
+ // Header bar
390
+ '.cm-wysiwyg-code-header': {
391
+ display: 'flex',
392
+ alignItems: 'center',
393
+ justifyContent: 'space-between',
394
+ gap: '8px',
395
+ fontFamily: 'var(--widget-font-ui, system-ui, sans-serif)',
396
+ fontSize: '12px',
397
+ padding: '4px 10px',
398
+ borderBottom: '1px solid color-mix(in srgb, var(--widget-border, #ddd) 50%, transparent)',
399
+ userSelect: 'none',
400
+ },
401
+
402
+ // Language label
403
+ '.cm-wysiwyg-code-header-lang': {
404
+ fontSize: '11px',
405
+ fontWeight: '600',
406
+ letterSpacing: '0.03em',
407
+ textTransform: 'uppercase',
408
+ color: 'var(--widget-text-muted, #777)',
409
+ cursor: 'pointer',
410
+ padding: '2px 8px',
411
+ borderRadius: '4px',
412
+ transition: 'background 0.15s, color 0.15s',
413
+ },
414
+ '.cm-wysiwyg-code-header-lang:hover': {
415
+ background: 'color-mix(in srgb, var(--widget-text-muted, #777) 12%, transparent)',
416
+ color: 'var(--widget-text, #333)',
417
+ },
418
+
419
+ // Language inline input
420
+ '.cm-wysiwyg-code-header-lang-input': {
421
+ fontSize: '11px',
422
+ fontWeight: '600',
423
+ letterSpacing: '0.03em',
424
+ fontFamily: 'var(--widget-font-ui, system-ui, sans-serif)',
425
+ padding: '2px 8px',
426
+ border: '1px solid var(--widget-border-accent, #aaa)',
427
+ borderRadius: '4px',
428
+ background: 'var(--widget-surface, #f5f5f5)',
429
+ color: 'var(--widget-text, #333)',
430
+ outline: 'none',
431
+ width: '100px',
432
+ },
433
+
434
+ // Menu wrapper (positioned relative for dropdown)
435
+ '.cm-wysiwyg-code-header-menu-wrap': {
436
+ position: 'relative',
437
+ display: 'inline-flex',
438
+ },
439
+
440
+ // Menu trigger button
441
+ '.cm-wysiwyg-code-header-btn': {
442
+ display: 'inline-flex',
443
+ alignItems: 'center',
444
+ justifyContent: 'center',
445
+ width: '26px',
446
+ height: '26px',
447
+ border: 'none',
448
+ background: 'transparent',
449
+ color: 'var(--widget-text-muted, #999)',
450
+ borderRadius: '4px',
451
+ cursor: 'pointer',
452
+ transition: 'background 0.15s, color 0.15s',
453
+ padding: '0',
454
+ },
455
+ '.cm-wysiwyg-code-header-btn:hover': {
456
+ background: 'color-mix(in srgb, var(--widget-text-muted, #777) 14%, transparent)',
457
+ color: 'var(--widget-text, #333)',
458
+ },
459
+
460
+ // Dropdown menu
461
+ '.cm-wysiwyg-code-header-dropdown': {
462
+ position: 'absolute',
463
+ top: '100%',
464
+ right: '0',
465
+ marginTop: '4px',
466
+ minWidth: '160px',
467
+ background: 'var(--widget-surface, #fff)',
468
+ border: '1px solid var(--widget-border, #ddd)',
469
+ borderRadius: '8px',
470
+ boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
471
+ padding: '4px',
472
+ zIndex: '100',
473
+ fontFamily: 'var(--widget-font-ui, system-ui, sans-serif)',
474
+ },
475
+ '.cm-wysiwyg-code-header-dropdown-item': {
476
+ display: 'flex',
477
+ alignItems: 'center',
478
+ gap: '8px',
479
+ width: '100%',
480
+ padding: '6px 10px',
481
+ border: 'none',
482
+ background: 'transparent',
483
+ color: 'var(--widget-text, #333)',
484
+ fontSize: '12px',
485
+ fontFamily: 'inherit',
486
+ borderRadius: '4px',
487
+ cursor: 'pointer',
488
+ textAlign: 'left',
489
+ whiteSpace: 'nowrap',
490
+ },
491
+ '.cm-wysiwyg-code-header-dropdown-item:hover': {
492
+ background: 'color-mix(in srgb, var(--widget-text-muted, #777) 10%, transparent)',
493
+ },
494
+ '.cm-wysiwyg-code-header-dropdown-item:last-child:hover': {
495
+ background: 'color-mix(in srgb, #ef4444 10%, transparent)',
496
+ color: '#ef4444',
497
+ },
498
+ });
499
+
500
+ // ---------------------------------------------------------------------------
501
+ // Transaction filter – block edits inside protected regions
502
+ // ---------------------------------------------------------------------------
503
+
504
+ function rangeTouchesProtected(changeFrom, changeTo, protectedFrom, protectedTo) {
505
+ if (changeFrom === changeTo) {
506
+ return changeFrom > protectedFrom && changeFrom < protectedTo;
507
+ }
508
+ return changeFrom < protectedTo && changeTo > protectedFrom;
509
+ }
510
+
511
+ const wysiwygTransactionFilter = EditorState.transactionFilter.of((tr) => {
512
+ if (!tr.docChanged) return tr;
513
+ if (!isWysiwygActive(tr.startState)) return tr;
514
+ if (!tr.annotation(Transaction.userEvent)) return tr;
515
+ if (tr.isUserEvent('input.wysiwyg') || tr.isUserEvent('delete.wysiwyg')) return tr;
516
+
517
+ const protectedRanges = collectProtectedRanges(tr.startState);
518
+
519
+ let blocked = false;
520
+ tr.changes.iterChangedRanges((fromA, toA) => {
521
+ if (blocked) return;
522
+ for (const range of protectedRanges) {
523
+ if (rangeTouchesProtected(fromA, toA, range.from, range.to)) {
524
+ blocked = true;
525
+ break;
526
+ }
527
+ }
528
+ });
529
+
530
+ return blocked ? [] : tr;
531
+ });
532
+
533
+ // ---------------------------------------------------------------------------
534
+ // Inline formatting toggle helpers
535
+ // ---------------------------------------------------------------------------
536
+
537
+ function isEscapedDelimiter(lineText, index) {
538
+ let backslashes = 0;
539
+ for (let i = index - 1; i >= 0 && lineText[i] === '\\'; i--) backslashes++;
540
+ return (backslashes % 2) === 1;
541
+ }
542
+
543
+ function inlineMarkerAt(lineText, index) {
544
+ if (index < 0 || index >= lineText.length) return null;
545
+ if (isEscapedDelimiter(lineText, index)) return null;
546
+ if (lineText.startsWith('**', index)) return '**';
547
+ if (lineText.startsWith('~~', index)) return '~~';
548
+ if (lineText[index] === '*') return '*';
549
+ if (lineText[index] === '`') return '`';
550
+ return null;
551
+ }
552
+
553
+ function inlineClassForMarker(marker) {
554
+ return inlineClassForMark(syntaxToMark(marker, marker === '<u>' ? '</u>' : marker));
555
+ }
556
+
557
+ function parseInlineSequence(lineText, index = 0, endMarker = null, activeMarkers = new Set()) {
558
+ const spans = [];
559
+ let cursor = index;
560
+
561
+ while (cursor < lineText.length) {
562
+ if (endMarker && !isEscapedDelimiter(lineText, cursor) && lineText.startsWith(endMarker, cursor)) {
563
+ return {
564
+ spans,
565
+ index: cursor + endMarker.length,
566
+ closeStart: cursor,
567
+ closed: true,
568
+ };
569
+ }
570
+
571
+ const marker = inlineMarkerAt(lineText, cursor);
572
+ if (!marker) {
573
+ cursor++;
574
+ continue;
575
+ }
576
+
577
+ // Inline code is atomic: find the next matching backtick and do not parse inside.
578
+ if (marker === '`') {
579
+ let close = cursor + 1;
580
+ while (close < lineText.length) {
581
+ if (lineText[close] === '`' && !isEscapedDelimiter(lineText, close)) break;
582
+ close++;
583
+ }
584
+
585
+ if (close < lineText.length && close > cursor + 1) {
586
+ spans.push({
587
+ marker,
588
+ start: cursor,
589
+ end: close + 1,
590
+ contentStart: cursor + 1,
591
+ contentEnd: close,
592
+ children: [],
593
+ });
594
+ cursor = close + 1;
595
+ continue;
596
+ }
597
+
598
+ cursor += 1;
599
+ continue;
600
+ }
601
+
602
+ // Prevent same-format nesting in the tolerant parser. The enclosing level
603
+ // owns the next close marker for that delimiter type.
604
+ if (activeMarkers.has(marker)) {
605
+ cursor += marker.length;
606
+ continue;
607
+ }
608
+
609
+ const nextActive = new Set(activeMarkers);
610
+ nextActive.add(marker);
611
+ const inner = parseInlineSequence(lineText, cursor + marker.length, marker, nextActive);
612
+
613
+ if (inner.closed && inner.closeStart > cursor + marker.length) {
614
+ spans.push({
615
+ marker,
616
+ start: cursor,
617
+ end: inner.index,
618
+ contentStart: cursor + marker.length,
619
+ contentEnd: inner.closeStart,
620
+ children: inner.spans,
621
+ });
622
+ cursor = inner.index;
623
+ continue;
624
+ }
625
+
626
+ cursor += marker.length;
627
+ }
628
+
629
+ return {
630
+ spans,
631
+ index: cursor,
632
+ closeStart: -1,
633
+ closed: false,
634
+ };
635
+ }
636
+
637
+ function parseInlineFormatting(lineText) {
638
+ const model = getLineInlineModel(lineText, 0);
639
+ return model.spans.map((span) => ({
640
+ marker: markToSyntax(span.mark)?.open || '',
641
+ start: span.from,
642
+ end: span.to,
643
+ contentStart: span.contentFrom,
644
+ contentEnd: span.contentTo,
645
+ openLength: span.openLength,
646
+ closeLength: span.closeLength,
647
+ children: [],
648
+ }));
649
+ }
650
+
651
+ function visitInlineSpans(spans, visitor) {
652
+ for (const span of spans) {
653
+ visitor(span);
654
+ if (span.children?.length) visitInlineSpans(span.children, visitor);
655
+ }
656
+ }
657
+
658
+ /**
659
+ * Find the delimited range (e.g. **…** or *…*) that contains `posInLine`.
660
+ * Returns { start, end, contentStart, contentEnd } (offsets within line text) or null.
661
+ */
662
+ function findDelimitedRange(lineText, posInLine, open, close) {
663
+ return sharedFindDelimitedRange(lineText, posInLine, open, close);
664
+ }
665
+
666
+ function findEmptyDelimitedPairAtCursor(lineText, posInLine, open, close = open) {
667
+ const before = posInLine - open.length;
668
+ const after = posInLine + close.length;
669
+ if (before < 0 || after > lineText.length) return null;
670
+ if (lineText.slice(before, posInLine) === open && lineText.slice(posInLine, after) === close) {
671
+ return {
672
+ start: before,
673
+ end: after,
674
+ contentStart: posInLine,
675
+ contentEnd: posInLine,
676
+ };
677
+ }
678
+ return null;
679
+ }
680
+
681
+ function findDelimitedRangeCoveringSelection(lineText, selStartInLine, selEndInLine, open, close = open) {
682
+ const probeStart = findDelimitedRange(lineText, selStartInLine, open, close);
683
+ if (!probeStart) return null;
684
+ if (probeStart.contentStart <= selStartInLine && probeStart.contentEnd >= selEndInLine) {
685
+ return probeStart;
686
+ }
687
+ return null;
688
+ }
689
+
690
+ /**
691
+ * Toggle inline formatting (bold / italic / inline code).
692
+ *
693
+ * Rich-text style behavior:
694
+ * - empty selection outside same-format span -> start formatted typing (insert open+close, place cursor inside)
695
+ * - empty selection inside same-format span -> exit that typing mode by moving cursor after the closing marker
696
+ * - empty selection inside an empty open|close pair -> also move cursor after closing marker
697
+ * - selection fully inside same-format span -> no-op (guard against double nesting)
698
+ * - selection exactly matches the full content of same-format span -> unwrap
699
+ * - otherwise wrap selection
700
+ */
701
+ function toggleInlineFormat(view, open, close) {
702
+ const mark = syntaxToMark(open, close || open);
703
+ if (!mark) return false;
704
+ return toggleInlineMark(view, mark);
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // Backspace handler
709
+ // ---------------------------------------------------------------------------
710
+
711
+ /**
712
+ * Find the FencedCode node that contains `pos`, if any.
713
+ */
714
+ function findFencedCodeAt(state, pos) {
715
+ let found = null;
716
+ syntaxTree(state).iterate({
717
+ from: Math.max(0, pos - 1),
718
+ to: pos + 1,
719
+ enter: (node) => {
720
+ if (node.name === 'FencedCode' && node.from <= pos && node.to >= pos) {
721
+ found = { from: node.from, to: node.to };
722
+ }
723
+ },
724
+ });
725
+ return found;
726
+ }
727
+
728
+ function backspaceWysiwyg(view) {
729
+ const state = view.state;
730
+ if (!isWysiwygActive(state)) return false;
731
+
732
+ const sel = state.selection.main;
733
+ if (!sel.empty) return false;
734
+
735
+ const pos = sel.head;
736
+ const doc = state.doc;
737
+ const line = doc.lineAt(pos);
738
+ const text = line.text;
739
+
740
+ // ── Code-block protection ──
741
+ // If inside a fenced code block at the start of the first code line → block
742
+ const fence = findFencedCodeAt(state, pos);
743
+ if (fence) {
744
+ const fenceStartLine = doc.lineAt(fence.from);
745
+ const firstCodeLine = fenceStartLine.number + 1 <= doc.lines ? doc.line(fenceStartLine.number + 1) : null;
746
+ if (firstCodeLine && pos === firstCodeLine.from) {
747
+ // Block backspace – would escape into the fence header
748
+ return true;
749
+ }
750
+ }
751
+
752
+ // ── Heading backspace ──
753
+ const headingMatch = text.match(/^(#{1,6})\s+/);
754
+ if (headingMatch) {
755
+ const contentStart = line.from + headingMatch[0].length;
756
+ if (pos === contentStart) {
757
+ // At the beginning of heading content
758
+ if (line.number > 1) {
759
+ const prev = doc.line(line.number - 1);
760
+ if (prev.text.trim() === '') {
761
+ // Previous line is empty → delete it, keep heading intact
762
+ view.dispatch({
763
+ changes: { from: prev.from, to: prev.to + 1, insert: '' },
764
+ userEvent: 'delete.wysiwyg.heading-eat-blank',
765
+ });
766
+ return true;
767
+ }
768
+ // Previous line has content → merge
769
+ const prevIsHeading = /^#{1,6}\s+/.test(prev.text);
770
+ if (prevIsHeading) {
771
+ // Previous line is also a heading → just delete the newline between them
772
+ // The current heading content joins the previous heading (keeping previous heading's level)
773
+ const headingContent = text.slice(headingMatch[0].length);
774
+ view.dispatch({
775
+ changes: { from: prev.to, to: line.to, insert: headingContent ? ' ' + headingContent : '' },
776
+ selection: { anchor: prev.to },
777
+ userEvent: 'delete.wysiwyg.merge-heading-up',
778
+ });
779
+ return true;
780
+ }
781
+ // Previous line is a paragraph → heading becomes paragraph, joins with prev
782
+ const headingContent = text.slice(headingMatch[0].length);
783
+ view.dispatch({
784
+ changes: { from: prev.to, to: line.to, insert: headingContent ? ' ' + headingContent : '' },
785
+ selection: { anchor: prev.to },
786
+ userEvent: 'delete.wysiwyg.merge-heading-up',
787
+ });
788
+ return true;
789
+ }
790
+ // First line – just block, don't delete hashes
791
+ return true;
792
+ }
793
+ }
794
+
795
+ // ── List backspace ──
796
+ const listMatch = text.match(/^(\s*)(?:[-+*]|\d+\.)\s+/);
797
+ if (listMatch) {
798
+ const contentStart = line.from + listMatch[0].length;
799
+ if (pos === contentStart) {
800
+ view.dispatch({
801
+ changes: { from: line.from, to: contentStart, insert: '' },
802
+ selection: { anchor: line.from },
803
+ userEvent: 'delete.wysiwyg.demote-list',
804
+ });
805
+ return true;
806
+ }
807
+ }
808
+
809
+ // ── Blockquote backspace ──
810
+ const quoteMatch = text.match(/^(\s*>\s?)+/);
811
+ if (quoteMatch) {
812
+ const contentStart = line.from + quoteMatch[0].length;
813
+ if (pos === contentStart) {
814
+ view.dispatch({
815
+ changes: { from: line.from, to: contentStart, insert: '' },
816
+ selection: { anchor: line.from },
817
+ userEvent: 'delete.wysiwyg.demote-quote',
818
+ });
819
+ return true;
820
+ }
821
+ }
822
+
823
+ return false;
824
+ }
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // Enter handler
828
+ // ---------------------------------------------------------------------------
829
+
830
+ function enterWysiwyg(view) {
831
+ const state = view.state;
832
+ if (!isWysiwygActive(state)) return false;
833
+
834
+ const sel = state.selection.main;
835
+ if (!sel.empty) return false;
836
+
837
+ const pos = sel.head;
838
+ const doc = state.doc;
839
+ const line = doc.lineAt(pos);
840
+ const text = line.text;
841
+
842
+ // ── Heading: Enter at end → new paragraph ──
843
+ const headingMatch = text.match(/^(#{1,6})\s+/);
844
+ if (headingMatch && pos === line.to) {
845
+ view.dispatch({
846
+ changes: { from: pos, insert: '\n' },
847
+ selection: { anchor: pos + 1 },
848
+ userEvent: 'input.wysiwyg.new-paragraph',
849
+ });
850
+ return true;
851
+ }
852
+
853
+ // ── List continuation ──
854
+ const listMatch = text.match(/^(\s*)([-+*]|\d+\.)\s+/);
855
+ if (listMatch) {
856
+ const prefix = listMatch[0];
857
+ const content = text.slice(prefix.length);
858
+ if (content.trim() === '') {
859
+ // Empty list item → exit list
860
+ view.dispatch({
861
+ changes: { from: line.from, to: line.from + prefix.length, insert: '' },
862
+ selection: { anchor: line.from },
863
+ userEvent: 'input.wysiwyg.exit-list',
864
+ });
865
+ return true;
866
+ }
867
+ const marker = listMatch[2];
868
+ const nextPrefix = /\d+\./.test(marker)
869
+ ? `${listMatch[1]}${Number.parseInt(marker, 10) + 1}. `
870
+ : prefix;
871
+ view.dispatch({
872
+ changes: { from: pos, insert: `\n${nextPrefix}` },
873
+ selection: { anchor: pos + 1 + nextPrefix.length },
874
+ userEvent: 'input.wysiwyg.continue-list',
875
+ });
876
+ return true;
877
+ }
878
+
879
+ // ── Blockquote continuation ──
880
+ const quoteMatch = text.match(/^(\s*>\s?)+/);
881
+ if (quoteMatch) {
882
+ const prefix = quoteMatch[0];
883
+ const content = text.slice(prefix.length);
884
+ if (content.trim() === '') {
885
+ view.dispatch({
886
+ changes: { from: line.from, to: line.from + prefix.length, insert: '' },
887
+ selection: { anchor: line.from },
888
+ userEvent: 'input.wysiwyg.exit-quote',
889
+ });
890
+ return true;
891
+ }
892
+ view.dispatch({
893
+ changes: { from: pos, insert: `\n${prefix}` },
894
+ selection: { anchor: pos + 1 + prefix.length },
895
+ userEvent: 'input.wysiwyg.continue-quote',
896
+ });
897
+ return true;
898
+ }
899
+
900
+ // ── Fenced code: prevent Enter on fence lines ──
901
+ const fencedCodeMatch = text.match(/^`{3,}/);
902
+ if (fencedCodeMatch && text.slice(pos - line.from).trim() === '') {
903
+ return true;
904
+ }
905
+
906
+ return false;
907
+ }
908
+
909
+ // ---------------------------------------------------------------------------
910
+ // Pending-format hider
911
+ //
912
+ // When typing inside **...** or *...* or `...`, a trailing space or other
913
+ // character may cause the markdown parser to stop recognizing the markers
914
+ // as EmphasisMark nodes. In WYSIWYG mode we still want them hidden.
915
+ // This ViewPlugin scans the cursor line for paired markers that the tree
916
+ // missed and applies cm-md-hidden decorations to them.
917
+ // ---------------------------------------------------------------------------
918
+
919
+ // ---------------------------------------------------------------------------
920
+ // Pending-format plugin
921
+ //
922
+ // The CommonMark spec says **hello ** (space before closing **) is NOT valid
923
+ // emphasis. The Lezer parser follows this, so mid-typing the markers lose
924
+ // their StrongEmphasis/Emphasis tree status, causing bold/italic to flicker.
925
+ //
926
+ // We patch this in rendered modes: scan every visible line for paired markers
927
+ // the tree missed, and apply the formatting class while either hiding the
928
+ // markers (rendered/WYSIWYG) or muting them (source mode).
929
+ //
930
+ // Also auto-cleans empty marker pairs (e.g. ****) when the cursor leaves.
931
+ // ---------------------------------------------------------------------------
932
+
933
+ /**
934
+ * Collect absolute positions of all inline formatting marker nodes the tree
935
+ * already recognises on a given line range.
936
+ */
937
+ function collectTreeMarks(state, from, to) {
938
+ const marks = new Set();
939
+ syntaxTree(state).iterate({
940
+ from,
941
+ to,
942
+ enter: (node) => {
943
+ if (
944
+ node.name === 'EmphasisMark' ||
945
+ node.name === 'CodeMark' ||
946
+ node.name === 'StrikethroughMark'
947
+ ) {
948
+ for (let i = node.from; i < node.to; i++) marks.add(i);
949
+ }
950
+ },
951
+ });
952
+ return marks;
953
+ }
954
+
955
+ /**
956
+ * Check whether the syntax tree already provides inline formatting coverage
957
+ * for the range [from, to).
958
+ */
959
+ function treeHasFormattingAt(state, from, to) {
960
+ let found = false;
961
+ syntaxTree(state).iterate({
962
+ from,
963
+ to,
964
+ enter: (node) => {
965
+ if (
966
+ (
967
+ node.name === 'StrongEmphasis' ||
968
+ node.name === 'Emphasis' ||
969
+ node.name === 'InlineCode' ||
970
+ node.name === 'Strikethrough'
971
+ ) &&
972
+ node.from <= from && node.to >= to
973
+ ) {
974
+ found = true;
975
+ }
976
+ },
977
+ });
978
+ return found;
979
+ }
980
+
981
+ const wysiwygPendingFormatPlugin = ViewPlugin.fromClass(
982
+ class {
983
+ constructor(view) {
984
+ this.decorations = this.build(view);
985
+ this.prevCursorLine = view.state.doc.lineAt(view.state.selection.main.head).number;
986
+ }
987
+
988
+ update(update) {
989
+ const modeChanged =
990
+ update.startState.facet(wysiwygModeFacet) !== update.state.facet(wysiwygModeFacet) ||
991
+ update.startState.facet(sourceModeFacet) !== update.state.facet(sourceModeFacet);
992
+
993
+ if (update.docChanged || update.selectionSet || update.viewportChanged || modeChanged) {
994
+ // Auto-clean empty markers when cursor leaves a line (WYSIWYG only)
995
+ if (isWysiwygActive(update.state) && update.selectionSet && !update.docChanged) {
996
+ const curLine = update.state.doc.lineAt(update.state.selection.main.head).number;
997
+ if (curLine !== this.prevCursorLine) {
998
+ this.cleanEmptyMarkers(update.view, this.prevCursorLine);
999
+ }
1000
+ this.prevCursorLine = curLine;
1001
+ } else if (update.docChanged && isWysiwygActive(update.state)) {
1002
+ this.prevCursorLine = update.state.doc.lineAt(update.state.selection.main.head).number;
1003
+ }
1004
+
1005
+ this.decorations = this.build(update.view);
1006
+ }
1007
+ }
1008
+
1009
+ /**
1010
+ * Remove empty marker pairs on a given line number.
1011
+ * Only removes truly empty/whitespace-only pairs where NEITHER the
1012
+ * opening nor closing marker is an EmphasisMark in the syntax tree
1013
+ * (which would mean it belongs to a real formatting span).
1014
+ */
1015
+ cleanEmptyMarkers(view, lineNumber) {
1016
+ const doc = view.state.doc;
1017
+ if (lineNumber < 1 || lineNumber > doc.lines) return;
1018
+ const line = doc.line(lineNumber);
1019
+ const text = line.text;
1020
+
1021
+ // Collect all tree-recognised emphasis/code mark positions on this line
1022
+ const treeMark = collectTreeMarks(view.state, line.from, line.to);
1023
+
1024
+ // Patterns that match empty pairs
1025
+ const emptyPatterns = [
1026
+ { re: /<u>(\s*)<\/u>/g, markerLen: 3, closeMarkerLen: 4 },
1027
+ { re: /\*\*(\s*)\*\*/g, markerLen: 2 },
1028
+ { re: /~~(\s*)~~/g, markerLen: 2 },
1029
+ { re: /(?<!\*)\*(\s*)\*(?!\*)/g, markerLen: 1 },
1030
+ { re: /`(\s*)`/g, markerLen: 1 },
1031
+ ];
1032
+
1033
+ const changes = [];
1034
+ for (const { re, markerLen, closeMarkerLen = markerLen } of emptyPatterns) {
1035
+ re.lastIndex = 0;
1036
+ let m;
1037
+ while ((m = re.exec(text)) !== null) {
1038
+ // Only whitespace (or nothing) between markers
1039
+ if (m[1].trim() !== '') continue;
1040
+
1041
+ const absFrom = line.from + m.index;
1042
+ const absTo = absFrom + m[0].length;
1043
+ const openStart = absFrom;
1044
+ const closeStart = absTo - closeMarkerLen;
1045
+
1046
+ // If either marker is tree-recognised, it belongs to real formatting — skip
1047
+ if (treeMark.has(openStart) || treeMark.has(closeStart)) continue;
1048
+
1049
+ changes.push({ from: absFrom, to: absTo, insert: '' });
1050
+ }
1051
+ }
1052
+
1053
+ if (changes.length > 0) {
1054
+ view.dispatch({
1055
+ changes,
1056
+ userEvent: 'delete.wysiwyg.auto-clean',
1057
+ });
1058
+ }
1059
+ }
1060
+
1061
+ build(view) {
1062
+ const state = view.state;
1063
+ const isSource = state.facet(sourceModeFacet);
1064
+ const isWysiwyg = isWysiwygActive(state);
1065
+ const cursorLine = state.doc.lineAt(state.selection.main.head).number;
1066
+
1067
+ const decorations = [];
1068
+ const { from: vpFrom, to: vpTo } = view.viewport;
1069
+
1070
+ // Collect tree-recognised marker positions across viewport
1071
+ const treeMarks = collectTreeMarks(state, vpFrom, vpTo);
1072
+
1073
+ const doc = state.doc;
1074
+ const startLine = doc.lineAt(vpFrom).number;
1075
+ const endLine = doc.lineAt(vpTo).number;
1076
+
1077
+ for (let ln = startLine; ln <= endLine; ln++) {
1078
+ // In normal rendered mode, skip the cursor line — the renderer already
1079
+ // shows raw markers there as cm-md-marker (standard behavior).
1080
+ // In source mode and WYSIWYG mode, patch every visible line.
1081
+ if (!isSource && !isWysiwyg && ln === cursorLine) continue;
1082
+
1083
+ const line = doc.line(ln);
1084
+ const text = line.text;
1085
+ const spans = parseInlineFormatting(text);
1086
+
1087
+ visitInlineSpans(spans, (span) => {
1088
+ const cls = inlineClassForMarker(span.marker);
1089
+ if (!cls) return;
1090
+
1091
+ const openFrom = line.from + span.start;
1092
+ const openTo = openFrom + span.openLength;
1093
+ const closeFrom = line.from + span.contentEnd;
1094
+ const closeTo = closeFrom + span.closeLength;
1095
+ const contentFrom = line.from + span.contentStart;
1096
+ const contentTo = line.from + span.contentEnd;
1097
+ const matchFrom = line.from + span.start;
1098
+ const matchTo = line.from + span.end;
1099
+
1100
+ if (treeHasFormattingAt(state, matchFrom, matchTo)) return;
1101
+ if (treeMarks.has(openFrom) && treeMarks.has(closeFrom)) return;
1102
+
1103
+ if (contentFrom < contentTo) {
1104
+ decorations.push(
1105
+ Decoration.mark({ class: cls }).range(contentFrom, contentTo)
1106
+ );
1107
+ }
1108
+
1109
+ const markerClass = isSource ? 'cm-md-marker' : 'cm-md-hidden';
1110
+
1111
+ if (!treeMarks.has(openFrom)) {
1112
+ decorations.push(
1113
+ Decoration.mark({ class: markerClass }).range(openFrom, openTo)
1114
+ );
1115
+ }
1116
+ if (!treeMarks.has(closeFrom)) {
1117
+ decorations.push(
1118
+ Decoration.mark({ class: markerClass }).range(closeFrom, closeTo)
1119
+ );
1120
+ }
1121
+ });
1122
+ }
1123
+
1124
+ return Decoration.set(decorations, true);
1125
+ }
1126
+ },
1127
+ { decorations: (v) => v.decorations }
1128
+ );
1129
+
1130
+ // ---------------------------------------------------------------------------
1131
+ // Keymap
1132
+ // ---------------------------------------------------------------------------
1133
+
1134
+ const wysiwygKeymap = keymap.of([
1135
+ { key: 'Backspace', run: backspaceWysiwyg },
1136
+ { key: 'Enter', run: enterWysiwyg },
1137
+ { key: 'Mod-b', run: (view) => toggleInlineMark(view, 'bold') },
1138
+ { key: 'Mod-i', run: (view) => toggleInlineMark(view, 'italic') },
1139
+ { key: 'Mod-u', run: (view) => toggleInlineMark(view, 'underline') },
1140
+ { key: 'Mod-`', run: (view) => toggleInlineMark(view, 'code') },
1141
+ ]);
1142
+
1143
+ // ---------------------------------------------------------------------------
1144
+ // Exports
1145
+ // ---------------------------------------------------------------------------
1146
+
1147
+ export { toggleInlineFormat, findDelimitedRange, findFencedCodeAt };
1148
+
1149
+ export function createWysiwygExtensions() {
1150
+ return [
1151
+ wysiwygAtomicPlugin,
1152
+ wysiwygFencePlugin,
1153
+ wysiwygPendingFormatPlugin,
1154
+ wysiwygTransactionFilter,
1155
+ wysiwygKeymap,
1156
+ wysiwygStyles,
1157
+ ];
1158
+ }