diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 0e510bb..989ff51 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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 diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 448de8c..cecd013 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -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`). Бтатистика (Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ρƒ постоянных Π°ΠΊΠΊΠ°ΡƒΠ½Ρ‚ΠΎΠ²): ΠΏΠΎΠ±Π΅Π΄Ρ‹, пораТСния, Π½ΠΈΡ‡ΡŒΠΈ, макс. ΠΎΡ‡ΠΊΠΎΠ² Π·Π° ΠΏΠ°Ρ€Ρ‚ΠΈΡŽ ΠΈ макс. ΠΎΡ‡ΠΊΠΎΠ² Π·Π° ΠΎΠ΄ΠΈΠ½ Ρ…ΠΎΠ΄ (Π»ΡƒΡ‡ΡˆΠΈΠΉ Ρ…ΠΎΠ΄, ΡƒΠΆΠ΅ Π²ΠΊΠ»ΡŽΡ‡Π°ΡŽΡ‰ΠΈΠΉ всС ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½Π½Ρ‹Π΅ ΠΈΠΌ слова ΠΈ бонус Π·Π° всС Ρ„ΠΈΡˆΠΊΠΈ). Π’Π°ΠΊΠΆΠ΅ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°ΡŽΡ‚ΡΡ **число Ρ…ΠΎΠ΄ΠΎΠ²** ΠΈΠ³Ρ€ΠΎΠΊΠ° (Π΅Π³ΠΎ Π²Ρ‹ΠΊΠ»Π°Π΄ΠΊΠΈ β€” пасы ΠΈ ΠΎΠ±ΠΌΠ΅Π½Ρ‹ Π½Π΅ ΡΡ‡ΠΈΡ‚Π°ΡŽΡ‚ΡΡ) ΠΈ diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index e0a7ed6..b9b32ee 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -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 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 / diff --git a/ui/e2e/export.spec.ts b/ui/e2e/export.spec.ts index f5d7154..fae1798 100644 --- a/ui/e2e/export.spec.ts +++ b/ui/e2e/export.spec.ts @@ -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 { 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 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(); }); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f2eba4a..e531e96 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -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 , 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 // ): 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} (exportOpen = false)}>
- + {#if exportImageAvailable} + + {/if} - {/if} - -{/if} -