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.
v/src/vcen-edit/comps/VisualPreview.svelte

352 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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>

Powered by TurnKey Linux.