docling-studio/frontend/src/features/analysis/ui/StructureViewer.vue
Pier-Jean Malandrino 77fcb32e7f fix(reasoning): re-scroll PDF when re-clicking the active iteration
The watch-based plumbing from iteration click to PDF scroll relied on a
"flip via null" pattern (assign null then the value) to coerce Vue into
re-firing the watcher. Vue 3 collapses synchronous mutations of the same
ref and only delivers the final value, so the trick was a no-op: a second
click on the same iteration left the document view stuck on the previous
page. The bug only showed when the trace had a single iteration — with
several, the user naturally clicks different ones and the value really
changes.

Replace the watch chain with imperative dispatch. ReasoningPanel now just
emits iterationFocus; ReasoningWorkspace handles it by calling the graph
focus and the new StructureViewer.scrollToFocused method directly. Both
side effects fire on every click regardless of state.
2026-04-27 11:48:32 +02:00

447 lines
12 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"
:class="{ selectable }"
@mousemove="onMouseMove"
@mouseleave="hoveredElement = null"
@click="onCanvasClick"
/>
<!-- 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,
/**
* Reasoning-trace integration hooks. Optional — when unset, StructureViewer
* renders like before (Studio "Structure" tab). When set, enables overlays
* for the reasoning viewer without forking the component:
*
* - `visitedBySelfRef`: elements whose `self_ref` is in this map render in
* the reasoning accent color with a numbered badge (the visit order).
* - `focusedSelfRef`: when it changes, auto-scroll to the page of that
* element and pulse its bbox briefly.
* - `selectable`: when true, clicking a bbox emits `elementFocus` so a
* parent can sync the selection with the graph view.
*/
visitedBySelfRef: {
type: Object as () => Map<string, number> | null,
default: null,
},
focusedSelfRef: { type: String as () => string | null, default: null },
selectable: { type: Boolean, default: false },
/**
* When true AND `visitedBySelfRef` is set, non-visited elements are drawn
* with reduced alpha so the visited ones pop. Matches the reasoning
* panel's "Focus" toggle behavior on the graph.
*/
dimNonVisited: { type: Boolean, default: false },
})
const emit = defineEmits<{
/** Fired when the user clicks a bbox — only if `selectable` is true. */
elementFocus: [selfRef: string]
}>()
const REASONING_COLOR = '#EA580C'
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)
// Two-pass draw so reasoning overlays (highlight + pulse) sit on top of
// the base element strokes without being painted over by subsequent
// elements. First pass = base, second pass = accents.
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const baseColor = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
const isVisited =
props.visitedBySelfRef !== null && !!el.self_ref && props.visitedBySelfRef.has(el.self_ref)
if (isVisited) {
// Reasoning-visited element — reasoning accent color, bolder stroke,
// more saturated fill than the base element. The visit-order badge
// is drawn in the second pass below.
ctx.strokeStyle = REASONING_COLOR
ctx.lineWidth = 3
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = REASONING_COLOR + '33'
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
} else {
// Dim non-visited when focus mode is on and a visited set is present,
// so visited bboxes pop. Otherwise keep the regular styling.
const dim = props.dimNonVisited && props.visitedBySelfRef !== null
ctx.strokeStyle = baseColor + (dim ? '22' : '')
ctx.lineWidth = dim ? 1 : 2
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = baseColor + (dim ? '08' : '20')
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
}
// Second pass — numbered badges on visited elements + focus pulse ring.
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const order =
props.visitedBySelfRef !== null && el.self_ref
? props.visitedBySelfRef.get(el.self_ref)
: undefined
if (order !== undefined) {
drawVisitBadge(ctx, rect.x, rect.y, order)
}
if (props.focusedSelfRef && el.self_ref === props.focusedSelfRef) {
ctx.strokeStyle = REASONING_COLOR
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.strokeRect(rect.x - 4, rect.y - 4, rect.w + 8, rect.h + 8)
ctx.setLineDash([])
}
}
}
function drawVisitBadge(ctx: CanvasRenderingContext2D, x: number, y: number, order: number): void {
const radius = 10
const cx = x
const cy = y
ctx.fillStyle = REASONING_COLOR
ctx.beginPath()
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 11px -apple-system, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(String(order), cx, cy + 0.5)
}
function elementAt(e: MouseEvent): PageElement | null {
const canvas = canvasRef.value
const page = currentPageData.value
const img = imageRef.value
if (!canvas || !page || !img) return null
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)
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) return el
}
return null
}
function onMouseMove(e: MouseEvent) {
const canvas = canvasRef.value
if (!canvas) return
const canvasRect = canvas.getBoundingClientRect()
const mx = e.clientX - canvasRect.left
const my = e.clientY - canvasRect.top
const found = elementAt(e)
hoveredElement.value = found
if (found) {
tooltipStyle.value = {
left: `${Math.min(mx + 12, canvas.width - 250)}px`,
top: `${my + 12}px`,
}
}
}
function onCanvasClick(e: MouseEvent): void {
if (!props.selectable) return
const el = elementAt(e)
if (el?.self_ref) emit('elementFocus', el.self_ref)
}
watch([() => props.pages, selectedPage, hiddenTypes], () => {
nextTick(drawOverlay)
})
watch(
() => [props.visitedBySelfRef, props.dimNonVisited],
() => nextTick(drawOverlay),
)
// When the caller sets a focused self_ref (e.g. the user clicked a node in
// the graph), find which page that element lives on and jump to it. The
// overlay redraw will then show the dashed focus ring around its bbox.
function scrollToFocused(ref: string | null): void {
if (!ref) {
nextTick(drawOverlay)
return
}
for (const page of props.pages) {
if (page.elements.some((e) => e.self_ref === ref)) {
if (selectedPage.value !== page.page_number) {
selectedPage.value = page.page_number
// Let <img> reload before drawing — drawOverlay runs on @load.
} else {
nextTick(drawOverlay)
}
return
}
}
// Ref not on any page (e.g. a #/body node) — just redraw to clear the
// previous focus ring.
nextTick(drawOverlay)
}
watch(() => props.focusedSelfRef, scrollToFocused)
// Imperative entry point so callers can re-trigger a scroll on the same
// self_ref (the watch above only fires on value change). Used by the
// reasoning workspace when the user re-clicks the active iteration card.
defineExpose({ scrollToFocused })
</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;
}
.overlay-canvas.selectable {
cursor: pointer;
}
.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>