@refrakt-md/editor 0.8.0 → 0.8.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.
Files changed (42) hide show
  1. package/app/dist/assets/{index-Ca-wW6uw.js → index-80NtMar1.js} +1 -1
  2. package/app/dist/assets/index-B6H6LF1M.css +1 -0
  3. package/app/dist/assets/{index-Dg4A5Pez.js → index-BDj1XPol.js} +1 -1
  4. package/app/dist/assets/{index-BfYWp0QC.js → index-BXe1fKaT.js} +1 -1
  5. package/app/dist/assets/{index-Cq0Maciq.js → index-BfxTGrHB.js} +1 -1
  6. package/app/dist/assets/{index-BsSUa0GD.js → index-Bn8ajfVl.js} +1 -1
  7. package/app/dist/assets/{index-D6vnTt4b.js → index-CCkzIGTi.js} +2 -2
  8. package/app/dist/assets/{index-BehCztSl.js → index-CXeK-dZx.js} +1 -1
  9. package/app/dist/assets/{index-iGDqoXj_.js → index-CaRBCHaX.js} +1 -1
  10. package/app/dist/assets/index-Cd12jZId.js +479 -0
  11. package/app/dist/assets/{index-D5pMhPrg.js → index-Cgbvx23V.js} +1 -1
  12. package/app/dist/assets/{index-IU6QYZAa.js → index-D5ucdUTo.js} +1 -1
  13. package/app/dist/assets/{index-CdpS6tGk.js → index-DGYxLhpR.js} +1 -1
  14. package/app/dist/assets/{index-RKEq45V5.js → index-DNJBunzP.js} +1 -1
  15. package/app/dist/assets/{index-Cgaw2jCE.js → index-DNtuldOx.js} +1 -1
  16. package/app/dist/assets/{index-BEPqnnsd.js → index-DQUOY-pF.js} +1 -1
  17. package/app/dist/assets/{index-2hOoPFOR.js → index-DskvyNKT.js} +1 -1
  18. package/app/dist/assets/{index-CLZfwYyS.js → index-aPeHMqUX.js} +1 -1
  19. package/app/dist/assets/{index-BobjskUl.js → index-dGztG-54.js} +1 -1
  20. package/app/dist/assets/{index-DHALjxX5.js → index-xo7v6nRB.js} +1 -1
  21. package/app/dist/index.html +2 -2
  22. package/app/src/lib/api/client.ts +81 -0
  23. package/app/src/lib/components/ActionEditPopover.svelte +267 -0
  24. package/app/src/lib/components/BlockCard.svelte +285 -0
  25. package/app/src/lib/components/BlockEditPanel.svelte +640 -260
  26. package/app/src/lib/components/BlockEditor.svelte +513 -52
  27. package/app/src/lib/components/CodeEditPopover.svelte +444 -0
  28. package/app/src/lib/components/ContentModelTree.svelte +835 -0
  29. package/app/src/lib/components/EditorLayout.svelte +1 -6
  30. package/app/src/lib/components/FrontmatterEditPanel.svelte +0 -1
  31. package/app/src/lib/components/IconPickerPopover.svelte +389 -0
  32. package/app/src/lib/components/ImageEditPopover.svelte +519 -0
  33. package/app/src/lib/components/InlineEditPopover.svelte +616 -0
  34. package/app/src/lib/components/RuneAttributes.svelte +51 -0
  35. package/app/src/lib/editor/block-parser.ts +424 -6
  36. package/dist/server.d.ts +1 -0
  37. package/dist/server.d.ts.map +1 -1
  38. package/dist/server.js +189 -2
  39. package/dist/server.js.map +1 -1
  40. package/package.json +6 -6
  41. package/app/dist/assets/index-98ylvoBO.css +0 -1
  42. package/app/dist/assets/index-CVzOx0nV.js +0 -372
@@ -15,9 +15,22 @@
15
15
  parseContentTree,
16
16
  serializeAttributes,
17
17
  replaceNodeSource,
18
+ insertFieldContent,
19
+ removeFieldContent,
20
+ appendListItem,
21
+ removeListItem,
22
+ reorderListItem,
23
+ appendGreedyItem,
24
+ removeGreedyItem,
25
+ reorderGreedyItem,
26
+ splitListItems,
18
27
  } from '../editor/block-parser.js';
28
+ import { resolveContentStructure, type ResolvedField } from '../editor/content-model-resolver.js';
29
+ import type { SectionMapping, CommandMapping } from '../editor/section-mapper.js';
30
+ import { stripInlineMarkdown } from '../editor/inline-markdown.js';
19
31
  import RuneAttributes from './RuneAttributes.svelte';
20
32
  import ContentTree from './ContentTree.svelte';
33
+ import ContentModelTree from './ContentModelTree.svelte';
21
34
  import InlineEditor from './InlineEditor.svelte';
22
35
 
23
36
  interface Props {
@@ -25,12 +38,16 @@
25
38
  runeMap: Map<string, RuneInfo>;
26
39
  runes: () => RuneInfo[];
27
40
  aggregated?: Record<string, unknown>;
41
+ /** When set, auto-navigate to the Nth nested rune (DFS order) on mount */
42
+ initialRuneIndex?: number | null;
28
43
  onupdate: (block: ParsedBlock) => void;
29
44
  onremove: () => void;
30
45
  onclose: () => void;
46
+ oneditfield?: (dataName: string, inlineSource: string, rect: DOMRect, mapping: SectionMapping) => void;
47
+ oneditcode?: (code: string, language: string, rect: DOMRect, mapping: CommandMapping) => void;
31
48
  }
32
49
 
33
- let { block, runeMap, runes, aggregated = {}, onupdate, onremove, onclose }: Props = $props();
50
+ let { block, runeMap, runes, aggregated = {}, initialRuneIndex = null, onupdate, onremove, onclose, oneditfield, oneditcode }: Props = $props();
34
51
 
