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})`
|
transform: `translate(${panZoom.state.x}px, ${panZoom.state.y}px) scale(${panZoom.state.scale})`
|
||||||
}"
|
}"
|
||||||
draggable="false"
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { usePanZoom } from '../../composables/usePanZoom.js'
|
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 emit = defineEmits(['close-request'])
|
||||||
const panZoom = usePanZoom()
|
const panZoom = usePanZoom()
|
||||||
|
|
||||||
const canvasEl = ref(null)
|
const canvasEl = ref(null)
|
||||||
const imgEl = 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).
|
// Reset zoom when the src changes (prev/next nav).
|
||||||
watch(() => props.src, () => panZoom.reset())
|
watch(() => props.src, () => panZoom.reset())
|
||||||
@@ -79,4 +150,42 @@ function onCanvasClick(ev) {
|
|||||||
transition: transform 100ms ease-out;
|
transition: transform 100ms ease-out;
|
||||||
pointer-events: none;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
<template v-if="modal.current">
|
<template v-if="modal.current">
|
||||||
<ImageCanvas
|
<ImageCanvas
|
||||||
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
|
v-if="!isVideo" :src="modal.current.image_url" :alt="`Image ${modal.current.id}`"
|
||||||
|
:hover="hoverGrounding"
|
||||||
@close-request="$emit('close')"
|
@close-request="$emit('close')"
|
||||||
/>
|
/>
|
||||||
<VideoCanvas
|
<VideoCanvas
|
||||||
@@ -94,7 +95,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useModalStore } from '../../stores/modal.js'
|
||||||
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
|
import { arrowNavAllowed, isTextEntry } from '../../utils/textEntry.js'
|
||||||
import ImageCanvas from './ImageCanvas.vue'
|
import ImageCanvas from './ImageCanvas.vue'
|
||||||
@@ -110,6 +111,13 @@ const modal = useModalStore()
|
|||||||
const rootEl = ref(null)
|
const rootEl = ref(null)
|
||||||
const showHelp = ref(false)
|
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(() =>
|
const isVideo = computed(() =>
|
||||||
modal.current?.mime && modal.current.mime.startsWith('video/')
|
modal.current?.mime && modal.current.mime.startsWith('video/')
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
name, score, and action buttons as one "object" (operator-asked
|
name, score, and action buttons as one "object" (operator-asked
|
||||||
2026-06-01). The row itself is informational; the green ✓ / red ✗
|
2026-06-01). The row itself is informational; the green ✓ / red ✗
|
||||||
verdict pair + 3-dot alias menu are the action affordances. -->
|
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">
|
<span class="fc-suggestion__name">
|
||||||
{{ suggestion.display_name }}
|
{{ suggestion.display_name }}
|
||||||
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
|
<span v-if="suggestion.rejected" class="fc-suggestion__rejected-tag"
|
||||||
@@ -72,12 +75,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, inject } from 'vue'
|
||||||
import KebabMenu from '../common/KebabMenu.vue'
|
import KebabMenu from '../common/KebabMenu.vue'
|
||||||
|
|
||||||
const props = defineProps({ suggestion: { type: Object, required: true } })
|
const props = defineProps({ suggestion: { type: Object, required: true } })
|
||||||
defineEmits(['accept', 'alias', 'remove-alias', 'dismiss', 'undismiss'])
|
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)}%`)
|
const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`)
|
||||||
// Kebab now only carries alias actions: show it when this suggestion can be
|
// 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
|
// aliased (raw model key, not yet aliased) or is already aliased (so it can be
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
:key="store.anchor.id"
|
:key="store.anchor.id"
|
||||||
:src="store.anchor.image_url"
|
:src="store.anchor.image_url"
|
||||||
:alt="`Image ${store.anchor.id}`"
|
:alt="`Image ${store.anchor.id}`"
|
||||||
|
:hover="hoverGrounding"
|
||||||
/>
|
/>
|
||||||
<VideoCanvas
|
<VideoCanvas
|
||||||
v-else
|
v-else
|
||||||
@@ -131,7 +132,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { useRoute, useRouter } from 'vue-router'
|
||||||
import { useApi } from '../composables/useApi.js'
|
import { useApi } from '../composables/useApi.js'
|
||||||
import { useExploreStore } from '../stores/explore.js'
|
import { useExploreStore } from '../stores/explore.js'
|
||||||
@@ -152,6 +153,11 @@ const modal = useModalStore()
|
|||||||
|
|
||||||
const anchorId = computed(() => route.params.imageId || null)
|
const anchorId = computed(() => route.params.imageId || null)
|
||||||
const isVideo = computed(() => !!store.anchor?.mime?.startsWith('video/'))
|
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 seeding = ref(false)
|
||||||
const seedError = ref(null)
|
const seedError = ref(null)
|
||||||
const tagPanelRef = ref(null)
|
const tagPanelRef = ref(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user