diff --git a/bitext/src/app.css b/bitext/src/app.css index 4921ec8..d43f4d8 100644 --- a/bitext/src/app.css +++ b/bitext/src/app.css @@ -202,6 +202,40 @@ 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; + transform-origin: 0 0; + will-change: transform; +} + .preview-stack { position: relative; z-index: 1; @@ -317,11 +351,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; @@ -347,6 +386,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 bd2d557..c46065a 100644 --- a/bitext/src/lib/components/preview/AlignmentPreview.svelte +++ b/bitext/src/lib/components/preview/AlignmentPreview.svelte @@ -1,4 +1,5 @@ +
1 ? 'none' : 'pan-y'} + onpointerdown={onPointerDown} + onpointermove={onPointerMove} + onpointerup={onPointerUp} + onpointercancel={onPointerUp} + onwheel={onWheel} + ondblclick={resetZoom} > {#if style.id === 'deco'} @@ -70,109 +297,131 @@ {/if}

{/if} -
- {#if !readonly} -
- -
- {/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 abc79f6..88f242e 100644 --- a/bitext/src/lib/components/preview/AlignmentSvg.svelte +++ b/bitext/src/lib/components/preview/AlignmentSvg.svelte @@ -19,12 +19,18 @@ let { rootEl, connections, - writesExportLayout = true + writesExportLayout = true, + thicknessScale = 1, + zoom = 1 }: { rootEl: HTMLElement | null; connections: Connection[]; /** When false, this preview only draws links locally (avoids clobbering export layout). */ 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>({}); @@ -48,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 }[] = []; @@ -61,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 }; }); @@ -78,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; @@ -172,7 +187,9 @@ pts.x2, pts.y2, settingsStore.settings.lineStyle, - settingsStore.settings.lineThickness * (style.connector.ribbonScale ?? 8), + settingsStore.settings.lineThickness * + (style.connector.ribbonScale ?? 8) * + thicknessScale, style.connector.taper ?? false ) : linkPathD(pts.x1, pts.y1, pts.x2, pts.y2, settingsStore.settings.lineStyle)} @@ -184,7 +201,9 @@ {@const baseOp = settingsStore.settings.lineOpacity} {@const pathOpacity = hi ? 1 : activeForPending ? baseOp : baseOp * PENDING_DIM_FACTOR} {@const baseThickness = - settingsStore.settings.lineThickness * (style.connector.widthScale ?? 1)} + settingsStore.settings.lineThickness * + (style.connector.widthScale ?? 1) * + thicknessScale}
+
+ + {#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), @@ -87,6 +89,7 @@ lineStyle: s.lineStyle, lineThickness: s.lineThickness, lineOpacity: s.lineOpacity, + contentScale: lay.contentScale, lineOrder, lines, tokenLayout: lay.tokenLayout, diff --git a/bitext/src/lib/domain/autofit.test.ts b/bitext/src/lib/domain/autofit.test.ts new file mode 100644 index 0000000..095dccd --- /dev/null +++ b/bitext/src/lib/domain/autofit.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { chromeScale, 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('chromeScale', () => { + it('is 1 when text is full size and shrinks partially otherwise', () => { + expect(chromeScale(1, 0.4)).toBe(1); + // content at 0.5 → 40% of the way down: 1 - 0.4*0.5 = 0.8 + expect(chromeScale(0.5, 0.4)).toBeCloseTo(0.8, 5); + // stronger strength shrinks more + expect(chromeScale(0.5, 0.65)).toBeCloseTo(0.675, 5); + expect(chromeScale(0, 1)).toBe(0); + }); +}); + +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..31785e5 --- /dev/null +++ b/bitext/src/lib/domain/autofit.ts @@ -0,0 +1,77 @@ +/** + * 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; +} + +/** + * When auto-fit shrinks the text, connectors and the attribution credit should shrink too — but + * only partially, so they don't vanish. `strength` is how much of the shrink to apply (0 = none, + * 1 = full). `contentScale` is the overall text scale (e.g. the mean of the per-line scales). + */ +export const AUTOFIT_LINE_STRENGTH = 0.5; +export const AUTOFIT_CREDIT_STRENGTH = 0.65; + +export function chromeScale(contentScale: number, strength: number): number { + const s = Math.max(0, Math.min(1, contentScale)); + return 1 - strength * (1 - s); +} + +/** 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/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; +} diff --git a/bitext/src/lib/export/svg.ts b/bitext/src/lib/export/svg.ts index 25ba4ef..e5bf1dc 100644 --- a/bitext/src/lib/export/svg.ts +++ b/bitext/src/lib/export/svg.ts @@ -15,6 +15,11 @@ import { styleExportFrame, type StyleId } from '$lib/domain/styles.js'; +import { + chromeScale, + AUTOFIT_LINE_STRENGTH, + AUTOFIT_CREDIT_STRENGTH +} from '$lib/domain/autofit.js'; import { escapeXml } from './xml.js'; const ATTRIBUTION_FONT = '"Google Sans", sans-serif'; @@ -75,6 +80,8 @@ export function buildStandaloneSvgString(args: { lineStyle: 'straight' | 'curved'; lineThickness: number; lineOpacity: number; + /** Overall auto-fit text scale (mean of per-line scales); gently shrinks lines + credit. */ + contentScale?: number; /** In display order (top to bottom). */ lineOrder: string[]; lines: { lineId: string; tokens: Token[]; fontFamilyStack: string; textSizePx: number }[]; @@ -101,6 +108,7 @@ export function buildStandaloneSvgString(args: { lineStyle, lineThickness, lineOpacity, + contentScale = 1, lineOrder, lines, tokenLayout, @@ -178,7 +186,9 @@ export function buildStandaloneSvgString(args: { ? `` : ''; // Soft glow via Gaussian blur (matches the preview's drop-shadow, not a hard outline). - const effWidth = lineThickness * (conn.widthScale ?? 1); + const lineScale = chromeScale(contentScale, AUTOFIT_LINE_STRENGTH); + const creditScale = chromeScale(contentScale, AUTOFIT_CREDIT_STRENGTH); + const effWidth = lineThickness * (conn.widthScale ?? 1) * lineScale; const glowDefs: string[] = []; if (conn.glow) { const sd = Math.max(2, Math.round(effWidth * 1.1)); @@ -211,7 +221,7 @@ export function buildStandaloneSvgString(args: { x2, y2, lineStyle, - lineThickness * (conn.ribbonScale ?? 8), + lineThickness * (conn.ribbonScale ?? 8) * lineScale, conn.taper ?? false ); paths.push( @@ -233,9 +243,10 @@ export function buildStandaloneSvgString(args: { const dot = conn.endpointDots; const fill = escapeXml(dot.color ?? color); const ring = dot.ring ? ` stroke="${escapeXml(dot.ring)}" stroke-width="1.5"` : ''; + const dr = Math.round(dot.r * lineScale * 100) / 100; paths.push( - ``, - `` + ``, + `` ); } } @@ -333,7 +344,7 @@ export function buildStandaloneSvgString(args: { ? ' font-style="italic"' : ''; const attribution = includeAttributionFooter - ? `${escapeXml(creditText)}` + ? `${escapeXml(creditText)}` : ''; /** Inset from the full export rectangle (including footer band) — same on right and bottom. */ 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..71c03e4 100644 --- a/bitext/src/lib/state/layoutExport.svelte.ts +++ b/bitext/src/lib/state/layoutExport.svelte.ts @@ -15,9 +15,21 @@ 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>({}); + /** Overall text scale (mean of per-line auto-fit scales); used to shrink lines/credit in export. */ + contentScale = $state(1); /** 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; + } + + setContentScale(scale: number) { + this.contentScale = scale; + } + 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}