Merge branch 'image-extraction-frontend'
This commit is contained in:
commit
08ccc1ee93
3 changed files with 440 additions and 7 deletions
291
client/src/components/ImageOverlay.vue
Normal file
291
client/src/components/ImageOverlay.vue
Normal 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>
|
||||
81
client/src/composables/useDocumentImages.js
Normal file
81
client/src/composables/useDocumentImages.js
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue