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>
This commit is contained in:
Marcel
2026-04-29 08:53:29 +02:00
parent 1842e23c81
commit 060db69108
3 changed files with 228 additions and 25 deletions

View File

@@ -7,6 +7,7 @@ import {
type SafeHtml,
PERSON_MENTION_SELECTOR
} from '$lib/utils/mention';
import { computeHoverCardPosition } from '$lib/utils/hoverCardPosition';
import PersonHoverCard from './PersonHoverCard.svelte';
import type { HoverData, LoadState } from '$lib/types/personHoverCard';
import { goto } from '$app/navigation';
@@ -38,10 +39,6 @@ let activeCard: {
position: { top: number; left: number };
} | null = $state(null);
const CARD_WIDTH = 320;
const CARD_HEIGHT = 180;
const CARD_GAP = 6;
// Compose splitByMarkers with renderTranscriptionBody. Markers are pre-rendered
// as <em data-marker> tags; text segments run through HTML-escaping + mention
// substitution. The two are concatenated to preserve marker boundaries — markers
@@ -81,27 +78,12 @@ function fetchHoverData(personId: string): Promise<HoverData | null> {
return cached;
}
function computeCardPosition(rect: DOMRect): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
let top = rect.bottom + CARD_GAP;
let left = rect.left;
// Flip up if the card would overflow the bottom edge OR the mention sits in
// the bottom 30% of the viewport (Leonie #5329).
if (vh - rect.bottom < CARD_HEIGHT + CARD_GAP || rect.top > vh * 0.7) {
top = rect.top - CARD_HEIGHT - CARD_GAP;
}
// Flip left if <300px from the right edge.
if (vw - rect.left < 300) {
left = rect.right - CARD_WIDTH;
}
function currentViewport() {
return {
top: Math.max(0, top + window.scrollY),
left: Math.max(0, left + window.scrollX)
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
scrollX: window.scrollX,
scrollY: window.scrollY
};
}
@@ -115,7 +97,7 @@ async function handleMentionEnter(event: Event) {
link.setAttribute('aria-describedby', cardId);
const rect = link.getBoundingClientRect();
const position = computeCardPosition(rect);
const position = computeHoverCardPosition(rect, currentViewport());
activeCard = { personId, cardId, position, state: { status: 'loading' } };