feat(game): finished-game export as a PNG image behind a format chooser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 59s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s

The history header's export button now opens a chooser — Telegram's
native popup inside Telegram, the app's own modal elsewhere — offering
the GCG file and a new client-rendered PNG of the final position
(lib/gameimage, Canvas 2D, lazy dynamic import, zero dependencies):
light theme, classic A..O/1..15 axes, label-free premium fills, and a
fixed-typography per-seat scoresheet with GCG-style move coordinates,
multi-word sub-lines, endgame rack-settlement row, winner trophy and a
hostname + device-locale finish date footer; a long game stretches the
board, never the typography.

Delivery mirrors the GCG rules (Web Share with no blob fallback, else
download) except on Android Telegram/VK WebViews and the desktop VK
iframe, where a binary PNG has no clipboard-text fallback: those get a
preview modal with a long-press/right-click save hint and a copy-image
button where ClipboardItem exists.
This commit is contained in:
Ilia Denisov
2026-07-02 16:20:35 +02:00
parent 2ab01ed8f7
commit a0eacf3011
15 changed files with 1097 additions and 36 deletions
+85 -2
View File
@@ -67,9 +67,10 @@ export async function shareOrDownloadGcg(
return 'downloaded';
}
function downloadFile(content: string, filename: string): void {
function downloadFile(content: Blob | string, filename: string, type = 'application/x-gcg'): void {
if (typeof document === 'undefined') return;
const url = URL.createObjectURL(new Blob([content], { type: 'application/x-gcg' }));
const blob = typeof content === 'string' ? new Blob([content], { type }) : content;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
@@ -79,6 +80,88 @@ function downloadFile(content: string, filename: string): void {
URL.revokeObjectURL(url);
}
/**
* pickImageDelivery decides how to deliver the PNG game image. Web Share (with the file) wins
* wherever it exists — mobile browsers and the iOS Telegram Mini App; a plain desktop browser
* downloads the Blob. The remaining webviews (Android Telegram/VK, the desktop VK iframe) can
* neither share a file nor honour <a download>, and unlike the tiny GCG text a binary PNG has
* no clipboard-text fallback — there the caller shows the image in a preview modal ('preview')
* for a long-press/right-click save, with a clipboard-image copy where supported. Pure, so it
* is unit-tested with a mock navigator.
*/
export function pickImageDelivery(
nav: ShareNav | undefined,
file: File,
inAppWebView: boolean,
): 'share' | 'download' | 'preview' {
if (
nav &&
typeof nav.canShare === 'function' &&
typeof nav.share === 'function' &&
nav.canShare({ files: [file] })
) {
return 'share';
}
return inAppWebView ? 'preview' : 'download';
}
/**
* shareOrDownloadImage delivers the rendered game image by the best available route and reports
* which it took — 'shared', 'downloaded' or 'preview' (the caller opens its preview modal). The
* Web Share invariant matches shareOrDownloadGcg: a cancelled or failed share is a deliberate
* no-op, never a Blob-download fallback (an <a download> strands the iOS WKWebView on the blob:
* URL).
*/
export async function shareOrDownloadImage(
file: File,
inAppWebView: boolean,
): Promise<'shared' | 'downloaded' | 'preview'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
const decision = pickImageDelivery(nav, file, inAppWebView);
if (decision === 'share' && nav) {
try {
await nav.share({ files: [file], title: file.name });
} catch {
/* cancelled or failed — intentionally a no-op (see shareOrDownloadGcg) */
}
return 'shared';
}
if (decision === 'download') downloadFile(file, file.name, file.type);
return decision === 'download' ? 'downloaded' : 'preview';
}
/** canCopyImage reports whether the clipboard-image API (ClipboardItem) is available, so the
* preview modal offers a copy button only where it can work. */
export function canCopyImage(): boolean {
return (
typeof navigator !== 'undefined' &&
typeof ClipboardItem !== 'undefined' &&
typeof navigator.clipboard?.write === 'function'
);
}
/** copyImageToClipboard writes the PNG blob to the system clipboard (where canCopyImage). */
export async function copyImageToClipboard(blob: Blob): Promise<boolean> {
if (!canCopyImage()) return false;
try {
await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]);
return true;
} catch {
return false;
}
}
/** blobToDataURL inlines a blob as a data: URL — the preview modal's <img> source (a data URL
* survives long-press "save image" in webviews where a blob: URL may not). */
export function blobToDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => resolve(String(r.result));
r.onerror = () => reject(r.error ?? new Error('blobToDataURL failed'));
r.readAsDataURL(blob);
});
}
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/**