From 4b09be25aecb87674c3d776da4f5cd7d3de24dd7 Mon Sep 17 00:00:00 2001 From: dani-polani Date: Fri, 3 Jul 2026 01:12:44 +0300 Subject: [PATCH 1/5] feat(preview): auto-fit text so a line never wraps Long or careless input produced diagrams where one input line wrapped onto several display rows, breaking the connectors. There is no valid case for that in this diagram type, so shrink the text to fit instead. - `.token-row` no longer wraps; a display-only auto-fit scales each line's effective font-size + word-gap down (never above the user's size) until it fits the container. Applied identically to the DOM and the exporter (the export draws glyphs at the scaled size, matching the measured boxes). - Per-line fit with a "variance" control: 0 = every line shares one scale (uniform), 1 = each line fits independently. Default 0.5. - Toggle "Auto-fit text to width" (default on); off restores legacy wrapping. - New settings persist in the share URL (compact `af`/`av`, omitted at defaults). Pure fit math in `src/lib/domain/autofit.ts` (unit-tested). Co-Authored-By: Claude Opus 4.8 (1M context) --- bitext/src/app.css | 7 +- .../preview/AlignmentPreview.svelte | 79 ++++++++++++++++- .../components/settings/AppearanceTab.svelte | 38 +++++++++ .../lib/components/share/ExportMenu.svelte | 4 +- bitext/src/lib/domain/autofit.test.ts | 85 +++++++++++++++++++ bitext/src/lib/domain/autofit.ts | 64 ++++++++++++++ bitext/src/lib/serialization/compact-v3.ts | 4 + bitext/src/lib/serialization/schema.ts | 19 ++++- .../serialization-roundtrip.test.ts | 17 ++++ bitext/src/lib/state/layoutExport.svelte.ts | 6 ++ bitext/src/routes/+page.svelte | 13 ++- 11 files changed, 325 insertions(+), 11 deletions(-) create mode 100644 bitext/src/lib/domain/autofit.test.ts create mode 100644 bitext/src/lib/domain/autofit.ts diff --git a/bitext/src/app.css b/bitext/src/app.css index 4921ec8..55b4a26 100644 --- a/bitext/src/app.css +++ b/bitext/src/app.css @@ -317,11 +317,16 @@ path.link-path { .token-row { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; justify-content: center; align-items: baseline; } +/* Auto-fit off → restore legacy wrapping (a line may spill onto multiple rows) */ +.preview-frame[data-autofit='off'] .token-row { + flex-wrap: wrap; +} + .token-view { display: inline-flex; align-items: baseline; diff --git a/bitext/src/lib/components/preview/AlignmentPreview.svelte b/bitext/src/lib/components/preview/AlignmentPreview.svelte index bd2d557..1f8a896 100644 --- a/bitext/src/lib/components/preview/AlignmentPreview.svelte +++ b/bitext/src/lib/components/preview/AlignmentPreview.svelte @@ -1,4 +1,5 @@ @@ -51,6 +125,7 @@ class:preview-frame--light={!previewDark} class:preview-frame--dark={previewDark} data-aligner-style={style.id} + data-autofit={autoFit ? 'on' : 'off'} style:background={isClassicStyle ? undefined : style.canvas.previewBackground} style:color={isClassicStyle ? undefined : style.canvas.textColor} > @@ -111,8 +186,8 @@
+
+ + {#if s.autoFit} +
+ + + settingsStore.patch({ + autoFitVariance: Number((e.currentTarget as HTMLInputElement).value) + })} + /> +

+ 0% = every line uses one size · 100% = each line is sized on its own +

+
+ {/if} +
l.id); const styledLines = exportStyledLines(); + // Auto-fit scales the DOM font size; the export draws glyphs at that same scaled size so + // they match the measured token boxes (positions already reflect the scaled DOM). const lines = styledLines.map((l) => ({ lineId: l.id, tokens: projectStore.tokensOnLine(l.id), fontFamilyStack: svgFontFamilyStackLine(l), - textSizePx: l.textSizePx + textSizePx: l.textSizePx * (lay.fontScaleByLine[l.id] ?? 1) })); return buildStandaloneSvgString({ width: Math.max(1, lay.width), diff --git a/bitext/src/lib/domain/autofit.test.ts b/bitext/src/lib/domain/autofit.test.ts new file mode 100644 index 0000000..517dd29 --- /dev/null +++ b/bitext/src/lib/domain/autofit.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { computeAutoFitScales, scalesChanged } from './autofit.js'; + +describe('computeAutoFitScales', () => { + it('leaves lines that fit at scale 1', () => { + const out = computeAutoFitScales( + [ + { lineId: 'a', width: 100, effScale: 1 }, + { lineId: 'b', width: 200, effScale: 1 } + ], + 300, + 1 + ); + expect(out.a).toBe(1); + expect(out.b).toBe(1); + }); + + it('per-line (variance 1): each line shrinks only as much as it needs', () => { + const out = computeAutoFitScales( + [ + { lineId: 'short', width: 200, effScale: 1 }, + { lineId: 'long', width: 400, effScale: 1 } + ], + 200, + 1 + ); + expect(out.short).toBeCloseTo(1, 5); // 200 fits + expect(out.long).toBeCloseTo(0.5, 5); // 400 → 0.5 to fit 200 + }); + + it('global (variance 0): all lines use the smallest scale', () => { + const out = computeAutoFitScales( + [ + { lineId: 'short', width: 200, effScale: 1 }, + { lineId: 'long', width: 400, effScale: 1 } + ], + 200, + 0 + ); + expect(out.short).toBeCloseTo(0.5, 5); + expect(out.long).toBeCloseTo(0.5, 5); + }); + + it('variance interpolates between global and per-line', () => { + const out = computeAutoFitScales( + [ + { lineId: 'short', width: 200, effScale: 1 }, + { lineId: 'long', width: 400, effScale: 1 } + ], + 200, + 0.5 + ); + // short: between 0.5 (global) and 1 (own) → 0.75 + expect(out.short).toBeCloseTo(0.75, 5); + expect(out.long).toBeCloseTo(0.5, 5); + }); + + it('honors the minimum floor and never exceeds 1', () => { + const out = computeAutoFitScales([{ lineId: 'x', width: 10000, effScale: 1 }], 100, 1, 0.1); + expect(out.x).toBe(0.1); + const grow = computeAutoFitScales([{ lineId: 'y', width: 50, effScale: 0.5 }], 400, 1); + expect(grow.y).toBe(1); // would be 4 → capped at 1 + }); + + it('converges: applying the scale and re-measuring is stable', () => { + // width scales with effScale. Start at 1, one tick, then re-measure. + const avail = 200; + const naturalAt1 = 400; + let eff = 1; + for (let i = 0; i < 4; i++) { + const width = naturalAt1 * eff; + eff = computeAutoFitScales([{ lineId: 'a', width, effScale: eff }], avail, 1).a; + } + expect(eff).toBeCloseTo(0.5, 3); + }); +}); + +describe('scalesChanged', () => { + it('detects meaningful changes and ignores tiny ones', () => { + expect(scalesChanged({ a: 1 }, { a: 0.5 })).toBe(true); + expect(scalesChanged({ a: 0.5 }, { a: 0.5001 })).toBe(false); + expect(scalesChanged({}, { a: 1 })).toBe(false); + expect(scalesChanged({ a: 1 }, { a: 0.9 })).toBe(true); + }); +}); diff --git a/bitext/src/lib/domain/autofit.ts b/bitext/src/lib/domain/autofit.ts new file mode 100644 index 0000000..4ed909e --- /dev/null +++ b/bitext/src/lib/domain/autofit.ts @@ -0,0 +1,64 @@ +/** + * Auto-fit shrinks each line's text just enough that it fits its container on a single row. + * + * It is display-only: the user's `textSizePx` is the maximum; the returned scales are in + * `[min, 1]`. `variance` controls how much line scales may differ: + * - `0` → every line uses the single smallest scale (uniform / "global"). + * - `1` → every line uses its own fit scale (independent / "per-line"). + * + * `width` is a line's measured content width at its current `effScale`; `avail` is the width to fit + * into. The width∝scale assumption is only approximate (fixed token padding), so the caller applies + * the result and re-measures — this function converges in a couple of ticks. + */ + +export interface AutoFitRow { + lineId: string; + /** Measured content width of the row at its current applied scale. */ + width: number; + /** Scale currently applied to the row (1 on first pass). */ + effScale: number; +} + +function clamp(n: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, n)); +} + +export function computeAutoFitScales( + rows: AutoFitRow[], + avail: number, + variance: number, + min = 0.1 +): Record { + const v = clamp(variance, 0, 1); + // Per-line fit target: grow toward 1 when there's slack, shrink when overflowing. + const perLine = new Map(); + for (const r of rows) { + if (r.width <= 0 || avail <= 0) { + perLine.set(r.lineId, r.effScale); + continue; + } + perLine.set(r.lineId, clamp((r.effScale * avail) / r.width, min, 1)); + } + const values = [...perLine.values()]; + const smallest = values.length ? Math.min(...values) : 1; + + const out: Record = {}; + for (const r of rows) { + const si = perLine.get(r.lineId) ?? 1; + out[r.lineId] = smallest + v * (si - smallest); + } + return out; +} + +/** True when any line's scale changed by more than `eps` (used to stop the reactive re-measure loop). */ +export function scalesChanged( + prev: Record, + next: Record, + eps = 0.005 +): boolean { + const keys = new Set([...Object.keys(prev), ...Object.keys(next)]); + for (const k of keys) { + if (Math.abs((prev[k] ?? 1) - (next[k] ?? 1)) > eps) return true; + } + return false; +} diff --git a/bitext/src/lib/serialization/compact-v3.ts b/bitext/src/lib/serialization/compact-v3.ts index 3f0d4d0..3e549fb 100644 --- a/bitext/src/lib/serialization/compact-v3.ts +++ b/bitext/src/lib/serialization/compact-v3.ts @@ -65,6 +65,8 @@ function settingsToCompact(rounded: VisualSettingsV2): CompactSettings3 | undefi } if (rounded.tokenPunctuationChars) o.px = rounded.tokenPunctuationChars; if (rounded.style !== def.style) o.st = rounded.style; + if (rounded.autoFit !== def.autoFit) o.af = rounded.autoFit ? 1 : 0; + if (rounded.autoFitVariance !== def.autoFitVariance) o.av = rounded.autoFitVariance; if (rounded.background !== def.background) { o.bg = rounded.background === 'dark' ? 1 : 0; } @@ -95,6 +97,8 @@ function compactToVisualSettings(s: CompactSettings3 | undefined): VisualSetting raw.background = n === 1 ? 'dark' : 'light'; } if (s.st !== undefined) raw.style = String(s.st); + if (s.af !== undefined) raw.autoFit = Number(s.af) === 1; + if (s.av !== undefined) raw.autoFitVariance = Number(s.av); return normalizeVisualSettingsV2(raw); } diff --git a/bitext/src/lib/serialization/schema.ts b/bitext/src/lib/serialization/schema.ts index 88115e1..6313ff5 100644 --- a/bitext/src/lib/serialization/schema.ts +++ b/bitext/src/lib/serialization/schema.ts @@ -159,6 +159,10 @@ export interface VisualSettingsV2 { background: BackgroundMode; /** Visual style preset (background, frame, connector treatment, default font). */ style: StyleId; + /** Shrink text to fit so a line never wraps to a second display row. */ + autoFit: boolean; + /** 0 = all lines share one scale (uniform); 1 = each line fits independently. */ + autoFitVariance: number; } export interface AppStateV2 { @@ -235,7 +239,9 @@ export function defaultVisualSettingsV2(): VisualSettingsV2 { tokenPunctuationChars: '', previewHideChrome: false, background: 'light', - style: 'classic' + style: 'classic', + autoFit: true, + autoFitVariance: 0.5 }; } @@ -311,7 +317,9 @@ export function visualSettingsV1ToV2(v1: VisualSettingsV1): VisualSettingsV2 { tokenPunctuationChars: '', previewHideChrome: false, background: normalizePreviewBackground(v1.background), - style: 'classic' + style: 'classic', + autoFit: true, + autoFitVariance: 0.5 }; } @@ -778,6 +786,11 @@ export function normalizeVisualSettingsV2( previewHideChrome: typeof raw.previewHideChrome === 'boolean' ? raw.previewHideChrome : d.previewHideChrome, background: normalizePreviewBackground(raw.background ?? d.background), - style: isStyleId(raw.style) ? raw.style : d.style + style: isStyleId(raw.style) ? raw.style : d.style, + autoFit: typeof raw.autoFit === 'boolean' ? raw.autoFit : d.autoFit, + autoFitVariance: + typeof raw.autoFitVariance === 'number' && Number.isFinite(raw.autoFitVariance) + ? Math.max(0, Math.min(1, raw.autoFitVariance)) + : d.autoFitVariance }; } diff --git a/bitext/src/lib/serialization/serialization-roundtrip.test.ts b/bitext/src/lib/serialization/serialization-roundtrip.test.ts index ee45808..ffab251 100644 --- a/bitext/src/lib/serialization/serialization-roundtrip.test.ts +++ b/bitext/src/lib/serialization/serialization-roundtrip.test.ts @@ -95,6 +95,23 @@ describe('compact v3 encode/decode (current share format)', () => { expect(decodeState(encodeState(classic)).settings.style).toBe('classic'); }); + it('round-trip: auto-fit off + variance; defaults stay out of the URL', () => { + const base = migrate({}); + expect(base.settings.autoFit).toBe(true); + expect(base.settings.autoFitVariance).toBe(0.5); + // Defaults (on, 0.5) must not enlarge the payload. + expect(encodeState(base)).toBe( + encodeState({ ...base, settings: { ...base.settings, autoFit: true, autoFitVariance: 0.5 } }) + ); + const s: AppStateV2 = { + ...base, + settings: { ...base.settings, autoFit: false, autoFitVariance: 0.3 } + }; + const out = decodeState(encodeState(s)).settings; + expect(out.autoFit).toBe(false); + expect(out.autoFitVariance).toBeCloseTo(0.3, 5); + }); + it('round-trip: three lines, connections, hidden pair control', () => { const base = migrate({}); const lines = [ diff --git a/bitext/src/lib/state/layoutExport.svelte.ts b/bitext/src/lib/state/layoutExport.svelte.ts index 9646571..27a781b 100644 --- a/bitext/src/lib/state/layoutExport.svelte.ts +++ b/bitext/src/lib/state/layoutExport.svelte.ts @@ -15,9 +15,15 @@ class LayoutExportStore { linkPaths = $state([]); /** Vertical center Y of each line’s token row, keyed by line id */ lineRowY = $state>({}); + /** Auto-fit scale applied to each line's font size (keyed by line id; missing ⇒ 1). */ + fontScaleByLine = $state>({}); /** Bumped so preview layout is remeasured (e.g. before export) with up-to-date font metrics. */ layoutRemeasureTick = $state(0); + setFontScaleByLine(map: Record) { + this.fontScaleByLine = map; + } + requestRemeasure() { this.layoutRemeasureTick++; } diff --git a/bitext/src/routes/+page.svelte b/bitext/src/routes/+page.svelte index 60d44c5..ca21147 100644 --- a/bitext/src/routes/+page.svelte +++ b/bitext/src/routes/+page.svelte @@ -397,10 +397,15 @@
-

- Narrow screen: try landscape orientation or reduce line size in line settings—layouts stay - readable with a bit more horizontal space. -

+ {#if !settingsStore.settings.autoFit} +

+ Narrow screen: try landscape orientation or reduce line size in line settings—layouts + stay readable with a bit more horizontal space. +

+ {/if}
From ccf9ae9a45a9f63be8c46ecd1efe83bdbff7b417 Mon Sep 17 00:00:00 2001 From: dani-polani Date: Fri, 3 Jul 2026 01:41:05 +0300 Subject: [PATCH 2/5] fix(preview): keep hyphenated tokens on one line; shrink lines/credit with fit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A token is one alignment unit: `white-space: nowrap` on the token text so it never breaks inside (e.g. at a hyphen). This also fixed a preview↔export mismatch where a hyphen-wrapped token measured as two rows. - When auto-fit shrinks the text, connectors (stroke + ribbon + endpoint dots) and the attribution credit now shrink too — partially, via a strength coefficient (lines 0.4, credit 0.65) applied to the mean text scale — so they don't look oversized on small text. Applied in preview and export. Co-Authored-By: Claude Opus 4.8 (1M context) --- bitext/src/app.css | 2 ++ .../preview/AlignmentPreview.svelte | 34 ++++++++++++++++--- .../components/preview/AlignmentSvg.svelte | 17 +++++++--- .../lib/components/share/ExportMenu.svelte | 1 + bitext/src/lib/domain/autofit.test.ts | 13 ++++++- bitext/src/lib/domain/autofit.ts | 13 +++++++ bitext/src/lib/export/svg.ts | 21 +++++++++--- bitext/src/lib/state/layoutExport.svelte.ts | 6 ++++ 8 files changed, 91 insertions(+), 16 deletions(-) diff --git a/bitext/src/app.css b/bitext/src/app.css index 55b4a26..9f7849e 100644 --- a/bitext/src/app.css +++ b/bitext/src/app.css @@ -352,6 +352,8 @@ path.link-path { .token-view__text { font-weight: 500; + /* A token is one alignment unit — never let it break inside (e.g. at a hyphen). */ + white-space: nowrap; } /* Bauhaus-style word cards */ diff --git a/bitext/src/lib/components/preview/AlignmentPreview.svelte b/bitext/src/lib/components/preview/AlignmentPreview.svelte index 1f8a896..808bffc 100644 --- a/bitext/src/lib/components/preview/AlignmentPreview.svelte +++ b/bitext/src/lib/components/preview/AlignmentPreview.svelte @@ -12,7 +12,13 @@ import { layoutExportStore } from '$lib/state/layoutExport.svelte.js'; import { lineIsLinkTargetWhilePending } from '$lib/domain/lines-helpers.js'; import { getStyle, effectiveLineFamily } from '$lib/domain/styles.js'; - import { computeAutoFitScales, scalesChanged } from '$lib/domain/autofit.js'; + import { + computeAutoFitScales, + scalesChanged, + chromeScale, + AUTOFIT_LINE_STRENGTH, + AUTOFIT_CREDIT_STRENGTH + } from '$lib/domain/autofit.js'; import type { LineV2 } from '$lib/serialization/schema.js'; import { ALIGNER_SITE_HOST, ALIGNER_SITE_URL } from '$lib/brand.js'; import { MAX_LINES } from '$lib/serialization/schema.js'; @@ -54,6 +60,15 @@ return autoFit ? (effScale[id] ?? 1) : 1; } + // Overall text scale (mean of per-line scales) → gently shrink connectors + credit with it. + const contentScale = $derived.by(() => { + if (!autoFit) return 1; + const v = Object.values(effScale); + return v.length ? v.reduce((a, b) => a + b, 0) / v.length : 1; + }); + const thicknessScale = $derived(chromeScale(contentScale, AUTOFIT_LINE_STRENGTH)); + const creditScale = $derived(chromeScale(contentScale, AUTOFIT_CREDIT_STRENGTH)); + $effect(() => { // Re-run when anything that changes line widths changes. void projectStore.lines; @@ -75,7 +90,10 @@ if (!autoFit) { if (Object.keys(effScale).length) effScale = {}; - if (writesExportLayout) layoutExportStore.setFontScaleByLine({}); + if (writesExportLayout) { + layoutExportStore.setFontScaleByLine({}); + layoutExportStore.setContentScale(1); + } return; } @@ -93,7 +111,13 @@ if (!rows.length || !Number.isFinite(avail)) return; const next = computeAutoFitScales(rows, avail * 0.98, autoFitVariance); if (scalesChanged(effScale, next)) effScale = next; - if (writesExportLayout) layoutExportStore.setFontScaleByLine(effScale); + if (writesExportLayout) { + layoutExportStore.setFontScaleByLine(effScale); + const vals = Object.values(effScale); + layoutExportStore.setContentScale( + vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 1 + ); + } } const ro = new ResizeObserver(() => { @@ -237,7 +261,7 @@ {/if} {#if hideChrome} -

+

Created with - - {/if} - {#each projectStore.lines as line, li (line.id)} - {@const gearDomId = `${instancePrefix}-line-gear-${line.id}`} - {@const pending = selectionStore.pending} - {@const rowDimmed = - pending != null && !lineIsLinkTargetWhilePending(lineIds, pending.lineId, line.id)} -

- {#if !readonly} -
= MAX_LINES} + onclick={() => projectStore.addLine(0)} > - -
- {/if} -
- + + Add line +
- {#if !readonly} -
- + {#if !readonly} +
+ +
+ {/if} +
+
+ {#if !readonly} +
+ +
+ {/if} +
+ {#if li < projectStore.lines.length - 1} + {@const lowerLine = projectStore.lines[li + 1]!} + {/if} -
- {#if li < projectStore.lines.length - 1} - {@const lowerLine = projectStore.lines[li + 1]!} - - {/if} - {/each} - {#if !readonly} -
- -
- {/if} - {#if hideChrome} -

- Created with - -

- {/if} + + + {/if} + {#if hideChrome} +

+ Created with + +

+ {/if} + + - diff --git a/bitext/src/lib/components/preview/AlignmentSvg.svelte b/bitext/src/lib/components/preview/AlignmentSvg.svelte index 295d119..88f242e 100644 --- a/bitext/src/lib/components/preview/AlignmentSvg.svelte +++ b/bitext/src/lib/components/preview/AlignmentSvg.svelte @@ -20,7 +20,8 @@ rootEl, connections, writesExportLayout = true, - thicknessScale = 1 + thicknessScale = 1, + zoom = 1 }: { rootEl: HTMLElement | null; connections: Connection[]; @@ -28,6 +29,8 @@ writesExportLayout?: boolean; /** Auto-fit shrink applied to connector thickness so lines don't look huge on small text. */ thicknessScale?: number; + /** Visual pan/zoom scale of the wrapper; measurements are divided by it to stay in layout space. */ + zoom?: number; } = $props(); let displayTokenLayout = $state>({}); @@ -51,9 +54,16 @@ function measure() { if (!rootEl) return; + // `rootEl` is the pan/zoom wrapper. Dividing every measured delta/size by the current zoom + // yields layout-space coordinates (transform-invariant), so connectors and the export are + // identical at any zoom. + const z = zoom || 1; + // Round to hundredths so dividing by the zoom yields the same values at any zoom (no + // floating-point drift) — keeps the export byte-identical and the SVG small. + const r2 = (n: number) => Math.round(n * 100) / 100; const ro = rootEl.getBoundingClientRect(); - const w = ro.width; - const h = ro.height; + const w = r2(ro.width / z); + const h = r2(ro.height / z); const tokenLayout: Record = {}; const linkPaths: { linkId: string; color: string; d: string }[] = []; @@ -64,15 +74,17 @@ const id = el.dataset.tokenId; if (!id) return; const b = el.getBoundingClientRect(); - const x = b.left - ro.left; - const y = b.top - ro.top; + const x = r2((b.left - ro.left) / z); + const y = r2((b.top - ro.top) / z); + const bw = r2(b.width / z); + const bh = r2(b.height / z); tokenLayout[id] = { - cx: x + b.width / 2, - cy: y + b.height / 2, + cx: r2(x + bw / 2), + cy: r2(y + bh / 2), x, y, - w: b.width, - h: b.height + w: bw, + h: bh }; }); @@ -81,7 +93,7 @@ const lineId = row.dataset.line; if (!lineId) return; const b = row.getBoundingClientRect(); - lineRowY[lineId] = b.top - ro.top + b.height / 2; + lineRowY[lineId] = r2((b.top - ro.top + b.height / 2) / z); }); const style = settingsStore.settings.lineStyle; diff --git a/bitext/src/lib/domain/zoom.test.ts b/bitext/src/lib/domain/zoom.test.ts new file mode 100644 index 0000000..2d0958b --- /dev/null +++ b/bitext/src/lib/domain/zoom.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { clampPan, clampZoom, IDENTITY_ZOOM, isIdentity, zoomAt } from './zoom.js'; + +describe('clampZoom', () => { + it('clamps to [1, 6]', () => { + expect(clampZoom(0.5)).toBe(1); + expect(clampZoom(3)).toBe(3); + expect(clampZoom(99)).toBe(6); + }); +}); + +describe('zoomAt', () => { + it('keeps the focal point fixed on screen', () => { + // From identity, zoom 2x around focal (100, 50). + const s = zoomAt(IDENTITY_ZOOM, 2, { x: 100, y: 50 }); + expect(s.z).toBe(2); + // The content point that was under (100,50) must still map to (100,50): + // screen = translate + z * content; content = (focal - t0)/z0 = (100,50) + const screenX = s.x + s.z * 100; + const screenY = s.y + s.z * 50; + expect(screenX).toBeCloseTo(100, 6); + expect(screenY).toBeCloseTo(50, 6); + }); + + it('is reversible back to identity', () => { + const a = zoomAt(IDENTITY_ZOOM, 3, { x: 40, y: 20 }); + const b = zoomAt(a, 1, { x: 40, y: 20 }); + expect(b.z).toBe(1); + expect(b.x).toBeCloseTo(0, 6); + expect(b.y).toBeCloseTo(0, 6); + }); +}); + +describe('clampPan', () => { + it('pins to 0 at zoom 1', () => { + const s = clampPan({ z: 1, x: -50, y: 30 }, 300, 200); + expect(s.x).toBe(0); + expect(s.y).toBe(0); + }); + + it('keeps zoomed content covering the viewport', () => { + // z=2, viewport 300×200 → x in [-300, 0], y in [-200, 0] + expect(clampPan({ z: 2, x: 100, y: 100 }, 300, 200).x).toBe(0); + expect(clampPan({ z: 2, x: -999, y: -999 }, 300, 200)).toEqual({ z: 2, x: -300, y: -200 }); + expect(clampPan({ z: 2, x: -150, y: -80 }, 300, 200)).toEqual({ z: 2, x: -150, y: -80 }); + }); +}); + +describe('isIdentity', () => { + it('detects the untransformed state', () => { + expect(isIdentity(IDENTITY_ZOOM)).toBe(true); + expect(isIdentity({ z: 1.2, x: 0, y: 0 })).toBe(false); + }); +}); diff --git a/bitext/src/lib/domain/zoom.ts b/bitext/src/lib/domain/zoom.ts new file mode 100644 index 0000000..f2df762 --- /dev/null +++ b/bitext/src/lib/domain/zoom.ts @@ -0,0 +1,57 @@ +/** + * Pan/zoom math for the preview's interaction-only magnifier. + * + * Transform model: `translate(x, y) scale(z)` with `transform-origin: 0 0`. Coordinates are in the + * wrapper's untransformed local space (screen coord minus the wrapper's untransformed origin). + * This is purely a viewing transform — it never changes layout, so measured token positions (and + * therefore the export) are unaffected. + */ + +export const MIN_ZOOM = 1; +export const MAX_ZOOM = 6; + +export interface ZoomState { + z: number; + x: number; + y: number; +} + +export const IDENTITY_ZOOM: ZoomState = { z: 1, x: 0, y: 0 }; + +export function clampZoom(z: number): number { + return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z)); +} + +/** + * Zoom to `newZ` while keeping the content point under `focal` fixed on screen. + * `focal` is in the wrapper's untransformed local space. + */ +export function zoomAt(state: ZoomState, newZ: number, focal: { x: number; y: number }): ZoomState { + const z = clampZoom(newZ); + const r = z / state.z; + return { + z, + x: focal.x * (1 - r) + state.x * r, + y: focal.y * (1 - r) + state.y * r + }; +} + +/** Keep the (transformed) content covering the `w × h` viewport so it can't be dragged off-screen. */ +export function clampPan(state: ZoomState, w: number, h: number): ZoomState { + const minX = w * (1 - state.z); + const minY = h * (1 - state.z); + return { + z: state.z, + x: Math.max(minX, Math.min(0, state.x)), + y: Math.max(minY, Math.min(0, state.y)) + }; +} + +/** Convert a wheel deltaY into a multiplicative zoom factor (trackpad/ctrl-wheel zoom). */ +export function wheelZoomFactor(deltaY: number): number { + return Math.exp(-deltaY * 0.0015); +} + +export function isIdentity(state: ZoomState): boolean { + return state.z === 1 && state.x === 0 && state.y === 0; +} From 24b5ca8b9aebe78678706527a0c81883da4b635c Mon Sep 17 00:00:00 2001 From: dani-polani Date: Fri, 3 Jul 2026 02:29:13 +0300 Subject: [PATCH 5/5] feat(preview): discoverability hint for the zoom gesture Show a small pill when auto-fit has shrunk the text (so zoom is useful) and the user hasn't zoomed yet: "Pinch to zoom in" on touch, "Ctrl + scroll to zoom in" on desktop. Switches to a reset hint while zoomed; hidden for short text, readonly and hide-chrome/export. Co-Authored-By: Claude Opus 4.8 (1M context) --- bitext/src/app.css | 27 +++++++++++++++++++ .../preview/AlignmentPreview.svelte | 21 +++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/bitext/src/app.css b/bitext/src/app.css index fbdc3bf..d43f4d8 100644 --- a/bitext/src/app.css +++ b/bitext/src/app.css @@ -202,6 +202,33 @@ html { 0 1px 3px rgb(0 0 0 / 0.9); } +/* Discoverability pill for the zoom gesture */ +.preview-zoom-hint { + position: absolute; + right: 0.5rem; + bottom: 0.5rem; + z-index: 20; + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.2rem 0.55rem; + font-family: var(--font-body, system-ui, sans-serif); + font-size: 0.7rem; + line-height: 1; + border-radius: 9999px; + pointer-events: none; + color: #475569; + background: rgb(255 255 255 / 0.82); + border: 1px solid rgb(0 0 0 / 0.08); + box-shadow: 0 1px 3px rgb(0 0 0 / 0.08); + backdrop-filter: blur(4px); +} +.preview-frame--dark .preview-zoom-hint { + color: #cbd5e1; + background: rgb(24 24 27 / 0.7); + border-color: rgb(255 255 255 / 0.12); +} + /* Interaction-only pan/zoom layer (pinch to magnify small text; never affects export) */ .preview-zoom { position: relative; diff --git a/bitext/src/lib/components/preview/AlignmentPreview.svelte b/bitext/src/lib/components/preview/AlignmentPreview.svelte index f694848..c46065a 100644 --- a/bitext/src/lib/components/preview/AlignmentPreview.svelte +++ b/bitext/src/lib/components/preview/AlignmentPreview.svelte @@ -247,6 +247,18 @@ function resetZoom() { zoom = { ...IDENTITY_ZOOM }; } + + // Discoverability: hint that zoom exists, but only when the text got small enough to need it. + let coarsePointer = $state(false); + $effect(() => { + if (browser) coarsePointer = window.matchMedia('(pointer: coarse)').matches; + }); + const zoomHint = $derived.by(() => { + if (readonly || hideChrome) return null; + if (zoom.z > 1) return coarsePointer ? 'Double-tap to reset' : 'Double-click to reset'; + if (contentScale < 0.8) return coarsePointer ? 'Pinch to zoom in' : 'Ctrl + scroll to zoom in'; + return null; + }); @@ -285,6 +297,15 @@ {/if}

{/if} + {#if zoomHint} + + {/if}
{#if !readonly}