@refrakt-md/editor 0.8.2 → 0.8.3

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 (29) hide show
  1. package/app/dist/assets/{index-Bn8ajfVl.js → index-3MvwKRVQ.js} +1 -1
  2. package/app/dist/assets/{index-DNtuldOx.js → index-B7e694w6.js} +1 -1
  3. package/app/dist/assets/{index-xo7v6nRB.js → index-BBljOYQu.js} +1 -1
  4. package/app/dist/assets/{index-DQUOY-pF.js → index-BEGy_i8o.js} +1 -1
  5. package/app/dist/assets/{index-DNJBunzP.js → index-BGy7ixjW.js} +1 -1
  6. package/app/dist/assets/{index-dGztG-54.js → index-BaLgiiKk.js} +1 -1
  7. package/app/dist/assets/{index-D5ucdUTo.js → index-BjlNcvOf.js} +1 -1
  8. package/app/dist/assets/{index-Cgbvx23V.js → index-CKfKYVw7.js} +1 -1
  9. package/app/dist/assets/{index-BDj1XPol.js → index-COFbngzR.js} +1 -1
  10. package/app/dist/assets/{index-aPeHMqUX.js → index-CPEo_rvd.js} +1 -1
  11. package/app/dist/assets/{index-BXe1fKaT.js → index-CQDCT-XT.js} +1 -1
  12. package/app/dist/assets/{index-CXeK-dZx.js → index-CUmEjEeR.js} +1 -1
  13. package/app/dist/assets/{index-80NtMar1.js → index-CeV-Af4N.js} +1 -1
  14. package/app/dist/assets/{index-CaRBCHaX.js → index-ChbH55h5.js} +1 -1
  15. package/app/dist/assets/index-CzvG5PZT.css +1 -0
  16. package/app/dist/assets/{index-BfxTGrHB.js → index-D9-aYc3I.js} +1 -1
  17. package/app/dist/assets/{index-DGYxLhpR.js → index-DezxtfNV.js} +1 -1
  18. package/app/dist/assets/{index-DskvyNKT.js → index-DrI4IfXE.js} +1 -1
  19. package/app/dist/assets/{index-CCkzIGTi.js → index-DwfxgjnU.js} +1 -1
  20. package/app/dist/assets/index-ogrpJNou.js +555 -0
  21. package/app/dist/index.html +2 -2
  22. package/app/src/lib/components/BlockEditor.svelte +381 -47
  23. package/app/src/lib/components/InlineEditor.svelte +15 -5
  24. package/app/src/lib/components/ProseBlockCard.svelte +446 -0
  25. package/app/src/lib/components/ProseEditPanel.svelte +470 -0
  26. package/app/src/lib/editor/block-parser.ts +59 -2
  27. package/package.json +6 -6
  28. package/app/dist/assets/index-B6H6LF1M.css +0 -1
  29. package/app/dist/assets/index-Cd12jZId.js +0 -479
