diff --git a/bitext/package-lock.json b/bitext/package-lock.json index 2b80a1a..27cbdb3 100644 --- a/bitext/package-lock.json +++ b/bitext/package-lock.json @@ -2453,9 +2453,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.70.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz", - "integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "dev": true, "license": "MIT", "dependencies": { @@ -3139,16 +3139,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3157,13 +3157,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3194,9 +3194,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -3207,13 +3207,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3221,14 +3221,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3237,9 +3237,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3247,13 +3247,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -3389,9 +3389,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3411,9 +3411,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -3612,9 +3612,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "dev": true, "license": "MIT" }, @@ -3625,9 +3625,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -4067,9 +4067,9 @@ } }, "node_modules/fflate": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", - "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", "license": "MIT" }, "node_modules/file-entry-cache": { @@ -5304,9 +5304,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5619,9 +5619,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5950,9 +5950,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -6191,19 +6191,19 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6231,12 +6231,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/bitext/src/app.css b/bitext/src/app.css index 0bf5577..de96a95 100644 --- a/bitext/src/app.css +++ b/bitext/src/app.css @@ -244,15 +244,37 @@ html { align-items: stretch; } +/* Rows: the wrapper around the lines takes no part in layout. */ +.preview-lines { + display: contents; +} + /* Column mode: lines sit side by side, first line leftmost — the same "first is at the start of the stack" rule as rows. Which script ends up on which side is set by line order. */ /* Columns stretch to the tallest one rather than to a percentage height, so the same rules work whether the frame is bounded (editor) or hugs its content (readonly embed, headless render). */ -.preview-stack--columns { - flex-direction: row; - flex-wrap: wrap; +.preview-stack--columns .preview-lines { + display: flex; + flex: 1; + min-height: 0; align-items: stretch; - justify-content: center; + /* Columns never wrap: a wrapped column would be linked across the whole diagram. Auto-fit + shrinks it to the frame's width; when it still does not fit, the frame scrolls. Auto margins + center a narrow diagram and let a wide one start at the left edge, so all of it scrolls into + view. */ + width: max-content; + margin-inline: auto; +} + +/* While pinch-zoomed, panning moves the diagram; a scrollbar would also grow with the zoom. */ +.preview-frame[data-aligner-axis='columns']:not([data-zoomed]) { + overflow-x: auto; +} + +/* The connector layer covers the zoom box, so the box grows with a diagram wider than the frame. */ +.preview-frame[data-aligner-axis='columns'] .preview-zoom { + width: max-content; + min-width: 100%; } /* Column mode grows sideways, so the block axis needs a bound for auto-fit to shrink a long @@ -277,12 +299,6 @@ html { align-items: center; } -/* The credit is a flex item of the stack, so in a row it would sit beside the diagram. Give it a - full basis and it wraps onto its own line underneath. */ -.preview-stack--columns .preview-frame__attribution { - flex-basis: 100%; -} - .preview-frame__attribution { position: relative; z-index: 3; @@ -425,6 +441,13 @@ path.link-path { align-items: center; } +/* Upright word in a column: each joined piece sits in its own row and column of the grid, which + lays the pieces out as a staircase. `dir="rtl"` on the word runs the steps right to left. */ +.token-row__word { + display: inline-grid; + writing-mode: horizontal-tb; +} + /* Glyph setting per line. `upright` is explicit so it resets the inherited vertical row above. */ .token-view--upright { writing-mode: horizontal-tb; diff --git a/bitext/src/lib/components/preview/AlignmentPreview.svelte b/bitext/src/lib/components/preview/AlignmentPreview.svelte index c7ad85d..3121fd8 100644 --- a/bitext/src/lib/components/preview/AlignmentPreview.svelte +++ b/bitext/src/lib/components/preview/AlignmentPreview.svelte @@ -19,6 +19,7 @@ } from '$lib/domain/styles.js'; import { computeAutoFitScales, + computeWidthFitCap, scalesChanged, chromeScale, AUTOFIT_LINE_STRENGTH, @@ -50,6 +51,7 @@ } = $props(); let rootEl = $state(null); + let linesEl = $state(null); const style = $derived(getStyle(settingsStore.settings.style)); const resolvedCanvas = $derived( @@ -80,6 +82,10 @@ const autoFit = $derived(settingsStore.settings.autoFit); const autoFitVariance = $derived(settingsStore.settings.autoFitVariance); let effScale = $state>({}); + /** Column mode only: uniform cap on line scales and column gaps so the diagram fits the width. */ + let widthFit = $state(1); + /** Each pass re-measures after the previous one applied its scales; this bounds the loop. */ + const MAX_AUTOFIT_PASSES = 12; function scaleFor(id: string): number { return autoFit ? (effScale[id] ?? 1) : 1; @@ -109,6 +115,7 @@ void settingsStore.settings.tokenSplitChars; void settingsStore.settings.style; void settingsStore.settings.layoutAxis; + void projectStore.linePairGaps; void writesExportLayout; void layoutExportStore.layoutRemeasureTick; @@ -116,6 +123,7 @@ if (!autoFit) { if (Object.keys(effScale).length) effScale = {}; + widthFit = 1; if (writesExportLayout) { layoutExportStore.setFontScaleByLine({}); layoutExportStore.setContentScale(1); @@ -123,8 +131,13 @@ return; } + let passes = 0; + function scheduleRecompute() { + requestAnimationFrame(() => requestAnimationFrame(() => recompute())); + } + function recompute() { - if (!rootEl) return; + if (cancelled || !rootEl) return; // Auto-fit works along the flow axis: row width in `rows`, column height in `columns`. const vertical = settingsStore.settings.layoutAxis === 'columns'; const rows: { lineId: string; extent: number; effScale: number }[] = []; @@ -144,8 +157,31 @@ }); }); if (!rows.length || !Number.isFinite(avail)) return; - const next = computeAutoFitScales(rows, avail * 0.98, autoFitVariance); - if (scalesChanged(effScale, next)) effScale = next; + // A readonly column diagram hugs its content, so its height is no bound: fitting into it + // shrank the text on every pass, as each pass also shrank the frame. + const next = + vertical && readonly + ? Object.fromEntries(rows.map((r) => [r.lineId, 1])) + : computeAutoFitScales(rows, avail * 0.98, autoFitVariance); + // Columns sit side by side, so the whole diagram must also fit the frame's width. + let nextWidthFit = 1; + if (vertical && linesEl) { + const frameStyle = getComputedStyle(rootEl); + const availWidth = + rootEl.clientWidth - + parseFloat(frameStyle.paddingLeft) - + parseFloat(frameStyle.paddingRight); + nextWidthFit = computeWidthFitCap(widthFit, linesEl.offsetWidth, availWidth * 0.98); + for (const id in next) next[id] = Math.min(next[id]!, nextWidthFit); + } + const changed = + scalesChanged(effScale, next) || scalesChanged({ w: widthFit }, { w: nextWidthFit }); + if (changed) { + effScale = next; + widthFit = nextWidthFit; + passes++; + if (passes < MAX_AUTOFIT_PASSES) scheduleRecompute(); + } if (writesExportLayout) { layoutExportStore.setFontScaleByLine(effScale); const vals = Object.values(effScale); @@ -155,23 +191,27 @@ } } + /** A new size or font starts a fresh round of passes. */ + function remeasure() { + passes = 0; + recompute(); + } + + let cancelled = false; const ro = new ResizeObserver(() => { - requestAnimationFrame(() => recompute()); + requestAnimationFrame(() => remeasure()); }); ro.observe(rootEl); - requestAnimationFrame(() => requestAnimationFrame(() => recompute())); + scheduleRecompute(); - let cancelled = false; if (document.fonts) { - void document.fonts.ready.then(() => { - if (!cancelled) recompute(); - }); - document.fonts.addEventListener('loadingdone', recompute); + void document.fonts.ready.then(() => remeasure()); + document.fonts.addEventListener('loadingdone', remeasure); } return () => { cancelled = true; ro.disconnect(); - if (document.fonts) document.fonts.removeEventListener('loadingdone', recompute); + if (document.fonts) document.fonts.removeEventListener('loadingdone', remeasure); }; }); @@ -300,9 +340,10 @@ data-aligner-axis={axis} data-aligner-readonly={readonly ? '' : undefined} data-autofit={autoFit ? 'on' : 'off'} + data-zoomed={zoom.z > 1 ? '' : undefined} style:background={plainCanvas ? undefined : canvas.previewBackground} style:color={plainCanvas ? undefined : canvas.textColor} - style:touch-action={zoom.z > 1 ? 'none' : 'pan-y'} + style:touch-action={zoom.z > 1 ? 'none' : columns ? 'pan-x pan-y' : 'pan-y'} onpointerdown={onPointerDown} onpointermove={onPointerMove} onpointerup={onPointerUp} @@ -337,110 +378,113 @@ {/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} + {#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} +
+ +
+ {/if} +
+
- {/if} -
- + +
+ {/if} +
+ {#if li < projectStore.lines.length - 1} + {@const lowerLine = projectStore.lines[li + 1]!} + -
- {#if !readonly} -
- -
{/if} -
- {#if li < projectStore.lines.length - 1} - {@const lowerLine = projectStore.lines[li + 1]!} - - {/if} - {/each} - {#if !readonly} -
- -
- {/if} + +
+ {/if} + {#if hideChrome}

Created with diff --git a/bitext/src/lib/components/preview/LinePairGapSlider.svelte b/bitext/src/lib/components/preview/LinePairGapSlider.svelte index 92e31cb..de37402 100644 --- a/bitext/src/lib/components/preview/LinePairGapSlider.svelte +++ b/bitext/src/lib/components/preview/LinePairGapSlider.svelte @@ -9,6 +9,7 @@ lowerLineId, previewDark = false, axis = 'rows', + gapScale = 1, showControls = true }: { upperLineId: string; @@ -16,6 +17,8 @@ previewDark?: boolean; /** `rows` reserves gap height between stacked rows; `columns` reserves width between columns. */ axis?: LayoutAxis; + /** Auto-fit shrink applied to the reserved gap; the control keeps showing the set value. */ + gapScale?: number; /** When false, only reserve the gap (gallery / headless render). */ showControls?: boolean; } = $props(); @@ -27,6 +30,7 @@ void projectStore.linePairGaps; return projectStore.lineGapPxBetween(upperLineId, lowerLineId); }); + const reservedPx = $derived(gapPx * gapScale); + - - {/each} + style:margin-top="{wi === 0 ? 0 : gapWordPx}px" + > + {#each word as { token, index }, k (token.id)} + + 0} + joinTightEnd={k < word.length - 1} + /> + + {/each} + + {/each} + {:else} + {#each tokens as t, i (t.id)} + {@const nextTok = tokens[i + 1]} + + + + {/each} + {/if} diff --git a/bitext/src/lib/domain/autofit.test.ts b/bitext/src/lib/domain/autofit.test.ts index 781ca49..7fdbf94 100644 --- a/bitext/src/lib/domain/autofit.test.ts +++ b/bitext/src/lib/domain/autofit.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { chromeScale, computeAutoFitScales, scalesChanged } from './autofit.js'; +import { + chromeScale, + computeAutoFitScales, + computeWidthFitCap, + scalesChanged, + WIDTH_FIT_MIN_SCALE +} from './autofit.js'; describe('computeAutoFitScales', () => { it('leaves lines that fit at scale 1', () => { @@ -75,6 +81,39 @@ describe('computeAutoFitScales', () => { }); }); +describe('computeWidthFitCap', () => { + /** A diagram whose width is `fixed` px of buttons plus `scalable` px at scale 1. */ + const widthAt = (cap: number, fixed: number, scalable: number) => fixed + scalable * cap; + + it('settles on a cap that fits the width when part of it does not scale', () => { + const avail = 1000; + let cap = 1; + for (let pass = 0; pass < 10; pass++) { + cap = computeWidthFitCap(cap, widthAt(cap, 300, 1100), avail); + } + expect(widthAt(cap, 300, 1100)).toBeLessThanOrEqual(avail + 0.5); + expect(widthAt(cap, 300, 1100)).toBeGreaterThan(avail - 5); + }); + + it('grows back to 1 once the diagram fits, without overshooting the width', () => { + const avail = 1000; + let cap = WIDTH_FIT_MIN_SCALE; + for (let pass = 0; pass < 10; pass++) { + cap = computeWidthFitCap(cap, widthAt(cap, 300, 600), avail); + expect(widthAt(cap, 300, 600)).toBeLessThanOrEqual(avail); + } + expect(cap).toBe(1); + }); + + it('stops at the minimum scale when even that cannot fit', () => { + expect(computeWidthFitCap(0.6, 5000, 400)).toBe(WIDTH_FIT_MIN_SCALE); + }); + + it('keeps the previous cap while nothing is measured yet', () => { + expect(computeWidthFitCap(0.6, 0, 400)).toBe(0.6); + }); +}); + describe('chromeScale', () => { it('is 1 when text is full size and shrinks partially otherwise', () => { expect(chromeScale(1, 0.4)).toBe(1); diff --git a/bitext/src/lib/domain/autofit.ts b/bitext/src/lib/domain/autofit.ts index 4629aaa..92223da 100644 --- a/bitext/src/lib/domain/autofit.ts +++ b/bitext/src/lib/domain/autofit.ts @@ -51,6 +51,32 @@ export function computeAutoFitScales( return out; } +/** + * Width fitting stops here and leaves the rest to scrolling. On a phone the line buttons alone can + * be wider than the frame, and shrinking text further would make it unreadable without making the + * diagram fit. + */ +export const WIDTH_FIT_MIN_SCALE = 0.5; + +/** + * Column mode also has to fit the frame's width, because lines sit side by side. Returns the + * uniform cap on every line's scale (also applied to the gaps between columns) that brings + * `extent`, the diagram width measured at the current cap `prev`, down to `avail`. + * + * Buttons and other chrome do not scale, so a single step undershoots the shrink; the caller + * re-measures and steps again until the cap settles. Growing back never overshoots for the same + * reason. + */ +export function computeWidthFitCap( + prev: number, + extent: number, + avail: number, + min = WIDTH_FIT_MIN_SCALE +): number { + if (extent <= 0 || avail <= 0) return prev; + return clamp((prev * avail) / extent, min, 1); +} + /** * 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, diff --git a/bitext/src/lib/domain/tokens.test.ts b/bitext/src/lib/domain/tokens.test.ts index e3e04e9..5f59018 100644 --- a/bitext/src/lib/domain/tokens.test.ts +++ b/bitext/src/lib/domain/tokens.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { tokenize, tokenizeOptionsFromVisualSettings } from './tokens.js'; +import { groupJoinedTokens, tokenize, tokenizeOptionsFromVisualSettings } from './tokens.js'; const base = () => tokenizeOptionsFromVisualSettings({ @@ -102,3 +102,27 @@ describe('tokenize', () => { expect(t3.map((x) => x.text)).toEqual(['Hi', ',', 'there']); }); }); + +describe('groupJoinedTokens', () => { + const opts = () => ({ ...base(), splitChars: '|' }); + const words = (raw: string) => + groupJoinedTokens(tokenize(raw, 'L', opts())).map((w) => + w.map(({ token, index }) => `${index}:${token.text}`) + ); + + it('keeps each joined morpheme with the word it attaches to', () => { + expect(words('ĥuʒaba|x la|x|y forêt')).toEqual([ + ['0:ĥuʒaba', '1:x'], + ['2:la', '3:x', '4:y'], + ['5:forêt'] + ]); + }); + + it('starts a word at a leading joined token instead of dropping it', () => { + expect(words('|x la')).toEqual([['0:x'], ['1:la']]); + }); + + it('returns no words for an empty line', () => { + expect(groupJoinedTokens([])).toEqual([]); + }); +}); diff --git a/bitext/src/lib/domain/tokens.ts b/bitext/src/lib/domain/tokens.ts index 5457b0c..e2eacef 100644 --- a/bitext/src/lib/domain/tokens.ts +++ b/bitext/src/lib/domain/tokens.ts @@ -166,6 +166,23 @@ export function tokenize(raw: string, lineId: string, opts: TokenizeOptions): To return out; } +export interface IndexedToken { + token: Token; + /** Position of the token in its line. */ + index: number; +} + +/** Split a line's tokens into words: each token plus the tokens joined to it with `joinLeft`. */ +export function groupJoinedTokens(tokens: Token[]): IndexedToken[][] { + const words: IndexedToken[][] = []; + tokens.forEach((token, index) => { + const current = words[words.length - 1]; + if (current && token.joinLeft) current.push({ token, index }); + else words.push([{ token, index }]); + }); + return words; +} + /** * Preserve token ids when text edits add/remove words. */ diff --git a/bitext/src/routes/+page.svelte b/bitext/src/routes/+page.svelte index c2c62bf..4ae67d9 100644 --- a/bitext/src/routes/+page.svelte +++ b/bitext/src/routes/+page.svelte @@ -177,7 +177,7 @@

-
+