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.
- package/package.json +3 -1
- package/src/commands.js +112 -4
- package/src/comment-syntax.js +364 -39
- package/src/config/handlers.js +1 -2
- package/src/config/schema.js +46 -4
- package/src/document-template.js +2236 -0
- package/src/frontmatter-updater.js +204 -74
- package/src/grammar.js +758 -0
- package/src/index.js +1074 -55
- package/src/keymap.js +11 -2
- package/src/markdown/block-decorations.js +108 -5
- package/src/markdown/facets.js +37 -0
- package/src/markdown/html-inline.js +9 -5
- package/src/markdown/index.js +13 -3
- package/src/markdown/inline-commands.js +256 -0
- package/src/markdown/inline-model.js +578 -0
- package/src/markdown/inline-state.js +103 -0
- package/src/markdown/renderer.js +219 -12
- package/src/markdown/styles.js +290 -3
- package/src/markdown/widgets/alert-title.js +10 -8
- package/src/markdown/widgets/frontmatter.js +0 -6
- package/src/markdown/widgets/index.js +1 -0
- package/src/markdown/widgets/list-marker.js +29 -0
- package/src/markdown/wysiwyg.js +1158 -0
- package/src/mrp-types.js +2 -0
- package/src/output-widget.js +532 -18
- package/src/page-view-pagination.js +127 -0
- package/src/runtime-lsp.js +1757 -150
- package/src/section-controls/commands.js +617 -0
- package/src/section-controls/index.js +63 -0
- package/src/section-controls/plugin.js +165 -0
- package/src/section-controls/widgets.js +936 -0
- package/src/shell/ai-menu.js +11 -0
- package/src/shell/components/context-panel.js +572 -0
- package/src/shell/components/status-bar.js +10 -2
- package/src/shell/layouts/studio.js +206 -14
- package/src/shell/orchestrator-client.js +69 -0
- package/src/spellcheck.js +166 -0
- package/src/tables/README.md +97 -0
- package/src/tables/commands/insert-linked-table.js +122 -0
- package/src/tables/commands/open-table-workspace.js +43 -0
- package/src/tables/index.js +24 -0
- package/src/tables/jobs/client.js +158 -0
- package/src/tables/parsing/anchors.js +82 -0
- package/src/tables/parsing/linked-table-blocks.js +61 -0
- package/src/tables/state/linked-table-state.js +68 -0
- package/src/tables/widgets/linked-table-source-banner.js +77 -0
- package/src/tables/widgets/linked-table-widget.js +256 -0
- package/src/tables/workspace/controller.js +616 -0
- package/src/term-pty-client.js +51 -2
- package/src/term-widget.js +43 -3
- package/src/widgets/theme-utils.js +24 -16
- package/src/widgets/theme.js +1015 -1
- package/src/runtime-codelens/detector.js +0 -279
- package/src/runtime-codelens/index.js +0 -76
- package/src/runtime-codelens/plugin.js +0 -142
- package/src/runtime-codelens/styles.js +0 -184
- 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
|
+
}
|