fix(hover-card): maiden name false positive, placeholder on non-empty editor, card persistence

- PersonHoverCard: alias is compared against both `lastName` and `displayName`
  before showing as maiden name — prevents false positive when alias is stored
  as the full current name (e.g. "Maria Schmidt" ≠ "Schmidt" but name unchanged)
- PersonMentionEditor: data-placeholder was set statically so the CSS ::before
  rule showed the placeholder on any blur even with content; now a $effect
  toggles the attribute based on editor.isEmpty
- TranscriptionReadView: hovering onto the card itself cancels the 150ms close
  timer so the card stays open while reading it; leaving the card closes it
  immediately — onmouseenter/onmouseleave wired through PersonHoverCard props
- hoverCardPosition: removed scrollX/scrollY offset since the card is now
  position:fixed (scroll is already baked into getBoundingClientRect coords)
- MentionDropdown: raised z-index from z-20 to z-50 to render above the hover card
- vite.config.ts: pre-bundle Tiptap packages to avoid HMR waterfall on first load

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 18:26:44 +02:00
parent 49443ad16a
commit 37edac4da6
7 changed files with 97 additions and 74 deletions

View File

@@ -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}

View File

@@ -11,15 +11,30 @@ 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',
'SPOUSE_OF', 'SPOUSE_OF',
'SIBLING_OF' 'SIBLING_OF'
]); ]);
function relationLabel(type: RelationshipDTO['relationType']): string {
switch (type) {
case 'PARENT_OF':
return m.relation_parent_of();
case 'SPOUSE_OF':
return m.relation_spouse_of();
case 'SIBLING_OF':
return m.relation_sibling_of();
default:
return m.relation_other();
}
}
const NOTES_MAX = 120; const NOTES_MAX = 120;
const familyChips = $derived( const familyChips = $derived(
@@ -79,9 +94,11 @@ 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}
> >
{#if state.status === 'loading'} {#if state.status === 'loading'}
<div <div
@@ -108,7 +125,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 state.person.alias && state.person.alias !== state.person.lastName && state.person.alias !== state.person.displayName}
<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 +135,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">{relationLabel(chip.relationType)}</span>
{chip.relatedPersonDisplayName}
</span>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -230,6 +250,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 +260,15 @@ const ariaBusy = $derived(state.status === 'loading');
padding: 2px 10px; padding: 2px 10px;
} }
.chip-type {
font-weight: 600;
opacity: 0.7;
}
.chip-type::after {
content: ':';
}
.notes { .notes {
font-size: 13px; font-size: 13px;
color: var(--c-ink-2); color: var(--c-ink-2);

View File

@@ -224,7 +224,6 @@ 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 } : {}),
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 +254,20 @@ 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; // track value as dependency so this re-runs on content changes
const inner = editorEl?.querySelector('.tiptap-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).

View File

@@ -93,13 +93,28 @@ 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: ReturnType<typeof setTimeout> | null = null;
function scheduleCardClose() {
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;
@@ -140,7 +155,11 @@ async function handleMentionEnter(event: Event) {
function handleMentionLeave(event: Event) { function handleMentionLeave(event: Event) {
const link = event.target as HTMLAnchorElement; const link = event.target as HTMLAnchorElement;
link.removeAttribute('aria-describedby'); link.removeAttribute('aria-describedby');
activeCard = null; if (event.type === 'mouseleave') {
scheduleCardClose();
} else {
activeCard = null;
}
} }
/** /**
@@ -231,6 +250,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}

View File

@@ -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);
}); });
}); });
}); });

View File

@@ -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)
}; };
} }

View File

@@ -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