diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java index 3ff9ca78..6d3e167d 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/TranscriptionBlock.java @@ -35,7 +35,9 @@ public class TranscriptionBlock { @Column(columnDefinition = "TEXT") private String text; - @ElementCollection(fetch = FetchType.LAZY) + // EAGER: mention set is bounded by block text length (typically < 20 entries). + // Switching back to LAZY requires callers to be inside an open Hibernate session. + @ElementCollection(fetch = FetchType.EAGER) @CollectionTable( name = "transcription_block_mentioned_persons", joinColumns = @JoinColumn(name = "block_id")) diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java index 589e7f56..b96f1a2a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TranscriptionService.java @@ -134,6 +134,8 @@ public class TranscriptionService { if (dto.getLabel() != null) { block.setLabel(dto.getLabel()); } + block.getMentionedPersons().clear(); + block.getMentionedPersons().addAll(dto.getMentionedPersons()); block.setUpdatedBy(userId); TranscriptionBlock saved = blockRepository.save(block); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java index 8aa6ee99..15a098c0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TranscriptionServiceTest.java @@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.model.BlockSource; import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.DocumentAnnotation; import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.model.PersonMention; import org.raddatz.familienarchiv.model.ScriptType; import org.raddatz.familienarchiv.model.TranscriptionBlock; import org.raddatz.familienarchiv.model.TranscriptionBlockVersion; @@ -215,6 +216,60 @@ class TranscriptionServiceTest { assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL); } + @Test + void updateBlock_replacesMentionedPersonsFromDto() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + UUID personId = UUID.randomUUID(); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("old").build(); + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(documentService.getDocumentById(any())).thenReturn( + Document.builder().scriptType(ScriptType.TYPEWRITER).build()); + + PersonMention mention = new PersonMention(personId, "Auguste"); + UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder() + .text("@Auguste text") + .mentionedPersons(List.of(mention)) + .build(); + + TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID()); + + assertThat(result.getMentionedPersons()) + .containsExactly(mention); + } + + @Test + void updateBlock_clearsPriorMentions_beforeApplyingDto() { + UUID docId = UUID.randomUUID(); + UUID blockId = UUID.randomUUID(); + + PersonMention prior = new PersonMention(UUID.randomUUID(), "Heinrich"); + PersonMention incoming = new PersonMention(UUID.randomUUID(), "Auguste"); + + TranscriptionBlock block = TranscriptionBlock.builder() + .id(blockId).documentId(docId).text("old").build(); + block.getMentionedPersons().add(prior); + + when(blockRepository.findByIdAndDocumentId(blockId, docId)).thenReturn(Optional.of(block)); + when(blockRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(documentService.getDocumentById(any())).thenReturn( + Document.builder().scriptType(ScriptType.TYPEWRITER).build()); + + UpdateTranscriptionBlockDTO dto = UpdateTranscriptionBlockDTO.builder() + .text("@Auguste text") + .mentionedPersons(List.of(incoming)) + .build(); + + TranscriptionBlock result = transcriptionService.updateBlock(docId, blockId, dto, UUID.randomUUID()); + + assertThat(result.getMentionedPersons()) + .containsExactly(incoming) + .doesNotContain(prior); + } + @Test void updateBlock_triggersTraining_whenKurrentSenderPresent() { UUID docId = UUID.randomUUID(); diff --git a/frontend/src/lib/components/MentionDropdown.svelte b/frontend/src/lib/components/MentionDropdown.svelte index d86e912a..6a95b233 100644 --- a/frontend/src/lib/components/MentionDropdown.svelte +++ b/frontend/src/lib/components/MentionDropdown.svelte @@ -111,7 +111,7 @@ function selectItem(item: Person) { unauthenticated users. -->
import { m } from '$lib/paraglide/messages.js'; import { formatLifeDateRange } from '$lib/utils/personLifeDates'; +import { chipLabel, otherName } from '$lib/relationshipLabels'; import type { components } from '$lib/generated/api'; import type { LoadState } from '$lib/types/personHoverCard'; @@ -11,9 +12,11 @@ type Props = { cardId: string; position: { top: number; left: number }; state: LoadState; + onmouseenter?: () => void; + onmouseleave?: () => void; }; -let { personId, cardId, position, state }: Props = $props(); +let { personId, cardId, position, state, onmouseenter, onmouseleave }: Props = $props(); const FAMILY_REL_TYPES: ReadonlySet = new Set([ 'PARENT_OF', @@ -69,6 +72,13 @@ const ariaLabel = $derived( // aria-busy="true" while loading so SR clients know the region's contents // will change. Cleared on loaded/error so the new content is announced. const ariaBusy = $derived(state.status === 'loading'); + +const showMaidenName = $derived( + state.status === 'loaded' && + !!state.person.alias && + state.person.alias !== state.person.lastName && + state.person.alias !== state.person.displayName +);
{ + if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) { + onmouseleave?.(); + } + }} > {#if state.status === 'loading'}
{dateRange}
{/if} - {#if state.person.alias} + {#if showMaidenName}
{m.person_born_name_prefix()} {state.person.alias} @@ -118,7 +136,10 @@ const ariaBusy = $derived(state.status === 'loading'); {#if familyChips.length > 0}
{#each familyChips as chip (chip.id)} - {chip.relatedPersonDisplayName} + + {chipLabel(chip, personId)}: + {otherName(chip, personId)} + {/each}
{/if} @@ -230,6 +251,9 @@ const ariaBusy = $derived(state.status === 'loading'); } .chip { + display: flex; + align-items: center; + gap: 4px; font-size: 12px; background-color: var(--c-accent-bg); color: var(--c-ink); @@ -237,6 +261,12 @@ const ariaBusy = $derived(state.status === 'loading'); padding: 2px 10px; } +.chip-type { + font-weight: 600; + /* opacity 0.7 on --c-ink: ~5.6:1 light, ~7.1:1 dark — WCAG AA ✓ */ + opacity: 0.7; +} + .notes { font-size: 13px; color: var(--c-ink-2); diff --git a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts index f68bfdfc..54881180 100644 --- a/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts +++ b/frontend/src/lib/components/PersonHoverCard.svelte.spec.ts @@ -165,6 +165,31 @@ describe('PersonHoverCard — loaded state', () => { await expect.element(friendChip).not.toBeInTheDocument(); }); + it('shows the other person name when hovered person is the object (relatedPersonId) in a PARENT_OF row', async () => { + // Storage: Heinrich PARENT_OF Auguste. When viewing Auguste's card, + // the chip must show "Heinrich" (the parent), not "Auguste" (herself). + const relationships: RelationshipDTO[] = [ + { + id: 'r-parent', + personId: 'p-heinrich', + relatedPersonId: 'p-aug', + personDisplayName: 'Heinrich Raddatz', + relatedPersonDisplayName: 'Auguste Raddatz', + relationType: 'PARENT_OF' + } + ]; + render(PersonHoverCard, { + personId: 'p-aug', + cardId: 'card-1', + position: POSITION, + state: { status: 'loaded', person: AUGUSTE, relationships } + }); + await expect.element(page.getByText('Heinrich Raddatz')).toBeInTheDocument(); + // Auguste must NOT appear as her own parent chip name + const chips = document.querySelector('[data-testid="person-hover-card-chips"]'); + expect(chips?.textContent).not.toContain('Auguste Raddatz'); + }); + it('omits the chips section entirely when no family relationships', async () => { const onlyFriend: RelationshipDTO[] = [ { @@ -333,7 +358,7 @@ describe('PersonHoverCard — accessibility', () => { expect(root.id).toBe('card-xyz'); }); - it('positions itself absolutely at the given top/left', async () => { + it('positions itself fixed at the given top/left', async () => { render(PersonHoverCard, { personId: 'p-aug', cardId: 'card-1', @@ -343,6 +368,6 @@ describe('PersonHoverCard — accessibility', () => { const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement; expect(root.style.top).toBe('333px'); expect(root.style.left).toBe('444px'); - expect(root.style.position).toBe('absolute'); + expect(root.style.position).toBe('fixed'); }); }); diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte index 3a9188c1..aa2643b4 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte +++ b/frontend/src/lib/components/PersonMentionEditor.svelte @@ -123,6 +123,7 @@ onMount(() => { }, suggestion: { char: '@', + allowSpaces: true, // ───────────────────────────────────────────────────────────── // EXCEPTION to frontend/CLAUDE.md "no client-side API fetch": // Tiptap's suggestion plugin lives entirely on the client and @@ -224,7 +225,7 @@ onMount(() => { role: 'textbox', 'aria-multiline': 'true', 'aria-label': m.transcription_editor_aria_label(), - ...(placeholder ? { 'data-placeholder': placeholder } : {}), + 'data-editor-inner': '', class: [ 'min-h-[120px] px-1 py-2.5', 'font-serif text-base leading-relaxed text-ink', @@ -255,6 +256,21 @@ onDestroy(() => { editor?.destroy(); }); +// Keep the data-placeholder attribute in sync with actual emptiness so the +// placeholder CSS only fires when there is no content (not just on blur). +$effect(() => { + if (!editor || !placeholder) return; + void value; // Tiptap's onUpdate always fires on content change, but $effect needs a + // reactive read to re-run — void value registers value as a dependency without using it. + const inner = editorEl?.querySelector('[data-editor-inner]') as HTMLElement | null; + if (!inner) return; + if (editor.isEmpty) { + inner.setAttribute('data-placeholder', placeholder); + } else { + inner.removeAttribute('data-placeholder'); + } +}); + // Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable // flips contenteditable on the inner DOM and stops accepting input — matches // the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1). diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts index bf4b59ea..15f04f7d 100644 --- a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts +++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts @@ -363,6 +363,38 @@ describe('PersonMentionEditor — XSS resistance', () => { }); }); +// ─── Placeholder behavior ───────────────────────────────────────────────────── + +describe('PersonMentionEditor — placeholder behavior', () => { + it('sets data-placeholder on the inner element when editor is empty', async () => { + render(PersonMentionEditorHost, { + initialValue: '', + initialMentions: [], + placeholder: 'Gib Text ein...', + onChange: () => {} + }); + await vi.waitFor(() => { + const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null; + expect(inner).not.toBeNull(); + expect(inner!.getAttribute('data-placeholder')).toBe('Gib Text ein...'); + }); + }); + + it('omits data-placeholder on the inner element when editor has content', async () => { + render(PersonMentionEditorHost, { + initialValue: 'Bestehender Text', + initialMentions: [], + placeholder: 'Gib Text ein...', + onChange: () => {} + }); + await vi.waitFor(() => { + const inner = document.querySelector('[data-editor-inner]') as HTMLElement | null; + expect(inner).not.toBeNull(); + expect(inner!.hasAttribute('data-placeholder')).toBe(false); + }); + }); +}); + // ─── Touch target (WCAG 2.2 AA) ────────────────────────────────────────────── describe('PersonMentionEditor — touch target', () => { diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte b/frontend/src/lib/components/TranscriptionReadView.svelte index 4a3ac949..c800037a 100644 --- a/frontend/src/lib/components/TranscriptionReadView.svelte +++ b/frontend/src/lib/components/TranscriptionReadView.svelte @@ -93,13 +93,30 @@ function getOrFetchHoverData(personId: string): Promise { function currentViewport() { return { viewportWidth: window.innerWidth, - viewportHeight: window.innerHeight, - scrollX: window.scrollX, - scrollY: window.scrollY + viewportHeight: window.innerHeight }; } +let closeTimer = $state | null>(null); + +function scheduleCardClose() { + // 150ms: long enough for pointer movement from mention to card, short enough + // to feel responsive. Matches the Radix/shadcn hover card delay. + closeTimer = setTimeout(() => { + activeCard = null; + closeTimer = null; + }, 150); +} + +function cancelCardClose() { + if (closeTimer !== null) { + clearTimeout(closeTimer); + closeTimer = null; + } +} + async function handleMentionEnter(event: Event) { + cancelCardClose(); const link = event.target as HTMLAnchorElement; const personId = link.dataset.personId; if (!personId) return; @@ -137,10 +154,10 @@ async function handleMentionEnter(event: Event) { } } -function handleMentionLeave(event: Event) { +function scheduleMentionLeave(event: Event) { const link = event.target as HTMLAnchorElement; link.removeAttribute('aria-describedby'); - activeCard = null; + scheduleCardClose(); } /** @@ -180,7 +197,7 @@ function attachMentionHandlers(node: HTMLElement) { } function onLeave(e: Event) { const t = e.target as HTMLElement; - if (t.matches?.(PERSON_MENTION_SELECTOR)) handleMentionLeave(e); + if (t.matches?.(PERSON_MENTION_SELECTOR)) scheduleMentionLeave(e); } function onClick(e: MouseEvent) { const t = e.target as HTMLElement; @@ -231,6 +248,8 @@ function attachMentionHandlers(node: HTMLElement) { cardId={activeCard.cardId} position={activeCard.position} state={activeCard.state} + onmouseenter={cancelCardClose} + onmouseleave={() => { activeCard = null; }} /> {/if} diff --git a/frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts b/frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts new file mode 100644 index 00000000..fbd121c4 --- /dev/null +++ b/frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import TranscriptionReadView from './TranscriptionReadView.svelte'; +import type { TranscriptionBlockData } from '$lib/types'; + +const PERSON_ID = '11111111-0000-0000-0000-000000000001'; + +const block: TranscriptionBlockData = { + id: 'b1', + annotationId: 'a1', + documentId: 'd1', + text: '@Auguste', + label: null, + sortOrder: 0, + version: 1, + source: 'MANUAL', + reviewed: false, + mentionedPersons: [{ personId: PERSON_ID, displayName: 'Auguste' }] +}; + +function mockPersonFetch() { + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string) => { + if (url.includes('/relationships')) { + return Promise.resolve({ ok: true, json: () => Promise.resolve([]) }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: PERSON_ID, + firstName: 'Auguste', + lastName: 'Raddatz', + displayName: 'Auguste Raddatz' + }) + }); + }) + ); +} + +function getMentionLink(): HTMLAnchorElement { + return document.querySelector( + `a.person-mention[data-person-id="${PERSON_ID}"]` + ) as HTMLAnchorElement; +} + +function getHoverCard(): HTMLElement | null { + return document.querySelector('[data-testid="person-hover-card"]'); +} + +/** Hover a mention and wait until the loaded card content is in the DOM. */ +async function showCard(): Promise { + getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +// ─── Mouse timer behavior ────────────────────────────────────────────────────── + +describe('TranscriptionReadView — hover card mouse timer', () => { + it('keeps the card open when mouse moves from mention to card within 150ms', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave mention — starts 150ms close timer + getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); + + // Enter card before 150ms — cancels timer + getHoverCard()!.dispatchEvent(new MouseEvent('mouseenter')); + + // Wait past the original 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); + + it('closes the card immediately when mouse leaves the card (no timer)', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave card — activeCard = null immediately, no timer + getHoverCard()!.dispatchEvent(new MouseEvent('mouseleave')); + + await vi.waitFor(() => { + expect(getHoverCard()).toBeNull(); + }); + }); + + it('cancels a pending close when mouse re-enters a mention', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + await showCard(); + + // Leave mention — starts 150ms close timer + getMentionLink().dispatchEvent(new MouseEvent('mouseleave', { bubbles: false })); + + // Re-enter same mention before 150ms — cancels timer + getMentionLink().dispatchEvent(new MouseEvent('mouseenter', { bubbles: false })); + + // Wait past the original 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); +}); + +// ─── Keyboard focus behavior ─────────────────────────────────────────────────── + +describe('TranscriptionReadView — hover card keyboard focus', () => { + it('keeps the card open when keyboard focus moves from mention into card', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + // Show card via keyboard focusin on mention + getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); + + // Focus leaves mention — starts 150ms close timer + getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + // Focus enters card — should cancel the close timer + getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + // Wait past the 150ms window + await new Promise((r) => setTimeout(r, 200)); + + expect(getHoverCard()).not.toBeNull(); + }); + + it('closes the card when keyboard focus leaves the card entirely', async () => { + mockPersonFetch(); + render(TranscriptionReadView, { blocks: [block], onParagraphClick: () => {} }); + + // Show card via keyboard focusin + getMentionLink().dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + await vi.waitFor(() => { + expect(document.querySelector('[data-testid="person-hover-card-content"]')).not.toBeNull(); + }); + + // Focus leaves mention — 150ms timer starts + getMentionLink().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); + + // Focus enters card — cancels timer + getHoverCard()!.dispatchEvent(new FocusEvent('focusin', { bubbles: true })); + + // Focus leaves card entirely (relatedTarget = null means focus left the page) + getHoverCard()!.dispatchEvent( + new FocusEvent('focusout', { bubbles: true, relatedTarget: null }) + ); + + await vi.waitFor(() => { + expect(getHoverCard()).toBeNull(); + }); + }); +}); diff --git a/frontend/src/lib/utils/hoverCardPosition.spec.ts b/frontend/src/lib/utils/hoverCardPosition.spec.ts index e953f442..7ea13d0c 100644 --- a/frontend/src/lib/utils/hoverCardPosition.spec.ts +++ b/frontend/src/lib/utils/hoverCardPosition.spec.ts @@ -19,6 +19,8 @@ const makeRect = (overrides: Partial = {}): DOMRect => { } as DOMRect; }; +const vp = { viewportWidth: 1440, viewportHeight: 900 }; + describe('computeHoverCardPosition', () => { it('exports the spec constants used by the spec/CSS layer', () => { // Pin the values the design spec calls out — if these drift, the design spec @@ -33,12 +35,7 @@ describe('computeHoverCardPosition', () => { describe('default placement (below-right)', () => { it('positions the card below the rect with a small gap', () => { const rect = makeRect({ top: 100, bottom: 120, left: 200 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(120 + CARD_GAP_PX); expect(result.left).toBe(200); }); @@ -48,24 +45,14 @@ describe('computeHoverCardPosition', () => { it('flips up when the card would overflow the bottom edge', () => { // Mention sits 50px above the viewport bottom — card is 180px tall, can't fit below const rect = makeRect({ top: 800, bottom: 850 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(800 - CARD_HEIGHT_PX - CARD_GAP_PX); }); it('flips up when the mention sits in the bottom 30% of the viewport (BOTTOM_BAND_RATIO)', () => { // rect.top is at 80% of viewport — fits below numerically, but poor UX const rect = makeRect({ top: 720, bottom: 740 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX); }); }); @@ -74,12 +61,7 @@ describe('computeHoverCardPosition', () => { it('flips left when the rect is within RIGHT_FLIP_THRESHOLD_PX of the right edge', () => { // vw - rect.left = 1440 - 1200 = 240 < 300, so flip const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, { viewportWidth: 1440, viewportHeight: 900 }); // left = right - CARD_WIDTH = 1300 - 320 = 980 expect(result.left).toBe(980); }); @@ -87,12 +69,7 @@ describe('computeHoverCardPosition', () => { it('does not flip left when the rect has plenty of right-side room', () => { // vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip const rect = makeRect({ left: 200, right: 300 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.left).toBe(200); }); }); @@ -103,50 +80,32 @@ describe('computeHoverCardPosition', () => { // Without clamping the card would be at left=0 but extend to 320 — fine. // At viewport=400px with rect.left=200, flip puts left=300-320=-20, clamped to 0. const rect = makeRect({ left: 200, right: 300, top: 100, bottom: 120 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 400, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, { viewportWidth: 400, viewportHeight: 900 }); expect(result.left).toBeGreaterThanOrEqual(0); expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400); }); it('never returns a negative top or left', () => { const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 0 - }); + const result = computeHoverCardPosition(rect, vp); expect(result.top).toBeGreaterThanOrEqual(0); expect(result.left).toBeGreaterThanOrEqual(0); }); }); - describe('scroll offset', () => { - it('adds window.scrollY to the absolute-positioned top', () => { + describe('position: fixed (viewport-relative coordinates)', () => { + it('returns viewport-relative top — does not add scroll offset', () => { + // getBoundingClientRect values are already viewport-relative; with position:fixed + // we use them directly without adding scrollY. const rect = makeRect({ top: 100, bottom: 120 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 0, - scrollY: 500 - }); - expect(result.top).toBe(120 + CARD_GAP_PX + 500); + const result = computeHoverCardPosition(rect, vp); + expect(result.top).toBe(120 + CARD_GAP_PX); }); - it('adds window.scrollX to the absolute-positioned left', () => { + it('returns viewport-relative left — does not add scroll offset', () => { const rect = makeRect({ top: 100, bottom: 120, left: 200, right: 300 }); - const result = computeHoverCardPosition(rect, { - viewportWidth: 1440, - viewportHeight: 900, - scrollX: 50, - scrollY: 0 - }); - expect(result.left).toBe(200 + 50); + const result = computeHoverCardPosition(rect, vp); + expect(result.left).toBe(200); }); }); }); diff --git a/frontend/src/lib/utils/hoverCardPosition.ts b/frontend/src/lib/utils/hoverCardPosition.ts index 4d16a899..1223ab7d 100644 --- a/frontend/src/lib/utils/hoverCardPosition.ts +++ b/frontend/src/lib/utils/hoverCardPosition.ts @@ -32,8 +32,6 @@ 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 }; @@ -63,7 +61,7 @@ export function computeHoverCardPosition(rect: DOMRect, vp: Viewport): CardPosit 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) + top: Math.max(0, top), + left: Math.max(0, left) }; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 38a3d537..1405f48f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,7 +7,7 @@ import { sveltekit } from '@sveltejs/kit/vite'; export default defineConfig({ optimizeDeps: { - include: ['pdfjs-dist'] + include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention'] }, server: { host: '0.0.0.0', // Erlaubt Zugriff von außen