docling-studio / frontend /src /features /analysis /ui /StructureViewer.vue
Pier-Jean's picture
Initial deploy: Docling Studio (local mode, port 7860)
5539271
<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>