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));
}
// ── Drag and drop ────────────────────────────────────────────────────────
// ── Pointer-based drag and drop ──────────────────────────────────────────
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) {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) {
e.preventDefault();
return;
}
function handleGripDown(e: PointerEvent, blockId: string) {
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
e.preventDefault();
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) {
e.preventDefault();
e.dataTransfer!.dropEffect = 'move';
}
function handlePointerMove(e: PointerEvent) {
if (!draggedBlockId || !listEl) return;
dragOffsetY = e.clientY - dragStartY;
function handleDrop(e: DragEvent, targetBlockId: string) {
e.preventDefault();
if (!draggedBlockId || draggedBlockId === targetBlockId) {
draggedBlockId = null;
return;
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;
}
}
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));
if (target === null) target = wrappers.length;
if (target === dragIdx || target === dragIdx + 1) target = null;
dropTargetIdx = target;
}
function handleDragEnd() {
function handlePointerUp() {
if (!draggedBlockId) return;
if (dropTargetIdx !== null) {
const sorted = [...sortedBlocks];
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
if (fromIdx >= 0) {
const [moved] = sorted.splice(fromIdx, 1);
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
sorted.splice(insertAt, 0, moved);
reorder(sorted.map((b) => b.id));
}
}
draggedBlockId = null;
dropTargetIdx = null;
dragOffsetY = 0;
capturedEl = null;
}
function flushViaBeacon() {
@@ -229,17 +247,24 @@ $effect(() => {
<div class="flex h-full flex-col overflow-y-auto bg-surface p-4">
{#if hasBlocks}
<div class="flex flex-col gap-3">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<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}
draggable={true}
ondragstart={(e) => handleDragStart(e, block.id)}
ondragover={handleDragOver}
ondrop={(e) => handleDrop(e, block.id)}
ondragend={handleDragEnd}
class={draggedBlockId === block.id ? 'opacity-40' : ''}
onpointerdown={(e) => handleGripDown(e, block.id)}
class="relative transition-all duration-150 {draggedBlockId === block.id ? 'z-10 rounded-lg shadow-xl ring-2 ring-turquoise/40' : ''}"
style={draggedBlockId === block.id ? `transform: translateY(${dragOffsetY}px) scale(1.02); opacity: 0.9;` : ''}
>
<TranscriptionBlock
blockId={block.id}
@@ -263,6 +288,10 @@ $effect(() => {
</div>
{/each}
{#if dropTargetIdx === sortedBlocks.length}
<div class="h-1 rounded-full bg-turquoise transition-all"></div>
{/if}
<!-- Next block CTA — dashed outline hint -->
<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"