feat(game): finished-game export as a PNG image behind a format chooser #159
+30
-7
@@ -16,7 +16,7 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge
|
||||
real-time in-app updates, switching interface language (en/ru) and theme, and a
|
||||
read-only profile. It also handles managing friends (including one-time friend
|
||||
codes) and blocks, friend-game invitations, editing the profile and binding an
|
||||
email, the statistics screen, and the in-game history viewer with GCG export.
|
||||
email, the statistics screen, and the in-game history viewer with GCG / PNG-image export.
|
||||
Settings also pick the board's bonus-label style (beginner / classic / none). A hint **lays the suggested tiles on the board** for the player to confirm and
|
||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||
@@ -318,12 +318,35 @@ silently ignores their messages) or clear a thread's messages. This is independe
|
||||
Feedback above — it serves people who write to the bot directly rather than through the app.
|
||||
|
||||
### History & statistics
|
||||
Finished games are archived in a dictionary-independent form and exportable to
|
||||
GCG; 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 client shares the `.gcg` file 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 to the clipboard (with a confirming toast); otherwise it downloads the file. Statistics (durable accounts only):
|
||||
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.
|
||||
|
||||
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
|
||||
as plain colour fills, no text labels — and a compact per-seat scoresheet on the
|
||||
right: each seat's name and final score in the header (🏆 by the winner), then one
|
||||
row per move carrying the main word's classic coordinate (an across play is
|
||||
row-first, `8G`; a down play column-first, `H8` — the GCG convention), the word and
|
||||
its points; extra words of a multi-word play ride a smaller second line, non-play
|
||||
moves show as localized notes (pass/exchange/resign/timeout), and a closing ± row
|
||||
shows the endgame rack settlement when there was one (the final scores are
|
||||
authoritative — running totals alone do not include it). The footer stamps the site
|
||||
host and the finish date in the device locale. The scoresheet typography is fixed;
|
||||
a long game stretches the board (never below its minimum) so the image carries no
|
||||
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):
|
||||
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
|
||||
|
||||
+29
-7
@@ -16,7 +16,7 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
профиль только для чтения. Он также включает управление друзьями (в т.ч.
|
||||
одноразовые коды-приглашения) и блоками, дружеские приглашения в игру,
|
||||
редактирование профиля и привязку email, экран статистики и просмотр истории
|
||||
партии с экспортом GCG. В настройках также выбирается стиль подписей бонус-клеток
|
||||
партии с экспортом в GCG / PNG-картинку. В настройках также выбирается стиль подписей бонус-клеток
|
||||
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
|
||||
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
|
||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||
@@ -327,12 +327,34 @@ Telegram.
|
||||
канал для тех, кто пишет боту напрямую, а не через приложение.
|
||||
|
||||
### История и статистика
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
||||
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
|
||||
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает;
|
||||
в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG
|
||||
в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов):
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются в
|
||||
**двух форматах за одной кнопкой 📤** — файл GCG и отрисованная **PNG-картинка**
|
||||
финальной позиции; экспорт доступен **только после завершения партии** и никогда —
|
||||
для тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а
|
||||
партия с ИИ одноразовая). Выбор формата — нативный диалог там, где платформа его
|
||||
даёт (попап Telegram), иначе собственный модал приложения.
|
||||
|
||||
**Картинка** рендерится на клиенте (Canvas 2D, всегда светлая тема): финальная
|
||||
доска с классическими осями координат A..O / 1..15 слева — бонус-клетки чистой
|
||||
цветовой заливкой, без текстовых подписей — и компактная таблица ходов по местам
|
||||
справа: в шапке имя и финальный счёт каждого места (🏆 у победителя), далее по
|
||||
строке на ход: классическая координата главного слова (горизонтальный ход —
|
||||
цифра первой, `8G`; вертикальный — буква первой, `H8` — конвенция GCG), слово и
|
||||
его очки; дополнительные слова мультисловного хода — второй мелкой строкой,
|
||||
не-игровые ходы — локализованными пометками (пас/обмен/сдался/таймаут), а
|
||||
замыкающая строка ± показывает концовочную поправку за рэк, когда она была
|
||||
(финальные счета авторитетны — суммы ходов её не содержат). В подвале — хост сайта
|
||||
и дата завершения в локали устройства. Типографика таблицы фиксирована; длинная
|
||||
партия растягивает доску (не ниже минимума), так что пустот на картинке нет.
|
||||
|
||||
Доставка по форматам: файлом **GCG** клиент делится через Web Share там, где
|
||||
платформа это поддерживает; в Android-приложении (Telegram / VK), где нет ни Web
|
||||
Share, ни рабочей загрузки файла, копирует текст GCG в буфер обмена (с
|
||||
подтверждающим тостом); иначе скачивает файл. **PNG** делится и скачивается так
|
||||
же, где возможно, но у бинарной картинки нет текстового фолбэка через буфер — в
|
||||
этих встроенных webview (и в десктопном iframe VK) вместо него **модал-превью**:
|
||||
картинка с подсказкой «долгое нажатие / правый клик — сохранить» и кнопкой
|
||||
копирования картинки там, где есть clipboard-image API. Статистика (только у постоянных аккаунтов):
|
||||
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
|
||||
ход, уже включающий все образованные им слова и бонус за все фишки). Также
|
||||
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
|
||||
|
||||
+25
-8
@@ -403,15 +403,32 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
||||
lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
|
||||
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
|
||||
Safari.
|
||||
- **History / GCG**: 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); *Export GCG* (the 📤 in the history
|
||||
header) delivers the `.gcg` file — 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 —
|
||||
and appears only once the game is finished — and
|
||||
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
|
||||
it closes the history drawer (portrait) and zooms the board out.
|
||||
- **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
|
||||
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 /
|
||||
1..15, premium squares as plain colour fills (no text labels), tiles in the in-game style
|
||||
(bevel, letter top-left, value bottom-right, ✻ for an Erudit blank), no last-move
|
||||
highlight. The right column is the scoresheet: per-seat name over the final score (🏆 by
|
||||
the winner), one fixed-typography row per move — muted classic coordinate (across =
|
||||
row-first `8G`, down = column-first `H8`), the main word, points right-aligned; extra
|
||||
words of a multi-word play on a smaller second line; italic localized notes for
|
||||
pass/exchange/resign/timeout; a closing muted ± row for the endgame rack settlement when
|
||||
present. Footer: `hostname · finish date` in the **device** locale. The scoresheet's
|
||||
height drives the board side (never below its minimum): a long game stretches the board,
|
||||
never the typography, so the image has no dead space.
|
||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||
offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
|
||||
offers *Export game* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
|
||||
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
|
||||
row is turn-driven: on your turn the message field shows until your one allowed message is
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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.
|
||||
|
||||
async function disableWebShare(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'canShare', { value: () => false, configurable: true });
|
||||
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function openFinishedHistory(page: Page): Promise<void> {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /Rick/ }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
||||
}
|
||||
|
||||
test('the export chooser downloads the PNG image on a plain browser', async ({ page }) => {
|
||||
await disableWebShare(page);
|
||||
await openFinishedHistory(page);
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
|
||||
// The app's own chooser modal (not inside Telegram) offers both formats.
|
||||
await expect(page.getByRole('button', { name: 'GCG file' })).toBeVisible();
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'Image (PNG)' }).click();
|
||||
expect((await download).suggestedFilename()).toMatch(/^game-.+\.png$/);
|
||||
});
|
||||
|
||||
test('the export chooser downloads the GCG file on a plain browser', async ({ page }) => {
|
||||
await disableWebShare(page);
|
||||
await openFinishedHistory(page);
|
||||
await page.getByRole('button', { name: 'Export game' }).click();
|
||||
|
||||
const download = page.waitForEvent('download');
|
||||
await page.getByRole('button', { name: 'GCG file' }).click();
|
||||
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 ({
|
||||
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>.
|
||||
await page.addInitScript(() => {
|
||||
Object.assign(window, {
|
||||
Telegram: {
|
||||
WebApp: {
|
||||
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
|
||||
initDataUnsafe: {},
|
||||
ready() {},
|
||||
expand() {},
|
||||
showPopup(params: unknown, cb: (id: string) => void) {
|
||||
(window as unknown as { __popup?: unknown }).__popup = params;
|
||||
setTimeout(() => cb('image'), 0);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible(); // auto-authenticated lobby
|
||||
await page.getByRole('button', { name: /Rick/ }).click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
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();
|
||||
});
|
||||
@@ -80,7 +80,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page }
|
||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
|
||||
// no comms entry at all.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -154,14 +154,14 @@ test('GCG export appears only for a finished game', async ({ page }) => {
|
||||
// The finished game vs Kaya exposes export 📤 in the history header.
|
||||
await page.getByRole('button', { name: /Kaya/ }).click();
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('GCG export is hidden for an active game', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export)
|
||||
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Export game' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('finished game draws an inert footer and trims live-only controls', async ({ page }) => {
|
||||
@@ -172,7 +172,7 @@ test('finished game draws an inert footer and trims live-only controls', async (
|
||||
await expect(page.locator('.tab').first()).toBeDisabled();
|
||||
// The history header offers Export GCG, not Drop game, once the game is over.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export game' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0);
|
||||
// The comms hub offers Chat only — the Dictionary tab is hidden for a finished game.
|
||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬
|
||||
|
||||
+119
-3
@@ -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,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildScoresheet, layoutImage, formatFinishedDate, MIN_SIDE } from './gameimage';
|
||||
import type { GameView, MoveRecord, Seat } from './model';
|
||||
|
||||
function seat(n: number, name: string, score: number, winner = false): Seat {
|
||||
return { seat: n, accountId: `a${n}`, displayName: name, score, hintsUsed: 0, isWinner: winner };
|
||||
}
|
||||
|
||||
function game(seats: Seat[], over = true): GameView {
|
||||
return {
|
||||
id: 'g1',
|
||||
variant: 'scrabble_ru',
|
||||
dictVersion: 'v1',
|
||||
vsAi: false,
|
||||
unreadChat: false,
|
||||
unreadMessages: false,
|
||||
status: over ? 'finished' : 'active',
|
||||
players: seats.length,
|
||||
toMove: 0,
|
||||
turnTimeoutSecs: 300,
|
||||
multipleWordsPerTurn: false,
|
||||
moveCount: 0,
|
||||
endReason: over ? 'standard' : '',
|
||||
lastActivityUnix: 1_782_997_629, // 2026-07-02 (UTC)
|
||||
seats,
|
||||
};
|
||||
}
|
||||
|
||||
function play(player: number, words: string[], score: number, total: number, dir: 'H' | 'V' = 'H'): MoveRecord {
|
||||
return { player, action: 'play', dir, mainRow: 7, mainCol: 7, tiles: [], words, count: words.length, score, total };
|
||||
}
|
||||
|
||||
// A non-play move carries the seat's current running total on the wire (engine game.go
|
||||
// sets Total: g.scores[player] on pass/exchange/resign) — the fixture mirrors that.
|
||||
function action(player: number, act: 'pass' | 'exchange' | 'resign', total = 0): MoveRecord {
|
||||
return { player, action: act, dir: '', mainRow: 0, mainCol: 0, tiles: [], words: [], count: 0, score: 0, total };
|
||||
}
|
||||
|
||||
const label = (a: string) => `<${a}>`;
|
||||
|
||||
describe('buildScoresheet', () => {
|
||||
it('builds per-seat header, coordinated play rows and localized actions', () => {
|
||||
const g = game([seat(0, 'Аня', 118, true), seat(1, 'Боб', 92)]);
|
||||
const moves = [
|
||||
play(0, ['ГРОТ'], 24, 24),
|
||||
action(1, 'exchange'),
|
||||
play(0, ['МЫС'], 18, 42, 'V'),
|
||||
play(1, ['СОН'], 12, 12),
|
||||
];
|
||||
const s = buildScoresheet(g, moves, label, { hostname: 'erudit-game.ru', dateLocale: 'ru' });
|
||||
|
||||
expect(s.header).toEqual([
|
||||
{ name: 'Аня', score: 118, winner: true },
|
||||
{ name: 'Боб', score: 92, winner: false },
|
||||
]);
|
||||
expect(s.rows).toHaveLength(2);
|
||||
expect(s.rows[0][0]).toMatchObject({ kind: 'play', coord: '8H', words: ['ГРОТ'], score: 24 });
|
||||
expect(s.rows[0][1]).toMatchObject({ kind: 'action', label: '<exchange>' });
|
||||
expect(s.rows[1][0]).toMatchObject({ kind: 'play', coord: 'H8' }); // vertical → letter first
|
||||
expect(s.rows[1][1]).toMatchObject({ kind: 'play', coord: '8H', words: ['СОН'] });
|
||||
expect(s.footer.startsWith('erudit-game.ru · ')).toBe(true);
|
||||
expect(s.footer).toContain('2026');
|
||||
});
|
||||
|
||||
it('derives the endgame rack settlement from final scores vs last running totals', () => {
|
||||
// Seat 0 went out (+6 from the opponent rack), seat 1 lost its rack value (−6).
|
||||
const g = game([seat(0, 'A', 48, true), seat(1, 'B', 24)]);
|
||||
const moves = [play(0, ['X'], 42, 42), play(1, ['Y'], 30, 30)];
|
||||
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
|
||||
expect(s.adjustments).toEqual([6, -6]);
|
||||
expect(s.hasAdjustments).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no settlement when the final scores match the journal (resignation)', () => {
|
||||
const g = game([seat(0, 'A', 42, true), seat(1, 'B', 30)]);
|
||||
const moves = [play(0, ['X'], 42, 42), play(1, ['Y'], 30, 30), action(1, 'resign', 30)];
|
||||
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
|
||||
expect(s.adjustments).toEqual([0, 0]);
|
||||
expect(s.hasAdjustments).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps one column per seat for a four-player game', () => {
|
||||
const g = game([seat(0, 'A', 10), seat(1, 'B', 20, true), seat(2, 'C', 5), seat(3, 'D', 0)]);
|
||||
const moves = [play(0, ['X'], 10, 10), play(1, ['Y'], 20, 20), play(2, ['Z'], 5, 5)];
|
||||
const s = buildScoresheet(g, moves, label, { hostname: 'h' });
|
||||
expect(s.header).toHaveLength(4);
|
||||
expect(s.rows[0]).toHaveLength(4);
|
||||
expect(s.rows[0][3]).toEqual({ kind: 'empty' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('layoutImage', () => {
|
||||
const short = (seats = 2) =>
|
||||
buildScoresheet(
|
||||
game(Array.from({ length: seats }, (_, i) => seat(i, `P${i}`, 0))),
|
||||
[play(0, ['ГРОТ'], 24, 24)],
|
||||
label,
|
||||
{ hostname: 'h' },
|
||||
);
|
||||
|
||||
it('clamps the board to MIN_SIDE for a short game', () => {
|
||||
const l = layoutImage(short());
|
||||
expect(l.board.side).toBe(MIN_SIDE);
|
||||
expect(l.board.cell).toBeCloseTo(MIN_SIDE / 15);
|
||||
});
|
||||
|
||||
it('stretches the board (and the image) to a long scoresheet, keeping the typography fixed', () => {
|
||||
const moves: MoveRecord[] = [];
|
||||
for (let i = 0; i < 80; i++) moves.push(play(i % 2, ['СЛОВО'], 10, 10 * i));
|
||||
const s = buildScoresheet(game([seat(0, 'A', 300, true), seat(1, 'B', 290)]), moves, label, { hostname: 'h' });
|
||||
const l = layoutImage(s);
|
||||
expect(l.board.side).toBeGreaterThan(MIN_SIDE);
|
||||
// The column content exactly fills the board height (that is the stretch contract).
|
||||
const content =
|
||||
l.headNameH + l.headScoreH + l.headRuleH + l.rowHeights.reduce((a, b) => a + b, 0) + l.adjRowH;
|
||||
expect(l.board.side).toBe(Math.ceil(content));
|
||||
expect(l.h).toBe(l.margin + l.axis + l.board.side + l.footerH + l.margin);
|
||||
});
|
||||
|
||||
it('gives a row the extra sub-line only when a play formed several words', () => {
|
||||
const s = buildScoresheet(
|
||||
game([seat(0, 'A', 0), seat(1, 'B', 0)]),
|
||||
[play(0, ['ОСА', 'ОМ'], 12, 12), play(1, ['ДОМ'], 8, 8)],
|
||||
label,
|
||||
{ hostname: 'h' },
|
||||
);
|
||||
const l = layoutImage(s);
|
||||
expect(l.rowHeights[0]).toBe(l.rowH + l.subRowH);
|
||||
expect(l.rowHeights).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('widens the sheet per seat', () => {
|
||||
const two = layoutImage(short(2));
|
||||
const four = layoutImage(short(4));
|
||||
expect(four.hist.w).toBeGreaterThan(two.hist.w);
|
||||
expect(four.w - four.hist.w).toBe(two.w - two.hist.w); // board block unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFinishedDate', () => {
|
||||
it('formats with the given locale (regional convention, long month)', () => {
|
||||
const s = formatFinishedDate(1_782_997_629, 'ru');
|
||||
expect(s).toContain('2026');
|
||||
expect(s).toMatch(/июн|июл/); // TZ-dependent day, but always mid-summer
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
// Finished-game PNG export: a light-theme snapshot of the final board (classic A..O / 1..15
|
||||
// axes) with a compact per-seat scoresheet beside it. Everything is drawn by hand on a
|
||||
// Canvas 2D — no dependencies — and the module is reached only through a dynamic import, so
|
||||
// it stays out of the startup bundle. The scoresheet typography is fixed; the board instead
|
||||
// stretches to the scoresheet's height (never below MIN_SIDE), so a long game grows the
|
||||
// board rather than leaving a hole under it.
|
||||
//
|
||||
// buildScoresheet and layoutImage are pure (vitest-covered); renderGameImage is the thin
|
||||
// canvas pass exercised by the Playwright e2e.
|
||||
|
||||
import type { GameView, MoveRecord, Variant } from './model';
|
||||
import { replay } from './board';
|
||||
import { historyGrid, type HistoryCell } from './history';
|
||||
import { premiumGrid, centre, BOARD_SIZE } from './premiums';
|
||||
import { valueForLetter } from './alphabet';
|
||||
|
||||
// The light-theme palette, mirrored from ui/src/app.css :root — the export is always light
|
||||
// regardless of the app theme, and a canvas cannot resolve CSS custom properties, so the
|
||||
// values are pinned here. Keep in sync with app.css.
|
||||
const C = {
|
||||
paper: '#ffffff',
|
||||
text: '#14181f',
|
||||
muted: '#6b7280',
|
||||
border: '#d8dce2',
|
||||
cell: '#e7ece8',
|
||||
cellDark: '#d9deda', // color-mix(in srgb, --cell-bg 94%, #000)
|
||||
tile: '#f4e2b8',
|
||||
tileEdge: '#d8c190',
|
||||
tileText: '#2a2113',
|
||||
premText: '#2a2113',
|
||||
prem: { TW: '#e06a5b', DW: '#efa6a0', TL: '#4f8fd6', DL: '#a8cdec' } as Record<string, string>,
|
||||
};
|
||||
|
||||
const FONT = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif';
|
||||
|
||||
/** One scoresheet cell: a play (classic coordinate + words + points), a localized non-play
|
||||
* action, or an empty filler keeping the seat columns aligned. */
|
||||
export type SheetCell =
|
||||
| { kind: 'play'; coord: string; words: string[]; score: number }
|
||||
| { kind: 'action'; label: string }
|
||||
| { kind: 'empty' };
|
||||
|
||||
/** Scoresheet is the pure content of the image's right column: one column per seat. */
|
||||
export interface Scoresheet {
|
||||
/** Per seat: display name, final (adjusted) score, winner flag. */
|
||||
header: { name: string; score: number; winner: boolean }[];
|
||||
/** Row-major move grid, one column per seat (row k = each seat's k-th move). */
|
||||
rows: SheetCell[][];
|
||||
/** Per-seat endgame rack settlement: final Seat.score minus the seat's last running
|
||||
* total. The journal carries no adjustment move (engine applyEndAdjustment folds it
|
||||
* into the final score only), so the sheet shows it as a closing ± row. */
|
||||
adjustments: number[];
|
||||
hasAdjustments: boolean;
|
||||
/** Bottom line: "<hostname> · <finish date>". */
|
||||
footer: string;
|
||||
}
|
||||
|
||||
/** formatFinishedDate renders the game's finish time for the footer in the DEVICE locale
|
||||
* (regional date conventions, not the UI language) — e.g. "3 июля 2026 г., 16:23".
|
||||
* dateLocale is for tests; production passes undefined = the system default. */
|
||||
export function formatFinishedDate(unixSec: number, dateLocale?: string): string {
|
||||
return new Intl.DateTimeFormat(dateLocale, {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(unixSec * 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* buildScoresheet lays the finished game's journal out as the export scoresheet.
|
||||
* actionLabel localizes a non-play move (the caller passes the same mapping the in-game
|
||||
* history uses); hostname/dateLocale feed the footer (tests pin them, production uses
|
||||
* location.hostname and the device locale).
|
||||
*/
|
||||
export function buildScoresheet(
|
||||
game: GameView,
|
||||
moves: MoveRecord[],
|
||||
actionLabel: (action: string) => string,
|
||||
opts?: { hostname?: string; dateLocale?: string },
|
||||
): Scoresheet {
|
||||
const seats = game.seats;
|
||||
const grid = historyGrid(moves, seats.length, null);
|
||||
const rows = grid.map((row) =>
|
||||
row.map((c: HistoryCell): SheetCell => {
|
||||
if (c.kind === 'play') {
|
||||
return { kind: 'play', coord: c.coord ?? '', words: c.words ?? [], score: c.score ?? 0 };
|
||||
}
|
||||
if (c.kind === 'action') return { kind: 'action', label: actionLabel(c.action ?? '') };
|
||||
return { kind: 'empty' };
|
||||
}),
|
||||
);
|
||||
// The last running total each seat reached in the journal; the final Seat.score differs
|
||||
// from it exactly by the endgame rack settlement (resignations freeze scores — zero diff).
|
||||
const lastTotal = seats.map((s) => {
|
||||
for (let i = moves.length - 1; i >= 0; i--) {
|
||||
if (moves[i].player === s.seat) return moves[i].total;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
const adjustments = seats.map((s, i) => s.score - lastTotal[i]);
|
||||
const host = opts?.hostname ?? (typeof location !== 'undefined' ? location.hostname : '');
|
||||
return {
|
||||
header: seats.map((s) => ({ name: s.displayName, score: s.score, winner: s.isWinner })),
|
||||
rows,
|
||||
adjustments,
|
||||
hasAdjustments: adjustments.some((a) => a !== 0),
|
||||
footer: `${host} · ${formatFinishedDate(game.lastActivityUnix, opts?.dateLocale)}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Fixed scoresheet typography (@1x logical px). The column format never changes with game
|
||||
// length — the board stretches instead (layoutImage).
|
||||
const MARGIN = 22;
|
||||
const AXIS = 20; // top letters / left numbers gutter
|
||||
const GAP = 22; // board ↔ scoresheet
|
||||
const COL_W = 158; // one seat column
|
||||
const COL_GAP = 14; // between seat columns
|
||||
const HEAD_NAME_H = 20;
|
||||
const HEAD_SCORE_H = 24;
|
||||
const HEAD_RULE_H = 12;
|
||||
const ROW_H = 17;
|
||||
const SUB_ROW_H = 13; // extra-words line under a multi-word play
|
||||
const ADJ_ROW_H = 20;
|
||||
const FOOTER_H = 30;
|
||||
/** The board never shrinks below this side (@1x) even for a two-move game. */
|
||||
export const MIN_SIDE = 620;
|
||||
|
||||
/** ImageLayout is the pure geometry of the export image (@1x; the canvas scales it). */
|
||||
export interface ImageLayout {
|
||||
w: number;
|
||||
h: number;
|
||||
board: { x: number; y: number; side: number; cell: number };
|
||||
hist: { x: number; y: number; w: number; colW: number; colGap: number };
|
||||
/** Per move-row height (a row with any multi-word play gets the extra sub-line). */
|
||||
rowHeights: number[];
|
||||
headNameH: number;
|
||||
headScoreH: number;
|
||||
headRuleH: number;
|
||||
rowH: number;
|
||||
subRowH: number;
|
||||
adjRowH: number;
|
||||
footerH: number;
|
||||
axis: number;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* layoutImage computes the export geometry from the scoresheet: the fixed-typography
|
||||
* history column height decides the board side (never below MIN_SIDE), so the image
|
||||
* carries no dead space under the board and a long game simply grows both.
|
||||
*/
|
||||
export function layoutImage(sheet: Scoresheet): ImageLayout {
|
||||
const seats = sheet.header.length;
|
||||
const rowHeights = sheet.rows.map(
|
||||
(row) => ROW_H + (row.some((c) => c.kind === 'play' && c.words.length > 1) ? SUB_ROW_H : 0),
|
||||
);
|
||||
const histContentH =
|
||||
HEAD_NAME_H +
|
||||
HEAD_SCORE_H +
|
||||
HEAD_RULE_H +
|
||||
rowHeights.reduce((a, b) => a + b, 0) +
|
||||
(sheet.hasAdjustments ? ADJ_ROW_H : 0);
|
||||
const side = Math.max(MIN_SIDE, Math.ceil(histContentH));
|
||||
const histW = seats * COL_W + (seats - 1) * COL_GAP;
|
||||
return {
|
||||
w: MARGIN + AXIS + side + GAP + histW + MARGIN,
|
||||
h: MARGIN + AXIS + side + FOOTER_H + MARGIN,
|
||||
board: { x: MARGIN + AXIS, y: MARGIN + AXIS, side, cell: side / BOARD_SIZE },
|
||||
hist: { x: MARGIN + AXIS + side + GAP, y: MARGIN + AXIS, w: histW, colW: COL_W, colGap: COL_GAP },
|
||||
rowHeights,
|
||||
headNameH: HEAD_NAME_H,
|
||||
headScoreH: HEAD_SCORE_H,
|
||||
headRuleH: HEAD_RULE_H,
|
||||
rowH: ROW_H,
|
||||
subRowH: SUB_ROW_H,
|
||||
adjRowH: ADJ_ROW_H,
|
||||
footerH: FOOTER_H,
|
||||
axis: AXIS,
|
||||
margin: MARGIN,
|
||||
};
|
||||
}
|
||||
|
||||
/** RenderOptions carries the presentation the caller resolves from app state. */
|
||||
export interface RenderOptions {
|
||||
/** Localizes a non-play move for the scoresheet (pass/exchange/resign/timeout). */
|
||||
actionLabel: (action: string) => string;
|
||||
/** Footer host override (defaults to location.hostname) — for tests/harnesses. */
|
||||
hostname?: string;
|
||||
/** Footer date locale override (defaults to the device locale) — for tests. */
|
||||
dateLocale?: string;
|
||||
/** Canvas pixel scale (default 2 — crisp on hi-dpi screens and in chats). */
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderGameImage draws the finished game as a PNG blob: final board with classic axes on
|
||||
* the left, the scoresheet on the right, the site + finish date at the bottom. Light theme
|
||||
* always; no last-move highlight (an archival artifact, not a live frame).
|
||||
*/
|
||||
export async function renderGameImage(
|
||||
game: GameView,
|
||||
moves: MoveRecord[],
|
||||
opts: RenderOptions,
|
||||
): Promise<Blob> {
|
||||
const sheet = buildScoresheet(game, moves, opts.actionLabel, {
|
||||
hostname: opts.hostname,
|
||||
dateLocale: opts.dateLocale,
|
||||
});
|
||||
const layout = layoutImage(sheet);
|
||||
const scale = opts.scale ?? 2;
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.round(layout.w * scale);
|
||||
canvas.height = Math.round(layout.h * scale);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('gameimage: no 2d context');
|
||||
ctx.scale(scale, scale);
|
||||
|
||||
ctx.fillStyle = C.paper;
|
||||
ctx.fillRect(0, 0, layout.w, layout.h);
|
||||
drawAxes(ctx, layout);
|
||||
drawBoard(ctx, layout, game.variant, moves);
|
||||
drawSheet(ctx, layout, sheet);
|
||||
|
||||
ctx.fillStyle = C.muted;
|
||||
ctx.font = `11px ${FONT}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillText(sheet.footer, layout.margin, layout.h - layout.margin - 6);
|
||||
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('gameimage: toBlob failed'))), 'image/png');
|
||||
});
|
||||
}
|
||||
|
||||
function drawAxes(ctx: CanvasRenderingContext2D, l: ImageLayout): void {
|
||||
const { x, y, cell } = l.board;
|
||||
ctx.fillStyle = C.muted;
|
||||
ctx.font = `${Math.max(10, cell * 0.28)}px ${FONT}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
for (let c = 0; c < BOARD_SIZE; c++) {
|
||||
ctx.fillText(String.fromCharCode(65 + c), x + c * cell + cell / 2, y - l.axis / 2);
|
||||
}
|
||||
for (let r = 0; r < BOARD_SIZE; r++) {
|
||||
ctx.fillText(String(r + 1), x - l.axis / 2, y + r * cell + cell / 2);
|
||||
}
|
||||
}
|
||||
|
||||
function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
function drawBoard(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
l: ImageLayout,
|
||||
variant: Variant,
|
||||
moves: MoveRecord[],
|
||||
): void {
|
||||
const { x, y, cell, side } = l.board;
|
||||
const board = replay(moves);
|
||||
const prem = premiumGrid(variant);
|
||||
const ctr = centre(variant);
|
||||
|
||||
// Squares first (premium colours + the alternating plain-cell tint, as in game). Bonus
|
||||
// squares carry NO text labels on the export — the fill alone reads universally, with the
|
||||
// coordinate axes carrying the location.
|
||||
for (let r = 0; r < BOARD_SIZE; r++) {
|
||||
for (let c = 0; c < BOARD_SIZE; c++) {
|
||||
const p = prem[r][c];
|
||||
ctx.fillStyle = p ? C.prem[p] : (r + c) % 2 === 1 && !board[r][c] ? C.cellDark : C.cell;
|
||||
ctx.fillRect(x + c * cell, y + r * cell, cell + 0.5, cell + 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// The centre star on an uncovered centre square (rare in a finished game).
|
||||
if (!board[ctr.row][ctr.col]) {
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = C.premText;
|
||||
ctx.globalAlpha = 0.7;
|
||||
ctx.font = `${cell * 0.54}px ${FONT}`;
|
||||
ctx.fillText('★', x + ctr.col * cell + cell / 2, y + ctr.row * cell + cell / 2);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Tiles: rounded face, inset bottom bevel and a soft right-side shadow, letter top-left,
|
||||
// value bottom-right — the in-game look (Board.svelte), light palette.
|
||||
const rad = cell * 0.1;
|
||||
for (let r = 0; r < BOARD_SIZE; r++) {
|
||||
for (let c = 0; c < BOARD_SIZE; c++) {
|
||||
const t = board[r][c];
|
||||
if (!t) continue;
|
||||
const tx = x + c * cell;
|
||||
const ty = y + r * cell;
|
||||
ctx.save();
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.4)';
|
||||
ctx.shadowOffsetX = cell * 0.05;
|
||||
ctx.shadowBlur = cell * 0.07;
|
||||
ctx.fillStyle = C.tile;
|
||||
roundRect(ctx, tx, ty, cell, cell, rad);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
// Inset bottom bevel: a tile-edge strip clipped to the rounded face.
|
||||
ctx.save();
|
||||
roundRect(ctx, tx, ty, cell, cell, rad);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = C.tileEdge;
|
||||
ctx.fillRect(tx, ty + cell - cell * 0.05, cell, cell * 0.05);
|
||||
ctx.restore();
|
||||
|
||||
ctx.fillStyle = C.tileText;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.font = `700 ${cell * 0.63}px ${FONT}`;
|
||||
ctx.fillText(t.letter, tx + cell * 0.08, ty + cell * 0.07);
|
||||
const isErudit = variant === 'erudit_ru';
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
if (t.blank && isErudit) {
|
||||
// The Erudit blank marker (✻) in the value corner, as in game (usesStarBlank).
|
||||
ctx.font = `${cell * 0.32}px ${FONT}`;
|
||||
ctx.fillText('✻', tx + cell * 0.9, ty + cell * 0.9);
|
||||
} else if (!t.blank) {
|
||||
ctx.font = `600 ${cell * 0.28}px ${FONT}`;
|
||||
ctx.fillText(String(valueForLetter(variant, t.letter)), tx + cell * 0.9, ty + cell * 0.9);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A hairline frame so the pale board edge reads against the paper.
|
||||
ctx.strokeStyle = C.border;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x - 0.5, y - 0.5, side + 1, side + 1);
|
||||
}
|
||||
|
||||
/** fitText shrinks the font size until text fits maxW (floor 8.5px), returning the size. */
|
||||
function fitText(ctx: CanvasRenderingContext2D, text: string, weight: string, size: number, maxW: number): number {
|
||||
let s = size;
|
||||
ctx.font = `${weight} ${s}px ${FONT}`;
|
||||
while (s > 8.5 && ctx.measureText(text).width > maxW) {
|
||||
s -= 0.5;
|
||||
ctx.font = `${weight} ${s}px ${FONT}`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function ellipsize(ctx: CanvasRenderingContext2D, text: string, maxW: number): string {
|
||||
if (ctx.measureText(text).width <= maxW) return text;
|
||||
let t = text;
|
||||
while (t.length > 1 && ctx.measureText(t + '…').width > maxW) t = t.slice(0, -1);
|
||||
return t + '…';
|
||||
}
|
||||
|
||||
function drawSheet(ctx: CanvasRenderingContext2D, l: ImageLayout, sheet: Scoresheet): void {
|
||||
const { x, y, colW, colGap } = l.hist;
|
||||
const seats = sheet.header.length;
|
||||
const colX = (i: number) => x + i * (colW + colGap);
|
||||
|
||||
// Header: per-seat name (ellipsized) over the final score; 🏆 marks the winner.
|
||||
for (let i = 0; i < seats; i++) {
|
||||
const h = sheet.header[i];
|
||||
const cx = colX(i) + colW / 2;
|
||||
ctx.fillStyle = C.text;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `600 13px ${FONT}`;
|
||||
ctx.fillText(ellipsize(ctx, h.name, colW - 8), cx, y + l.headNameH / 2);
|
||||
ctx.font = `700 16px ${FONT}`;
|
||||
const score = h.winner ? `${h.score} 🏆` : String(h.score);
|
||||
ctx.fillText(score, cx, y + l.headNameH + l.headScoreH / 2);
|
||||
}
|
||||
// Rule under the header, spanning the sheet.
|
||||
const ruleY = y + l.headNameH + l.headScoreH + l.headRuleH / 2;
|
||||
ctx.strokeStyle = C.border;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, ruleY);
|
||||
ctx.lineTo(x + l.hist.w, ruleY);
|
||||
ctx.stroke();
|
||||
|
||||
// Move rows: "<coord> <WORD>" left, points right; extra words on a small second line.
|
||||
let rowY = y + l.headNameH + l.headScoreH + l.headRuleH;
|
||||
for (let r = 0; r < sheet.rows.length; r++) {
|
||||
const row = sheet.rows[r];
|
||||
const rh = l.rowHeights[r];
|
||||
for (let i = 0; i < seats; i++) {
|
||||
const cell = row[i];
|
||||
const bx = colX(i);
|
||||
const baseline = rowY + l.rowH / 2;
|
||||
if (cell.kind === 'play') {
|
||||
const scoreText = String(cell.score);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.font = `600 11.5px ${FONT}`;
|
||||
ctx.fillStyle = C.text;
|
||||
ctx.fillText(scoreText, bx + colW, baseline);
|
||||
const scoreW = ctx.measureText(scoreText).width + 6;
|
||||
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillStyle = C.muted;
|
||||
ctx.font = `600 10.5px ${FONT}`;
|
||||
ctx.fillText(cell.coord, bx, baseline);
|
||||
const coordW = ctx.measureText('D15').width + 5; // fixed slot so words align
|
||||
|
||||
const wordMax = colW - coordW - scoreW;
|
||||
const word = cell.words[0] ?? '';
|
||||
ctx.fillStyle = C.text;
|
||||
const size = fitText(ctx, word, '500', 11.5, wordMax);
|
||||
ctx.font = `500 ${size}px ${FONT}`;
|
||||
ctx.fillText(ellipsize(ctx, word, wordMax), bx + coordW, baseline);
|
||||
|
||||
if (cell.words.length > 1) {
|
||||
ctx.fillStyle = C.muted;
|
||||
ctx.font = `9.5px ${FONT}`;
|
||||
ctx.fillText(
|
||||
ellipsize(ctx, `+ ${cell.words.slice(1).join(', ')}`, colW - coordW),
|
||||
bx + coordW,
|
||||
rowY + l.rowH + l.subRowH / 2 - 1,
|
||||
);
|
||||
}
|
||||
} else if (cell.kind === 'action') {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = C.muted;
|
||||
ctx.font = `italic 10.5px ${FONT}`;
|
||||
ctx.fillText(ellipsize(ctx, cell.label, colW), bx, baseline);
|
||||
}
|
||||
}
|
||||
rowY += rh;
|
||||
}
|
||||
|
||||
// Endgame rack settlement (when any seat has one): a closing ± row per column.
|
||||
if (sheet.hasAdjustments) {
|
||||
for (let i = 0; i < seats; i++) {
|
||||
const a = sheet.adjustments[i];
|
||||
if (a === 0) continue;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillStyle = C.muted;
|
||||
ctx.font = `600 10.5px ${FONT}`;
|
||||
ctx.fillText(a > 0 ? `+${a}` : String(a), colX(i) + colW, rowY + l.adjRowH / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { historyGrid, type HistoryCell } from './history';
|
||||
import { historyGrid, moveCoord, type HistoryCell } from './history';
|
||||
import type { MoveRecord } from './model';
|
||||
|
||||
function play(player: number, words: string[], score: number): MoveRecord {
|
||||
@@ -67,4 +67,26 @@ describe('historyGrid', () => {
|
||||
expect(kinds(grid)).toEqual([['play', 'empty']]);
|
||||
expect(grid[0][0]).toMatchObject({ words: ['ДОМ'] });
|
||||
});
|
||||
|
||||
it('stamps a play cell with its classic coordinate', () => {
|
||||
const grid = historyGrid([play(0, ['ДОМ'], 12)], 1, null);
|
||||
expect(grid[0][0]).toMatchObject({ kind: 'play', coord: '8H' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveCoord', () => {
|
||||
// The classic notation the GCG export uses (backend gcgPos): an across play is
|
||||
// row-then-column ("8G"), a down play column-then-row ("H8").
|
||||
it('puts the row number first for an across play', () => {
|
||||
expect(moveCoord({ dir: 'H', mainRow: 7, mainCol: 6 })).toBe('8G');
|
||||
});
|
||||
|
||||
it('puts the column letter first for a down play', () => {
|
||||
expect(moveCoord({ dir: 'V', mainRow: 7, mainCol: 7 })).toBe('H8');
|
||||
});
|
||||
|
||||
it('covers the board corners', () => {
|
||||
expect(moveCoord({ dir: 'H', mainRow: 0, mainCol: 0 })).toBe('1A');
|
||||
expect(moveCoord({ dir: 'V', mainRow: 14, mainCol: 14 })).toBe('O15');
|
||||
});
|
||||
});
|
||||
|
||||
+16
-1
@@ -16,11 +16,26 @@ export interface HistoryCell {
|
||||
words?: string[];
|
||||
/** Points scored by the move — set for kind 'play'. */
|
||||
score?: number;
|
||||
/** The main word's board coordinate in classic notation (see moveCoord) — set for kind
|
||||
* 'play'. The in-game panel does not show it; the game-image export does. */
|
||||
coord?: string;
|
||||
/** The non-play action ('pass' | 'exchange' | 'resign' | 'timeout' | …) — set for kind
|
||||
* 'action'; the component localizes it. */
|
||||
action?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* moveCoord renders a play's board coordinate in the classic notation the GCG export uses
|
||||
* (the backend's gcgPos): row-then-column (e.g. "8G") for an across play, column-then-row
|
||||
* ("H8") for a down play. Rows are 1-based numbers top to bottom, columns letters from "A"
|
||||
* left to right.
|
||||
*/
|
||||
export function moveCoord(mv: Pick<MoveRecord, 'dir' | 'mainRow' | 'mainCol'>): string {
|
||||
const col = String.fromCharCode(65 + mv.mainCol);
|
||||
const row = String(mv.mainRow + 1);
|
||||
return mv.dir === 'V' ? col + row : row + col;
|
||||
}
|
||||
|
||||
/**
|
||||
* historyGrid arranges the move journal into a row-major matrix with one column per seat.
|
||||
* Each seat's moves fill its column top to bottom, so a seat's k-th move lands in row k of
|
||||
@@ -40,7 +55,7 @@ export function historyGrid(
|
||||
if (m.player < 0 || m.player >= seatCount) continue; // defensive: drop an out-of-range seat
|
||||
cols[m.player].push(
|
||||
m.action === 'play'
|
||||
? { kind: 'play', player: m.player, words: m.words, score: m.score }
|
||||
? { kind: 'play', player: m.player, words: m.words, score: m.score, coord: moveCoord(m) }
|
||||
: { kind: 'action', player: m.player, action: m.action },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,7 +310,13 @@ export const en = {
|
||||
'stats.maxWord': 'Best move',
|
||||
'stats.guestHint': 'Sign in to track your statistics.',
|
||||
|
||||
'game.exportGcg': 'Export GCG',
|
||||
'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?',
|
||||
|
||||
@@ -310,7 +310,13 @@ export const ru: Record<MessageKey, string> = {
|
||||
'stats.maxWord': 'Лучший ход',
|
||||
'stats.guestHint': 'Войдите, чтобы вести статистику.',
|
||||
|
||||
'game.exportGcg': 'Экспорт GCG',
|
||||
'game.exportGcg': 'Экспорт партии',
|
||||
'game.exportChoice': 'Экспортировать завершённую партию как:',
|
||||
'game.exportImageOpt': 'Картинка (PNG)',
|
||||
'game.exportGcgOpt': 'Файл GCG',
|
||||
'game.imageSaveHint': 'Чтобы сохранить картинку — долгое нажатие (или правый клик).',
|
||||
'game.imageCopy': 'Скопировать картинку',
|
||||
'game.imageCopied': 'Картинка скопирована в буфер обмена.',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
'game.gcgCopied': 'GCG скопирован в буфер обмена.',
|
||||
'game.addFriendShort': 'В друзья?',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share';
|
||||
import { pickGcgDelivery, pickImageDelivery, pickTextShare, shareOrDownloadGcg, shareOrDownloadImage, shareText } from './share';
|
||||
import type { GcgExport } from './model';
|
||||
|
||||
const file = {} as File;
|
||||
@@ -83,6 +83,69 @@ describe('shareOrDownloadGcg', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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('downloads on a plain browser without Web Share (desktop)', () => {
|
||||
expect(pickImageDelivery(noShareNav, file, false)).toBe('download');
|
||||
expect(pickImageDelivery(undefined, file, false)).toBe('download');
|
||||
});
|
||||
});
|
||||
|
||||
describe('shareOrDownloadImage', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
function stubEnv(canShare: boolean, share: () => Promise<void>) {
|
||||
const anchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
||||
const createElement = vi.fn(() => anchor);
|
||||
vi.stubGlobal('navigator', { canShare: () => canShare, share });
|
||||
vi.stubGlobal('document', { createElement, body: { appendChild: vi.fn() } });
|
||||
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:x'), revokeObjectURL: vi.fn() });
|
||||
return { anchor, createElement };
|
||||
}
|
||||
|
||||
const png = () => ({ name: 'game-1.png', type: 'image/png' }) as File;
|
||||
|
||||
it('never falls back to the navigating Blob download when a share is cancelled', async () => {
|
||||
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
|
||||
const { createElement } = stubEnv(true, share);
|
||||
|
||||
expect(await shareOrDownloadImage(png(), false)).toBe('shared');
|
||||
|
||||
expect(share).toHaveBeenCalledOnce();
|
||||
expect(createElement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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(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', () => {
|
||||
it('shares when Web Share is available', () => {
|
||||
expect(pickTextShare({ share: async () => {}, canShare: () => true })).toBe('share');
|
||||
|
||||
+85
-2
@@ -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'] };
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user