Image export fixes
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

This commit is contained in:
Kalle 2026-08-02 07:15:41 +03:00
parent fed8de5f68
commit 1280a88d21
6 changed files with 327 additions and 267 deletions

View File

@ -72,6 +72,10 @@ same place as an auto margin would but leaves their text room to breathe.
}
.headerTitle {
/* the export freezes this box at its exact text width, so it has to claim the row's free
space rather than shrink-wrap, or a glyph the raster measures wider gets ellipsised away.
Basis stays auto so the title still contributes its text to the row's intrinsic width */
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: var(--font-lg);
@ -210,10 +214,10 @@ same place as an auto margin would but leaves their text room to breathe.
color: var(--graphic-text-dim);
}
/* no ellipsis: the chips wrap and a username is capped well below the row width, so truncating
here only ever fired on the frozen-width rounding described above */
.playerName {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.weapons {

View File

@ -1,4 +1,4 @@
import { HardDriveDownload } from "lucide-react";
import { HardDriveDownload, Share2 } from "lucide-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useMatches } from "react-router";
@ -16,6 +16,8 @@ import styles from "./ImageExportDialog.module.css";
const EXPORT_SCALE = 1.75;
const COARSE_POINTER_QUERY = "(pointer: coarse)";
type ThemeSelection = "light" | "dark" | "light-custom" | "dark-custom";
const THEME_SELECTIONS = [
@ -87,6 +89,7 @@ function ImageExportDialogContent({
const { htmlThemeClass } = useTheme();
const location = useLocation();
const pageHasCustomTheme = usePageHasCustomTheme();
const isMobile = useIsMobile();
const [themeSelection, setThemeSelection] = React.useState<ThemeSelection>(
() => {
const mode = htmlThemeClass === "light" ? "light" : "dark";
@ -103,7 +106,28 @@ function ImageExportDialogContent({
const qrCodeUrl = `${SENDOU_INK_BASE_URL}${qrCodePath ?? `${location.pathname}${location.search}`}`;
const handleDownload = async () => {
// snapdom re-downloads every image at export time rather than reusing what the preview
// already painted, and silently drops any that fails. Warming them while the preview sits
// idle keeps those fetches from racing the capture's own work for the main thread.
// Runs after every render because settings can mount images that were not there before
// (e.g. the build export's ability chunks); re-running once everything is cached is ~7ms.
React.useEffect(() => {
if (isDownloading) return;
let cancelled = false;
import("@zumer/snapdom").then(({ preCache }) => {
if (cancelled || !frameRef.current) return;
preCache(frameRef.current).catch(() => {});
});
return () => {
cancelled = true;
};
});
const handleExport = async () => {
if (!frameRef.current) return;
setIsDownloading(true);
@ -119,9 +143,13 @@ function ImageExportDialogContent({
embedFonts: true,
// without this snapdom re-encodes images down to their rendered size, making e.g. the tier image look rough
compress: false,
// names ending in a glyph outside the graphic's font (emoji, Greek, ...) get measured with
// one fallback font in the page and another when rasterized, so a box frozen to its exact
// text width ends up an ellipsis short. This re-measures the clone and pins diverging boxes
reconcile: true,
});
await saveImage(blob, `${filename}.png`);
await saveImage(blob, `${filename}.png`, { canShare: isMobile });
} finally {
setIsDownloading(false);
}
@ -151,14 +179,16 @@ function ImageExportDialogContent({
{settings}
</div>
<SendouButton
icon={<HardDriveDownload />}
onPress={handleDownload}
icon={isMobile ? <Share2 /> : <HardDriveDownload />}
onPress={handleExport}
isDisabled={isDownloading}
className="mx-auto"
>
{isDownloading
? t("common:actions.loading")
: t("common:imageExport.download")}
: isMobile
? t("common:actions.share")
: t("common:imageExport.download")}
</SendouButton>
<div className={styles.scroller}>
<div
@ -179,11 +209,18 @@ function ImageExportDialogContent({
);
}
/** Shares the image if the platform supports it (iOS), otherwise downloads it */
async function saveImage(blob: Blob, filename: string) {
/**
* Opens the share sheet when sharing is allowed (mobile) and the platform supports it,
* otherwise downloads the image
*/
async function saveImage(
blob: Blob,
filename: string,
{ canShare }: { canShare: boolean },
) {
const file = new File([blob], filename, { type: blob.type });
if (navigator.canShare?.({ files: [file] })) {
if (canShare && navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ files: [file], title: filename });
return;
@ -203,6 +240,20 @@ async function saveImage(blob: Blob, filename: string) {
URL.revokeObjectURL(url);
}
function subscribeToPointerQuery(callback: () => void) {
const mediaQueryList = window.matchMedia(COARSE_POINTER_QUERY);
mediaQueryList.addEventListener("change", callback);
return () => mediaQueryList.removeEventListener("change", callback);
}
function useIsMobile() {
return React.useSyncExternalStore(
subscribeToPointerQuery,
() => window.matchMedia(COARSE_POINTER_QUERY).matches,
() => false,
);
}
function usePageHasCustomTheme() {
const matches = useMatches();

View File

@ -256,6 +256,8 @@
}
.mateNameText {
/* claims .mateName's free space, see .headerTitle */
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;

File diff suppressed because it is too large Load Diff

View File

@ -53,7 +53,7 @@
"@react-router/serve": "8.1.0",
"@sentry/react-router": "10.67.0",
"@tldraw/tldraw": "3.12.1",
"@zumer/snapdom": "2.16.0",
"@zumer/snapdom": "2.23.1",
"better-sqlite3": "13.0.1",
"chart.js": "4.5.1",
"clsx": "2.1.1",

View File

@ -55,8 +55,8 @@ importers:
specifier: 3.12.1
version: 3.12.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@zumer/snapdom':
specifier: 2.16.0
version: 2.16.0
specifier: 2.23.1
version: 2.23.1
better-sqlite3:
specifier: 13.0.1
version: 13.0.1
@ -2850,9 +2850,8 @@ packages:
'@vitest/utils@4.1.10':
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
'@zumer/snapdom@2.16.0':
resolution: {integrity: sha512-ZfN3o6kcxb+jDhYe/JHFZf3g4UfDv3MGyTiK/sWvFU+vYlxa2KZVLZH7DxhKkobwGp66KMhRnXTqIKmaSgM50Q==}
deprecated: Old versions have known fidelity bugs (text re-wrap, width freezing, KaTeX clipping). Upgrade to ^2.22 — drop-in for any 2.x. https://github.com/zumerlab/snapdom/releases
'@zumer/snapdom@2.23.1':
resolution: {integrity: sha512-r+6LRFXIpzTxlvGBjTr7mhvVW05TozgyE1QFmvO1hbchdbl4Km5/7DIkeyl2+nEVbz8Sp6/K2g4b86pXhCrFIQ==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
@ -7464,7 +7463,7 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@zumer/snapdom@2.16.0': {}
'@zumer/snapdom@2.23.1': {}
accepts@2.0.0:
dependencies: