You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
347 lines
16 KiB
347 lines
16 KiB
<script lang="ts">
|
|
import type { Metadata, MetadataField, MetadataFieldType } from '../../lib';
|
|
import type { GlobInstance } from '@/glob/lib';
|
|
import { validateMetadata, validateField, field as f } from '../../lib';
|
|
import { makeLingRecord } from '@/ling/lib';
|
|
import AddFieldMenu from './AddFieldMenu.svelte';
|
|
import AddFieldDrawer from './AddFieldDrawer.svelte';
|
|
import FieldEditor from './FieldEditor.svelte';
|
|
import TreeNodes from './TreeNodes.svelte';
|
|
|
|
let {
|
|
value = $bindable<Metadata>({}),
|
|
dark = false,
|
|
attributes = [],
|
|
glob,
|
|
onUpdate,
|
|
}: {
|
|
value?: Metadata;
|
|
dark?: boolean;
|
|
attributes?: string[];
|
|
glob: GlobInstance;
|
|
onUpdate?: (metadata: Metadata) => void;
|
|
} = $props();
|
|
|
|
// ── Selected node ─────────────────────────────────────────────────
|
|
// Path is an array of keys, e.g. ['specs', 'motor']
|
|
let selectedPath = $state<string[]>([]);
|
|
|
|
let drawerOpen = $state(false);
|
|
let drawerType = $state<MetadataFieldType>('string');
|
|
// Path where the new field will be added ([] = root)
|
|
let drawerTargetPath = $state<string[]>([]);
|
|
|
|
let keyOrder = $derived(
|
|
value.order ?? Object.keys(value).filter(k => k !== 'order')
|
|
);
|
|
|
|
let validation = $derived(validateMetadata(value));
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
function emit(): void { onUpdate?.(value); }
|
|
|
|
function getFieldAtPath(path: string[]): MetadataField | null {
|
|
let node: Metadata = value;
|
|
for (let i = 0; i < path.length; i++) {
|
|
const f = node[path[i]];
|
|
if (f === undefined) return null;
|
|
if (i === path.length - 1) return f as MetadataField;
|
|
if ((f as MetadataField).type !== 'group') return null;
|
|
node = (f as any).value as Metadata;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function setFieldAtPath(path: string[], newField: MetadataField): void {
|
|
if (path.length === 1) {
|
|
value = { ...value, [path[0]]: newField };
|
|
emit();
|
|
return;
|
|
}
|
|
const [head, ...tail] = path;
|
|
const parent = value[head] as MetadataField;
|
|
if (parent.type !== 'group') return;
|
|
const updatedGroup = setFieldAtPathInMetadata(parent.value as Metadata, tail, newField);
|
|
value = { ...value, [head]: { ...parent, value: updatedGroup } };
|
|
emit();
|
|
}
|
|
|
|
function setFieldAtPathInMetadata(meta: Metadata, path: string[], newField: MetadataField): Metadata {
|
|
if (path.length === 1) return { ...meta, [path[0]]: newField };
|
|
const [head, ...tail] = path;
|
|
const parent = meta[head] as MetadataField;
|
|
if (parent.type !== 'group') return meta;
|
|
return { ...meta, [head]: { ...parent, value: setFieldAtPathInMetadata(parent.value as Metadata, tail, newField) } };
|
|
}
|
|
|
|
function deleteFieldAtPath(path: string[]): void {
|
|
if (path.length === 1) {
|
|
const next = { ...value };
|
|
delete next[path[0]];
|
|
next.order = keyOrder.filter(k => k !== path[0]);
|
|
value = next;
|
|
if (selectedPath[0] === path[0]) selectedPath = [];
|
|
emit();
|
|
return;
|
|
}
|
|
const [head, ...tail] = path;
|
|
const parent = value[head] as MetadataField;
|
|
if (parent.type !== 'group') return;
|
|
value = { ...value, [head]: { ...parent, value: deleteFromMetadata(parent.value as Metadata, tail) } };
|
|
if (selectedPath.join('.').startsWith(path.join('.'))) selectedPath = [];
|
|
emit();
|
|
}
|
|
|
|
function deleteFromMetadata(meta: Metadata, path: string[]): Metadata {
|
|
if (path.length === 1) {
|
|
const next = { ...meta };
|
|
delete next[path[0]];
|
|
return next;
|
|
}
|
|
const [head, ...tail] = path;
|
|
const parent = meta[head] as MetadataField;
|
|
if (parent.type !== 'group') return meta;
|
|
return { ...meta, [head]: { ...parent, value: deleteFromMetadata(parent.value as Metadata, tail) } };
|
|
}
|
|
|
|
function renameFieldAtPath(path: string[], newKey: string): void {
|
|
if (!newKey || newKey === path[path.length - 1]) return;
|
|
if (path.length === 1) {
|
|
const entries = Object.entries(value)
|
|
.filter(([k]) => k !== 'order')
|
|
.map(([k, v]) => [k === path[0] ? newKey : k, v] as [string, unknown]);
|
|
const next: Metadata = Object.fromEntries(entries);
|
|
next.order = keyOrder.map(k => k === path[0] ? newKey : k);
|
|
value = next;
|
|
selectedPath = [newKey];
|
|
emit();
|
|
return;
|
|
}
|
|
const [head, ...tail] = path;
|
|
const parent = value[head] as MetadataField;
|
|
if (parent.type !== 'group') return;
|
|
value = { ...value, [head]: { ...parent, value: renameInMetadata(parent.value as Metadata, tail, newKey) } };
|
|
selectedPath = [...path.slice(0, -1), newKey];
|
|
emit();
|
|
}
|
|
|
|
function renameInMetadata(meta: Metadata, path: string[], newKey: string): Metadata {
|
|
if (path.length === 1) {
|
|
const entries = Object.entries(meta).map(([k, v]) => [k === path[0] ? newKey : k, v] as [string, unknown]);
|
|
return Object.fromEntries(entries);
|
|
}
|
|
const [head, ...tail] = path;
|
|
const parent = meta[head] as MetadataField;
|
|
if (parent.type !== 'group') return meta;
|
|
return { ...meta, [head]: { ...parent, value: renameInMetadata(parent.value as Metadata, tail, newKey) } };
|
|
}
|
|
|
|
function moveFieldAtPath(path: string[], dir: -1 | 1): void {
|
|
if (path.length === 1) {
|
|
const order = [...keyOrder];
|
|
const idx = order.indexOf(path[0]);
|
|
const newIdx = idx + dir;
|
|
if (newIdx < 0 || newIdx >= order.length) return;
|
|
order.splice(idx, 1);
|
|
order.splice(newIdx, 0, path[0]);
|
|
value = { ...value, order };
|
|
emit();
|
|
return;
|
|
}
|
|
const [head, ...tail] = path;
|
|
const parent = value[head] as MetadataField;
|
|
if (parent.type !== 'group') return;
|
|
value = { ...value, [head]: { ...parent, value: moveInMetadata(parent.value as Metadata, tail, dir) } };
|
|
emit();
|
|
}
|
|
|
|
function moveInMetadata(meta: Metadata, path: string[], dir: -1 | 1): Metadata {
|
|
if (path.length === 1) {
|
|
const keys = Object.keys(meta);
|
|
const idx = keys.indexOf(path[0]);
|
|
const newIdx = idx + dir;
|
|
if (newIdx < 0 || newIdx >= keys.length) return meta;
|
|
keys.splice(idx, 1);
|
|
keys.splice(newIdx, 0, path[0]);
|
|
return Object.fromEntries(keys.map(k => [k, meta[k]]));
|
|
}
|
|
const [head, ...tail] = path;
|
|
const parent = meta[head] as MetadataField;
|
|
if (parent.type !== 'group') return meta;
|
|
return { ...meta, [head]: { ...parent, value: moveInMetadata(parent.value as Metadata, tail, dir) } };
|
|
}
|
|
|
|
function addFieldAtPath(targetPath: string[], key: string, field: MetadataField): void {
|
|
if (targetPath.length === 0) {
|
|
value = { ...value, [key]: field, order: [...keyOrder, key] };
|
|
emit();
|
|
return;
|
|
}
|
|
const [head, ...tail] = targetPath;
|
|
const parent = value[head] as MetadataField;
|
|
if (parent.type !== 'group') return;
|
|
value = { ...value, [head]: { ...parent, value: addInMetadata(parent.value as Metadata, tail, key, field) } };
|
|
emit();
|
|
}
|
|
|
|
function addInMetadata(meta: Metadata, path: string[], key: string, field: MetadataField): Metadata {
|
|
if (path.length === 0) return { ...meta, [key]: field };
|
|
const [head, ...tail] = path;
|
|
const parent = meta[head] as MetadataField;
|
|
if (parent.type !== 'group') return meta;
|
|
return { ...meta, [head]: { ...parent, value: addInMetadata(parent.value as Metadata, tail, key, field) } };
|
|
}
|
|
|
|
function openDrawer(type: MetadataFieldType, targetPath: string[]): void {
|
|
drawerType = type;
|
|
drawerTargetPath = targetPath;
|
|
drawerOpen = true;
|
|
}
|
|
|
|
function handleDrawerConfirm(key: string, field: MetadataField): void {
|
|
addFieldAtPath(drawerTargetPath, key, field);
|
|
selectedPath = [...drawerTargetPath, key];
|
|
}
|
|
|
|
function existingKeysAt(targetPath: string[]): string[] {
|
|
if (targetPath.length === 0) return keyOrder;
|
|
const f = getFieldAtPath(targetPath);
|
|
if (!f || f.type !== 'group') return [];
|
|
return Object.keys(f.value);
|
|
}
|
|
|
|
// ── Add target path ──────────────────────────────────────────────
|
|
// Where the next field will be created:
|
|
// - selected is group → inside it
|
|
// - selected is leaf → in its parent (same level)
|
|
// - nothing selected → root
|
|
let addTargetPath = $derived((() => {
|
|
if (selectedPath.length === 0) return [];
|
|
const f = getFieldAtPath(selectedPath);
|
|
if (f && f.type === 'group') return selectedPath;
|
|
return selectedPath.slice(0, -1);
|
|
})());
|
|
|
|
// ── Selected field ────────────────────────────────────────────────
|
|
let selectedField = $derived(
|
|
selectedPath.length > 0 ? getFieldAtPath(selectedPath) : null
|
|
);
|
|
let selectedKey = $derived(
|
|
selectedPath.length > 0 ? selectedPath[selectedPath.length - 1] : ''
|
|
);
|
|
</script>
|
|
|
|
<div data-theme={dark ? 'dark' : 'light'} class="h-full flex flex-col min-h-0">
|
|
<div class="card bg-base-100 border border-base-300 shadow-md w-full flex flex-col flex-1 min-h-0">
|
|
|
|
<!-- Body: left tree col + right editor col -->
|
|
<div class="flex flex-1 min-h-0 overflow-hidden">
|
|
|
|
<!-- Left: header + tree in same column -->
|
|
<div class="flex flex-col w-56 shrink-0 border-r border-base-300 min-h-0">
|
|
|
|
<!-- Tree header -->
|
|
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-base-300 bg-base-200/60 shrink-0">
|
|
<div role="img"
|
|
title={validation.valid
|
|
? glob.t('vcen.editor.mtas.validation.valid')
|
|
: glob.t('vcen.editor.mtas.validation.errorCount', { count: validation.errors.length })}
|
|
class={['h-2 w-2 shrink-0 rounded-full transition-colors', validation.valid ? 'bg-success' : 'bg-error animate-pulse'].join(' ')}
|
|
></div>
|
|
<h2 class="font-semibold text-sm flex-1 text-base-content">
|
|
{glob.t('vcen.editor.mtas.header.title')}
|
|
</h2>
|
|
<span class="badge badge-ghost badge-sm text-base-content/60 font-normal">
|
|
{glob.t('vcen.editor.mtas.header.fieldCount', { count: keyOrder.length })}
|
|
</span>
|
|
<AddFieldMenu onSelect={(type) => openDrawer(type, addTargetPath)} iconOnly={true} {glob} placement="start"/>
|
|
</div>
|
|
|
|
<!-- Tree nodes -->
|
|
<div class="flex-1 overflow-y-auto">
|
|
{#if keyOrder.length === 0}
|
|
<div class="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
|
|
<svg class="h-8 w-8 text-base-content/10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<rect x="3" y="3" width="18" height="18" rx="4"/>
|
|
<path d="M12 8v8M8 12h8"/>
|
|
</svg>
|
|
<p class="text-xs text-base-content/30">{glob.t('vcen.editor.mtas.field.empty')}</p>
|
|
</div>
|
|
{:else}
|
|
<div class="flex flex-col py-1">
|
|
<TreeNodes
|
|
meta={value}
|
|
order={keyOrder}
|
|
path={[]}
|
|
depth={0}
|
|
{selectedPath}
|
|
{glob}
|
|
onSelect={(p) => selectedPath = p}
|
|
onMove={(p, dir) => moveFieldAtPath(p, dir)}
|
|
onDelete={(p) => deleteFieldAtPath(p)}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right: editor panel -->
|
|
<div class="flex-1 min-w-0 overflow-y-auto">
|
|
{#if selectedField && selectedPath.length > 0}
|
|
<!-- Breadcrumb -->
|
|
<div class="flex items-center gap-1 px-4 py-2 border-b border-base-300 bg-base-200/40 text-xs text-base-content/50 font-mono">
|
|
{#each selectedPath as seg, i}
|
|
{#if i > 0}<span class="text-base-content/25">/</span>{/if}
|
|
<span class={i === selectedPath.length - 1 ? 'text-base-content/80 font-semibold' : ''}>{seg}</span>
|
|
{/each}
|
|
</div>
|
|
<FieldEditor
|
|
fieldKey={selectedKey}
|
|
field={selectedField}
|
|
{attributes} {glob}
|
|
onKeyChange={(newKey) => renameFieldAtPath(selectedPath, newKey)}
|
|
onFieldChange={(f) => setFieldAtPath(selectedPath, f)}
|
|
/>
|
|
{:else}
|
|
<div class="flex flex-col items-center justify-center h-full gap-3 text-center px-8">
|
|
<svg class="h-10 w-10 text-base-content/10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
|
</svg>
|
|
<p class="text-sm text-base-content/30">
|
|
{glob.t('vcen.editor.mtas.tree.selectHint')}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Validation errors -->
|
|
{#if !validation.valid}
|
|
<div class="px-3 pb-3 shrink-0 border-t border-base-300">
|
|
<div class="alert alert-error mt-3 py-2.5">
|
|
<svg class="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
|
|
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
|
</svg>
|
|
<div class="flex flex-col gap-0.5">
|
|
<span class="font-medium text-sm">{glob.t('vcen.editor.mtas.validation.errorsTitle')}</span>
|
|
{#each validation.errors as error}
|
|
<span class="text-xs opacity-80">· {error}</span>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<AddFieldDrawer
|
|
open={drawerOpen}
|
|
selectedType={drawerType}
|
|
existingKeys={existingKeysAt(drawerTargetPath)}
|
|
{dark} {glob}
|
|
onConfirm={handleDrawerConfirm}
|
|
onClose={() => drawerOpen = false}
|
|
/>
|