feat(transcription): add drag-and-drop + arrow button reordering
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
TranscriptionBlock: - Desktop: grip handle (⠿) on left side, serves as drag handle - Mobile (<768px): ▲/▼ arrow buttons (44px tap targets) replace grip - isFirst/isLast disable boundary arrows - onMoveUp/onMoveDown callbacks for arrow button clicks TranscriptionEditView: - HTML5 drag-and-drop on block wrappers (only initiates from grip handle) - Dragged block shows 40% opacity - On drop: reorder array and call PUT /reorder endpoint - Arrow handlers: swap adjacent blocks and call reorder endpoint 5 new tests: - drag handle element present - move-up disabled when isFirst - move-down disabled when isLast - onMoveUp fires on click - onMoveDown fires on click Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,10 @@ type Props = {
|
||||
onFocus: () => void;
|
||||
onDeleteClick: () => void;
|
||||
onRetry: () => void;
|
||||
onMoveUp?: () => void;
|
||||
onMoveDown?: () => void;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
@@ -33,7 +37,11 @@ let {
|
||||
onTextChange,
|
||||
onFocus,
|
||||
onDeleteClick,
|
||||
onRetry
|
||||
onRetry,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
isFirst = false,
|
||||
isLast = false
|
||||
}: Props = $props();
|
||||
|
||||
let localText = $state(text);
|
||||
@@ -101,7 +109,7 @@ function handleTextareaMouseUp() {
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative overflow-visible rounded border border-line {leftBorderClass}"
|
||||
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
|
||||
data-block-id={blockId}
|
||||
>
|
||||
<!-- Turquoise numbered badge — overlaps top-left of card -->
|
||||
@@ -110,7 +118,44 @@ function handleTextareaMouseUp() {
|
||||
>
|
||||
{blockNumber}
|
||||
</span>
|
||||
<div class="p-4 pl-6">
|
||||
|
||||
<!-- Drag handle (desktop) / Arrow buttons (mobile) -->
|
||||
<div class="flex shrink-0 flex-col items-center justify-center border-r border-line px-1">
|
||||
<!-- Mobile: arrow buttons -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
|
||||
disabled={isFirst}
|
||||
aria-label="Nach oben"
|
||||
onclick={() => onMoveUp?.()}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Desktop: grip handle (drag target) -->
|
||||
<div
|
||||
class="hidden cursor-grab text-ink-3 transition-colors select-none hover:text-ink active:cursor-grabbing md:block"
|
||||
data-drag-handle
|
||||
aria-label="Ziehen zum Sortieren"
|
||||
>
|
||||
⠿
|
||||
</div>
|
||||
<!-- Mobile: arrow down -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-7 w-7 cursor-pointer items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink disabled:opacity-20 md:hidden"
|
||||
disabled={isLast}
|
||||
aria-label="Nach unten"
|
||||
onclick={() => onMoveDown?.()}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 p-4 pl-3">
|
||||
<!-- Header -->
|
||||
<div class="mb-2 flex items-center gap-2">
|
||||
{#if label}
|
||||
|
||||
@@ -121,3 +121,41 @@ describe('TranscriptionBlock — interactions', () => {
|
||||
await expect.element(btn).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Reorder controls ────────────────────────────────────────────────────────
|
||||
|
||||
describe('TranscriptionBlock — reorder controls', () => {
|
||||
it('shows a drag handle element', async () => {
|
||||
renderBlock();
|
||||
const handle = document.querySelector('[data-drag-handle]');
|
||||
expect(handle).not.toBeNull();
|
||||
});
|
||||
|
||||
it('disables move-up button when isFirst', async () => {
|
||||
renderBlock({ isFirst: true });
|
||||
const btn = page.getByRole('button', { name: 'Nach oben' });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables move-down button when isLast', async () => {
|
||||
renderBlock({ isLast: true });
|
||||
const btn = page.getByRole('button', { name: 'Nach unten' });
|
||||
await expect.element(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('calls onMoveUp when up arrow clicked', async () => {
|
||||
const onMoveUp = vi.fn();
|
||||
renderBlock({ onMoveUp, isFirst: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach oben' });
|
||||
await btn.click();
|
||||
expect(onMoveUp).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onMoveDown when down arrow clicked', async () => {
|
||||
const onMoveDown = vi.fn();
|
||||
renderBlock({ onMoveDown, isLast: false });
|
||||
const btn = page.getByRole('button', { name: 'Nach unten' });
|
||||
await btn.click();
|
||||
expect(onMoveDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +125,82 @@ function handleDelete(blockId: string) {
|
||||
onDeleteBlock(blockId);
|
||||
}
|
||||
|
||||
async function reorder(newOrder: string[]) {
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${documentId}/transcription-blocks/reorder`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ blockIds: newOrder })
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const updated = await res.json();
|
||||
// Update blocks with new sort orders from server
|
||||
for (const b of updated) {
|
||||
const existing = blocks.find((x) => x.id === b.id);
|
||||
if (existing) existing.sortOrder = b.sortOrder;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoveUp(blockId: string) {
|
||||
const sorted = [...sortedBlocks];
|
||||
const idx = sorted.findIndex((b) => b.id === blockId);
|
||||
if (idx <= 0) return;
|
||||
[sorted[idx - 1], sorted[idx]] = [sorted[idx], sorted[idx - 1]];
|
||||
reorder(sorted.map((b) => b.id));
|
||||
}
|
||||
|
||||
function handleMoveDown(blockId: string) {
|
||||
const sorted = [...sortedBlocks];
|
||||
const idx = sorted.findIndex((b) => b.id === blockId);
|
||||
if (idx < 0 || idx >= sorted.length - 1) return;
|
||||
[sorted[idx], sorted[idx + 1]] = [sorted[idx + 1], sorted[idx]];
|
||||
reorder(sorted.map((b) => b.id));
|
||||
}
|
||||
|
||||
// ── Drag and drop ────────────────────────────────────────────────────────
|
||||
|
||||
let draggedBlockId: string | null = $state(null);
|
||||
|
||||
function handleDragStart(e: DragEvent, blockId: string) {
|
||||
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
draggedBlockId = blockId;
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent, targetBlockId: string) {
|
||||
e.preventDefault();
|
||||
if (!draggedBlockId || draggedBlockId === targetBlockId) {
|
||||
draggedBlockId = null;
|
||||
return;
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
draggedBlockId = null;
|
||||
}
|
||||
|
||||
function flushViaBeacon() {
|
||||
for (const [blockId, text] of pendingTexts) {
|
||||
clearDebounce(blockId);
|
||||
@@ -155,7 +231,16 @@ $effect(() => {
|
||||
{#if hasBlocks}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each sortedBlocks as block, i (block.id)}
|
||||
<div onblur={handleBlur}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
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' : ''}
|
||||
>
|
||||
<TranscriptionBlock
|
||||
blockId={block.id}
|
||||
documentId={documentId}
|
||||
@@ -170,6 +255,10 @@ $effect(() => {
|
||||
onFocus={() => handleFocus(block.id)}
|
||||
onDeleteClick={() => handleDelete(block.id)}
|
||||
onRetry={() => handleRetry(block.id)}
|
||||
onMoveUp={() => handleMoveUp(block.id)}
|
||||
onMoveDown={() => handleMoveDown(block.id)}
|
||||
isFirst={i === 0}
|
||||
isLast={i === sortedBlocks.length - 1}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user