@@ -0,0 +1,470 @@
1
+ <script lang="ts">
2
+ import type { RuneInfo } from '../api/client.js';
3
+ import type {
4
+ ProseBlock,
5
+ ParsedBlock,
6
+ HeadingBlock,
7
+ FenceBlock,
8
+ ListBlock,
9
+ } from '../editor/block-parser.js';
10
+ import {
11
+ blockLabel,
12
+ parseBlocks,
13
+ serializeBlocks,
14
+ } from '../editor/block-parser.js';
15
+ import InlineEditor from './InlineEditor.svelte';
16
+
17
+ interface Props {
18
+ block: ProseBlock;
19
+ runes: () => RuneInfo[];
20
+ aggregated?: Record<string, unknown>;
21
+ onupdate: (block: ProseBlock) => void;
22
+ onremove: () => void;
23
+ onclose: () => void;
24
+ }
25
+
26
+ let { block, runes, aggregated = {}, onupdate, onremove, onclose }: Props = $props();
27
+
28
+ type TabId = 'structure' | 'content';
29
+ let activeTab: TabId = $state('structure');
30
+
31
+ // ── Structure tab: child list ────────────────────────────────
32
+
33
+ /** Short preview text for a child block */
34
+ function childPreview(child: ParsedBlock): string {
35
+ switch (child.type) {
36
+ case 'heading':
37
+ return (child as HeadingBlock).text;
38
+ case 'paragraph':
39
+ return child.source.length > 60 ? child.source.slice(0, 60) + '...' : child.source;
40
+ case 'fence': {
41
+ const fb = child as FenceBlock;
42
+ const lang = fb.language ? `${fb.language}: ` : '';
43
+ const code = fb.code.split('\n')[0] ?? '';
44
+ return lang + (code.length > 50 ? code.slice(0, 50) + '...' : code);
45
+ }
46
+ case 'list':
47
+ return child.source.split('\n')[0] ?? '';
48
+ case 'quote':
49
+ return child.source.replace(/^>\s*/gm, '').split('\n')[0] ?? '';
50
+ case 'hr':
51
+ return '---';
52
+ case 'image':
53
+ return child.source;
54
+ default:
55
+ return child.source.split('\n')[0] ?? '';
56
+ }
57
+ }
58
+
59
+ /** Icon SVG for a child block type */
60
+ function childIcon(child: ParsedBlock): string {
61
+ switch (child.type) {
62
+ case 'heading':
63
+ return '<path d="M3 3v10M13 3v10M3 8h10" />';
64
+ case 'paragraph':
65
+ return '<path d="M2 4h12M2 8h12M2 12h8" />';
66
+ case 'fence':
67
+ return '<polyline points="4 6 1 9 4 12" /><polyline points="12 6 15 9 12 12" />';
68
+ case 'list':
69
+ return '<circle cx="3" cy="4" r="1" fill="currentColor" /><line x1="6" y1="4" x2="14" y2="4" /><circle cx="3" cy="8" r="1" fill="currentColor" /><line x1="6" y1="8" x2="14" y2="8" /><circle cx="3" cy="12" r="1" fill="currentColor" /><line x1="6" y1="12" x2="14" y2="12" />';
70
+ case 'quote':
71
+ return '<path d="M3 5h10M5 5v8M5 9h6" />';
72
+ case 'hr':
73
+ return '<line x1="2" y1="8" x2="14" y2="8" />';
74
+ case 'image':
75
+ return '<rect x="2" y="2" width="12" height="12" rx="1" /><circle cx="5.5" cy="5.5" r="1.5" /><path d="M14 10l-3-3-5 5" />';
76
+ default:
77
+ return '<path d="M2 4h12M2 8h12M2 12h8" />';
78
+ }
79
+ }
80
+
81
+ // ── Drag reorder ─────────────────────────────────────────────
82
+
83
+ let dragIdx: number | null = $state(null);
84
+ let dropIdx: number | null = $state(null);
85
+
86
+ function handleDragStart(e: DragEvent, idx: number) {
87
+ dragIdx = idx;
88
+ if (e.dataTransfer) {
89
+ e.dataTransfer.effectAllowed = 'move';
90
+ e.dataTransfer.setData('text/plain', String(idx));
91
+ }
92
+ }
93
+
94
+ function handleDragOver(e: DragEvent, idx: number) {
95
+ e.preventDefault();
96
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
97
+ dropIdx = idx;
98
+ }
99
+
100
+ function handleDrop(e: DragEvent, idx: number) {
101
+ e.preventDefault();
102
+ if (dragIdx !== null && dragIdx !== idx) {
103
+ reorderChild(dragIdx, idx);
104
+ }
105
+ dragIdx = null;
106
+ dropIdx = null;
107
+ }
108
+
109
+ function handleDragEnd() {
110
+ dragIdx = null;
111
+ dropIdx = null;
112
+ }
113
+
114
+ function reorderChild(from: number, to: number) {
115
+ const children = [...block.children];
116
+ const [moved] = children.splice(from, 1);
117
+ children.splice(to, 0, moved);
118
+ const newSource = serializeBlocks(children);
119
+ onupdate({ ...block, children, source: newSource });
120
+ }
121
+
122
+ function removeChild(idx: number) {
123
+ const children = block.children.filter((_, i) => i !== idx);
124
+ if (children.length === 0) {
125
+ onremove();
126
+ return;
127
+ }
128
+ const newSource = serializeBlocks(children);
129
+ onupdate({ ...block, children, source: newSource });
130
+ }
131
+
132
+ // ── Content tab ──────────────────────────────────────────────
133
+
134
+ function handleContentChange(content: string) {
135
+ if (!content.trim()) {
136
+ onremove();
137
+ return;
138
+ }
139
+ const newChildren = parseBlocks(content);
140
+ onupdate({ ...block, children: newChildren, source: content });
141
+ }
142
+ </script>
143
+
144
+ <div class="edit-panel">
145
+ <div class="edit-panel__top">
146
+ <div class="edit-panel__header">
147
+ <span class="edit-panel__type">prose</span>
148
+ <span class="edit-panel__count">{block.children.length} block{block.children.length !== 1 ? 's' : ''}</span>
149
+ <div class="edit-panel__spacer"></div>
150
+ <button
151
+ class="edit-panel__btn edit-panel__btn--danger"
152
+ onclick={onremove}
153
+ title="Remove block"
154
+ >
155
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
156
+ <polyline points="3 6 3 14 13 14 13 6" />
157
+ <line x1="1" y1="4" x2="15" y2="4" />
158
+ <line x1="6" y1="2" x2="10" y2="2" />
159
+ <line x1="6" y1="8" x2="6" y2="12" />
160
+ <line x1="10" y1="8" x2="10" y2="12" />
161
+ </svg>
162
+ </button>
163
+ <button
164
+ class="edit-panel__btn"
165
+ onclick={onclose}
166
+ title="Close panel"
167
+ >&times;</button>
168
+ </div>
169
+
170
+ <div class="edit-panel__tabs">
171
+ <button
172
+ type="button"
173
+ class="edit-panel__tab"
174
+ class:active={activeTab === 'structure'}
175
+ onclick={() => activeTab = 'structure'}
176
+ >
177
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
178
+ <path d="M2 3h4M2 7h4M6 11h4M6 15h4M4 3v8M8 11v4" />
179
+ </svg>
180
+ Structure
181
+ </button>
182
+ <button
183
+ type="button"
184
+ class="edit-panel__tab"
185
+ class:active={activeTab === 'content'}
186
+ onclick={() => activeTab = 'content'}
187
+ >
188
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
189
+ <path d="M2 4h12M2 8h12M2 12h8" />
190
+ </svg>
191
+ Content
192
+ </button>
193
+ </div>
194
+ </div>
195
+
196
+ {#if activeTab === 'structure'}
197
+ <div class="edit-panel__tab-panel">
198
+ <div class="structure-list">
199
+ {#each block.children as child, i (child.id)}
200
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
201
+ <div
202
+ class="structure-item"
203
+ class:drag-source={dragIdx === i}
204
+ class:drag-over={dropIdx === i && dragIdx !== i}
205
+ draggable="true"
206
+ ondragstart={(e) => handleDragStart(e, i)}
207
+ ondragover={(e) => handleDragOver(e, i)}
208
+ ondrop={(e) => handleDrop(e, i)}
209
+ ondragend={handleDragEnd}
210
+ >
211
+ <span class="structure-item__drag" title="Drag to reorder">
212
+ <svg width="8" height="12" viewBox="0 0 8 12" fill="currentColor">
213
+ <circle cx="2" cy="2" r="1" />
214
+ <circle cx="6" cy="2" r="1" />
215
+ <circle cx="2" cy="6" r="1" />
216
+ <circle cx="6" cy="6" r="1" />
217
+ <circle cx="2" cy="10" r="1" />
218
+ <circle cx="6" cy="10" r="1" />
219
+ </svg>
220
+ </span>
221
+ <span class="structure-item__icon">
222
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
223
+ {@html childIcon(child)}
224
+ </svg>
225
+ </span>
226
+ <span class="structure-item__info">
227
+ <span class="structure-item__type">{blockLabel(child)}</span>
228
+ <span class="structure-item__preview">{childPreview(child)}</span>
229
+ </span>
230
+ <button
231
+ class="structure-item__remove"
232
+ onclick={() => removeChild(i)}
233
+ title="Remove"
234
+ >
235
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
236
+ <line x1="4" y1="4" x2="12" y2="12" />
237
+ <line x1="12" y1="4" x2="4" y2="12" />
238
+ </svg>
239
+ </button>
240
+ </div>
241
+ {/each}
242
+ </div>
243
+ </div>
244
+ {/if}
245
+
246
+ {#if activeTab === 'content'}
247
+ <div class="edit-panel__tab-panel">
248
+ <div class="edit-panel__content-editor">
249
+ <InlineEditor
250
+ content={block.source}
251
+ onchange={handleContentChange}
252
+ {runes}
253
+ aggregated={() => aggregated}
254
+ />
255
+ </div>
256
+ </div>
257
+ {/if}
258
+ </div>
259
+
260
+ <style>
261
+ .edit-panel {
262
+ display: flex;
263
+ flex-direction: column;
264
+ flex: 1;
265
+ min-height: 0;
266
+ }
267
+
268
+ .edit-panel__top {
269
+ flex-shrink: 0;
270
+ background: var(--ed-surface-0);
271
+ border-bottom: 1px solid var(--ed-border-default);
272
+ }
273
+
274
+ .edit-panel__header {
275
+ display: flex;
276
+ align-items: center;
277
+ gap: 0.5rem;
278
+ padding: var(--ed-space-4) var(--ed-space-5);
279
+ }
280
+
281
+ .edit-panel__type {
282
+ font-size: 12px;
283
+ font-weight: 700;
284
+ color: var(--ed-text-primary);
285
+ text-transform: uppercase;
286
+ letter-spacing: 0.03em;
287
+ }
288
+
289
+ .edit-panel__count {
290
+ font-size: 11px;
291
+ color: var(--ed-text-muted);
292
+ }
293
+
294
+ .edit-panel__spacer {
295
+ flex: 1;
296
+ }
297
+
298
+ .edit-panel__btn {
299
+ background: none;
300
+ border: none;
301
+ color: var(--ed-text-muted);
302
+ cursor: pointer;
303
+ padding: 0.25rem;
304
+ font-size: 18px;
305
+ line-height: 1;
306
+ border-radius: var(--ed-radius-sm);
307
+ display: flex;
308
+ align-items: center;
309
+ justify-content: center;
310
+ transition: color var(--ed-transition-fast), background var(--ed-transition-fast);
311
+ }
312
+
313
+ .edit-panel__btn:hover {
314
+ color: var(--ed-text-secondary);
315
+ background: var(--ed-surface-2);
316
+ }
317
+
318
+ .edit-panel__btn--danger:hover {
319
+ color: var(--ed-danger);
320
+ background: var(--ed-danger-subtle);
321
+ }
322
+
323
+ /* Tab strip */
324
+ .edit-panel__tabs {
325
+ display: flex;
326
+ gap: 2px;
327
+ background: var(--ed-surface-2);
328
+ border-radius: var(--ed-radius-sm);
329
+ padding: 2px;
330
+ margin: 0 var(--ed-space-4) var(--ed-space-3);
331
+ }
332
+
333
+ .edit-panel__tab {
334
+ flex: 1;
335
+ display: flex;
336
+ align-items: center;
337
+ justify-content: center;
338
+ gap: 0.35rem;
339
+ padding: 0.35rem 0.5rem;
340
+ border: none;
341
+ background: transparent;
342
+ color: var(--ed-text-muted);
343
+ font-size: var(--ed-text-sm);
344
+ font-weight: 500;
345
+ cursor: pointer;
346
+ border-radius: calc(var(--ed-radius-sm) - 1px);
347
+ transition: background var(--ed-transition-fast), color var(--ed-transition-fast);
348
+ }
349
+
350
+ .edit-panel__tab:hover {
351
+ color: var(--ed-text-secondary);
352
+ }
353
+
354
+ .edit-panel__tab.active {
355
+ background: var(--ed-surface-0);
356
+ color: var(--ed-text-primary);
357
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
358
+ }
359
+
360
+ /* Tab panels */
361
+ .edit-panel__tab-panel {
362
+ flex: 1;
363
+ overflow-y: auto;
364
+ display: flex;
365
+ flex-direction: column;
366
+ }
367
+
368
+ .edit-panel__content-editor {
369
+ display: flex;
370
+ flex-direction: column;
371
+ flex-shrink: 0;
372
+ overflow: hidden;
373
+ }
374
+
375
+ /* ── Structure list ───────────────────────────────────────── */
376
+
377
+ .structure-list {
378
+ display: flex;
379
+ flex-direction: column;
380
+ padding: var(--ed-space-3);
381
+ gap: 2px;
382
+ }
383
+
384
+ .structure-item {
385
+ display: flex;
386
+ align-items: center;
387
+ gap: var(--ed-space-2);
388
+ padding: var(--ed-space-2) var(--ed-space-3);
389
+ border-radius: var(--ed-radius-sm);
390
+ transition: background var(--ed-transition-fast), opacity var(--ed-transition-fast);
391
+ cursor: grab;
392
+ }
393
+
394
+ .structure-item:hover {
395
+ background: var(--ed-surface-2);
396
+ }
397
+
398
+ .structure-item.drag-source {
399
+ opacity: 0.4;
400
+ }
401
+
402
+ .structure-item.drag-over {
403
+ box-shadow: inset 0 -2px 0 var(--ed-accent);
404
+ }
405
+
406
+ .structure-item__drag {
407
+ color: var(--ed-text-muted);
408
+ opacity: 0.4;
409
+ flex-shrink: 0;
410
+ display: flex;
411
+ align-items: center;
412
+ }
413
+
414
+ .structure-item:hover .structure-item__drag {
415
+ opacity: 0.7;
416
+ }
417
+
418
+ .structure-item__icon {
419
+ color: var(--ed-text-tertiary);
420
+ flex-shrink: 0;
421
+ display: flex;
422
+ align-items: center;
423
+ }
424
+
425
+ .structure-item__info {
426
+ flex: 1;
427
+ min-width: 0;
428
+ display: flex;
429
+ flex-direction: column;
430
+ gap: 1px;
431
+ }
432
+
433
+ .structure-item__type {
434
+ font-size: 11px;
435
+ font-weight: 600;
436
+ color: var(--ed-text-secondary);
437
+ text-transform: uppercase;
438
+ letter-spacing: 0.02em;
439
+ }
440
+
441
+ .structure-item__preview {
442
+ font-size: 12px;
443
+ color: var(--ed-text-muted);
444
+ overflow: hidden;
445
+ text-overflow: ellipsis;
446
+ white-space: nowrap;
447
+ }
448
+
449
+ .structure-item__remove {
450
+ background: none;
451
+ border: none;
452
+ color: var(--ed-text-muted);
453
+ cursor: pointer;
454
+ padding: 0.2rem;
455
+ border-radius: var(--ed-radius-sm);
456
+ display: flex;
457
+ align-items: center;
458
+ opacity: 0;
459
+ transition: opacity var(--ed-transition-fast), color var(--ed-transition-fast), background var(--ed-transition-fast);
460
+ }
461
+
462
+ .structure-item:hover .structure-item__remove {
463
+ opacity: 1;
464
+ }
465
+
466
+ .structure-item__remove:hover {
467
+ color: var(--ed-danger);
468
+ background: var(--ed-danger-subtle);
469
+ }
470
+ </style>
@@ -68,6 +68,25 @@ export type ParsedBlock =
68
68
  | ListBlock
69
69
  | (Block & { type: 'paragraph' | 'quote' | 'hr' | 'image' });
70
70
 
71
+ /**
72
+ * A prose block groups consecutive non-rune blocks into a single
73
+ * editing region. This is a view-layer concept — the underlying
74
+ * `ParsedBlock[]` array remains the source of truth.
75
+ */
76
+ export interface ProseBlock {
77
+ id: string;
78
+ type: 'prose';
79
+ /** The child blocks that make up this prose group */
80
+ children: ParsedBlock[];
81
+ /** Combined source of all children, joined by blank lines */
82
+ source: string;
83
+ startLine: number;
84
+ endLine: number;
85
+ }
86
+
87
+ /** An editor block is either a rune block (unchanged) or a prose block (grouped non-rune blocks) */
88
+ export type EditorBlock = RuneBlock | ProseBlock;
89
+
71
90
  /** Deterministic hash (djb2) for stable block IDs across re-parses */
72
91
  function hashSource(s: string): string {
73
92
  let h = 5381;
@@ -1019,8 +1038,10 @@ export function extractRuneInner(example: string, name: string): string {
1019
1038
  }
1020
1039
 
1021
1040
  /** Human-readable label for a block, used in rail labels and edit panel header */
1022
- export function blockLabel(block: ParsedBlock): string {
1041
+ export function blockLabel(block: ParsedBlock | EditorBlock): string {
1023
1042
  switch (block.type) {
1043
+ case 'prose':
1044
+ return 'Prose';
1024
1045
  case 'heading':
1025
1046
  return `H${(block as HeadingBlock).level}`;
1026
1047
  case 'rune':
@@ -1040,6 +1061,42 @@ export function blockLabel(block: ParsedBlock): string {
1040
1061
  case 'paragraph':
1041
1062
  return 'Paragraph';
1042
1063
  default:
1043
- return block.type;
1064
+ return (block as ParsedBlock).type;
1044
1065
  }
1045
1066
  }
1067
+
1068
+ /**
1069
+ * Group consecutive non-rune blocks into prose blocks.
1070
+ * Rune blocks pass through unchanged. Every non-rune block
1071
+ * (heading, paragraph, list, fence, quote, hr, image) becomes
1072
+ * part of the nearest prose group.
1073
+ */
1074
+ export function groupIntoEditorBlocks(blocks: ParsedBlock[]): EditorBlock[] {
1075
+ const result: EditorBlock[] = [];
1076
+ let proseChildren: ParsedBlock[] = [];
1077
+
1078
+ function flushProse() {
1079
+ if (proseChildren.length === 0) return;
1080
+ const source = proseChildren.map(b => b.source).join('\n\n');
1081
+ result.push({
1082
+ id: `prose_${proseChildren[0].id}`,
1083
+ type: 'prose',
1084
+ children: [...proseChildren],
1085
+ source,
1086
+ startLine: proseChildren[0].startLine,
1087
+ endLine: proseChildren[proseChildren.length - 1].endLine,
1088
+ });
1089
+ proseChildren = [];
1090
+ }
1091
+
1092
+ for (const block of blocks) {
1093
+ if (block.type === 'rune') {
1094
+ flushProse();
1095
+ result.push(block);
1096
+ } else {
1097
+ proseChildren.push(block);
1098
+ }
1099
+ }
1100
+ flushProse();
1101
+ return result;
1102
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@refrakt-md/editor",
3
3
  "description": "Browser-based content editor for refrakt.md",
4
- "version": "0.8.2",
4
+ "version": "0.8.3",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -50,11 +50,11 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@markdoc/markdoc": "0.4.0",
53
- "@refrakt-md/content": "0.8.2",
54
- "@refrakt-md/highlight": "0.8.2",
55
- "@refrakt-md/runes": "0.8.2",
56
- "@refrakt-md/transform": "0.8.2",
57
- "@refrakt-md/types": "0.8.2"
53
+ "@refrakt-md/content": "0.8.3",
54
+ "@refrakt-md/highlight": "0.8.3",
55
+ "@refrakt-md/runes": "0.8.3",
56
+ "@refrakt-md/transform": "0.8.3",
57
+ "@refrakt-md/types": "0.8.3"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@codemirror/autocomplete": "^6.20.0",