From 57beb15825ad197f014e1cdb5a28a27314d79538 Mon Sep 17 00:00:00 2001 From: dani-polani Date: Sun, 5 Jul 2026 05:55:39 +0300 Subject: [PATCH] fix(editor): touch drag reorder, mobile style picker + padding, export names - Line reorder now uses pointer events (mouse + touch) instead of HTML5 drag-and-drop, which never fires on phones. Same drop-position model and index math; adds a small drag threshold and a larger handle hit area. - Style picker dropdown no longer clips off-screen on mobile: it anchors to the viewport (fixed inset-x-2) on small screens and stays anchored to the button on sm+, with a max-height and internal scroll. - Drop the editor's horizontal padding on mobile so the canvas uses the full device width; sm+ keeps its padding. - Export files are named from the first non-empty line (e.g. al-hello-world) instead of always "alignment". New exportBaseName() keeps letters/digits from any script (Cyrillic, CJK), strips everything else to dashes, caps length, and falls back to "alignment" for empty / punctuation-only / emoji input. Applied to PNG/SVG/PDF/HTML and the share QR. Covered by tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/components/editor/LineCard.svelte | 96 ++++++++++++++----- .../lib/components/preview/StylePicker.svelte | 2 +- .../lib/components/share/ExportMenu.svelte | 16 +++- .../lib/components/share/ShareDialog.svelte | 3 +- bitext/src/lib/export/filename.test.ts | 75 +++++++++++++++ bitext/src/lib/export/filename.ts | 39 ++++++++ bitext/src/routes/+page.svelte | 2 +- 7 files changed, 199 insertions(+), 34 deletions(-) create mode 100644 bitext/src/lib/export/filename.test.ts create mode 100644 bitext/src/lib/export/filename.ts diff --git a/bitext/src/lib/components/editor/LineCard.svelte b/bitext/src/lib/components/editor/LineCard.svelte index 7ccbd03..60dad9e 100644 --- a/bitext/src/lib/components/editor/LineCard.svelte +++ b/bitext/src/lib/components/editor/LineCard.svelte @@ -30,37 +30,80 @@ isDropTarget && lineDrag.overPos === 'after' && index !== draggingIndex - 1 ); - function dropPosition(e: DragEvent): 'before' | 'after' { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; + // Pointer-based drag so reordering works with both mouse and touch (native + // HTML5 drag-and-drop never fires on phones). + const DRAG_THRESHOLD_PX = 5; + let pointerId: number | null = null; + let armed = false; + let dragging = false; + let startX = 0; + let startY = 0; + + function resetPointer() { + pointerId = null; + armed = false; + dragging = false; } - function onDragStart(e: DragEvent) { - e.dataTransfer?.setData('text/plain', line.id); - e.dataTransfer!.effectAllowed = 'move'; - lineDrag.start(line.id); + function rowUnderPoint(x: number, y: number): HTMLElement | null { + const el = document.elementFromPoint(x, y) as HTMLElement | null; + return el?.closest('[data-line-row]') ?? null; } - function onDragOver(e: DragEvent) { - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; - if (lineDrag.draggingId && lineDrag.draggingId !== line.id) { - lineDrag.over(index, dropPosition(e)); - } + function onHandlePointerDown(e: PointerEvent) { + if (armed || dragging) return; + if (e.pointerType === 'mouse' && e.button !== 0) return; + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + pointerId = e.pointerId; + startX = e.clientX; + startY = e.clientY; + armed = true; } - function onDrop(e: DragEvent) { + function onHandlePointerMove(e: PointerEvent) { + if (e.pointerId !== pointerId) return; + if (armed && !dragging) { + if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return; + dragging = true; + lineDrag.start(line.id); + } + if (!dragging) return; e.preventDefault(); - const draggedId = e.dataTransfer?.getData('text/plain'); - const pos = dropPosition(e); + const row = rowUnderPoint(e.clientX, e.clientY); + if (!row) return; + const targetIndex = Number(row.dataset.lineIndex); + if (Number.isNaN(targetIndex)) return; + const rect = row.getBoundingClientRect(); + const pos: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; + lineDrag.over(targetIndex, pos); + } + + function onHandlePointerUp(e: PointerEvent) { + if (e.pointerId !== pointerId) return; + (e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId); + const wasDragging = dragging; + const targetIndex = lineDrag.overIndex; + const pos = lineDrag.overPos; + resetPointer(); + if (!wasDragging) return; lineDrag.end(); - if (!draggedId || draggedId === line.id) return; - const dragIndex = projectStore.lines.findIndex((l) => l.id === draggedId); + dropOnto(targetIndex, pos); + } + + function onHandlePointerCancel(e: PointerEvent) { + if (e.pointerId !== pointerId) return; + resetPointer(); + lineDrag.end(); + } + + function dropOnto(targetIndex: number | null, pos: 'before' | 'after') { + if (targetIndex == null) return; + const dragIndex = projectStore.lines.findIndex((l) => l.id === line.id); if (dragIndex < 0) return; // Position in the full list where it should land, then adjust for its own removal. - const insertBefore = pos === 'before' ? index : index + 1; + const insertBefore = pos === 'before' ? targetIndex : targetIndex + 1; const restIndex = insertBefore > dragIndex ? insertBefore - 1 : insertBefore; - projectStore.moveLineToIndex(draggedId, restIndex); + projectStore.moveLineToIndex(line.id, restIndex); } function toggleLineDir() { @@ -91,8 +134,8 @@ class="relative mb-1.5 flex w-full flex-nowrap items-center gap-x-2 transition-opacity {isDragging ? 'opacity-40' : ''}" - ondragover={onDragOver} - ondrop={onDrop} + data-line-row + data-line-index={index} role="group" aria-label="Line {index + 1}" > @@ -111,10 +154,11 @@
diff --git a/bitext/src/lib/components/preview/StylePicker.svelte b/bitext/src/lib/components/preview/StylePicker.svelte index b5819d8..3a87227 100644 --- a/bitext/src/lib/components/preview/StylePicker.svelte +++ b/bitext/src/lib/components/preview/StylePicker.svelte @@ -61,7 +61,7 @@ {#if open}