fix(transcription): replace broken HTML5 drag with pointer-based drag
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

HTML5 drag-and-drop didn't work — the grip handle couldn't initiate
drag properly. Replace with pointer event-based drag:

- Grip handle pointerdown starts drag, captures pointer
- Pointermove tracks offset, shows floaty style (shadow, scale, ring)
- Turquoise drop indicator line appears between blocks at cursor position
- Pointerup finalizes: reorders array and calls PUT /reorder endpoint

Visual feedback:
- Dragged block: shadow-xl, ring-2 ring-turquoise/40, scale 1.02, opacity 0.9
- Drop indicator: turquoise h-1 rounded bar between blocks

6 new TranscriptionEditView tests:
- renders blocks in sort order
- shows next-block CTA
- shows empty state
- move-up disabled on first block
- move-down disabled on last block
- drag handle present on each block

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-05 23:07:42 +02:00
parent 7d2d615e0c
commit c22f2e41b1
2 changed files with 142 additions and 35 deletions

View File

@@ -160,45 +160,63 @@ function handleMoveDown(blockId: string) {
reorder(sorted.map((b) => b.id)); reorder(sorted.map((b) => b.id));
} }
// ── Drag and drop ──────────────────────────────────────────────────────── // ── Pointer-based drag and drop ──────────────────────────────────────────
let draggedBlockId: string | null = $state(null); let draggedBlockId: string | null = $state(null);
let dropTargetIdx: number | null = $state(null);
let dragOffsetY: number = $state(0);
let dragStartY = 0;
let capturedEl: HTMLElement | null = null;
let listEl: HTMLElement | null = null;
function handleDragStart(e: DragEvent, blockId: string) { function handleGripDown(e: PointerEvent, blockId: string) {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) { if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault(); e.preventDefault();
return;
}
draggedBlockId = blockId; draggedBlockId = blockId;
e.dataTransfer!.effectAllowed = 'move'; dragStartY = e.clientY;
dragOffsetY = 0;
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
capturedEl?.setPointerCapture(e.pointerId);
} }
function handleDragOver(e: DragEvent) { function handlePointerMove(e: PointerEvent) {
e.preventDefault(); if (!draggedBlockId || !listEl) return;
e.dataTransfer!.dropEffect = 'move'; dragOffsetY = e.clientY - dragStartY;
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
let target: number | null = null;
for (let i = 0; i < wrappers.length; i++) {
const rect = wrappers[i].getBoundingClientRect();
if (e.clientY < rect.top + rect.height / 2) {
target = i;
break;
}
}
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
} }
function handleDrop(e: DragEvent, targetBlockId: string) { function handlePointerUp() {
e.preventDefault(); if (!draggedBlockId) return;
if (!draggedBlockId || draggedBlockId === targetBlockId) {
draggedBlockId = null; if (dropTargetIdx !== null) {
return;
}
const sorted = [...sortedBlocks]; const sorted = [...sortedBlocks];
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId); const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
const toIdx = sorted.findIndex((b) => b.id === targetBlockId); if (fromIdx >= 0) {
if (fromIdx < 0 || toIdx < 0) {
draggedBlockId = null;
return;
}
const [moved] = sorted.splice(fromIdx, 1); const [moved] = sorted.splice(fromIdx, 1);
sorted.splice(toIdx, 0, moved); const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
draggedBlockId = null; sorted.splice(insertAt, 0, moved);
reorder(sorted.map((b) => b.id)); reorder(sorted.map((b) => b.id));
} }
}
function handleDragEnd() {
draggedBlockId = null; draggedBlockId = null;
dropTargetIdx = null;
dragOffsetY = 0;
capturedEl = null;
} }
function flushViaBeacon() { function flushViaBeacon() {
@@ -229,17 +247,24 @@ $effect(() => {
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4"> <div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
{#if hasBlocks} {#if hasBlocks}
<div class="flex flex-col gap-3">
{#each sortedBlocks as block, i (block.id)}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="flex flex-col gap-3"
bind:this={listEl}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{#each sortedBlocks as block, i (block.id)}
{#if dropTargetIdx === i}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-block-wrapper
onblur={handleBlur} onblur={handleBlur}
draggable={true} onpointerdown={(e) => handleGripDown(e, block.id)}
ondragstart={(e) => handleDragStart(e, block.id)} class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
ondragover={handleDragOver} style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
ondrop={(e) => handleDrop(e, block.id)}
ondragend={handleDragEnd}
class={draggedBlockId === block.id ? 'opacity-40' : ''}
> >
<TranscriptionBlock <TranscriptionBlock
blockId={block.id} blockId={block.id}
@@ -263,6 +288,10 @@ $effect(() => {
</div> </div>
{/each} {/each}
{#if dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- Next block CTA — dashed outline hint --> <!-- Next block CTA — dashed outline hint -->
<div <div
class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3" class="flex items-center justify-center rounded border border-dashed border-line px-4 py-5 text-center font-sans text-sm text-ink-3"

View File

@@ -0,0 +1,78 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TranscriptionEditView from './TranscriptionEditView.svelte';
afterEach(cleanup);
const block1 = {
id: 'b1',
annotationId: 'a1',
documentId: 'doc-1',
text: 'Block eins',
label: null,
sortOrder: 0,
version: 0
};
const block2 = {
id: 'b2',
annotationId: 'a2',
documentId: 'doc-1',
text: 'Block zwei',
label: null,
sortOrder: 1,
version: 0
};
function renderView(overrides: Record<string, unknown> = {}) {
return render(TranscriptionEditView, {
documentId: 'doc-1',
blocks: [block1, block2],
canComment: true,
currentUserId: 'user-1',
onBlockFocus: vi.fn(),
onSaveBlock: vi.fn(),
onDeleteBlock: vi.fn(),
...overrides
});
}
describe('TranscriptionEditView — rendering', () => {
it('renders blocks in sort order', async () => {
renderView();
const textareas = page.getByRole('textbox').all();
expect(textareas.length).toBeGreaterThanOrEqual(2);
});
it('shows next-block CTA after block list', async () => {
renderView();
await expect.element(page.getByText(/Block 3/)).toBeInTheDocument();
});
it('shows empty state when no blocks', async () => {
renderView({ blocks: [] });
await expect.element(page.getByText(/Markiere einen Bereich/)).toBeInTheDocument();
});
});
describe('TranscriptionEditView — reorder', () => {
it('renders move-up button disabled on first block', async () => {
renderView();
const upButtons = page.getByRole('button', { name: 'Nach oben' }).all();
// First block's up button should be disabled
await expect.element(upButtons[0]).toBeDisabled();
});
it('renders move-down button disabled on last block', async () => {
renderView();
const downButtons = page.getByRole('button', { name: 'Nach unten' }).all();
// Last block's down button should be disabled
await expect.element(downButtons[downButtons.length - 1]).toBeDisabled();
});
it('has a drag handle on each block', async () => {
renderView();
const handles = document.querySelectorAll('[data-drag-handle]');
expect(handles.length).toBe(2);
});
});