257 lines
6.3 KiB
Vue
257 lines
6.3 KiB
Vue
<template>
|
||
<div
|
||
class="h-full flex flex-col bg-canvas rounded-card border border-line overflow-hidden"
|
||
data-testid="scan-viewer"
|
||
>
|
||
<!-- Toolbar — light chrome, no decorative overlays on the page -->
|
||
<div
|
||
class="flex flex-wrap items-center gap-0.5 sm:gap-1 px-2 sm:px-3 py-2 bg-surface border-b border-line text-sm text-ink shrink-0"
|
||
data-testid="scan-viewer-toolbar"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="scan-toolbar-btn"
|
||
title="Zoom out"
|
||
:disabled="!url"
|
||
@click="zoomOut"
|
||
>
|
||
−
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="scan-toolbar-btn"
|
||
title="Zoom in"
|
||
:disabled="!url"
|
||
@click="zoomIn"
|
||
>
|
||
+
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="scan-toolbar-btn"
|
||
title="Fit width"
|
||
:disabled="!url"
|
||
data-testid="scan-fit-width"
|
||
@click="fitWidth"
|
||
>
|
||
Fit width
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="scan-toolbar-btn"
|
||
title="Reset zoom"
|
||
:disabled="!url"
|
||
@click="resetZoom"
|
||
>
|
||
1:1
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="scan-toolbar-btn"
|
||
title="Rotate 90°"
|
||
:disabled="!url"
|
||
@click="rotateCw"
|
||
>
|
||
Rotate
|
||
</button>
|
||
<span class="ml-auto text-ink-secondary text-xs tabular-nums">
|
||
{{ url ? `${Math.round(scale * 100)}%` : '' }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Document surface -->
|
||
<div
|
||
ref="viewerContainer"
|
||
class="flex-1 min-h-0 overflow-auto bg-[#E8ECF1]"
|
||
:class="url && !loading && !error ? 'cursor-grab active:cursor-grabbing' : ''"
|
||
@mousedown="startPan"
|
||
@mousemove="pan"
|
||
@mouseup="stopPan"
|
||
@mouseleave="stopPan"
|
||
@wheel.prevent="onWheel"
|
||
>
|
||
<div v-if="loading" class="flex h-full items-center justify-center p-8">
|
||
<SkeletonBlock
|
||
variant="block"
|
||
height="min(70vh, 640px)"
|
||
width="min(100%, 480px)"
|
||
class="max-w-full shadow-page bg-surface"
|
||
/>
|
||
</div>
|
||
|
||
<div v-else-if="error" class="flex min-h-[10rem] items-center justify-center p-4 sm:p-6">
|
||
<InlineError
|
||
class="max-w-md w-full"
|
||
title="Could not load document"
|
||
:message="error"
|
||
preserved="Your form draft was not affected."
|
||
retry-label="Retry"
|
||
@retry="$emit('retry')"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="url"
|
||
class="p-6 inline-block min-w-full"
|
||
>
|
||
<div
|
||
:style="{
|
||
transform: `translate(${panX}px, ${panY}px) scale(${scale}) rotate(${rotation}deg)`,
|
||
transformOrigin: 'top left',
|
||
transition: isPanning ? 'none' : 'transform 0.15s ease-out',
|
||
}"
|
||
>
|
||
<!-- PDF/image from same-origin blob URL (avoids cross-origin MinIO iframe issues) -->
|
||
<iframe
|
||
v-if="isPdf"
|
||
:src="url"
|
||
class="scan-page w-[800px] h-[1100px] bg-white"
|
||
frameborder="0"
|
||
title="Scanned document"
|
||
/>
|
||
<img
|
||
v-else
|
||
:src="url"
|
||
class="scan-page max-w-none bg-white"
|
||
draggable="false"
|
||
alt="Scanned document"
|
||
@load="onImageLoad"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<EmptyState
|
||
v-else
|
||
title="No document available"
|
||
description="The source scan will appear here when the batch document loads."
|
||
class="h-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch, nextTick } from 'vue'
|
||
import SkeletonBlock from './SkeletonBlock.vue'
|
||
import InlineError from './InlineError.vue'
|
||
import EmptyState from './EmptyState.vue'
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
url?: string | null
|
||
loading?: boolean
|
||
error?: string | null
|
||
}>(),
|
||
{
|
||
url: null,
|
||
loading: false,
|
||
error: null,
|
||
}
|
||
)
|
||
|
||
defineEmits<{
|
||
(e: 'retry'): void
|
||
}>()
|
||
|
||
const isPdf = computed(() => {
|
||
if (!props.url) return false
|
||
const lower = props.url.toLowerCase()
|
||
return lower.includes('.pdf') || lower.includes('application/pdf')
|
||
})
|
||
|
||
const scale = ref(1)
|
||
const rotation = ref(0)
|
||
const panX = ref(0)
|
||
const panY = ref(0)
|
||
const isPanning = ref(false)
|
||
const lastX = ref(0)
|
||
const lastY = ref(0)
|
||
const viewerContainer = ref<HTMLElement | null>(null)
|
||
const naturalPageWidth = ref(800)
|
||
|
||
function zoomIn() {
|
||
scale.value = Math.min(scale.value + 0.25, 5)
|
||
}
|
||
function zoomOut() {
|
||
scale.value = Math.max(scale.value - 0.25, 0.25)
|
||
}
|
||
function resetZoom() {
|
||
scale.value = 1
|
||
panX.value = 0
|
||
panY.value = 0
|
||
rotation.value = 0
|
||
}
|
||
function rotateCw() {
|
||
rotation.value = (rotation.value + 90) % 360
|
||
}
|
||
|
||
function fitWidth() {
|
||
const container = viewerContainer.value
|
||
if (!container) return
|
||
const padding = 48 // matches p-6
|
||
const available = Math.max(container.clientWidth - padding, 120)
|
||
scale.value = Math.min(Math.max(available / naturalPageWidth.value, 0.25), 5)
|
||
panX.value = 0
|
||
panY.value = 0
|
||
}
|
||
|
||
function onWheel(e: WheelEvent) {
|
||
if (!props.url || props.loading || props.error) return
|
||
if (e.deltaY < 0) zoomIn()
|
||
else zoomOut()
|
||
}
|
||
|
||
function startPan(e: MouseEvent) {
|
||
if (!props.url || props.loading || props.error) return
|
||
if ((e.target as HTMLElement)?.closest('button')) return
|
||
isPanning.value = true
|
||
lastX.value = e.clientX
|
||
lastY.value = e.clientY
|
||
}
|
||
|
||
function pan(e: MouseEvent) {
|
||
if (!isPanning.value) return
|
||
panX.value += e.clientX - lastX.value
|
||
panY.value += e.clientY - lastY.value
|
||
lastX.value = e.clientX
|
||
lastY.value = e.clientY
|
||
}
|
||
|
||
function stopPan() {
|
||
isPanning.value = false
|
||
}
|
||
|
||
function onImageLoad(e: Event) {
|
||
const img = e.target as HTMLImageElement
|
||
if (img.naturalWidth > 0) {
|
||
naturalPageWidth.value = img.naturalWidth
|
||
}
|
||
scale.value = 1
|
||
panX.value = 0
|
||
panY.value = 0
|
||
nextTick(() => fitWidth())
|
||
}
|
||
|
||
watch(
|
||
() => props.url,
|
||
(url) => {
|
||
if (!url) return
|
||
if (isPdf.value) {
|
||
naturalPageWidth.value = 800
|
||
nextTick(() => fitWidth())
|
||
}
|
||
}
|
||
)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.scan-toolbar-btn {
|
||
@apply px-2 py-1 rounded-control text-ink hover:bg-canvas
|
||
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent
|
||
transition-colors;
|
||
}
|
||
.scan-page {
|
||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.08);
|
||
}
|
||
</style>
|