Files
familienarchiv/frontend/src/lib/utils/hoverCardPosition.ts
Marcel 060db69108 refactor(person-mention): extract computeHoverCardPosition into testable util
Three reviewer concerns land here:
- Felix #2: magic numbers 0.7 and 300 belong in named constants
- Sara #6: the position function had 4 branches and 2 thresholds with zero tests
- Leonie FINDING-05: at 320px viewport the flip-left could push the card
  past the right edge — needed a viewport clamp

Move the function to src/lib/utils/hoverCardPosition.ts as a pure
(rect, viewport) → {top, left} mapping, with named exports CARD_WIDTH_PX,
CARD_HEIGHT_PX, CARD_GAP_PX, BOTTOM_BAND_RATIO, RIGHT_FLIP_THRESHOLD_PX.
Add a viewport clamp so left + CARD_WIDTH never exceeds the right edge.

Ten unit tests cover default placement, flip-up (both triggers), flip-left,
flip-right-edge clamp, and scroll offset. TranscriptionReadView passes the
current window viewport in on each call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 08:53:29 +02:00

70 lines
2.3 KiB
TypeScript

/**
* Pure positioning logic for the person-mention hover card.
*
* Pulled out of TranscriptionReadView so the four placement branches
* (default, flip-up, flip-left, both) plus the viewport clamp are unit-testable
* without DOM. Sara's PR-B2 review #6 (no test for computeCardPosition) and
* Leonie's FINDING-05 (320px overflow) both land here.
*/
/** Width of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
export const CARD_WIDTH_PX = 320;
/** Min-height of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
export const CARD_HEIGHT_PX = 180;
/** Gap between the mention rect and the card so they do not touch. */
export const CARD_GAP_PX = 6;
/**
* Mentions in the bottom 30% of the viewport flip the card up by default,
* even if it would numerically fit below — keeping the eye-line stable
* is more important than minimal travel (Leonie #5329).
*/
export const BOTTOM_BAND_RATIO = 0.7;
/**
* Mentions within this distance of the right viewport edge flip the card
* left so it stays fully visible.
*/
export const RIGHT_FLIP_THRESHOLD_PX = 300;
export type Viewport = {
viewportWidth: number;
viewportHeight: number;
scrollX: number;
scrollY: number;
};
export type CardPosition = { top: number; left: number };
/**
* Compute absolute-positioned top/left for the hover card, given a rect for
* the mention anchor and the current viewport. Output is in document
* coordinates (already includes scroll offsets).
*/
export function computeHoverCardPosition(rect: DOMRect, vp: Viewport): CardPosition {
let top = rect.bottom + CARD_GAP_PX;
let left = rect.left;
const overflowsBottom = vp.viewportHeight - rect.bottom < CARD_HEIGHT_PX + CARD_GAP_PX;
const inBottomBand = rect.top > vp.viewportHeight * BOTTOM_BAND_RATIO;
if (overflowsBottom || inBottomBand) {
top = rect.top - CARD_HEIGHT_PX - CARD_GAP_PX;
}
if (vp.viewportWidth - rect.left < RIGHT_FLIP_THRESHOLD_PX) {
left = rect.right - CARD_WIDTH_PX;
}
// Clamp left so the card never extends past the right viewport edge
// (FINDING-05: at 320px viewport the flip would otherwise produce a
// negative left or right-side overflow).
left = Math.min(left, vp.viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX);
return {
top: Math.max(0, top + vp.scrollY),
left: Math.max(0, left + vp.scrollX)
};
}