@refrakt-md/editor 0.7.2 → 0.8.1

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 (50) hide show
  1. package/app/dist/assets/{index-4SP4_AaD.js → index-BBinZAiy.js} +1 -1
  2. package/app/dist/assets/index-BD2EBUrQ.css +1 -0
  3. package/app/dist/assets/{index-D77rckeh.js → index-BLuaHLN3.js} +1 -1
  4. package/app/dist/assets/{index-30gAspk8.js → index-BgCNqcSo.js} +1 -1
  5. package/app/dist/assets/index-BlAOhWAQ.js +453 -0
  6. package/app/dist/assets/{index-BZ4adnS0.js → index-BwFn9q4x.js} +1 -1
  7. package/app/dist/assets/{index-DFkteo0w.js → index-C72UC2ga.js} +1 -1
  8. package/app/dist/assets/{index-x67KGOIr.js → index-COIPZ34u.js} +1 -1
  9. package/app/dist/assets/{index-BEFUVB_B.js → index-CW02bulk.js} +1 -1
  10. package/app/dist/assets/{index-CI5PewQM.js → index-CXFMPmtf.js} +1 -1
  11. package/app/dist/assets/{index-ByHhigzw.js → index-CeU_s7BB.js} +1 -1
  12. package/app/dist/assets/{index-DvgOtlru.js → index-CqHjo2YT.js} +1 -1
  13. package/app/dist/assets/{index-DKnhR16N.js → index-D3TQo8gu.js} +1 -1
  14. package/app/dist/assets/{index-Baf7ZSct.js → index-DVM3uoxc.js} +1 -1
  15. package/app/dist/assets/{index-C9w1RpYY.js → index-DW2zI-Ss.js} +1 -1
  16. package/app/dist/assets/{index--rGC9bba.js → index-D_Y6J00B.js} +1 -1
  17. package/app/dist/assets/{index-kPhFxtn-.js → index-DgIg-QAA.js} +2 -2
  18. package/app/dist/assets/{index-DIuFNfTc.js → index-DmY6uqAw.js} +1 -1
  19. package/app/dist/assets/{index-D1WOi3EN.js → index-DzHt8ZRh.js} +1 -1
  20. package/app/dist/assets/{index-BwWzfQVn.js → index-ZLvRNfLb.js} +1 -1
  21. package/app/dist/index.html +2 -2
  22. package/app/src/lib/api/client.ts +49 -0
  23. package/app/src/lib/components/ActionEditPopover.svelte +245 -0
  24. package/app/src/lib/components/BlockCard.svelte +255 -1
  25. package/app/src/lib/components/BlockEditPanel.svelte +697 -138
  26. package/app/src/lib/components/BlockEditor.svelte +467 -389
  27. package/app/src/lib/components/CodeEditPopover.svelte +226 -0
  28. package/app/src/lib/components/ContentModelTree.svelte +562 -0
  29. package/app/src/lib/components/ContentTree.svelte +181 -0
  30. package/app/src/lib/components/EditorLayout.svelte +1 -6
  31. package/app/src/lib/components/FrontmatterEditPanel.svelte +0 -1
  32. package/app/src/lib/components/HeaderBar.svelte +38 -0
  33. package/app/src/lib/components/InlineEditPopover.svelte +593 -0
  34. package/app/src/lib/components/InsertBlockDialog.svelte +429 -0
  35. package/app/src/lib/components/PageCard.svelte +3 -4
  36. package/app/src/lib/components/PreviewPane.svelte +19 -1
  37. package/app/src/lib/components/RuneAttributes.svelte +249 -100
  38. package/app/src/lib/editor/block-parser.ts +463 -0
  39. package/app/src/lib/preview/block-renderer.ts +30 -14
  40. package/dist/community-tags-builder.d.ts.map +1 -1
  41. package/dist/community-tags-builder.js +5 -1
  42. package/dist/community-tags-builder.js.map +1 -1
  43. package/dist/server.d.ts +1 -0
  44. package/dist/server.d.ts.map +1 -1
  45. package/dist/server.js +92 -6
  46. package/dist/server.js.map +1 -1
  47. package/package.json +6 -6
  48. package/preview-runtime/App.svelte +2 -0
  49. package/app/dist/assets/index-DlrXwdpb.css +0 -1
  50. package/app/dist/assets/index-GlUHQ_jL.js +0 -324
@@ -5,14 +5,27 @@
5
5
  HeadingBlock,
6
6
  RuneBlock,
7
7
  FenceBlock,
8
+ ContentNode,
8
9
  } from '../editor/block-parser.js';
9
10
  import {
10
11
  rebuildRuneSource,
11
12
  rebuildHeadingSource,
12
13
  rebuildFenceSource,
13
14
  blockLabel,
15
+ parseContentTree,
16
+ serializeAttributes,
17
+ replaceNodeSource,
18
+ insertFieldContent,
19
+ removeFieldContent,
20
+ appendListItem,
21
+ removeListItem,
14
22
  } from '../editor/block-parser.js';
23
+ import { resolveContentStructure } from '../editor/content-model-resolver.js';
24
+ import type { SectionMapping } from '../editor/section-mapper.js';
25
+ import { stripInlineMarkdown } from '../editor/inline-markdown.js';
15
26
  import RuneAttributes from './RuneAttributes.svelte';
