leepi 0.0.0 → 0.0.2

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 CHANGED
@@ -1 +1,90 @@
1
- {"name":"leepi","version":"0.0.0","description":"Lipi - A composable React markdown editor","license":"MIT"}
1
+ {
2
+ "name": "leepi",
3
+ "version": "0.0.2",
4
+ "description": "A composable React markdown editor with inline styling",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/brijbyte/leepi.git",
8
+ "directory": "packages/leepi"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "src",
13
+ "!src/**/*.test.ts",
14
+ "!src/**/test-helpers.ts"
15
+ ],
16
+ "type": "module",
17
+ "exports": {
18
+ "./core/active-marks": "./dist/core/active-marks.js",
19
+ "./core/commands": "./dist/core/commands.js",
20
+ "./core/editor": "./dist/core/editor.js",
21
+ "./core/field-notifier": "./dist/core/field-notifier.js",
22
+ "./core/highlight": "./dist/core/highlight.js",
23
+ "./core/plugins": "./dist/core/plugins.js",
24
+ "./core/popover": "./dist/core/popover.js",
25
+ "./core/registry": "./dist/core/registry.js",
26
+ "./core/types": "./dist/core/types.js",
27
+ "./core/utils": "./dist/core/utils.js",
28
+ "./react/code-block-popover": "./dist/react/code-block-popover.js",
29
+ "./react/context": "./dist/react/context.js",
30
+ "./react/editor": "./dist/react/editor.js",
31
+ "./react/floating-toolbar": "./dist/react/floating-toolbar.js",
32
+ "./react/link-popover": "./dist/react/link-popover.js",
33
+ "./react/preview": "./dist/react/preview.js",
34
+ "./react/toolbar": "./dist/react/toolbar.js",
35
+ "./package.json": "./package.json",
36
+ "./styles.css": "./dist/leepi.css",
37
+ "./styles/*.css": "./src/styles/*.css"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public",
41
+ "provenance": true
42
+ },
43
+ "dependencies": {
44
+ "@codemirror/commands": "^6.0.0",
45
+ "@codemirror/lang-markdown": "^6.0.0",
46
+ "@codemirror/language": "^6.0.0",
47
+ "@codemirror/language-data": "^6.0.0",
48
+ "@codemirror/state": "^6.0.0",
49
+ "@codemirror/view": "^6.0.0",
50
+ "@lezer/highlight": "^1.0.0",
51
+ "marked": ">=15.0.0",
52
+ "marked-highlight": "^2.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@base-ui/react": "^1.3.0",
56
+ "@codemirror/commands": "^6.10.3",
57
+ "@codemirror/lang-markdown": "^6.5.0",
58
+ "@codemirror/language": "^6.12.3",
59
+ "@codemirror/language-data": "^6.5.2",
60
+ "@codemirror/state": "^6.6.0",
61
+ "@codemirror/view": "^6.41.0",
62
+ "@lezer/highlight": "^1.2.3",
63
+ "@types/react": "^19.2.14",
64
+ "@types/react-dom": "^19.2.3",
65
+ "marked": ">=18.0.0",
66
+ "marked-highlight": "^2.2.3",
67
+ "react": "^19.2.4",
68
+ "react-dom": "^19.2.4"
69
+ },
70
+ "peerDependencies": {
71
+ "@base-ui/react": "^1.0.0",
72
+ "react": "^19.0.0",
73
+ "react-dom": "^19.0.0"
74
+ },
75
+ "peerDependenciesMeta": {
76
+ "@base-ui/react": {
77
+ "optional": true
78
+ },
79
+ "react": {
80
+ "optional": true
81
+ },
82
+ "react-dom": {
83
+ "optional": true
84
+ }
85
+ },
86
+ "scripts": {
87
+ "build": "tsdown",
88
+ "typecheck": "tsc --noEmit"
89
+ }
90
+ }
@@ -0,0 +1,89 @@
1
+ import { type Extension } from "@codemirror/state";
2
+ import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
3
+ import { syntaxTree } from "@codemirror/language";
4
+ import { markRegistry } from "./registry";
5
+
6
+ /**
7
+ * Active marks as a dynamic record. Keys are mark names registered via `markRegistry`.
8
+ */
9
+ export type ActiveMarks = Record<string, boolean>;
10
+
11
+ export const emptyMarks: ActiveMarks = {};
12
+
13
+ export function detectMarks(view: EditorView): ActiveMarks {
14
+ const { state } = view;
15
+ const pos = state.selection.main.from;
16
+ const tree = syntaxTree(state);
17
+ const detectors = state.facet(markRegistry);
18
+
19
+ const marks: ActiveMarks = {};
20
+
21
+ // Tree walk: call all detectors with each node name
22
+ let node = tree.resolveInner(pos, -1);
23
+ while (node) {
24
+ for (const det of detectors) {
25
+ if (!marks[det.mark] && det.detect(node.name, state, pos)) {
26
+ marks[det.mark] = true;
27
+ }
28
+ }
29
+ if (!node.parent) break;
30
+ node = node.parent;
31
+ }
32
+
33
+ // Line pass: call detectors with "__line__" sentinel for line-level checks
34
+ for (const det of detectors) {
35
+ if (!marks[det.mark] && det.detect("__line__", state, pos)) {
36
+ marks[det.mark] = true;
37
+ }
38
+ }
39
+
40
+ return marks;
41
+ }
42
+
43
+ // Notification system: views notify React subscribers when marks change
44
+ const viewListeners = new WeakMap<EditorView, Set<() => void>>();
45
+ const viewSnapshots = new WeakMap<EditorView, ActiveMarks>();
46
+
47
+ export function subscribeToMarks(view: EditorView, callback: () => void): () => void {
48
+ let listeners = viewListeners.get(view);
49
+ if (!listeners) {
50
+ listeners = new Set();
51
+ viewListeners.set(view, listeners);
52
+ }
53
+ listeners.add(callback);
54
+ return () => {
55
+ listeners!.delete(callback);
56
+ };
57
+ }
58
+
59
+ export function getMarksSnapshot(view: EditorView): ActiveMarks {
60
+ return viewSnapshots.get(view) ?? emptyMarks;
61
+ }
62
+
63
+ function marksEqual(a: ActiveMarks, b: ActiveMarks): boolean {
64
+ const aKeys = Object.keys(a);
65
+ const bKeys = Object.keys(b);
66
+ if (aKeys.length !== bKeys.length) return false;
67
+ for (const key of aKeys) {
68
+ if (a[key] !== b[key]) return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ export const activeMarksPlugin: Extension = ViewPlugin.fromClass(
74
+ class {
75
+ update(update: ViewUpdate): void {
76
+ if (update.selectionSet || update.docChanged) {
77
+ const marks = detectMarks(update.view);
78
+ const prev = viewSnapshots.get(update.view) ?? emptyMarks;
79
+ if (!marksEqual(prev, marks)) {
80
+ viewSnapshots.set(update.view, marks);
81
+ const listeners = viewListeners.get(update.view);
82
+ if (listeners) {
83
+ for (const cb of listeners) cb();
84
+ }
85
+ }
86
+ }
87
+ }
88
+ },
89
+ );
@@ -0,0 +1,461 @@
1
+ import type { EditorView } from "@codemirror/view";
2
+ import { type EditorState, EditorSelection } from "@codemirror/state";
3
+ import { syntaxTree } from "@codemirror/language";
4
+ import { isInsideCodeBlock } from "./utils";
5
+ import type { CodeBlockData } from "./plugins/code-block";
6
+
7
+ /**
8
+ * Toggle an inline markdown marker (e.g. `**`, `_`, `` ` ``, `~~`) around
9
+ * each selection range. If the selected text is already wrapped with the
10
+ * marker, the marker is removed; otherwise it is added.
11
+ *
12
+ * When nothing is selected the marker pair is inserted at the cursor and the
13
+ * cursor is placed between them.
14
+ */
15
+ export function toggleMarker(marker: string) {
16
+ return (view: EditorView): boolean => {
17
+ if (isInsideCodeBlock(view)) return false;
18
+ const { state } = view;
19
+ const len = marker.length;
20
+
21
+ const changes = state.changeByRange((range) => {
22
+ const doc = state.doc;
23
+
24
+ if (range.empty) {
25
+ const line = doc.lineAt(range.from);
26
+ const lineText = line.text;
27
+ const cursorOffset = range.from - line.from;
28
+
29
+ const positions: number[] = [];
30
+ let searchFrom = 0;
31
+ while (searchFrom <= lineText.length - len) {
32
+ const idx = lineText.indexOf(marker, searchFrom);
33
+ if (idx === -1) break;
34
+ positions.push(idx);
35
+ searchFrom = idx + len;
36
+ }
37
+
38
+ for (let i = 0; i + 1 < positions.length; i += 2) {
39
+ const openEnd = positions[i] + len;
40
+ const closeStart = positions[i + 1];
41
+ if (cursorOffset >= openEnd && cursorOffset <= closeStart) {
42
+ const absOpen = line.from + positions[i];
43
+ const absClose = line.from + closeStart;
44
+ const innerText = doc.sliceString(absOpen + len, absClose);
45
+ const cursorInInner = range.from - (absOpen + len);
46
+ return {
47
+ changes: {
48
+ from: absOpen,
49
+ to: absClose + len,
50
+ insert: innerText,
51
+ },
52
+ range: EditorSelection.cursor(absOpen + cursorInInner),
53
+ };
54
+ }
55
+ }
56
+
57
+ return {
58
+ changes: { from: range.from, insert: marker + marker },
59
+ range: EditorSelection.cursor(range.from + len),
60
+ };
61
+ }
62
+
63
+ const selected = doc.sliceString(range.from, range.to);
64
+
65
+ if (selected.startsWith(marker) && selected.endsWith(marker) && selected.length >= len * 2) {
66
+ const inner = selected.slice(len, -len);
67
+ return {
68
+ changes: { from: range.from, to: range.to, insert: inner },
69
+ range: EditorSelection.range(range.from, range.from + inner.length),
70
+ };
71
+ }
72
+
73
+ const beforeSel = doc.sliceString(Math.max(0, range.from - len), range.from);
74
+ const afterSel = doc.sliceString(range.to, Math.min(doc.length, range.to + len));
75
+
76
+ if (beforeSel === marker && afterSel === marker) {
77
+ return {
78
+ changes: [
79
+ { from: range.from - len, to: range.from },
80
+ { from: range.to, to: range.to + len },
81
+ ],
82
+ range: EditorSelection.range(range.from - len, range.to - len),
83
+ };
84
+ }
85
+
86
+ return {
87
+ changes: [
88
+ { from: range.from, insert: marker },
89
+ { from: range.to, insert: marker },
90
+ ],
91
+ range: EditorSelection.range(range.from, range.to + len * 2),
92
+ };
93
+ });
94
+
95
+ view.dispatch(changes);
96
+ return true;
97
+ };
98
+ }
99
+
100
+ // --- Blockquote toggle ---
101
+
102
+ const BQ_RE = /^(\s*)>\s?/;
103
+
104
+ export function toggleBlockquote(view: EditorView): boolean {
105
+ if (isInsideCodeBlock(view)) return false;
106
+ const { state } = view;
107
+ const changes = state.changeByRange((range) => {
108
+ const fromLine = state.doc.lineAt(range.from);
109
+ const toLine = state.doc.lineAt(range.to);
110
+ const edits: { from: number; to: number; insert: string }[] = [];
111
+
112
+ let allQuoted = true;
113
+ for (let num = fromLine.number; num <= toLine.number; num++) {
114
+ const line = state.doc.line(num);
115
+ if (!BQ_RE.test(line.text)) {
116
+ allQuoted = false;
117
+ break;
118
+ }
119
+ }
120
+
121
+ for (let num = fromLine.number; num <= toLine.number; num++) {
122
+ const line = state.doc.line(num);
123
+ if (allQuoted) {
124
+ const match = line.text.match(BQ_RE)!;
125
+ edits.push({
126
+ from: line.from,
127
+ to: line.from + match[0].length,
128
+ insert: match[1],
129
+ });
130
+ } else if (!BQ_RE.test(line.text)) {
131
+ const indent = line.text.match(/^(\s*)/)![1];
132
+ edits.push({
133
+ from: line.from + indent.length,
134
+ to: line.from + indent.length,
135
+ insert: "> ",
136
+ });
137
+ }
138
+ }
139
+
140
+ if (edits.length === 0) return { changes: [], range };
141
+
142
+ const firstDelta = edits[0].insert.length - (edits[0].to - edits[0].from);
143
+ const totalDelta = edits.reduce((sum, e) => sum + e.insert.length - (e.to - e.from), 0);
144
+ const newFrom = Math.max(0, range.from + firstDelta);
145
+ return {
146
+ changes: edits,
147
+ range: range.empty
148
+ ? EditorSelection.cursor(newFrom)
149
+ : EditorSelection.range(newFrom, Math.max(newFrom, range.to + totalDelta)),
150
+ };
151
+ });
152
+
153
+ view.dispatch(changes);
154
+ return true;
155
+ }
156
+
157
+ // --- Block prefix helper ---
158
+
159
+ /**
160
+ * Find the offset where content begins after any blockquote markers on a line.
161
+ * Uses the syntax tree to detect `QuoteMark` nodes, handling nested blockquotes.
162
+ */
163
+ function getBlockContentOffset(state: EditorState, lineFrom: number, lineTo: number): number {
164
+ const tree = syntaxTree(state);
165
+ let lastQuoteEnd = lineFrom;
166
+ let foundQuote = false;
167
+
168
+ tree.iterate({
169
+ from: lineFrom,
170
+ to: lineTo,
171
+ enter(node) {
172
+ if (node.name === "QuoteMark" && node.from >= lineFrom && node.to <= lineTo) {
173
+ foundQuote = true;
174
+ lastQuoteEnd = node.to;
175
+ }
176
+ },
177
+ });
178
+
179
+ if (!foundQuote) return 0;
180
+
181
+ // Skip whitespace after the last QuoteMark
182
+ const doc = state.doc;
183
+ let offset = lastQuoteEnd;
184
+ while (offset < lineTo && doc.sliceString(offset, offset + 1) === " ") {
185
+ offset++;
186
+ }
187
+
188
+ return offset - lineFrom;
189
+ }
190
+
191
+ // --- Heading toggle ---
192
+
193
+ export function toggleHeading(level: number) {
194
+ return (view: EditorView): boolean => {
195
+ if (isInsideCodeBlock(view)) return false;
196
+ const { state } = view;
197
+ const line = state.doc.lineAt(state.selection.main.from);
198
+ const contentOffset = getBlockContentOffset(state, line.from, line.to);
199
+ const content = line.text.slice(contentOffset);
200
+ const headingMatch = content.match(/^(#{1,6})\s/);
201
+ const prefix = "#".repeat(level) + " ";
202
+ const contentFrom = line.from + contentOffset;
203
+
204
+ const cursorPos = state.selection.main.from;
205
+
206
+ if (headingMatch && headingMatch[1].length === level) {
207
+ // Removing heading — place cursor at same relative content position
208
+ const delta = -headingMatch[0].length;
209
+ view.dispatch({
210
+ changes: { from: contentFrom, to: contentFrom + headingMatch[0].length, insert: "" },
211
+ selection: EditorSelection.cursor(Math.max(contentFrom, cursorPos + delta)),
212
+ });
213
+ } else if (headingMatch) {
214
+ // Switching heading level — adjust cursor for prefix length change
215
+ const delta = prefix.length - headingMatch[0].length;
216
+ view.dispatch({
217
+ changes: { from: contentFrom, to: contentFrom + headingMatch[0].length, insert: prefix },
218
+ selection: EditorSelection.cursor(Math.max(contentFrom + prefix.length, cursorPos + delta)),
219
+ });
220
+ } else {
221
+ // Adding heading — place cursor after the new prefix
222
+ view.dispatch({
223
+ changes: { from: contentFrom, to: contentFrom, insert: prefix },
224
+ selection: EditorSelection.cursor(
225
+ Math.max(contentFrom + prefix.length, cursorPos + prefix.length),
226
+ ),
227
+ });
228
+ }
229
+ return true;
230
+ };
231
+ }
232
+
233
+ // --- List toggle ---
234
+
235
+ const TASK_RE = /^(\s*)[-*+]\s\[([ xX])\]\s/;
236
+
237
+ type ListKind = "ul" | "ol" | "task" | "none";
238
+
239
+ function detectListKind(
240
+ state: EditorState,
241
+ pos: number,
242
+ contentText: string,
243
+ ): { kind: ListKind; indent: string } {
244
+ const tree = syntaxTree(state);
245
+ let node = tree.resolveInner(pos, 1);
246
+ const indent = contentText.match(/^(\s*)/)![1];
247
+
248
+ while (node) {
249
+ if (node.name === "ListItem" && node.getChild("Task")) {
250
+ return { kind: "task", indent };
251
+ }
252
+ if (node.name === "BulletList" || node.name === "OrderedList") {
253
+ return { kind: node.name === "BulletList" ? "ul" : "ol", indent };
254
+ }
255
+ if (!node.parent) break;
256
+ node = node.parent;
257
+ }
258
+ return { kind: "none", indent: "" };
259
+ }
260
+
261
+ function stripListPrefix(lineText: string): string {
262
+ return lineText.replace(/^(\s*)(?:[-*+]\s\[([ xX])\]\s|[-*+]\s|\d+\.\s)/, "$1");
263
+ }
264
+
265
+ export function toggleListKind(target: ListKind) {
266
+ return (view: EditorView): boolean => {
267
+ if (isInsideCodeBlock(view)) return false;
268
+ const { state } = view;
269
+ const changes = state.changeByRange((range) => {
270
+ const fromLine = state.doc.lineAt(range.from);
271
+ const toLine = state.doc.lineAt(range.to);
272
+ const edits: { from: number; to: number; insert: string }[] = [];
273
+ let olIdx = 1;
274
+
275
+ for (let num = fromLine.number; num <= toLine.number; num++) {
276
+ const line = state.doc.line(num);
277
+ const bqOffset = getBlockContentOffset(state, line.from, line.to);
278
+ const content = line.text.slice(bqOffset);
279
+ const { kind, indent } = detectListKind(state, line.from + bqOffset, content);
280
+ const stripped = stripListPrefix(content);
281
+
282
+ let newPrefix: string;
283
+ if (kind === target) {
284
+ newPrefix = indent;
285
+ } else {
286
+ switch (target) {
287
+ case "ul":
288
+ newPrefix = `${indent}- `;
289
+ break;
290
+ case "ol":
291
+ newPrefix = `${indent}${olIdx++}. `;
292
+ break;
293
+ case "task":
294
+ newPrefix = `${indent}- [ ] `;
295
+ break;
296
+ default:
297
+ newPrefix = indent;
298
+ }
299
+ }
300
+
301
+ const newContent = newPrefix + stripped.slice(indent.length);
302
+ const newLine = line.text.slice(0, bqOffset) + newContent;
303
+ if (newLine !== line.text) {
304
+ edits.push({ from: line.from, to: line.to, insert: newLine });
305
+ }
306
+ }
307
+
308
+ if (edits.length === 0) return { changes: [], range };
309
+
310
+ const firstDelta = edits[0].insert.length - (edits[0].to - edits[0].from);
311
+ const totalDelta = edits.reduce((sum, e) => sum + e.insert.length - (e.to - e.from), 0);
312
+ const newFrom = Math.max(0, range.from + firstDelta);
313
+ return {
314
+ changes: edits,
315
+ range: range.empty
316
+ ? EditorSelection.cursor(newFrom)
317
+ : EditorSelection.range(newFrom, Math.max(newFrom, range.to + totalDelta)),
318
+ };
319
+ });
320
+
321
+ view.dispatch(changes);
322
+ return true;
323
+ };
324
+ }
325
+
326
+ export function toggleTaskCheck(view: EditorView): boolean {
327
+ if (isInsideCodeBlock(view)) return false;
328
+ const { state } = view;
329
+ const changes = state.changeByRange((range) => {
330
+ const fromLine = state.doc.lineAt(range.from);
331
+ const toLine = state.doc.lineAt(range.to);
332
+ const edits: { from: number; to: number; insert: string }[] = [];
333
+
334
+ for (let num = fromLine.number; num <= toLine.number; num++) {
335
+ const line = state.doc.line(num);
336
+ const match = line.text.match(TASK_RE);
337
+ if (!match) continue;
338
+ const isChecked = match[2] !== " ";
339
+ const bracketStart = line.from + line.text.indexOf("[");
340
+ edits.push({
341
+ from: bracketStart,
342
+ to: bracketStart + 3,
343
+ insert: isChecked ? "[ ]" : "[x]",
344
+ });
345
+ }
346
+
347
+ if (edits.length === 0) return { changes: [], range };
348
+ return { changes: edits, range };
349
+ });
350
+
351
+ view.dispatch(changes);
352
+ return true;
353
+ }
354
+
355
+ // --- Link helpers ---
356
+
357
+ export function findLinkAtCursor(
358
+ state: EditorState,
359
+ pos: number,
360
+ ): { from: number; to: number; label: string; url: string } | null {
361
+ const tree = syntaxTree(state);
362
+ let node = tree.resolveInner(pos, -1);
363
+ while (node) {
364
+ if (node.name === "Link") {
365
+ const from = node.from;
366
+ const to = node.to;
367
+ const full = state.sliceDoc(from, to);
368
+ const labelStart = full.indexOf("[");
369
+ const labelEnd = full.indexOf("]");
370
+ const urlStart = full.indexOf("(", labelEnd);
371
+ const urlEnd = full.lastIndexOf(")");
372
+ if (labelStart === -1 || labelEnd === -1 || urlStart === -1 || urlEnd === -1) return null;
373
+ const label = full.slice(labelStart + 1, labelEnd);
374
+ const url = full.slice(urlStart + 1, urlEnd);
375
+ return { from, to, label, url };
376
+ }
377
+ if (!node.parent) break;
378
+ node = node.parent;
379
+ }
380
+ return null;
381
+ }
382
+
383
+ /** Insert a markdown link into the editor, replacing `from..to`. */
384
+ export function insertLink(
385
+ view: EditorView,
386
+ from: number,
387
+ to: number,
388
+ label: string,
389
+ url: string,
390
+ ): void {
391
+ const insert = `[${label}](${url})`;
392
+ view.dispatch({
393
+ changes: { from, to, insert },
394
+ selection: EditorSelection.cursor(from + insert.length),
395
+ });
396
+ view.focus();
397
+ }
398
+
399
+ // --- Code fence helpers ---
400
+
401
+ function parseFenceInfo(info: string): { lang: string; filename: string } {
402
+ const parts = info.trim().split(/\s+/);
403
+ let lang = "";
404
+ let filename = "";
405
+ for (const part of parts) {
406
+ if (part.startsWith("filename=")) {
407
+ filename = part.slice("filename=".length);
408
+ } else if (!lang) {
409
+ lang = part;
410
+ }
411
+ }
412
+ return { lang, filename };
413
+ }
414
+
415
+ export function findCodeFenceAtCursor(view: EditorView): {
416
+ fenceLine: { from: number; to: number };
417
+ lang: string;
418
+ filename: string;
419
+ } | null {
420
+ const { state } = view;
421
+ const pos = state.selection.main.from;
422
+ const tree = syntaxTree(state);
423
+
424
+ let node = tree.resolveInner(pos, -1);
425
+ while (node) {
426
+ if (node.name === "FencedCode") {
427
+ const fenceLine = state.doc.lineAt(node.from);
428
+ const infoNode = node.getChild("CodeInfo");
429
+ const info = infoNode ? state.sliceDoc(infoNode.from, infoNode.to) : "";
430
+ const { lang, filename } = parseFenceInfo(info);
431
+ return { fenceLine: { from: fenceLine.from, to: fenceLine.to }, lang, filename };
432
+ }
433
+ if (!node.parent) break;
434
+ node = node.parent;
435
+ }
436
+ return null;
437
+ }
438
+
439
+ /** Apply code block settings. For existing blocks, replaces the fence line. For new blocks, inserts a fenced block. */
440
+ export function applyCodeBlock(
441
+ view: EditorView,
442
+ data: CodeBlockData,
443
+ lang: string,
444
+ filename: string,
445
+ ): void {
446
+ const info = [lang, filename ? `filename=${filename}` : ""].filter(Boolean).join(" ");
447
+ if (data.isNew) {
448
+ const insert = `\`\`\`${info}\n\n\`\`\``;
449
+ const pos = data.insertPos!;
450
+ view.dispatch({
451
+ changes: { from: pos, to: pos, insert },
452
+ selection: EditorSelection.cursor(pos + 3 + info.length + 1),
453
+ });
454
+ } else {
455
+ const fenceLine = `\`\`\`${info}`;
456
+ view.dispatch({
457
+ changes: { from: data.fenceFrom, to: data.fenceTo, insert: fenceLine },
458
+ });
459
+ }
460
+ view.focus();
461
+ }