Spaces:
Running
Running
| <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> | |