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

@@ -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() {
</script>
<div
class="relative overflow-visible rounded border border-line {leftBorderClass}"
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
data-block-id={blockId}
>
<!-- Turquoise numbered badge — overlaps top-left of card -->
@@ -110,7 +118,44 @@ function handleTextareaMouseUp() {
>
{blockNumber}
</span>
<div class="p-4 pl-6">
<!-- Drag handle (desktop) / Arrow buttons (mobile) -->
<div class="flex shrink-0 flex-col items-center justify-center border-r border-line px-1">
<!-- Mobile: arrow buttons -->
<button
type="button"
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
disabled={isFirst}
aria-label="Nach oben"
onclick={() => onMoveUp?.()}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<!-- Desktop: grip handle (drag target) -->
<div
class="hidden cursor-grab text-ink-3 transition-colors select-none hover:text-ink active:cursor-grabbing md:block"
data-drag-handle
aria-label="Ziehen zum Sortieren"
>
</div>
<!-- Mobile: arrow down -->
<button
type="button"
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
disabled={isLast}
aria-label="Nach unten"
onclick={() => onMoveDown?.()}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div class="min-w-0 flex-1 p-4 pl-3">
<!-- Header -->
<div class="mb-2 flex items-center gap-2">
{#if label}

View File

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

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}