feat(ui): hover a suggestion → highlight the crop region it came from (#133 step 3)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m36s

The payoff: hover a suggestion in the rail and the exact crop that produced it
lights up on the image (a booru:head, a panel, a figure); a null-grounding tag
shows a subtle dashed whole-image frame ('global vector won, not a crop').
ImageCanvas gains a grounding overlay that tracks the <img>'s live bounding rect
(correct under object-fit letterboxing + pan/zoom) and draws the normalized bbox
+ a detector/kind label. SuggestionItem sets the hovered grounding via
provide/inject (no 4-level event relay through TagPanel/SuggestionsPanel/group);
ImageViewer AND ExploreView provide it + pass it to their canvas. Overlay is
pointer-events:none so it never blocks pan/zoom/click. Videos out of v1 scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-05 23:24:18 -04:00
parent dfe2fda564
commit 524a26c618
4 changed files with 142 additions and 6 deletions
+111 -2
View File
@@ -16,20 +16,91 @@
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">{{ regionLabel }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { usePanZoom } from '../../composables/usePanZoom.js'
const props = defineProps({ src: String, alt: String })
const props = defineProps({
src: String,
alt: String,
// Hovered-suggestion grounding, or null when nothing is hovered:
// null → no overlay
// { g: {bbox,kind,detector} } → highlight that crop region
// { g: null } → whole-image frame (not localized)
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}%`,
}
})
const regionLabel = 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())
@@ -79,4 +150,42 @@ function onCanvasClick(ev) {
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%);
font-size: 11px;
font-family: 'JetBrains Mono', monospace;
line-height: 1.4;
padding: 1px 6px;
white-space: nowrap;
background: rgb(var(--v-theme-accent));
color: rgb(var(--v-theme-on-surface));
border-radius: 3px 3px 0 0;
}
.fc-canvas__region--whole .fc-canvas__region-label {
background: rgb(var(--v-theme-on-surface-variant));
}
</style>
@@ -60,6 +60,7 @@
<template v-if="modal.current">
<ImageCanvas
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
:hover="hoverGrounding"
@close-request="$emit('close')"
/>
<VideoCanvas
@@ -94,7 +95,7 @@
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useModalStore } from '../../stores/modal.js'
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
import ImageCanvas from './ImageCanvas.vue'
@@ -110,6 +111,13 @@ const modal = useModalStore()
const rootEl = ref(null)
const showHelp = ref(false)
// #1206: a deep descendant (SuggestionItem) sets this on hover to the crop that
// produced a suggestion; ImageCanvas draws it over the image. provide/inject
// avoids relaying a hover event through TagPanel → SuggestionsPanel → group.
// null = nothing hovered; { g: grounding|null } = hovering (g null → whole-image).
const hoverGrounding = ref(null)
provide('fcSuggestionHover', hoverGrounding)
const isVideo = computed(() =>
modal.current?.mime && modal.current.mime.startsWith('video/')
)
@@ -3,7 +3,10 @@
name, score, and action buttons as one "object" (operator-asked
2026-06-01). The row itself is informational; the green / red
verdict pair + 3-dot alias menu are the action affordances. -->
<div class="fc-suggestion" :class="{ 'fc-suggestion--rejected': suggestion.rejected }">
<div
class="fc-suggestion" :class="{ 'fc-suggestion--rejected': suggestion.rejected }"
@mouseenter="onEnter" @mouseleave="onLeave"
>
<span class="fc-suggestion__name">
{{ suggestion.display_name }}
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
@@ -72,12 +75,22 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, inject } from 'vue'
import KebabMenu from '../common/KebabMenu.vue'
const props = defineProps({ suggestion: { type: Object, required: true } })
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
// #1206: on hover, tell the image viewer which crop produced this suggestion so
// it highlights that region. Provided by ImageViewer/Explore; a no-op elsewhere.
const hover = inject('fcSuggestionHover', null)
function onEnter () {
if (hover) hover.value = { g: props.suggestion.grounding ?? null }
}
function onLeave () {
if (hover) hover.value = null
}
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
// Kebab now only carries alias actions: show it when this suggestion can be
// aliased (raw model key, not yet aliased) or is already aliased (so it can be
+7 -1
View File
@@ -98,6 +98,7 @@
:key="store.anchor.id"
:src="store.anchor.image_url"
:alt="`Image ${store.anchor.id}`"
:hover="hoverGrounding"
/>
<VideoCanvas
v-else
@@ -131,7 +132,7 @@
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApi } from '../composables/useApi.js'
import { useExploreStore } from '../stores/explore.js'
@@ -152,6 +153,11 @@ const modal = useModalStore()
const anchorId = computed(() => route.params.imageId || null)
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
// #1206: hovering a suggestion in the rail highlights the crop it came from on
// the anchor image (same provide/inject as the modal viewer).
const hoverGrounding = ref(null)
provide('fcSuggestionHover', hoverGrounding)
const seeding = ref(false)
const seedError = ref(null)
const tagPanelRef = ref(null)