27
+ import ContentTree from './ContentTree.svelte';
28
+ import ContentModelTree from './ContentModelTree.svelte';
16
29
  import InlineEditor from './InlineEditor.svelte';
17
30
 
18
31
  interface Props {
@@ -20,12 +33,15 @@
20
33
  runeMap: Map<string, RuneInfo>;
21
34
  runes: () => RuneInfo[];
22
35
  aggregated?: Record<string, unknown>;
36
+ /** When set, auto-navigate to the Nth nested rune (DFS order) on mount */
37
+ initialRuneIndex?: number | null;
23
38
  onupdate: (block: ParsedBlock) => void;
24
39
  onremove: () => void;
25
40
  onclose: () => void;
41
+ oneditfield?: (dataName: string, inlineSource: string, rect: DOMRect, mapping: SectionMapping) => void;
26
42
  }
27
43
 
28
- let { block, runeMap, runes, aggregated = {}, onupdate, onremove, onclose }: Props = $props();
44
+ let { block, runeMap, runes, aggregated = {}, initialRuneIndex = null, onupdate, onremove, onclose, oneditfield }: Props = $props();
29
45
 
30
46
  let label = $derived(blockLabel(block));
31
47
 
@@ -35,6 +51,225 @@
35
51
 
36
52
  let category = $derived(runeInfo?.category ?? null);
37
53
 
