Merge branch 'image-extraction-frontend'

This commit is contained in:
ggq-admin 2025-10-19 20:00:28 +02:00
commit 08ccc1ee93
3 changed files with 440 additions and 7 deletions

View file

@ -0,0 +1,291 @@
<template>
<div
class="image-overlay"
:style="overlayStyle"
@click="handleClick"
@mouseenter="showTooltip = true"
@mouseleave="showTooltip = false"
role="button"
tabindex="0"
:aria-label="`Image ${image.imageIndex + 1} - Click to view full size`"
@keydown.enter="handleClick"
@keydown.space="handleClick"
>
<!-- Semi-transparent border indicator -->
<div class="overlay-border"></div>
<!-- Tooltip showing OCR text on hover -->
<div
v-if="showTooltip && image.extractedText"
class="image-tooltip"
role="tooltip"
>
<div class="tooltip-header">
<span class="tooltip-title">Extracted Text</span>
<span v-if="image.textConfidence" class="tooltip-confidence">
{{ Math.round(image.textConfidence * 100) }}% confidence
</span>
</div>
<div class="tooltip-text">{{ image.extractedText }}</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
image: {
type: Object,
required: true,
validator: (image) => {
return image && image.id && image.position
}
},
canvasWidth: {
type: Number,
required: true
},
canvasHeight: {
type: Number,
required: true
},
pdfScale: {
type: Number,
default: 1.5
}
})
const emit = defineEmits(['click'])
const showTooltip = ref(false)
/**
* Calculate overlay position and size based on PDF coordinates
* Position from DB is in PDF coordinates, we need to convert to canvas pixels
*/
const overlayStyle = computed(() => {
let position
try {
position = typeof props.image.position === 'string'
? JSON.parse(props.image.position)
: props.image.position
} catch (e) {
console.error('Error parsing image position:', e)
return {}
}
if (!position || !position.x || !position.y || !position.width || !position.height) {
return {}
}
// Convert PDF coordinates to canvas pixels
// The position is in PDF points, we need to scale it to match the canvas
const scale = props.pdfScale
const left = position.x * scale
const top = position.y * scale
const width = position.width * scale
const height = position.height * scale
return {
position: 'absolute',
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
cursor: 'pointer',
zIndex: 10
}
})
function handleClick(event) {
event.preventDefault()
emit('click', props.image)
}
</script>
<style scoped>
.image-overlay {
position: absolute;
transition: all 0.2s ease;
}
.image-overlay:hover {
transform: scale(1.02);
z-index: 20 !important;
}
.image-overlay:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.overlay-border {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border: 2px solid rgba(59, 130, 246, 0.4);
background-color: rgba(59, 130, 246, 0.1);
border-radius: 4px;
transition: all 0.2s ease;
pointer-events: none;
}
.image-overlay:hover .overlay-border {
border-color: rgba(59, 130, 246, 0.8);
background-color: rgba(59, 130, 246, 0.2);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.image-tooltip {
position: absolute;
bottom: calc(100% + 12px);
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(8px);
color: white;
padding: 12px 16px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
max-width: 300px;
min-width: 200px;
z-index: 1000;
pointer-events: none;
animation: tooltipFadeIn 0.2s ease;
}
/* Arrow pointing down */
.image-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 0, 0, 0.9);
}
.tooltip-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.tooltip-title {
font-weight: 600;
font-size: 0.875rem;
}
.tooltip-confidence {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.7);
background-color: rgba(255, 255, 255, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
.tooltip-text {
font-size: 0.875rem;
line-height: 1.5;
max-height: 150px;
overflow-y: auto;
word-wrap: break-word;
}
/* Scrollbar styling for tooltip text */
.tooltip-text::-webkit-scrollbar {
width: 4px;
}
.tooltip-text::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.tooltip-text::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
}
.tooltip-text::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(-4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
.image-overlay,
.overlay-border,
.image-tooltip {
transition: none;
animation: none;
}
.image-overlay:hover {
transform: none;
}
@keyframes tooltipFadeIn {
from, to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.overlay-border {
border-color: rgba(59, 130, 246, 1);
border-width: 3px;
}
.image-overlay:hover .overlay-border {
background-color: rgba(59, 130, 246, 0.3);
}
.image-tooltip {
background-color: black;
border: 2px solid white;
}
.image-tooltip::after {
border-top-color: white;
}
}
/* Ensure tooltip stays on screen */
.image-tooltip {
max-width: min(300px, 90vw);
}
/* If overlay is near top of screen, show tooltip below instead */
.image-overlay[data-position="bottom"] .image-tooltip {
bottom: auto;
top: calc(100% + 12px);
}
.image-overlay[data-position="bottom"] .image-tooltip::after {
top: auto;
bottom: 100%;
border-top-color: transparent;
border-bottom-color: rgba(0, 0, 0, 0.9);
}
</style>

View file

@ -0,0 +1,81 @@
/**
* Document Images Composable
* Handles fetching and managing extracted images from PDF documents
*/
import { ref } from 'vue'
export function useDocumentImages() {
const images = ref([])
const loading = ref(false)
const error = ref(null)
/**
* Fetch images for a specific page of a document
* @param {string} documentId - The document UUID
* @param {number} pageNumber - The page number (1-indexed)
* @returns {Promise<Array>} Array of image objects
*/
async function fetchPageImages(documentId, pageNumber) {
if (!documentId || !pageNumber) {
console.warn('Missing documentId or pageNumber')
images.value = []
return []
}
loading.value = true
error.value = null
try {
const response = await fetch(`/api/documents/${documentId}/images?page=${pageNumber}`)
if (!response.ok) {
if (response.status === 404) {
// No images found for this page - not an error
images.value = []
return []
}
throw new Error(`Failed to fetch images: ${response.statusText}`)
}
const data = await response.json()
images.value = data.images || []
return images.value
} catch (err) {
console.error('Error fetching page images:', err)
error.value = err.message
images.value = []
return []
} finally {
loading.value = false
}
}
/**
* Get the full image URL for a specific image
* @param {string} documentId - The document UUID
* @param {string} imageId - The image ID
* @returns {string} Full URL to the image
*/
function getImageUrl(documentId, imageId) {
return `/api/documents/${documentId}/images/${imageId}`
}
/**
* Clear current images
*/
function clearImages() {
images.value = []
error.value = null
}
return {
images,
loading,
error,
fetchPageImages,
getImageUrl,
clearImages
}
}

View file

@ -18,6 +18,9 @@
<div class="flex items-center gap-3">
<span class="text-dark-300 text-sm">Page {{ currentPage }} / {{ totalPages }}</span>
<span v-if="pageImages.length > 0" class="text-dark-400 text-sm">
({{ pageImages.length }} {{ pageImages.length === 1 ? 'image' : 'images' }})
</span>
</div>
</div>
@ -77,21 +80,46 @@
<p class="text-red-300">{{ error }}</p>
</div>
<div v-else class="bg-white rounded-2xl shadow-2xl overflow-hidden">
<canvas
ref="pdfCanvas"
class="w-full"
></canvas>
<div v-else class="bg-white rounded-2xl shadow-2xl overflow-hidden relative">
<div ref="canvasContainer" class="relative">
<canvas
ref="pdfCanvas"
class="w-full"
></canvas>
<!-- Image Overlays -->
<ImageOverlay
v-for="image in pageImages"
:key="image.id"
:image="image"
:canvas-width="canvasWidth"
:canvas-height="canvasHeight"
:pdf-scale="pdfScale"
@click="openImageModal"
/>
</div>
</div>
</div>
</main>
<!-- Full-size Image Modal -->
<FigureZoom
v-if="selectedImage"
:is-open="!!selectedImage"
:image-src="selectedImageUrl"
:image-alt="`Image ${selectedImage.imageIndex + 1} from page ${currentPage}`"
@close="closeImageModal"
/>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, watch, computed } from 'vue'
import { useRoute } from 'vue-router'
import * as pdfjsLib from 'pdfjs-dist'
import ImageOverlay from '../components/ImageOverlay.vue'
import FigureZoom from '../components/FigureZoom.vue'
import { useDocumentImages } from '../composables/useDocumentImages'
// Configure PDF.js worker - use local worker file instead of CDN
// This works with Vite's bundler and avoids CORS/CDN issues
@ -111,9 +139,27 @@ const boatInfo = ref('')
const loading = ref(true)
const error = ref(null)
const pdfCanvas = ref(null)
const canvasContainer = ref(null)
const pdfDoc = ref(null)
const isRendering = ref(false)
// PDF rendering scale
const pdfScale = ref(1.5)
// Canvas dimensions
const canvasWidth = ref(0)
const canvasHeight = ref(0)
// Image handling
const { images: pageImages, fetchPageImages, getImageUrl } = useDocumentImages()
const selectedImage = ref(null)
// Computed property for selected image URL
const selectedImageUrl = computed(() => {
if (!selectedImage.value) return ''
return getImageUrl(documentId.value, selectedImage.value.id)
})
async function loadDocument() {
try {
loading.value = true
@ -157,7 +203,7 @@ async function renderPage(pageNum) {
try {
const page = await pdfDoc.value.getPage(pageNum)
const viewport = page.getViewport({ scale: 1.5 })
const viewport = page.getViewport({ scale: pdfScale.value })
const canvas = pdfCanvas.value
const context = canvas.getContext('2d')
@ -165,12 +211,19 @@ async function renderPage(pageNum) {
canvas.height = viewport.height
canvas.width = viewport.width
// Store canvas dimensions for image overlays
canvasWidth.value = viewport.width
canvasHeight.value = viewport.height
const renderContext = {
canvasContext: context,
viewport: viewport
}
await page.render(renderContext).promise
// Fetch images for this page after PDF is rendered
await fetchPageImages(documentId.value, pageNum)
} catch (err) {
console.error('Error rendering page:', err)
error.value = `Failed to render PDF page ${pageNum}: ${err.message}`
@ -213,6 +266,14 @@ watch(() => route.query.page, (newPage) => {
}
})
function openImageModal(image) {
selectedImage.value = image
}
function closeImageModal() {
selectedImage.value = null
}
onMounted(() => {
loadDocument()
})