Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 70 additions & 26 deletions bitext/src/lib/components/editor/LineCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[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() {
Expand Down Expand Up @@ -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}"
>
Expand All @@ -111,10 +154,11 @@
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="cursor-grab select-none border-0 bg-transparent p-0.5 text-gray-400 active:cursor-grabbing dark:text-gray-500"
draggable="true"
ondragstart={onDragStart}
ondragend={() => lineDrag.end()}
class="flex h-9 w-7 shrink-0 cursor-grab touch-none items-center justify-center border-0 bg-transparent p-0 text-gray-400 select-none active:cursor-grabbing dark:text-gray-500"
onpointerdown={onHandlePointerDown}
onpointermove={onHandlePointerMove}
onpointerup={onHandlePointerUp}
onpointercancel={onHandlePointerCancel}
title="Drag to reorder"
aria-label="Drag to reorder line">⠿</button
>
Expand Down
2 changes: 1 addition & 1 deletion bitext/src/lib/components/preview/StylePicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@

{#if open}
<div
class="absolute right-0 z-30 mt-1 grid w-[min(20rem,80vw)] grid-cols-2 gap-2 border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-600 dark:bg-gray-800"
class="fixed inset-x-2 z-30 mt-1 grid max-h-[70vh] w-auto grid-cols-2 gap-2 overflow-y-auto overscroll-contain border border-gray-200 bg-white p-2 shadow-lg sm:absolute sm:inset-x-auto sm:right-0 sm:w-[min(20rem,80vw)] dark:border-gray-600 dark:bg-gray-800"
role="menu"
aria-label="Visual styles"
>
Expand Down
16 changes: 11 additions & 5 deletions bitext/src/lib/components/share/ExportMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import SettingsFieldHint from '$lib/components/settings/SettingsFieldHint.svelte';
import { buildStandaloneSvgString } from '$lib/export/svg.js';
import { svgStringToPngBlob, downloadBlob } from '$lib/export/png.js';
import { exportBaseName, firstNonEmptyText } from '$lib/export/filename.js';
import { svgStringToPdfBlob } from '$lib/export/pdf.js';
import { wrapSvgInHtml } from '$lib/export/html.js';
import { projectStore } from '$lib/state/project.svelte.js';
Expand Down Expand Up @@ -50,6 +51,11 @@
return '#ffffff';
}

/** File base name seeded from the first non-empty line, e.g. `al-hello-world`. */
function exportName(ext: string): string {
return `${exportBaseName(firstNonEmptyText(projectStore.lines))}.${ext}`;
}

/** Lines with the active style's default font applied (so exports embed it like the preview). */
function exportStyledLines() {
return applyStyleFont(projectStore.lines, getStyle(settingsStore.settings.style));
Expand Down Expand Up @@ -106,7 +112,7 @@
await flushPreviewLayout();
const rawSvg = buildSvg({ includeAttributionFooter: true });
const svg = await convertCustomFontTextToPaths(rawSvg, projectStore.lines);
downloadBlob('alignment.svg', new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
downloadBlob(exportName('svg'), new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
}

async function buildRasterSvg(): Promise<string> {
Expand All @@ -120,22 +126,22 @@
await flushPreviewLayout();
const svg = await buildRasterSvg();
const blob = await svgStringToPngBlob(svg, rasterExportScale);
downloadBlob('alignment.png', blob);
downloadBlob(exportName('png'), blob);
}

async function downloadPdf() {
await flushPreviewLayout();
const svg = await buildRasterSvg();
const blob = await svgStringToPdfBlob(svg, rasterExportScale);
downloadBlob('alignment.pdf', blob);
downloadBlob(exportName('pdf'), blob);
}

async function downloadHtml() {
await flushPreviewLayout();
const rawSvg = buildSvg({ includeAttributionFooter: false });
const svg = await convertCustomFontTextToPaths(rawSvg, projectStore.lines);
const html = wrapSvgInHtml(svg, 'Alignment export', googleFontImportList());
downloadBlob('alignment.html', new Blob([html], { type: 'text/html;charset=utf-8' }));
downloadBlob(exportName('html'), new Blob([html], { type: 'text/html;charset=utf-8' }));
}

function buildState(): AppStateV2 {
Expand All @@ -154,7 +160,7 @@
const dataUrl = await shareUrlToQrDataUrl(url);
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'alignment-share-qr.png';
a.download = `${exportBaseName(firstNonEmptyText(projectStore.lines))}-share-qr.png`;
a.click();
} catch (e: unknown) {
const raw = e instanceof Error ? e.message : String(e);
Expand Down
3 changes: 2 additions & 1 deletion bitext/src/lib/components/share/ShareDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
type VisualSettingsV2
} from '$lib/serialization/schema.js';
import { projectStore } from '$lib/state/project.svelte.js';
import { exportBaseName, firstNonEmptyText } from '$lib/export/filename.js';
import { settingsStore } from '$lib/state/settings.svelte.js';
import { getShareUrl } from '$lib/share/url.js';
import { shareUrlToQrDataUrl } from '$lib/share/qr.js';
Expand Down Expand Up @@ -89,7 +90,7 @@
if (!qrSrc) return;
const a = document.createElement('a');
a.href = qrSrc;
a.download = 'alignment-share-qr.png';
a.download = `${exportBaseName(firstNonEmptyText(projectStore.lines))}-share-qr.png`;
a.click();
}

Expand Down
75 changes: 75 additions & 0 deletions bitext/src/lib/export/filename.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import { exportBaseName, firstNonEmptyText } from './filename.js';

describe('exportBaseName', () => {
it('slugifies a plain Latin phrase', () => {
expect(exportBaseName('Hello world')).toBe('al-hello-world');
});

it('collapses punctuation and extra spaces into single dashes', () => {
expect(exportBaseName(' The quick, brown fox! ')).toBe('al-the-quick-brown-fox');
});

it('keeps Cyrillic letters readable', () => {
expect(exportBaseName('Привет мир')).toBe('al-привет-мир');
});

it('keeps CJK characters', () => {
expect(exportBaseName('你好 世界')).toBe('al-你好-世界');
});

it('falls back on empty string', () => {
expect(exportBaseName('')).toBe('alignment');
});

it('falls back on whitespace only', () => {
expect(exportBaseName(' \t\n')).toBe('alignment');
});

it('falls back on punctuation only', () => {
expect(exportBaseName('!!! ... ??? —')).toBe('alignment');
});

it('falls back on emoji only', () => {
expect(exportBaseName('😀🎉👍')).toBe('alignment');
});

it('drops emoji but keeps surrounding words', () => {
expect(exportBaseName('hi 😀 there')).toBe('al-hi-there');
});

it('caps length without leaving a trailing dash', () => {
const out = exportBaseName('a'.repeat(100));
expect(out).toBe(`al-${'a'.repeat(40)}`);
expect(out.endsWith('-')).toBe(false);
});

it('does not split a multi-unit character at the length cap', () => {
// 40 astral CJK extension-B chars then more; slicing must not leave a lone surrogate.
const out = exportBaseName('𠀀'.repeat(50), { maxLen: 40 });
expect(out.startsWith('al-')).toBe(true);
expect([...out].every((ch) => ch !== '�')).toBe(true);
// Array-from length: 'al-' is 3 code points + 40 kept chars.
expect(Array.from(out).length).toBe(43);
});

it('produces no filesystem-unsafe characters', () => {
const out = exportBaseName('a/b\\c:d*e?f"g<h>i|j');
expect(/[/\\:*?"<>|]/.test(out)).toBe(false);
});

it('honors a custom fallback and prefix', () => {
expect(exportBaseName('', { fallback: 'diagram' })).toBe('diagram');
expect(exportBaseName('Hi', { prefix: 'wa' })).toBe('wa-hi');
});
});

describe('firstNonEmptyText', () => {
it('returns the first line with visible text', () => {
expect(firstNonEmptyText([{ rawText: ' ' }, { rawText: 'second' }])).toBe('second');
});

it('returns empty string when all lines are blank', () => {
expect(firstNonEmptyText([{ rawText: '' }, { rawText: ' \t' }])).toBe('');
});
});
39 changes: 39 additions & 0 deletions bitext/src/lib/export/filename.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Build a short, human-recognisable file base name from user text.
*
* Keeps letters and digits from any script (so Cyrillic, CJK, etc. stay
* readable), turns everything else into single dashes, and falls back to a
* default when nothing usable remains (empty text, punctuation-only, emoji,
* whitespace). The result is safe as a filename: no `/ \ : * ? " < > |`,
* no control characters, and length-capped.
*/
export function exportBaseName(
text: string,
opts: { prefix?: string; fallback?: string; maxLen?: number } = {}
): string {
const prefix = opts.prefix ?? 'al';
const fallback = opts.fallback ?? 'alignment';
const maxLen = opts.maxLen ?? 40;

const cleaned = (text ?? '')
.normalize('NFC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-')
.replace(/^-+|-+$/g, '');

// Slice by code point so multi-unit characters are never split mid-way.
const slug = Array.from(cleaned).slice(0, maxLen).join('').replace(/-+$/g, '');

return slug ? `${prefix}-${slug}` : fallback;
}

/**
* Pick the first line with visible text to seed the export file name.
* Returns an empty string when every line is blank.
*/
export function firstNonEmptyText(lines: { rawText: string }[]): string {
for (const line of lines) {
if (line.rawText && line.rawText.trim()) return line.rawText;
}
return '';
}
2 changes: 1 addition & 1 deletion bitext/src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@

<!-- Canvas scroll area -->
<div bind:this={canvasScrollEl} class="min-h-0 flex-1 overflow-auto">
<div class="p-3 sm:p-4">
<div class="px-0 py-3 sm:p-4">
<AlignmentPreview instancePrefix="editor" writesExportLayout={!previewExpand} />
</div>
</div>
Expand Down
Loading