Batch reformat to eliminate 23 files with inconsistent formatting that were accumulating across feature branches.
313 lines
7.2 KiB
Vue
313 lines
7.2 KiB
Vue
<template>
|
|
<div class="structure-viewer">
|
|
<!-- Page selector -->
|
|
<div class="page-selector" v-if="pages.length > 1">
|
|
<button
|
|
v-for="p in pages"
|
|
:key="p.page_number"
|
|
class="page-btn"
|
|
:class="{ active: selectedPage === p.page_number }"
|
|
@click="selectedPage = p.page_number"
|
|
>
|
|
{{ p.page_number }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
<div class="legend">
|
|
<button
|
|
v-for="[type, color] in Object.entries(ELEMENT_COLORS)"
|
|
:key="type"
|
|
class="legend-item"
|
|
: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 overlay -->
|
|
<div class="canvas-container" ref="containerRef">
|
|
<img
|
|
v-if="documentId"
|
|
:src="previewUrl ?? undefined"
|
|
class="page-image"
|
|
ref="imageRef"
|
|
@load="onImageLoad"
|
|
/>
|
|
<canvas
|
|
ref="canvasRef"
|
|
class="overlay-canvas"
|
|
@mousemove="onMouseMove"
|
|
@mouseleave="hoveredElement = null"
|
|
/>
|
|
<!-- Tooltip -->
|
|
<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>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick, reactive } from 'vue'
|
|
import { getPreviewUrl } from '../../document/api'
|
|
import { computeScale, bboxToRect, pointInRect } from '../bboxScaling'
|
|
import type { Page, PageElement } from '../../../shared/types'
|
|
|
|
const ELEMENT_COLORS: Record<string, string> = {
|
|
section_header: '#F97316',
|
|
text: '#3B82F6',
|
|
table: '#8B5CF6',
|
|
picture: '#22C55E',
|
|
list: '#06B6D4',
|
|
formula: '#EC4899',
|
|
caption: '#EAB308',
|
|
}
|
|
|
|
const props = defineProps({
|
|
pages: { type: Array as () => Page[], default: () => [] },
|
|
documentId: String,
|
|
})
|
|
|
|
const selectedPage = ref(1)
|
|
const hiddenTypes = reactive(new Set<string>())
|
|
const containerRef = ref<HTMLDivElement | null>(null)
|
|
const imageRef = ref<HTMLImageElement | null>(null)
|
|
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
|
const hoveredElement = ref<PageElement | null>(null)
|
|
const tooltipStyle = ref<Record<string, string>>({})
|
|
const imageSize = ref({ width: 0, height: 0 })
|
|
|
|
const currentPageData = computed(() => {
|
|
return props.pages.find((p) => p.page_number === selectedPage.value)
|
|
})
|
|
|
|
const visibleElements = computed(() => {
|
|
if (!currentPageData.value) return []
|
|
return currentPageData.value.elements.filter((e) => !hiddenTypes.has(e.type))
|
|
})
|
|
|
|
const previewUrl = computed(() => {
|
|
if (!props.documentId) return null
|
|
return getPreviewUrl(props.documentId, selectedPage.value)
|
|
})
|
|
|
|
function toggleType(type: string) {
|
|
if (hiddenTypes.has(type)) hiddenTypes.delete(type)
|
|
else hiddenTypes.add(type)
|
|
drawOverlay()
|
|
}
|
|
|
|
function countElements(type: string) {
|
|
if (!currentPageData.value) return 0
|
|
return currentPageData.value.elements.filter((e) => e.type === type).length
|
|
}
|
|
|
|
function onImageLoad() {
|
|
const img = imageRef.value
|
|
if (!img) return
|
|
imageSize.value = { width: img.naturalWidth, height: img.naturalHeight }
|
|
nextTick(drawOverlay)
|
|
}
|
|
|
|
function drawOverlay() {
|
|
const canvas = canvasRef.value
|
|
const img = imageRef.value
|
|
if (!canvas || !img) return
|
|
|
|
canvas.width = img.clientWidth
|
|
canvas.height = img.clientHeight
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
if (!ctx) return
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
|
|
const page = currentPageData.value
|
|
if (!page) return
|
|
|
|
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
|
|
|
|
for (const el of visibleElements.value) {
|
|
const rect = bboxToRect(el.bbox, scale)
|
|
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
|
|
|
ctx.strokeStyle = color
|
|
ctx.lineWidth = 2
|
|
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
|
|
|
ctx.fillStyle = color + '20'
|
|
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
|
}
|
|
}
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
const canvas = canvasRef.value
|
|
const page = currentPageData.value
|
|
const img = imageRef.value
|
|
if (!canvas || !page || !img) return
|
|
|
|
const canvasRect = canvas.getBoundingClientRect()
|
|
const mx = e.clientX - canvasRect.left
|
|
const my = e.clientY - canvasRect.top
|
|
|
|
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
|
|
|
|
let found: PageElement | null = null
|
|
for (const el of visibleElements.value) {
|
|
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
|
|
found = el
|
|
break
|
|
}
|
|
}
|
|
|
|
hoveredElement.value = found
|
|
if (found) {
|
|
tooltipStyle.value = {
|
|
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
|
|
top: `${my + 12}px`,
|
|
}
|
|
}
|
|
}
|
|
|
|
watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
|
nextTick(drawOverlay)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.structure-viewer {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.page-selector {
|
|
display: flex;
|
|
gap: 4px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.page-btn {
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-secondary);
|
|
font-size: 12px;
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
}
|
|
|
|
.page-btn:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
.page-btn.active {
|
|
background: var(--accent-muted);
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
|
|
.legend {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 10px;
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border);
|
|
border-radius: 16px;
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
}
|
|
|
|
.legend-item:hover {
|
|
background: var(--bg-hover);
|
|
}
|
|
.legend-item.dimmed {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.legend-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.legend-count {
|
|
color: var(--text-muted);
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
}
|
|
|
|
.canvas-container {
|
|
position: relative;
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.page-image {
|
|
width: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.overlay-canvas {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.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>
|