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
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:
@@ -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;
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: DragEvent, targetBlockId: string) {
|
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
|
||||||
e.preventDefault();
|
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
|
||||||
if (!draggedBlockId || draggedBlockId === targetBlockId) {
|
let target: number | null = null;
|
||||||
draggedBlockId = null;
|
|
||||||
return;
|
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 handlePointerUp() {
|
||||||
|
if (!draggedBlockId) return;
|
||||||
|
|
||||||
|
if (dropTargetIdx !== null) {
|
||||||
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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user