@refrakt-md/editor 0.8.1 → 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 (42) hide show
  1. package/app/dist/assets/{index-D3TQo8gu.js → index-3MvwKRVQ.js} +1 -1
  2. package/app/dist/assets/{index-CeU_s7BB.js → index-B7e694w6.js} +1 -1
  3. package/app/dist/assets/{index-DzHt8ZRh.js → index-BBljOYQu.js} +1 -1
  4. package/app/dist/assets/{index-C72UC2ga.js → index-BEGy_i8o.js} +1 -1
  5. package/app/dist/assets/{index-CqHjo2YT.js → index-BGy7ixjW.js} +1 -1
  6. package/app/dist/assets/{index-DVM3uoxc.js → index-BaLgiiKk.js} +1 -1
  7. package/app/dist/assets/{index-CW02bulk.js → index-BjlNcvOf.js} +1 -1
  8. package/app/dist/assets/{index-DmY6uqAw.js → index-CKfKYVw7.js} +1 -1
  9. package/app/dist/assets/{index-BLuaHLN3.js → index-COFbngzR.js} +1 -1
  10. package/app/dist/assets/{index-BBinZAiy.js → index-CPEo_rvd.js} +1 -1
  11. package/app/dist/assets/{index-D_Y6J00B.js → index-CQDCT-XT.js} +1 -1
  12. package/app/dist/assets/{index-COIPZ34u.js → index-CUmEjEeR.js} +1 -1
  13. package/app/dist/assets/{index-BgCNqcSo.js → index-CeV-Af4N.js} +1 -1
  14. package/app/dist/assets/{index-DW2zI-Ss.js → index-ChbH55h5.js} +1 -1
  15. package/app/dist/assets/index-CzvG5PZT.css +1 -0
  16. package/app/dist/assets/{index-ZLvRNfLb.js → index-D9-aYc3I.js} +1 -1
  17. package/app/dist/assets/{index-BwFn9q4x.js → index-DezxtfNV.js} +1 -1
  18. package/app/dist/assets/{index-CXFMPmtf.js → index-DrI4IfXE.js} +1 -1
  19. package/app/dist/assets/{index-DgIg-QAA.js → index-DwfxgjnU.js} +2 -2
  20. package/app/dist/assets/index-ogrpJNou.js +555 -0
  21. package/app/dist/index.html +2 -2
  22. package/app/src/lib/api/client.ts +32 -0
  23. package/app/src/lib/components/ActionEditPopover.svelte +41 -19
  24. package/app/src/lib/components/BlockCard.svelte +74 -17
  25. package/app/src/lib/components/BlockEditPanel.svelte +142 -9
  26. package/app/src/lib/components/BlockEditor.svelte +534 -48
  27. package/app/src/lib/components/CodeEditPopover.svelte +281 -63
  28. package/app/src/lib/components/ContentModelTree.svelte +340 -67
  29. package/app/src/lib/components/IconPickerPopover.svelte +389 -0
  30. package/app/src/lib/components/ImageEditPopover.svelte +519 -0
  31. package/app/src/lib/components/InlineEditPopover.svelte +79 -56
  32. package/app/src/lib/components/InlineEditor.svelte +15 -5
  33. package/app/src/lib/components/ProseBlockCard.svelte +446 -0
  34. package/app/src/lib/components/ProseEditPanel.svelte +470 -0
  35. package/app/src/lib/components/RuneAttributes.svelte +51 -0
  36. package/app/src/lib/editor/block-parser.ts +211 -9
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +129 -1
  39. package/dist/server.js.map +1 -1
  40. package/package.json +6 -6
  41. package/app/dist/assets/index-BD2EBUrQ.css +0 -1
  42. package/app/dist/assets/index-BlAOhWAQ.js +0 -453
@@ -9,20 +9,32 @@
9
9
  blockLabel,
10
10
  extractRuneInner,
11
11
  rebuildRuneSource,
12
+ rebuildHeadingSource,
13
+ rebuildFenceSource,
14
+ groupIntoEditorBlocks,
12
15
  type ParsedBlock,
13
16
  type RuneBlock,
17
+ type HeadingBlock,
18
+ type FenceBlock,
19
+ type EditorBlock,
20
+ type ProseBlock,
14
21
  } from '../editor/block-parser.js';
15
- import { findSectionMapping, applySectionEdit, findActionMapping, applyActionEdit, findCommandMapping, applyCommandEdit, type SectionMapping, type ActionMapping, type CommandMapping } from '../editor/section-mapper.js';
22
+ import { findSectionMapping, applySectionEdit, findActionMapping, applyActionEdit, findCommandMapping, applyCommandEdit, applyLanguageEdit, findImageMapping, applyImageEdit, findIconMapping, applyIconEdit, type SectionMapping, type ActionMapping, type CommandMapping, type ImageMapping, type IconMapping } from '../editor/section-mapper.js';
16
23
  import { stripInlineMarkdown } from '../editor/inline-markdown.js';
