Frontend: Highlight matching face marker on sidebar hover #4966

Hovering a person row in the lightbox sidebar's People section now
sets a thicker purple stroke + glow on the matching face-marker rect
on the image, making the visual link between the row and the box
unambiguous in both display and edit modes. Implementation routes
through a new hoveredMarkerUid field on the $faceMarkers singleton
that the lightbox forwards as a prop to the overlay. Reverse
direction (rect hover → sidebar row) isn't wired since rects carry
pointer-events: none — recorded for future work in the singleton +
CSS comments. Also drops two redundant text-align: start declarations
on .meta-title / .meta-caption (defaults already match).
This commit is contained in:
Michael Mayer 2026-05-14 07:06:10 +00:00
parent 30e41c12d5
commit 15dabc5dcd
6 changed files with 65 additions and 3 deletions

View file

@ -55,6 +55,7 @@ export class FaceMarkers {
this.mode = null;
this.busy = false;
this.pendingNameMarkerUid = "";
this.hoveredMarkerUid = "";
}
// active reports whether any face-marker mode (display or draw) is on.
@ -110,6 +111,18 @@ export class FaceMarkers {
this.pendingNameMarkerUid = typeof uid === "string" ? uid : "";
}
// setHoveredMarkerUid records the UID of the marker currently being
// hovered. Drives the sidebar → image highlight: sidebar people
// rows set it on @mouseenter (and clear it on @mouseleave) so the
// matching overlay rect picks up the `--hovered` modifier class.
// The reverse direction (rect → sidebar row) is not wired today
// because rects carry `pointer-events: none`; a future
// implementation would set this from a parent-level pointermove
// hit-test on the overlay.
setHoveredMarkerUid(uid) {
this.hoveredMarkerUid = typeof uid === "string" ? uid : "";
}
// reset returns every field to its default. Called when the lightbox
// closes or the user navigates to a different photo so a fresh slide
// never inherits stale state.
@ -117,6 +130,7 @@ export class FaceMarkers {
this.mode = null;
this.busy = false;
this.pendingNameMarkerUid = "";
this.hoveredMarkerUid = "";
}
}

View file

@ -49,6 +49,7 @@
:markers="markers"
:pswp="pswp()"
:busy="faceMarkers.busy"
:hovered-uid="faceMarkers.hoveredMarkerUid"
@create="onCreateFaceMarker"
@cancel="exitFaceMarkerMode"
@remove="onRemoveFaceMarker"

View file

@ -16,6 +16,7 @@
:class="{
'p-face-markers__rect--named': !!m.Name,
'p-face-markers__rect--removing': removingMarker && removingMarker.UID === m.UID,
'p-face-markers__rect--hovered': hoveredUid && hoveredUid === m.UID,
}"
:x="m.X * bounds.width"
:y="m.Y * bounds.height"
@ -138,6 +139,14 @@ export default {
type: Boolean,
default: false,
},
// hoveredUid is the UID of the marker that should render with the
// `--hovered` highlight (thicker, accent-colored stroke). Forwarded
// from `$faceMarkers.hoveredMarkerUid` by the lightbox so sidebar
// people-row hover and direct rect hover stay in sync.
hoveredUid: {
type: String,
default: "",
},
},
emits: ["create", "cancel", "remove"],
data() {

View file

@ -218,7 +218,14 @@
></v-btn>
</template>
</v-list-item>
<v-list-item v-for="m in people" :key="m.UID || m.CropID" :data-marker-uid="m.UID" class="metadata__item metadata__person-row">
<v-list-item
v-for="m in people"
:key="m.UID || m.CropID"
:data-marker-uid="m.UID"
class="metadata__item metadata__person-row"
@mouseenter="faceMarkers.active && faceMarkers.setHoveredMarkerUid(m.UID)"
@mouseleave="faceMarkers.active && faceMarkers.setHoveredMarkerUid('')"
>
<template #prepend>
<img
:src="m.thumbnailUrl('tile_160')"

View file

@ -121,6 +121,24 @@
stroke-width: 2.5px;
}
/* Hover highlight driven by sidebar People-row hover: when the user
hovers a person row in the sidebar, the matching rect picks up a
thicker accent stroke + soft glow so the visual link between the
image and the People list is unambiguous. Listed AFTER --named so
it overrides the named-marker stroke color too. The drop-shadow is
non-scaling-stroke-aware (it keys off the rendered stroke, not the
SVG userspace), so it stays consistent across PhotoSwipe zoom.
Reverse direction (rect sidebar row) is not wired today: the
rect carries `pointer-events: none` so native @mouseenter never
fires; a future implementation would need parent-level pointermove
+ hit-testing against marker bounds. */
.p-face-markers__rect--hovered {
stroke: rgba(196, 167, 255, 1);
stroke-width: 4px;
fill: rgba(167, 139, 250, 0);
filter: drop-shadow(0 0 4px rgba(196, 167, 255, 0.7));
}
.p-face-markers__handle {
fill: rgba(167, 139, 250, 1);
stroke: rgba(70, 40, 140, 0.95);
@ -270,7 +288,6 @@
font-weight: 700;
word-wrap: normal;
word-break: break-word;
text-align: start;
hyphens: auto;
}
@ -280,7 +297,6 @@
overflow-wrap: normal;
word-wrap: normal;
word-break: break-word;
text-align: start;
hyphens: auto;
}

View file

@ -9,6 +9,7 @@ describe("common/face-markers", () => {
expect(fm.mode).toBeNull();
expect(fm.busy).toBe(false);
expect(fm.pendingNameMarkerUid).toBe("");
expect(fm.hoveredMarkerUid).toBe("");
expect(fm.active).toBe(false);
expect(fm.isDisplay).toBe(false);
expect(fm.isEdit).toBe(false);
@ -68,15 +69,29 @@ describe("common/face-markers", () => {
expect(fm.pendingNameMarkerUid).toBe("");
});
it("setHoveredMarkerUid only accepts strings; non-strings clear it", () => {
const fm = new FaceMarkers();
fm.setHoveredMarkerUid("uid42");
expect(fm.hoveredMarkerUid).toBe("uid42");
fm.setHoveredMarkerUid("");
expect(fm.hoveredMarkerUid).toBe("");
fm.setHoveredMarkerUid(null);
expect(fm.hoveredMarkerUid).toBe("");
fm.setHoveredMarkerUid(123);
expect(fm.hoveredMarkerUid).toBe("");
});
it("reset returns every field to its default", () => {
const fm = new FaceMarkers();
fm.setMode(FaceMarkerEdit);
fm.setBusy(true);
fm.setPendingNameMarkerUid("uid1");
fm.setHoveredMarkerUid("uid2");
fm.reset();
expect(fm.mode).toBeNull();
expect(fm.busy).toBe(false);
expect(fm.pendingNameMarkerUid).toBe("");
expect(fm.hoveredMarkerUid).toBe("");
});
});