fix(game): own export chooser modal; PNG option outside in-app webviews only
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 1m26s

Telegram's native showPopup delivers its callback with no user
activation, so navigator.share (TG iOS) and clipboard writes (TG
Android GCG copy) silently fail from it — the chooser is now always the
app's own modal, keeping the button click's gesture alive for the
delivery APIs.

The data:URL preview modal is dropped: the Android TG/VK long-press
menu mangles data: URLs (dead download, black-screen open, base64
clipboard garbage), so a binary PNG has no working client-side route in
those webviews at all. The image option is withheld there until the
server-rendered signed-URL delivery (Telegram downloadFile /
VKWebAppDownloadFile) lands; the plain web and mobile browsers keep it.

On-device findings by the owner on the test contour (TG iOS, TG
Android, VK Android).
This commit is contained in:
Ilia Denisov
2026-07-02 17:16:05 +02:00
parent a0eacf3011
commit 946420db93
9 changed files with 95 additions and 202 deletions
+9 -7
View File
@@ -322,8 +322,9 @@ Finished games are archived in a dictionary-independent form and exportable in
**two formats behind one 📤 button** — the GCG file and a rendered **PNG image** of
the final position; the export is offered **only once a game is finished**, and
never for an honest-AI practice game (a live game's export would leak the move
journal; an AI game is throwaway). The format chooser is native where the platform
has one (Telegram's popup), the app's own modal elsewhere.
journal; an AI game is throwaway). The format chooser is always the app's **own
modal** — deliberately not Telegram's native popup, whose callback runs without
user activation and silently breaks the share/clipboard delivery it leads to.
The **image** is rendered on the client (Canvas 2D, always the light theme): the
final board with classic coordinate axes A..O / 1..15 on the left — premium squares
@@ -342,11 +343,12 @@ dead space.
Delivery per format: the **GCG** file is Web-Shared where the platform supports it;
on an Android in-app client (Telegram / VK), which has neither Web Share nor a
working file download, it copies the GCG text to the clipboard (with a confirming
toast); otherwise it downloads the file. The **PNG** shares or downloads the same
way where possible, but a binary image has no clipboard-text fallback — those
in-app webviews (and the desktop VK iframe) get a **preview modal** instead: the
image with a long-press / right-click save hint and a copy-image button where the
clipboard-image API exists. Statistics (durable accounts only):
toast); otherwise it downloads the file. The **PNG** is Web-Shared or downloaded the
same way, but a binary image has no clipboard-text fallback at all — so the image
option is currently **withheld inside the in-app webviews** (Telegram / VK) and
offered on the plain web and mobile browsers only; it returns there with the
server-rendered signed-URL delivery (Telegram `downloadFile` /
`VKWebAppDownloadFile`). Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). It
also shows the player's **move count** (their plays — passes and exchanges do not
+9 -7
View File
@@ -331,8 +331,9 @@ Telegram.
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
партия с ИИ одноразовая). Выбор формата — нативный диалог там, где платформа его
даёт (попап Telegram), иначе собственный модал приложения.
партия с ИИ одноразовая). Выбор формата — всегда **собственный модал** приложения,
сознательно не нативный попап Telegram: его коллбек приходит без user activation и
молча ломает доставку через share/буфер, к которой ведёт выбор.
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
@@ -350,11 +351,12 @@ Telegram.
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так
же, где возможно, но у бинарной картинки нет текстового фолбэка через буфер в
этих встроенных webview (и в десктопном iframe VK) вместо него **модал-превью**:
картинка с подсказкой «долгое нажатие / правый клик — сохранить» и кнопкой
копирования картинки там, где есть clipboard-image API. Статистика (только у постоянных аккаунтов):
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так же,
но у бинарной картинки нет текстового фолбэка через буфер вовсе — поэтому пункт
«картинка» пока **скрыт во встроенных webview** (Telegram / VK) и предлагается
только в обычном вебе и мобильных браузерах; туда он вернётся с серверным рендером
и доставкой по подписанному URL (Telegram `downloadFile` /
`VKWebAppDownloadFile`). Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
+10 -8
View File
@@ -406,14 +406,16 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
- **History / export**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); the 📤 in the history header appears
only once the game is finished — never in an honest-AI game (throwaway practice) — and
opens a **format chooser**: Telegram's native popup inside Telegram (image / GCG / cancel),
the app's own modal elsewhere (the accent button leads with the image, the GCG file is the
quiet alternative; GCG needs the connection, the image renders locally). The GCG file goes
by Web Share where available, a clipboard copy in an Android in-app WebView (Telegram / VK,
which has neither Web Share nor a working download), else a Blob download. The image goes by
Web Share / Blob download too, but on those in-app WebViews (and the desktop VK iframe) it
opens a **preview modal** instead — the PNG as a data: URL with a long-press / right-click
save hint and a copy-image button where `ClipboardItem` exists. Confirming a resign reveals
opens the **format chooser**: always the app's own modal, never Telegram's native popup
(a `showPopup` callback carries no user activation, which silently breaks the
`navigator.share` / clipboard delivery it leads to — on-device finding). The accent button
leads with the image where offered, the GCG file is the quiet alternative (GCG needs the
connection, the image renders locally). The GCG file goes by Web Share where available, a
clipboard copy in an Android in-app WebView (Telegram / VK, which has neither Web Share nor
a working download), else a Blob download. The image option shows **only outside the in-app
WebViews** for now (a binary PNG has no working route there — no Web Share, a dead
`<a download>`, a text-only clipboard, and the long-press menu mangles data: URLs); it
returns there with the server-rendered signed-URL delivery. Confirming a resign reveals
the full board: it closes the history drawer (portrait) and zooms the board out.
- **Export image** (`lib/gameimage.ts`, Canvas 2D, dynamically imported): always the light
palette (pinned constants mirroring `app.css`), the final board with classic axes A..O /
+23 -26
View File
@@ -1,10 +1,13 @@
import { expect, test, type Page } from './fixtures';
// The finished-game export: the history header's 📤 button opens a format chooser —
// the GCG file or the rendered PNG image (lib/gameimage) — as a native Telegram popup
// inside Telegram and as the app's own modal elsewhere. g3 ("vs Rick") is the seeded
// finished game. Web Share is force-disabled per test so every engine deterministically
// exercises the download (plain browser) or preview (in-app webview) branch.
// The finished-game export: the history header's 📤 button opens the app's own format
// chooser modal — never Telegram's native popup, whose callback runs without user
// activation and so kills navigator.share / clipboard writes (verified on-device).
// The PNG option is offered only outside the in-app WebViews (no working binary
// delivery there until the server-rendered signed-URL path lands); the GCG file is
// always offered. g3 ("vs Rick") is the seeded finished game. Web Share is
// force-disabled per test so both engines deterministically exercise the download
// branch.
async function disableWebShare(page: Page): Promise<void> {
await page.addInitScript(() => {
@@ -27,7 +30,7 @@ test('the export chooser downloads the PNG image on a plain browser', async ({ p
await openFinishedHistory(page);
await page.getByRole('button', { name: 'Export game' }).click();
// The app's own chooser modal (not inside Telegram) offers both formats.
// The app's own chooser modal offers both formats on the plain web.
await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible();
const download = page.waitForEvent('download');
await page.getByRole('button', { name: 'Image (PNG)' }).click();
@@ -44,13 +47,13 @@ test('the export chooser downloads the GCG file on a plain browser', async ({ pa
expect((await download).suggestedFilename()).toMatch(/^game-.+\.gcg$/);
});
test('inside Telegram the chooser is the native popup and the image lands in the preview modal', async ({
test('inside Telegram the chooser is the app modal and withholds the image option', async ({
page,
}) => {
await disableWebShare(page);
// A Telegram WebApp stub whose showPopup records its params and picks the image option —
// the native-chooser path. With no Web Share in the webview, the image must land in the
// long-press preview modal (the Android Telegram delivery), never a dead <a download>.
// A Telegram WebApp stub with showPopup present: were the chooser still native, it would
// be called — the spy locks the regression (its activation-less callback breaks share and
// clipboard delivery on real devices).
await page.addInitScript(() => {
Object.assign(window, {
Telegram: {
@@ -59,9 +62,9 @@ test('inside Telegram the chooser is the native popup and the image lands in the
initDataUnsafe: {},
ready() {},
expand() {},
showPopup(params: unknown, cb: (id: string) => void) {
(window as unknown as { __popup?: unknown }).__popup = params;
setTimeout(() => cb('image'), 0);
showPopup(_params: unknown, cb: (id: string) => void) {
(window as unknown as { __popupCalled?: boolean }).__popupCalled = true;
setTimeout(() => cb(''), 0);
},
},
},
@@ -74,17 +77,11 @@ test('inside Telegram the chooser is the native popup and the image lands in the
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Export game' }).click();
// The native popup carried both formats plus cancel (≤3 buttons, Bot API 6.2)
const popup = await page.evaluate(
() => (window as unknown as { __popup?: { buttons?: { id?: string; type?: string }[] } }).__popup,
);
expect(popup?.buttons?.map((b) => b.id ?? b.type)).toEqual(['image', 'gcg', 'cancel']);
// …and no in-app chooser modal was mounted.
await expect(page.getByRole('button', { name: 'GCG file' })).toHaveCount(0);
// The preview modal shows the rendered PNG as a data: URL (long-press-saveable) + the hint.
const img = page.locator('.export-preview');
await expect(img).toBeVisible();
expect(await img.getAttribute('src')).toMatch(/^data:image\/png/);
await expect(page.locator('.export-hint')).toBeVisible();
// The app's own modal opened with the GCG option only — no PNG in an in-app WebView
await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Image (PNG)' })).toHaveCount(0);
// …and the native popup was never used for the chooser.
expect(
await page.evaluate(() => (window as unknown as { __popupCalled?: boolean }).__popupCalled),
).toBeFalsy();
});
+22 -68
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 { blobToDataURL, canCopyImage, copyImageToClipboard, shareOrDownloadGcg, shareOrDownloadImage } 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';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm, telegramShowPopup } from '../lib/telegram';
import { insideTelegram, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
import { haptic } from '../lib/haptics';
import {
BLANK,
@@ -928,26 +928,18 @@
}
// 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).
// 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);
let exportPreviewUrl = $state('');
let exportBlob: Blob | null = null;
// 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()));
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;
}
function onExportClick() {
exportOpen = true;
}
@@ -963,38 +955,21 @@
}
// 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.
// 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' });
const outcome = await shareOrDownloadImage(file, insideTelegram() || insideVK());
if (outcome === 'preview') {
exportBlob = blob;
exportPreviewUrl = await blobToDataURL(blob);
}
await shareOrDownloadImage(file);
} 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.
@@ -1512,11 +1487,13 @@
{#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>
{#if exportImageAvailable}
<button class="confirm" onclick={() => { exportOpen = false; void exportImage(); }}>
{t('game.exportImageOpt')}
</button>
{/if}
<button
class="export-alt"
class={exportImageAvailable ? 'export-alt' : 'confirm'}
onclick={() => { exportOpen = false; void exportGcg(); }}
disabled={!connection.online}
>
@@ -1526,16 +1503,6 @@
</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;
@@ -1939,19 +1906,6 @@
.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,
-4
View File
@@ -311,12 +311,8 @@ export const en = {
'stats.guestHint': 'Sign in to track your statistics.',
'game.exportGcg': 'Export game',
'game.exportChoice': 'Export the finished game as:',
'game.exportImageOpt': 'Image (PNG)',
'game.exportGcgOpt': 'GCG file',
'game.imageSaveHint': 'Long-press (or right-click) the image to save it.',
'game.imageCopy': 'Copy image',
'game.imageCopied': 'Image copied to the clipboard.',
'game.gcgActiveOnly': 'Available once the game is finished.',
'game.gcgCopied': 'GCG copied to the clipboard.',
'game.addFriendShort': 'Add friend?',
-4
View File
@@ -311,12 +311,8 @@ export const ru: Record<MessageKey, string> = {
'stats.guestHint': 'Войдите, чтобы вести статистику.',
'game.exportGcg': 'Экспорт партии',
'game.exportChoice': 'Экспортировать завершённую партию как:',
'game.exportImageOpt': 'Картинка (PNG)',
'game.exportGcgOpt': 'Файл GCG',
'game.imageSaveHint': 'Чтобы сохранить картинку — долгое нажатие (или правый клик).',
'game.imageCopy': 'Скопировать картинку',
'game.imageCopied': 'Картинка скопирована в буфер обмена.',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.gcgCopied': 'GCG скопирован в буфер обмена.',
'game.addFriendShort': 'В друзья?',
+6 -20
View File
@@ -87,19 +87,13 @@ describe('pickImageDelivery', () => {
const canShareNav = { canShare: () => true, share: async () => {} };
const noShareNav = { canShare: () => false, share: async () => {} };
it('shares when the platform can share files, even in an in-app webview (iOS)', () => {
expect(pickImageDelivery(canShareNav, file, false)).toBe('share');
expect(pickImageDelivery(canShareNav, file, true)).toBe('share');
});
it('previews in an in-app webview that cannot share files (a binary PNG has no clipboard-text fallback)', () => {
expect(pickImageDelivery(noShareNav, file, true)).toBe('preview');
expect(pickImageDelivery(undefined, file, true)).toBe('preview');
it('shares when the platform can share files', () => {
expect(pickImageDelivery(canShareNav, file)).toBe('share');
});
it('downloads on a plain browser without Web Share (desktop)', () => {
expect(pickImageDelivery(noShareNav, file, false)).toBe('download');
expect(pickImageDelivery(undefined, file, false)).toBe('download');
expect(pickImageDelivery(noShareNav, file)).toBe('download');
expect(pickImageDelivery(undefined, file)).toBe('download');
});
});
@@ -121,7 +115,7 @@ describe('shareOrDownloadImage', () => {
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
const { createElement } = stubEnv(true, share);
expect(await shareOrDownloadImage(png(), false)).toBe('shared');
expect(await shareOrDownloadImage(png())).toBe('shared');
expect(share).toHaveBeenCalledOnce();
expect(createElement).not.toHaveBeenCalled();
@@ -130,20 +124,12 @@ describe('shareOrDownloadImage', () => {
it('downloads via an anchor on a desktop browser that cannot share files', async () => {
const { anchor, createElement } = stubEnv(false, vi.fn());
expect(await shareOrDownloadImage(png(), false)).toBe('downloaded');
expect(await shareOrDownloadImage(png())).toBe('downloaded');
expect(createElement).toHaveBeenCalledWith('a');
expect(anchor.click).toHaveBeenCalledOnce();
expect(anchor.download).toBe('game-1.png');
});
it('hands an in-app webview over to the preview modal (no share, no dead download)', async () => {
const { createElement } = stubEnv(false, vi.fn());
expect(await shareOrDownloadImage(png(), true)).toBe('preview');
expect(createElement).not.toHaveBeenCalled();
});
});
describe('pickTextShare', () => {
+16 -58
View File
@@ -81,19 +81,14 @@ function downloadFile(content: Blob | string, filename: string, type = 'applicat
}
/**
* 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.
* pickImageDelivery decides how to deliver the PNG game image: Web Share (with the file)
* wherever it exists — mobile browsers and the iOS Telegram Mini App — else a Blob download.
* The image option is not offered inside the in-app WebViews at all (Android Telegram/VK, the
* desktop VK iframe): a binary PNG has no working route there until the server-rendered
* signed-URL delivery lands, so this decision never sees them. Pure, unit-tested with a mock
* navigator.
*/
export function pickImageDelivery(
nav: ShareNav | undefined,
file: File,
inAppWebView: boolean,
): 'share' | 'download' | 'preview' {
export function pickImageDelivery(nav: ShareNav | undefined, file: File): 'share' | 'download' {
if (
nav &&
typeof nav.canShare === 'function' &&
@@ -102,23 +97,18 @@ export function pickImageDelivery(
) {
return 'share';
}
return inAppWebView ? 'preview' : 'download';
return '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).
* shareOrDownloadImage delivers the rendered game image and reports the route taken. 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'> {
export async function shareOrDownloadImage(file: File): Promise<'shared' | 'downloaded'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
const decision = pickImageDelivery(nav, file, inAppWebView);
if (decision === 'share' && nav) {
if (pickImageDelivery(nav, file) === 'share' && nav) {
try {
await nav.share({ files: [file], title: file.name });
} catch {
@@ -126,40 +116,8 @@ export async function shareOrDownloadImage(
}
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);
});
downloadFile(file, file.name, file.type);
return 'downloaded';
}
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };