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
115 changes: 115 additions & 0 deletions bitext/src/lib/components/share/AspectSelect.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
<script lang="ts">
export type AspectOption = {
id: string;
label: string;
ratio: string;
/** Dashed placeholder glyph (Auto); otherwise a proportional rectangle. */
dashed?: boolean;
width?: number;
height?: number;
};

let { value = $bindable(), options }: { value: string; options: AspectOption[] } = $props();

let open = $state(false);
let wrapEl = $state<HTMLDivElement | null>(null);

const selected = $derived(options.find((o) => o.id === value) ?? options[0]);

/** Proportional glyph (max 18px) so each option shows its shape. */
function shape(o: AspectOption): { w: number; h: number } {
if (o.dashed || !o.width || !o.height) return { w: 18, h: 18 };
const r = o.width / o.height;
const max = 18;
return r >= 1
? { w: max, h: Math.max(4, Math.round(max / r)) }
: { w: Math.max(4, Math.round(max * r)), h: max };
}

$effect(() => {
if (!open) return;
function onDown(e: PointerEvent) {
if (wrapEl && !wrapEl.contains(e.target as Node)) open = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
window.addEventListener('pointerdown', onDown, true);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pointerdown', onDown, true);
window.removeEventListener('keydown', onKey);
};
});

function choose(id: string) {
value = id;
open = false;
}
</script>

{#snippet glyph(o: AspectOption)}
<span class="inline-flex h-4.5 w-4.5 shrink-0 items-center justify-center">
{#if o.dashed}
<span class="h-4.5 w-4.5 border border-dashed border-current opacity-70"></span>
{:else}
{@const sh = shape(o)}
<span class="border border-current" style="width:{sh.w}px;height:{sh.h}px"></span>
{/if}
</span>
{/snippet}

<div bind:this={wrapEl} class="relative">
<button
type="button"
class="flex w-full items-center gap-2 border border-gray-300 bg-white px-2.5 py-1.5 text-sm text-gray-800 transition-colors hover:border-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:hover:border-gray-500"
aria-haspopup="listbox"
aria-expanded={open}
onclick={() => (open = !open)}
>
{@render glyph(selected)}
<span class="font-medium">{selected.label}</span>
<span class="text-gray-400 dark:text-gray-500">{selected.ratio}</span>
<svg
class="ml-auto h-4 w-4 shrink-0 text-gray-500 transition-transform dark:text-gray-400 {open
? 'rotate-180'
: ''}"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.24 4.5a.75.75 0 0 1-1.08 0l-4.24-4.5a.75.75 0 0 1 .02-1.06z"
clip-rule="evenodd"
/>
</svg>
</button>

{#if open}
<ul
class="absolute right-0 left-0 z-30 mt-1 max-h-72 list-none overflow-y-auto border border-gray-200 bg-white p-0 shadow-lg dark:border-gray-600 dark:bg-gray-800"
role="listbox"
aria-label="Export canvas"
>
{#each options as o (o.id)}
<li>
<button
type="button"
role="option"
aria-selected={o.id === value}
class="flex w-full items-center gap-2 px-2.5 py-2 text-left text-sm transition-colors {o.id ===
value
? 'bg-primary-50 text-primary-800 dark:bg-primary-950/40 dark:text-primary-200'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-700/70'}"
onclick={() => choose(o.id)}
>
{@render glyph(o)}
<span class="font-medium">{o.label}</span>
<span class="text-gray-400 dark:text-gray-500">{o.ratio}</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
161 changes: 135 additions & 26 deletions bitext/src/lib/components/share/ExportMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import { buildStandaloneSvgString } from '$lib/export/svg.js';
import { svgStringToPngBlob, downloadBlob } from '$lib/export/png.js';
import { exportBaseName, firstNonEmptyText } from '$lib/export/filename.js';
import { ASPECT_PRESETS, presetPadding, findAspectPreset } from '$lib/export/aspect-presets.js';
import AspectSelect, { type AspectOption } from './AspectSelect.svelte';
import { svgStringToPdfBlob } from '$lib/export/pdf.js';
import { wrapSvgInHtml } from '$lib/export/html.js';
import { projectStore } from '$lib/state/project.svelte.js';
Expand All @@ -31,6 +33,37 @@
return (RASTER_SCALE_OPTIONS as readonly number[]).includes(n);
}

const hintAspect =
'Auto fits the canvas to your diagram, as before. A preset exports a fixed size for a platform: the diagram is scaled and spread to fill the card, on the theme background. PNG and PDF for a preset export at the exact pixel size shown.';

// Export canvas: 'auto' (dynamic, current behaviour) or a fixed social preset.
let selectedAspect = $state<'auto' | string>('auto');
const activePreset = $derived(
selectedAspect === 'auto' ? undefined : findAspectPreset(selectedAspect)
);

function activeFrame() {
const p = activePreset;
if (!p) return undefined;
return {
width: p.width,
height: p.height,
padding: presetPadding(p),
background: exportBackgroundColor()
};
}

const aspectOptions: AspectOption[] = [
{ id: 'auto', label: 'Auto', ratio: 'fits diagram', dashed: true },
...ASPECT_PRESETS.map((p) => ({
id: p.id,
label: p.label,
ratio: p.ratio,
width: p.width,
height: p.height
}))
];

async function flushPreviewLayout() {
if (!browser) return;
layoutExportStore.requestRemeasure();
Expand All @@ -51,9 +84,11 @@
return '#ffffff';
}

/** File base name seeded from the first non-empty line, e.g. `al-hello-world`. */
/** File name seeded from the first non-empty line, plus the preset id, e.g. `al-hello-world-square.png`. */
function exportName(ext: string): string {
return `${exportBaseName(firstNonEmptyText(projectStore.lines))}.${ext}`;
const base = exportBaseName(firstNonEmptyText(projectStore.lines));
const suffix = activePreset ? `-${activePreset.id}` : '';
return `${base}${suffix}.${ext}`;
}

/** Lines with the active style's default font applied (so exports embed it like the preview). */
Expand All @@ -70,6 +105,7 @@
embedFontCss?: string;
includeImports?: boolean;
siteQrPngDataUri?: string;
frame?: { width: number; height: number; padding?: number; background?: string };
}): string {
const lay = layoutExportStore;
const s = settingsStore.settings;
Expand Down Expand Up @@ -104,46 +140,81 @@
includeAttributionFooter: opts.includeAttributionFooter,
embedFontCdataImports: imports.length ? imports : undefined,
embedFontCss: opts.embedFontCss,
siteQrPngDataUri: opts.siteQrPngDataUri
siteQrPngDataUri: opts.siteQrPngDataUri,
frame: opts.frame
});
}

async function downloadSvg() {
await flushPreviewLayout();
const rawSvg = buildSvg({ includeAttributionFooter: true });
const rawSvg = buildSvg({ includeAttributionFooter: true, frame: activeFrame() });
const svg = await convertCustomFontTextToPaths(rawSvg, projectStore.lines);
downloadBlob(exportName('svg'), new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }));
}

async function buildRasterSvg(): Promise<string> {
await ensureVisualizationCustomFontsFromLines(projectStore.lines);
const embedFontCss = await buildInlinedFontCssFromLines(exportStyledLines());
const svg = buildSvg({ includeAttributionFooter: true, embedFontCss, includeImports: false });
const svg = buildSvg({
includeAttributionFooter: true,
embedFontCss,
includeImports: false,
frame: activeFrame()
});
return await convertCustomFontTextToPaths(svg, projectStore.lines);
}

async function downloadPng() {
await flushPreviewLayout();
const svg = await buildRasterSvg();
const blob = await svgStringToPngBlob(svg, rasterExportScale);
// A preset is already sized in pixels; render 1:1. Auto uses the chosen scale.
const blob = await svgStringToPngBlob(svg, activePreset ? 1 : rasterExportScale);
downloadBlob(exportName('png'), blob);
}

async function downloadPdf() {
await flushPreviewLayout();
const svg = await buildRasterSvg();
const blob = await svgStringToPdfBlob(svg, rasterExportScale);
const blob = await svgStringToPdfBlob(svg, activePreset ? 1 : rasterExportScale);
downloadBlob(exportName('pdf'), blob);
}

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

