mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Lightbox: Precompute marker rects and coalesce overlay updates #5672
This commit is contained in:
parent
c650f38f18
commit
54996b07fe
2 changed files with 489 additions and 29 deletions
|
|
@ -10,7 +10,7 @@
|
|||
@wheel="onWheel"
|
||||
>
|
||||
<svg v-if="bounds" class="p-meta-face-markers__svg" :style="svgStyle" :viewBox="`0 0 ${bounds.width} ${bounds.height}`">
|
||||
<g v-for="m in markers" :key="m.UID || m.CropID">
|
||||
<g v-for="m in markers" :key="markerKey(m)">
|
||||
<rect
|
||||
class="p-meta-face-markers__rect"
|
||||
:class="{
|
||||
|
|
@ -173,6 +173,7 @@ export default {
|
|||
isEditMode() {
|
||||
return this.mode === FaceMarkerEdit;
|
||||
},
|
||||
// svgStyle positions the absolute SVG overlay over the letterboxed image area.
|
||||
svgStyle() {
|
||||
if (!this.bounds) {
|
||||
return { display: "none" };
|
||||
|
|
@ -191,6 +192,7 @@ export default {
|
|||
rootStyle() {
|
||||
return this.hoverCursor ? { cursor: this.hoverCursor } : {};
|
||||
},
|
||||
// confirmStyle positions the confirm pill centered below the pending rect.
|
||||
confirmStyle() {
|
||||
if (!this.pending || !this.bounds) {
|
||||
return { display: "none" };
|
||||
|
|
@ -219,6 +221,7 @@ export default {
|
|||
h: m.H * this.bounds.height,
|
||||
};
|
||||
},
|
||||
// removeConfirmStyle positions the remove-confirm pill centered below the targeted marker.
|
||||
removeConfirmStyle() {
|
||||
const r = this.removingMarkerRect;
|
||||
if (!r || !this.bounds) {
|
||||
|
|
@ -233,16 +236,60 @@ export default {
|
|||
transform: "translate(-50%, 8px)",
|
||||
};
|
||||
},
|
||||
// markerRects precomputes unnamed-marker pixel rects from the current
|
||||
// bounds so hover and pointer-down hit-testing reuse them instead of
|
||||
// recomputing every marker's rect on each pointer event. Vue caches it on
|
||||
// `bounds` + `markers`, so it only recomputes when those change. Named
|
||||
// markers are excluded — they are not removable via this overlay.
|
||||
markerRects() {
|
||||
if (!this.bounds || !Array.isArray(this.markers)) {
|
||||
return [];
|
||||
}
|
||||
const rects = [];
|
||||
for (const m of this.markers) {
|
||||
if (!m || m.SubjUID) {
|
||||
continue;
|
||||
}
|
||||
rects.push({
|
||||
marker: m,
|
||||
x: m.X * this.bounds.width,
|
||||
y: m.Y * this.bounds.height,
|
||||
w: m.W * this.bounds.width,
|
||||
h: m.H * this.bounds.height,
|
||||
});
|
||||
}
|
||||
return rects;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// mode cancels any active draft and pending remove when leaving edit mode.
|
||||
mode(newVal) {
|
||||
if (newVal !== FaceMarkerEdit) {
|
||||
this.cancelActiveDraft();
|
||||
this.removingMarker = null;
|
||||
}
|
||||
},
|
||||
// busy clears the synchronous confirm lock once the parent's save settles —
|
||||
// success clears the pending; a failed save keeps it so retry must re-enable.
|
||||
busy(newVal) {
|
||||
if (!newVal) {
|
||||
this._confirming = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Non-reactive scratch state for rAF-batched drag flushing and the cached
|
||||
// overlay-root rect (kept off `data()` so intermediate rects don't render).
|
||||
this._dragRect = null;
|
||||
this._dragTarget = null;
|
||||
this._dragRaf = null;
|
||||
this._dragScheduled = false;
|
||||
this._cachedParentRect = null;
|
||||
this._parentRectFresh = false;
|
||||
// Synchronous in-flight guard so a rapid second confirm cannot emit `create`
|
||||
// again before the parent's async `busy` lock propagates back as a prop.
|
||||
this._confirming = false;
|
||||
|
||||
this.attachPswpListeners();
|
||||
this.attachImageLoadListener();
|
||||
this.scheduleUpdate();
|
||||
|
|
@ -270,12 +317,15 @@ export default {
|
|||
this.rafHandle = null;
|
||||
}
|
||||
|
||||
this.cancelQueuedDrag();
|
||||
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// imageElement returns the current PhotoSwipe slide's <img> element, or null if none.
|
||||
imageElement() {
|
||||
const el = this.pswp?.currSlide?.content?.element;
|
||||
if (el instanceof HTMLImageElement) {
|
||||
|
|
@ -304,7 +354,13 @@ export default {
|
|||
this._loadListenedImg = img;
|
||||
this._onImgLoad = () => this.scheduleUpdate();
|
||||
img.addEventListener("load", this._onImgLoad);
|
||||
// A cached image is already complete when we attach, so its `load` event
|
||||
// will never fire; trigger a bounds update now so markers initialize.
|
||||
if (img.complete && img.naturalWidth > 0) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
},
|
||||
// detachImageLoadListener removes the image load listener and clears its refs.
|
||||
detachImageLoadListener() {
|
||||
if (this._loadListenedImg && this._onImgLoad) {
|
||||
this._loadListenedImg.removeEventListener("load", this._onImgLoad);
|
||||
|
|
@ -312,11 +368,12 @@ export default {
|
|||
this._loadListenedImg = null;
|
||||
this._onImgLoad = null;
|
||||
},
|
||||
// attachPswpListeners subscribes to PhotoSwipe zoom/change/resize events to keep bounds in sync.
|
||||
attachPswpListeners() {
|
||||
if (!this.pswp || typeof this.pswp.on !== "function") {
|
||||
return;
|
||||
}
|
||||
this._onZoomPan = () => this.scheduleUpdate();
|
||||
this._onZoomPan = () => this.scheduleUpdate(false);
|
||||
this._onChange = () => {
|
||||
this.attachImageLoadListener();
|
||||
this.scheduleUpdate();
|
||||
|
|
@ -327,6 +384,7 @@ export default {
|
|||
this.pswp.on("resize", this._onResize);
|
||||
this.pswp.on("imageClickAction", this._onChange);
|
||||
},
|
||||
// detachPswpListeners unsubscribes the PhotoSwipe event handlers.
|
||||
detachPswpListeners() {
|
||||
if (!this.pswp || typeof this.pswp.off !== "function") {
|
||||
return;
|
||||
|
|
@ -342,7 +400,15 @@ export default {
|
|||
this.pswp.off("resize", this._onResize);
|
||||
}
|
||||
},
|
||||
scheduleUpdate() {
|
||||
// scheduleUpdate batches a bounds recompute into the next animation frame.
|
||||
// `invalidateParent` (default true) marks the cached overlay-root rect
|
||||
// stale; the zoom path passes false since the container doesn't move during
|
||||
// pinch-zoom. The flag is set before the rafHandle dedup so dirtiness still
|
||||
// accumulates when a frame is coalesced.
|
||||
scheduleUpdate(invalidateParent = true) {
|
||||
if (invalidateParent) {
|
||||
this._parentRectFresh = false;
|
||||
}
|
||||
if (this.rafHandle) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -351,6 +417,17 @@ export default {
|
|||
this.updateBounds();
|
||||
});
|
||||
},
|
||||
// parentRect returns the overlay-root rect, cached across pinch-zoom frames
|
||||
// where only the image transform (not the container) changes; layout
|
||||
// events invalidate it via scheduleUpdate(true).
|
||||
parentRect() {
|
||||
if (!this._parentRectFresh || !this._cachedParentRect) {
|
||||
this._cachedParentRect = this.$refs.root.getBoundingClientRect();
|
||||
this._parentRectFresh = true;
|
||||
}
|
||||
return this._cachedParentRect;
|
||||
},
|
||||
// updateBounds recomputes the overlay pixel bounds from the image rect, insetting for letterboxed (object-fit: contain) slides.
|
||||
updateBounds() {
|
||||
const img = this.imageElement();
|
||||
if (!img || !this.$refs.root) {
|
||||
|
|
@ -360,7 +437,7 @@ export default {
|
|||
return;
|
||||
}
|
||||
const imgRect = img.getBoundingClientRect();
|
||||
const parentRect = this.$refs.root.getBoundingClientRect();
|
||||
const parentRect = this.parentRect();
|
||||
if (imgRect.width <= 0 || imgRect.height <= 0) {
|
||||
if (this.bounds !== null) {
|
||||
this.bounds = null;
|
||||
|
|
@ -403,12 +480,14 @@ export default {
|
|||
}
|
||||
this.bounds = { left, top, width, height };
|
||||
},
|
||||
// onPointerDown begins a resize, move, remove-click, or new draft depending on what the pointer lands on (edit mode only).
|
||||
onPointerDown(ev) {
|
||||
if (!this.isEditMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.bounds) {
|
||||
this._parentRectFresh = false;
|
||||
this.updateBounds();
|
||||
if (!this.bounds) {
|
||||
return;
|
||||
|
|
@ -425,7 +504,7 @@ export default {
|
|||
}
|
||||
|
||||
if (this.pending) {
|
||||
const corner = this.hitTestCorner(local, this.pending);
|
||||
const corner = this.hitTestCorner(local, this.pending, this.handleHitRadius(ev));
|
||||
if (corner) {
|
||||
this.beginResize(corner, ev);
|
||||
return;
|
||||
|
|
@ -462,28 +541,86 @@ export default {
|
|||
|
||||
this.attachWindowPointerListeners();
|
||||
},
|
||||
// Returns the first unnamed marker whose pixel rect contains the
|
||||
// given local point, or null if none. Named markers are skipped.
|
||||
findMarkerAt(local) {
|
||||
if (!this.bounds || !Array.isArray(this.markers)) {
|
||||
return null;
|
||||
// markerKey returns a stable, unique :key for a marker row. Saved markers
|
||||
// use their UID (CropID kept as a legacy fallback); brand-new unsaved
|
||||
// markers have no UID yet, so derive a key from the rect geometry — stable
|
||||
// across the fresh Marker instances getMarkers() returns each render, and
|
||||
// unique per rect, so Vue never collapses keyless rows or reuses/misorders
|
||||
// their DOM nodes.
|
||||
markerKey(m) {
|
||||
if (!m) {
|
||||
return "marker-nil";
|
||||
}
|
||||
for (const m of this.markers) {
|
||||
if (!m || m.SubjUID) {
|
||||
continue;
|
||||
}
|
||||
const rect = {
|
||||
x: m.X * this.bounds.width,
|
||||
y: m.Y * this.bounds.height,
|
||||
w: m.W * this.bounds.width,
|
||||
h: m.H * this.bounds.height,
|
||||
};
|
||||
if (this.insidePending(local, rect)) {
|
||||
return m;
|
||||
if (m.UID) {
|
||||
return m.UID;
|
||||
}
|
||||
if (m.CropID) {
|
||||
return m.CropID;
|
||||
}
|
||||
return `marker-tmp-${m.X}-${m.Y}-${m.W}-${m.H}`;
|
||||
},
|
||||
// Returns the first unnamed marker whose pixel rect contains the
|
||||
// given local point, or null if none. Reuses the precomputed
|
||||
// `markerRects` so hit-testing stays O(n) reads, not O(n) multiplies.
|
||||
findMarkerAt(local) {
|
||||
for (const r of this.markerRects) {
|
||||
if (this.insidePending(local, r)) {
|
||||
return r.marker;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
// queueDrag stores the latest draft/pending rect and flushes it to reactive
|
||||
// state at most once per animation frame, collapsing multiple pointermoves
|
||||
// per frame into a single SVG re-render. `target` is "draft" or "pending".
|
||||
// The boolean guard is set before scheduling so the synchronous rAF stub
|
||||
// used in tests still flushes immediately and leaves the guard reset.
|
||||
queueDrag(target, rect) {
|
||||
this._dragTarget = target;
|
||||
this._dragRect = rect;
|
||||
if (this._dragScheduled) {
|
||||
return;
|
||||
}
|
||||
this._dragScheduled = true;
|
||||
this._dragRaf = requestAnimationFrame(() => {
|
||||
this._dragRaf = null;
|
||||
this._dragScheduled = false;
|
||||
this.applyQueuedDrag();
|
||||
});
|
||||
},
|
||||
// applyQueuedDrag writes the held rect to the reactive draft/pending field.
|
||||
applyQueuedDrag() {
|
||||
if (!this._dragRect) {
|
||||
return;
|
||||
}
|
||||
if (this._dragTarget === "pending") {
|
||||
this.pending = this._dragRect;
|
||||
} else {
|
||||
this.draft = this._dragRect;
|
||||
}
|
||||
this._dragRect = null;
|
||||
},
|
||||
// flushQueuedDrag applies any rAF-batched rect immediately, e.g. on pointer
|
||||
// up so the committed rect reflects the final pointer position.
|
||||
flushQueuedDrag() {
|
||||
if (this._dragRaf) {
|
||||
cancelAnimationFrame(this._dragRaf);
|
||||
this._dragRaf = null;
|
||||
}
|
||||
this._dragScheduled = false;
|
||||
this.applyQueuedDrag();
|
||||
},
|
||||
// cancelQueuedDrag drops any queued rect without applying it (cancel,
|
||||
// escape, unmount) so a late flush cannot overwrite restored state.
|
||||
cancelQueuedDrag() {
|
||||
if (this._dragRaf) {
|
||||
cancelAnimationFrame(this._dragRaf);
|
||||
this._dragRaf = null;
|
||||
}
|
||||
this._dragScheduled = false;
|
||||
this._dragRect = null;
|
||||
this._dragTarget = null;
|
||||
},
|
||||
// ✓ in the remove-confirm pill. Emits `remove` with the marker so
|
||||
// the lightbox can call marker.reject() and re-derive the overlay
|
||||
// from the updated photo state.
|
||||
|
|
@ -499,6 +636,7 @@ export default {
|
|||
onCancelRemove() {
|
||||
this.removingMarker = null;
|
||||
},
|
||||
// onPointerMove updates the active draft/move/resize rect from the pointer, keeping it square and inside bounds.
|
||||
onPointerMove(ev) {
|
||||
if (!this.interaction || !this.dragStart || !this.bounds) {
|
||||
return;
|
||||
|
|
@ -532,7 +670,7 @@ export default {
|
|||
if (ny + origin.h > this.bounds.height) {
|
||||
ny = this.bounds.height - origin.h;
|
||||
}
|
||||
this.pending = { x: nx, y: ny, w: origin.w, h: origin.h };
|
||||
this.queueDrag("pending", { x: nx, y: ny, w: origin.w, h: origin.h });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -591,11 +729,12 @@ export default {
|
|||
}
|
||||
|
||||
if (this.interaction === InteractionResize) {
|
||||
this.pending = { x: sx, y: sy, w: sw, h: sh };
|
||||
this.queueDrag("pending", { x: sx, y: sy, w: sw, h: sh });
|
||||
} else {
|
||||
this.draft = { x: sx, y: sy, w: sw, h: sh };
|
||||
this.queueDrag("draft", { x: sx, y: sy, w: sw, h: sh });
|
||||
}
|
||||
},
|
||||
// onPointerUp ends the gesture, promoting a large-enough draw draft into a confirmable pending rect.
|
||||
onPointerUp(ev) {
|
||||
if (!this.interaction) {
|
||||
return;
|
||||
|
|
@ -605,6 +744,9 @@ export default {
|
|||
}
|
||||
|
||||
this.detachWindowPointerListeners();
|
||||
// Apply any rAF-batched move so the committed rect reflects the final
|
||||
// pointer position even if the frame flush hasn't fired yet.
|
||||
this.flushQueuedDrag();
|
||||
|
||||
const wasInteraction = this.interaction;
|
||||
const draft = this.draft;
|
||||
|
|
@ -625,10 +767,13 @@ export default {
|
|||
return;
|
||||
}
|
||||
|
||||
// A freshly promoted pending is a new marker — allow it to be confirmed.
|
||||
this._confirming = false;
|
||||
this.pending = draft;
|
||||
},
|
||||
// onConfirmPending emits the normalized pending rect as a create event once, guarded against double-confirm.
|
||||
onConfirmPending() {
|
||||
if (this.busy) {
|
||||
if (this.busy || this._confirming) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -638,6 +783,8 @@ export default {
|
|||
return;
|
||||
}
|
||||
|
||||
this._confirming = true;
|
||||
|
||||
const nx = pending.x / bounds.width;
|
||||
const ny = pending.y / bounds.height;
|
||||
const nw = pending.w / bounds.width;
|
||||
|
|
@ -650,9 +797,11 @@ export default {
|
|||
H: this.clamp01(nh),
|
||||
});
|
||||
},
|
||||
// onCancelPending discards the pending rect without exiting draw mode.
|
||||
onCancelPending() {
|
||||
this.pending = null;
|
||||
this.hoverCursor = null;
|
||||
this._confirming = false;
|
||||
},
|
||||
// Back-button click. Signals the parent lightbox to exit face-marker
|
||||
// mode entirely (display or draw). Uses the existing `cancel` emit
|
||||
|
|
@ -668,11 +817,14 @@ export default {
|
|||
clearPending() {
|
||||
this.pending = null;
|
||||
this.hoverCursor = null;
|
||||
this._confirming = false;
|
||||
},
|
||||
// cancelActiveDraft tears down any in-progress gesture and clears draft/pending state.
|
||||
cancelActiveDraft() {
|
||||
if (this.interaction) {
|
||||
this.detachWindowPointerListeners();
|
||||
}
|
||||
this.cancelQueuedDrag();
|
||||
this.interaction = null;
|
||||
this.resizeCorner = null;
|
||||
this.pointerId = null;
|
||||
|
|
@ -680,6 +832,7 @@ export default {
|
|||
this.draft = null;
|
||||
this.pending = null;
|
||||
this.hoverCursor = null;
|
||||
this._confirming = false;
|
||||
},
|
||||
// handleEnter mirrors a ✓ click; no-op during draft / drag / remove-confirm.
|
||||
handleEnter() {
|
||||
|
|
@ -692,6 +845,7 @@ export default {
|
|||
// rect without exiting draw mode; returns true when the overlay consumed it.
|
||||
handleEscape() {
|
||||
if (this.interaction === InteractionDraw) {
|
||||
this.cancelQueuedDrag();
|
||||
this.interaction = null;
|
||||
this.pointerId = null;
|
||||
this.dragStart = null;
|
||||
|
|
@ -700,6 +854,7 @@ export default {
|
|||
return true;
|
||||
}
|
||||
if (this.interaction === InteractionMove || this.interaction === InteractionResize) {
|
||||
this.cancelQueuedDrag();
|
||||
const snapshot = this.dragStart && this.dragStart.pending;
|
||||
if (snapshot) {
|
||||
this.pending = { ...snapshot };
|
||||
|
|
@ -713,6 +868,7 @@ export default {
|
|||
}
|
||||
if (this.pending) {
|
||||
this.pending = null;
|
||||
this._confirming = false;
|
||||
return true;
|
||||
}
|
||||
if (this.removingMarker) {
|
||||
|
|
@ -721,6 +877,7 @@ export default {
|
|||
}
|
||||
return false;
|
||||
},
|
||||
// stopEventFromPswp stops propagation and default so PhotoSwipe doesn't treat the gesture as a pan/zoom.
|
||||
stopEventFromPswp(ev) {
|
||||
if (typeof ev.stopPropagation === "function") {
|
||||
ev.stopPropagation();
|
||||
|
|
@ -729,18 +886,28 @@ export default {
|
|||
ev.preventDefault();
|
||||
}
|
||||
},
|
||||
// attachWindowPointerListeners tracks pointer move/up/cancel on window for the duration of a drag.
|
||||
attachWindowPointerListeners() {
|
||||
window.addEventListener("pointermove", this.onPointerMove);
|
||||
window.addEventListener("pointerup", this.onPointerUp);
|
||||
window.addEventListener("pointercancel", this.onPointerUp);
|
||||
},
|
||||
// detachWindowPointerListeners removes the window-level drag pointer listeners.
|
||||
detachWindowPointerListeners() {
|
||||
window.removeEventListener("pointermove", this.onPointerMove);
|
||||
window.removeEventListener("pointerup", this.onPointerUp);
|
||||
window.removeEventListener("pointercancel", this.onPointerUp);
|
||||
},
|
||||
hitTestCorner(p, rect) {
|
||||
const r = 14;
|
||||
// handleHitRadius returns the corner grab radius for the event's pointer
|
||||
// type: larger for touch/pen (coarse) pointers, where the visible handle is
|
||||
// hard to hit; the mouse radius is unchanged so existing behavior holds.
|
||||
handleHitRadius(ev) {
|
||||
const coarse = !!ev && (ev.pointerType === "touch" || ev.pointerType === "pen");
|
||||
return coarse ? 22 : 14;
|
||||
},
|
||||
// hitTestCorner returns the resize-handle corner key within the given radius of point p, or null.
|
||||
hitTestCorner(p, rect, radius = 14) {
|
||||
const r = radius;
|
||||
const corners = {
|
||||
tl: { x: rect.x, y: rect.y },
|
||||
tr: { x: rect.x + rect.w, y: rect.y },
|
||||
|
|
@ -755,6 +922,7 @@ export default {
|
|||
}
|
||||
return null;
|
||||
},
|
||||
// insidePending reports whether point p lies within the given rect.
|
||||
insidePending(p, rect) {
|
||||
return p.x >= rect.x && p.y >= rect.y && p.x <= rect.x + rect.w && p.y <= rect.y + rect.h;
|
||||
},
|
||||
|
|
@ -788,6 +956,7 @@ export default {
|
|||
};
|
||||
this.attachWindowPointerListeners();
|
||||
},
|
||||
// onHoverMove sets the cursor to reflect the resize/move/remove affordance under the pointer (edit mode only).
|
||||
onHoverMove(ev) {
|
||||
if (!this.isEditMode || this.interaction) {
|
||||
return;
|
||||
|
|
@ -806,7 +975,7 @@ export default {
|
|||
return;
|
||||
}
|
||||
if (this.pending) {
|
||||
const corner = this.hitTestCorner(local, this.pending);
|
||||
const corner = this.hitTestCorner(local, this.pending, this.handleHitRadius(ev));
|
||||
if (corner) {
|
||||
const c = corner === "tl" || corner === "br" ? "nwse-resize" : "nesw-resize";
|
||||
if (this.hoverCursor !== c) {
|
||||
|
|
@ -833,6 +1002,7 @@ export default {
|
|||
this.hoverCursor = null;
|
||||
}
|
||||
},
|
||||
// onHoverLeave clears the hover cursor when the pointer leaves the overlay.
|
||||
onHoverLeave() {
|
||||
if (this.hoverCursor !== null) {
|
||||
this.hoverCursor = null;
|
||||
|
|
@ -868,6 +1038,7 @@ export default {
|
|||
})
|
||||
);
|
||||
},
|
||||
// beginMove starts dragging the whole pending rect from the given local anchor point.
|
||||
beginMove(local, ev) {
|
||||
const p = this.pending;
|
||||
if (!p) {
|
||||
|
|
@ -885,6 +1056,7 @@ export default {
|
|||
};
|
||||
this.attachWindowPointerListeners();
|
||||
},
|
||||
// toLocal converts client coordinates to the overlay's image-local coordinate space.
|
||||
toLocal(clientX, clientY) {
|
||||
if (!this.bounds || !this.$refs.root) {
|
||||
return { x: 0, y: 0 };
|
||||
|
|
@ -895,9 +1067,11 @@ export default {
|
|||
y: clientY - rect.top - this.bounds.top,
|
||||
};
|
||||
},
|
||||
// insideBounds reports whether local point p lies within the image bounds.
|
||||
insideBounds(p) {
|
||||
return this.bounds && p.x >= 0 && p.y >= 0 && p.x <= this.bounds.width && p.y <= this.bounds.height;
|
||||
},
|
||||
// clamp01 clamps a normalized coordinate to [0, 1), capping at 0.999999 to stay below 1.
|
||||
clamp01(v) {
|
||||
if (v < 0) {
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -950,4 +950,290 @@ describe("PMetaFaceMarkers", () => {
|
|||
expect(wrapper.vm.removingMarker).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The hover and pointer-down hit-tests reuse the `markerRects` computed
|
||||
// instead of recomputing every marker's pixel rect per pointer event.
|
||||
describe("performance — precomputed marker hit-testing", () => {
|
||||
const unnamed = { UID: "u1", Name: "", X: 0.2, Y: 0.2, W: 0.2, H: 0.2 };
|
||||
const named = { UID: "n1", Name: "Jane", SubjUID: "subj1", X: 0.6, Y: 0.6, W: 0.2, H: 0.2 };
|
||||
|
||||
it("markerRects precomputes pixel rects for unnamed markers only", () => {
|
||||
const { wrapper } = mountOverlay({ mode: FaceMarkerEdit, markers: [unnamed, named] });
|
||||
const rects = wrapper.vm.markerRects;
|
||||
expect(rects).toHaveLength(1);
|
||||
expect(rects[0].marker.UID).toBe("u1");
|
||||
expect(rects[0]).toMatchObject({
|
||||
x: unnamed.X * IMAGE_RECT.width,
|
||||
y: unnamed.Y * IMAGE_RECT.height,
|
||||
w: unnamed.W * IMAGE_RECT.width,
|
||||
h: unnamed.H * IMAGE_RECT.height,
|
||||
});
|
||||
});
|
||||
|
||||
it("findMarkerAt returns the unnamed marker inside its rect and null elsewhere", () => {
|
||||
const { wrapper } = mountOverlay({ mode: FaceMarkerEdit, markers: [unnamed, named] });
|
||||
const inside = {
|
||||
x: (unnamed.X + unnamed.W / 2) * IMAGE_RECT.width,
|
||||
y: (unnamed.Y + unnamed.H / 2) * IMAGE_RECT.height,
|
||||
};
|
||||
expect(wrapper.vm.findMarkerAt(inside)?.UID).toBe("u1");
|
||||
// Named markers are skipped — they are not removable via the overlay.
|
||||
const namedCentre = {
|
||||
x: (named.X + named.W / 2) * IMAGE_RECT.width,
|
||||
y: (named.Y + named.H / 2) * IMAGE_RECT.height,
|
||||
};
|
||||
expect(wrapper.vm.findMarkerAt(namedCentre)).toBeNull();
|
||||
expect(wrapper.vm.findMarkerAt({ x: IMAGE_RECT.width - 1, y: IMAGE_RECT.height - 1 })).toBeNull();
|
||||
});
|
||||
|
||||
it("markerRects is empty and findMarkerAt null when bounds are unavailable", () => {
|
||||
const { wrapper } = mountOverlay({ mode: FaceMarkerEdit, markers: [unnamed] });
|
||||
wrapper.vm.bounds = null;
|
||||
expect(wrapper.vm.markerRects).toEqual([]);
|
||||
expect(wrapper.vm.findMarkerAt({ x: 10, y: 10 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Pointer-move draft/pending writes are coalesced through requestAnimationFrame
|
||||
// so multiple moves per frame collapse into a single SVG re-render; pointer up
|
||||
// flushes synchronously so the committed rect reflects the final position.
|
||||
describe("performance — drag coalescing through requestAnimationFrame", () => {
|
||||
function stubDeferredRaf() {
|
||||
const frames = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (cb) => {
|
||||
frames.push(cb);
|
||||
return frames.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id) => {
|
||||
if (id >= 1 && id <= frames.length) {
|
||||
frames[id - 1] = null;
|
||||
}
|
||||
});
|
||||
return frames;
|
||||
}
|
||||
function startDraw(wrapper, pointerId) {
|
||||
wrapper.vm.onPointerDown({
|
||||
button: 0,
|
||||
pointerId,
|
||||
clientX: 150,
|
||||
clientY: 100,
|
||||
stopPropagation: () => {},
|
||||
preventDefault: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
it("defers draft writes to the next frame, applying only the latest queued rect", () => {
|
||||
const frames = stubDeferredRaf();
|
||||
const { wrapper } = mountOverlay();
|
||||
startDraw(wrapper, 1);
|
||||
// pointerdown seeds a zero-size draft synchronously.
|
||||
expect(wrapper.vm.draft).toMatchObject({ x: 50, y: 50, w: 0, h: 0 });
|
||||
// Two moves in the same frame both queue without touching reactive draft.
|
||||
wrapper.vm.onPointerMove({ pointerId: 1, clientX: 280, clientY: 180 });
|
||||
wrapper.vm.onPointerMove({ pointerId: 1, clientX: 250, clientY: 150 });
|
||||
expect(wrapper.vm.draft).toMatchObject({ w: 0, h: 0 });
|
||||
// Running the scheduled frames applies only the latest queued rect.
|
||||
frames.forEach((cb) => cb && cb());
|
||||
expect(wrapper.vm.draft).toMatchObject({ x: 50, y: 50, w: 100, h: 100 });
|
||||
});
|
||||
|
||||
it("flushes the queued rect on pointer up and promotes it to pending", () => {
|
||||
stubDeferredRaf();
|
||||
const { wrapper } = mountOverlay();
|
||||
startDraw(wrapper, 2);
|
||||
wrapper.vm.onPointerMove({ pointerId: 2, clientX: 280, clientY: 180 });
|
||||
// Still the seeded draft — the move is queued, not yet applied.
|
||||
expect(wrapper.vm.draft).toMatchObject({ w: 0, h: 0 });
|
||||
wrapper.vm.onPointerUp({ pointerId: 2 });
|
||||
expect(wrapper.vm.draft).toBeNull();
|
||||
expect(wrapper.vm.pending).toMatchObject({ x: 50, y: 50, w: 130, h: 130 });
|
||||
});
|
||||
|
||||
it("escape during draw cancels the queued frame so the draft stays cleared", () => {
|
||||
const frames = stubDeferredRaf();
|
||||
const { wrapper } = mountOverlay();
|
||||
startDraw(wrapper, 3);
|
||||
wrapper.vm.onPointerMove({ pointerId: 3, clientX: 280, clientY: 180 });
|
||||
expect(wrapper.vm.handleEscape()).toBe(true);
|
||||
expect(wrapper.vm.draft).toBeNull();
|
||||
// A leftover frame must not resurrect the cancelled draft.
|
||||
frames.forEach((cb) => cb && cb());
|
||||
expect(wrapper.vm.draft).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The overlay-root rect is cached across pinch-zoom frames (where only the
|
||||
// image transform changes) and re-read on layout-changing events.
|
||||
describe("performance — overlay-root rect caching", () => {
|
||||
it("reads the root rect once, reuses it, and re-reads after invalidation", () => {
|
||||
const { wrapper } = mountOverlay();
|
||||
const spy = vi.fn(() => ROOT_RECT);
|
||||
wrapper.vm.$refs.root.getBoundingClientRect = spy;
|
||||
// Prime the cache with a single read.
|
||||
wrapper.vm._parentRectFresh = false;
|
||||
wrapper.vm.updateBounds();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
// Further recomputes reuse the cache — no extra layout reads.
|
||||
wrapper.vm.updateBounds();
|
||||
wrapper.vm.updateBounds();
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
// Invalidation forces one fresh read.
|
||||
wrapper.vm._parentRectFresh = false;
|
||||
wrapper.vm.updateBounds();
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("zoomPanUpdate keeps the cache; resize/change invalidate it", () => {
|
||||
const { wrapper, pswp } = mountOverlay();
|
||||
wrapper.vm._parentRectFresh = true;
|
||||
pswp._listeners.zoomPanUpdate();
|
||||
expect(wrapper.vm._parentRectFresh).toBe(true);
|
||||
pswp._listeners.resize();
|
||||
expect(wrapper.vm._parentRectFresh).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// A brand-new unsaved marker has no UID/CropID (both default to ""), so the
|
||||
// key must come from the rect geometry to stay stable and unique.
|
||||
describe("correctness — stable marker keys", () => {
|
||||
it("markerKey uses the marker UID when present", () => {
|
||||
const { wrapper } = mountOverlay();
|
||||
expect(wrapper.vm.markerKey({ UID: "mabc", X: 0.1, Y: 0.1, W: 0.1, H: 0.1 })).toBe("mabc");
|
||||
});
|
||||
|
||||
it("markerKey derives a stable, non-empty geometry key for UID-less markers", () => {
|
||||
const { wrapper } = mountOverlay();
|
||||
const m = { UID: "", CropID: "", X: 0.2, Y: 0.3, W: 0.1, H: 0.1 };
|
||||
const k1 = wrapper.vm.markerKey(m);
|
||||
// A different object with the same geometry yields the same key (getMarkers
|
||||
// returns fresh instances each render, so the key can't rely on identity).
|
||||
const k2 = wrapper.vm.markerKey({ ...m });
|
||||
expect(k1).toBe(k2);
|
||||
expect(k1).not.toBe("");
|
||||
expect(k1).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it("markerKey gives distinct keys to distinct UID-less markers", () => {
|
||||
const { wrapper } = mountOverlay();
|
||||
const a = wrapper.vm.markerKey({ UID: "", X: 0.1, Y: 0.1, W: 0.1, H: 0.1 });
|
||||
const b = wrapper.vm.markerKey({ UID: "", X: 0.5, Y: 0.5, W: 0.1, H: 0.1 });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("renders distinct rows for two UID-less markers (no key collapse)", async () => {
|
||||
const markers = [
|
||||
{ UID: "", Name: "", X: 0.1, Y: 0.1, W: 0.1, H: 0.1 },
|
||||
{ UID: "", Name: "", X: 0.5, Y: 0.5, W: 0.1, H: 0.1 },
|
||||
];
|
||||
const { wrapper } = mountOverlay({ markers });
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
const rects = wrapper.element.querySelectorAll("rect.p-meta-face-markers__rect");
|
||||
expect(rects.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// The parent's `busy` lock propagates asynchronously, so a synchronous in-flight
|
||||
// guard keeps confirm idempotent across rapid double-clicks / ✓ + Enter.
|
||||
describe("correctness — idempotent confirm", () => {
|
||||
it("emits create at most once on a rapid double confirm", () => {
|
||||
const onCreate = vi.fn();
|
||||
const { wrapper } = mountOverlay({}, { onCreate });
|
||||
wrapper.vm.pending = { x: 50, y: 50, w: 130, h: 130 };
|
||||
wrapper.vm.onConfirmPending();
|
||||
// Second confirm fires before the parent's busy prop propagates back.
|
||||
wrapper.vm.onConfirmPending();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("re-enables confirm after the busy cycle settles (failed-save retry)", async () => {
|
||||
const onCreate = vi.fn();
|
||||
const { wrapper } = mountOverlay({}, { onCreate });
|
||||
wrapper.vm.pending = { x: 50, y: 50, w: 130, h: 130 };
|
||||
wrapper.vm.onConfirmPending();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
// Parent toggles busy true→false (e.g. save failed, rect kept for retry).
|
||||
await wrapper.setProps({ busy: true });
|
||||
await wrapper.setProps({ busy: false });
|
||||
wrapper.vm.onConfirmPending();
|
||||
expect(onCreate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("allows confirming a new pending after cancel without a busy cycle", () => {
|
||||
const onCreate = vi.fn();
|
||||
const { wrapper } = mountOverlay({}, { onCreate });
|
||||
wrapper.vm.pending = { x: 50, y: 50, w: 130, h: 130 };
|
||||
wrapper.vm.onConfirmPending();
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
wrapper.vm.onCancelPending();
|
||||
wrapper.vm.pending = { x: 20, y: 20, w: 60, h: 60 };
|
||||
wrapper.vm.onConfirmPending();
|
||||
expect(onCreate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// A cached image is already complete when the overlay attaches its load
|
||||
// listener, so that listener never fires; bounds must initialize anyway.
|
||||
describe("correctness — cached-image bounds", () => {
|
||||
it("initializes bounds for an already-complete image without a load event", () => {
|
||||
const { wrapper, pswp } = mountOverlay();
|
||||
Object.defineProperty(pswp.img, "complete", { value: true, configurable: true });
|
||||
Object.defineProperty(pswp.img, "naturalWidth", { value: 480, configurable: true });
|
||||
Object.defineProperty(pswp.img, "naturalHeight", { value: 360, configurable: true });
|
||||
// Force a re-attach and clear state (mount already attached once). The
|
||||
// synchronous rAF stub leaves rafHandle truthy, which would dedup the
|
||||
// scheduled update — reset it so updateBounds actually runs.
|
||||
wrapper.vm._loadListenedImg = null;
|
||||
wrapper.vm.rafHandle = null;
|
||||
wrapper.vm.bounds = null;
|
||||
wrapper.vm.attachImageLoadListener();
|
||||
// No load event was dispatched, yet bounds were recomputed.
|
||||
expect(wrapper.vm.bounds).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Corner handles use a larger grab radius on coarse pointers so they are
|
||||
// easier to grab on touch, while the mouse radius is unchanged.
|
||||
describe("ux — touch-friendly resize handles", () => {
|
||||
it("uses a larger corner hit radius for coarse (touch/pen) pointers", () => {
|
||||
const { wrapper } = mountOverlay();
|
||||
expect(wrapper.vm.handleHitRadius({ pointerType: "mouse" })).toBe(14);
|
||||
expect(wrapper.vm.handleHitRadius({ pointerType: "touch" })).toBe(22);
|
||||
expect(wrapper.vm.handleHitRadius({ pointerType: "pen" })).toBe(22);
|
||||
expect(wrapper.vm.handleHitRadius(undefined)).toBe(14);
|
||||
});
|
||||
|
||||
it("grabs a corner on touch from just outside the mouse hit radius", () => {
|
||||
const { wrapper } = mountOverlay();
|
||||
wrapper.vm.pending = { x: 100, y: 100, w: 100, h: 100 };
|
||||
// br corner is local (200, 200); click 18 px below → local (200, 218) →
|
||||
// client (300, 268). 18 px is outside 14 (mouse) but inside 22 (touch).
|
||||
wrapper.vm.onPointerDown({
|
||||
button: 0,
|
||||
pointerId: 7,
|
||||
pointerType: "touch",
|
||||
clientX: 300,
|
||||
clientY: 268,
|
||||
stopPropagation: () => {},
|
||||
preventDefault: () => {},
|
||||
});
|
||||
expect(wrapper.vm.interaction).toBe("resize");
|
||||
expect(wrapper.vm.resizeCorner).toBe("br");
|
||||
});
|
||||
|
||||
it("does not grab the corner on mouse from outside the 14 px radius", () => {
|
||||
const { wrapper } = mountOverlay();
|
||||
wrapper.vm.pending = { x: 100, y: 100, w: 100, h: 100 };
|
||||
wrapper.vm.onPointerDown({
|
||||
button: 0,
|
||||
pointerId: 8,
|
||||
pointerType: "mouse",
|
||||
clientX: 300,
|
||||
clientY: 268,
|
||||
stopPropagation: () => {},
|
||||
preventDefault: () => {},
|
||||
});
|
||||
expect(wrapper.vm.interaction).not.toBe("resize");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue