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:
@@ -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' } };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user