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">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-dark-300 text-sm">Page {{ currentPage }} / {{ totalPages }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -77,21 +80,46 @@
|
||||||
<p class="text-red-300">{{ error }}</p>
|
<p class="text-red-300">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="bg-white rounded-2xl shadow-2xl overflow-hidden">
|
<div v-else class="bg-white rounded-2xl shadow-2xl overflow-hidden relative">
|
||||||
<canvas
|
<div ref="canvasContainer" class="relative">
|
||||||
ref="pdfCanvas"
|
<canvas
|
||||||
class="w-full"
|
ref="pdfCanvas"
|
||||||
></canvas>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import * as pdfjsLib from 'pdfjs-dist'
|
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
|
// Configure PDF.js worker - use local worker file instead of CDN
|
||||||
// This works with Vite's bundler and avoids CORS/CDN issues
|
// This works with Vite's bundler and avoids CORS/CDN issues
|
||||||
|
|
@ -111,9 +139,27 @@ const boatInfo = ref('')
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref(null)
|
const error = ref(null)
|
||||||
const pdfCanvas = ref(null)
|
const pdfCanvas = ref(null)
|
||||||
|
const canvasContainer = ref(null)
|
||||||
const pdfDoc = ref(null)
|
const pdfDoc = ref(null)
|
||||||
const isRendering = ref(false)
|
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() {
|
async function loadDocument() {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -157,7 +203,7 @@ async function renderPage(pageNum) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await pdfDoc.value.getPage(pageNum)
|
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 canvas = pdfCanvas.value
|
||||||
const context = canvas.getContext('2d')
|
const context = canvas.getContext('2d')
|
||||||
|
|
@ -165,12 +211,19 @@ async function renderPage(pageNum) {
|
||||||
canvas.height = viewport.height
|
canvas.height = viewport.height
|
||||||
canvas.width = viewport.width
|
canvas.width = viewport.width
|
||||||
|
|
||||||
|
// Store canvas dimensions for image overlays
|
||||||
|
canvasWidth.value = viewport.width
|
||||||
|
canvasHeight.value = viewport.height
|
||||||
|
|
||||||
const renderContext = {
|
const renderContext = {
|
||||||
canvasContext: context,
|
canvasContext: context,
|
||||||
viewport: viewport
|
viewport: viewport
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.render(renderContext).promise
|
await page.render(renderContext).promise
|
||||||
|
|
||||||
|
// Fetch images for this page after PDF is rendered
|
||||||
|
await fetchPageImages(documentId.value, pageNum)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error rendering page:', err)
|
console.error('Error rendering page:', err)
|
||||||
error.value = `Failed to render PDF page ${pageNum}: ${err.message}`
|
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(() => {
|
onMounted(() => {
|
||||||
loadDocument()
|
loadDocument()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue