Merge pull request 'feat(game): finished-game export as a PNG image behind a format chooser' (#159) from feature/game-export-image into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m0s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m0s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s
This commit was merged in pull request #159.
This commit is contained in:
+72
-2
@@ -22,7 +22,7 @@
|
||||
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 { 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';
|
||||
@@ -931,6 +931,22 @@
|
||||
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, always through the app's own modal. Never Telegram's native popup
|
||||
// here: its callback runs with NO user activation, so navigator.share / clipboard writes
|
||||
// silently fail from it (verified on-device, TG iOS + Android). A real DOM button click in
|
||||
// the modal keeps the gesture alive for the delivery APIs.
|
||||
let exportOpen = $state(false);
|
||||
// The PNG option is withheld in an in-app WebView (Telegram / VK) for now: a binary image
|
||||
// has no working delivery there (no Web Share, a dead <a download>, text-only clipboard,
|
||||
// and the long-press menu mangles data: URLs). It returns with the server-rendered
|
||||
// signed-URL delivery (Telegram downloadFile / VKWebAppDownloadFile).
|
||||
const exportImageAvailable = $derived(!(insideTelegram() || insideVK()));
|
||||
|
||||
function onExportClick() {
|
||||
exportOpen = true;
|
||||
}
|
||||
|
||||
async function exportGcg() {
|
||||
try {
|
||||
const gcg = await gateway.exportGcg(id);
|
||||
@@ -942,6 +958,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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 where the platform
|
||||
// supports it, else a download (only reachable outside the in-app WebViews, see
|
||||
// exportImageAvailable).
|
||||
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' });
|
||||
await shareOrDownloadImage(file);
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -1274,7 +1306,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>
|
||||
@@ -1456,6 +1488,25 @@
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
{#if exportOpen}
|
||||
<Modal title={t('game.exportGcg')} onclose={() => (exportOpen = false)}>
|
||||
<div class="export-opts">
|
||||
{#if exportImageAvailable}
|
||||
<button class="confirm" onclick={() => { exportOpen = false; void exportImage(); }}>
|
||||
{t('game.exportImageOpt')}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
class={exportImageAvailable ? 'export-alt' : 'confirm'}
|
||||
onclick={() => { exportOpen = false; void exportGcg(); }}
|
||||
disabled={!connection.online}
|
||||
>
|
||||
{t('game.exportGcgOpt')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.scoreboard {
|
||||
position: relative;
|
||||
@@ -1840,6 +1891,25 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* --- Landscape (wide) layout ------------------------------------------------------------
|
||||
When the viewport is wider than tall the game lays out as two columns: a left panel (rack,
|
||||
|
||||
Reference in New Issue
Block a user