// Live mini-preview of the export — the framed card for a preset, or the
// content-sized diagram for Auto.
let previewUrl = $state<string | null>(null);
$effect(() => {
if (!browser) {
previewUrl = null;
return;
}
// Track the inputs that change the layout.
void selectedAspect;
void projectStore.lines;
void projectStore.connections;
void projectStore.pairControls;
void settingsStore.settings;
void layoutExportStore.width;
void layoutExportStore.height;
void layoutExportStore.tokenLayout;
const t = setTimeout(() => {
try {
const svg = buildSvg({ includeAttributionFooter: true, frame: activeFrame() });
previewUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
} catch {
previewUrl = null;
}
}, 120);
return () => clearTimeout(t);
});

function buildState(): AppStateV2 {
return {
v: SCHEMA_VERSION,
Expand Down Expand Up @@ -173,26 +244,64 @@
}
</script>

<div class="mb-3 flex flex-wrap items-center gap-2">
<Label for="export-raster-scale" class="mb-0 text-sm text-gray-600 dark:text-gray-400">
PNG / PDF scale
</Label>
<select
id="export-raster-scale"
class="rounded-lg border border-gray-300 bg-gray-50 py-1.5 pl-2 pr-8 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
value={String(rasterExportScale)}
onchange={(e) => {
const n = Number((e.currentTarget as HTMLSelectElement).value);
if (isRasterScale(n)) rasterExportScale = n;
}}
>
{#each RASTER_SCALE_OPTIONS as s (s)}
<option value={String(s)}>{s}×</option>
{/each}
</select>
<SettingsFieldHint text={hintRasterScale} />
<div class="mb-3">
<div class="mb-1.5 flex items-center gap-2">
<Label class="mb-0 text-sm text-gray-600 dark:text-gray-400">Canvas</Label>
<SettingsFieldHint text={hintAspect} />
</div>
<AspectSelect bind:value={selectedAspect} options={aspectOptions} />

<div class="mt-3 flex flex-col gap-3 sm:flex-row sm:items-start">
<div
class="flex max-w-55 items-center justify-center border border-gray-200 bg-gray-50 p-2 dark:border-gray-700 dark:bg-gray-900/40"
>
{#if previewUrl}
<img
src={previewUrl}
alt={`Preview of the ${activePreset ? activePreset.label : 'Auto'} export`}
class="max-h-56 w-auto max-w-full"
/>
{:else}
<div class="flex h-24 w-24 items-center justify-center text-xs text-gray-400">…</div>
{/if}
</div>
<p class="text-xs leading-relaxed text-gray-600 dark:text-gray-400">
{#if activePreset}
<span class="font-medium text-gray-800 dark:text-gray-200"
>{activePreset.width} × {activePreset.height} px</span
><br />
{activePreset.note}.<br />
The diagram fills the card; PNG and PDF export at this exact size.
{:else}
<span class="font-medium text-gray-800 dark:text-gray-200">Fits your diagram</span><br />
The canvas tracks the content, as on screen. PNG and PDF use the scale below.
{/if}
</p>
</div>
</div>

{#if !activePreset}
<div class="mb-3 flex flex-wrap items-center gap-2">
<Label for="export-raster-scale" class="mb-0 text-sm text-gray-600 dark:text-gray-400">
PNG / PDF scale
</Label>
<select
id="export-raster-scale"
class="rounded-lg border border-gray-300 bg-gray-50 py-1.5 pl-2 pr-8 text-sm text-gray-900 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-blue-500 dark:focus:ring-blue-500"
value={String(rasterExportScale)}
onchange={(e) => {
const n = Number((e.currentTarget as HTMLSelectElement).value);
if (isRasterScale(n)) rasterExportScale = n;
}}
>
{#each RASTER_SCALE_OPTIONS as s (s)}
<option value={String(s)}>{s}×</option>
{/each}
</select>
<SettingsFieldHint text={hintRasterScale} />
</div>
{/if}

<ButtonGroup class="flex-wrap">
<Button color="light" size="sm" onclick={downloadPng}>PNG</Button>
<Button color="light" size="sm" onclick={downloadSvg}>SVG</Button>
Expand Down
Loading
Loading