feat(ui): hover a suggestion → highlight the crop region it came from (#133 step 3)
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user