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;
|
onFocus: () => void;
|
||||||
onDeleteClick: () => void;
|
onDeleteClick: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
|
onMoveUp?: () => void;
|
||||||
|
onMoveDown?: () => void;
|
||||||
|
isFirst?: boolean;
|
||||||
|
isLast?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -33,7 +37,11 @@ let {
|
|||||||
onTextChange,
|
onTextChange,
|
||||||
onFocus,
|
onFocus,
|
||||||
onDeleteClick,
|
onDeleteClick,
|
||||||
onRetry
|
onRetry,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
isFirst = false,
|
||||||
|
isLast = false
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let localText = $state(text);
|
let localText = $state(text);
|
||||||
@@ -101,7 +109,7 @@ function handleTextareaMouseUp() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="relative overflow-visible rounded border border-line {leftBorderClass}"
|
class="relative flex overflow-visible rounded border border-line {leftBorderClass}"
|
||||||
data-block-id={blockId}
|
data-block-id={blockId}
|
||||||
>
|
>
|
||||||
<!-- Turquoise numbered badge — overlaps top-left of card -->
|
<!-- Turquoise numbered badge — overlaps top-left of card -->
|
||||||
@@ -110,7 +118,44 @@ function handleTextareaMouseUp() {
|
|||||||
>
|
>
|
||||||
{blockNumber}
|
{blockNumber}
|
||||||
</span>
|
</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 -->
|
<!-- Header -->
|
||||||
<div class="mb-2 flex items-center gap-2">
|
<div class="mb-2 flex items-center gap-2">
|
||||||
{#if label}
|
{#if label}
|
||||||
|
|||||||
@@ -121,3 +121,41 @@ describe('TranscriptionBlock — interactions', () => {
|
|||||||
await expect.element(btn).toBeInTheDocument();
|
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);
|
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() {
|
function flushViaBeacon() {
|
||||||
for (const [blockId, text] of pendingTexts) {
|
for (const [blockId, text] of pendingTexts) {
|
||||||
clearDebounce(blockId);
|
clearDebounce(blockId);
|
||||||
@@ -155,7 +231,16 @@ $effect(() => {
|
|||||||
{#if hasBlocks}
|
{#if hasBlocks}
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
{#each sortedBlocks as block, i (block.id)}
|
{#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
|
<TranscriptionBlock
|
||||||
blockId={block.id}
|
blockId={block.id}
|
||||||
documentId={documentId}
|
documentId={documentId}
|
||||||
@@ -170,6 +255,10 @@ $effect(() => {
|
|||||||
onFocus={() => handleFocus(block.id)}
|
onFocus={() => handleFocus(block.id)}
|
||||||
onDeleteClick={() => handleDelete(block.id)}
|
onDeleteClick={() => handleDelete(block.id)}
|
||||||
onRetry={() => handleRetry(block.id)}
|
onRetry={() => handleRetry(block.id)}
|
||||||
|
onMoveUp={() => handleMoveUp(block.id)}
|
||||||
|
onMoveDown={() => handleMoveDown(block.id)}
|
||||||
|
isFirst={i === 0}
|
||||||
|
isLast={i === sortedBlocks.length - 1}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user