|
|
<script lang="ts">
|
|
|
import { untrack, getContext } from 'svelte'
|
|
|
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
|
|
|
import type { View, HotspotID, LayerDefinition,
|
|
|
ViewResolution, ResolvedLayersView } from '@/vcen/lib'
|
|
|
|
|
|
const glob = getContext<EditorGlob>('editor-glob')
|
|
|
const STRATEGY_LABELS = $derived<Record<string, string>>({
|
|
|
static: glob.t('vcen.editor.view.strategies.static'),
|
|
|
dynamic: glob.t('vcen.editor.view.strategies.dynamic'),
|
|
|
layers: glob.t('vcen.editor.view.strategies.layers'),
|
|
|
api: glob.t('vcen.editor.view.strategies.api'),
|
|
|
d3: glob.t('vcen.editor.view.strategies.d3'),
|
|
|
})
|
|
|
|
|
|
let {
|
|
|
view,
|
|
|
resolution = null,
|
|
|
activeHotspotId = null,
|
|
|
pickingPos = false,
|
|
|
onPick,
|
|
|
getLabelStr,
|
|
|
}: {
|
|
|
view: View
|
|
|
resolution?: ViewResolution | null
|
|
|
activeHotspotId?: HotspotID | null
|
|
|
pickingPos?: boolean
|
|
|
onPick?: (x: number, y: number) => void
|
|
|
getLabelStr: (label: any) => string
|
|
|
} = $props()
|
|
|
|
|
|
const strategy = $derived(view.config.strategy)
|
|
|
|
|
|
// URLs resueltas
|
|
|
const resolvedUrl = $derived.by((): string => {
|
|
|
const r = resolution?.resolved
|
|
|
if (r?.strategy === 'dynamic') return (r as any).url
|
|
|
return ''
|
|
|
})
|
|
|
const resolvedLayers = $derived.by((): Array<{ url: string; zIndex: number }> => {
|
|
|
const r = resolution?.resolved
|
|
|
if (r?.strategy !== 'layers') return []
|
|
|
return (r as ResolvedLayersView).layers
|
|
|
})
|
|
|
const apiEp = $derived.by((): string => {
|
|
|
const r = resolution?.resolved
|
|
|
if (r?.strategy === 'api') return (r as any).endpoint
|
|
|
return ''
|
|
|
})
|
|
|
|
|
|
// Fallbacks sin resolución
|
|
|
function cfgGet(key: string): string {
|
|
|
return ((view.config as any)[key] as string) ?? ''
|
|
|
}
|
|
|
const staticUrl = $derived(strategy === 'static' ? cfgGet('url') : '')
|
|
|
const dynTpl = $derived(strategy === 'dynamic' ? cfgGet('template') : '')
|
|
|
const d3Model = $derived(strategy === 'd3' ? cfgGet('modelUrl') : '')
|
|
|
const layers = $derived(strategy === 'layers'
|
|
|
? (((view.config as any).layers ?? []) as LayerDefinition[])
|
|
|
: [] as LayerDefinition[])
|
|
|
|
|
|
let imgError = $state(false)
|
|
|
let imgLoaded = $state(false)
|
|
|
|
|
|
$effect(() => {
|
|
|
void staticUrl; void resolvedUrl; void apiEp
|
|
|
imgError = false; imgLoaded = false
|
|
|
})
|
|
|
|
|
|
function handleClick(e: MouseEvent) {
|
|
|
if (!pickingPos || !onPick) return
|
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
|
const x = Math.round(Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100)) * 10) / 10
|
|
|
const y = Math.round(Math.min(100, Math.max(0, ((e.clientY - rect.top) / rect.height) * 100)) * 10) / 10
|
|
|
onPick(x, y)
|
|
|
}
|
|
|
|
|
|
// ── Panel de capas ────────────────────────────────────────────────────────────
|
|
|
// Capas ocultas por el usuario (índice en resolvedLayers, ordenado por zIndex desc)
|
|
|
let hiddenLayers = $state<Set<number>>(new Set())
|
|
|
|
|
|
// Cuando cambian las capas resueltas, limpiar ocultas que ya no existen
|
|
|
$effect(() => {
|
|
|
const n = resolvedLayers.length
|
|
|
const current = untrack(() => hiddenLayers)
|
|
|
const filtered = new Set([...current].filter(i => i < n))
|
|
|
if (filtered.size !== current.size) hiddenLayers = filtered
|
|
|
})
|
|
|
|
|
|
// Ordenar capas de mayor a menor zIndex para el panel (igual que Photoshop)
|
|
|
const sortedLayerIndices = $derived(
|
|
|
resolvedLayers
|
|
|
.map((l, i) => ({ i, zIndex: l.zIndex }))
|
|
|
.sort((a, b) => b.zIndex - a.zIndex)
|
|
|
.map(x => x.i)
|
|
|
)
|
|
|
|
|
|
function toggleLayer(i: number) {
|
|
|
const next = new Set(hiddenLayers)
|
|
|
if (next.has(i)) next.delete(i)
|
|
|
else next.add(i)
|
|
|
hiddenLayers = next
|
|
|
}
|
|
|
|
|
|
// Panel de capas — mostrar si hay capas resueltas O capas sin resolver
|
|
|
const showLayersPanel = $derived(strategy === 'layers' && (resolvedLayers.length > 0 || layers.length > 0))
|
|
|
let layersPanelOpen = $state(true)
|
|
|
</script>
|
|
|
|
|
|
<div class="flex-1 border-l border-gray-800 flex flex-col overflow-y-auto min-w-0">
|
|
|
|
|
|
<div class="px-3 py-2 border-b border-gray-800 flex items-center justify-between shrink-0">
|
|
|
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">preview</span>
|
|
|
<span class="text-[10px] text-gray-700 font-mono">{STRATEGY_LABELS[strategy] ?? strategy}</span>
|
|
|
</div>
|
|
|
|
|
|
<div class="p-3 flex flex-col gap-3">
|
|
|
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
|
<div
|
|
|
class="relative w-full rounded border border-gray-800 bg-gray-900 overflow-hidden
|
|
|
{pickingPos ? 'ring-2 ring-indigo-500/60' : ''}"
|
|
|
style="aspect-ratio: {view.width || 4} / {view.height || 3};
|
|
|
cursor: {pickingPos ? 'crosshair' : 'default'}"
|
|
|
onclick={handleClick}
|
|
|
>
|
|
|
{#if strategy === 'static' && staticUrl}
|
|
|
{#if !imgError}
|
|
|
<img src={staticUrl} alt={getLabelStr(view.label) || view.code}
|
|
|
class="absolute inset-0 w-full h-full object-contain object-top pointer-events-none"
|
|
|
onload={() => imgLoaded = true} onerror={() => imgError = true} />
|
|
|
{:else}
|
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
|
|
|
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="3" x2="21" y2="21"/>
|
|
|
</svg>
|
|
|
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
|
|
|
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{staticUrl}</span>
|
|
|
</div>
|
|
|
{/if}
|
|
|
|
|
|
{:else if strategy === 'dynamic' && resolvedUrl}
|
|
|
{#if !imgError}
|
|
|
<img src={resolvedUrl} alt={getLabelStr(view.label) || view.code}
|
|
|
class="absolute inset-0 w-full h-full object-contain object-top pointer-events-none"
|
|
|
onload={() => imgLoaded = true} onerror={() => imgError = true} />
|
|
|
{:else}
|
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
|
|
|
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="3" x2="21" y2="21"/>
|
|
|
</svg>
|
|
|
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
|
|
|
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{resolvedUrl}</span>
|
|
|
</div>
|
|
|
{/if}
|
|
|
{#if resolution?.hasFallback}
|
|
|
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded bg-amber-900/80 text-amber-300 text-[9px] font-medium">fallback</div>
|
|
|
{/if}
|
|
|
|
|
|
{:else if strategy === 'layers' && resolvedLayers.length > 0}
|
|
|
{#each resolvedLayers as layer, li (li)}
|
|
|
{#if !hiddenLayers.has(li)}
|
|
|
<img src={layer.url} alt={glob.t('vcen.editor.view.layerN', { n: li + 1 })}
|
|
|
class="absolute inset-0 w-full h-full object-contain object-top pointer-events-none"
|
|
|
style="z-index: {layer.zIndex}" />
|
|
|
{/if}
|
|
|
{/each}
|
|
|
{#if resolution?.hasFallback}
|
|
|
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded bg-amber-900/80 text-amber-300 text-[9px] font-medium z-50">fallback</div>
|
|
|
{/if}
|
|
|
|
|
|
{:else if strategy === 'layers' && layers.length === 0}
|
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none">
|
|
|
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
|
|
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
|
|
</svg>
|
|
|
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noLayers')}</span>
|
|
|
</div>
|
|
|
|
|
|
{:else if strategy === 'dynamic'}
|
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
|
|
|
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>
|
|
|
</svg>
|
|
|
{#if dynTpl}
|
|
|
<span class="text-[10px] font-mono text-center break-all text-gray-600">{dynTpl}</span>
|
|
|
{:else}
|
|
|
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noTemplate')}</span>
|
|
|
{/if}
|
|
|
</div>
|
|
|
|
|
|
{:else if strategy === 'api'}
|
|
|
{#if apiEp && !imgError}
|
|
|
<img src={apiEp} alt={getLabelStr(view.label) || view.code}
|
|
|
class="absolute inset-0 w-full h-full object-contain object-top pointer-events-none"
|
|
|
onload={() => imgLoaded = true} onerror={() => imgError = true} />
|
|
|
{:else if apiEp && imgError}
|
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
|
|
|
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="3" x2="21" y2="21"/>
|
|
|
</svg>
|
|
|
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
|
|
|
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{apiEp}</span>
|
|
|
</div>
|
|
|
{:else}
|
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
|
|
|
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
|
|
<path d="M21 12h-4m-2-7 2 7-2 7M3 12h4m2-7-2 7 2 7"/>
|
|
|
</svg>
|
|
|
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noEndpoint')}</span>
|
|
|
</div>
|
|
|
{/if}
|
|
|
|
|
|
{:else if strategy === 'd3'}
|
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
|
|
|
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
|
|
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
|
|
</svg>
|
|
|
{#if d3Model}
|
|
|
<span class="text-[10px] font-mono text-center break-all text-gray-600">{d3Model}</span>
|
|
|
{:else}
|
|
|
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noModel')}</span>
|
|
|
{/if}
|
|
|
</div>
|
|
|
|
|
|
{:else}
|
|
|
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none">
|
|
|
<svg class="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>
|
|
|
</svg>
|
|
|
<span class="text-[10px]">{pickingPos ? glob.t('vcen.editor.hotspot.clickToPosition') : glob.t('vcen.editor.view.noImage')}</span>
|
|
|
</div>
|
|
|
{/if}
|
|
|
|
|
|
<!-- Hotspot markers -->
|
|
|
{#each (view.hotspots ?? []) as marker (marker.id)}
|
|
|
{@const isActive = activeHotspotId === marker.id}
|
|
|
{@const mX = typeof marker.posX === 'number' ? marker.posX : parseFloat(String(marker.posX)) || 0}
|
|
|
{@const mY = typeof marker.posY === 'number' ? marker.posY : parseFloat(String(marker.posY)) || 0}
|
|
|
{@const size = (marker.radius ?? 8) * 2}
|
|
|
<div
|
|
|
class="absolute rounded-full border-2 transition-all pointer-events-none
|
|
|
{isActive ? 'border-white shadow-lg' : 'border-white/50'}"
|
|
|
style="left:{mX}%;top:{mY}%;width:{size}px;height:{size}px;
|
|
|
transform:translate(-50%,-50%){isActive ? ' scale(1.25)' : ''};
|
|
|
background-color:{marker.color ?? '#4f46e5'};
|
|
|
opacity:{isActive ? 1 : 0.7};z-index:{(marker.zIndex ?? 1) + 10};"
|
|
|
title={getLabelStr(marker.label) || marker.id}
|
|
|
></div>
|
|
|
{/each}
|
|
|
|
|
|
<!-- Panel flotante de capas -->
|
|
|
{#if showLayersPanel}
|
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
|
<div
|
|
|
class="absolute bottom-2 right-2 z-50 rounded border border-gray-700 bg-gray-950/90
|
|
|
backdrop-blur-sm shadow-xl min-w-[140px] max-w-[200px]"
|
|
|
onclick={e => e.stopPropagation()}
|
|
|
>
|
|
|
<!-- Cabecera del panel -->
|
|
|
<button
|
|
|
onclick={() => layersPanelOpen = !layersPanelOpen}
|
|
|
class="flex items-center gap-1.5 w-full px-2 py-1.5 hover:bg-gray-800/50
|
|
|
transition-colors rounded-t border-b border-gray-800/60"
|
|
|
>
|
|
|
<svg class="h-3 w-3 text-gray-500 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
|
|
</svg>
|
|
|
<span class="text-[9px] font-bold uppercase tracking-widest text-gray-500 flex-1 text-left">{glob.t('vcen.editor.view.layers')}</span>
|
|
|
{#if resolvedLayers.length > 0 && hiddenLayers.size > 0}
|
|
|
<span class="text-[9px] text-amber-600">{hiddenLayers.size} oculta{hiddenLayers.size !== 1 ? 's' : ''}</span>
|
|
|
{/if}
|
|
|
<svg class="h-2.5 w-2.5 text-gray-600 shrink-0 transition-transform {layersPanelOpen ? '' : 'rotate-180'}"
|
|
|
viewBox="0 0 8 8" fill="currentColor"><polygon points="1,6 7,6 4,2"/></svg>
|
|
|
</button>
|
|
|
|
|
|
{#if layersPanelOpen}
|
|
|
<div class="flex flex-col py-0.5">
|
|
|
{#if resolvedLayers.length > 0}
|
|
|
<!-- Capas resueltas: toggle de visibilidad -->
|
|
|
{#each sortedLayerIndices as li (li)}
|
|
|
{@const layer = resolvedLayers[li]}
|
|
|
{@const hidden = hiddenLayers.has(li)}
|
|
|
<button
|
|
|
onclick={() => toggleLayer(li)}
|
|
|
class="flex items-center gap-1.5 px-2 py-1 hover:bg-gray-800/60
|
|
|
transition-colors w-full text-left group"
|
|
|
>
|
|
|
<!-- Eye icon -->
|
|
|
<svg class="h-3 w-3 shrink-0 transition-colors
|
|
|
{hidden ? 'text-gray-700' : 'text-gray-400 group-hover:text-gray-200'}"
|
|
|
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
{#if hidden}
|
|
|
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
|
|
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
|
|
<line x1="1" y1="1" x2="23" y2="23"/>
|
|
|
{:else}
|
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
|
<circle cx="12" cy="12" r="3"/>
|
|
|
{/if}
|
|
|
</svg>
|
|
|
<!-- z-index badge -->
|
|
|
<span class="text-[9px] font-mono shrink-0
|
|
|
{hidden ? 'text-gray-700' : 'text-indigo-500'}">z{layer.zIndex}</span>
|
|
|
<!-- URL truncada -->
|
|
|
<span class="text-[9px] font-mono truncate flex-1
|
|
|
{hidden ? 'text-gray-700 line-through' : 'text-gray-500'}">
|
|
|
{layer.url.split('/').pop() ?? layer.url}
|
|
|
</span>
|
|
|
</button>
|
|
|
{/each}
|
|
|
{:else}
|
|
|
<!-- Capas sin resolver: info estática -->
|
|
|
{#each [...layers].sort((a, b) => (b.zIndex ?? 0) - (a.zIndex ?? 0)) as layer, li (li)}
|
|
|
<div class="flex items-center gap-1.5 px-2 py-1">
|
|
|
<svg class="h-3 w-3 text-gray-700 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
|
<circle cx="12" cy="12" r="3"/>
|
|
|
</svg>
|
|
|
<span class="text-[9px] font-mono text-indigo-500/50 shrink-0">z{layer.zIndex ?? 0}</span>
|
|
|
<span class="text-[9px] font-mono text-gray-700 truncate flex-1">{layer.attId || '—'}</span>
|
|
|
{#if layer.condition}
|
|
|
<span class="text-[9px] text-amber-700 shrink-0">if</span>
|
|
|
{/if}
|
|
|
</div>
|
|
|
{/each}
|
|
|
{/if}
|
|
|
</div>
|
|
|
{/if}
|
|
|
</div>
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<!-- Info inferior -->
|
|
|
<div class="flex flex-col gap-1 text-[10px] text-gray-700 font-mono">
|
|
|
<div class="flex items-center justify-between">
|
|
|
<span>{view.width || '?'} × {view.height || '?'} px</span>
|
|
|
{#if pickingPos}
|
|
|
<span class="text-indigo-400 font-sans font-medium">{glob.t('vcen.editor.hotspot.clickToPosition')}</span>
|
|
|
{/if}
|
|
|
</div>
|
|
|
{#if resolvedUrl || (strategy === 'static' && staticUrl)}
|
|
|
<span class="text-gray-600 break-all">{resolvedUrl || staticUrl}</span>
|
|
|
{/if}
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
</div>
|