feat(transcription): add drag-and-drop + arrow button reordering
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-05 23:00:52 +02:00
parent 4a88b3ba82
commit 7d2d615e0c
3 changed files with 176 additions and 4 deletions

View File

@@ -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}
<div class="flex flex-col gap-3">
{#each sortedBlocks as block, i (block.id)}
<div onblur={handleBlur}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
onblur={handleBlur}
draggable={true}
ondragstart={(e) => handleDragStart(e, block.id)}
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, block.id)}
ondragend={handleDragEnd}
class={draggedBlockId === block.id ? 'opacity-40' : ''}
>
<TranscriptionBlock
blockId={block.id}
documentId={documentId}
@@ -170,6 +255,10 @@ $effect(() => {
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}
/>
</div>
{/each}