17
24
  import { editorState } from '../state/editor.svelte.js';
18
25
  import BlockCard from './BlockCard.svelte';
19
26
  import type { SectionClickInfo } from './BlockCard.svelte';
27
+ import ProseBlockCard from './ProseBlockCard.svelte';
28
+ import type { ProseElementClickInfo } from './ProseBlockCard.svelte';
20
29
  import BlockEditPanel from './BlockEditPanel.svelte';
30
+ import ProseEditPanel from './ProseEditPanel.svelte';
21
31
  import FrontmatterEditPanel from './FrontmatterEditPanel.svelte';
22
32
  import InsertBlockDialog from './InsertBlockDialog.svelte';
23
33
  import InlineEditPopover from './InlineEditPopover.svelte';
24
34
  import ActionEditPopover from './ActionEditPopover.svelte';
25
35
  import CodeEditPopover from './CodeEditPopover.svelte';
36
+ import ImageEditPopover from './ImageEditPopover.svelte';
37
+ import IconPickerPopover from './IconPickerPopover.svelte';
26
38
 
27
39
  interface Props {
28
40
  bodyContent: string;
@@ -112,6 +124,7 @@
112
124
  editingFrontmatter = false;
113
125
  anchorPoint = null;
114
126
  inlineEdit = null;
127
+ proseInlineEdit = null;
115
128
  }
116
129
  });
117
130
 
@@ -122,6 +135,162 @@
122
135
  onchange(newSource);
123
136
  }
124
137
 
