fix: hover card maiden name false positive, editor placeholder on non-empty content, mention persistence #375
@@ -35,7 +35,9 @@ public class TranscriptionBlock {
|
|||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String 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(
|
@CollectionTable(
|
||||||
name = "transcription_block_mentioned_persons",
|
name = "transcription_block_mentioned_persons",
|
||||||
joinColumns = @JoinColumn(name = "block_id"))
|
joinColumns = @JoinColumn(name = "block_id"))
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ public class TranscriptionService {
|
|||||||
if (dto.getLabel() != null) {
|
if (dto.getLabel() != null) {
|
||||||
block.setLabel(dto.getLabel());
|
block.setLabel(dto.getLabel());
|
||||||
}
|
}
|
||||||
|
block.getMentionedPersons().clear();
|
||||||
|
block.getMentionedPersons().addAll(dto.getMentionedPersons());
|
||||||
block.setUpdatedBy(userId);
|
block.setUpdatedBy(userId);
|
||||||
|
|
||||||
TranscriptionBlock saved = blockRepository.save(block);
|
TranscriptionBlock saved = blockRepository.save(block);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.model.BlockSource;
|
|||||||
import org.raddatz.familienarchiv.model.Document;
|
import org.raddatz.familienarchiv.model.Document;
|
||||||
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
import org.raddatz.familienarchiv.model.DocumentAnnotation;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
|
import org.raddatz.familienarchiv.model.PersonMention;
|
||||||
import org.raddatz.familienarchiv.model.ScriptType;
|
import org.raddatz.familienarchiv.model.ScriptType;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
import org.raddatz.familienarchiv.model.TranscriptionBlockVersion;
|
||||||
@@ -215,6 +216,60 @@ class TranscriptionServiceTest {
|
|||||||
assertThat(result.getSource()).isEqualTo(BlockSource.MANUAL);
|
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
|
@Test
|
||||||
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
void updateBlock_triggersTraining_whenKurrentSenderPresent() {
|
||||||
UUID docId = UUID.randomUUID();
|
UUID docId = UUID.randomUUID();
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function selectItem(item: Person) {
|
|||||||
unauthenticated users.
|
unauthenticated users.
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class="fixed z-20 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
class="fixed z-50 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={m.person_mention_btn_label()}
|
aria-label={m.person_mention_btn_label()}
|
||||||
style:top={position.top}
|
style:top={position.top}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||||
|
import { chipLabel, otherName } from '$lib/relationshipLabels';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
import type { LoadState } from '$lib/types/personHoverCard';
|
import type { LoadState } from '$lib/types/personHoverCard';
|
||||||
|
|
||||||
@@ -11,9 +12,11 @@ type Props = {
|
|||||||
cardId: string;
|
cardId: string;
|
||||||
position: { top: number; left: number };
|
position: { top: number; left: number };
|
||||||
state: LoadState;
|
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<RelationshipDTO['relationType']> = new Set([
|
const FAMILY_REL_TYPES: ReadonlySet<RelationshipDTO['relationType']> = new Set([
|
||||||
'PARENT_OF',
|
'PARENT_OF',
|
||||||
@@ -69,6 +72,13 @@ const ariaLabel = $derived(
|
|||||||
// aria-busy="true" while loading so SR clients know the region's contents
|
// 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.
|
// will change. Cleared on loaded/error so the new content is announced.
|
||||||
const ariaBusy = $derived(state.status === 'loading');
|
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
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -79,9 +89,17 @@ const ariaBusy = $derived(state.status === 'loading');
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-busy={ariaBusy ? 'true' : undefined}
|
aria-busy={ariaBusy ? 'true' : undefined}
|
||||||
style:position="absolute"
|
style:position="fixed"
|
||||||
style:top={`${position.top}px`}
|
style:top={`${position.top}px`}
|
||||||
style:left={`${position.left}px`}
|
style:left={`${position.left}px`}
|
||||||
|
onmouseenter={onmouseenter}
|
||||||
|
onmouseleave={onmouseleave}
|
||||||
|
onfocusin={onmouseenter}
|
||||||
|
onfocusout={(e) => {
|
||||||
|
if (!(e.currentTarget as HTMLElement).contains(e.relatedTarget as Node | null)) {
|
||||||
|
onmouseleave?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#if state.status === 'loading'}
|
{#if state.status === 'loading'}
|
||||||
<div
|
<div
|
||||||
@@ -108,7 +126,7 @@ const ariaBusy = $derived(state.status === 'loading');
|
|||||||
{#if dateRange}
|
{#if dateRange}
|
||||||
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if state.person.alias}
|
{#if showMaidenName}
|
||||||
<div class="maiden" data-testid="person-hover-card-maiden">
|
<div class="maiden" data-testid="person-hover-card-maiden">
|
||||||
{m.person_born_name_prefix()}
|
{m.person_born_name_prefix()}
|
||||||
{state.person.alias}
|
{state.person.alias}
|
||||||
@@ -118,7 +136,10 @@ const ariaBusy = $derived(state.status === 'loading');
|
|||||||
{#if familyChips.length > 0}
|
{#if familyChips.length > 0}
|
||||||
<div class="chips" data-testid="person-hover-card-chips">
|
<div class="chips" data-testid="person-hover-card-chips">
|
||||||
{#each familyChips as chip (chip.id)}
|
{#each familyChips as chip (chip.id)}
|
||||||
<span class="chip">{chip.relatedPersonDisplayName}</span>
|
<span class="chip">
|
||||||
|
<span class="chip-type">{chipLabel(chip, personId)}:</span>
|
||||||
|
{otherName(chip, personId)}
|
||||||
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -230,6 +251,9 @@ const ariaBusy = $derived(state.status === 'loading');
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background-color: var(--c-accent-bg);
|
background-color: var(--c-accent-bg);
|
||||||
color: var(--c-ink);
|
color: var(--c-ink);
|
||||||
@@ -237,6 +261,12 @@ const ariaBusy = $derived(state.status === 'loading');
|
|||||||
padding: 2px 10px;
|
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 {
|
.notes {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--c-ink-2);
|
color: var(--c-ink-2);
|
||||||
|
|||||||
@@ -165,6 +165,31 @@ describe('PersonHoverCard — loaded state', () => {
|
|||||||
await expect.element(friendChip).not.toBeInTheDocument();
|
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 () => {
|
it('omits the chips section entirely when no family relationships', async () => {
|
||||||
const onlyFriend: RelationshipDTO[] = [
|
const onlyFriend: RelationshipDTO[] = [
|
||||||
{
|
{
|
||||||
@@ -333,7 +358,7 @@ describe('PersonHoverCard — accessibility', () => {
|
|||||||
expect(root.id).toBe('card-xyz');
|
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, {
|
render(PersonHoverCard, {
|
||||||
personId: 'p-aug',
|
personId: 'p-aug',
|
||||||
cardId: 'card-1',
|
cardId: 'card-1',
|
||||||
@@ -343,6 +368,6 @@ describe('PersonHoverCard — accessibility', () => {
|
|||||||
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
const root = document.querySelector('[data-testid="person-hover-card"]') as HTMLElement;
|
||||||
expect(root.style.top).toBe('333px');
|
expect(root.style.top).toBe('333px');
|
||||||
expect(root.style.left).toBe('444px');
|
expect(root.style.left).toBe('444px');
|
||||||
expect(root.style.position).toBe('absolute');
|
expect(root.style.position).toBe('fixed');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ onMount(() => {
|
|||||||
},
|
},
|
||||||
suggestion: {
|
suggestion: {
|
||||||
char: '@',
|
char: '@',
|
||||||
|
allowSpaces: true,
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
|
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
|
||||||
// Tiptap's suggestion plugin lives entirely on the client and
|
// Tiptap's suggestion plugin lives entirely on the client and
|
||||||
@@ -224,7 +225,7 @@ onMount(() => {
|
|||||||
role: 'textbox',
|
role: 'textbox',
|
||||||
'aria-multiline': 'true',
|
'aria-multiline': 'true',
|
||||||
'aria-label': m.transcription_editor_aria_label(),
|
'aria-label': m.transcription_editor_aria_label(),
|
||||||
...(placeholder ? { 'data-placeholder': placeholder } : {}),
|
'data-editor-inner': '',
|
||||||
class: [
|
class: [
|
||||||
'min-h-[120px] px-1 py-2.5',
|
'min-h-[120px] px-1 py-2.5',
|
||||||
'font-serif text-base leading-relaxed text-ink',
|
'font-serif text-base leading-relaxed text-ink',
|
||||||
@@ -255,6 +256,21 @@ onDestroy(() => {
|
|||||||
editor?.destroy();
|
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
|
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
||||||
// flips contenteditable on the inner DOM and stops accepting input — matches
|
// flips contenteditable on the inner DOM and stops accepting input — matches
|
||||||
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
||||||
|
|||||||
@@ -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) ──────────────────────────────────────────────
|
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — touch target', () => {
|
describe('PersonMentionEditor — touch target', () => {
|
||||||
|
|||||||
@@ -93,13 +93,30 @@ function getOrFetchHoverData(personId: string): Promise<HoverData | null> {
|
|||||||
function currentViewport() {
|
function currentViewport() {
|
||||||
return {
|
return {
|
||||||
viewportWidth: window.innerWidth,
|
viewportWidth: window.innerWidth,
|
||||||
viewportHeight: window.innerHeight,
|
viewportHeight: window.innerHeight
|
||||||
scrollX: window.scrollX,
|
|
||||||
scrollY: window.scrollY
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let closeTimer = $state<ReturnType<typeof setTimeout> | 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) {
|
async function handleMentionEnter(event: Event) {
|
||||||
|
cancelCardClose();
|
||||||
const link = event.target as HTMLAnchorElement;
|
const link = event.target as HTMLAnchorElement;
|
||||||
const personId = link.dataset.personId;
|
const personId = link.dataset.personId;
|
||||||
if (!personId) return;
|
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;
|
const link = event.target as HTMLAnchorElement;
|
||||||
link.removeAttribute('aria-describedby');
|
link.removeAttribute('aria-describedby');
|
||||||
activeCard = null;
|
scheduleCardClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -180,7 +197,7 @@ function attachMentionHandlers(node: HTMLElement) {
|
|||||||
}
|
}
|
||||||
function onLeave(e: Event) {
|
function onLeave(e: Event) {
|
||||||
const t = e.target as HTMLElement;
|
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) {
|
function onClick(e: MouseEvent) {
|
||||||
const t = e.target as HTMLElement;
|
const t = e.target as HTMLElement;
|
||||||
@@ -231,6 +248,8 @@ function attachMentionHandlers(node: HTMLElement) {
|
|||||||
cardId={activeCard.cardId}
|
cardId={activeCard.cardId}
|
||||||
position={activeCard.position}
|
position={activeCard.position}
|
||||||
state={activeCard.state}
|
state={activeCard.state}
|
||||||
|
onmouseenter={cancelCardClose}
|
||||||
|
onmouseleave={() => { activeCard = null; }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
170
frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts
Normal file
170
frontend/src/lib/components/TranscriptionReadView.svelte.spec.ts
Normal file
@@ -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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -19,6 +19,8 @@ const makeRect = (overrides: Partial<DOMRect> = {}): DOMRect => {
|
|||||||
} as DOMRect;
|
} as DOMRect;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const vp = { viewportWidth: 1440, viewportHeight: 900 };
|
||||||
|
|
||||||
describe('computeHoverCardPosition', () => {
|
describe('computeHoverCardPosition', () => {
|
||||||
it('exports the spec constants used by the spec/CSS layer', () => {
|
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
|
// 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)', () => {
|
describe('default placement (below-right)', () => {
|
||||||
it('positions the card below the rect with a small gap', () => {
|
it('positions the card below the rect with a small gap', () => {
|
||||||
const rect = makeRect({ top: 100, bottom: 120, left: 200 });
|
const rect = makeRect({ top: 100, bottom: 120, left: 200 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, vp);
|
||||||
viewportWidth: 1440,
|
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0
|
|
||||||
});
|
|
||||||
expect(result.top).toBe(120 + CARD_GAP_PX);
|
expect(result.top).toBe(120 + CARD_GAP_PX);
|
||||||
expect(result.left).toBe(200);
|
expect(result.left).toBe(200);
|
||||||
});
|
});
|
||||||
@@ -48,24 +45,14 @@ describe('computeHoverCardPosition', () => {
|
|||||||
it('flips up when the card would overflow the bottom edge', () => {
|
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
|
// Mention sits 50px above the viewport bottom — card is 180px tall, can't fit below
|
||||||
const rect = makeRect({ top: 800, bottom: 850 });
|
const rect = makeRect({ top: 800, bottom: 850 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, vp);
|
||||||
viewportWidth: 1440,
|
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0
|
|
||||||
});
|
|
||||||
expect(result.top).toBe(800 - CARD_HEIGHT_PX - CARD_GAP_PX);
|
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)', () => {
|
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
|
// rect.top is at 80% of viewport — fits below numerically, but poor UX
|
||||||
const rect = makeRect({ top: 720, bottom: 740 });
|
const rect = makeRect({ top: 720, bottom: 740 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, vp);
|
||||||
viewportWidth: 1440,
|
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0
|
|
||||||
});
|
|
||||||
expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX);
|
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', () => {
|
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
|
// vw - rect.left = 1440 - 1200 = 240 < 300, so flip
|
||||||
const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 });
|
const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, { viewportWidth: 1440, viewportHeight: 900 });
|
||||||
viewportWidth: 1440,
|
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0
|
|
||||||
});
|
|
||||||
// left = right - CARD_WIDTH = 1300 - 320 = 980
|
// left = right - CARD_WIDTH = 1300 - 320 = 980
|
||||||
expect(result.left).toBe(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', () => {
|
it('does not flip left when the rect has plenty of right-side room', () => {
|
||||||
// vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip
|
// vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip
|
||||||
const rect = makeRect({ left: 200, right: 300 });
|
const rect = makeRect({ left: 200, right: 300 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, vp);
|
||||||
viewportWidth: 1440,
|
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0
|
|
||||||
});
|
|
||||||
expect(result.left).toBe(200);
|
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.
|
// 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.
|
// 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 rect = makeRect({ left: 200, right: 300, top: 100, bottom: 120 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, { viewportWidth: 400, viewportHeight: 900 });
|
||||||
viewportWidth: 400,
|
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0
|
|
||||||
});
|
|
||||||
expect(result.left).toBeGreaterThanOrEqual(0);
|
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400);
|
expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never returns a negative top or left', () => {
|
it('never returns a negative top or left', () => {
|
||||||
const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 });
|
const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, vp);
|
||||||
viewportWidth: 1440,
|
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 0
|
|
||||||
});
|
|
||||||
expect(result.top).toBeGreaterThanOrEqual(0);
|
expect(result.top).toBeGreaterThanOrEqual(0);
|
||||||
expect(result.left).toBeGreaterThanOrEqual(0);
|
expect(result.left).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('scroll offset', () => {
|
describe('position: fixed (viewport-relative coordinates)', () => {
|
||||||
it('adds window.scrollY to the absolute-positioned top', () => {
|
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 rect = makeRect({ top: 100, bottom: 120 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, vp);
|
||||||
viewportWidth: 1440,
|
expect(result.top).toBe(120 + CARD_GAP_PX);
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 0,
|
|
||||||
scrollY: 500
|
|
||||||
});
|
|
||||||
expect(result.top).toBe(120 + CARD_GAP_PX + 500);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 rect = makeRect({ top: 100, bottom: 120, left: 200, right: 300 });
|
||||||
const result = computeHoverCardPosition(rect, {
|
const result = computeHoverCardPosition(rect, vp);
|
||||||
viewportWidth: 1440,
|
expect(result.left).toBe(200);
|
||||||
viewportHeight: 900,
|
|
||||||
scrollX: 50,
|
|
||||||
scrollY: 0
|
|
||||||
});
|
|
||||||
expect(result.left).toBe(200 + 50);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export const RIGHT_FLIP_THRESHOLD_PX = 300;
|
|||||||
export type Viewport = {
|
export type Viewport = {
|
||||||
viewportWidth: number;
|
viewportWidth: number;
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
scrollX: number;
|
|
||||||
scrollY: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CardPosition = { top: number; left: 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);
|
left = Math.min(left, vp.viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: Math.max(0, top + vp.scrollY),
|
top: Math.max(0, top),
|
||||||
left: Math.max(0, left + vp.scrollX)
|
left: Math.max(0, left)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ['pdfjs-dist']
|
include: ['pdfjs-dist', '@tiptap/core', '@tiptap/starter-kit', '@tiptap/extension-mention']
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
host: '0.0.0.0', // Erlaubt Zugriff von außen
|
||||||
|
|||||||
Reference in New Issue
Block a user