@cat-factory/app 0.43.0 → 0.44.0
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.
- package/app/components/media/ArtifactLightbox.vue +273 -0
- package/app/components/media/ImageCompare.vue +305 -0
- package/app/components/testing/TestReportWindow.vue +149 -8
- package/app/components/visualConfirm/VisualConfirmationWindow.vue +190 -104
- package/app/composables/useArtifactBlobs.ts +120 -0
- package/app/composables/useFocusTrap.ts +72 -0
- package/app/stores/visualConfirm.ts +6 -35
- package/app/types/domain.ts +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Full-screen artifact viewer — a reusable zoom/pan lightbox for a SET of stored images
|
|
3
|
+
// (screenshots / reference designs), shared by the visual-confirmation gate and the test
|
|
4
|
+
// report window. It reuses the owner's `useArtifactBlobs` cache (passed in as `blobs`) so
|
|
5
|
+
// opening the lightbox never re-fetches a blob the gallery already resolved.
|
|
6
|
+
//
|
|
7
|
+
// Zoom is pure CSS `transform` (GPU, no canvas), so even large PNGs stay smooth. Keyboard:
|
|
8
|
+
// Esc close · ←/→ prev/next · +/- zoom · 0 reset · double-click toggle fit↔2×.
|
|
9
|
+
import { computed, ref, watch } from 'vue'
|
|
10
|
+
import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
|
|
11
|
+
import { useFocusTrap } from '~/composables/useFocusTrap'
|
|
12
|
+
|
|
13
|
+
interface LightboxItem {
|
|
14
|
+
artifactId: string
|
|
15
|
+
label: string
|
|
16
|
+
alt: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const props = defineProps<{
|
|
20
|
+
open: boolean
|
|
21
|
+
index: number
|
|
22
|
+
items: LightboxItem[]
|
|
23
|
+
blobs: ArtifactBlobs
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
(e: 'update:open', value: boolean): void
|
|
28
|
+
(e: 'update:index', value: number): void
|
|
29
|
+
}>()
|
|
30
|
+
|
|
31
|
+
const MIN_SCALE = 1
|
|
32
|
+
const MAX_SCALE = 8
|
|
33
|
+
const scale = ref(1)
|
|
34
|
+
const tx = ref(0)
|
|
35
|
+
const ty = ref(0)
|
|
36
|
+
|
|
37
|
+
// Move focus into the lightbox on open + trap Tab within it (and restore focus on close).
|
|
38
|
+
// It's the topmost surface, so its trap stays live even over an owning review window.
|
|
39
|
+
const dialogRoot = ref<HTMLElement | null>(null)
|
|
40
|
+
useFocusTrap(
|
|
41
|
+
dialogRoot,
|
|
42
|
+
computed(() => props.open),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const current = computed(() => props.items[props.index] ?? null)
|
|
46
|
+
const total = computed(() => props.items.length)
|
|
47
|
+
const url = computed(() =>
|
|
48
|
+
current.value ? props.blobs.urlFor(current.value.artifactId) : undefined,
|
|
49
|
+
)
|
|
50
|
+
const state = computed(() =>
|
|
51
|
+
current.value ? props.blobs.statusFor(current.value.artifactId) : 'idle',
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
function resetView() {
|
|
55
|
+
scale.value = 1
|
|
56
|
+
tx.value = 0
|
|
57
|
+
ty.value = 0
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resolve the active item (plus its immediate neighbours, so ←/→ is instant) whenever the
|
|
61
|
+
// lightbox is open or the index moves. Reset the zoom/pan on every navigation.
|
|
62
|
+
watch(
|
|
63
|
+
() => [props.open, props.index, props.items.length] as const,
|
|
64
|
+
() => {
|
|
65
|
+
if (!props.open) return
|
|
66
|
+
resetView()
|
|
67
|
+
const ids = [props.index - 1, props.index, props.index + 1]
|
|
68
|
+
.map((i) => props.items[i]?.artifactId)
|
|
69
|
+
.filter((v): v is string => !!v)
|
|
70
|
+
for (const id of ids) void props.blobs.resolve(id)
|
|
71
|
+
},
|
|
72
|
+
{ immediate: true },
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
function close() {
|
|
76
|
+
emit('update:open', false)
|
|
77
|
+
}
|
|
78
|
+
function go(delta: number) {
|
|
79
|
+
if (!total.value) return
|
|
80
|
+
const next = (props.index + delta + total.value) % total.value
|
|
81
|
+
emit('update:index', next)
|
|
82
|
+
}
|
|
83
|
+
function zoomBy(factor: number) {
|
|
84
|
+
scale.value = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale.value * factor))
|
|
85
|
+
if (scale.value === 1) {
|
|
86
|
+
tx.value = 0
|
|
87
|
+
ty.value = 0
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function onWheel(e: WheelEvent) {
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
zoomBy(e.deltaY < 0 ? 1.15 : 1 / 1.15)
|
|
93
|
+
}
|
|
94
|
+
function toggleZoom() {
|
|
95
|
+
if (scale.value > 1) resetView()
|
|
96
|
+
else scale.value = 2
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Pointer drag to pan (only meaningful when zoomed in).
|
|
100
|
+
const dragging = ref(false)
|
|
101
|
+
let startX = 0
|
|
102
|
+
let startY = 0
|
|
103
|
+
let baseX = 0
|
|
104
|
+
let baseY = 0
|
|
105
|
+
function onPointerDown(e: PointerEvent) {
|
|
106
|
+
if (scale.value <= 1) return
|
|
107
|
+
dragging.value = true
|
|
108
|
+
startX = e.clientX
|
|
109
|
+
startY = e.clientY
|
|
110
|
+
baseX = tx.value
|
|
111
|
+
baseY = ty.value
|
|
112
|
+
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
|
|
113
|
+
}
|
|
114
|
+
function onPointerMove(e: PointerEvent) {
|
|
115
|
+
if (!dragging.value) return
|
|
116
|
+
tx.value = baseX + (e.clientX - startX)
|
|
117
|
+
ty.value = baseY + (e.clientY - startY)
|
|
118
|
+
}
|
|
119
|
+
function onPointerUp() {
|
|
120
|
+
dragging.value = false
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function onKey(e: KeyboardEvent) {
|
|
124
|
+
if (!props.open) return
|
|
125
|
+
switch (e.key) {
|
|
126
|
+
case 'Escape':
|
|
127
|
+
e.stopPropagation()
|
|
128
|
+
close()
|
|
129
|
+
break
|
|
130
|
+
case 'ArrowLeft':
|
|
131
|
+
go(-1)
|
|
132
|
+
break
|
|
133
|
+
case 'ArrowRight':
|
|
134
|
+
go(1)
|
|
135
|
+
break
|
|
136
|
+
case '+':
|
|
137
|
+
case '=':
|
|
138
|
+
zoomBy(1.25)
|
|
139
|
+
break
|
|
140
|
+
case '-':
|
|
141
|
+
case '_':
|
|
142
|
+
zoomBy(1 / 1.25)
|
|
143
|
+
break
|
|
144
|
+
case '0':
|
|
145
|
+
resetView()
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Capture-phase so Esc closes the lightbox BEFORE the underlying window's own Esc handler
|
|
150
|
+
// (both use window keydown; the lightbox is the topmost surface so it wins).
|
|
151
|
+
onMounted(() => window.addEventListener('keydown', onKey, true))
|
|
152
|
+
onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
<template>
|
|
156
|
+
<Teleport to="body">
|
|
157
|
+
<div
|
|
158
|
+
v-if="open"
|
|
159
|
+
ref="dialogRoot"
|
|
160
|
+
tabindex="-1"
|
|
161
|
+
class="fixed inset-0 z-[60] flex flex-col bg-slate-950/95 backdrop-blur-sm focus:outline-none"
|
|
162
|
+
role="dialog"
|
|
163
|
+
aria-modal="true"
|
|
164
|
+
:aria-label="current ? `Screenshot: ${current.label}` : 'Screenshot viewer'"
|
|
165
|
+
@click.self="close"
|
|
166
|
+
>
|
|
167
|
+
<!-- Toolbar -->
|
|
168
|
+
<div class="flex items-center gap-3 border-b border-slate-800/60 px-4 py-2.5">
|
|
169
|
+
<span class="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-200">
|
|
170
|
+
{{ current?.label ?? 'Screenshot' }}
|
|
171
|
+
</span>
|
|
172
|
+
<span v-if="total > 1" class="shrink-0 text-[12px] tabular-nums text-slate-400">
|
|
173
|
+
{{ index + 1 }} / {{ total }}
|
|
174
|
+
</span>
|
|
175
|
+
<div class="flex shrink-0 items-center gap-1">
|
|
176
|
+
<button
|
|
177
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200 disabled:opacity-40"
|
|
178
|
+
title="Zoom out (-)"
|
|
179
|
+
:disabled="scale <= MIN_SCALE"
|
|
180
|
+
@click="zoomBy(1 / 1.25)"
|
|
181
|
+
>
|
|
182
|
+
<UIcon name="i-lucide-zoom-out" class="h-4 w-4" />
|
|
183
|
+
</button>
|
|
184
|
+
<span class="w-10 text-center text-[11px] tabular-nums text-slate-500"
|
|
185
|
+
>{{ Math.round(scale * 100) }}%</span
|
|
186
|
+
>
|
|
187
|
+
<button
|
|
188
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200 disabled:opacity-40"
|
|
189
|
+
title="Zoom in (+)"
|
|
190
|
+
:disabled="scale >= MAX_SCALE"
|
|
191
|
+
@click="zoomBy(1.25)"
|
|
192
|
+
>
|
|
193
|
+
<UIcon name="i-lucide-zoom-in" class="h-4 w-4" />
|
|
194
|
+
</button>
|
|
195
|
+
<button
|
|
196
|
+
class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
197
|
+
title="Reset (0)"
|
|
198
|
+
@click="resetView"
|
|
199
|
+
>
|
|
200
|
+
<UIcon name="i-lucide-maximize" class="h-4 w-4" />
|
|
201
|
+
</button>
|
|
202
|
+
<button
|
|
203
|
+
class="ml-1 rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
|
|
204
|
+
title="Close (Esc)"
|
|
205
|
+
@click="close"
|
|
206
|
+
>
|
|
207
|
+
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
208
|
+
</button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<!-- Stage -->
|
|
213
|
+
<div
|
|
214
|
+
class="relative flex min-h-0 flex-1 items-center justify-center overflow-hidden p-4"
|
|
215
|
+
@wheel="onWheel"
|
|
216
|
+
@click.self="close"
|
|
217
|
+
>
|
|
218
|
+
<button
|
|
219
|
+
v-if="total > 1"
|
|
220
|
+
class="absolute left-3 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 p-2 text-slate-300 hover:bg-slate-800 hover:text-white"
|
|
221
|
+
title="Previous (←)"
|
|
222
|
+
@click="go(-1)"
|
|
223
|
+
>
|
|
224
|
+
<UIcon name="i-lucide-chevron-left" class="h-5 w-5" />
|
|
225
|
+
</button>
|
|
226
|
+
|
|
227
|
+
<img
|
|
228
|
+
v-if="url"
|
|
229
|
+
:src="url"
|
|
230
|
+
:alt="current?.alt ?? ''"
|
|
231
|
+
draggable="false"
|
|
232
|
+
class="max-h-full max-w-full select-none rounded shadow-2xl"
|
|
233
|
+
:class="[
|
|
234
|
+
scale > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in',
|
|
235
|
+
dragging ? '' : 'transition-transform duration-100',
|
|
236
|
+
]"
|
|
237
|
+
:style="{ transform: `translate(${tx}px, ${ty}px) scale(${scale})` }"
|
|
238
|
+
@dblclick="toggleZoom"
|
|
239
|
+
@pointerdown="onPointerDown"
|
|
240
|
+
@pointermove="onPointerMove"
|
|
241
|
+
@pointerup="onPointerUp"
|
|
242
|
+
@pointercancel="onPointerUp"
|
|
243
|
+
/>
|
|
244
|
+
<div v-else class="flex flex-col items-center gap-2 text-slate-500">
|
|
245
|
+
<UIcon
|
|
246
|
+
:name="state === 'error' ? 'i-lucide-image-off' : 'i-lucide-loader'"
|
|
247
|
+
class="h-8 w-8"
|
|
248
|
+
:class="state === 'error' ? '' : 'animate-spin'"
|
|
249
|
+
/>
|
|
250
|
+
<p class="text-[12px]">
|
|
251
|
+
{{ state === 'error' ? 'Failed to load image.' : 'Loading…' }}
|
|
252
|
+
</p>
|
|
253
|
+
<button
|
|
254
|
+
v-if="state === 'error' && current"
|
|
255
|
+
class="text-[12px] text-amber-300 hover:underline"
|
|
256
|
+
@click="props.blobs.retry(current.artifactId)"
|
|
257
|
+
>
|
|
258
|
+
Retry
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<button
|
|
263
|
+
v-if="total > 1"
|
|
264
|
+
class="absolute right-3 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 p-2 text-slate-300 hover:bg-slate-800 hover:text-white"
|
|
265
|
+
title="Next (→)"
|
|
266
|
+
@click="go(1)"
|
|
267
|
+
>
|
|
268
|
+
<UIcon name="i-lucide-chevron-right" class="h-5 w-5" />
|
|
269
|
+
</button>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</Teleport>
|
|
273
|
+
</template>
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Actual-vs-reference comparator for one view. Four modes:
|
|
3
|
+
// · side-by-side — the two images in a 2-col grid (the original layout)
|
|
4
|
+
// · overlay — stacked, with an opacity (onion-skin) slider on the actual layer
|
|
5
|
+
// · swipe — reference under, actual clipped by a draggable split handle
|
|
6
|
+
// · diff — canvas `difference` composite (identical pixels go black)
|
|
7
|
+
// Modes that need both images hide themselves when there's no reference yet. Clicking any
|
|
8
|
+
// image emits `expand` so the owner can open the shared lightbox; the reference slot doubles
|
|
9
|
+
// as a drag-and-drop / click upload target (emits `uploadReference`).
|
|
10
|
+
import { computed, nextTick, ref, watch } from 'vue'
|
|
11
|
+
import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
view: string
|
|
15
|
+
actualId: string | null | undefined
|
|
16
|
+
referenceId: string | null | undefined
|
|
17
|
+
blobs: ArtifactBlobs
|
|
18
|
+
busy?: boolean
|
|
19
|
+
}>()
|
|
20
|
+
|
|
21
|
+
const emit = defineEmits<{
|
|
22
|
+
(e: 'expand', artifactId: string): void
|
|
23
|
+
(e: 'uploadReference', file: File): void
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
type Mode = 'side-by-side' | 'overlay' | 'swipe' | 'diff'
|
|
27
|
+
|
|
28
|
+
const actualUrl = computed(() => props.blobs.urlFor(props.actualId))
|
|
29
|
+
const refUrl = computed(() => props.blobs.urlFor(props.referenceId))
|
|
30
|
+
const hasBoth = computed(() => !!actualUrl.value && !!refUrl.value)
|
|
31
|
+
|
|
32
|
+
const diffFailed = ref(false)
|
|
33
|
+
const MODES = computed<{ id: Mode; icon: string; label: string }[]>(() => {
|
|
34
|
+
const base: { id: Mode; icon: string; label: string }[] = [
|
|
35
|
+
{ id: 'side-by-side', icon: 'i-lucide-columns-2', label: 'Side by side' },
|
|
36
|
+
]
|
|
37
|
+
if (hasBoth.value) {
|
|
38
|
+
base.push(
|
|
39
|
+
{ id: 'overlay', icon: 'i-lucide-layers', label: 'Overlay' },
|
|
40
|
+
{ id: 'swipe', icon: 'i-lucide-flip-horizontal-2', label: 'Swipe' },
|
|
41
|
+
)
|
|
42
|
+
if (!diffFailed.value) base.push({ id: 'diff', icon: 'i-lucide-contrast', label: 'Difference' })
|
|
43
|
+
}
|
|
44
|
+
return base
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const mode = ref<Mode>('side-by-side')
|
|
48
|
+
// Fall back to side-by-side if the active mode stops being available (e.g. reference removed).
|
|
49
|
+
watch(MODES, (list) => {
|
|
50
|
+
if (!list.some((m) => m.id === mode.value)) mode.value = 'side-by-side'
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const overlayOpacity = ref(50)
|
|
54
|
+
const splitPct = ref(50)
|
|
55
|
+
|
|
56
|
+
// --- swipe handle drag ---
|
|
57
|
+
const swipeBox = ref<HTMLElement | null>(null)
|
|
58
|
+
const swiping = ref(false)
|
|
59
|
+
function onSwipeDown(e: PointerEvent) {
|
|
60
|
+
swiping.value = true
|
|
61
|
+
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
|
|
62
|
+
moveSwipe(e)
|
|
63
|
+
}
|
|
64
|
+
function moveSwipe(e: PointerEvent) {
|
|
65
|
+
if (!swiping.value || !swipeBox.value) return
|
|
66
|
+
const r = swipeBox.value.getBoundingClientRect()
|
|
67
|
+
splitPct.value = Math.min(100, Math.max(0, ((e.clientX - r.left) / r.width) * 100))
|
|
68
|
+
}
|
|
69
|
+
function onSwipeUp() {
|
|
70
|
+
swiping.value = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- diff canvas ---
|
|
74
|
+
const diffCanvas = ref<HTMLCanvasElement | null>(null)
|
|
75
|
+
const CAP = 2000
|
|
76
|
+
// Bumped on every renderDiff entry so a render whose async work (image decode) is overtaken
|
|
77
|
+
// by a newer mode/image change bails out instead of drawing stale pixels onto the canvas.
|
|
78
|
+
let renderToken = 0
|
|
79
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
80
|
+
return new Promise((resolve, reject) => {
|
|
81
|
+
const img = new Image()
|
|
82
|
+
img.onload = () => resolve(img)
|
|
83
|
+
img.onerror = reject
|
|
84
|
+
img.src = src
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
async function renderDiff() {
|
|
88
|
+
if (mode.value !== 'diff' || !actualUrl.value || !refUrl.value) return
|
|
89
|
+
const token = ++renderToken
|
|
90
|
+
await nextTick()
|
|
91
|
+
const canvas = diffCanvas.value
|
|
92
|
+
if (!canvas || token !== renderToken) return
|
|
93
|
+
try {
|
|
94
|
+
const [a, b] = await Promise.all([loadImage(actualUrl.value), loadImage(refUrl.value)])
|
|
95
|
+
if (token !== renderToken) return
|
|
96
|
+
const scale = Math.min(1, CAP / Math.max(a.naturalWidth, a.naturalHeight || 1))
|
|
97
|
+
const w = Math.max(1, Math.round((a.naturalWidth || 1) * scale))
|
|
98
|
+
const h = Math.max(1, Math.round((a.naturalHeight || 1) * scale))
|
|
99
|
+
canvas.width = w
|
|
100
|
+
canvas.height = h
|
|
101
|
+
const ctx = canvas.getContext('2d')
|
|
102
|
+
if (!ctx) throw new Error('no 2d context')
|
|
103
|
+
ctx.clearRect(0, 0, w, h)
|
|
104
|
+
ctx.globalCompositeOperation = 'source-over'
|
|
105
|
+
ctx.drawImage(b, 0, 0, w, h)
|
|
106
|
+
ctx.globalCompositeOperation = 'difference'
|
|
107
|
+
ctx.drawImage(a, 0, 0, w, h)
|
|
108
|
+
ctx.globalCompositeOperation = 'source-over'
|
|
109
|
+
// Touch the pixels to surface a taint SecurityError early (untainted for same-origin
|
|
110
|
+
// blobs, but degrade gracefully if that ever changes).
|
|
111
|
+
ctx.getImageData(0, 0, 1, 1)
|
|
112
|
+
diffFailed.value = false
|
|
113
|
+
} catch {
|
|
114
|
+
diffFailed.value = true
|
|
115
|
+
if (mode.value === 'diff') mode.value = 'overlay'
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
watch([mode, actualUrl, refUrl], renderDiff, { immediate: true })
|
|
119
|
+
|
|
120
|
+
// --- reference upload (drag-drop + click) ---
|
|
121
|
+
const dragOver = ref(false)
|
|
122
|
+
const refInput = ref<HTMLInputElement | null>(null)
|
|
123
|
+
// Accept only the formats the file input advertises (PNG/JPEG), so a dropped GIF/SVG/WebP
|
|
124
|
+
// can't slip past the picker's `accept` filter.
|
|
125
|
+
const ACCEPTED = /^image\/(png|jpeg)$/
|
|
126
|
+
function pickFile(files: FileList | null | undefined) {
|
|
127
|
+
const file = files?.[0]
|
|
128
|
+
if (file && ACCEPTED.test(file.type)) emit('uploadReference', file)
|
|
129
|
+
}
|
|
130
|
+
function onDrop(e: DragEvent) {
|
|
131
|
+
dragOver.value = false
|
|
132
|
+
pickFile(e.dataTransfer?.files)
|
|
133
|
+
}
|
|
134
|
+
function onRefInput(e: Event) {
|
|
135
|
+
pickFile((e.target as HTMLInputElement).files)
|
|
136
|
+
if (refInput.value) refInput.value.value = ''
|
|
137
|
+
}
|
|
138
|
+
</script>
|
|
139
|
+
|
|
140
|
+
<template>
|
|
141
|
+
<div class="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
|
|
142
|
+
<div class="mb-2 flex items-center justify-between gap-2">
|
|
143
|
+
<h3 class="min-w-0 truncate text-[12px] font-semibold text-slate-200">{{ view }}</h3>
|
|
144
|
+
<!-- Mode switch -->
|
|
145
|
+
<div
|
|
146
|
+
v-if="MODES.length > 1"
|
|
147
|
+
class="flex items-center gap-0.5 rounded-md border border-slate-800 bg-slate-950/60 p-0.5"
|
|
148
|
+
>
|
|
149
|
+
<button
|
|
150
|
+
v-for="m in MODES"
|
|
151
|
+
:key="m.id"
|
|
152
|
+
class="rounded px-1.5 py-1 text-slate-400 hover:text-slate-200"
|
|
153
|
+
:class="mode === m.id ? 'bg-slate-800 text-slate-100' : ''"
|
|
154
|
+
:title="m.label"
|
|
155
|
+
@click="mode = m.id"
|
|
156
|
+
>
|
|
157
|
+
<UIcon :name="m.icon" class="h-3.5 w-3.5" />
|
|
158
|
+
</button>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<!-- SIDE BY SIDE -->
|
|
163
|
+
<div v-if="mode === 'side-by-side'" class="grid grid-cols-2 gap-3">
|
|
164
|
+
<figure class="space-y-1">
|
|
165
|
+
<figcaption class="text-[10px] uppercase tracking-wide text-slate-500">Actual</figcaption>
|
|
166
|
+
<button
|
|
167
|
+
v-if="actualUrl"
|
|
168
|
+
class="block w-full overflow-hidden rounded border border-slate-800 hover:border-slate-600"
|
|
169
|
+
@click="actualId && emit('expand', actualId)"
|
|
170
|
+
>
|
|
171
|
+
<img :src="actualUrl" :alt="`${view} (actual)`" class="w-full cursor-zoom-in" />
|
|
172
|
+
</button>
|
|
173
|
+
<div
|
|
174
|
+
v-else
|
|
175
|
+
class="flex h-32 items-center justify-center rounded border border-dashed border-slate-700 text-[11px] text-slate-600"
|
|
176
|
+
>
|
|
177
|
+
{{
|
|
178
|
+
props.blobs.statusFor(actualId) === 'error'
|
|
179
|
+
? 'Failed to load'
|
|
180
|
+
: actualId
|
|
181
|
+
? 'Loading…'
|
|
182
|
+
: 'Not captured'
|
|
183
|
+
}}
|
|
184
|
+
</div>
|
|
185
|
+
</figure>
|
|
186
|
+
|
|
187
|
+
<figure class="space-y-1">
|
|
188
|
+
<figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
189
|
+
Reference
|
|
190
|
+
</figcaption>
|
|
191
|
+
<button
|
|
192
|
+
v-if="refUrl"
|
|
193
|
+
class="group relative block w-full overflow-hidden rounded border border-slate-800 hover:border-slate-600"
|
|
194
|
+
@click="referenceId && emit('expand', referenceId)"
|
|
195
|
+
>
|
|
196
|
+
<img :src="refUrl" :alt="`${view} (reference)`" class="w-full cursor-zoom-in" />
|
|
197
|
+
<span
|
|
198
|
+
class="absolute bottom-1 right-1 rounded bg-slate-950/80 px-1.5 py-0.5 text-[10px] text-slate-300 opacity-0 group-hover:opacity-100"
|
|
199
|
+
@click.stop="refInput?.click()"
|
|
200
|
+
>
|
|
201
|
+
Replace
|
|
202
|
+
</span>
|
|
203
|
+
</button>
|
|
204
|
+
<!-- Drop zone when no reference yet -->
|
|
205
|
+
<div
|
|
206
|
+
v-else
|
|
207
|
+
class="flex h-32 cursor-pointer flex-col items-center justify-center gap-1 rounded border border-dashed text-[11px] transition"
|
|
208
|
+
:class="
|
|
209
|
+
dragOver
|
|
210
|
+
? 'border-amber-500 bg-amber-500/5 text-amber-300'
|
|
211
|
+
: 'border-slate-700 text-slate-600 hover:border-slate-500 hover:text-slate-400'
|
|
212
|
+
"
|
|
213
|
+
@click="refInput?.click()"
|
|
214
|
+
@dragover.prevent="dragOver = true"
|
|
215
|
+
@dragleave.prevent="dragOver = false"
|
|
216
|
+
@drop.prevent="onDrop"
|
|
217
|
+
>
|
|
218
|
+
<UIcon name="i-lucide-image-up" class="h-5 w-5" />
|
|
219
|
+
<span>Drop or click to add a reference</span>
|
|
220
|
+
</div>
|
|
221
|
+
</figure>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<!-- OVERLAY (onion-skin) -->
|
|
225
|
+
<div v-else-if="mode === 'overlay'" class="space-y-2">
|
|
226
|
+
<div class="relative w-full overflow-hidden rounded border border-slate-800">
|
|
227
|
+
<img :src="refUrl" :alt="`${view} (reference)`" class="w-full" />
|
|
228
|
+
<!-- object-contain so a differing aspect ratio onion-skins undistorted over the reference. -->
|
|
229
|
+
<img
|
|
230
|
+
:src="actualUrl"
|
|
231
|
+
:alt="`${view} (actual)`"
|
|
232
|
+
class="absolute inset-0 h-full w-full object-contain"
|
|
233
|
+
:style="{ opacity: overlayOpacity / 100 }"
|
|
234
|
+
/>
|
|
235
|
+
</div>
|
|
236
|
+
<div class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
|
|
237
|
+
<span>Reference</span>
|
|
238
|
+
<input
|
|
239
|
+
v-model.number="overlayOpacity"
|
|
240
|
+
type="range"
|
|
241
|
+
min="0"
|
|
242
|
+
max="100"
|
|
243
|
+
class="flex-1 accent-amber-500"
|
|
244
|
+
/>
|
|
245
|
+
<span>Actual</span>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<!-- SWIPE (split slider) -->
|
|
250
|
+
<div
|
|
251
|
+
v-else-if="mode === 'swipe'"
|
|
252
|
+
ref="swipeBox"
|
|
253
|
+
class="relative w-full cursor-ew-resize select-none overflow-hidden rounded border border-slate-800"
|
|
254
|
+
@pointerdown="onSwipeDown"
|
|
255
|
+
@pointermove="moveSwipe"
|
|
256
|
+
@pointerup="onSwipeUp"
|
|
257
|
+
@pointercancel="onSwipeUp"
|
|
258
|
+
>
|
|
259
|
+
<img :src="refUrl" :alt="`${view} (reference)`" class="block w-full" />
|
|
260
|
+
<div
|
|
261
|
+
class="absolute inset-0 overflow-hidden"
|
|
262
|
+
:style="{ clipPath: `inset(0 ${100 - splitPct}% 0 0)` }"
|
|
263
|
+
>
|
|
264
|
+
<!-- Fit the actual within the reference's box (object-contain) so a differing aspect
|
|
265
|
+
ratio doesn't stretch it; the split then compares like-for-like. -->
|
|
266
|
+
<img
|
|
267
|
+
:src="actualUrl"
|
|
268
|
+
:alt="`${view} (actual)`"
|
|
269
|
+
class="absolute inset-0 block h-full w-full object-contain"
|
|
270
|
+
/>
|
|
271
|
+
</div>
|
|
272
|
+
<div class="absolute inset-y-0 w-0.5 bg-amber-400" :style="{ left: `${splitPct}%` }">
|
|
273
|
+
<span
|
|
274
|
+
class="absolute top-1/2 left-1/2 flex h-6 w-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-amber-400 text-slate-950 shadow"
|
|
275
|
+
>
|
|
276
|
+
<UIcon name="i-lucide-move-horizontal" class="h-3.5 w-3.5" />
|
|
277
|
+
</span>
|
|
278
|
+
</div>
|
|
279
|
+
<span
|
|
280
|
+
class="absolute left-1 top-1 rounded bg-slate-950/70 px-1 text-[9px] uppercase text-slate-300"
|
|
281
|
+
>Actual</span
|
|
282
|
+
>
|
|
283
|
+
<span
|
|
284
|
+
class="absolute right-1 top-1 rounded bg-slate-950/70 px-1 text-[9px] uppercase text-slate-300"
|
|
285
|
+
>Reference</span
|
|
286
|
+
>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<!-- DIFF (canvas) -->
|
|
290
|
+
<div v-else-if="mode === 'diff'" class="space-y-1">
|
|
291
|
+
<canvas ref="diffCanvas" class="w-full rounded border border-slate-800 bg-black" />
|
|
292
|
+
<p class="text-[10px] text-slate-500">Identical pixels appear black; differences glow.</p>
|
|
293
|
+
</div>
|
|
294
|
+
|
|
295
|
+
<!-- Hidden file input shared by replace/drop zone -->
|
|
296
|
+
<input
|
|
297
|
+
ref="refInput"
|
|
298
|
+
type="file"
|
|
299
|
+
accept="image/png,image/jpeg"
|
|
300
|
+
class="hidden"
|
|
301
|
+
:disabled="busy"
|
|
302
|
+
@change="onRefInput"
|
|
303
|
+
/>
|
|
304
|
+
</div>
|
|
305
|
+
</template>
|