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:
@@ -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