Apply the same dimming strategy used in Prepare mode: when an element is highlighted, reduce stroke and fill opacity of all other bboxes so the focused element stands out clearly.
295 lines
7.8 KiB
Vue
295 lines
7.8 KiB
Vue
<template>
|
|
<div class="bbox-overlay">
|
|
<!-- Legend bar -->
|
|
<div class="overlay-legend" v-if="pageData">
|
|
<button
|
|
v-for="[type, color] in Object.entries(ELEMENT_COLORS)"
|
|
:key="type"
|
|
class="legend-chip"
|
|
:class="{ dimmed: hiddenTypes.has(type) }"
|
|
@click="toggleType(type)"
|
|
>
|
|
<span class="legend-dot" :style="{ background: color }" />
|
|
<span>{{ type }}</span>
|
|
<span class="legend-count">{{ countElements(type) }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Canvas + Tooltip (positioned over image via parent) -->
|
|
<canvas
|
|
ref="canvasRef"
|
|
class="overlay-canvas"
|
|
@mousemove="onMouseMove"
|
|
@mouseleave="hoveredElement = null; emit('highlight-element', -1)"
|
|
/>
|
|
<div
|
|
v-if="hoveredElement"
|
|
class="tooltip"
|
|
:style="tooltipStyle"
|
|
>
|
|
<span class="tooltip-type" :style="{ color: ELEMENT_COLORS[hoveredElement.type] || ELEMENT_COLORS.text }">
|
|
{{ hoveredElement.type }}
|
|
</span>
|
|
<span class="tooltip-content">{{ hoveredElement.content?.substring(0, 150) }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick, reactive, onMounted, onBeforeUnmount } from 'vue'
|
|
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
|
import type { Page, PageElement, ChunkBbox } from '../../../shared/types'
|
|
|
|
const ELEMENT_COLORS: Record<string, string> = {
|
|
title: '#EF4444',
|
|
section_header: '#F97316',
|
|
text: '#3B82F6',
|
|
table: '#8B5CF6',
|
|
picture: '#22C55E',
|
|
list: '#06B6D4',
|
|
formula: '#EC4899',
|
|
code: '#14B8A6',
|
|
caption: '#EAB308'
|
|
}
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
imageEl: HTMLImageElement | null
|
|
pageData: Page | null
|
|
highlightedIndex: number
|
|
highlightedBboxes: ChunkBbox[]
|
|
}>(),
|
|
{ highlightedBboxes: () => [] },
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
'highlight-element': [index: number]
|
|
}>()
|
|
|
|
const hiddenTypes = reactive(new Set<string>())
|
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
const hoveredElement = ref<PageElement | null>(null)
|
|
const tooltipStyle = ref<Record<string, string>>({})
|
|
|
|
const visibleElements = computed(() => {
|
|
if (!props.pageData) return []
|
|
return props.pageData.elements.filter((e: PageElement) => !hiddenTypes.has(e.type))
|
|
})
|
|
|
|
// Single source of truth for content-bearing elements.
|
|
// Used by both draw() and onMouseMove() to keep highlight indices in sync.
|
|
const contentElements = computed(() => {
|
|
if (!props.pageData) return []
|
|
return props.pageData.elements.filter((e: PageElement) => e.content)
|
|
})
|
|
|
|
function toggleType(type: string): void {
|
|
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
|
|
else hiddenTypes.add(type)
|
|
draw()
|
|
}
|
|
|
|
function countElements(type: string): number {
|
|
if (!props.pageData) return 0
|
|
return props.pageData.elements.filter((e: PageElement) => e.type === type).length
|
|
}
|
|
|
|
function draw(): void {
|
|
const canvas = canvasRef.value
|
|
const img = props.imageEl
|
|
if (!canvas || !img) return
|
|
|
|
if (!img.clientWidth || !img.clientHeight) return
|
|
|
|
// Retina support: scale the canvas backing store by devicePixelRatio
|
|
// so strokes render crisp on high-DPI screens.
|
|
const dpr = window.devicePixelRatio || 1
|
|
canvas.width = img.clientWidth * dpr
|
|
canvas.height = img.clientHeight * dpr
|
|
canvas.style.width = img.clientWidth + 'px'
|
|
canvas.style.height = img.clientHeight + 'px'
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
ctx.clearRect(0, 0, img.clientWidth, img.clientHeight)
|
|
|
|
if (!props.pageData) return
|
|
|
|
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
|
|
|
const hasHighlight = props.highlightedIndex >= 0
|
|
const hasChunkHighlight = props.highlightedBboxes.length > 0
|
|
|
|
for (const el of visibleElements.value) {
|
|
const rect = bboxToRect(el.bbox, scale)
|
|
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
|
|
|
const elContentIdx = contentElements.value.indexOf(el)
|
|
const isHighlighted = hasHighlight && elContentIdx === props.highlightedIndex
|
|
const dimmed = (hasHighlight || hasChunkHighlight) && !isHighlighted
|
|
|
|
ctx.strokeStyle = dimmed ? color + '40' : color
|
|
ctx.lineWidth = isHighlighted ? 3 : 2
|
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
|
|
|
ctx.fillStyle = color + (isHighlighted ? '40' : dimmed ? '08' : '20')
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
|
}
|
|
|
|
// Draw chunk-highlighted bboxes on top with accent color
|
|
if (hasChunkHighlight) {
|
|
const CHUNK_COLOR = '#F59E0B'
|
|
for (const cb of props.highlightedBboxes) {
|
|
const rect = bboxToRect(cb.bbox, scale)
|
|
ctx.strokeStyle = CHUNK_COLOR
|
|
ctx.lineWidth = 3
|
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
|
ctx.fillStyle = CHUNK_COLOR + '30'
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
|
}
|
|
}
|
|
}
|
|
|
|
function onMouseMove(e: MouseEvent): void {
|
|
const canvas = canvasRef.value
|
|
const img = props.imageEl
|
|
if (!canvas || !img || !props.pageData) return
|
|
|
|
const canvasRect = canvas.getBoundingClientRect()
|
|
const mx = e.clientX - canvasRect.left
|
|
const my = e.clientY - canvasRect.top
|
|
|
|
const scale = computeScale(img.clientWidth, img.clientHeight, props.pageData.width, props.pageData.height)
|
|
|
|
let found: PageElement | null = null
|
|
let foundIdx = -1
|
|
for (const el of visibleElements.value) {
|
|
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
|
|
found = el
|
|
foundIdx = contentElements.value.indexOf(el)
|
|
break
|
|
}
|
|
}
|
|
|
|
hoveredElement.value = found
|
|
emit('highlight-element', foundIdx)
|
|
if (found) {
|
|
tooltipStyle.value = {
|
|
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
|
top: `${my + 12}px`
|
|
}
|
|
}
|
|
}
|
|
|
|
let resizeObserver: ResizeObserver | null = null
|
|
onMounted(() => {
|
|
if (props.imageEl) {
|
|
resizeObserver = new ResizeObserver(() => draw())
|
|
resizeObserver.observe(props.imageEl)
|
|
}
|
|
})
|
|
onBeforeUnmount(() => {
|
|
resizeObserver?.disconnect()
|
|
})
|
|
|
|
// Redraw when data or image changes
|
|
watch([() => props.pageData, () => props.imageEl, () => props.highlightedIndex, () => props.highlightedBboxes, hiddenTypes], () => {
|
|
nextTick(draw)
|
|
})
|
|
|
|
// Expose draw so parent can call it after image load
|
|
defineExpose({ draw })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.bbox-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.overlay-legend {
|
|
position: absolute;
|
|
top: 8px;
|
|
left: 8px;
|
|
right: 8px;
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
z-index: 5;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.legend-chip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 3px 8px;
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
font-size: 10px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
backdrop-filter: blur(8px);
|
|
opacity: 0.9;
|
|
}
|
|
|
|
.legend-chip:hover { opacity: 1; background: var(--bg-hover); }
|
|
.legend-chip.dimmed { opacity: 0.35; }
|
|
|
|
.legend-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.legend-count {
|
|
color: var(--text-muted);
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
}
|
|
|
|
.overlay-canvas {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
pointer-events: auto;
|
|
/* No CSS width/height — dimensions set purely by canvas.width/height in JS
|
|
to avoid browser stretching the canvas content */
|
|
}
|
|
|
|
.tooltip {
|
|
position: absolute;
|
|
background: var(--bg-surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
padding: 8px 12px;
|
|
max-width: 250px;
|
|
pointer-events: none;
|
|
z-index: 10;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
.tooltip-type {
|
|
display: block;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.tooltip-content {
|
|
display: block;
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
line-height: 1.4;
|
|
word-break: break-word;
|
|
}
|
|
</style>
|