54
+ // ── Nested rune tree ─────────────────────────────────────────
55
+
56
+ /** Path of indices into the content tree for the selected nested rune */
57
+ let activePath: number[] = $state([]);
58
+
59
+ /** Parse inner content into a tree (for rune blocks with content) */
60
+ let contentTree = $derived.by(() => {
61
+ if (block.type !== 'rune') return [];
62
+ const rb = block as RuneBlock;
63
+ if (rb.selfClosing || !rb.innerContent.trim()) return [];
64
+ return parseContentTree(rb.innerContent);
65
+ });
66
+
67
+ /** Whether this rune has any nested rune children worth showing a tree for */
68
+ let hasNestedRunes = $derived(
69
+ contentTree.some(n => n.type === 'rune')
70
+ );
71
+
72
+ /** The effective rune info for the structure tab — nested rune if selected, root otherwise */
73
+ let effectiveRuneInfo = $derived.by(() => {
74
+ if (activeNode?.type === 'rune' && activeRuneInfo) return activeRuneInfo;
75
+ return runeInfo;
76
+ });
77
+
78
+ /** Content tree for the effective rune (nested rune's children or root's) */
79
+ let effectiveContentTree = $derived.by(() => {
80
+ if (activeNode?.type === 'rune' && activeNode.children) return activeNode.children;
81
+ return contentTree;
82
+ });
83
+
84
+ /** Whether the effective rune has a declarative content model */
85
+ let hasContentModel = $derived(effectiveRuneInfo?.contentModel != null);
86
+
87
+ /** Resolved structure: effective content tree matched against effective content model */
88
+ let resolvedStructure = $derived.by(() => {
89
+ if (!effectiveRuneInfo?.contentModel) return null;
90
+ return resolveContentStructure(effectiveContentTree, effectiveRuneInfo.contentModel);
91
+ });
92
+
93
+ /** Currently selected field in the content model tree */
94
+ let selectedField: string | null = $state(null);
95
+
96
+ /** Find the path to the Nth rune node in DFS order */
97
+ function findRunePathByDfsIndex(nodes: ContentNode[], target: number): number[] | null {
98
+ let count = 0;
99
+ function walk(ns: ContentNode[], path: number[]): number[] | null {
100
+ for (let i = 0; i < ns.length; i++) {
101
+ if (ns[i].type === 'rune') {
102
+ if (count === target) return [...path, i];
103
+ count++;
104
+ const found = walk(ns[i].children ?? [], [...path, i]);
105
+ if (found) return found;
106
+ }
107
+ }
108
+ return null;
109
+ }
110
+ return walk(nodes, []);
111
+ }
112
+
113
+ // Auto-navigate to a nested rune when initialRuneIndex is provided
114
+ $effect(() => {
115
+ if (initialRuneIndex != null && contentTree.length > 0) {
116
+ const path = findRunePathByDfsIndex(contentTree, initialRuneIndex);
117
+ if (path) activePath = path;
118
+ }
119
+ });
120
+
121
+ /** Resolve the active nested node from the path */
122
+ function resolveNode(nodes: ContentNode[], path: number[]): ContentNode | null {
123
+ if (path.length === 0) return null;
124
+ const [head, ...rest] = path;
125
+ const node = nodes[head];
126
+ if (!node) return null;
127
+ if (rest.length === 0) return node;
128
+ return resolveNode(node.children ?? [], rest);
129
+ }
130
+
131
+ let activeNode = $derived(resolveNode(contentTree, activePath));
132
+
133
+ let activeRuneInfo = $derived(
134
+ activeNode?.runeName ? runeMap.get(activeNode.runeName) ?? null : null
135
+ );
136
+
137
+ /** Whether the active node is a non-rune content node */
138
+ let activeIsContent = $derived(activeNode != null && activeNode.type !== 'rune');
139
+
140
+ /** Display label for header — show content node type when one is selected */
141
+ let headerLabel = $derived.by(() => {
142
+ if (!activeNode) return label;
143
+ if (activeNode.type === 'rune') return activeNode.runeName ?? label;
144
+ return activeNode.type;
145
+ });
146
+
147
+ type TabId = 'settings' | 'structure' | 'content';
148
+ let activeTab: TabId = $state('settings');
149
+
150
+ let availableTabs = $derived.by(() => {
151
+ if (block.type !== 'rune') return [] as TabId[];
152
+ const rb = block as RuneBlock;
153
+ const tabs: TabId[] = ['settings'];
154
+ // Show structure tab when the effective rune has a content model,
155
+ // or when no nested rune is selected and root has nested runes (legacy tree)
156
+ if (hasContentModel || (!activeNode && hasNestedRunes)) tabs.push('structure');
157
+ if (!rb.selfClosing) tabs.push('content');
158
+ return tabs;
159
+ });
160
+
161
+ // Auto-switch away from structure tab if it becomes unavailable
162
+ $effect(() => {
163
+ if (activeTab === 'structure' && !availableTabs.includes('structure')) {
164
+ activeTab = 'settings';
165
+ }
166
+ });
167
+
168
+ function handleTreeSelect(path: number[]) {
169
+ activePath = path;
170
+ // Auto-switch to Content tab when selecting a content node
171
+ const node = resolveNode(contentTree, path);
172
+ if (node && node.type !== 'rune') {
173
+ activeTab = 'content';
174
+ }
175
+ }
176
+
177
+ function navigateToRoot() {
178
+ activePath = [];
179
+ }
180
+
181
+ // ── Content model field handlers ─────────────────────────────
182
+
183
+ function handleFieldSelect(fieldName: string, zoneName?: string) {
184
+ selectedField = zoneName ? `${zoneName}.${fieldName}` : fieldName;
185
+ }
186
+
187
+ /** Apply a field content change — works for both root and nested runes */
188
+ function applyFieldChange(updater: (content: string) => string) {
189
+ const rb = block as RuneBlock;
190
+ if (activeNode?.type === 'rune' && activeNode.innerContent !== undefined) {
191
+ // Nested rune: update its inner content, then replace in root
192
+ const newNestedInner = updater(activeNode.innerContent);
193
+ if (newNestedInner === activeNode.innerContent) return;
194
+ const attrStr = serializeAttributes(activeNode.attributes ?? {});
195
+ const inner = newNestedInner.trim();
196
+ const newSource = inner
197
+ ? `{% ${activeNode.runeName}${attrStr} %}\n${inner}\n{% /${activeNode.runeName} %}`
198
+ : `{% ${activeNode.runeName}${attrStr} %}\n\n{% /${activeNode.runeName} %}`;
199
+ const newRootInner = replaceNodeSource(rb.innerContent, activeNode.source, newSource);
200
+ const updated: RuneBlock = { ...rb, innerContent: newRootInner, source: '' };
201
+ updated.source = rebuildRuneSource(updated);
202
+ onupdate(updated);
203
+ } else {
204
+ // Root rune
205
+ const newInner = updater(rb.innerContent);
206
+ if (newInner === rb.innerContent) return;
207
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
208
+ updated.source = rebuildRuneSource(updated);
209
+ onupdate(updated);
210
+ }
211
+ }
212
+
213
+ function handleAddField(fieldName: string, zoneName?: string) {
214
+ if (!resolvedStructure) return;
215
+ applyFieldChange(content => insertFieldContent(content, resolvedStructure!, fieldName, zoneName));
216
+ }
217
+
218
+ function handleRemoveField(fieldName: string, zoneName?: string) {
219
+ if (!resolvedStructure) return;
220
+ applyFieldChange(content => removeFieldContent(content, resolvedStructure!, fieldName, zoneName));
221
+ }
222
+
223
+ function handleAppendItem(fieldName: string, zoneName?: string) {
224
+ if (!resolvedStructure) return;
225
+ applyFieldChange(content => appendListItem(content, resolvedStructure!, fieldName, zoneName));
226
+ }
227
+
228
+ function handleRemoveListItem(fieldName: string, itemIndex: number, zoneName?: string) {
229
+ if (!resolvedStructure) return;
230
+ applyFieldChange(content => removeListItem(content, resolvedStructure!, fieldName, itemIndex, zoneName));
231
+ }
232
+
233
+ function handleEditField(fieldName: string, rect: DOMRect, zoneName?: string) {
234
+ if (!resolvedStructure || !oneditfield) return;
235
+ // Find the field in the resolved structure
236
+ let field;
237
+ if (resolvedStructure.type === 'sequence') {
238
+ field = resolvedStructure.fields.find(f => f.name === fieldName);
239
+ } else if (resolvedStructure.type === 'delimited' && zoneName) {
240
+ const zone = resolvedStructure.zones.find(z => z.name === zoneName);
241
+ field = zone?.fields.find(f => f.name === fieldName);
242
+ }
243
+ if (!field || !field.filled || field.nodes.length !== 1) return;
244
+
245
+ const source = field.nodes[0].source;
246
+ const trimmed = source.trim();
247
+
248
+ // Strip markdown prefix (heading markers, blockquote markers)
249
+ let prefix = '';
250
+ let inlineContent = trimmed;
251
+ const headingMatch = trimmed.match(/^(#{1,6}\s+)(.*)/);
252
+ if (headingMatch) {
253
+ prefix = headingMatch[1];
254
+ inlineContent = headingMatch[2];
255
+ } else {
256
+ const quoteMatch = trimmed.match(/^(>\s*)(.*)/);
257
+ if (quoteMatch) {
258
+ prefix = quoteMatch[1];
259
+ inlineContent = quoteMatch[2];
260
+ }
261
+ }
262
+
263
+ const mapping: SectionMapping = {
264
+ dataName: fieldName,
265
+ text: stripInlineMarkdown(inlineContent),
266
+ source,
267
+ sourcePrefix: prefix,
268
+ inlineSource: inlineContent,
269
+ };
270
+ oneditfield(fieldName, inlineContent, rect, mapping);
271
+ }
272
+
38
273
  // ── Edit handlers ────────────────────────────────────────────
39
274
 
40
275
  function handleHeadingTextChange(text: string) {
@@ -65,6 +300,45 @@
65
300
  onupdate(updated);
66
301
  }
67
302
 
303
+ /** Handle attribute changes for a nested rune node */
304
+ function handleNestedAttrsChange(attrs: Record<string, string>) {
305
+ if (!activeNode || !activeNode.runeName) return;
306
+ const oldSource = activeNode.source;
307
+ // Rebuild the nested rune's source with new attributes
308
+ const attrStr = serializeAttributes(attrs);
309
+ let newSource: string;
310
+ if (activeNode.selfClosing) {
311
+ newSource = `{% ${activeNode.runeName}${attrStr} /%}`;
312
+ } else {
313
+ const inner = (activeNode.innerContent ?? '').trim();
314
+ newSource = inner
315
+ ? `{% ${activeNode.runeName}${attrStr} %}\n${inner}\n{% /${activeNode.runeName} %}`
316
+ : `{% ${activeNode.runeName}${attrStr} %}\n\n{% /${activeNode.runeName} %}`;
317
+ }
318
+ // Replace in the top-level block's inner content
319
+ const rb = block as RuneBlock;
320
+ const newInner = replaceNodeSource(rb.innerContent, oldSource, newSource);
321
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
322
+ updated.source = rebuildRuneSource(updated);
323
+ onupdate(updated);
324
+ }
325
+
326
+ /** Handle inner content changes for a nested rune node */
327
+ function handleNestedContentChange(content: string) {
328
+ if (!activeNode || !activeNode.runeName) return;
329
+ const oldSource = activeNode.source;
330
+ const attrStr = serializeAttributes(activeNode.attributes ?? {});
331
+ const inner = content.trim();
332
+ const newSource = inner
333
+ ? `{% ${activeNode.runeName}${attrStr} %}\n${inner}\n{% /${activeNode.runeName} %}`
334
+ : `{% ${activeNode.runeName}${attrStr} %}\n\n{% /${activeNode.runeName} %}`;
335
+ const rb = block as RuneBlock;
336
+ const newInner = replaceNodeSource(rb.innerContent, oldSource, newSource);
337
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
338
+ updated.source = rebuildRuneSource(updated);
339
+ onupdate(updated);
340
+ }
341
+
68
342
  function handleFenceCodeChange(code: string) {
69
343
  const b = block as FenceBlock;
70
344
  const updated: FenceBlock = { ...b, code, source: '' };
@@ -82,171 +356,395 @@
82
356
  function handleSourceChange(source: string) {
83
357
  onupdate({ ...block, source });
84
358
  }
359
+
360
+ // ── Nested content node edit handlers ────────────────────────
361
+
362
+ /** Replace a nested content node's source within the parent rune's inner content */
363
+ function replaceNestedSource(newSource: string) {
364
+ if (!activeNode) return;
365
+ const rb = block as RuneBlock;
366
+ const newInner = replaceNodeSource(rb.innerContent, activeNode.source, newSource);
367
+ const updated: RuneBlock = { ...rb, innerContent: newInner, source: '' };
368
+ updated.source = rebuildRuneSource(updated);
369
+ onupdate(updated);
370
+ }
371
+
372
+ function handleNestedHeadingLevelChange(level: number) {
373
+ if (!activeNode) return;
374
+ replaceNestedSource(`${'#'.repeat(level)} ${activeNode.headingText ?? ''}`);
375
+ }
376
+
377
+ function handleNestedHeadingTextChange(text: string) {
378
+ if (!activeNode) return;
379
+ replaceNestedSource(`${'#'.repeat(activeNode.headingLevel ?? 1)} ${text}`);
380
+ }
381
+
382
+ function handleNestedFenceLangChange(lang: string) {
383
+ if (!activeNode) return;
384
+ const code = activeNode.fenceCode ?? '';
385
+ replaceNestedSource(`\`\`\`${lang}\n${code}\n\`\`\``);
386
+ }
387
+
388
+ function handleNestedFenceCodeChange(code: string) {
389
+ if (!activeNode) return;
390
+ const lang = activeNode.fenceLanguage ?? '';
391
+ replaceNestedSource(`\`\`\`${lang}\n${code}\n\`\`\``);
392
+ }
393
+
394
+ function handleNestedSourceChange(source: string) {
395
+ replaceNestedSource(source);
396
+ }
397
+
85
398
  </script>
86
399
 
87
400
  <div class="edit-panel">
88
- <div class="edit-panel__header">
89
- <span class="edit-panel__type">{label}</span>
90
- {#if category}
91
- <span class="edit-panel__category">{category}</span>
401
+ <div class="edit-panel__top">
402
+ <div class="edit-panel__header">
403
+ <span class="edit-panel__type">{headerLabel}</span>
404
+ {#if !activeIsContent && category}
405
+ <span class="edit-panel__category">{category}</span>
406
+ {/if}
407
+ <div class="edit-panel__spacer"></div>
408
+ <button
409
+ class="edit-panel__btn edit-panel__btn--danger"
410
+ onclick={onremove}
411
+ title="Remove block"
412
+ >
413
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
414
+ <polyline points="3 6 3 14 13 14 13 6" />
415
+ <line x1="1" y1="4" x2="15" y2="4" />
416
+ <line x1="6" y1="2" x2="10" y2="2" />
417
+ <line x1="6" y1="8" x2="6" y2="12" />
418
+ <line x1="10" y1="8" x2="10" y2="12" />
419
+ </svg>
420
+ </button>
421
+ <button
422
+ class="edit-panel__btn"
423
+ onclick={onclose}
424
+ title="Close panel"
425
+ >&times;</button>
426
+ </div>
427
+
428
+ {#if block.type === 'rune' && availableTabs.length > 1}
429
+ <div class="edit-panel__tabs">
430
+ {#each availableTabs as tab}
431
+ <button
432
+ type="button"
433
+ class="edit-panel__tab"
434
+ class:active={activeTab === tab}
435
+ onclick={() => activeTab = tab}
436
+ >
437
+ {#if tab === 'settings'}
438
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
439
+ <circle cx="8" cy="8" r="2" />
440
+ <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" />
441
+ </svg>
442
+ Settings
443
+ {:else if tab === 'structure'}
444
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
445
+ <path d="M2 3h4M2 7h4M6 11h4M6 15h4M4 3v8M8 11v4" />
446
+ </svg>
447
+ Structure
448
+ {:else if tab === 'content'}
449
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
450
+ <path d="M2 4h12M2 8h12M2 12h8" />
451
+ </svg>
452
+ Content
453
+ {/if}
454
+ </button>
455
+ {/each}
456
+ </div>
92
457
  {/if}
93
- <div class="edit-panel__spacer"></div>
94
- <button
95
- class="edit-panel__btn edit-panel__btn--danger"
96
- onclick={onremove}
97
- title="Remove block"
98
- >
99
- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
100
- <polyline points="3 6 3 14 13 14 13 6" />
101
- <line x1="1" y1="4" x2="15" y2="4" />
102
- <line x1="6" y1="2" x2="10" y2="2" />
103
- <line x1="6" y1="8" x2="6" y2="12" />
104
- <line x1="10" y1="8" x2="10" y2="12" />
105
- </svg>
106
- </button>
107
- <button
108
- class="edit-panel__btn"
109
- onclick={onclose}
110
- title="Close panel"
111
- >&times;</button>
112
458
  </div>
113
459
 
114
- <div class="edit-panel__body">
115
- {#if block.type === 'heading'}
116
- {@const hb = block as HeadingBlock}
117
- <div class="edit-panel__field-group">
118
- <label class="edit-panel__field">
119
- <span class="edit-panel__field-label">Level</span>
120
- <select
121
- class="edit-panel__select"
122
- value={String(hb.level)}
123
- onchange={(e) => handleHeadingLevelChange(Number((e.target as HTMLSelectElement).value))}
124
- >
125
- <option value="1">H1</option>
126
- <option value="2">H2</option>
127
- <option value="3">H3</option>
128
- <option value="4">H4</option>
129
- <option value="5">H5</option>
130
- <option value="6">H6</option>
131
- </select>
132
- </label>
133
- <label class="edit-panel__field">
134
- <span class="edit-panel__field-label">Text</span>
135
- <input
136
- class="edit-panel__input"
137
- type="text"
138
- value={hb.text}
139
- oninput={(e) => handleHeadingTextChange((e.target as HTMLInputElement).value)}
460
+ {#if block.type === 'rune'}
461
+ {@const rb = block as RuneBlock}
462
+
463
+ <!-- Settings tab -->
464
+ {#if activeTab === 'settings'}
465
+ <div class="edit-panel__tab-panel">
466
+ {#if activeIsContent && activeNode}
467
+ {#if activeNode.type === 'heading'}
468
+ <div class="edit-panel__field-group">
469
+ <label class="edit-panel__field">
470
+ <span class="edit-panel__field-label">Level</span>
471
+ <select
472
+ class="edit-panel__select"
473
+ value={String(activeNode.headingLevel ?? 1)}
474
+ onchange={(e) => handleNestedHeadingLevelChange(Number((e.target as HTMLSelectElement).value))}
475
+ >
476
+ <option value="1">H1</option>
477
+ <option value="2">H2</option>
478
+ <option value="3">H3</option>
479
+ <option value="4">H4</option>
480
+ <option value="5">H5</option>
481
+ <option value="6">H6</option>
482
+ </select>
483
+ </label>
484
+ <label class="edit-panel__field">
485
+ <span class="edit-panel__field-label">Text</span>
486
+ <input
487
+ class="edit-panel__input"
488
+ type="text"
489
+ value={activeNode.headingText ?? ''}
490
+ oninput={(e) => handleNestedHeadingTextChange((e.target as HTMLInputElement).value)}
491
+ />
492
+ </label>
493
+ </div>
494
+ {:else if activeNode.type === 'fence'}
495
+ <div class="edit-panel__field-group">
496
+ <label class="edit-panel__field">
497
+ <span class="edit-panel__field-label">Language</span>
498
+ <input
499
+ class="edit-panel__input"
500
+ type="text"
501
+ value={activeNode.fenceLanguage ?? ''}
502
+ oninput={(e) => handleNestedFenceLangChange((e.target as HTMLInputElement).value)}
503
+ placeholder="e.g. js, python, html"
504
+ />
505
+ </label>
506
+ </div>
507
+ {:else}
508
+ <div class="edit-panel__empty-tab">
509
+ <span class="edit-panel__empty-tab-text">No settings for this element</span>
510
+ </div>
511
+ {/if}
512
+
513
+ {:else if activeNode && activeRuneInfo}
514
+ <RuneAttributes
515
+ runeInfo={activeRuneInfo}
516
+ attributes={activeNode.attributes ?? {}}
517
+ onchange={handleNestedAttrsChange}
140
518
  />
141
- </label>
519
+
520
+ {:else if !activeNode}
521
+ {#if runeInfo}
522
+ <RuneAttributes
523
+ {runeInfo}
524
+ attributes={rb.attributes}
525
+ onchange={handleRuneAttrsChange}
526
+ />
527
+ {:else}
528
+ <div class="edit-panel__unknown">
529
+ <span class="edit-panel__unknown-label">Unknown rune: {rb.runeName}</span>
530
+ {#each Object.entries(rb.attributes) as [key, val]}
531
+ <label class="edit-panel__field">
532
+ <span class="edit-panel__field-label">{key}</span>
533
+ <input
534
+ class="edit-panel__input"
535
+ type="text"
536
+ value={val}
537
+ oninput={(e) => {
538
+ const next = { ...rb.attributes, [key]: (e.target as HTMLInputElement).value };
539
+ handleRuneAttrsChange(next);
540
+ }}
541
+ />
542
+ </label>
543
+ {/each}
544
+ </div>
545
+ {/if}
546
+ {/if}
142
547
  </div>
548
+ {/if}
143
549
 
144
- {:else if block.type === 'rune'}
145
- {@const rb = block as RuneBlock}
146
- {#if runeInfo}
147
- <RuneAttributes
148
- {runeInfo}
149
- attributes={rb.attributes}
150
- onchange={handleRuneAttrsChange}
151
- />
152
- {:else}
153
- <div class="edit-panel__unknown">
154
- <span class="edit-panel__unknown-label">Unknown rune: {rb.runeName}</span>
155
- {#each Object.entries(rb.attributes) as [key, val]}
156
- <label class="edit-panel__field">
157
- <span class="edit-panel__field-label">{key}</span>
158
- <input
159
- class="edit-panel__input"
160
- type="text"
161
- value={val}
162
- oninput={(e) => {
163
- const next = { ...rb.attributes, [key]: (e.target as HTMLInputElement).value };
164
- handleRuneAttrsChange(next);
165
- }}
550
+ <!-- Structure tab -->
551
+ {#if activeTab === 'structure'}
552
+ <div class="edit-panel__tab-panel">
553
+ {#if hasContentModel && resolvedStructure}
554
+ <ContentModelTree
555
+ structure={resolvedStructure}
556
+ rootLabel={activeNode?.runeName ?? rb.runeName}
557
+ onaddfield={handleAddField}
558
+ onremovefield={handleRemoveField}
559
+ onappenditem={handleAppendItem}
560
+ onremovelistitem={handleRemoveListItem}
561
+ oneditfield={handleEditField}
562
+ onfieldselect={handleFieldSelect}
563
+ {selectedField}
564
+ />
565
+ {:else if !activeNode && hasNestedRunes}
566
+ <ContentTree
567
+ nodes={contentTree}
568
+ {activePath}
569
+ onselect={handleTreeSelect}
570
+ rootLabel={rb.runeName}
571
+ onrootclick={navigateToRoot}
572
+ isRootActive={activePath.length === 0}
573
+ />
574
+ {/if}
575
+ </div>
576
+ {/if}
577
+
578
+ <!-- Content tab -->
579
+ {#if activeTab === 'content' && !rb.selfClosing}
580
+ <div class="edit-panel__tab-panel">
581
+ {#if activeIsContent && activeNode}
582
+ {#if activeNode.type === 'fence'}
583
+ <div class="edit-panel__content-editor">
584
+ <InlineEditor
585
+ content={activeNode.fenceCode ?? ''}
586
+ onchange={handleNestedFenceCodeChange}
587
+ language={activeNode.fenceLanguage}
588
+ {runes}
589
+ aggregated={() => aggregated}
590
+ />
591
+ </div>
592
+ {:else if activeNode.type === 'paragraph'}
593
+ <div class="edit-panel__content-editor">
594
+ <InlineEditor
595
+ content={activeNode.source}
596
+ onchange={handleNestedSourceChange}
597
+ {runes}
598
+ aggregated={() => aggregated}
166
599
  />
167
- </label>
168
- {/each}
600
+ </div>
601
+ {:else if activeNode.type === 'heading'}
602
+ <div class="edit-panel__empty-tab">
603
+ <span class="edit-panel__empty-tab-text">Edit heading text in Settings</span>
604
+ </div>
605
+ {:else}
606
+ <div class="edit-panel__field-group">
607
+ <label class="edit-panel__field">
608
+ <span class="edit-panel__field-label">Source</span>
609
+ <textarea
610
+ class="edit-panel__textarea"
611
+ value={activeNode.source}
612
+ oninput={(e) => handleNestedSourceChange((e.target as HTMLTextAreaElement).value)}
613
+ rows={Math.max(4, activeNode.source.split('\n').length)}
614
+ ></textarea>
615
+ </label>
616
+ </div>
617
+ {/if}
618
+
619
+ {:else if activeNode && activeRuneInfo}
620
+ {#if !activeNode.selfClosing && activeNode.innerContent !== undefined}
621
+ <div class="edit-panel__content-editor">
622
+ <InlineEditor
623
+ content={activeNode.innerContent}
624
+ onchange={handleNestedContentChange}
625
+ {runes}
626
+ aggregated={() => aggregated}
627
+ />
628
+ </div>
629
+ {:else}
630
+ <div class="edit-panel__empty-tab">
631
+ <span class="edit-panel__empty-tab-text">This rune has no inner content</span>
632
+ </div>
633
+ {/if}
634
+
635
+ {:else if !activeNode}
636
+ <div class="edit-panel__content-editor">
637
+ <InlineEditor
638
+ content={rb.innerContent}
639
+ onchange={handleRuneContentChange}
640
+ {runes}
641
+ aggregated={() => aggregated}
642
+ />
643
+ </div>
644
+ {/if}
645
+ </div>
646
+ {/if}
647
+
648
+ {:else}
649
+ <div class="edit-panel__body">
650
+ {#if block.type === 'heading'}
651
+ {@const hb = block as HeadingBlock}
652
+ <div class="edit-panel__field-group">
653
+ <label class="edit-panel__field">
654
+ <span class="edit-panel__field-label">Level</span>
655
+ <select
656
+ class="edit-panel__select"
657
+ value={String(hb.level)}
658
+ onchange={(e) => handleHeadingLevelChange(Number((e.target as HTMLSelectElement).value))}
659
+ >
660
+ <option value="1">H1</option>
661
+ <option value="2">H2</option>
662
+ <option value="3">H3</option>
663
+ <option value="4">H4</option>
664
+ <option value="5">H5</option>
665
+ <option value="6">H6</option>
666
+ </select>
667
+ </label>
668
+ <label class="edit-panel__field">
669
+ <span class="edit-panel__field-label">Text</span>
670
+ <input
671
+ class="edit-panel__input"
672
+ type="text"
673
+ value={hb.text}
674
+ oninput={(e) => handleHeadingTextChange((e.target as HTMLInputElement).value)}
675
+ />
676
+ </label>
677
+ </div>
678
+
679
+ {:else if block.type === 'fence'}
680
+ {@const fb = block as FenceBlock}
681
+ <div class="edit-panel__field-group">
682
+ <label class="edit-panel__field">
683
+ <span class="edit-panel__field-label">Language</span>
684
+ <input
685
+ class="edit-panel__input"
686
+ type="text"
687
+ value={fb.language}
688
+ oninput={(e) => handleFenceLangChange((e.target as HTMLInputElement).value)}
689
+ placeholder="e.g. js, python, html"
690
+ />
691
+ </label>
169
692
  </div>
170
- {/if}
171
- {#if !rb.selfClosing}
172
693
  <div class="edit-panel__content-editor">
173
694
  <InlineEditor
174
- content={rb.innerContent}
175
- onchange={handleRuneContentChange}
695
+ content={fb.code}
696
+ onchange={handleFenceCodeChange}
697
+ language={fb.language}
176
698
  {runes}
177
699
  aggregated={() => aggregated}
178
700
  />
179
701
  </div>
180
- {/if}
181
702
 
182
- {:else if block.type === 'fence'}
183
- {@const fb = block as FenceBlock}
184
- <div class="edit-panel__field-group">
185
- <label class="edit-panel__field">
186
- <span class="edit-panel__field-label">Language</span>
187
- <input
188
- class="edit-panel__input"
189
- type="text"
190
- value={fb.language}
191
- oninput={(e) => handleFenceLangChange((e.target as HTMLInputElement).value)}
192
- placeholder="e.g. js, python, html"
703
+ {:else if block.type === 'paragraph'}
704
+ <div class="edit-panel__content-editor">
705
+ <InlineEditor
706
+ content={block.source}
707
+ onchange={handleSourceChange}
708
+ {runes}
709
+ aggregated={() => aggregated}
193
710
  />
194
- </label>
195
- </div>
196
- <div class="edit-panel__content-editor">
197
- <InlineEditor
198
- content={fb.code}
199
- onchange={handleFenceCodeChange}
200
- language={fb.language}
201
- {runes}
202
- />
203
- aggregated={() => aggregated}
204
- </div>
205
-
206
- {:else if block.type === 'paragraph'}
207
- <div class="edit-panel__content-editor">
208
- <InlineEditor
209
- content={block.source}
210
- onchange={handleSourceChange}
211
- {runes}
212
- />
213
- aggregated={() => aggregated}
214
- </div>
711
+ </div>
215
712
 
216
- {:else}
217
- <!-- List, quote, image, etc. — raw source editing -->
218
- <div class="edit-panel__field-group">
219
- <label class="edit-panel__field">
220
- <span class="edit-panel__field-label">Source</span>
221
- <textarea
222
- class="edit-panel__textarea"
223
- value={block.source}
224
- oninput={(e) => handleSourceChange((e.target as HTMLTextAreaElement).value)}
225
- rows={Math.max(4, block.source.split('\n').length)}
226
- ></textarea>
227
- </label>
228
- </div>
229
- {/if}
230
- </div>
713
+ {:else}
714
+ <!-- List, quote, image, etc. — raw source editing -->
715
+ <div class="edit-panel__field-group">
716
+ <label class="edit-panel__field">
717
+ <span class="edit-panel__field-label">Source</span>
718
+ <textarea
719
+ class="edit-panel__textarea"
720
+ value={block.source}
721
+ oninput={(e) => handleSourceChange((e.target as HTMLTextAreaElement).value)}
722
+ rows={Math.max(4, block.source.split('\n').length)}
723
+ ></textarea>
724
+ </label>
725
+ </div>
726
+ {/if}
727
+ </div>
728
+ {/if}
231
729
  </div>
232
730
 
233
731
  <style>
234
732
  .edit-panel {
235
733
  display: flex;
236
734
  flex-direction: column;
237
- height: 100%;
735
+ }
736
+
737
+ .edit-panel__top {
738
+ flex-shrink: 0;
739
+ background: var(--ed-surface-0);
740
+ border-bottom: 1px solid var(--ed-border-default);
238
741
  }
239
742
 
240
743
  .edit-panel__header {
241
744
  display: flex;
242
745
  align-items: center;
243
746
  gap: 0.5rem;
244
- padding: var(--ed-space-3) var(--ed-space-4);
245
- border-bottom: 1px solid var(--ed-border-default);
246
- background: transparent;
247
- position: sticky;
248
- top: 0;
249
- z-index: 1;
747
+ padding: var(--ed-space-4) var(--ed-space-5);
250
748
  }
251
749
 
252
750
  .edit-panel__type {
@@ -302,10 +800,10 @@
302
800
  .edit-panel__body {
303
801
  flex: 1;
304
802
  overflow-y: auto;
305
- padding: var(--ed-space-4);
803
+ padding: var(--ed-space-5);
306
804
  display: flex;
307
805
  flex-direction: column;
308
- gap: var(--ed-space-4);
806
+ gap: var(--ed-space-5);
309
807
  }
310
808
 
311
809
  .edit-panel__field-group {
@@ -388,8 +886,8 @@
388
886
  display: flex;
389
887
  flex-direction: column;
390
888
  overflow: hidden;
391
- margin-left: calc(-1 * var(--ed-space-4));
392
- margin-right: calc(-1 * var(--ed-space-4));
889
+ margin-left: calc(-1 * var(--ed-space-5));
890
+ margin-right: calc(-1 * var(--ed-space-5));
393
891
  border-top: 1px solid var(--ed-border-subtle);
394
892
  }
395
893
 
@@ -404,4 +902,65 @@
404
902
  color: var(--ed-unsaved);
405
903
  font-style: italic;
406
904
  }
905
+
906
+ /* Tab strip */
907
+ .edit-panel__tabs {
908
+ display: flex;
909
+ gap: 2px;
910
+ background: var(--ed-surface-2);
911
+ border-radius: var(--ed-radius-sm);
912
+ padding: 2px;
913
+ margin: 0 var(--ed-space-4) var(--ed-space-3);
914
+ }
915
+
916
+ .edit-panel__tab {
917
+ flex: 1;
918
+ display: flex;
919
+ align-items: center;
920
+ justify-content: center;
921
+ gap: 0.35rem;
922
+ padding: 0.35rem 0.5rem;
923
+ border: none;
924
+ background: transparent;
925
+ color: var(--ed-text-muted);
926
+ font-size: var(--ed-text-sm);
927
+ font-weight: 500;
928
+ cursor: pointer;
929
+ border-radius: calc(var(--ed-radius-sm) - 1px);
930
+ transition: background var(--ed-transition-fast), color var(--ed-transition-fast);
931
+ }
932
+
933
+ .edit-panel__tab:hover {
934
+ color: var(--ed-text-secondary);
935
+ }
936
+
937
+ .edit-panel__tab.active {
938
+ background: var(--ed-surface-0);
939
+ color: var(--ed-text-primary);
940
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
941
+ }
942
+
943
+ /* Tab panels */
944
+ .edit-panel__tab-panel {
945
+ flex: 1;
946
+ overflow-y: auto;
947
+ padding: var(--ed-space-5);
948
+ display: flex;
949
+ flex-direction: column;
950
+ gap: var(--ed-space-5);
951
+ }
952
+
953
+ /* Empty tab state */
954
+ .edit-panel__empty-tab {
955
+ display: flex;
956
+ align-items: center;
957
+ justify-content: center;
958
+ padding: var(--ed-space-8) var(--ed-space-4);
959
+ }
960
+
961
+ .edit-panel__empty-tab-text {
962
+ font-size: var(--ed-text-sm);
963
+ color: var(--ed-text-muted);
964
+ font-style: italic;
965
+ }
407
966
  </style>