From c22f2e41b197bd514c787fe4434c46d118ee0f12 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 5 Apr 2026 23:07:42 +0200 Subject: [PATCH] fix(transcription): replace broken HTML5 drag with pointer-based drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/TranscriptionEditView.svelte | 99 ++++++++++++------- .../TranscriptionEditView.svelte.spec.ts | 78 +++++++++++++++ 2 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 frontend/src/lib/components/TranscriptionEditView.svelte.spec.ts diff --git a/frontend/src/lib/components/TranscriptionEditView.svelte b/frontend/src/lib/components/TranscriptionEditView.svelte index edad6189..44a21288 100644 --- a/frontend/src/lib/components/TranscriptionEditView.svelte +++ b/frontend/src/lib/components/TranscriptionEditView.svelte @@ -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(() => {
{#if hasBlocks} -
+ +
{#each sortedBlocks as block, i (block.id)} + {#if dropTargetIdx === i} +
+ {/if}
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;` : ''} > {
{/each} + {#if dropTargetIdx === sortedBlocks.length} +
+ {/if} +
= {}) { + 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); + }); +});