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
+119 -3
View File
@@ -22,12 +22,12 @@
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share';
import { blobToDataURL, canCopyImage, copyImageToClipboard, shareOrDownloadGcg, shareOrDownloadImage } from '../lib/share';
import { insideVK, vkCopyText } from '../lib/vk';
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
import { haptic } from '../lib/haptics';
import {
BLANK,
@@ -927,6 +927,30 @@
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
// The finished-game export offers two formats — the GCG file and the rendered PNG image —
// behind one 📤 button: a native Telegram popup where available, the app's own modal
// elsewhere (the onResignClick pattern).
let exportOpen = $state(false);
let exportPreviewUrl = $state('');
let exportBlob: Blob | null = null;
async function onExportClick() {
if (insideTelegram() && telegramDialogsAvailable()) {
const choice = await telegramShowPopup({
message: t('game.exportChoice'),
buttons: [
{ id: 'image', type: 'default', text: t('game.exportImageOpt') },
{ id: 'gcg', type: 'default', text: t('game.exportGcgOpt') },
{ type: 'cancel' },
],
});
if (choice === 'image') void exportImage();
else if (choice === 'gcg') void exportGcg();
return;
}
exportOpen = true;
}
async function exportGcg() {
try {
const gcg = await gateway.exportGcg(id);
@@ -938,6 +962,39 @@
}
}
// exportImage renders the finished game as a PNG (lib/gameimage, dynamically imported so the
// renderer stays out of the startup bundle) and delivers it: Web Share / download where they
// work, else the preview modal (Android Telegram/VK WebView and the desktop VK iframe, where
// neither exists for a binary file) with a long-press/right-click save hint.
async function exportImage() {
if (!view) return;
try {
const { renderGameImage } = await import('../lib/gameimage');
const blob = await renderGameImage(view.game, moves, { actionLabel: moveActionLabel });
const file = new File([blob], `game-${id.slice(0, 8)}.png`, { type: 'image/png' });
const outcome = await shareOrDownloadImage(file, insideTelegram() || insideVK());
if (outcome === 'preview') {
exportBlob = blob;
exportPreviewUrl = await blobToDataURL(blob);
}
} catch (e) {
handleError(e);
}
}
function closeExportPreview() {
exportPreviewUrl = '';
exportBlob = null;
}
async function copyExportImage() {
if (exportBlob && (await copyImageToClipboard(exportBlob))) {
showToast(t('game.imageCopied'), 'info');
} else {
showToast(t('error.generic'), 'error');
}
}
// The GCG export copies to the clipboard in an Android in-app WebView (no Web Share, a dead
// <a download>): VKWebAppCopyText inside VK — which also works in the desktop VK iframe, where
// navigator.clipboard is blocked — and navigator.clipboard elsewhere.
@@ -1270,7 +1327,7 @@
icon pinned right (the header is space-between). -->
<span aria-hidden="true"></span>
{:else}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
<button class="hicon" onclick={onExportClick} aria-label={t('game.exportGcg')}>📤</button>
{/if}
{:else}
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
@@ -1452,6 +1509,33 @@
</Modal>
{/if}
{#if exportOpen}
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
<div class="export-opts">
<button class="confirm" onclick={() => { exportOpen = false; void exportImage(); }}>
{t('game.exportImageOpt')}
</button>
<button
class="export-alt"
onclick={() => { exportOpen = false; void exportGcg(); }}
disabled={!connection.online}
>
{t('game.exportGcgOpt')}
</button>
</div>
</Modal>
{/if}
{#if exportPreviewUrl}
<Modal title={t('game.exportImageOpt')} onclose={closeExportPreview}>
<img class="export-preview" src={exportPreviewUrl} alt={t('game.exportGcg')} />
<p class="export-hint">{t('game.imageSaveHint')}</p>
{#if canCopyImage()}
<button class="confirm" onclick={copyExportImage}>{t('game.imageCopy')}</button>
{/if}
</Modal>
{/if}
<style>
.scoreboard {
position: relative;
@@ -1836,6 +1920,38 @@
color: #fff !important;
border-color: var(--danger) !important;
}
/* Export chooser: the image option leads (accent .confirm), the GCG file is the quiet
alternative below it. */
.export-opts {
display: flex;
flex-direction: column;
gap: 8px;
}
.export-alt {
width: 100%;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.export-alt:disabled {
opacity: 0.5;
}
/* Preview modal (webviews with no Web Share / download): the image itself is the artifact —
the user saves it with a long-press (or right-click in the desktop VK iframe). */
.export-preview {
display: block;
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.export-hint {
margin: 8px 0;
color: var(--text-muted);
font-size: 0.85rem;
}
/* --- Landscape (wide) layout ------------------------------------------------------------
When the viewport is wider than tall the game lays out as two columns: a left panel (rack,