35
52
  let label = $derived(blockLabel(block));
36
53
 
@@ -58,6 +75,55 @@
58
75
  contentTree.some(n => n.type === 'rune')
59
76
  );
60
77
 
78
+ /** The effective rune info for the structure tab — nested rune if selected, root otherwise */
79
+ let effectiveRuneInfo = $derived.by(() => {
80
+ if (activeNode?.type === 'rune' && activeRuneInfo) return activeRuneInfo;
81
+ return runeInfo;
82
+ });
83
+
84
+ /** Content tree for the effective rune (nested rune's children or root's) */
85
+ let effectiveContentTree = $derived.by(() => {
86
+ if (activeNode?.type === 'rune' && activeNode.children) return activeNode.children;
87
+ return contentTree;
88
+ });
89
+
90
+ /** Whether the effective rune has a declarative content model */
91
+ let hasContentModel = $derived(effectiveRuneInfo?.contentModel != null);
92
+
93
+ /** Resolved structure: effective content tree matched against effective content model */
94
+ let resolvedStructure = $derived.by(() => {
95
+ if (!effectiveRuneInfo?.contentModel) return null;
96
+ return resolveContentStructure(effectiveContentTree, effectiveRuneInfo.contentModel);
97
+ });
98
+
99
+ /** Currently selected field in the content model tree */
100
+ let selectedField: string | null = $state(null);
101
+
102
+ /** Find the path to the Nth rune node in DFS order */
103
+ function findRunePathByDfsIndex(nodes: ContentNode[], target: number): number[] | null {
104
+ let count = 0;
105
+ function walk(ns: ContentNode[], path: number[]): number[] | null {
106
+ for (let i = 0; i < ns.length; i++) {
107
+ if (ns[i].type === 'rune') {
108
+ if (count === target) return [...path, i];
109
+ count++;
110
+ const found = walk(ns[i].children ?? [], [...path, i]);
111
+ if (found) return found;
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ return walk(nodes, []);
117
+ }
118
+
119
+ // Auto-navigate to a nested rune when initialRuneIndex is provided
120
+ $effect(() => {
121
+ if (initialRuneIndex != null && contentTree.length > 0) {
122
+ const path = findRunePathByDfsIndex(contentTree, initialRuneIndex);
123
+ if (path) activePath = path;
124
+ }
125
+ });
126
+
61
127
  /** Resolve the active nested node from the path */
62
128
  function resolveNode(nodes: ContentNode[], path: number[]): ContentNode | null {
63
129
  if (path.length === 0) return null;
@@ -84,36 +150,253 @@
84
150
  return activeNode.type;
85
151
  });
86
152
 
87
- let showTreeDropdown: boolean = $state(false);
88
- let treeDropdownEl: HTMLDivElement;
153
+ type TabId = 'settings' | 'structure' | 'content';
154
+ let activeTab: TabId = $state('settings');
155
+
156
+ let availableTabs = $derived.by(() => {
157
+ if (block.type !== 'rune') return [] as TabId[];
158
+ const rb = block as RuneBlock;
159
+ const tabs: TabId[] = ['settings'];
160
+ // Show structure tab when the effective rune has a content model,
161
+ // or when no nested rune is selected and root has nested runes (legacy tree)
162
+ if (hasContentModel || (!activeNode && hasNestedRunes)) tabs.push('structure');
163
+ if (!rb.selfClosing) tabs.push('content');
164
+ return tabs;
165
+ });
166
+
167
+ // Auto-switch away from structure tab if it becomes unavailable
168
+ $effect(() => {
169
+ if (activeTab === 'structure' && !availableTabs.includes('structure')) {
170
+ activeTab = 'settings';
171
+ }
172
+ });
89
173
 
90
174
  function handleTreeSelect(path: number[]) {
91
175
  activePath = path;
92
- showTreeDropdown = false;
176
+ // Auto-switch to Content tab when selecting a content node
177
+ const node = resolveNode(contentTree, path);
178
+ if (node && node.type !== 'rune') {
179
+ activeTab = 'content';
180
+ }
93
181
  }
94
182
 
95
183
  function navigateToRoot() {
96
184
  activePath = [];
97
- showTreeDropdown = false;
98
185
  }
99
186
 
100
- function toggleTreeDropdown() {
101
- showTreeDropdown = !showTreeDropdown;
187
+ // ── Content model field handlers ─────────────────────────────
188
+
189
+ function handleFieldSelect(fieldName: string, zoneName?: string) {
190
+ selectedField = zoneName ? `${zoneName}.${fieldName}` : fieldName;
102
191
  }
103
192
 
104
- function handleTreeClickOutside(e: MouseEvent) {
105
- if (showTreeDropdown && treeDropdownEl && !treeDropdownEl.contains(e.target as Node)) {
106
- showTreeDropdown = false;
193
+ /** Apply a field content change — works for both root and nested runes */
194
+ function applyFieldChange(updater: (content: string) => string) {
195
+ const rb = block as RuneBlock;
196
+ if (activeNode?.type === 'rune' && activeNode.innerContent !== undefined) {
197
+ // Nested rune: update its inner content, then replace in root
198
+ const newNestedInner = updater(activeNode.innerContent);
199
+ if (newNestedInner === activeNode.innerContent) return;
200
+ const attrStr = serializeAttributes(activeNode.attributes ?? {});
201
+ const inner = newNestedInner.trim();
202
+ const newSource = inner
203
+ ? `{% ${activeNode.runeName}${attrStr} %}\n${inner}\n{% /${activeNode.runeName} %}`
204
+ : `{% ${activeNode.runeName}${attrStr} %}\n\n{% /${activeNode.runeName} %}`;
205
+ const newRootInner = replaceNodeSource(rb.innerContent, activeNode.source, newSource);
206
+ const updated: RuneBlock = { ...rb, innerContent: newRootInner, source: '' };
207
+ updated.source = rebuildRuneSource(updated);
208
+ onupdate(updated);
209
+ } else {
210
+ // Root rune
211
+ const newInner = updater(rb.innerContent);
212
+ if (newInner === rb.innerContent) return;
213
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
214
+ updated.source = rebuildRuneSource(updated);
215
+ onupdate(updated);
216
+ }
217
+ }
218
+
219
+ function handleAddField(fieldName: string, zoneName?: string) {
220
+ if (!resolvedStructure) return;
221
+ applyFieldChange(content => insertFieldContent(content, resolvedStructure!, fieldName, zoneName));
222
+ }
223
+
224
+ function handleRemoveField(fieldName: string, zoneName?: string) {
225
+ if (!resolvedStructure) return;
226
+ applyFieldChange(content => removeFieldContent(content, resolvedStructure!, fieldName, zoneName));
227
+ }
228
+
229
+ function findResolvedField(fieldName: string, zoneName?: string): ResolvedField | null {
230
+ if (!resolvedStructure) return null;
231
+ if (resolvedStructure.type === 'sequence') {
232
+ return resolvedStructure.fields.find(f => f.name === fieldName) ?? null;
233
+ }
234
+ if (resolvedStructure.type === 'delimited' && zoneName) {
235
+ const zone = resolvedStructure.zones.find(z => z.name === zoneName);
236
+ return zone?.fields.find(f => f.name === fieldName) ?? null;
107
237
  }
238
+ return null;
108
239
  }
109
240
 
110
- function handleTreeKeydown(e: KeyboardEvent) {
111
- if (e.key === 'Escape' && showTreeDropdown) {
112
- showTreeDropdown = false;
113
- e.stopPropagation();
241
+ function isGreedyItemField(field: ResolvedField): boolean {
242
+ return field.greedy && field.match !== 'any';
243
+ }
244
+
245
+ function handleAppendItem(fieldName: string, zoneName?: string) {
246
+ if (!resolvedStructure) return;
247
+ const field = findResolvedField(fieldName, zoneName);
248
+ if (field && isGreedyItemField(field)) {
249
+ applyFieldChange(content => appendGreedyItem(content, resolvedStructure!, fieldName, zoneName));
250
+ } else {
251
+ applyFieldChange(content => appendListItem(content, resolvedStructure!, fieldName, zoneName));
114
252
  }
115
253
  }
116
254
 
255
+ function handleRemoveListItem(fieldName: string, itemIndex: number, zoneName?: string) {
256
+ if (!resolvedStructure) return;
257
+ const field = findResolvedField(fieldName, zoneName);
258
+ if (field && isGreedyItemField(field)) {
259
+ applyFieldChange(content => removeGreedyItem(content, resolvedStructure!, fieldName, itemIndex, zoneName));
260
+ } else {
261
+ applyFieldChange(content => removeListItem(content, resolvedStructure!, fieldName, itemIndex, zoneName));
262
+ }
263
+ }
264
+
265
+ function handleReorderListItem(fieldName: string, fromIndex: number, toIndex: number, zoneName?: string) {
266
+ if (!resolvedStructure) return;
267
+ const field = findResolvedField(fieldName, zoneName);
268
+ if (field && isGreedyItemField(field)) {
269
+ applyFieldChange(content => reorderGreedyItem(content, resolvedStructure!, fieldName, fromIndex, toIndex, zoneName));
270
+ } else {
271
+ applyFieldChange(content => reorderListItem(content, resolvedStructure!, fieldName, fromIndex, toIndex, zoneName));
272
+ }
273
+ }
274
+
275
+ function handleEditField(fieldName: string, rect: DOMRect, zoneName?: string, nodeIndex?: number) {
276
+ if (!resolvedStructure) return;
277
+ // Find the field in the resolved structure
278
+ let field;
279
+ if (resolvedStructure.type === 'sequence') {
280
+ field = resolvedStructure.fields.find(f => f.name === fieldName);
281
+ } else if (resolvedStructure.type === 'delimited' && zoneName) {
282
+ const zone = resolvedStructure.zones.find(z => z.name === zoneName);
283
+ field = zone?.fields.find(f => f.name === fieldName);
284
+ }
285
+ if (!field || !field.filled) return;
286
+
287
+ // Pick the target node — use nodeIndex for greedy fields, default to single-node fields
288
+ const targetIndex = nodeIndex ?? 0;
289
+ if (targetIndex >= field.nodes.length) return;
290
+ if (nodeIndex === undefined && field.nodes.length !== 1) return;
291
+
292
+ const targetNode = field.nodes[targetIndex];
293
+ const source = targetNode.source;
294
+
295
+ // Fence nodes → open code editor instead of inline text editor
296
+ if (targetNode.type === 'fence' && oneditcode) {
297
+ const code = targetNode.fenceCode ?? '';
298
+ const language = targetNode.fenceLanguage ?? '';
299
+ const opener = source.split('\n')[0];
300
+ const delimMatch = opener.match(/^(`{3,}|~{3,})/);
301
+ const delimiter = delimMatch ? delimMatch[1] : '```';
302
+ const mapping: CommandMapping = { source, code, language, opener, delimiter };
303
+ oneditcode(code, language, rect, mapping);
304
+ return;
305
+ }
306
+
307
+ if (!oneditfield) return;
308
+
309
+ const trimmed = source.trim();
310
+
311
+ // Strip markdown prefix (heading markers, blockquote markers)
312
+ let prefix = '';
313
+ let inlineContent = trimmed;
314
+ const headingMatch = trimmed.match(/^(#{1,6}\s+)(.*)/);
315
+ if (headingMatch) {
316
+ prefix = headingMatch[1];
317
+ inlineContent = headingMatch[2];
318
+ } else {
319
+ const quoteMatch = trimmed.match(/^(>\s*)(.*)/);
320
+ if (quoteMatch) {
321
+ prefix = quoteMatch[1];
322
+ inlineContent = quoteMatch[2];
323
+ }
324
+ }
325
+
326
+ const mapping: SectionMapping = {
327
+ dataName: fieldName,
328
+ text: stripInlineMarkdown(inlineContent),
329
+ source,
330
+ sourcePrefix: prefix,
331
+ inlineSource: inlineContent,
332
+ };
333
+ oneditfield(fieldName, inlineContent, rect, mapping);
334
+ }
335
+
336
+ function handleEditListItem(fieldName: string, itemIndex: number, rect: DOMRect, zoneName?: string) {
337
+ if (!resolvedStructure || !oneditfield) return;
338
+ // Find the field in the resolved structure
339
+ let field;
340
+ if (resolvedStructure.type === 'sequence') {
341
+ field = resolvedStructure.fields.find(f => f.name === fieldName);
342
+ } else if (resolvedStructure.type === 'delimited' && zoneName) {
343
+ const zone = resolvedStructure.zones.find(z => z.name === zoneName);
344
+ field = zone?.fields.find(f => f.name === fieldName);
345
+ }
346
+ if (!field || !field.filled || field.nodes.length === 0) return;
347
+
348
+ const listNode = field.nodes[0];
349
+ const items = splitListItems(listNode.source);
350
+ if (itemIndex < 0 || itemIndex >= items.length) return;
351
+
352
+ const itemSource = items[itemIndex];
353
+ // Strip the list marker to get inline content
354
+ const markerMatch = itemSource.match(/^([-*+]\s+|\d+\.\s+)/);
355
+ const prefix = markerMatch ? markerMatch[1] : '';
356
+ const inlineContent = markerMatch ? itemSource.slice(prefix.length) : itemSource;
357
+
358
+ const mapping: SectionMapping = {
359
+ dataName: `${fieldName}[${itemIndex}]`,
360
+ text: stripInlineMarkdown(inlineContent),
361
+ source: itemSource,
362
+ sourcePrefix: prefix,
363
+ inlineSource: inlineContent,
364
+ };
365
+ oneditfield(`${fieldName}[${itemIndex}]`, inlineContent, rect, mapping);
366
+ }
367
+
368
+ function handleNavigateRune(fieldName: string, nodeIndex: number, zoneName?: string) {
369
+ if (!resolvedStructure) return;
370
+
371
+ // Find the field
372
+ let field;
373
+ if (resolvedStructure.type === 'sequence') {
374
+ field = resolvedStructure.fields.find(f => f.name === fieldName);
375
+ } else if (resolvedStructure.type === 'delimited' && zoneName) {
376
+ const zone = resolvedStructure.zones.find(z => z.name === zoneName);
377
+ field = zone?.fields.find(f => f.name === fieldName);
378
+ }
379
+ if (!field || nodeIndex < 0 || nodeIndex >= field.nodes.length) return;
380
+
381
+ const targetNode = field.nodes[nodeIndex];
382
+ if (targetNode.type !== 'rune') return;
383
+
384
+ // Find this node's index in the effectiveContentTree
385
+ const tree = effectiveContentTree;
386
+ const treeIndex = tree.findIndex(n => n.source === targetNode.source);
387
+ if (treeIndex === -1) return;
388
+
389
+ // Navigate to the nested rune
390
+ if (activeNode?.type === 'rune') {
391
+ activePath = [...activePath, treeIndex];
392
+ } else {
393
+ activePath = [treeIndex];
394
+ }
395
+
396
+ // Switch to settings tab to show the nested rune's settings
397
+ activeTab = 'settings';
398
+ }
399
+
117
400
  // ── Edit handlers ────────────────────────────────────────────
118
401
 
119
402
  function handleHeadingTextChange(text: string) {
@@ -241,27 +524,13 @@
241
524
 
242
525
  </script>
243
526
 
244
- <svelte:window onmousedown={handleTreeClickOutside} onkeydown={handleTreeKeydown} />
245
-
246
527
  <div class="edit-panel">
247
- <div class="edit-panel__header-wrap" bind:this={treeDropdownEl}>
528
+ <div class="edit-panel__top">
248
529
  <div class="edit-panel__header">
249
530
  <span class="edit-panel__type">{headerLabel}</span>
250
531
  {#if !activeIsContent && category}
251
532
  <span class="edit-panel__category">{category}</span>
252
533
  {/if}
253
- {#if hasNestedRunes}
254
- <button
255
- class="edit-panel__btn"
256
- class:active={showTreeDropdown}
257
- onclick={toggleTreeDropdown}
258
- title="Content tree"
259
- >
260
- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
261
- <path d="M2 3h4M2 7h4M6 11h4M6 15h4M4 3v8M8 11v4" />
262
- </svg>
263
- </button>
264
- {/if}
265
534
  <div class="edit-panel__spacer"></div>
266
535
  <button
267
536
  class="edit-panel__btn edit-panel__btn--danger"
@@ -283,176 +552,217 @@
283
552
  >&times;</button>
284
553
  </div>
285
554
 
286
- {#if showTreeDropdown && hasNestedRunes && block.type === 'rune'}
287
- {@const rb = block as RuneBlock}
288
- <div class="edit-panel__tree-dropdown">
289
- <ContentTree
290
- nodes={contentTree}
291
- {activePath}
292
- onselect={handleTreeSelect}
293
- rootLabel={rb.runeName}
294
- onrootclick={navigateToRoot}
295
- isRootActive={activePath.length === 0}
296
- />
555
+ {#if block.type === 'rune' && availableTabs.length > 1}
556
+ <div class="edit-panel__tabs">
557
+ {#each availableTabs as tab}
558
+ <button
559
+ type="button"
560
+ class="edit-panel__tab"
561
+ class:active={activeTab === tab}
562
+ onclick={() => activeTab = tab}
563
+ >
564
+ {#if tab === 'settings'}
565
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
566
+ <circle cx="8" cy="8" r="2" />
567
+ <path d="M6.7 1.6h2.6l.4 1.8.8.4 1.7-.7 1.8 1.8-.7 1.7.4.8 1.8.4v2.6l-1.8.4-.4.8.7 1.7-1.8 1.8-1.7-.7-.8.4-.4 1.8H6.7l-.4-1.8-.8-.4-1.7.7-1.8-1.8.7-1.7-.4-.8-1.8-.4V6.7l1.8-.4.4-.8-.7-1.7 1.8-1.8 1.7.7.8-.4z" />
568
+ </svg>
569
+ Settings
570
+ {:else if tab === 'structure'}
571
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
572
+ <path d="M2 3h4M2 7h4M6 11h4M6 15h4M4 3v8M8 11v4" />
573
+ </svg>
574
+ Structure
575
+ {:else if tab === 'content'}
576
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
577
+ <path d="M2 4h12M2 8h12M2 12h8" />
578
+ </svg>
579
+ Content
580
+ {/if}
581
+ </button>
582
+ {/each}
297
583
  </div>
298
584
  {/if}
299
585
  </div>
300
586
 
301
- <div class="edit-panel__body">
302
- {#if block.type === 'heading'}
303
- {@const hb = block as HeadingBlock}
304
- <div class="edit-panel__field-group">
305
- <label class="edit-panel__field">
306
- <span class="edit-panel__field-label">Level</span>
307
- <select
308
- class="edit-panel__select"
309
- value={String(hb.level)}
310
- onchange={(e) => handleHeadingLevelChange(Number((e.target as HTMLSelectElement).value))}
311
- >
312
- <option value="1">H1</option>
313
- <option value="2">H2</option>
314
- <option value="3">H3</option>
315
- <option value="4">H4</option>
316
- <option value="5">H5</option>
317
- <option value="6">H6</option>
318
- </select>
319
- </label>
320
- <label class="edit-panel__field">
321
- <span class="edit-panel__field-label">Text</span>
322
- <input
323
- class="edit-panel__input"
324
- type="text"
325
- value={hb.text}
326
- oninput={(e) => handleHeadingTextChange((e.target as HTMLInputElement).value)}
587
+ {#if block.type === 'rune'}
588
+ {@const rb = block as RuneBlock}
589
+
590
+ <!-- Settings tab -->
591
+ {#if activeTab === 'settings'}
592
+ <div class="edit-panel__tab-panel">
593
+ {#if activeIsContent && activeNode}
594
+ {#if activeNode.type === 'heading'}
595
+ <div class="edit-panel__field-group">
596
+ <label class="edit-panel__field">
597
+ <span class="edit-panel__field-label">Level</span>
598
+ <select
599
+ class="edit-panel__select"
600
+ value={String(activeNode.headingLevel ?? 1)}
601
+ onchange={(e) => handleNestedHeadingLevelChange(Number((e.target as HTMLSelectElement).value))}
602
+ >
603
+ <option value="1">H1</option>
604
+ <option value="2">H2</option>
605
+ <option value="3">H3</option>
606
+ <option value="4">H4</option>
607
+ <option value="5">H5</option>
608
+ <option value="6">H6</option>
609
+ </select>
610
+ </label>
611
+ <label class="edit-panel__field">
612
+ <span class="edit-panel__field-label">Text</span>
613
+ <input
614
+ class="edit-panel__input"
615
+ type="text"
616
+ value={activeNode.headingText ?? ''}
617
+ oninput={(e) => handleNestedHeadingTextChange((e.target as HTMLInputElement).value)}
618
+ />
619
+ </label>
620
+ </div>
621
+ {:else if activeNode.type === 'fence'}
622
+ <div class="edit-panel__field-group">
623
+ <label class="edit-panel__field">
624
+ <span class="edit-panel__field-label">Language</span>
625
+ <input
626
+ class="edit-panel__input"
627
+ type="text"
628
+ value={activeNode.fenceLanguage ?? ''}
629
+ oninput={(e) => handleNestedFenceLangChange((e.target as HTMLInputElement).value)}
630
+ placeholder="e.g. js, python, html"
631
+ />
632
+ </label>
633
+ </div>
634
+ {:else}
635
+ <div class="edit-panel__empty-tab">
636
+ <span class="edit-panel__empty-tab-text">No settings for this element</span>
637
+ </div>
638
+ {/if}
639
+
640
+ {:else if activeNode && activeRuneInfo}
641
+ <RuneAttributes
642
+ runeInfo={activeRuneInfo}
643
+ attributes={activeNode.attributes ?? {}}
644
+ onchange={handleNestedAttrsChange}
327
645
  />
328
- </label>
329
- </div>
330
646
 
331
- {:else if block.type === 'rune'}
332
- {@const rb = block as RuneBlock}
333
-
334
- <!-- Editor for the active node -->
335
- {#if activeIsContent && activeNode}
336
- <!-- Type-specific editors for content nodes -->
337
- {#if activeNode.type === 'heading'}
338
- <div class="edit-panel__field-group">
339
- <label class="edit-panel__field">
340
- <span class="edit-panel__field-label">Level</span>
341
- <select
342
- class="edit-panel__select"
343
- value={String(activeNode.headingLevel ?? 1)}
344
- onchange={(e) => handleNestedHeadingLevelChange(Number((e.target as HTMLSelectElement).value))}
345
- >
346
- <option value="1">H1</option>
347
- <option value="2">H2</option>
348
- <option value="3">H3</option>
349
- <option value="4">H4</option>
350
- <option value="5">H5</option>
351
- <option value="6">H6</option>
352
- </select>
353
- </label>
354
- <label class="edit-panel__field">
355
- <span class="edit-panel__field-label">Text</span>
356
- <input
357
- class="edit-panel__input"
358
- type="text"
359
- value={activeNode.headingText ?? ''}
360
- oninput={(e) => handleNestedHeadingTextChange((e.target as HTMLInputElement).value)}
361
- />
362
- </label>
363
- </div>
364
- {:else if activeNode.type === 'fence'}
365
- <div class="edit-panel__field-group">
366
- <label class="edit-panel__field">
367
- <span class="edit-panel__field-label">Language</span>
368
- <input
369
- class="edit-panel__input"
370
- type="text"
371
- value={activeNode.fenceLanguage ?? ''}
372
- oninput={(e) => handleNestedFenceLangChange((e.target as HTMLInputElement).value)}
373
- placeholder="e.g. js, python, html"
374
- />
375
- </label>
376
- </div>
377
- <div class="edit-panel__content-editor">
378
- <InlineEditor
379
- content={activeNode.fenceCode ?? ''}
380
- onchange={handleNestedFenceCodeChange}
381
- language={activeNode.fenceLanguage}
382
- {runes}
383
- aggregated={() => aggregated}
384
- />
385
- </div>
386
- {:else if activeNode.type === 'paragraph'}
387
- <div class="edit-panel__content-editor">
388
- <InlineEditor
389
- content={activeNode.source}
390
- onchange={handleNestedSourceChange}
391
- {runes}
392
- aggregated={() => aggregated}
647
+ {:else if !activeNode}
648
+ {#if runeInfo}
649
+ <RuneAttributes
650
+ {runeInfo}
651
+ attributes={rb.attributes}
652
+ onchange={handleRuneAttrsChange}
393
653
  />
394
- </div>
395
- {:else}
396
- <!-- List, quote, image, hr — raw source editing -->
397
- <div class="edit-panel__field-group">
398
- <label class="edit-panel__field">
399
- <span class="edit-panel__field-label">Source</span>
400
- <textarea
401
- class="edit-panel__textarea"
402
- value={activeNode.source}
403
- oninput={(e) => handleNestedSourceChange((e.target as HTMLTextAreaElement).value)}
404
- rows={Math.max(4, activeNode.source.split('\n').length)}
405
- ></textarea>
406
- </label>
407
- </div>
654
+ {:else}
655
+ <div class="edit-panel__unknown">
656
+ <span class="edit-panel__unknown-label">Unknown rune: {rb.runeName}</span>
657
+ {#each Object.entries(rb.attributes) as [key, val]}
658
+ <label class="edit-panel__field">
659
+ <span class="edit-panel__field-label">{key}</span>
660
+ <input
661
+ class="edit-panel__input"
662
+ type="text"
663
+ value={val}
664
+ oninput={(e) => {
665
+ const next = { ...rb.attributes, [key]: (e.target as HTMLInputElement).value };
666
+ handleRuneAttrsChange(next);
667
+ }}
668
+ />
669
+ </label>
670
+ {/each}
671
+ </div>
672
+ {/if}
408
673
  {/if}
674
+ </div>
675
+ {/if}
409
676
 
410
- {:else if activeNode && activeRuneInfo}
411
- <!-- Nested rune: attributes + content -->
412
- <RuneAttributes
413
- runeInfo={activeRuneInfo}
414
- attributes={activeNode.attributes ?? {}}
415
- onchange={handleNestedAttrsChange}
416
- />
417
- {#if !activeNode.selfClosing && activeNode.innerContent !== undefined}
418
- <div class="edit-panel__content-editor">
419
- <InlineEditor
420
- content={activeNode.innerContent}
421
- onchange={handleNestedContentChange}
422
- {runes}
423
- aggregated={() => aggregated}
424
- />
425
- </div>
677
+ <!-- Structure tab -->
678
+ {#if activeTab === 'structure'}
679
+ <div class="edit-panel__tab-panel">
680
+ {#if hasContentModel && resolvedStructure}
681
+ <ContentModelTree
682
+ structure={resolvedStructure}
683
+ rootLabel={activeNode?.runeName ?? rb.runeName}
684
+ onaddfield={handleAddField}
685
+ onremovefield={handleRemoveField}
686
+ onappenditem={handleAppendItem}
687
+ onremovelistitem={handleRemoveListItem}
688
+ onreorderlistitem={handleReorderListItem}
689
+ oneditfield={handleEditField}
690
+ oneditlistitem={handleEditListItem}
691
+ onnavigaterune={handleNavigateRune}
692
+ onfieldselect={handleFieldSelect}
693
+ {selectedField}
694
+ />
695
+ {:else if !activeNode && hasNestedRunes}
696
+ <ContentTree
697
+ nodes={contentTree}
698
+ {activePath}
699
+ onselect={handleTreeSelect}
700
+ rootLabel={rb.runeName}
701
+ onrootclick={navigateToRoot}
702
+ isRootActive={activePath.length === 0}
703
+ />
426
704
  {/if}
705
+ </div>
706
+ {/if}
427
707
 
428
- {:else if !activeNode}
429
- <!-- Root rune: attributes + content -->
430
- {#if runeInfo}
431
- <RuneAttributes
432
- {runeInfo}
433
- attributes={rb.attributes}
434
- onchange={handleRuneAttrsChange}
435
- />
436
- {:else}
437
- <div class="edit-panel__unknown">
438
- <span class="edit-panel__unknown-label">Unknown rune: {rb.runeName}</span>
439
- {#each Object.entries(rb.attributes) as [key, val]}
708
+ <!-- Content tab -->
709
+ {#if activeTab === 'content' && !rb.selfClosing}
710
+ <div class="edit-panel__tab-panel">
711
+ {#if activeIsContent && activeNode}
712
+ {#if activeNode.type === 'fence'}
713
+ <div class="edit-panel__content-editor">
714
+ <InlineEditor
715
+ content={activeNode.fenceCode ?? ''}
716
+ onchange={handleNestedFenceCodeChange}
717
+ language={activeNode.fenceLanguage}
718
+ {runes}
719
+ aggregated={() => aggregated}
720
+ />
721
+ </div>
722
+ {:else if activeNode.type === 'paragraph'}
723
+ <div class="edit-panel__content-editor">
724
+ <InlineEditor
725
+ content={activeNode.source}
726
+ onchange={handleNestedSourceChange}
727
+ {runes}
728
+ aggregated={() => aggregated}
729
+ />
730
+ </div>
731
+ {:else if activeNode.type === 'heading'}
732
+ <div class="edit-panel__empty-tab">
733
+ <span class="edit-panel__empty-tab-text">Edit heading text in Settings</span>
734
+ </div>
735
+ {:else}
736
+ <div class="edit-panel__field-group">
440
737
  <label class="edit-panel__field">
441
- <span class="edit-panel__field-label">{key}</span>
442
- <input
443
- class="edit-panel__input"
444
- type="text"
445
- value={val}
446
- oninput={(e) => {
447
- const next = { ...rb.attributes, [key]: (e.target as HTMLInputElement).value };
448
- handleRuneAttrsChange(next);
449
- }}
450
- />
738
+ <span class="edit-panel__field-label">Source</span>
739
+ <textarea
740
+ class="edit-panel__textarea"
741
+ value={activeNode.source}
742
+ oninput={(e) => handleNestedSourceChange((e.target as HTMLTextAreaElement).value)}
743
+ rows={Math.max(4, activeNode.source.split('\n').length)}
744
+ ></textarea>
451
745
  </label>
452
- {/each}
453
- </div>
454
- {/if}
455
- {#if !rb.selfClosing}
746
+ </div>
747
+ {/if}
748
+
749
+ {:else if activeNode && activeRuneInfo}
750
+ {#if !activeNode.selfClosing && activeNode.innerContent !== undefined}
751
+ <div class="edit-panel__content-editor">
752
+ <InlineEditor
753
+ content={activeNode.innerContent}
754
+ onchange={handleNestedContentChange}
755
+ {runes}
756
+ aggregated={() => aggregated}
757
+ />
758
+ </div>
759
+ {:else}
760
+ <div class="edit-panel__empty-tab">
761
+ <span class="edit-panel__empty-tab-text">This rune has no inner content</span>
762
+ </div>
763
+ {/if}
764
+
765
+ {:else if !activeNode}
456
766
  <div class="edit-panel__content-editor">
457
767
  <InlineEditor
458
768
  content={rb.innerContent}
@@ -462,64 +772,104 @@
462
772
  />
463
773
  </div>
464
774
  {/if}
465
- {/if}
466
-
467
- {:else if block.type === 'fence'}
468
- {@const fb = block as FenceBlock}
469
- <div class="edit-panel__field-group">
470
- <label class="edit-panel__field">
471
- <span class="edit-panel__field-label">Language</span>
472
- <input
473
- class="edit-panel__input"
474
- type="text"
475
- value={fb.language}
476
- oninput={(e) => handleFenceLangChange((e.target as HTMLInputElement).value)}
477
- placeholder="e.g. js, python, html"
478
- />
479
- </label>
480
- </div>
481
- <div class="edit-panel__content-editor">
482
- <InlineEditor
483
- content={fb.code}
484
- onchange={handleFenceCodeChange}
485
- language={fb.language}
486
- {runes}
487
- />
488
- aggregated={() => aggregated}
489
- </div>
490
-
491
- {:else if block.type === 'paragraph'}
492
- <div class="edit-panel__content-editor">
493
- <InlineEditor
494
- content={block.source}
495
- onchange={handleSourceChange}
496
- {runes}
497
- />
498
- aggregated={() => aggregated}
499
- </div>
500
-
501
- {:else}
502
- <!-- List, quote, image, etc. — raw source editing -->
503
- <div class="edit-panel__field-group">
504
- <label class="edit-panel__field">
505
- <span class="edit-panel__field-label">Source</span>
506
- <textarea
507
- class="edit-panel__textarea"
508
- value={block.source}
509
- oninput={(e) => handleSourceChange((e.target as HTMLTextAreaElement).value)}
510
- rows={Math.max(4, block.source.split('\n').length)}
511
- ></textarea>
512
- </label>
513
775
  </div>
514
776
  {/if}
515
- </div>
777
+
778
+ {:else}
779
+ <div class="edit-panel__body">
780
+ {#if block.type === 'heading'}
781
+ {@const hb = block as HeadingBlock}
782
+ <div class="edit-panel__field-group">
783
+ <label class="edit-panel__field">
784
+ <span class="edit-panel__field-label">Level</span>
785
+ <select
786
+ class="edit-panel__select"
787
+ value={String(hb.level)}
788
+ onchange={(e) => handleHeadingLevelChange(Number((e.target as HTMLSelectElement).value))}
789
+ >
790
+ <option value="1">H1</option>
791
+ <option value="2">H2</option>
792
+ <option value="3">H3</option>
793
+ <option value="4">H4</option>
794
+ <option value="5">H5</option>
795
+ <option value="6">H6</option>
796
+ </select>
797
+ </label>
798
+ <label class="edit-panel__field">
799
+ <span class="edit-panel__field-label">Text</span>
800
+ <input
801
+ class="edit-panel__input"
802
+ type="text"
803
+ value={hb.text}
804
+ oninput={(e) => handleHeadingTextChange((e.target as HTMLInputElement).value)}
805
+ />
806
+ </label>
807
+ </div>
808
+
809
+ {:else if block.type === 'fence'}
810
+ {@const fb = block as FenceBlock}
811
+ <div class="edit-panel__field-group">
812
+ <label class="edit-panel__field">
813
+ <span class="edit-panel__field-label">Language</span>
814
+ <input
815
+ class="edit-panel__input"
816
+ type="text"
817
+ value={fb.language}
818
+ oninput={(e) => handleFenceLangChange((e.target as HTMLInputElement).value)}
819
+ placeholder="e.g. js, python, html"
820
+ />
821
+ </label>
822
+ </div>
823
+ <div class="edit-panel__content-editor">
824
+ <InlineEditor
825
+ content={fb.code}
826
+ onchange={handleFenceCodeChange}
827
+ language={fb.language}
828
+ {runes}
829
+ aggregated={() => aggregated}
830
+ />
831
+ </div>
832
+
833
+ {:else if block.type === 'paragraph'}
834
+ <div class="edit-panel__content-editor">
835
+ <InlineEditor
836
+ content={block.source}
837
+ onchange={handleSourceChange}
838
+ {runes}
839
+ aggregated={() => aggregated}
840
+ />
841
+ </div>
842
+
843
+ {:else}
844
+ <!-- List, quote, image, etc. — raw source editing -->
845
+ <div class="edit-panel__field-group">
846
+ <label class="edit-panel__field">
847
+ <span class="edit-panel__field-label">Source</span>
848
+ <textarea
849
+ class="edit-panel__textarea"
850
+ value={block.source}
851
+ oninput={(e) => handleSourceChange((e.target as HTMLTextAreaElement).value)}
852
+ rows={Math.max(4, block.source.split('\n').length)}
853
+ ></textarea>
854
+ </label>
855
+ </div>
856
+ {/if}
857
+ </div>
858
+ {/if}
516
859
  </div>
517
860
 
518
861
  <style>
519
862
  .edit-panel {
520
863
  display: flex;
521
864
  flex-direction: column;
522
- height: 100%;
865
+ flex: 1;
866
+ min-height: 0;
867
+ }
868
+
869
+ .edit-panel__top {
870
+ flex-shrink: 0;
871
+ background: var(--ed-surface-0);
872
+ border-bottom: 1px solid var(--ed-border-default);
523
873
  }
524
874
 
525
875
  .edit-panel__header {
@@ -527,11 +877,6 @@
527
877
  align-items: center;
528
878
  gap: 0.5rem;
529
879
  padding: var(--ed-space-4) var(--ed-space-5);
530
- border-bottom: 1px solid var(--ed-border-default);
531
- background: transparent;
532
- position: sticky;
533
- top: 0;
534
- z-index: 1;
535
880
  }
536
881
 
537
882
  .edit-panel__type {
@@ -672,9 +1017,10 @@
672
1017
  .edit-panel__content-editor {
673
1018
  display: flex;
674
1019
  flex-direction: column;
1020
+ flex-shrink: 0;
675
1021
  overflow: hidden;
676
- margin-left: calc(-1 * var(--ed-space-4));
677
- margin-right: calc(-1 * var(--ed-space-4));
1022
+ margin-left: calc(-1 * var(--ed-space-5));
1023
+ margin-right: calc(-1 * var(--ed-space-5));
678
1024
  border-top: 1px solid var(--ed-border-subtle);
679
1025
  }
680
1026
 
@@ -690,30 +1036,64 @@
690
1036
  font-style: italic;
691
1037
  }
692
1038
 
693
- /* Header wrap (contains header + tree dropdown) */
694
- .edit-panel__header-wrap {
695
- position: relative;
696
- flex-shrink: 0;
1039
+ /* Tab strip */
1040
+ .edit-panel__tabs {
1041
+ display: flex;
1042
+ gap: 2px;
1043
+ background: var(--ed-surface-2);
1044
+ border-radius: var(--ed-radius-sm);
1045
+ padding: 2px;
1046
+ margin: 0 var(--ed-space-4) var(--ed-space-3);
1047
+ }
1048
+
1049
+ .edit-panel__tab {
1050
+ flex: 1;
1051
+ display: flex;
1052
+ align-items: center;
1053
+ justify-content: center;
1054
+ gap: 0.35rem;
1055
+ padding: 0.35rem 0.5rem;
1056
+ border: none;
1057
+ background: transparent;
1058
+ color: var(--ed-text-muted);
1059
+ font-size: var(--ed-text-sm);
1060
+ font-weight: 500;
1061
+ cursor: pointer;
1062
+ border-radius: calc(var(--ed-radius-sm) - 1px);
1063
+ transition: background var(--ed-transition-fast), color var(--ed-transition-fast);
697
1064
  }
698
1065
 
699
- /* Tree button active state */
700
- .edit-panel__btn.active {
701
- color: var(--ed-accent);
702
- background: var(--ed-accent-muted);
1066
+ .edit-panel__tab:hover {
1067
+ color: var(--ed-text-secondary);
703
1068
  }
704
1069
 
705
- /* Content tree dropdown */
706
- .edit-panel__tree-dropdown {
707
- padding: 0.5rem;
708
- border-bottom: 1px solid var(--ed-border-default);
1070
+ .edit-panel__tab.active {
709
1071
  background: var(--ed-surface-0);
710
- max-height: 300px;
1072
+ color: var(--ed-text-primary);
1073
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
1074
+ }
1075
+
1076
+ /* Tab panels */
1077
+ .edit-panel__tab-panel {
1078
+ flex: 1;
711
1079
  overflow-y: auto;
712
- animation: tree-dropdown-enter 0.1s ease-out;
1080
+ padding: var(--ed-space-5);
1081
+ display: flex;
1082
+ flex-direction: column;
1083
+ gap: var(--ed-space-5);
713
1084
  }
714
1085
 
715
- @keyframes tree-dropdown-enter {
716
- from { opacity: 0; transform: translateY(-4px); }
717
- to { opacity: 1; transform: translateY(0); }
1086
+ /* Empty tab state */
1087
+ .edit-panel__empty-tab {
1088
+ display: flex;
1089
+ align-items: center;
1090
+ justify-content: center;
1091
+ padding: var(--ed-space-8) var(--ed-space-4);
1092
+ }
1093
+
1094
+ .edit-panel__empty-tab-text {
1095
+ font-size: var(--ed-text-sm);
1096
+ color: var(--ed-text-muted);
1097
+ font-style: italic;
718
1098
  }
719
1099
  </style>