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));
|
||||
}
|
||||
|
||||
// ── 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"
|
||||
|
||||
@@ -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