Files
FabledCurator/frontend/src/components/modal/ImageCanvas.vue
T
bvandeusen 7939dba9ed
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m34s
feat(ui): grounding overlay leads with the hovered tag, crop origin as subtext (#1206)
The overlay label showed only the crop origin (booru:head / panel / person-m).
Put the hovered TAG on top as the headline and demote the crop origin to a small,
dimmed, monospace subline — the tag is what you're evaluating; the origin is just
provenance. Threads the tag name through the fcSuggestionHover payload ({g, tag})
from both setters (SuggestionItem for suggestions, TagChip for applied chips).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
2026-07-06 15:11:10 -04:00

213 lines
7.1 KiB
Vue

<template>
<div
ref="canvasEl"
class="fc-canvas"
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
@wheel="panZoom.handlers.onWheel"
@pointerdown="panZoom.handlers.onPointerDown"
@pointermove="panZoom.handlers.onPointerMove"
@pointerup="panZoom.handlers.onPointerUp"
@click="onCanvasClick"
>
<img
ref="imgEl"
:src="src" :alt="alt"
:style="{
transform: `translate(${panZoom.state.x}px, ${panZoom.state.y}px) scale(${panZoom.state.scale})`
}"
draggable="false"
@load="onImgLoad"
>
<!-- #1206 grounding overlay: tracks the <img>'s live rendered rect (so it's
correct under object-fit letterboxing + pan/zoom) and draws the crop
region a hovered suggestion came from. Null grounding a subtle
whole-image frame ("the global vector won, not a crop"). -->
<div
v-if="hover && overlayBox"
class="fc-canvas__overlay"
:style="{
left: overlayBox.left + 'px', top: overlayBox.top + 'px',
width: overlayBox.width + 'px', height: overlayBox.height + 'px',
}"
>
<div
class="fc-canvas__region"
:class="{ 'fc-canvas__region--whole': !regionStyle }"
:style="regionStyle || {}"
>
<span class="fc-canvas__region-label">
<span v-if="regionTag" class="fc-canvas__region-tag">{{ regionTag }}</span>
<span class="fc-canvas__region-origin">{{ regionOrigin }}</span>
</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { usePanZoom } from '../../composables/usePanZoom.js'
const props = defineProps({
src: String,
alt: String,
// Hovered-suggestion state, or null when nothing is hovered:
// null → no overlay
// { g: {bbox,kind,detector}, tag } → highlight that crop, label with the tag
// { g: null, tag } → whole-image frame (not localized)
// `tag` is the hovered concept's name (primary label line); `g` is where the
// crop came from (secondary provenance line).
hover: { type: Object, default: null },
})
const emit = defineEmits(['close-request'])
const panZoom = usePanZoom()
const canvasEl = ref(null)
const imgEl = ref(null)
const overlayBox = ref(null) // the <img>'s rect relative to the canvas
// The <img> renders aspect-fit + transformed, so its getBoundingClientRect is
// the ground truth for where the picture actually is. Position the overlay to
// match it, then place the region as a % within.
function measure () {
const img = imgEl.value
const canvas = canvasEl.value
if (!img || !canvas) { overlayBox.value = null; return }
const ir = img.getBoundingClientRect()
const cr = canvas.getBoundingClientRect()
if (!ir.width || !ir.height) { overlayBox.value = null; return }
overlayBox.value = {
left: ir.left - cr.left, top: ir.top - cr.top,
width: ir.width, height: ir.height,
}
}
const regionStyle = computed(() => {
const g = props.hover?.g
if (!g || !Array.isArray(g.bbox)) return null // null grounding → whole frame
const [rx, ry, rw, rh] = g.bbox
return {
left: `${rx * 100}%`, top: `${ry * 100}%`,
width: `${rw * 100}%`, height: `${rh * 100}%`,
}
})
// The hovered tag/suggestion name — the PRIMARY label line (what you care about).
const regionTag = computed(() => props.hover?.tag || '')
// Where the winning crop came from (the region's detector_version, else kind) —
// shown small + dimmed beneath the tag as provenance, not the headline. "whole
// image" = null grounding (the global vector won, not a crop).
const regionOrigin = computed(() => {
const g = props.hover?.g
return g ? (g.detector || g.kind || 'region') : 'whole image'
})
// Re-measure whenever hovering starts or the image could have moved.
watch(() => props.hover, (h) => { if (h) nextTick(measure) })
watch(
() => [panZoom.state.scale, panZoom.state.x, panZoom.state.y],
() => { if (props.hover) measure() },
)
function onResize () { if (props.hover) measure() }
function onImgLoad () { if (props.hover) measure() }
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
// Reset zoom when the src changes (prev/next nav).
watch(() => props.src, () => panZoom.reset())
// Click on the image → toggle zoom (IR/expected behavior). Click on
// the haze area around the image → request close. We use the image's
// own bounding rect rather than @click.self because the <img> sets
// pointer-events: none for the pan-drag pathway, so the click always
// targets the canvas div regardless of where the cursor was.
function onCanvasClick(ev) {
if (panZoom.state.scale > 1) {
// When zoomed, any click resets zoom — preserve old behavior.
panZoom.handlers.onClick(ev)
return
}
const img = imgEl.value
if (!img) return
const r = img.getBoundingClientRect()
const inside =
ev.clientX >= r.left && ev.clientX <= r.right &&
ev.clientY >= r.top && ev.clientY <= r.bottom
if (inside) {
panZoom.handlers.onClick(ev)
} else {
emit('close-request')
}
}
</script>
<style scoped>
.fc-canvas {
flex: 1;
position: relative;
/* Transparent so the viewer's haze shows through to the image area
instead of a solid panel sitting on top of it. */
background: transparent;
overflow: hidden;
cursor: zoom-in;
user-select: none;
display: flex; align-items: center; justify-content: center;
}
.fc-canvas--zoomed { cursor: grab; }
.fc-canvas--zoomed:active { cursor: grabbing; }
.fc-canvas img {
max-width: 100%; max-height: 100%;
transform-origin: center;
transition: transform 100ms ease-out;
pointer-events: none;
}
/* Grounding overlay — never intercepts pan/zoom/click. */
.fc-canvas__overlay {
position: absolute;
pointer-events: none;
z-index: 2;
}
.fc-canvas__region {
position: absolute;
border: 2px solid rgb(var(--v-theme-accent));
border-radius: 3px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.28); /* dim everything outside it */
transition: all 90ms ease-out;
}
/* Null grounding: a subtle full-image frame, no dimming — it says "this tag
came from the whole image, not a crop." */
.fc-canvas__region--whole {
inset: 0;
border-style: dashed;
border-color: rgb(var(--v-theme-on-surface-variant));
box-shadow: none;
}
.fc-canvas__region-label {
position: absolute;
top: 0; left: 0;
transform: translateY(-100%);
display: flex; flex-direction: column;
line-height: 1.25;
padding: 2px 6px;
white-space: nowrap;
background: rgb(var(--v-theme-accent));
color: rgb(var(--v-theme-on-surface));
border-radius: 3px 3px 0 0;
}
/* The hovered tag is the headline. */
.fc-canvas__region-tag {
font-size: 12px;
font-weight: 600;
}
/* Crop origin is provenance, not the point — subordinate: smaller, dimmed, and
monospace (it's a detector token like booru:head, not prose). */
.fc-canvas__region-origin {
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
color: rgb(var(--v-theme-on-surface), 0.72);
}
.fc-canvas__region--whole .fc-canvas__region-label {
background: rgb(var(--v-theme-on-surface-variant));
}
</style>