From 7d2d615e0c03319e62f38302956e6d00e8ba57c1 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 23:00:52 +0200 Subject: [PATCH] feat(transcription): add drag-and-drop + arrow button reordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TranscriptionBlock: - Desktop: grip handle (⠿) on left side, serves as drag handle - Mobile (<768px): ▲/▼ arrow buttons (44px tap targets) replace grip - isFirst/isLast disable boundary arrows - onMoveUp/onMoveDown callbacks for arrow button clicks TranscriptionEditView: - HTML5 drag-and-drop on block wrappers (only initiates from grip handle) - Dragged block shows 40% opacity - On drop: reorder array and call PUT /reorder endpoint - Arrow handlers: swap adjacent blocks and call reorder endpoint 5 new tests: - drag handle element present - move-up disabled when isFirst - move-down disabled when isLast - onMoveUp fires on click - onMoveDown fires on click Co-Authored-By: Claude Sonnet 4.6 --- .../lib/components/TranscriptionBlock.svelte | 51 ++++++++++- .../TranscriptionBlock.svelte.spec.ts | 38 ++++++++ .../components/TranscriptionEditView.svelte | 91 ++++++++++++++++++- 3 files changed, 176 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte index 73caa2d1..3da162b5 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte +++ b/frontend/src/lib/components/TranscriptionBlock.svelte @@ -18,6 +18,10 @@ type Props = { onFocus: () => void; onDeleteClick: () => void; onRetry: () => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + isFirst?: boolean; + isLast?: boolean; }; let { @@ -33,7 +37,11 @@ let { onTextChange, onFocus, onDeleteClick, - onRetry + onRetry, + onMoveUp, + onMoveDown, + isFirst = false, + isLast = false }: Props = $props(); let localText = $state(text); @@ -101,7 +109,7 @@ function handleTextareaMouseUp() {
@@ -110,7 +118,44 @@ function handleTextareaMouseUp() { > {blockNumber} -
+ + +
+ + + + + + +
+ +
{#if label} diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts index 5e2af323..1f305694 100644 --- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts +++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts @@ -121,3 +121,41 @@ describe('TranscriptionBlock — interactions', () => { await expect.element(btn).toBeInTheDocument(); }); }); + +// ─── Reorder controls ──────────────────────────────────────────────────────── + +describe('TranscriptionBlock — reorder controls', () => { + it('shows a drag handle element', async () => { + renderBlock(); + const handle = document.querySelector('[data-drag-handle]'); + expect(handle).not.toBeNull(); + }); + + it('disables move-up button when isFirst', async () => { + renderBlock({ isFirst: true }); + const btn = page.getByRole('button', { name: 'Nach oben' }); + await expect.element(btn).toBeDisabled(); + }); + + it('disables move-down button when isLast', async () => { + renderBlock({ isLast: true }); + const btn = page.getByRole('button', { name: 'Nach unten' }); + await expect.element(btn).toBeDisabled(); + }); + + it('calls onMoveUp when up arrow clicked', async () => { + const onMoveUp = vi.fn(); + renderBlock({ onMoveUp, isFirst: false }); + const btn = page.getByRole('button', { name: 'Nach oben' }); + await btn.click(); + expect(onMoveUp).toHaveBeenCalled(); + }); + + it('calls onMoveDown when down arrow clicked', async () => { + const onMoveDown = vi.fn(); + renderBlock({ onMoveDown, isLast: false }); + const btn = page.getByRole('button', { name: 'Nach unten' }); + await btn.click(); + expect(onMoveDown).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index 74d13c94..edad6189 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -125,6 +125,82 @@ function handleDelete(blockId: string) { onDeleteBlock(blockId); } +async function reorder(newOrder: string[]) { + try { + const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ blockIds: newOrder }) + }); + if (!res.ok) return; + const updated = await res.json(); + // Update blocks with new sort orders from server + for (const b of updated) { + const existing = blocks.find((x) => x.id === b.id); + if (existing) existing.sortOrder = b.sortOrder; + } + } catch { + // ignore + } +} + +function handleMoveUp(blockId: string) { + const sorted = [...sortedBlocks]; + const idx = sorted.findIndex((b) => b.id === blockId); + if (idx <= 0) return; + [sorted[idx - 1], sorted[idx]] = [sorted[idx], sorted[idx - 1]]; + reorder(sorted.map((b) => b.id)); +} + +function handleMoveDown(blockId: string) { + const sorted = [...sortedBlocks]; + const idx = sorted.findIndex((b) => b.id === blockId); + if (idx < 0 || idx >= sorted.length - 1) return; + [sorted[idx], sorted[idx + 1]] = [sorted[idx + 1], sorted[idx]]; + reorder(sorted.map((b) => b.id)); +} + +// ── Drag and drop ──────────────────────────────────────────────────────── + +let draggedBlockId: string | null = $state(null); + +function handleDragStart(e: DragEvent, blockId: string) { + if (!(e.target as HTMLElement).closest('[data-drag-handle]')) { + e.preventDefault(); + return; + } + draggedBlockId = blockId; + e.dataTransfer!.effectAllowed = 'move'; +} + +function handleDragOver(e: DragEvent) { + e.preventDefault(); + e.dataTransfer!.dropEffect = 'move'; +} + +function handleDrop(e: DragEvent, targetBlockId: string) { + e.preventDefault(); + if (!draggedBlockId || draggedBlockId === targetBlockId) { + draggedBlockId = null; + return; + } + const sorted = [...sortedBlocks]; + const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId); + const toIdx = sorted.findIndex((b) => b.id === targetBlockId); + if (fromIdx < 0 || toIdx < 0) { + draggedBlockId = null; + return; + } + const [moved] = sorted.splice(fromIdx, 1); + sorted.splice(toIdx, 0, moved); + draggedBlockId = null; + reorder(sorted.map((b) => b.id)); +} + +function handleDragEnd() { + draggedBlockId = null; +} + function flushViaBeacon() { for (const [blockId, text] of pendingTexts) { clearDebounce(blockId); @@ -155,7 +231,16 @@ $effect(() => { {#if hasBlocks}
{#each sortedBlocks as block, i (block.id)} -
+ +
handleDragStart(e, block.id)} + ondragover={handleDragOver} + ondrop={(e) => handleDrop(e, block.id)} + ondragend={handleDragEnd} + class={draggedBlockId === block.id ? 'opacity-40' : ''} + > { onFocus={() => handleFocus(block.id)} onDeleteClick={() => handleDelete(block.id)} onRetry={() => handleRetry(block.id)} + onMoveUp={() => handleMoveUp(block.id)} + onMoveDown={() => handleMoveDown(block.id)} + isFirst={i === 0} + isLast={i === sortedBlocks.length - 1} />
{/each}