refactor(TranscriptionBlock): migrate quote selection to Tiptap selectionUpdate (AC-7)

Replaces captureTextarea + handleTextareaMouseUp (which read selection
bounds off a real <textarea>) with an onSelectionChange callback prop
on PersonMentionEditor, wired to Tiptap's selectionUpdate event. The
editor emits the selected text directly so the parent no longer needs
DOM access.

Tests are updated to drive the contenteditable via the Selection API
instead of the now-deleted textarea.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-29 15:53:54 +02:00
parent d87ad36278
commit 7a25feb04e
2 changed files with 27 additions and 48 deletions

View File

@@ -79,17 +79,6 @@ let leftBorderClass = $derived(
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
);
// Single source of truth for the editor's textarea — stored on attach so
// we can read selection bounds for quote selection without re-querying the DOM.
let textareaEl: HTMLTextAreaElement | null = null;
function captureTextarea(node: HTMLTextAreaElement) {
textareaEl = node;
return () => {
textareaEl = null;
};
}
function emitChange() {
onTextChange(localText, localMentions);
}
@@ -101,17 +90,6 @@ async function handleDelete() {
});
if (confirmed) onDeleteClick();
}
function handleTextareaMouseUp() {
if (!textareaEl) return;
const start = textareaEl.selectionStart;
const end = textareaEl.selectionEnd;
if (start !== end) {
selectedQuote = localText.substring(start, end);
} else {
selectedQuote = null;
}
}
</script>
<div
@@ -176,24 +154,22 @@ function handleTextareaMouseUp() {
{/if}
</div>
<!-- Textarea (now powered by PersonMentionEditor for @-mention typeahead) -->
<div onmouseup={handleTextareaMouseUp} role="presentation">
<PersonMentionEditor
bind:value={() => localText,
(v) => {
localText = v;
emitChange();
}}
bind:mentionedPersons={() => localMentions,
(next) => {
localMentions = next;
emitChange();
}}
placeholder={m.transcription_block_placeholder()}
onfocus={onFocus}
captureTextarea={captureTextarea}
/>
</div>
<!-- Editor powered by PersonMentionEditor (Tiptap) for @-mention typeahead -->
<PersonMentionEditor
bind:value={() => localText,
(v) => {
localText = v;
emitChange();
}}
bind:mentionedPersons={() => localMentions,
(next) => {
localMentions = next;
emitChange();
}}
placeholder={m.transcription_block_placeholder()}
onfocus={onFocus}
onSelectionChange={(text) => (selectedQuote = text)}
/>
{#if selectedQuote}
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>

View File

@@ -41,8 +41,7 @@ describe('TranscriptionBlock — rendering', () => {
it('renders text in textarea', async () => {
renderBlock();
const textarea = page.getByRole('textbox');
await expect.element(textarea).toHaveValue('Liebe Mutter,');
await expect.element(page.getByText('Liebe Mutter,')).toBeInTheDocument();
});
it('renders optional label when provided', async () => {
@@ -226,14 +225,18 @@ describe('TranscriptionBlock — delete confirmation', () => {
// ─── Quote selection ─────────────────────────────────────────────────────────
describe('TranscriptionBlock — quote selection', () => {
it('shows quote hint after text is selected in textarea', async () => {
it('shows quote hint after text is selected in the editor', async () => {
renderBlock({ text: 'Breslau, den 12. August' });
await page.getByRole('textbox').click();
// Select text and fire mouseup via native DOM — locator.selectText/dispatchEvent not available
const el = document.querySelector('textarea') as HTMLTextAreaElement;
el.focus();
el.setSelectionRange(0, el.value.length);
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
// Select all text in the contenteditable via the native Selection API.
// Tiptap fires selectionUpdate which the block forwards as onSelectionChange.
const editorEl = document.querySelector('[role="textbox"]') as HTMLElement;
const range = document.createRange();
range.selectNodeContents(editorEl);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
});
});