138
+ // ── Prose block grouping ─────────────────────────────────────
139
+
140
+ let editorBlocks: EditorBlock[] = $derived(groupIntoEditorBlocks(blocks));
141
+
142
+ /** Map an editor block index to the range of flat block indices it covers */
143
+ function editorBlockToFlatRange(editorIndex: number): [number, number] {
144
+ const eb = editorBlocks[editorIndex];
145
+ if (!eb) return [0, 0];
146
+ if (eb.type === 'prose') {
147
+ const firstChild = eb.children[0];
148
+ const lastChild = eb.children[eb.children.length - 1];
149
+ const start = blocks.indexOf(firstChild);
150
+ const end = blocks.indexOf(lastChild);
151
+ return [start, end];
152
+ }
153
+ // Rune block — find its position in the flat array
154
+ const idx = blocks.indexOf(eb as ParsedBlock);
155
+ return [idx, idx];
156
+ }
157
+
158
+ /** Map a flat block index to the corresponding editor block index */
159
+ function flatToEditorIndex(flatIndex: number): number {
160
+ let offset = 0;
161
+ for (let i = 0; i < editorBlocks.length; i++) {
162
+ const eb = editorBlocks[i];
163
+ const count = eb.type === 'prose' ? eb.children.length : 1;
164
+ if (flatIndex < offset + count) return i;
165
+ offset += count;
166
+ }
167
+ return editorBlocks.length - 1;
168
+ }
169
+
170
+ // ── Prose block operations ───────────────────────────────────
171
+
172
+ /** Update a prose block from the ProseEditPanel (structure reorder, content edit) */
173
+ function handleUpdateProseBlock(editorIndex: number, updated: ProseBlock) {
174
+ const [startFlat, endFlat] = editorBlockToFlatRange(editorIndex);
175
+ const oldChildren = blocks.slice(startFlat, endFlat + 1);
176
+ reconcileIds(oldChildren, updated.children);
177
+ blocks = [
178
+ ...blocks.slice(0, startFlat),
179
+ ...updated.children,
180
+ ...blocks.slice(endFlat + 1),
181
+ ];
182
+ syncToSource();
183
+ }
184
+
185
+ /** Handle click on a prose element (heading, paragraph, etc.) for inline editing */
186
+ function handleProseSectionClick(editorIndex: number, info: ProseElementClickInfo) {
187
+ const eb = editorBlocks[editorIndex];
188
+ if (!eb || eb.type !== 'prose') return;
189
+
190
+ const child = eb.children[info.childIndex];
191
+ if (!child) return;
192
+
193
+ const [startFlat] = editorBlockToFlatRange(editorIndex);
194
+ const flatIndex = startFlat + info.childIndex;
195
+
196
+ if (child.type === 'fence') {
197
+ const fb = child as FenceBlock;
198
+ const code = fb.code;
199
+ const language = fb.language;
200
+ const opener = child.source.split('\n')[0];
201
+ const delimMatch = opener.match(/^(`{3,}|~{3,})/);
202
+ const delimiter = delimMatch ? delimMatch[1] : '```';
203
+ commandEdit = {
204
+ blockIndex: flatIndex,
205
+ rect: info.rect,
206
+ mapping: { source: child.source, code, language, opener, delimiter },
207
+ };
208
+ return;
209
+ }
210
+
211
+ // For headings and paragraphs: inline text editing
212
+ const source = child.source.trim();
213
+ let prefix = '';
214
+ let inlineContent = source;
215
+
216
+ if (child.type === 'heading') {
217
+ const match = source.match(/^(#{1,6}\s+)(.*)/);
218
+ if (match) {
219
+ prefix = match[1];
220
+ inlineContent = match[2];
221
+ }
222
+ } else if (child.type === 'quote') {
223
+ const match = source.match(/^(>\s*)(.*)/);
224
+ if (match) {
225
+ prefix = match[1];
226
+ inlineContent = match[2];
227
+ }
228
+ }
229
+
230
+ const mapping: SectionMapping = {
231
+ dataName: child.type,
232
+ text: stripInlineMarkdown(inlineContent),
233
+ source,
234
+ sourcePrefix: prefix,
235
+ inlineSource: inlineContent,
236
+ };
237
+
238
+ proseInlineEdit = {
239
+ editorIndex,
240
+ childIndex: info.childIndex,
241
+ flatIndex,
242
+ inlineSource: inlineContent,
243
+ rect: info.rect,
244
+ mapping,
245
+ };
246
+ }
247
+
248
+ // ── Prose inline edit state ──────────────────────────────────
249
+
250
+ let proseInlineEdit: {
251
+ editorIndex: number;
252
+ childIndex: number;
253
+ flatIndex: number;
254
+ inlineSource: string;
255
+ rect: DOMRect;
256
+ mapping: SectionMapping;
257
+ } | null = $state(null);
258
+
259
+ function handleProseInlineEditChange(newInlineSource: string) {
260
+ if (!proseInlineEdit) return;
261
+ const child = blocks[proseInlineEdit.flatIndex];
262
+ if (!child) return;
263
+
264
+ const newSource = proseInlineEdit.mapping.sourcePrefix + newInlineSource;
265
+ let updated: ParsedBlock;
266
+
267
+ if (child.type === 'heading') {
268
+ const hb = child as HeadingBlock;
269
+ updated = { ...hb, text: newInlineSource, source: '' } as HeadingBlock;
270
+ (updated as HeadingBlock).source = rebuildHeadingSource(updated as HeadingBlock);
271
+ } else {
272
+ updated = { ...child, source: newSource };
273
+ }
274
+
275
+ // Update the mapping for subsequent edits
276
+ proseInlineEdit = {
277
+ ...proseInlineEdit,
278
+ inlineSource: newInlineSource,
279
+ mapping: {
280
+ ...proseInlineEdit.mapping,
281
+ text: stripInlineMarkdown(newInlineSource),
282
+ source: newSource,
283
+ inlineSource: newInlineSource,
284
+ },
285
+ };
286
+
287
+ handleUpdateBlock(proseInlineEdit.flatIndex, updated);
288
+ }
289
+
290
+ function closeProseInlineEdit() {
291
+ proseInlineEdit = null;
292
+ }
293
+
125
294
  // ── Frontmatter summary for visual mode header ──────────────
126
295
 
127
296
  let editingFrontmatter = $state(false);
@@ -144,6 +313,33 @@
144
313
  const POPOVER_WIDTH = 420;
145
314
  const POPOVER_GAP = 12;
146
315
 
316
+ /** Drag the popover by its header */
317
+ function handlePopoverDragStart(e: MouseEvent) {
318
+ const header = (e.target as HTMLElement).closest('.edit-panel__header');
319
+ if (!header || (e.target as HTMLElement).closest('button')) return;
320
+
321
+ e.preventDefault();
322
+ const popoverEl = e.currentTarget as HTMLElement;
323
+ const rect = popoverEl.getBoundingClientRect();
324
+ const startX = e.clientX;
325
+ const startY = e.clientY;
326
+ const origLeft = rect.left;
327
+ const origTop = rect.top;
328
+
329
+ function onMove(ev: MouseEvent) {
330
+ popoverEl.style.left = `${origLeft + (ev.clientX - startX)}px`;
331
+ popoverEl.style.top = `${origTop + (ev.clientY - startY)}px`;
332
+ }
333
+
334
+ function onUp() {
335
+ window.removeEventListener('mousemove', onMove);
336
+ window.removeEventListener('mouseup', onUp);
337
+ }
338
+
339
+ window.addEventListener('mousemove', onMove);
340
+ window.addEventListener('mouseup', onUp);
341
+ }
342
+
147
343
  let popoverStyle = $derived.by(() => {
148
344
  if (!anchorPoint) return '';
149
345
 
@@ -169,24 +365,27 @@
169
365
  return `left: ${left}px; top: ${top}px; max-height: min(600px, ${maxH}px);`;
170
366
  });
171
367
 
172
- function toggleBlock(index: number, x: number, y: number) {
368
+ function toggleBlock(editorIndex: number, x: number, y: number) {
173
369
  editingFrontmatter = false;
174
- if (activeIndex === index) {
370
+ const eb = editorBlocks[editorIndex];
371
+ if (!eb) return;
372
+
373
+ if (activeIndex === editorIndex) {
175
374
  activeIndex = null;
176
375
  anchorPoint = null;
177
376
  pendingRuneIndex = null;
178
377
  } else {
179
378
  editSessionId++;
180
- activeIndex = index;
379
+ activeIndex = editorIndex;
181
380
  anchorPoint = { x, y };
182
381
  pendingRuneIndex = null;
183
382
  }
184
383
  }
185
384
 
186
- function handleRuneClick(index: number, x: number, y: number, nestedRuneIndex?: number) {
385
+ function handleRuneClick(editorIndex: number, x: number, y: number, nestedRuneIndex?: number) {
187
386
  editingFrontmatter = false;
188
387
  editSessionId++;
189
- activeIndex = index;
388
+ activeIndex = editorIndex;
190
389
  anchorPoint = { x, y };
191
390
  pendingRuneIndex = nestedRuneIndex ?? null;
192
391
  }
@@ -205,6 +404,10 @@
205
404
  function handleKeydown(e: KeyboardEvent) {
206
405
  if (readOnly) return;
207
406
  if (e.key === 'Escape') {
407
+ if (proseInlineEdit) {
408
+ closeProseInlineEdit();
409
+ return;
410
+ }
208
411
  if (activeIndex !== null || editingFrontmatter) {
209
412
  activeIndex = null;
210
413
  editingFrontmatter = false;
@@ -229,21 +432,24 @@
229
432
 
230
433
  // ── Block operations ─────────────────────────────────────────
231
434
 
232
- function handleUpdateBlock(index: number, updated: ParsedBlock) {
233
- blocks = blocks.map((b, i) => (i === index ? updated : b));
435
+ /** Update a block by its flat index in the blocks array */
436
+ function handleUpdateBlock(flatIndex: number, updated: ParsedBlock) {
437
+ blocks = blocks.map((b, i) => (i === flatIndex ? updated : b));
234
438
  syncToSource();
235
439
  }
236
440
 
237
- function handleRemoveBlock(index: number) {
441
+ /** Remove a block by its editor block index */
442
+ function handleRemoveEditorBlock(editorIndex: number) {
443
+ const [startFlat, endFlat] = editorBlockToFlatRange(editorIndex);
238
444
  // Adjust activeIndex
239
445
  if (activeIndex !== null) {
240
- if (activeIndex === index) {
446
+ if (activeIndex === editorIndex) {
241
447
  activeIndex = null;
242
- } else if (activeIndex > index) {
448
+ } else if (activeIndex > editorIndex) {
243
449
  activeIndex--;
244
450
  }
245
451
  }
246
- blocks = blocks.filter((_, i) => i !== index);
452
+ blocks = [...blocks.slice(0, startFlat), ...blocks.slice(endFlat + 1)];
247
453
  syncToSource();
248
454
  }
249
455
 
@@ -274,17 +480,32 @@
274
480
  function handleDrop(e: DragEvent, index: number) {
275
481
  e.preventDefault();
276
482
  if (dragIndex !== null && dragIndex !== index) {
277
- const moved = blocks[dragIndex];
278
- const next = blocks.filter((_, i) => i !== dragIndex);
279
- next.splice(index, 0, moved);
280
- blocks = next;
483
+ // Get the flat block ranges for source and destination
484
+ const [srcStart, srcEnd] = editorBlockToFlatRange(dragIndex);
485
+ const movedBlocks = blocks.slice(srcStart, srcEnd + 1);
486
+
487
+ // Remove the source blocks
488
+ const without = [...blocks.slice(0, srcStart), ...blocks.slice(srcEnd + 1)];
489
+
490
+ // Find the insertion point in the filtered array
491
+ let insertAt: number;
492
+ if (index >= editorBlocks.length) {
493
+ insertAt = without.length;
494
+ } else {
495
+ // Recalculate target position in the reduced array
496
+ const [tgtStart] = editorBlockToFlatRange(index);
497
+ // Adjust for removed blocks if source was before target
498
+ insertAt = tgtStart > srcEnd ? tgtStart - movedBlocks.length : tgtStart;
499
+ }
500
+
501
+ without.splice(insertAt, 0, ...movedBlocks);
502
+ blocks = without;
281
503
 
282
504
  // Update activeIndex to follow the active block
283
505
  if (activeIndex !== null) {
284
506
  if (activeIndex === dragIndex) {
285
507
  activeIndex = index > dragIndex ? index - 1 : index;
286
508
  } else {
287
- // Adjust if the move shifts the active block's position
288
509
  let newActive = activeIndex;
289
510
  if (dragIndex < activeIndex && index >= activeIndex) {
290
511
  newActive--;
@@ -408,12 +629,29 @@
408
629
  }
409
630
  }
410
631
 
411
- const pos = insertAtIndex ?? blocks.length;
412
- blocks = [...blocks.slice(0, pos), newBlock, ...blocks.slice(pos)];
632
+ // Convert editor block index to flat block insertion position
633
+ const editorPos = insertAtIndex ?? editorBlocks.length;
634
+ let flatPos: number;
635
+ if (editorPos >= editorBlocks.length) {
636
+ flatPos = blocks.length;
637
+ } else {
638
+ const [start] = editorBlockToFlatRange(editorPos);
639
+ flatPos = start;
640
+ }
641
+
642
+ blocks = [...blocks.slice(0, flatPos), newBlock, ...blocks.slice(flatPos)];
413
643
  insertAtIndex = null;
414
644
  showInsertMenu = false;
415
645
  editingFrontmatter = false;
416
- activeIndex = pos;
646
+ // Set activeIndex for rune blocks; for prose blocks the new element merges into a prose block
647
+ if (type === 'rune') {
648
+ // Find the new block's editor index after re-grouping
649
+ const newEditorBlocks = groupIntoEditorBlocks(blocks);
650
+ activeIndex = newEditorBlocks.findIndex(eb => eb.type === 'rune' && eb === blocks[flatPos]);
651
+ if (activeIndex === -1) activeIndex = null;
652
+ } else {
653
+ activeIndex = null;
654
+ }
417
655
  syncToSource();
418
656
  }
419
657
 
@@ -427,10 +665,15 @@
427
665
  mapping: SectionMapping;
428
666
  } | null = $state(null);
429
667
 
430
- function handleSectionClick(index: number, info: SectionClickInfo) {
431
- const block = blocks[index];
668
+ function handleSectionClick(editorIndex: number, info: SectionClickInfo) {
669
+ const eb = editorBlocks[editorIndex];
670
+ if (!eb || eb.type !== 'rune') return;
671
+ const [flatIndex] = editorBlockToFlatRange(editorIndex);
672
+ const block = blocks[flatIndex];
432
673
  if (block.type !== 'rune') return;
433
674
  const rb = block as RuneBlock;
675
+ // Use flatIndex for all block operations below
676
+ const index = flatIndex;
434
677
 
435
678
  if (info.editType === 'link') {
436
679
  const mapping = findActionMapping(rb.innerContent, info.text, info.href ?? '');
@@ -456,6 +699,30 @@
456
699
  return;
457
700
  }
458
701
 
702
+ if (info.editType === 'image') {
703
+ const imgSrc = info.href ?? '';
704
+ const mapping = findImageMapping(rb.innerContent, imgSrc);
705
+ if (!mapping) return;
706
+
707
+ imageEdit = {
708
+ blockIndex: index,
709
+ mapping,
710
+ };
711
+ return;
712
+ }
713
+
714
+ if (info.editType === 'icon') {
715
+ const iconName = info.iconName ?? '';
716
+ const mapping = findIconMapping(rb.innerContent, iconName);
717
+ if (!mapping) return;
718
+
719
+ iconEdit = {
720
+ blockIndex: index,
721
+ mapping,
722
+ };
723
+ return;
724
+ }
725
+
459
726
  // Default: inline text editing
460
727
  const mapping = findSectionMapping(rb.innerContent, info.dataName, info.text);
461
728
  if (!mapping) return;
@@ -550,6 +817,16 @@
550
817
  function handleCommandEditChange(newCode: string) {
551
818
  if (!commandEdit) return;
552
819
  const block = blocks[commandEdit.blockIndex];
820
+
821
+ if (block.type === 'fence') {
822
+ // Direct fence block (from prose section)
823
+ const fb = block as FenceBlock;
824
+ const updated: FenceBlock = { ...fb, code: newCode, source: '' };
825
+ updated.source = rebuildFenceSource(updated);
826
+ handleUpdateBlock(commandEdit.blockIndex, updated);
827
+ return;
828
+ }
829
+
553
830
  if (block.type !== 'rune') return;
554
831
  const rb = block as RuneBlock;
555
832
 
@@ -564,6 +841,15 @@
564
841
  if (!commandEdit) return;
565
842
  const blockIndex = commandEdit.blockIndex;
566
843
  const block = blocks[blockIndex];
844
+
845
+ if (block.type === 'fence') {
846
+ // Direct fence block — remove entirely
847
+ commandEdit = null;
848
+ blocks = blocks.filter((_, i) => i !== blockIndex);
849
+ syncToSource();
850
+ return;
851
+ }
852
+
567
853
  if (block.type !== 'rune') return;
568
854
  const rb = block as RuneBlock;
569
855
 
@@ -580,12 +866,127 @@
580
866
  commandEdit = null;
581
867
  }
582
868
 
869
+ function handleLanguageChange(newLanguage: string) {
870
+ if (!commandEdit) return;
871
+ const block = blocks[commandEdit.blockIndex];
872
+
873
+ if (block.type === 'fence') {
874
+ // Direct fence block (from prose section)
875
+ const fb = block as FenceBlock;
876
+ const updated: FenceBlock = { ...fb, language: newLanguage, source: '' };
877
+ updated.source = rebuildFenceSource(updated);
878
+
879
+ // Update the mapping
880
+ const afterDelimiter = commandEdit.mapping.opener.slice(commandEdit.mapping.delimiter.length);
881
+ const infoString = afterDelimiter.replace(/^\w*/, '').trim();
882
+ const newOpener = commandEdit.mapping.delimiter + newLanguage + (infoString ? ' ' + infoString : '');
883
+ const newSource = newOpener + '\n' + commandEdit.mapping.code + '\n' + commandEdit.mapping.delimiter;
884
+ commandEdit = {
885
+ ...commandEdit,
886
+ mapping: { ...commandEdit.mapping, language: newLanguage, opener: newOpener, source: newSource },
887
+ };
888
+
889
+ handleUpdateBlock(commandEdit.blockIndex, updated);
890
+ return;
891
+ }
892
+
893
+ if (block.type !== 'rune') return;
894
+ const rb = block as RuneBlock;
895
+
896
+ const newInner = applyLanguageEdit(rb.innerContent, commandEdit.mapping, newLanguage);
897
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
898
+ updated.source = rebuildRuneSource(updated);
899
+
900
+ // Update the mapping to reflect the new language and opener
901
+ const afterDelimiter = commandEdit.mapping.opener.slice(commandEdit.mapping.delimiter.length);
902
+ const infoString = afterDelimiter.replace(/^\w*/, '').trim();
903
+ const newOpener = commandEdit.mapping.delimiter + newLanguage + (infoString ? ' ' + infoString : '');
904
+ const newSource = newOpener + '\n' + commandEdit.mapping.code + '\n' + commandEdit.mapping.delimiter;
905
+
906
+ commandEdit = {
907
+ ...commandEdit,
908
+ mapping: {
909
+ ...commandEdit.mapping,
910
+ language: newLanguage,
911
+ opener: newOpener,
912
+ source: newSource,
913
+ },
914
+ };
915
+
916
+ handleUpdateBlock(commandEdit.blockIndex, updated);
917
+ }
918
+
919
+ // ── Image editing ────────────────────────────────────────────
920
+
921
+ let imageEdit: {
922
+ blockIndex: number;
923
+ mapping: ImageMapping;
924
+ } | null = $state(null);
925
+
926
+ function handleImageEditChange(newSrc: string, newAlt: string) {
927
+ if (!imageEdit) return;
928
+ const block = blocks[imageEdit.blockIndex];
929
+ if (block.type !== 'rune') return;
930
+ const rb = block as RuneBlock;
931
+
932
+ const newInner = applyImageEdit(rb.innerContent, imageEdit.mapping, newAlt, newSrc);
933
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
934
+ updated.source = rebuildRuneSource(updated);
935
+
936
+ handleUpdateBlock(imageEdit.blockIndex, updated);
937
+ }
938
+
939
+ function handleImageRemove() {
940
+ if (!imageEdit) return;
941
+ const blockIndex = imageEdit.blockIndex;
942
+ const block = blocks[blockIndex];
943
+ if (block.type !== 'rune') return;
944
+ const rb = block as RuneBlock;
945
+
946
+ const newInner = rb.innerContent.replace(imageEdit.mapping.source + '\n', '').replace(imageEdit.mapping.source, '');
947
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
948
+ updated.source = rebuildRuneSource(updated);
949
+
950
+ imageEdit = null;
951
+ handleUpdateBlock(blockIndex, updated);
952
+ }
953
+
954
+ function closeImageEdit() {
955
+ imageEdit = null;
956
+ }
957
+
958
+ // ── Icon editing ────────────────────────────────────────────
959
+
960
+ let iconEdit: {
961
+ blockIndex: number;
962
+ mapping: IconMapping;
963
+ } | null = $state(null);
964
+
965
+ function handleIconEditChange(newIconName: string) {
966
+ if (!iconEdit) return;
967
+ const block = blocks[iconEdit.blockIndex];
968
+ if (block.type !== 'rune') return;
969
+ const rb = block as RuneBlock;
970
+
971
+ const newInner = applyIconEdit(rb.innerContent, iconEdit.mapping, newIconName);
972
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
973
+ updated.source = rebuildRuneSource(updated);
974
+
975
+ handleUpdateBlock(iconEdit.blockIndex, updated);
976
+ }
977
+
978
+ function closeIconEdit() {
979
+ iconEdit = null;
980
+ }
981
+
583
982
  // ── Field edit from Structure tab ──────────────────────────────
584
983
 
585
984
  function handleFieldEdit(dataName: string, inlineSource: string, rect: DOMRect, mapping: SectionMapping) {
586
985
  if (activeIndex === null) return;
986
+ const [flatIndex] = editorBlockToFlatRange(activeIndex);
987
+ commandEdit = null;
587
988
  inlineEdit = {
588
- blockIndex: activeIndex,
989
+ blockIndex: flatIndex,
589
990
  dataName,
590
991
  inlineSource,
591
992
  rect,
@@ -593,6 +994,17 @@
593
994
  };
594
995
  }
595
996
 
997
+ function handleFieldCodeEdit(code: string, language: string, rect: DOMRect, mapping: CommandMapping) {
998
+ if (activeIndex === null) return;
999
+ const [flatIndex] = editorBlockToFlatRange(activeIndex);
1000
+ inlineEdit = null;
1001
+ commandEdit = {
1002
+ blockIndex: flatIndex,
1003
+ rect,
1004
+ mapping,
1005
+ };
1006
+ }
1007
+
596
1008
  // Group runes by category for the insert menu
597
1009
  let runesByCategory = $derived.by(() => {
598
1010
  const map = new Map<string, RuneInfo[]>();
@@ -648,7 +1060,7 @@
648
1060
  </div>
649
1061
  {/if}
650
1062
 
651
- {#each blocks as block, i (block.id)}
1063
+ {#each editorBlocks as eb, i (eb.id)}
652
1064
  <div
653
1065
  class="block-editor__row"
654
1066
  class:hovered={!readOnly && hoveredIndex === i}
@@ -659,23 +1071,43 @@
659
1071
  onmouseleave={() => { if (!readOnly && hoveredIndex === i) hoveredIndex = null; }}
660
1072
  >
661
1073
  <div class="block-editor__block-cell">
662
- <BlockCard
663
- {block}
664
- {themeConfig}
665
- {themeCss}
666
- {highlightCss}
667
- {highlightTransform}
668
- {communityTags}
669
- {communityPostTransforms}
670
- {communityStyles}
671
- {aggregated}
672
- {readOnly}
673
- onsectionclick={readOnly ? undefined : (info) => handleSectionClick(i, info)}
674
- onruneclick={readOnly ? undefined : (info) => handleRuneClick(i, info.x, info.y, info.nestedRuneIndex)}
675
- ondragstart={readOnly ? undefined : (e) => handleDragStart(e, i)}
676
- ondragover={readOnly ? undefined : (e) => handleDragOver(e, i)}
677
- ondrop={readOnly ? undefined : (e) => handleDrop(e, i)}
678
- />
1074
+ {#if eb.type === 'prose'}
1075
+ <ProseBlockCard
1076
+ block={eb}
1077
+ {themeConfig}
1078
+ {themeCss}
1079
+ {highlightCss}
1080
+ {highlightTransform}
1081
+ {communityTags}
1082
+ {communityPostTransforms}
1083
+ {communityStyles}
1084
+ {aggregated}
1085
+ {readOnly}
1086
+ onsectionclick={readOnly ? undefined : (info) => handleProseSectionClick(i, info)}
1087
+ onblockclick={readOnly ? undefined : (info) => toggleBlock(i, info.x, info.y)}
1088
+ ondragstart={readOnly ? undefined : (e) => handleDragStart(e, i)}
1089
+ ondragover={readOnly ? undefined : (e) => handleDragOver(e, i)}
1090
+ ondrop={readOnly ? undefined : (e) => handleDrop(e, i)}
1091
+ />
1092
+ {:else}
1093
+ <BlockCard
1094
+ block={eb}
1095
+ {themeConfig}
1096
+ {themeCss}
1097
+ {highlightCss}
1098
+ {highlightTransform}
1099
+ {communityTags}
1100
+ {communityPostTransforms}
1101
+ {communityStyles}
1102
+ {aggregated}
1103
+ {readOnly}
1104
+ onsectionclick={readOnly ? undefined : (info) => handleSectionClick(i, info)}
1105
+ onruneclick={readOnly ? undefined : (info) => handleRuneClick(i, info.x, info.y, info.nestedRuneIndex)}
1106
+ ondragstart={readOnly ? undefined : (e) => handleDragStart(e, i)}
1107
+ ondragover={readOnly ? undefined : (e) => handleDragOver(e, i)}
1108
+ ondrop={readOnly ? undefined : (e) => handleDrop(e, i)}
1109
+ />
1110
+ {/if}
679
1111
  </div>
680
1112
  {#if !readOnly && showInsertMenuProp}
681
1113
  <!-- Insert markers — top and bottom edges -->
@@ -707,7 +1139,7 @@
707
1139
  onclick={(e) => toggleBlock(i, e.clientX, e.clientY)}
708
1140
  aria-pressed={activeIndex === i}
709
1141
  >
710
- {blockLabel(block)}
1142
+ {blockLabel(eb)}
711
1143
  </button>
712
1144
  {/if}
713
1145
  </div>
@@ -724,23 +1156,37 @@
724
1156
  class="block-editor__popover-backdrop"
725
1157
  onmousedown={() => { activeIndex = null; editingFrontmatter = false; anchorPoint = null; pendingRuneIndex = null; }}
726
1158
  ></div>
727
- <div class="block-editor__popover" style={popoverStyle}>
1159
+ <div class="block-editor__popover" style={popoverStyle} onmousedown={handlePopoverDragStart}>
728
1160
  {#if editingFrontmatter}
729
1161
  <FrontmatterEditPanel
730
1162
  onclose={() => { editingFrontmatter = false; anchorPoint = null; }}
731
1163
  />
732
- {:else if activeIndex !== null && blocks[activeIndex]}
1164
+ {:else if activeIndex !== null && editorBlocks[activeIndex]?.type === 'rune'}
1165
+ {@const activeBlock = blocks[editorBlockToFlatRange(activeIndex)[0]]}
733
1166
  {#key editSessionId}
734
1167
  <BlockEditPanel
735
- block={blocks[activeIndex]}
1168
+ block={activeBlock}
736
1169
  {runeMap}
737
1170
  runes={() => runes}
738
1171
  {aggregated}
739
1172
  initialRuneIndex={pendingRuneIndex}
740
- onupdate={(updated) => handleUpdateBlock(activeIndex!, updated)}
741
- onremove={() => { const idx = activeIndex!; activeIndex = null; anchorPoint = null; pendingRuneIndex = null; handleRemoveBlock(idx); }}
1173
+ onupdate={(updated) => handleUpdateBlock(editorBlockToFlatRange(activeIndex!)[0], updated)}
1174
+ onremove={() => { const idx = activeIndex!; activeIndex = null; anchorPoint = null; pendingRuneIndex = null; handleRemoveEditorBlock(idx); }}
742
1175
  onclose={() => { activeIndex = null; anchorPoint = null; pendingRuneIndex = null; }}
743
1176
  oneditfield={handleFieldEdit}
1177
+ oneditcode={handleFieldCodeEdit}
1178
+ />
1179
+ {/key}
1180
+ {:else if activeIndex !== null && editorBlocks[activeIndex]?.type === 'prose'}
1181
+ {@const proseBlock = editorBlocks[activeIndex] as ProseBlock}
1182
+ {#key editSessionId}
1183
+ <ProseEditPanel
1184
+ block={proseBlock}
1185
+ runes={() => runes}
1186
+ {aggregated}
1187
+ onupdate={(updated) => handleUpdateProseBlock(activeIndex!, updated)}
1188
+ onremove={() => { const idx = activeIndex!; activeIndex = null; anchorPoint = null; handleRemoveEditorBlock(idx); }}
1189
+ onclose={() => { activeIndex = null; anchorPoint = null; }}
744
1190
  />
745
1191
  {/key}
746
1192
  {/if}
@@ -783,10 +1229,40 @@
783
1229
  code={commandEdit.mapping.code}
784
1230
  language={commandEdit.mapping.language}
785
1231
  onchange={handleCommandEditChange}
1232
+ onlanguagechange={handleLanguageChange}
786
1233
  onremove={handleCommandRemove}
787
1234
  onclose={closeCommandEdit}
788
1235
  />
789
1236
  {/if}
1237
+
1238
+ {#if imageEdit}
1239
+ <ImageEditPopover
1240
+ currentSrc={imageEdit.mapping.src}
1241
+ currentAlt={imageEdit.mapping.alt}
1242
+ onchange={(src, alt) => { handleImageEditChange(src, alt); closeImageEdit(); }}
1243
+ onremove={handleImageRemove}
1244
+ onclose={closeImageEdit}
1245
+ />
1246
+ {/if}
1247
+
1248
+ {#if iconEdit}
1249
+ <IconPickerPopover
1250
+ icons={themeConfig?.icons ?? {}}
1251
+ currentIcon={iconEdit.mapping.name}
1252
+ onchange={(name) => { handleIconEditChange(name); closeIconEdit(); }}
1253
+ onclose={closeIconEdit}
1254
+ />
1255
+ {/if}
1256
+
1257
+ {#if proseInlineEdit}
1258
+ <InlineEditPopover
1259
+ anchorRect={proseInlineEdit.rect}
1260
+ dataName={proseInlineEdit.mapping.dataName}
1261
+ inlineSource={proseInlineEdit.inlineSource}
1262
+ onchange={handleProseInlineEditChange}
1263
+ onclose={closeProseInlineEdit}
1264
+ />
1265
+ {/if}
790
1266
  </div>
791
1267
 
792
1268
 
@@ -1005,7 +1481,9 @@
1005
1481
  .block-editor__popover {
1006
1482
  position: fixed;
1007
1483
  width: 420px;
1008
- overflow-y: auto;
1484
+ overflow: hidden;
1485
+ display: flex;
1486
+ flex-direction: column;
1009
1487
  background: var(--ed-surface-0);
1010
1488
  border-radius: var(--ed-radius-lg);
1011
1489
  border: 1px solid var(--ed-border-default);
@@ -1015,6 +1493,14 @@
1015
1493
  animation: popover-enter 0.15s ease-out;
1016
1494
  }
1017
1495
 
1496
+ .block-editor__popover :global(.edit-panel__header) {
1497
+ cursor: grab;
1498
+ }
1499
+
1500
+ .block-editor__popover :global(.edit-panel__header):active {
1501
+ cursor: grabbing;
1502
+ }
1503
+
1018
1504
  @keyframes popover-enter {
1019
1505
  from {
1020
1506
  opacity: 0;