Files
vigilcare-records/vigilcare-records-web/src/components/ScanViewer.vue
T
Trent 4c5aafe591
CI / backend (push) Successful in 6m14s
CI / frontend (push) Failing after 59s
Fix responsiveness
2026-08-12 05:45:12 +08:00

257 lines
6.3 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>