diff --git a/DECISIONS.md b/DECISIONS.md index 10f2cbc..fee05c3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -200,8 +200,20 @@ средняя ошибка чуть выше, чем у «размах × пиксели», зато мелкий акцент (глаз, блик) не растворяется уже с 16 цветов. Увеличение снимается автоматически, но отключается одной галочкой. -- Браузерный декодер (canvas) не используем: управление цветом может - сдвинуть значения, а для Aseprite его нет вовсе. +- Пиксель-арт браузерный декодер (canvas) не трогает: управление цветом + может сдвинуть значения, а для Aseprite его нет вовсе. Браузер нужен + только фото и картинкам крупнее холста: он декодирует (JPEG, WebP…) и + уменьшает выбранный квадрат, а палитру подбирает всё равно ядро. +- **Импорт живёт в редакторе** («Ещё → Новая карточка → Из картинки»), + отдельной вкладки нет. Результат — обычный рисунок на холсте: название, + рамку и подпись даёт редактор, прежний рисунок возвращает «Отменить». + Имя файла названием карточки не становится. +- Карточка квадратная. Пиксель-арт до 64×64 ложится как есть: неквадратный + — в середину холста, который его вмещает, на пустые клетки (ничего не + теряется). Крупнее или фото — через обрезку: квадрат двигают пальцем и + тянут за углы (колесо — на компьютере). Пиксель-арт режется по своей + сетке и не больше 64, чтобы пиксели остались; фото уменьшается до + выбранного размера. ## Экземпляры и чтение QR @@ -308,8 +320,30 @@ - Только что полученная (или сделанная) карточка открывается крупно в коллекции, где она теперь и лежит. -- Экран импорта — неизменный каркас: рамка-паспарту → строка состояния - фиксированной высоты → поля → кнопки; редкие галочки — под кнопками. +- Вкладка «Отдать» (на месте «Импорта»): свои карточки с кодом наготове — + выбранная лежит на бумаге своей рамки тем же компонентом, что и лист + «Поделиться»; внизу ряд своих карточек, выбранная приподнята. Код + получателя здесь не нужен — он для получения, и живёт на сканере. +- Способы отдать, везде одинаковые: код, «Ссылкой», «Файлом» (.pips — тот + же конверт, что в коде, со всеми подписями) и «Картинкой». Скачанный + файл называется латиницей: иначе некоторые браузеры называют его просто + «download». +- «Картинкой» — карточка целиком (1080×1440): бумага рамки, рисунок, имя, + автор и, если карточка помещается в один код, этот код — в цветах рамки, + на небольшой скруглённой подложке, одинаковой по виду на любой бумаге: + на светлой подложка — осветлённая бумага, модули — чернила, смягчённые к + серому ровно настолько, насколько позволяет контраст (≥ 7:1); на тёмной — + подложка светлого тона чернил, модули цветом бумаги (светлый код на + тёмном читают не все). Модуль ≥ 3 px; тест читает код на всех восьми бумагах после JPEG, + в полный размер и в ¾. Тот же конверт + лежит и внутри PNG (свой блок `piPs`, `png-card.js`). Файл открывается в + Pips как сама карточка, с автором и подписями; если мессенджер пережал + картинку или это скриншот — карточку читает код на ней. Поэтому так + можно отдать и чужую карточку: автор виден и подписан. Открытая в + редакторе «Из картинки», такая картинка сначала предлагает добавить + карточку в коллекцию. +- «Новая из неё» — только у своих карточек: рисунок уходит в редактор как + новая карточка, без названия. Чужой рисунок своим стать не может. - Секреты на вкладке «Профиль» показываются и скрываются той же кнопкой; текст бумажного ключа свёрнут — печатается QR. - Уведомления — всегда в одном месте, под верхней панелью: низ занят то @@ -336,8 +370,19 @@ - Запись в хранилище считается сделанной, когда транзакция завершилась, а не когда принят запрос: иначе переполненный диск выдаёт ложное «сохранено». - Резервную копию больше лимита ядра (64 МБ) не читаем в память вовсе. +- Версия раскладки данных — это номер версии самой базы IndexedDB + (`schema.js`). Новая версия приложения переводит данные шагами внутри + транзакции обновления: всё или ничего. Старые вкладки получают сигнал и + уступают («обновите страницу»). Старое приложение данные новее себя не + открывает и говорит об этом, а не показывает пустую коллекцию. Шаг после + выпуска не меняется: новая раскладка — новый шаг в конце. +- Редактор: у неподвижной картинки полоса кадров — одна кнопка «Кадр + анимации»; плитка проигрывания и пронумерованные кадры — с двух кадров. + Пустой холст не подписывается. - Сканер (везде, где он есть): пока камера выключена, рамка сама — кнопка - «Включить камеру». Подсказки и ошибки — внизу рамки, поверх видео тоже. + «Включить камеру»; подсказки и ошибки — под её надписью, внутри уголков. + С камерой — внизу, поверх видео. Без доступа рамка так и говорит и + повторяет запрос по касанию. ## Модули diff --git a/scripts/samples.py b/scripts/samples.py index 2f2a00c..9480590 100644 --- a/scripts/samples.py +++ b/scripts/samples.py @@ -106,9 +106,11 @@ def mushroom(): (1, 'M5 2h6v1h-6zM3 3h2v1h-2zM7 3h6v1h-6zM2 4h2v1h-2zM7 4h4v1h-4zM13 4h1v1h-1zM2 5h3v1h-3zM6 5h5v1h-5zM13 5h1v1h-1zM1 6h7v1h-7zM9 6h6v1h-6zM1 7h1v1h-1zM4 7h11v1h-11z'), (2, 'M5 3h2v1h-2zM4 4h3v1h-3zM11 4h2v1h-2zM5 5h1v1h-1zM11 5h2v1h-2zM8 6h1v1h-1zM2 7h2v1h-2z'), (3, 'M2 8h12v1h-12z'), + # The cap's underside edge is the cap's: it sinks with it, over the stem. + (0, 'M2 9h4v1h-4zM10 9h4v1h-4z'), ] stem = [ - (0, 'M2 9h4v1h-4zM10 9h4v1h-4zM5 10h1v1h-1zM10 10h1v1h-1zM4 11h1v1h-1zM11 11h1v1h-1zM4 12h1v1h-1zM11 12h1v1h-1zM4 13h1v1h-1zM11 13h1v1h-1zM5 14h6v1h-6z'), + (0, 'M5 10h1v1h-1zM10 10h1v1h-1zM4 11h1v1h-1zM11 11h1v1h-1zM4 12h1v1h-1zM11 12h1v1h-1zM4 13h1v1h-1zM11 13h1v1h-1zM5 14h6v1h-6z'), (4, 'M6 9h4v1h-4zM6 10h4v1h-4zM5 11h4v1h-4zM5 12h4v1h-4zM5 13h3v1h-3z'), (5, 'M9 11h2v1h-2zM9 12h2v1h-2zM8 13h3v1h-3z'), ] diff --git a/tests/e2e/app.test.mjs b/tests/e2e/app.test.mjs index 533148a..7f71280 100644 --- a/tests/e2e/app.test.mjs +++ b/tests/e2e/app.test.mjs @@ -5,7 +5,7 @@ import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { setup, photo, fromPicture, choose, tab, toast, heart, gradient, wide, textMatching, samples } from './helpers.mjs'; +import { setup, photo, fromPicture, choose, tab, toast, heart, gradient, wide, png, textMatching, samples } from './helpers.mjs'; /** @type {Awaited>} */ let env; @@ -18,15 +18,21 @@ after(async () => { const words = (p) => p.evaluate(async () => (await import('/src/state.js')).app.identity.words()); const cards = (p) => p.evaluate(async () => (await import('/src/state.js')).app.cards.size); +/** Opens a picture from a file in the editor: Draw → More → From a picture. */ +async function picture(p, file) { + await tab(p, 'draw'); + await p.locator('.ed-art').waitFor(); + await p.click('[aria-label="Ещё"]'); + await choose(p, () => p.click('.ed-new-tile >> nth=1'), file); + await p.locator('.overlay .pic-body').waitFor(); +} /** Makes an own card from the heart picture; it opens large in the collection. */ async function ownCard(p, title) { - await tab(p, 'import'); - await choose(p, () => p.click('.import-preview'), await heart(env.dir)); - // The file is read in the background and puts its name in the title: - // type over it only after that, as a person would. - await p.waitForFunction(() => document.querySelector('.import-body input:not([type])')?.value === 'heart-x6'); - await p.locator('.import-body input:not([type])').fill(title); - await p.click('text=Подписать и добавить'); + await picture(p, await heart(env.dir)); + await p.click('.overlay .btn.key'); // «На холст» + await p.locator('.overlay').waitFor({ state: 'detached' }); + await p.fill('.ed-title', title); + await p.click('.ed-sign'); await p.locator('.turn:not([hidden])').waitFor(); } /** The card in front: its share sheet, its back. */ @@ -140,55 +146,54 @@ test('restoring on a new phone: the backup and the key in one step', async () => for (const p of [a, b, c, d, e]) await p.context().close(); }); -test('import keeps its layout and reports what it did', async () => { +test('a picture comes onto the canvas: pixel art as it is, the rest through a square', async () => { const p = await env.phone('pixelfox'); - await tab(p, 'import'); - // Measured with the fonts in place and the sheet's entry animation over. - await p.evaluate(() => Promise.all([document.fonts.ready, ...document.getAnimations().map((a) => a.finished)])); - const buttonsAt = async () => (await p.locator('.import-actions').boundingBox())?.y; - const empty = await buttonsAt(); - assert.ok(await p.locator('.import-actions .btn.key').isDisabled(), 'nothing to sign yet'); - // 512 colours: an own palette is fitted by itself, and said so. - await choose(p, () => p.click('.import-preview'), await gradient(env.dir)); - assert.match(await textMatching(p, '.import-status', /подобрана своя палитра/), /больше 256 цветов файла → свои 32/); - assert.equal(await p.inputValue('.import-body select'), 'fit:32'); - assert.ok(await p.locator('.import-actions .btn.key').isEnabled()); - assert.equal(await buttonsAt(), empty, 'the status does not move the buttons'); - - await p.selectOption('.import-body select', { label: 'PICO-8 · 16' }); - assert.match(await textMatching(p, '.import-status', /палитра PICO-8 · 512 пикселей сменили цвет/), /палитра PICO-8 · 512 пикселей сменили цвет/); - assert.equal(await buttonsAt(), empty); - - // Wider than any canvas of the app: the editor's rule, said here too. - await choose(p, () => p.click('text=Другой файл'), await wide(env.dir)); - assert.match(await textMatching(p, '.import-status', /до 64×64/), /^80×8: в приложении карточка до 64×64/); - assert.ok(await p.locator('.import-actions .btn.key').isDisabled(), 'not signed'); - assert.equal(await buttonsAt(), empty); - - await choose(p, () => p.click('text=Другой файл'), await heart(env.dir)); - assert.match(await textMatching(p, '.import-status', /60×48 → 10×8 \(снято увеличение ×6\)/), /60×48 → 10×8 \(снято увеличение ×6\)/); - assert.equal(await buttonsAt(), empty); - await p.click('text=Подписать и добавить'); - await p.locator('.turn:not([hidden])').waitFor(); - assert.equal(await cards(p), 1); - await p.context().close(); -}); - -test('an import under way survives a look at another tab', async () => { - const p = await env.phone('pixelfox'); - await tab(p, 'import'); - await choose(p, () => p.click('.import-preview'), await heart(env.dir)); - await p.waitForFunction(() => document.querySelector('.import-body input:not([type])')?.value === 'heart-x6'); // read, and named after the file - await p.locator('.import-body input:not([type])').fill('Сердце'); - await tab(p, 'receive'); - await tab(p, 'collection'); - await tab(p, 'import'); - assert.equal(await p.inputValue('.import-body input:not([type])'), 'Сердце', 'the title is kept'); - assert.ok(await p.locator('.import-actions .btn.key').isEnabled(), 'the picture is kept'); - await p.click('.import-view .bar .ib'); - assert.equal(await p.inputValue('.import-body input:not([type])'), '', 'start over clears it'); - assert.ok(await p.locator('.import-actions .btn.key').isDisabled()); + await picture(p, await gradient(env.dir)); + assert.match(await textMatching(p, '.overlay .import-status', /подобрана своя палитра/), /больше 256 цветов файла → свои 32/); + assert.equal(await p.inputValue('.overlay select'), 'fit:32'); + await p.selectOption('.overlay select', { label: 'PICO-8 · 16' }); + assert.match(await textMatching(p, '.overlay .import-status', /палитра PICO-8 · 512 пикселей сменили цвет/), /палитра PICO-8 · 512 пикселей сменили цвет/); + assert.equal(await p.locator('.overlay .pic-stage').count(), 0, 'no crop: 64×8 fits the canvas as it is'); + await p.click('.overlay .btn.key'); + assert.equal(await toast(p), 'Картинка на холсте'); + const size = () => p.$eval('.ed-layer', (c) => String(c.width)); + assert.equal(await size(), '64', 'the canvas that holds it'); + + // Wider than any canvas: a square of its own pixels, never more than fits. + await picture(p, await wide(env.dir)); + assert.ok(await p.isVisible('.overlay .pic-stage')); + assert.match(await textMatching(p, '.overlay .import-status', /^8×8/), /^8×8/); + await p.click('.overlay .btn.key'); + assert.equal(await size(), '16'); + + // A large picture with soft colours: cropped and scaled to the size chosen. + const big = await png(env.dir, 'dusk.png', 300, 200, (x, y) => [(x * 0.8) | 0, (y * 1.2) | 0, (x + y) % 256, 255]); // no pixel grid in it: a photo + await picture(p, big); + assert.match(await textMatching(p, '.overlay .import-status', /^32×32/), /^32×32/, 'a square at 32 to begin with'); + await p.click('.overlay .seg button:text-is("16")'); + assert.match(await textMatching(p, '.overlay .import-status', /^16×16/), /^16×16/); + // The square follows a finger on its corner, and the result with it. + const box = await p.locator('.pic-crop').boundingBox(); + await p.evaluate(async ([x0, y0]) => { + const st = document.querySelector('.pic-stage'); + const fire = (t, x, y) => st.dispatchEvent(new PointerEvent(t, { bubbles: true, pointerId: 3, pointerType: 'touch', clientX: x, clientY: y })); + fire('pointerdown', x0, y0); + for (let i = 1; i <= 6; i++) { await new Promise((r) => setTimeout(r, 16)); fire('pointermove', x0 - i * 15, y0 - i * 15); } + fire('pointerup', x0 - 90, y0 - 90); + }, [box.x + box.width - 3, box.y + box.height - 3]); + await p.waitForTimeout(300); + const after = await p.locator('.pic-crop').boundingBox(); + assert.ok(Math.abs(after.width - (box.width - 90)) < 3 && Math.abs(after.x - box.x) < 1, 'smaller, from the corner held still'); + await p.click('.overlay .btn.key'); + assert.equal(await size(), '16'); + + // The heart: unscaled, and one tap brings the previous picture back. + await picture(p, await heart(env.dir)); + assert.match(await textMatching(p, '.overlay .import-status', /60×48 → 10×8 \(снято увеличение ×6\)/), /60×48 → 10×8 \(снято увеличение ×6\)/); + await p.click('.overlay .btn.key'); + await p.click('.toast button'); // «Вернуть» + assert.equal(await size(), '16', 'the cropped picture is back'); assert.equal(await cards(p), 0, 'nothing was signed on the way'); await p.context().close(); }); @@ -811,7 +816,7 @@ test('nothing on screen covers anything else, and the hint shows only when it ap // An empty collection: nothing to zoom, so no word about zooming. assert.ok(await p.locator('.first-run').isVisible()); assert.equal(await p.locator('.coach').isVisible(), false, 'no zoom hint without cards'); - for (const t of ['me', 'receive', 'import', 'draw']) { + for (const t of ['me', 'receive', 'give', 'draw']) { await tab(p, t); await p.waitForTimeout(500); assert.deepEqual(await overlaps(p), [], `${t}, ${desk ? 'computer' : 'phone'}`); @@ -924,7 +929,7 @@ test('a card sent as a link opens the app and arrives, even before the first run // The device's share sheet, stood in for: what it is handed is kept. await a.evaluate(() => { /** @type {any} */ (navigator).share = async (d) => { /** @type {any} */ (window).shared = d; }; }); await act(a, 'share'); - await a.click('.top-sheet .share-link'); + await a.click('.top-sheet .share-link >> nth=0'); // «Ссылкой» const sent = await a.evaluate(() => /** @type {any} */ (window).shared); assert.match(sent.url, /^https:\/\/pips-app\.github\.io\/pips\/#[A-Za-z0-9_-]+$/, 'the published app (this one is not on https)'); assert.equal(sent.title, 'Сердце'); @@ -965,18 +970,10 @@ test('a card sent as a link opens the app and arrives, even before the first run test('a card is signed with a name; one that came without shows «Без названия»', async () => { const p = await env.phone('pixelfox'); - // The import: the title emptied, signing asks for one instead. - await tab(p, 'import'); - await choose(p, () => p.click('.import-preview'), await heart(env.dir)); - await p.waitForFunction(() => document.querySelector('.import-body input:not([type])')?.value === 'heart-x6'); - await p.fill('.import-body input:not([type])', ' '); - await p.click('text=Подписать и добавить'); - assert.match(await toast(p), /Сначала назовите карточку/); - assert.equal(await p.evaluate(() => document.activeElement?.matches('.import-body input:not([type])')), true, 'the title field, ready to type'); - assert.equal(await cards(p), 0); - // The editor: the same. - await tab(p, 'draw'); - await p.locator('.ed-art').waitFor(); + // A picture from a file comes without a name: signing asks for one. + await picture(p, await heart(env.dir)); + await p.click('.overlay .btn.key'); + assert.equal(await p.inputValue('.ed-title'), '', 'the file name is no card title'); await p.click('.ed-sign'); assert.match(await textMatching(p, '.toast span', /назовите/), /Сначала назовите карточку/); assert.equal(await p.evaluate(() => document.activeElement?.classList.contains('ed-title')), true); @@ -1103,8 +1100,15 @@ test('the profile keeps itself as it is typed, and the scanner frame turns the c await tab(p, 'receive'); assert.equal(await p.locator('section.view:visible .btn:text-is("Включить камеру")').count(), 0); await p.click('section.view:visible .scanner-start'); - assert.match(await textMatching(p, 'section.view:visible .scanner p', /Камера недоступна/), /Камера недоступна/, 'no camera here: said in the frame'); + assert.match(await textMatching(p, 'section.view:visible .scanner-start', /Нет доступа к камере/), /Нет доступа к камере.*настройках браузера/, 'no camera here: said in the frame'); assert.ok(await p.isVisible('section.view:visible .scanner-start'), 'and the frame still offers to try again'); + // The words stay inside the corners, however many lines they take. + const inside = await p.evaluate(() => { + const c = document.querySelector('section.view:not([hidden]) .scanner .corners').getBoundingClientRect(); + const s = document.querySelector('section.view:not([hidden]) .scanner-start small').getBoundingClientRect(); + return s.left >= c.left && s.right <= c.right && s.top >= c.top && s.bottom <= c.bottom; + }); + assert.ok(inside, 'the note inside the corners'); await p.context().close(); }); @@ -1164,3 +1168,194 @@ test('a row of kinds in the editor is a small menu: Esc or a tap elsewhere puts assert.equal(await chosen(), before, 'and nothing under it was pressed'); await p.context().close(); }); + +test('stored data: an older layout is upgraded in place, a newer one is never hidden behind an empty app', async () => { + // A collection as the last version left it: layout 1, records without tags. + const a = await env.phone('oldfox'); + await tab(a, 'me'); + await samples(a); + const dump = await a.evaluate(async () => { + const s = await import('/src/state.js'); + const st = await import('/src/store.js'); + return { name: s.app.stub ? 'pips-dev-stub' : 'pips', seed: [...(await st.getMeta('seed'))], profile: await st.getMeta('profile'), cards: (await st.allCards()).map((r) => ({ ...r, payload: [...r.payload], cert: r.cert && [...r.cert] })) }; + }); + const ctx = a.context(); + const b = await ctx.browser().newContext({ viewport: { width: 390, height: 844 }, locale: 'ru-RU' }); + const p = await b.newPage(); + await p.goto(`${env.url}sw.js`); // the same site, without the app (a plain text page) + await p.evaluate((d) => new Promise((ok, fail) => { + const req = indexedDB.open(d.name, 1); + req.onupgradeneeded = () => { req.result.createObjectStore('meta'); req.result.createObjectStore('cards', { keyPath: 'digest' }); }; + req.onsuccess = () => { + const t = req.result.transaction(['meta', 'cards'], 'readwrite'); + t.objectStore('meta').put(new Uint8Array(d.seed), 'seed'); + t.objectStore('meta').put(d.profile, 'profile'); + for (const r of d.cards) { + const { tags, ...old } = r; // what layout 1 could hold: no tags + t.objectStore('cards').put({ ...old, payload: new Uint8Array(r.payload), cert: r.cert && new Uint8Array(r.cert) }); + } + t.oncomplete = () => { req.result.close(); ok(); }; + t.onerror = () => fail(t.error); + }; + }), dump); + await p.goto(env.url); + await p.locator('nav.tabs').waitFor(); + const after = await p.evaluate(async (name) => { + const s = await import('/src/state.js'); + const st = await import('/src/store.js'); + const db = await new Promise((ok) => { const r = indexedDB.open(name); r.onsuccess = () => ok(r.result); }); + const v = db.version; + db.close(); + return { version: v, cards: s.app.cards.size, tagged: (await st.allCards()).every((r) => Array.isArray(r.tags)), nick: s.app.profile.nick }; + }, dump.name); + assert.deepEqual(after, { version: 2, cards: 18, tagged: true, nick: 'oldfox' }); + + // Another tab with a newer app takes the data: this one steps aside, and says so. + const q = await b.newPage(); + await q.goto(`${env.url}sw.js`); + await q.evaluate((name) => new Promise((ok) => { const r = indexedDB.open(name, 3); r.onsuccess = () => { r.result.close(); ok(); }; }), dump.name); + assert.match(await p.textContent('.halt'), /В другой вкладке открыта новая версия/); + // And now this app finds data newer than it knows: it says so instead of starting empty. + await p.goto(env.url); + assert.match(await p.locator('.halt').textContent(), /уже записаны более новой версией/); + assert.equal(await p.locator('nav.tabs').count(), 0, 'no empty app over the data'); + await b.close(); + await ctx.close(); +}); + +test('the editor: a still picture shows no frames to mistake for colours, and an empty canvas is not signed', async () => { + const p = await env.phone('stillfox'); + await tab(p, 'draw'); + await p.locator('.ed-art').waitFor(); + // One frame: no play tile, no thumbnail — one button that says what it does. + assert.ok(!(await p.isVisible('.ed-play')), 'nothing to play'); + assert.equal(await p.locator('.ed-frame-list .ed-frame:visible').count(), 0, 'no lone thumbnail'); + assert.match(await p.textContent('.ed-frame.add'), /Кадр анимации/); + const height = () => p.$eval('.ed-frames', (e) => e.getBoundingClientRect().height); + const h1 = await height(); + // Signing nothing: said, and nothing is made. + await p.fill('.ed-title', 'Пусто'); + await p.click('.ed-sign'); + assert.equal(await toast(p), 'Холст пуст — сначала нарисуйте что-нибудь'); + assert.equal(await p.evaluate(async () => (await import('/src/state.js')).app.cards.size), 0); + // Two frames: the strip of numbered frames, at the same height. + await p.click('.ed-frame.add'); + assert.ok(await p.isVisible('.ed-play')); + assert.deepEqual(await p.$$eval('.ed-frame-list .ed-frame-n', (e) => e.map((x) => x.textContent)), ['1', '2']); + assert.equal(await height(), h1, 'the strip does not jump'); + await p.context().close(); +}); + +test('give: one’s own cards with their codes, as a link, a file or a picture; a new card from one', async () => { + const p = await env.phone('givefox'); + await tab(p, 'give'); + assert.match(await p.textContent('section.view:visible .empty'), /Пока нечего отдать/); + await ownCard(p, 'Сердце'); + await ownCard(p, 'Второе'); + await tab(p, 'give'); + // The newest first, chosen; another one a tap away, on its own paper. + assert.equal(await p.locator('.give-mini:not(.add)').count(), 2); + assert.equal(await p.textContent('.give-paper .paper-body > b'), 'Второе'); + await p.click('.give-mini >> nth=1'); + assert.equal(await p.textContent('.give-paper .paper-body > b'), 'Сердце'); + assert.equal(await p.getAttribute('.give-mini >> nth=1', 'aria-pressed'), 'true'); + assert.ok(await p.isVisible('.give-paper .qr-box canvas'), 'the code, ready'); + // The other ways: a .pips file another phone opens, and the picture as a PNG. + const [file] = await Promise.all([p.waitForEvent('download'), p.click('.give-paper .share-link:has-text("Файлом")')]); + assert.equal(file.suggestedFilename(), 'Serdtse.pips', 'a download keeps to Latin letters'); + const pips = join(env.dir, 'given.pips'); + await file.saveAs(pips); + const [pic] = await Promise.all([p.waitForEvent('download'), p.click('.give-paper .share-link:has-text("Картинкой")')]); + assert.equal(pic.suggestedFilename(), 'Serdtse.png'); + const pngPath = join(env.dir, 'given.png'); + await pic.saveAs(pngPath); + const head = await readFile(pngPath); + assert.ok(head.readUInt32BE(16) >= 512, 'at least 512 pixels across'); + const q = await env.phone('lumen'); + await tab(q, 'receive'); + await choose(q, () => q.click('section.view:visible >> text=Файл .pips'), pips); + await q.waitForTimeout(800); + assert.equal(await cards(q), 1, 'the file opens on another phone'); + + // One's own card starts a new one in the editor: the picture, without the name. + await tab(p, 'collection'); + await p.evaluate(async () => (await import('/src/ui/shell.js')).go('collection', { open: [...(await import('/src/state.js')).app.cards.keys()][0] })); + await p.waitForTimeout(900); + await act(p, 'derive'); + await p.locator('.ed-art').waitFor(); + assert.match(await toast(p), /Новая карточка из прежней/); + assert.equal(await p.inputValue('.ed-title'), ''); + assert.equal(await p.$eval('.ed-layer', (c) => c.width), 16, 'the heart’s canvas'); + for (const x of [p, q]) await x.context().close(); +}); + +test('a picture of a card opens as the card: from the file, or by its code after re-encoding', async () => { + const p = await env.phone('picfox'); + await ownCard(p, 'Сердце'); + await tab(p, 'give'); + const [pic] = await Promise.all([p.waitForEvent('download'), p.click('.give-paper .share-link:has-text("Картинкой")')]); + const file = join(env.dir, 'card-picture.png'); + await pic.saveAs(file); + const bytes = await readFile(file); + assert.equal(bytes.readUInt32BE(16), 1080, 'a card-shaped picture'); + + // As sent, a file: the card itself, signed, on another phone. + const q = await env.phone('lumen'); + await tab(q, 'receive'); + await fromPicture(q, 'section.view:visible', file); + await q.locator('.turn:not([hidden])').waitFor(); + const got = await q.evaluate(async () => [...(await import('/src/state.js')).app.cards.values()].map((e) => [e.card.title, e.author?.hex.length > 0])); + assert.deepEqual(got, [['Сердце', true]], 'the card, with its author'); + + // Re-encoded on the way (a messenger, a screenshot): the code on it. + const jpeg = join(env.dir, 'card-picture.jpg'); + const data = await p.evaluate(async (b64) => { + const img = await createImageBitmap(new Blob([Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))], { type: 'image/png' })); + const cv = new OffscreenCanvas(img.width / 2, img.height / 2); + cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height); + const blob = await cv.convertToBlob({ type: 'image/jpeg', quality: 0.8 }); + return btoa(String.fromCharCode(...new Uint8Array(await blob.arrayBuffer()))); + }, bytes.toString('base64')); + await writeFile(jpeg, Buffer.from(data, 'base64')); + const r = await env.phone('rook'); + await tab(r, 'receive'); + await fromPicture(r, 'section.view:visible', jpeg); + await r.locator('.turn:not([hidden])').waitFor({ timeout: 15000 }); + assert.equal(await cards(r), 1, 'read off the picture'); + + // In the editor, the picture says what it is before anything else. + await picture(r, file); + assert.match(await r.textContent('.overlay .pic-card'), /карточка «Сердце», @picfox/); + await r.click('.overlay .pic-card .btn'); + await r.locator('.overlay').waitFor({ state: 'detached' }); + assert.equal(await cards(r), 1, 'the same card: nothing new'); + for (const x of [p, q, r]) await x.context().close(); +}); + +test('the code on a card picture takes the paper’s colours and still reads, on every paper, after JPEG', async () => { + const p = await env.phone('shadefox'); + const results = await p.evaluate(async () => { + const s = await import('/src/state.js'); + const { cardPicture } = await import('/src/ui/card-picture.js'); + const { qrReader } = await import('/src/qrscan.js'); + const reader = await qrReader(); + const out = []; + for (const scale of [1, 0.75]) for (let shade = 0; shade < 8; shade++) { + const card = { width: 16, height: 16, opaque: true, palette: { builtin: 0 }, frame: shade, frames: 1, delay_ms: 0, title: `Рамка ${shade}`, author: 'shadefox', handle: null, link: '', pixels: new Uint8Array(256).map((_, i) => i % 7) }; + const env = s.app.identity.sealAsAuthor(s.app.identity.signCard(card), undefined); + const png = await cardPicture(card, env); + // As a messenger passes a photo on: a JPEG, at the size it was (they + // keep 1280 px), and a screenshot of it on a smaller screen. + const img = await createImageBitmap(png); + const cv = new OffscreenCanvas(img.width * scale, img.height * scale); + cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height); + const jpeg = await createImageBitmap(await cv.convertToBlob({ type: 'image/jpeg', quality: 0.75 })); + const texts = await reader.read(jpeg); + const want = s.app.core.qrPlan(env).text; + out.push(texts.includes(want)); + } + return out; + }); + assert.deepEqual(results, Array(16).fill(true), `every paper, at both sizes: ${results.map(Number).join('')}`); + await p.context().close(); +}); diff --git a/tests/unit/editor.test.mjs b/tests/unit/editor.test.mjs index d3ada38..0be0e0e 100644 --- a/tests/unit/editor.test.mjs +++ b/tests/unit/editor.test.mjs @@ -242,3 +242,42 @@ test('the pen: corners come out of a curve, never out of what was already drawn' draw(strokes, [[0, 0], [1, 0], [1, 1], [2, 1], [2, 2]]); assert.deepEqual([at(0, 0), at(1, 0), at(1, 1), at(2, 1), at(2, 2)], [1, 1, 1, 1, 1]); }); + +test('a card back into a picture: the same card again, smaller ones centred, others refused', async () => { + const { fromCard } = await import('../../web/src/editor/from-card.js'); + const rgbOf = (c) => [c >> 16, (c >> 8) & 255, c & 255]; + const builtin = (id) => (id === 7 ? PAL.colors.map(rgbOf) : undefined); + const by = { nick: 'a', handle: null, link: '' }; + const m = createModel(blank(16, PAL)); + const s = m.stroke(0); + s.set(0, 2); + s.set(17, 3); + s.commit('pen'); + m.setTitle?.('Гриб'); + for (const draw of [() => {}, () => { const t = m.stroke(0); t.set(5, 0); t.commit('pen'); }]) { + draw(); // the second time the first colour is drawn too: the card's colours go inline + const card = /** @type {any} */ (toCard(m.doc, by)).card; + const doc = fromCard(card, builtin); + assert.ok(doc); + assert.equal(doc.size, 16); + const again = /** @type {any} */ (toCard(doc, by)).card; + assert.deepEqual([...again.pixels], [...card.pixels], 'the same cells'); + assert.deepEqual(again.palette, card.palette, 'the same palette'); + assert.equal(again.opaque, card.opaque); + } + // A 40-cell card: in the middle of the 48 canvas. + const small = { width: 40, height: 40, opaque: true, palette: { inline: [[0, 0, 0], [255, 255, 255]] }, frames: 1, delay_ms: 0, pixels: new Uint8Array(1600).fill(1), title: 'x', frame: 2 }; + const d = fromCard(small, builtin); + assert.equal(d.size, 48); + assert.equal(d.frames[0][0], T, 'the border is empty'); + assert.equal(d.frames[0][4 * 48 + 4], 1, 'the picture starts 4 cells in'); + assert.equal(d.shade, 2); + // Not square: the whole of it, in the middle of the canvas that holds it. + const wide = fromCard({ ...small, width: 10, height: 8, pixels: new Uint8Array(80).fill(1) }, builtin); + assert.equal(wide.size, 16); + assert.deepEqual([wide.frames[0][3 * 16 + 2], wide.frames[0][4 * 16 + 3], wide.frames[0][11 * 16 + 12]], [T, 1, 1], 'from (3, 4) to (12, 11)'); + assert.equal(wide.frames[0].filter((v) => v === 1).length, 80, 'every cell of it'); + // Larger than the largest canvas: crop first. + assert.equal(fromCard({ ...small, width: 128, height: 128 }, builtin), null); + assert.equal(fromCard({ ...small, width: 80, height: 8 }, builtin), null); +}); diff --git a/tests/unit/png-card.test.mjs b/tests/unit/png-card.test.mjs new file mode 100644 index 0000000..5635895 --- /dev/null +++ b/tests/unit/png-card.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { deflateSync, crc32 as zcrc } from 'node:zlib'; +import { withEnvelope, envelopeIn, isPng, crc32 } from '../../web/src/png-card.js'; + +/** A real 2×2 PNG, as any program writes it. */ +function tinyPng() { + const chunk = (type, data) => { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const body = Buffer.concat([Buffer.from(type), data]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(zcrc(body)); + return Buffer.concat([len, body, crc]); + }; + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(2, 0); + ihdr.writeUInt32BE(2, 4); + ihdr.set([8, 6, 0, 0, 0], 8); + const rows = Buffer.from([0, 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 255, 255, 255, 255, 255, 255]); + return new Uint8Array(Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk('IHDR', ihdr), chunk('IDAT', deflateSync(rows)), chunk('IEND', Buffer.alloc(0))])); +} + +test('PNG CRC-32 is the standard one', () => { + const b = new Uint8Array(Buffer.from('IEND')); + assert.equal(crc32(b), zcrc(Buffer.from('IEND'))); +}); + +test('a card goes into a picture and comes out the same; the picture stays a PNG', () => { + const png = tinyPng(); + const env = new Uint8Array([0x45, 1, 2, 3, 250, 0, 7]); + const out = withEnvelope(png, env); + assert.ok(isPng(out)); + assert.deepEqual([...envelopeIn(out)], [...env]); + assert.equal(Buffer.from(out.subarray(out.length - 8, out.length - 4)).toString(), 'IEND', 'the end stays the end'); + assert.deepEqual([...out.subarray(0, png.length - 12)], [...png.subarray(0, png.length - 12)], 'the picture itself is untouched'); + // A second card replaces the first: one card per picture. + const again = withEnvelope(out, new Uint8Array([0x45, 9])); + assert.deepEqual([...envelopeIn(again)], [0x45, 9]); + assert.equal(again.length, png.length + 12 + 1 + 2); + assert.equal(envelopeIn(png), null, 'a plain picture holds no card'); +}); + +test('a damaged or foreign file holds no card, and never throws', () => { + const out = withEnvelope(tinyPng(), new Uint8Array([0x45, 1, 2, 3])); + for (let i = 8; i < out.length; i++) { + const bad = out.slice(); + bad[i] ^= 0x10; + const r = envelopeIn(bad); + assert.ok(r === null || r.length === 3, `a flip at ${i}`); + if (r) assert.notDeepEqual([...r], [1, 2, 3].map((x) => x ^ 0x10), 'a changed card is never taken'); + } + for (let n = 0; n < out.length; n += 7) assert.equal(envelopeIn(out.subarray(0, n)), null, `cut at ${n}`); + assert.equal(envelopeIn(new Uint8Array([1, 2, 3])), null); + let seed = 7; + for (let k = 0; k < 300; k++) { + const junk = Uint8Array.from({ length: 64 + (k % 50) }, () => (seed = (seed * 1103515245 + 12345) & 0xff)); + junk.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + assert.equal(envelopeIn(junk), null); + } + assert.throws(() => withEnvelope(new Uint8Array([1, 2, 3]), new Uint8Array([0x45])), /not a PNG/); +}); diff --git a/tests/unit/schema.test.mjs b/tests/unit/schema.test.mjs new file mode 100644 index 0000000..b7e1269 --- /dev/null +++ b/tests/unit/schema.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { SCHEMA, STEPS, fillCard, stepsFrom } from '../../web/src/schema.js'; + +test('one step for every version up to this one', () => { + assert.equal(STEPS.length, SCHEMA); + assert.deepEqual(stepsFrom(0), STEPS); + assert.deepEqual(stepsFrom(SCHEMA), []); + assert.throws(() => stepsFrom(SCHEMA + 1), RangeError, 'data from a newer app has no way back'); + assert.throws(() => stepsFrom(-1), RangeError); +}); + +test('1 → 2: an older card record gains the fields it lacked, and keeps its own', () => { + const old = { digest: 'd', payload: new Uint8Array([1]), mine: false, state: 'archive', received: 5, note: 'с ярмарки' }; + const rec = fillCard(old); + assert.deepEqual(rec, { ...old, cert: null, edition: null, trashedAt: null, from: null, tags: [] }); + assert.equal(rec.note, 'с ярмарки'); + assert.equal(fillCard(rec), rec, 'a full record comes back as it is: nothing to write'); + assert.deepEqual(fillCard({ ...rec, tags: 'нет' }).tags, [], 'tags are a list'); +}); diff --git a/web/css/app.css b/web/css/app.css index 4b6a0aa..b053725 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -177,7 +177,10 @@ svg.i { width: 22px; height: 22px; stroke: currentColor; fill: none; stroke-widt /* Off, the frame is the button that turns the camera on; on, the words stay under the picture, out of the way of the code. */ .scanner-start { position: absolute; inset: 0; border: 0; background: none; display: flex; flex-direction: column; - align-items: center; justify-content: center; gap: 10px; color: var(--cobalt-hi); font-size: 15px; } + align-items: center; justify-content: center; gap: 10px; color: var(--cobalt-hi); font-size: 15px; + padding: 0 calc(18% + 14px); } /* the words stay inside the corners */ +.scanner-start small { color: var(--text3); font-size: 12.5px; line-height: 1.4; text-align: center; } +.scanner.live .scanner-start { display: none; } .scanner-start svg { width: 36px; height: 36px; } .scanner-start:active { background: #ffffff08; } .scanner-start:focus-visible { outline: 2px solid var(--cobalt-hi); outline-offset: -4px; border-radius: 22px; } @@ -237,21 +240,9 @@ a.btn { text-decoration: none; } @media (prefers-reduced-motion: reduce) { * { transition-duration: 0s !important; animation: none !important; } } -/* Import */ -.import-body { flex: 1; overflow-y: auto; padding: 4px 16px calc(24px + var(--safe-b, 0px)); } -.import-preview { display: block; position: relative; width: min(260px, 70vw, calc((100dvh - 470px) / 1.26)); min-width: 180px; - margin: 8px auto 0; padding: 0; border: 0; background: none; color: inherit; text-align: left; cursor: pointer; } -.import-preview:focus-visible { outline: 2px solid var(--cobalt); outline-offset: 6px; border-radius: 4px; } -/* An empty frame waiting for a picture: soft fill, like a passe-partout. */ -.frame.blank .art { background: color-mix(in srgb, var(--shade) 72%, #9a8f7c); } -.frame.blank .cap b { color: var(--shade-dim); font-weight: 500; } +/* A picture from a file (editor/picture.js): what the conversion did. */ /* A card that came without a name (another app, an old card). */ .cap b.untitled, h3.untitled { font-style: italic; font-weight: 500; opacity: .7; } -.import-empty { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; - color: color-mix(in srgb, var(--shade-ink) 70%, transparent); text-align: center; padding: 8%; } -.import-empty .i { width: 30%; height: auto; stroke-width: 1.2; opacity: .7; } -.import-empty b { font-size: max(12px, 6cqw); font-weight: 600; } -.import-empty small { font-size: max(10px, 4.4cqw); opacity: .75; overflow-wrap: anywhere; } .import-original { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; } .import-original.dim { opacity: .45; } .import-chip { position: absolute; top: -10px; right: -10px; background: var(--raise2); color: var(--text2); font-size: 11px; @@ -261,8 +252,6 @@ a.btn { text-decoration: none; } .import-status.bad { color: var(--warn); font-weight: 500; } .import-extras:empty { display: none; } .import-extras .check { margin: 10px 0; } -.import-actions { margin-top: 20px; flex-direction: column; } -.import-body .group { margin-top: 16px; } .field.attention { box-shadow: inset 0 0 0 2px var(--cobalt); } .shades { display: flex; flex-wrap: wrap; gap: 8px; } .shade { width: 34px; height: 34px; border-radius: 9px; border: 0; box-shadow: inset 0 0 0 1px #0003; } @@ -314,7 +303,31 @@ a.btn { text-decoration: none; } /* ------------------------------------------------------------ wide screens -- */ /* Forms and lists keep a phone's comfortable width, centred; buttons stay the size of what they say instead of crossing the screen. */ -.view:not(.collection-view) > .scroll, .view > .import-body, .overlay > .scroll, .overlay > .sheet-foot, .onb { +.view:not(.collection-view) > .scroll, .overlay > .scroll, .overlay > .sheet-foot, .onb { padding-left: max(16px, calc((100% - 560px) / 2)); padding-right: max(16px, calc((100% - 560px) / 2)); } .view:not(.collection-view, .ed-view) > .bar, .overlay > .bar { padding-left: max(20px, calc((100% - 560px) / 2 + 4px)); padding-right: max(10px, calc((100% - 560px) / 2 - 6px)); } + +/* The app cannot go on with the data as it is (store.js): over everything. */ +.halt { position: fixed; inset: 0; z-index: 450; /* under the journal, if opened from here */ background: var(--void); display: flex; align-items: center; justify-content: center; padding: 24px; } +.halt > div { max-width: 360px; } +.halt h1 { margin: 0 0 10px; font: 600 24px/1.2 var(--serif); } +.halt p { color: var(--text2); line-height: 1.5; margin: 0 0 20px; } + +/* ---------------------------------------------------------------- give --- */ +/* ui/give.js: the chosen card on its paper, with its code; the row of + one's cards below, the chosen one lifted. */ +.give-scroll { display: flex; flex-direction: column; align-items: center; padding-top: 4px; } +.give-paper { --shade: var(--paper); --shade-ink: var(--ink); --shade-dim: var(--dim); + width: min(100%, 380px); background: var(--shade); color: var(--shade-ink); border-radius: 6px; padding: 16px 14px 14px; + box-shadow: 0 30px 50px -26px #000; } +.paper-body { display: flex; flex-direction: column; align-items: center; gap: 12px; } +.give-deck-box { flex: none; } +.give-deck-head { display: flex; justify-content: space-between; padding: 6px 20px 0; font-size: 12px; font-weight: 500; color: var(--text3); } +.give-deck { display: flex; gap: 14px; align-items: flex-end; overflow-x: auto; scrollbar-width: none; padding: 16px 20px 26px; } /* clear of the raised scan button */ +.give-deck::-webkit-scrollbar { display: none; } +.give-mini { flex: none; width: 76px; height: 76px; padding: 6px; border: 0; border-radius: 3px; background: var(--shade, var(--paper)); + opacity: .82; box-shadow: 0 8px 12px -9px #000; transition: transform .25s cubic-bezier(.2, .9, .25, 1), box-shadow .25s, opacity .25s; } +.give-mini canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; } +.give-mini[aria-pressed="true"] { opacity: 1; transform: translateY(-10px); box-shadow: 0 16px 20px -12px #000, 0 0 0 2px var(--cobalt-hi); } +.give-mini.add { background: none; opacity: 1; box-shadow: inset 0 0 0 1px var(--line); color: var(--text2); display: grid; place-items: center; } diff --git a/web/css/collection.css b/web/css/collection.css index f9f94fc..c868e73 100644 --- a/web/css/collection.css +++ b/web/css/collection.css @@ -156,7 +156,8 @@ input.tag.add::placeholder { color: var(--shade-dim); } .share-code p { margin: 0; font-size: 13px; text-align: center; max-width: 34ch; color: var(--shade-dim); } .share-code p.small { font-size: 11.5px; } .share-code .qr-box { box-shadow: none; } -.share-link { margin-top: 4px; display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 99px; padding: 8px 14px; +.share-ways { margin-top: 4px; display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; } +.share-link { display: inline-flex; align-items: center; gap: 6px; border: 0; border-radius: 99px; padding: 8px 12px; background: color-mix(in srgb, var(--shade-ink) 8%, transparent); color: var(--shade-ink); font: 500 13px var(--sans); } .share-link .i { width: 16px; height: 16px; } /* The numbered-copy panel inside the sheet takes the paper's colours. */ diff --git a/web/css/editor.css b/web/css/editor.css index 73f77b4..55dff3b 100644 --- a/web/css/editor.css +++ b/web/css/editor.css @@ -53,7 +53,6 @@ .ed-preview-art { width: 32px; height: 32px; image-rendering: pixelated; } .ed-play-badge { position: absolute; right: -4px; bottom: -4px; width: 20px; height: 20px; border-radius: 50%; background: var(--cobalt); color: #fff; display: grid; place-items: center; } .ed-play-badge .i { width: 10px; height: 10px; stroke-width: 2; } -.ed-play.still .ed-play-badge { display: none; } .ed-frame-scroll { flex: 1; min-width: 0; display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; padding-right: 24px; -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); } .ed-frame-scroll::-webkit-scrollbar { display: none; } @@ -62,6 +61,10 @@ .ed-frame canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; } .ed-frame[aria-pressed="true"] { opacity: 1; box-shadow: inset 0 0 0 3px var(--cobalt-hi); } .ed-frame.add { opacity: 1; background: none; border: 1px dashed var(--line); color: var(--text2); display: grid; place-items: center; } +.ed-frame.add.first { width: auto; display: flex; gap: 6px; padding: 0 14px; font-size: 13px; } +.ed-frame { position: relative; } +.ed-frame-n { position: absolute; left: 3px; top: 2px; font-size: 9px; font-weight: 600; line-height: 1; color: var(--ink); opacity: .55; } +.ed-frames { min-height: 66px; } /* the same height with one frame or many: the play tile and its padding */ .ed-onion-btn { flex: none; height: 44px; border: 0; border-radius: 12px; padding: 0 10px; background: var(--raise2); color: var(--text2); font-size: 12px; line-height: 1.25; text-align: left; display: flex; flex-direction: column; justify-content: center; } .ed-onion-btn b { font-weight: 500; } @@ -176,3 +179,34 @@ .ed-frames { justify-content: center; padding-bottom: 18px; } .ed-frame-scroll { flex: 0 1 auto; } } + +/* editor/picture.js: a picture from a file. The result is shown on the + frame's paper, without a caption: it has no name yet. */ +.pic-body { display: flex; flex-direction: column; align-items: stretch; gap: 4px; padding-bottom: 12px; } +.pic-tile { position: relative; align-self: center; width: min(240px, 62vw); aspect-ratio: 1; padding: 10px; border: 0; border-radius: 3px; + background: var(--paper); box-shadow: 0 24px 40px -22px #000; display: block; } +.pic-tile canvas, .pic-tile img { display: block; width: 100%; height: 100%; image-rendering: pixelated; object-fit: contain; } +.pic-tile.small { width: 88px; padding: 6px; align-self: auto; flex: none; box-shadow: 0 14px 22px -14px #000; } +.pic-why { text-align: center; margin: 0 0 10px; } +.pic-stage { position: relative; align-self: center; max-width: 100%; touch-action: none; user-select: none; -webkit-user-select: none; overflow: hidden; border-radius: 6px; } +.pic-source { display: block; } +.pic-source.pixel { image-rendering: pixelated; } +.pic-crop { position: absolute; box-shadow: 0 0 0 9999px #0b0c10b8, inset 0 0 0 1px #fffd; cursor: move; } +.pic-crop .c { position: absolute; width: 22px; height: 22px; border: 0 solid #fff; pointer-events: none; } +.pic-crop .tl { left: -3px; top: -3px; border-width: 4px 0 0 4px; } +.pic-crop .tr { right: -3px; top: -3px; border-width: 4px 4px 0 0; } +.pic-crop .bl { left: -3px; bottom: -3px; border-width: 0 0 4px 4px; } +.pic-crop .br { right: -3px; bottom: -3px; border-width: 0 4px 4px 0; } +.pic-result { display: flex; align-items: center; gap: 14px; margin-top: 16px; } +.pic-result .import-status { flex: 1; text-align: left; margin: 0; min-height: 0; } +.pic-body .ed-set-row { margin-top: 14px; } + +/* The two ways to start a card, first in the editor's settings. */ +.ed-new { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } +.ed-new-tile { border: 0; border-radius: 14px; background: var(--raise2); color: var(--text); padding: 12px 14px; text-align: left; + display: flex; flex-direction: column; align-items: flex-start; gap: 2px; font: inherit; } +.ed-new-tile .i { width: 22px; height: 22px; color: var(--cobalt-hi); margin-bottom: 6px; } +.ed-new-tile b { font-weight: 500; font-size: 14px; } +.ed-new-tile small { font-size: 12px; color: var(--text3); } +.pic-card { background: var(--raise); border-radius: 14px; padding: 14px; margin-bottom: 16px; box-shadow: inset 0 0 0 1px var(--line); } +.pic-card p { margin: 0 0 12px; font-size: 13.5px; line-height: 1.45; color: var(--text2); } diff --git a/web/css/kit/top-sheet.css b/web/css/kit/top-sheet.css index 953e8ea..1762421 100644 --- a/web/css/kit/top-sheet.css +++ b/web/css/kit/top-sheet.css @@ -7,11 +7,11 @@ transform: translateY(-101%); } .top-sheet.anim { transition: transform .55s cubic-bezier(.2, .9, .25, 1); } .top-sheet-body { width: 100%; max-width: 420px; display: flex; flex-direction: column; align-items: center; gap: 12px; } -.top-sheet-body > b { font: 600 19px/1.15 var(--serif); text-align: center; } +.top-sheet-body > b, .paper-body > b { font: 600 19px/1.15 var(--serif); text-align: center; } .top-sheet .grab { width: 40px; height: 5px; border-radius: 3px; background: color-mix(in srgb, var(--shade-ink) 25%, transparent); } -.top-sheet .seg { background: color-mix(in srgb, var(--shade-ink) 8%, transparent); box-shadow: none; } -.top-sheet .seg button { color: var(--shade-dim); } -.top-sheet .seg button[aria-pressed="true"] { background: var(--shade); color: var(--shade-ink); box-shadow: 0 1px 3px #0003; } +:is(.top-sheet, .on-paper) .seg { background: color-mix(in srgb, var(--shade-ink) 8%, transparent); box-shadow: none; } +:is(.top-sheet, .on-paper) .seg button { color: var(--shade-dim); } +:is(.top-sheet, .on-paper) .seg button[aria-pressed="true"] { background: var(--shade); color: var(--shade-ink); box-shadow: 0 1px 3px #0003; } .scrim { position: absolute; z-index: 59; inset: 0; background: #000; opacity: 0; pointer-events: none; } .scrim.anim { transition: opacity .4s; } .scrim.on { pointer-events: auto; } diff --git a/web/samples/samples.js b/web/samples/samples.js index 4244cab..de86361 100644 --- a/web/samples/samples.js +++ b/web/samples/samples.js @@ -3,5 +3,5 @@ // characters, '.' empty, else an index into `colors` (or into the built-in // palette `builtin`). FILES: pictures beside this file, through the import. // `mine`: signed with the person's own key; the rest come from made-up artists. -export const DRAWN = [{"title": "Грибок", "mine": true, "size": 16, "colors": ["#2a1d1b", "#c4392f", "#f6f3eb", "#8c2a25", "#efe7d6", "#b9ad98"], "shade": 0, "delay": 420, "frames": [["................", ".....000000.....", "...0011111100...", "..011221111110..", ".01122211112210.", ".01112111112210.", "0111111121111110", "0122111111111110", ".03333333333330.", "..000044440000..", ".....044440.....", "....04444550....", "....04444550....", "....04445550....", ".....000000.....", "................"], ["................", "................", ".....000000.....", "...0011111100...", "..011221111110..", ".01122211112210.", ".01112111112210.", "0111111121111110", "0122111111111110", ".03333333333330.", ".....044440.....", "....04444550....", "....04444550....", "....04445550....", ".....000000.....", "................"]]}, {"title": "Сумеречный гриб", "mine": false, "size": 16, "colors": ["#14122b", "#5b4bb5", "#d8d1ff", "#352b7a", "#c6c9e8", "#8b8fb8"], "shade": 6, "delay": 420, "frames": [["................", ".....000000.....", "...0011111100...", "..011221111110..", ".01122211112210.", ".01112111112210.", "0111111121111110", "0122111111111110", ".03333333333330.", "..000044440000..", ".....044440.....", "....04444550....", "....04444550....", "....04445550....", ".....000000.....", "................"], ["................", "................", ".....000000.....", "...0011111100...", "..011221111110..", ".01122211112210.", ".01112111112210.", "0111111121111110", "0122111111111110", ".03333333333330.", ".....044440.....", "....04444550....", "....04444550....", "....04445550....", ".....000000.....", "................"]]}, {"title": "Лунная дорожка", "mine": true, "size": 32, "colors": ["#0d1022", "#1b2142", "#2e3a6b", "#4a5d96", "#f3e9c6", "#c9bd94", "#0a1a2c", "#163450", "#2a5b78", "#e7dcb0"], "shade": 7, "delay": 0, "frames": [["00000000000000000000000000000000", "00000000000000000000000000000000", "00100010001000900010001000100010", "00040000000000000000000000000900", "10101010101010101010444440101010", "01010101010101010104444444010101", "10101010141010101044444444401010", "01010101010101010144554444410101", "11111111111111111144444444411111", "11111111111111111944444454411111", "11211121112111211144454544411121", "11111111111111111114444444111111", "21212921212121212121444441212121", "12121212121212121212121212121212", "22222222222222222222222222222222", "12221222122212221222122212241222", "32223222322232223222322232223222", "22222222222222222222222222222222", "32323232323232323232323232323232", "23232323232323232323232323232323", "33333333333333333333333333333333", "66666666666666666666666666666666", "66766676667666766676699666766676", "66676667666766676667666766676667", "77767776777677767776779697767776", "77677767776777677767776777677767", "77777777777777777777557577777777", "77777777777777777777777777777777", "87878787878787878787875555878787", "78787878787878787878787878787878", "88888888888888888885855558888888", "88888888888888888888888888888888"]]}, {"title": "Горный рассвет", "mine": false, "size": 48, "colors": ["#2b1d3a", "#5a2f55", "#a14d63", "#e3825c", "#f6c27a", "#fff1c9", "#453a63", "#2f2b4c", "#1d1b31", "#f2eef5", "#8c86ad"], "shade": 1, "delay": 0, "frames": [["000000000000000000000000000000000000000000000000", "000000000000000000000000000000000000000000000000", "101010101010101010101010101010101010101010101010", "000100010001000100010001000100010001000100010001", "111011101110111011101110111011101110111011101110", "010101010101010101010101010101010101010101010101", "111111111111111111111111111111111111111111111111", "011101110111011101110111011101110111011101110111", "211121112111211121112111211121112111211121112111", "111111111111111111111111111111111111111111111111", "212121212121212121212121212121212121212121212121", "111211121112111211121112111211121112111211121112", "222122212221222122212221222122212221222122212221", "121212121212121212121212121212121212121212121212", "222222222222222222222222222222222222222222222222", "122212221222122212221222122244444222122212221222", "222222222222222222222222224444544442222222222222", "222222222222222222222222244555555544222222222222", "323232323232323232323232345555555554323232323232", "222322232223222322232223445555555554422322232223", "323232323232323232323232445555555554423232323232", "999323232323232323232323455555555555432323232323", "999932333233323332333233445555555554423332333233", "6969a3232323aaa3232323234455555555544323232323aa", "66696a33333a666a3333333334555555555aaaa33333aa66", "666666a333a66666a33333333445555555a6666a333a6666", "6666666aaa6666666a4333433344445444666666aaa66666", "666666666666666666333333333344444a66666666666666", "666666666666666666a34343434343434666666666666666", "66666666666666666664343434343434a666666666666666", "6666666666666666666a4343434343477776666666666666", "66666666666666666666aa34343434777777666666666666", "66666666666666666666777aa44447777777766666666666", "6666666666666666667777777aa477777777776666666666", "666666666666667777777777777777777777777766666666", "666666666666777777777777777777777777777777666666", "766666666667777777777777777777777777777777766666", "776666666677777777777777777777777777777777777666", "777666666777777777777777777777777777777777778886", "777766888887777777777777777777777777777777888888", "888888888888877777777777777777777777777778888888", "888888888888887777777777777777777777777788888888", "888888888888888777777777777778888887777888888888", "888888888888888877777788888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888"]]}, {"title": "Горная ночь", "mine": false, "size": 48, "colors": ["#070a17", "#0e1430", "#172152", "#22306e", "#2d3d82", "#e8eefc", "#202a4f", "#151c38", "#0b0f22", "#c7d3f2", "#5d6ea8"], "shade": 6, "delay": 0, "frames": [["000000000000000000000000000000000000000000000000", "000000000000000000000000000000000000000000000000", "101010101010101010101010101010101010101010101010", "000100010001000100010001000100010001000100010001", "111011101110111011101110111011101110111011101110", "010101010101010101010101010101010101010101010101", "111111111111111111111111111111111111111111111111", "011101110111011101110111011101110111011101110111", "211121112111211121112111211121112111211121112111", "111111111111111111111111111111111111111111111111", "212121212121212121212121212121212121212121212121", "111211121112111211121112111211121112111211121112", "222122212221222122212221222122212221222122212221", "121212121212121212121212121212121212121212121212", "222222222222222222222222222222222222222222222222", "122212221222122212221222122244444222122212221222", "222222222222222222222222224444544442222222222222", "222222222222222222222222244555555544222222222222", "323232323232323232323232345555555554323232323232", "222322232223222322232223445555555554422322232223", "323232323232323232323232445555555554423232323232", "999323232323232323232323455555555555432323232323", "999932333233323332333233445555555554423332333233", "6969a3232323aaa3232323234455555555544323232323aa", "66696a33333a666a3333333334555555555aaaa33333aa66", "666666a333a66666a33333333445555555a6666a333a6666", "6666666aaa6666666a4333433344445444666666aaa66666", "666666666666666666333333333344444a66666666666666", "666666666666666666a34343434343434666666666666666", "66666666666666666664343434343434a666666666666666", "6666666666666666666a4343434343477776666666666666", "66666666666666666666aa34343434777777666666666666", "66666666666666666666777aa44447777777766666666666", "6666666666666666667777777aa477777777776666666666", "666666666666667777777777777777777777777766666666", "666666666666777777777777777777777777777777666666", "766666666667777777777777777777777777777777766666", "776666666677777777777777777777777777777777777666", "777666666777777777777777777777777777777777778886", "777766888887777777777777777777777777777777888888", "888888888888877777777777777777777777777778888888", "888888888888887777777777777777777777777788888888", "888888888888888777777777777778888887777888888888", "888888888888888877777788888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888"]]}, {"title": "Северное сияние", "mine": false, "size": 64, "colors": ["#05070f", "#0b1424", "#13253a", "#1d8a6b", "#43d39a", "#a8f5c8", "#6e4fb3", "#e9f1f7", "#b9c7d6", "#163c2d", "#24594a", "#ffffff"], "shade": 7, "delay": 0, "frames": [["0000000000000000000000000000000000000000000000000000000000000000", "0000000000000000000000000000000000000003030300000000000000000000", "00000000000000000000000000000000000000333b3330000000000000000000", "0000000000000000000080000000000000000303333333000000000000000000", "1010101010101010101010101010101010103333333333303010101010101010", "0000800000000000000000000000000000033333333333330300000000000000", "1010101010101010101010101010101010333334444443333030101010801010", "0000000000000000000000000000000003333444444444333303000000000000", "1010101010101010101010101010101033334444444444433330331010101010", "0100010001000100010001000100010333344445555554444333030303000100", "3010101010101010106660101010103333444555555555444433333330333030", "3301010101010101060606060103033334445551010105544443333333030303", "3330101010101010663336606330333344455010101010555444333333333330", "3333010101010106333333330633333444550101010101055544443333333333", "4333303110133063333333333366634445511011101110111554444444333333", "4433330303030633334443333336364656060606010101010155444444444443", "4443333133336333444444443333336566616661661111111115554444444444", "5444333333363334444444444333333333333333060101010101555555444446", "5544433333633344445554444443333333333333336611111111115555555563", "0554444336333444555555554444443333333333333606010101010101565633", "1155446463334445555555555444444444444444333366611111111111616333", "1616564633344455555555555554444444444444443333161111111116133334", "6166653333444555511111111555554444444444444333366111111161333344", "3333333334445511111111111115555555555555444433331616161613333444", "3333333344455111211121112111215555555555554444333661666133344445", "3333334444551111111111111111111111111111555444433333333333444455", "4444444445511121112111211121112111211121115544443333333334444551", "4444444455111111111111111111111111111111111555444333333344455511", "4444445551212121212121212121212121212121212155544444444444555121", "5555555511111111111111111111111111111111111111554444444445511111", "5555552121212121212121212121212121212121212121255444444455212121", "1112111211121112111211121112111211121112111211125555555551121112", "2121212121212121212121212121212121212121212121212555555521212121", "1212121212121212121212121212121212121212121212121212121212121212", "2121212121212121212121212121212121212121212121212121212121212121", "1212121212121212121212121212121212121212121212121212121212121212", "2221222122212221222122212221222122212a21222122212221222122212221", "1212121212121212121292121212121212121a12121212121212921212121212", "21222a22212221222122a122212221222122aa92212221222122a12221222122", "12121a1212121212121aaa12121212121212a9a212121212121aaa1212129212", "2222aa9222222222222aa9222222222222229aa222222222222aa9222222a222", "2212a9a222122212221a9a1222122a122219aaa9221222a2221a9a12221aaa12", "22229aa22222222222a9aaa222222a22222aaa9a222222a222a9aaa2222aa922", "1229aaa912221a22129aaa921222aa92122aa9aa12221a9a129aaa92122a9a22", "222aaa9a22222a2222aaa9a22222a9a2222a9aaa222229aa22aaa9a222a9aaa2", "222aa9aa2222aa9222aa9aa222229aa222a9aaa9a2222aaa22aa9aa2229aaa92", "222a9aaa2222a9a22aa9aaa92229aaa9229aaa9aa222aaa9aaa9aaa922aaa9a2", "22a9aaa9a2229aa22a9aaa9a222aaa9a22aaa9aaa222aa9aaa9aaa9a22aa9aa2", "229aaa9aa229aaa929aaa9aa222aa9aa2aaa9aaa9a22a9aaa9aaa9aa2aa9aaa9", "22aaa9aaa22aaa9a9aaa9aaa922a9aaa2aa9aaa9aa229aaa9aaa9aaa9a9aaa9a", "2aaa9aaa9a2aa9aaaaa9aaa9a2a9aaa9aa9aaa9aaa7777977777977777779777", "2aa9aaa9aa2a9aaaaa9aaa9aa29aaa9aa2277977777777777777777777777777", "7777792222a9aaaaa9aaa9aaa922297777777777777777777777777777777777", "7777777777777977777797777777777777778787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787"]]}, {"title": "Кактус", "mine": false, "size": 24, "builtin": 16, "shade": 4, "delay": 0, "frames": [["........................", "...........00...........", "..........0ee0..........", ".........0eaee0.........", ".........0eeee0.........", "........033ee330........", "........033b3330........", "........037b3330000.....", "........033b33303330....", "....000.033b37303730....", "...03330033b33303330....", "...03730033b33333330....", "...03330037b33333330....", "...03330033b3330000.....", "...03333333b3730........", "...03333333b3330........", "....0000033b7330........", "......00033b333000......", ".....09999999999990.....", ".....09999999999990.....", "......022222222220......", "......044444444440......", ".......0000000000.......", "........................"]]}, {"title": "Сердечко", "mine": false, "size": 16, "colors": ["#3a0f1e", "#d6304f", "#ff7a8a", "#ffd3d8"], "shade": 3, "delay": 0, "frames": [["................", "................", "................", "....00....00....", "...0110..0110...", "..012210011110..", "..012311111110..", "..012111111110..", "...0111111110...", "....01111110....", ".....011110.....", "......0110......", ".......00.......", "................", "................", "................"]]}, {"title": "Сердечко · Game Boy", "mine": false, "size": 16, "builtin": 21, "shade": 0, "delay": 0, "frames": [["0000000000000000", "0000000000000000", "0000000000000000", "0000330000330000", "0003223003223000", "0032112332222300", "0032102222222300", "0032122222222300", "0003222222223000", "0000322222230000", "0000032222300000", "0000003223000000", "0000000330000000", "0000000000000000", "0000000000000000", "0000000000000000"]]}, {"title": "Ключик", "mine": true, "size": 24, "colors": ["#2c1b0e", "#b07a2a", "#e3b04b", "#fbe3a1"], "shade": 5, "delay": 0, "frames": [["........................", "........................", "........................", "........................", "........................", "........................", "......000...............", "....0011100.............", "...013312210............", "..01311111210...........", "..0311101111000000000...", ".013110.01112322222220..", ".01110...0111111111110..", ".011110.01111111111110..", "..01111011110000011010..", "..01211111210...011010..", "...011111110....011010..", "....0011100......00010..", "......000...........0...", "........................", "........................", "........................", "........................", "........................"]]}, {"title": "Кораблик", "mine": true, "size": 32, "colors": ["#e8f1f4", "#bfe0ea", "#8cc6d8", "#2f7ea0", "#1d5b7a", "#14405a", "#f7f4ec", "#d5cfc1", "#a39b8b", "#ef6b4f"], "shade": 2, "delay": 320, "frames": [["00000000000000000000000000000000", "00000000000000000000000000000000", "00100010001000100010001000100010", "00000000000000000000000000000000", "10101010101010101010101010101010", "01010101010101010101010101010101", "10111011101110111011101110111011", "01010101010101010101010101010101", "11111111111111199111111111111111", "11111111111111161111111111111111", "11111111111111661111111111111111", "11111111111116761111111111111111", "21212121212166762121212121212121", "12111211121667761211121112111211", "21212121216667762121212121212121", "12121212166677761212121212121212", "22222222666666666666666622222222", "22122212266666666666666222122212", "22222222227777777777772222222222", "33333333333888888888833333333333", "43432223434322234343222343432223", "34333433343334333433343334333433", "43434343434343434343434343434343", "34343434343434343434343434343434", "22244444222444442224444422244444", "44444444444444444444444444444444", "44544454445444544454445444544454", "44444444444444444444444444444444", "54542224545422245454222454542224", "45454545454545454545454545454545", "55555555555555555555555555555555", "55555555555555555555555555555555"], ["00000000000000000000000000000000", "00000000000000000000000000000000", "00100010001000100010001000100010", "00000000000000000000000000000000", "10101010101010101010101010101010", "01010101010101010101010101010101", "10111011101110111011101110111011", "01010101010101099101010101010101", "11111111111111161111111111111111", "11111111111111661111111111111111", "11111111111116761111111111111111", "11111111111166761111111111111111", "21212121212667762121212121212121", "12111211126667761211121112111211", "21212121266677762121212121212121", "12121212666666666666666612121212", "22222222266666666666666222222222", "22122212227777777777771222122212", "22222222222888888888822222222222", "33333333333333333333333333333333", "43222343432223434322234343222343", "34333433343334333433343334333433", "43434343434343434343434343434343", "34343434343434343434343434343434", "24444422244444222444442224444422", "44444444444444444444444444444444", "44544454445444544454445444544454", "44444444444444444444444444444444", "54222454542224545422245454222454", "45454545454545454545454545454545", "55555555555555555555555555555555", "55555555555555555555555555555555"], ["00000000000000000000000000000000", "00000000000000000000000000000000", "00100010001000100010001000100010", "00000000000000000000000000000000", "10101010101010101010101010101010", "01010101010101010101010101010101", "10111011101110111011101110111011", "01010101010101010101010101010101", "11111111111111199111111111111111", "11111111111111161111111111111111", "11111111111111661111111111111111", "11111111111116761111111111111111", "21212121212166762121212121212121", "12111211121667761211121112111211", "21212121216667762121212121212121", "12121212166677761212121212121212", "22222222666666666666666622222222", "22122212266666666666666222122212", "22222222227777777777772222222222", "33333333333888888888833333333333", "22234343222343432223434322234343", "34333433343334333433343334333433", "43434343434343434343434343434343", "34343434343434343434343434343434", "44442224444422244444222444442224", "44444444444444444444444444444444", "44544454445444544454445444544454", "44444444444444444444444444444444", "22245454222454542224545422245454", "45454545454545454545454545454545", "55555555555555555555555555555555", "55555555555555555555555555555555"]]}]; +export const DRAWN = [{"title": "Грибок", "mine": true, "size": 16, "colors": ["#2a1d1b", "#c4392f", "#f6f3eb", "#8c2a25", "#efe7d6", "#b9ad98"], "shade": 0, "delay": 420, "frames": [["................", ".....000000.....", "...0011111100...", "..011221111110..", ".01122211112210.", ".01112111112210.", "0111111121111110", "0122111111111110", ".03333333333330.", "..000044440000..", ".....044440.....", "....04444550....", "....04444550....", "....04445550....", ".....000000.....", "................"], ["................", "................", ".....000000.....", "...0011111100...", "..011221111110..", ".01122211112210.", ".01112111112210.", "0111111121111110", "0122111111111110", ".03333333333330.", "..000044440000..", "....04444550....", "....04444550....", "....04445550....", ".....000000.....", "................"]]}, {"title": "Сумеречный гриб", "mine": false, "size": 16, "colors": ["#14122b", "#5b4bb5", "#d8d1ff", "#352b7a", "#c6c9e8", "#8b8fb8"], "shade": 6, "delay": 420, "frames": [["................", ".....000000.....", "...0011111100...", "..011221111110..", ".01122211112210.", ".01112111112210.", "0111111121111110", "0122111111111110", ".03333333333330.", "..000044440000..", ".....044440.....", "....04444550....", "....04444550....", "....04445550....", ".....000000.....", "................"], ["................", "................", ".....000000.....", "...0011111100...", "..011221111110..", ".01122211112210.", ".01112111112210.", "0111111121111110", "0122111111111110", ".03333333333330.", "..000044440000..", "....04444550....", "....04444550....", "....04445550....", ".....000000.....", "................"]]}, {"title": "Лунная дорожка", "mine": true, "size": 32, "colors": ["#0d1022", "#1b2142", "#2e3a6b", "#4a5d96", "#f3e9c6", "#c9bd94", "#0a1a2c", "#163450", "#2a5b78", "#e7dcb0"], "shade": 7, "delay": 0, "frames": [["00000000000000000000000000000000", "00000000000000000000000000000000", "00100010001000900010001000100010", "00040000000000000000000000000900", "10101010101010101010444440101010", "01010101010101010104444444010101", "10101010141010101044444444401010", "01010101010101010144554444410101", "11111111111111111144444444411111", "11111111111111111944444454411111", "11211121112111211144454544411121", "11111111111111111114444444111111", "21212921212121212121444441212121", "12121212121212121212121212121212", "22222222222222222222222222222222", "12221222122212221222122212241222", "32223222322232223222322232223222", "22222222222222222222222222222222", "32323232323232323232323232323232", "23232323232323232323232323232323", "33333333333333333333333333333333", "66666666666666666666666666666666", "66766676667666766676699666766676", "66676667666766676667666766676667", "77767776777677767776779697767776", "77677767776777677767776777677767", "77777777777777777777557577777777", "77777777777777777777777777777777", "87878787878787878787875555878787", "78787878787878787878787878787878", "88888888888888888885855558888888", "88888888888888888888888888888888"]]}, {"title": "Горный рассвет", "mine": false, "size": 48, "colors": ["#2b1d3a", "#5a2f55", "#a14d63", "#e3825c", "#f6c27a", "#fff1c9", "#453a63", "#2f2b4c", "#1d1b31", "#f2eef5", "#8c86ad"], "shade": 1, "delay": 0, "frames": [["000000000000000000000000000000000000000000000000", "000000000000000000000000000000000000000000000000", "101010101010101010101010101010101010101010101010", "000100010001000100010001000100010001000100010001", "111011101110111011101110111011101110111011101110", "010101010101010101010101010101010101010101010101", "111111111111111111111111111111111111111111111111", "011101110111011101110111011101110111011101110111", "211121112111211121112111211121112111211121112111", "111111111111111111111111111111111111111111111111", "212121212121212121212121212121212121212121212121", "111211121112111211121112111211121112111211121112", "222122212221222122212221222122212221222122212221", "121212121212121212121212121212121212121212121212", "222222222222222222222222222222222222222222222222", "122212221222122212221222122244444222122212221222", "222222222222222222222222224444544442222222222222", "222222222222222222222222244555555544222222222222", "323232323232323232323232345555555554323232323232", "222322232223222322232223445555555554422322232223", "323232323232323232323232445555555554423232323232", "999323232323232323232323455555555555432323232323", "999932333233323332333233445555555554423332333233", "6969a3232323aaa3232323234455555555544323232323aa", "66696a33333a666a3333333334555555555aaaa33333aa66", "666666a333a66666a33333333445555555a6666a333a6666", "6666666aaa6666666a4333433344445444666666aaa66666", "666666666666666666333333333344444a66666666666666", "666666666666666666a34343434343434666666666666666", "66666666666666666664343434343434a666666666666666", "6666666666666666666a4343434343477776666666666666", "66666666666666666666aa34343434777777666666666666", "66666666666666666666777aa44447777777766666666666", "6666666666666666667777777aa477777777776666666666", "666666666666667777777777777777777777777766666666", "666666666666777777777777777777777777777777666666", "766666666667777777777777777777777777777777766666", "776666666677777777777777777777777777777777777666", "777666666777777777777777777777777777777777778886", "777766888887777777777777777777777777777777888888", "888888888888877777777777777777777777777778888888", "888888888888887777777777777777777777777788888888", "888888888888888777777777777778888887777888888888", "888888888888888877777788888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888"]]}, {"title": "Горная ночь", "mine": false, "size": 48, "colors": ["#070a17", "#0e1430", "#172152", "#22306e", "#2d3d82", "#e8eefc", "#202a4f", "#151c38", "#0b0f22", "#c7d3f2", "#5d6ea8"], "shade": 6, "delay": 0, "frames": [["000000000000000000000000000000000000000000000000", "000000000000000000000000000000000000000000000000", "101010101010101010101010101010101010101010101010", "000100010001000100010001000100010001000100010001", "111011101110111011101110111011101110111011101110", "010101010101010101010101010101010101010101010101", "111111111111111111111111111111111111111111111111", "011101110111011101110111011101110111011101110111", "211121112111211121112111211121112111211121112111", "111111111111111111111111111111111111111111111111", "212121212121212121212121212121212121212121212121", "111211121112111211121112111211121112111211121112", "222122212221222122212221222122212221222122212221", "121212121212121212121212121212121212121212121212", "222222222222222222222222222222222222222222222222", "122212221222122212221222122244444222122212221222", "222222222222222222222222224444544442222222222222", "222222222222222222222222244555555544222222222222", "323232323232323232323232345555555554323232323232", "222322232223222322232223445555555554422322232223", "323232323232323232323232445555555554423232323232", "999323232323232323232323455555555555432323232323", "999932333233323332333233445555555554423332333233", "6969a3232323aaa3232323234455555555544323232323aa", "66696a33333a666a3333333334555555555aaaa33333aa66", "666666a333a66666a33333333445555555a6666a333a6666", "6666666aaa6666666a4333433344445444666666aaa66666", "666666666666666666333333333344444a66666666666666", "666666666666666666a34343434343434666666666666666", "66666666666666666664343434343434a666666666666666", "6666666666666666666a4343434343477776666666666666", "66666666666666666666aa34343434777777666666666666", "66666666666666666666777aa44447777777766666666666", "6666666666666666667777777aa477777777776666666666", "666666666666667777777777777777777777777766666666", "666666666666777777777777777777777777777777666666", "766666666667777777777777777777777777777777766666", "776666666677777777777777777777777777777777777666", "777666666777777777777777777777777777777777778886", "777766888887777777777777777777777777777777888888", "888888888888877777777777777777777777777778888888", "888888888888887777777777777777777777777788888888", "888888888888888777777777777778888887777888888888", "888888888888888877777788888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888", "888888888888888888888888888888888888888888888888"]]}, {"title": "Северное сияние", "mine": false, "size": 64, "colors": ["#05070f", "#0b1424", "#13253a", "#1d8a6b", "#43d39a", "#a8f5c8", "#6e4fb3", "#e9f1f7", "#b9c7d6", "#163c2d", "#24594a", "#ffffff"], "shade": 7, "delay": 0, "frames": [["0000000000000000000000000000000000000000000000000000000000000000", "0000000000000000000000000000000000000003030300000000000000000000", "00000000000000000000000000000000000000333b3330000000000000000000", "0000000000000000000080000000000000000303333333000000000000000000", "1010101010101010101010101010101010103333333333303010101010101010", "0000800000000000000000000000000000033333333333330300000000000000", "1010101010101010101010101010101010333334444443333030101010801010", "0000000000000000000000000000000003333444444444333303000000000000", "1010101010101010101010101010101033334444444444433330331010101010", "0100010001000100010001000100010333344445555554444333030303000100", "3010101010101010106660101010103333444555555555444433333330333030", "3301010101010101060606060103033334445551010105544443333333030303", "3330101010101010663336606330333344455010101010555444333333333330", "3333010101010106333333330633333444550101010101055544443333333333", "4333303110133063333333333366634445511011101110111554444444333333", "4433330303030633334443333336364656060606010101010155444444444443", "4443333133336333444444443333336566616661661111111115554444444444", "5444333333363334444444444333333333333333060101010101555555444446", "5544433333633344445554444443333333333333336611111111115555555563", "0554444336333444555555554444443333333333333606010101010101565633", "1155446463334445555555555444444444444444333366611111111111616333", "1616564633344455555555555554444444444444443333161111111116133334", "6166653333444555511111111555554444444444444333366111111161333344", "3333333334445511111111111115555555555555444433331616161613333444", "3333333344455111211121112111215555555555554444333661666133344445", "3333334444551111111111111111111111111111555444433333333333444455", "4444444445511121112111211121112111211121115544443333333334444551", "4444444455111111111111111111111111111111111555444333333344455511", "4444445551212121212121212121212121212121212155544444444444555121", "5555555511111111111111111111111111111111111111554444444445511111", "5555552121212121212121212121212121212121212121255444444455212121", "1112111211121112111211121112111211121112111211125555555551121112", "2121212121212121212121212121212121212121212121212555555521212121", "1212121212121212121212121212121212121212121212121212121212121212", "2121212121212121212121212121212121212121212121212121212121212121", "1212121212121212121212121212121212121212121212121212121212121212", "2221222122212221222122212221222122212a21222122212221222122212221", "1212121212121212121292121212121212121a12121212121212921212121212", "21222a22212221222122a122212221222122aa92212221222122a12221222122", "12121a1212121212121aaa12121212121212a9a212121212121aaa1212129212", "2222aa9222222222222aa9222222222222229aa222222222222aa9222222a222", "2212a9a222122212221a9a1222122a122219aaa9221222a2221a9a12221aaa12", "22229aa22222222222a9aaa222222a22222aaa9a222222a222a9aaa2222aa922", "1229aaa912221a22129aaa921222aa92122aa9aa12221a9a129aaa92122a9a22", "222aaa9a22222a2222aaa9a22222a9a2222a9aaa222229aa22aaa9a222a9aaa2", "222aa9aa2222aa9222aa9aa222229aa222a9aaa9a2222aaa22aa9aa2229aaa92", "222a9aaa2222a9a22aa9aaa92229aaa9229aaa9aa222aaa9aaa9aaa922aaa9a2", "22a9aaa9a2229aa22a9aaa9a222aaa9a22aaa9aaa222aa9aaa9aaa9a22aa9aa2", "229aaa9aa229aaa929aaa9aa222aa9aa2aaa9aaa9a22a9aaa9aaa9aa2aa9aaa9", "22aaa9aaa22aaa9a9aaa9aaa922a9aaa2aa9aaa9aa229aaa9aaa9aaa9a9aaa9a", "2aaa9aaa9a2aa9aaaaa9aaa9a2a9aaa9aa9aaa9aaa7777977777977777779777", "2aa9aaa9aa2a9aaaaa9aaa9aa29aaa9aa2277977777777777777777777777777", "7777792222a9aaaaa9aaa9aaa922297777777777777777777777777777777777", "7777777777777977777797777777777777778787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787", "7777777777777777777777777777777777777777777777777777777777777777", "8787878787878787878787878787878787878787878787878787878787878787"]]}, {"title": "Кактус", "mine": false, "size": 24, "builtin": 16, "shade": 4, "delay": 0, "frames": [["........................", "...........00...........", "..........0ee0..........", ".........0eaee0.........", ".........0eeee0.........", "........033ee330........", "........033b3330........", "........037b3330000.....", "........033b33303330....", "....000.033b37303730....", "...03330033b33303330....", "...03730033b33333330....", "...03330037b33333330....", "...03330033b3330000.....", "...03333333b3730........", "...03333333b3330........", "....0000033b7330........", "......00033b333000......", ".....09999999999990.....", ".....09999999999990.....", "......022222222220......", "......044444444440......", ".......0000000000.......", "........................"]]}, {"title": "Сердечко", "mine": false, "size": 16, "colors": ["#3a0f1e", "#d6304f", "#ff7a8a", "#ffd3d8"], "shade": 3, "delay": 0, "frames": [["................", "................", "................", "....00....00....", "...0110..0110...", "..012210011110..", "..012311111110..", "..012111111110..", "...0111111110...", "....01111110....", ".....011110.....", "......0110......", ".......00.......", "................", "................", "................"]]}, {"title": "Сердечко · Game Boy", "mine": false, "size": 16, "builtin": 21, "shade": 0, "delay": 0, "frames": [["0000000000000000", "0000000000000000", "0000000000000000", "0000330000330000", "0003223003223000", "0032112332222300", "0032102222222300", "0032122222222300", "0003222222223000", "0000322222230000", "0000032222300000", "0000003223000000", "0000000330000000", "0000000000000000", "0000000000000000", "0000000000000000"]]}, {"title": "Ключик", "mine": true, "size": 24, "colors": ["#2c1b0e", "#b07a2a", "#e3b04b", "#fbe3a1"], "shade": 5, "delay": 0, "frames": [["........................", "........................", "........................", "........................", "........................", "........................", "......000...............", "....0011100.............", "...013312210............", "..01311111210...........", "..0311101111000000000...", ".013110.01112322222220..", ".01110...0111111111110..", ".011110.01111111111110..", "..01111011110000011010..", "..01211111210...011010..", "...011111110....011010..", "....0011100......00010..", "......000...........0...", "........................", "........................", "........................", "........................", "........................"]]}, {"title": "Кораблик", "mine": true, "size": 32, "colors": ["#e8f1f4", "#bfe0ea", "#8cc6d8", "#2f7ea0", "#1d5b7a", "#14405a", "#f7f4ec", "#d5cfc1", "#a39b8b", "#ef6b4f"], "shade": 2, "delay": 320, "frames": [["00000000000000000000000000000000", "00000000000000000000000000000000", "00100010001000100010001000100010", "00000000000000000000000000000000", "10101010101010101010101010101010", "01010101010101010101010101010101", "10111011101110111011101110111011", "01010101010101010101010101010101", "11111111111111199111111111111111", "11111111111111161111111111111111", "11111111111111661111111111111111", "11111111111116761111111111111111", "21212121212166762121212121212121", "12111211121667761211121112111211", "21212121216667762121212121212121", "12121212166677761212121212121212", "22222222666666666666666622222222", "22122212266666666666666222122212", "22222222227777777777772222222222", "33333333333888888888833333333333", "43432223434322234343222343432223", "34333433343334333433343334333433", "43434343434343434343434343434343", "34343434343434343434343434343434", "22244444222444442224444422244444", "44444444444444444444444444444444", "44544454445444544454445444544454", "44444444444444444444444444444444", "54542224545422245454222454542224", "45454545454545454545454545454545", "55555555555555555555555555555555", "55555555555555555555555555555555"], ["00000000000000000000000000000000", "00000000000000000000000000000000", "00100010001000100010001000100010", "00000000000000000000000000000000", "10101010101010101010101010101010", "01010101010101010101010101010101", "10111011101110111011101110111011", "01010101010101099101010101010101", "11111111111111161111111111111111", "11111111111111661111111111111111", "11111111111116761111111111111111", "11111111111166761111111111111111", "21212121212667762121212121212121", "12111211126667761211121112111211", "21212121266677762121212121212121", "12121212666666666666666612121212", "22222222266666666666666222222222", "22122212227777777777771222122212", "22222222222888888888822222222222", "33333333333333333333333333333333", "43222343432223434322234343222343", "34333433343334333433343334333433", "43434343434343434343434343434343", "34343434343434343434343434343434", "24444422244444222444442224444422", "44444444444444444444444444444444", "44544454445444544454445444544454", "44444444444444444444444444444444", "54222454542224545422245454222454", "45454545454545454545454545454545", "55555555555555555555555555555555", "55555555555555555555555555555555"], ["00000000000000000000000000000000", "00000000000000000000000000000000", "00100010001000100010001000100010", "00000000000000000000000000000000", "10101010101010101010101010101010", "01010101010101010101010101010101", "10111011101110111011101110111011", "01010101010101010101010101010101", "11111111111111199111111111111111", "11111111111111161111111111111111", "11111111111111661111111111111111", "11111111111116761111111111111111", "21212121212166762121212121212121", "12111211121667761211121112111211", "21212121216667762121212121212121", "12121212166677761212121212121212", "22222222666666666666666622222222", "22122212266666666666666222122212", "22222222227777777777772222222222", "33333333333888888888833333333333", "22234343222343432223434322234343", "34333433343334333433343334333433", "43434343434343434343434343434343", "34343434343434343434343434343434", "44442224444422244444222444442224", "44444444444444444444444444444444", "44544454445444544454445444544454", "44444444444444444444444444444444", "22245454222454542224545422245454", "45454545454545454545454545454545", "55555555555555555555555555555555", "55555555555555555555555555555555"]]}]; export const FILES = [{"title": "Прибой", "file": "surf.png", "fit": 24, "shade": 2, "mine": true}, {"title": "Прибой · PICO-8", "file": "surf.png", "palette": 16, "shade": 0}, {"title": "Разряд", "file": "spark.png", "fit": 16, "shade": 7}, {"title": "Мармелад", "file": "ribbons.png", "fit": 12, "shade": 3}, {"title": "Мармелад · Sweetie", "file": "ribbons.png", "palette": 17, "shade": 5}, {"title": "Старый терминал", "file": "terminal.png", "fit": 16, "shade": 6}, {"title": "Терминал · CGA", "file": "terminal.png", "palette": 22, "shade": 6}]; diff --git a/web/src/collection/share.js b/web/src/collection/share.js index 3f1a860..959dd95 100644 --- a/web/src/collection/share.js +++ b/web/src/collection/share.js @@ -1,12 +1,16 @@ // @ts-check // What the share sheet holds for a card: its code, ready to be scanned, -// and the same envelope as a link, for someone who is not in the room. +// and the ways for someone who is not in the room — the same envelope as +// a link, as a .pips file, or as a picture of the card that carries it +// (card-picture.js): its code drawn on it, the envelope inside. // An own card goes out signed by its author, as a plain copy or as a // numbered one; someone else's card goes out as a copy passed on by you. import { h, icon } from '../dom.js'; import { t } from '../i18n.js'; -import { linkBase } from '../links.js'; +import { linkBase, MAGIC } from '../links.js'; +import { sendLink, sendFile, fileName } from '../ui/send.js'; +import { cardPicture } from '../ui/card-picture.js'; import { toast } from '../ui/shell.js'; import { caught } from '../diagnostics.js'; import { app, subscribe } from '../state.js'; @@ -20,16 +24,18 @@ import { FRAME_SHADES, cardTitle } from '../ui/card.js'; * Fills `body` and returns a function that stops what keeps running * (a QR carousel, the screen lock). * @param {HTMLElement} body @param {import('../state.js').Entry} entry + * @param {{size?: number, hint?: string}} [o] size: the code's side; hint: what to + * do with it — both where the room and the gesture are not the sheet's */ -export function fillShare(body, entry) { +export function fillShare(body, entry, o = {}) { + const size = o.size ?? codeSize(); let stop = () => {}; let release = () => {}; keepAwake().then((r) => (release = r)); // The code area keeps one height whether it shows a code or the // numbered-copy panel, so switching between them does not jump. - const size = codeSize(); const code = h('div', { class: 'share-code' }); - code.style.height = `${size + 28 + 72 + 44}px`; // + the link button + code.style.height = `${size + 28 + 72 + 52}px`; // + the row of ways /** @param {Uint8Array} env @param {string} note */ const showCode = (env, note) => { @@ -39,11 +45,26 @@ export function fillShare(body, entry) { code.replaceChildren(q.el, h('p', {}, note), h('p', { class: 'small' }, q.plan.kind === 'single' ? t('give.single', env.length) : t('give.carousel', q.plan.fragments)), - h('button', { type: 'button', class: 'share-link', onclick: () => sendLink(env, cardTitle(entry.card)) }, icon('link'), t('share.link'))); + ways(env)); }; - const copy = () => showCode(app.identity.sealAsAuthor(entry.rec.payload, undefined), t('share.hint')); + const name = cardTitle(entry.card); + /** @param {Uint8Array} env */ + const ways = (env) => h('div', { class: 'share-ways', role: 'group', 'aria-label': t('share.ways') }, + h('button', { type: 'button', class: 'share-link', onclick: () => sendLink(app.core.cardLink(linkBase(location), env), name, t('share.link.text', name)) }, icon('link'), t('share.link')), + h('button', { type: 'button', class: 'share-link', onclick: () => sendFile(new File([new Uint8Array([...MAGIC, ...env])], fileName(name, 'pips'), { type: 'application/octet-stream' }), name) }, icon('file'), t('share.file')), + h('button', { type: 'button', class: 'share-link', onclick: () => picture(env) }, icon('image'), t('share.png'))); + /** @param {Uint8Array} env */ + const picture = async (env) => { + try { + await sendFile(new File([await cardPicture(entry.card, env)], fileName(name, 'png'), { type: 'image/png' }), name); + } catch (e) { + caught(e, 'share'); + toast(t('err.generic')); + } + }; + const copy = () => showCode(app.identity.sealAsAuthor(entry.rec.payload, undefined), o.hint ?? t('share.hint')); - const title = h('b', {}, cardTitle(entry.card)); + const title = h('b', {}, name); if (entry.rec.mine) { const mode = segmented({ label: t('share.title'), value: 'copy', @@ -67,31 +88,6 @@ export function fillShare(body, entry) { return () => { stop(); release(); }; } -/** - * The envelope as a link, the device's way: its share sheet (a messenger), - * else the clipboard. Links point at this app (or the published one, when - * this one is not on https), with the card after '#'. - * @param {Uint8Array} env @param {string} title - */ -async function sendLink(env, title) { - const url = app.core.cardLink(linkBase(location), env); - if (navigator.share) { - try { - await navigator.share({ title, text: t('share.link.text', title), url }); - return; - } catch (e) { - if (/** @type {any} */ (e)?.name === 'AbortError') return; // the person changed their mind - } - } - try { - await navigator.clipboard.writeText(url); - toast(t('share.link.copied')); - } catch (e) { - caught(e, 'share'); - toast(t('share.link.failed')); - } -} - /** * The sheet itself: filled as soon as a card is lifted (so the code is * ready when it comes down), opened for good, or let go. diff --git a/web/src/collection/view.js b/web/src/collection/view.js index 25a128d..bb7d933 100644 --- a/web/src/collection/view.js +++ b/web/src/collection/view.js @@ -20,7 +20,8 @@ import { t } from '../i18n.js'; import { app, subscribe } from '../state.js'; import { getMeta, setMeta } from '../store.js'; import { frame, FRAME_SHADES } from '../ui/card.js'; -import { go, hideTabs } from '../ui/shell.js'; +import { go, hideTabs, toast } from '../ui/shell.js'; +import { fromCard } from '../editor/from-card.js'; import { playOnly } from '../ui/player.js'; import { loop, clamp } from '../kit/motion.js'; import { iconButton } from '../kit/icon-button.js'; @@ -126,6 +127,8 @@ export function collectionView(o) { }); el.append(probe); + /** Whether the card in front is one's own (it has one more action). */ + let frontIsMine = false; const stage = createStage({ host, surface: el, frame: frameOf, by: () => filter.by, @@ -301,6 +304,8 @@ export function collectionView(o) { /** Things that follow the zoom every frame: cheap writes only. @param {number} z */ function drawn(z) { const c = stage.current(); + const mine = !!c && !!app.cards.get(c.id)?.rec.mine; + if (mine !== frontIsMine) { frontIsMine = mine; paintActions(); } // A finger on the back's pencil is a touch too: only a pinch or a // flight makes the back stop taking presses. turn.draw(z, c, build, !stage.pinching && !busy.n); @@ -370,6 +375,14 @@ export function collectionView(o) { paintActions(); } + /** The card's picture in the editor, as the start of a new card. @param {Item} c */ + function derive(c) { + const e = app.cards.get(c.id); + const doc = e && fromCard(e.card, (id) => app.core.palettes().find((/** @type {any} */ p) => p.id === id)?.colors); + if (!doc) return toast(t('act.derive.too-big')); + go('draw', { doc: { ...doc, title: '' } }); + } + /** What can be done with the chosen cards, and with the card in front. */ function paintActions() { const chosen = () => stage.list.filter((c) => selection.has(c.id)); @@ -389,6 +402,8 @@ export function collectionView(o) { { id: 'purge', label: t('act.purge'), icon: 'trash', run: one((c) => moves.purgeAll([c.id])) }] : [{ id: 'share', label: t('act.share'), icon: 'share', run: one(share) }, { id: 'turn', label: t('act.turn'), icon: 'turn', run: turnOver }, + // One's own card can start the next one; someone else's drawing cannot become one's own. + ...(filter.place === 'collection' && frontIsMine ? [{ id: 'derive', label: t('act.derive'), icon: /** @type {const} */ ('copy'), run: one(derive) }] : []), ...(filter.place === 'archive' ? [{ id: 'trash', label: t('act.to-trash'), icon: /** @type {const} */ ('trash'), run: one((c) => moves.toTrash([c.id])) }] : []), { id: 'down', label: t(filter.place === 'archive' ? 'act.to-collection' : 'act.archive'), icon: filter.place === 'archive' ? 'restore' : 'archive', run: one((c) => moves.send([c], true)) }]); } diff --git a/web/src/dom.js b/web/src/dom.js index 4630746..c6ddf5e 100644 --- a/web/src/dom.js +++ b/web/src/dom.js @@ -41,7 +41,7 @@ export function replace(el, ...children) { const ICONS = { collection: '', - give: '', + give: '', receive: '', me: '', archive: '', diff --git a/web/src/editor/frames.js b/web/src/editor/frames.js index 4fe5113..cc4b618 100644 --- a/web/src/editor/frames.js +++ b/web/src/editor/frames.js @@ -1,8 +1,10 @@ // @ts-check // The frames of the picture as a strip: the first tile plays them (and -// shows the card as small as in the collection), then a thumbnail of each -// frame, "+" for a copy of the open one, and the onion skin switch. A tap on -// the open frame shows what can be done with it. +// shows the card as small as in the collection), then a numbered thumbnail +// of each frame, "+" for a copy of the open one, and the onion skin switch. +// A tap on the open frame shows what can be done with it. A still picture +// has nothing to play or choose between: there, the strip is one button +// that starts an animation, and nothing in it can pass for a colour. import { h, icon, reveal } from '../dom.js'; import { t } from '../i18n.js'; @@ -28,9 +30,10 @@ export function thumb(cv, doc, f) { export function framesStrip(o) { const preview = /** @type {HTMLCanvasElement} */ (h('canvas', { class: 'ed-preview-art' })); const badge = h('span', { class: 'ed-play-badge' }); - const play = h('button', { type: 'button', class: 'ed-play', onclick: o.play }, preview, badge); - const list = h('div', { class: 'ed-frame-list' }); - const add = h('button', { type: 'button', class: 'ed-frame add', 'aria-label': t('ed.frame.add'), title: t('ed.frame.add'), onclick: o.add }, icon('plus')); + const play = h('button', { type: 'button', class: 'ed-play', hidden: true, onclick: o.play }, preview, badge); // still until told otherwise: no flash of a lone tile + const list = h('div', { class: 'ed-frame-list', hidden: true }); + const addText = h('span', {}, t('ed.frame.first')); + const add = h('button', { type: 'button', class: 'ed-frame add first', 'aria-label': t('ed.frame.add'), title: t('ed.frame.add'), onclick: o.add }, icon('plus'), addText); const onion = h('button', { type: 'button', class: 'ed-onion-btn', onclick: o.onion }); const scroller = h('div', { class: 'ed-frame-scroll' }, list, add, onion); const el = h('section', { class: 'ed-frames', 'aria-label': t('ed.frames') }, play, scroller); @@ -52,7 +55,7 @@ export function framesStrip(o) { thumbs = doc.frames.map((_, k) => /** @type {HTMLCanvasElement} */ (h('canvas', {}))); list.replaceChildren(...thumbs.map((cv, k) => h('button', { type: 'button', class: 'ed-frame', onclick: () => (k === open ? o.menu(k) : o.select(k)), - }, cv))); + }, cv, h('span', { class: 'ed-frame-n', 'aria-hidden': 'true' }, String(k + 1))))); } thumbs.forEach((cv, k) => { if (dirty === 'all' || dirty.includes(k)) thumb(cv, doc, k); @@ -64,7 +67,10 @@ export function framesStrip(o) { badge.replaceChildren(icon(s.playing ? 'pause' : 'play')); play.setAttribute('aria-label', t(s.playing ? 'ed.stop' : 'ed.play')); play.title = t(s.playing ? 'ed.stop' : 'ed.play'); - play.classList.toggle('still', doc.frames.length < 2); + const still = doc.frames.length < 2; + play.hidden = list.hidden = still; + add.classList.toggle('first', still); + addText.hidden = !still; add.hidden = doc.frames.length >= 64; onion.replaceChildren(h('b', {}, t('ed.onion')), h('span', {}, s.onion ? t('ed.on') : t('ed.off'))); onion.setAttribute('aria-pressed', String(s.onion)); diff --git a/web/src/editor/from-card.js b/web/src/editor/from-card.js new file mode 100644 index 0000000..f9549d9 --- /dev/null +++ b/web/src/editor/from-card.js @@ -0,0 +1,47 @@ +// @ts-check +// Card data back into a picture the editor can work on — the other way +// round from to-card.js: an imported file, or one's own card to start a new +// one from. The picture is square, one of the editor's sizes: a smaller +// card sits in the middle of the nearest larger canvas, on empty cells. +// A card that is not square, or larger than the editor's largest canvas, +// is no picture for it: null, and the caller crops first. +// No DOM: runs under node for tests. + +import { T, SIZES, DELAY } from './model.js'; + +/** @param {number[]} c [r, g, b] */ +const packed = (c) => (c[0] << 16) | (c[1] << 8) | c[2]; + +/** + * @param {any} card width, height, opaque, palette ({builtin} | {inline}), frames, delay_ms, pixels, title, frame + * @param {(id: number) => number[][] | undefined} builtin a built-in palette's colours, by id + * @returns {import('./model.js').Doc | null} + */ +export function fromCard(card, builtin) { + const w = card.width, h = card.height; + const size = SIZES.find((n) => n >= Math.max(w, h)); + if (!size) return null; + const inline = card.palette.inline; + const list = inline ?? builtin(card.palette.builtin); + if (!list) return null; + // An empty cell is index 0 of a card that is not opaque. With its own + // colours, that slot is no colour of the picture: the rest move down one. + const skip = !card.opaque && inline ? 1 : 0; + const colors = list.slice(skip).map(packed); + const ox = (size - w) >> 1, oy = (size - h) >> 1, n = w * h; + const frames = Array.from({ length: card.frames }, (_, k) => { + const f = new Uint8Array(size * size).fill(T); + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const v = card.pixels[k * n + y * w + x]; + f[(y + oy) * size + x + ox] = !card.opaque && v === 0 ? T : v - skip; + } + } + return f; + }); + return { + size, frames, colors, builtin: inline ? null : card.palette.builtin, + title: card.title ?? '', shade: card.frame ?? 0, + delay: card.frames > 1 ? Math.min(DELAY.max, Math.max(DELAY.min, card.delay_ms)) : DELAY.start, + }; +} diff --git a/web/src/editor/picture.js b/web/src/editor/picture.js new file mode 100644 index 0000000..c344e94 --- /dev/null +++ b/web/src/editor/picture.js @@ -0,0 +1,403 @@ +// @ts-check +// A picture from a file, onto the editor's canvas: PNG or Aseprite pixel +// art as it is, any other picture (a photo, a screenshot) through a square +// crop. The file never leaves the device; the core does the conversion +// (pips-raster) and this sheet shows what it did and passes the options +// back. Each change re-runs it from the original, so options never +// compound. What lands on the canvas is a picture like any other: named, +// framed and signed in the editor, and undone there with one step. +// +// Pixel art that is square and fits the editor goes straight to the +// canvas sheet's preview. Anything else is cropped: a square over the +// picture, moved with a finger and sized by its corners (or the wheel). +// Pixel art is cropped on its own pixel grid and keeps every pixel; +// a photo is scaled down to the chosen size and fitted to a palette. + +import { h, replace } from '../dom.js'; +import { t, reason } from '../i18n.js'; +import { app } from '../state.js'; +import { fullSheet } from '../kit/full-sheet.js'; +import { segmented } from '../kit/segmented.js'; +import { toast } from '../ui/shell.js'; +import { caught } from '../diagnostics.js'; +import { issuable } from '../budget.js'; +import { fromCard } from './from-card.js'; +import { isPng, envelopeIn } from '../png-card.js'; +import { peek, arrive } from '../ui/arrival.js'; +import { SIZES } from './model.js'; + +const ACCEPT = 'image/*,.png,.aseprite,.ase'; +const MAX_FILE = 32 * 1024 * 1024; +/** A crop is never smaller than this, in pixels of the source. */ +const MIN_CROP = 4; +const CORNER = 28; // px of screen around a corner that take it + +/** @type {HTMLInputElement|null} */ +let input = null; + +/** + * Asks for a file (call it from a tap), then shows what it becomes; `done` + * gets the picture for the canvas. @param {(doc: import('./model.js').Doc) => void} done + */ +export function pickPicture(done) { + input?.remove(); + input = /** @type {HTMLInputElement} */ (h('input', { type: 'file', accept: ACCEPT, hidden: true })); + input.addEventListener('change', () => { + const f = input?.files?.[0]; + input?.remove(); + input = null; + if (f) openPicture(f, done).catch((e) => { caught(e, 'picture'); toast(t('err.generic')); }); + }); + document.body.append(input); + input.click(); +} + +/** @param {File} file @param {(doc: import('./model.js').Doc) => void} done */ +export async function openPicture(file, done) { + if (file.size > MAX_FILE) return toast(t('import.err.too_large')); + const bytes = new Uint8Array(await file.arrayBuffer()); + const aseprite = /\.(aseprite|ase)$/i.test(file.name); + const sourceUrl = aseprite ? null : URL.createObjectURL(file); + + /** @type {() => void} */ + let stopPlay = () => {}; + const sheet = fullSheet(t('pic.title'), () => { + stopPlay(); + if (sourceUrl) URL.revokeObjectURL(sourceUrl); + }); + // A picture of a card from Pips holds the card itself: offered first — + // it is most likely what the person meant — the drawing still below. + const inside = isPng(bytes) ? envelopeIn(bytes) : null; + const who = inside && peek(inside); + const carried = inside && who + ? h('div', { class: 'pic-card' }, + h('p', {}, t('pic.has-card', who.title, who.author ? `@${who.author}` : '—')), + h('button', { class: 'btn key wide', onclick: () => { sheet.close(); arrive(inside); } }, t('pic.add-card'))) + : ''; + const toCanvas = /** @type {HTMLButtonElement} */ (h('button', { class: 'btn key' }, t('pic.place'))); + const other = h('button', { class: 'btn', onclick: () => { sheet.close(); pickPicture(done); } }, t('pic.other')); + replace(sheet.foot, h('div', { class: 'row-btns' }, other, toCanvas)); + + /** Places a converted card on the canvas, if it is a picture the editor takes. @param {any} card */ + const place = (card) => { + const doc = fromCard({ ...card, title: '' }, builtinColors); + if (!doc) return; + sheet.close(); + done(doc); + }; + + // Pixel art first: the core reads PNG and Aseprite as they are. + /** @type {{keepScale: boolean, firstFrameOnly: boolean, palette: number|undefined, fit: number|undefined}} */ + const opts = { keepScale: false, firstFrameOnly: false, palette: undefined, fit: undefined }; + /** @type {any} */ + let result = null; + /** @type {any} */ + let error = null; + let autoFit = false; + const run = () => { + try { + result = app.core.importPicture(bytes, { ...opts }); + error = null; + } catch (e) { + caught(e, 'picture'); + result = null; + error = e; + // Too many colours for exact: fit its own palette rather than stop. + if (error?.code === 'too_many_colors' && opts.palette == null && opts.fit == null) { + opts.fit = 32; + autoFit = true; + return run(); + } + } + }; + const isPicture = /\.(png|aseprite|ase)$/i.test(file.name) || file.type === 'image/png'; + if (isPicture) run(); + + // Pixel art the canvas holds (up to 64 a side): as it is, centred if need be. + if (result && fromCard(result.card, builtinColors)) return pixelView(); + // Otherwise a crop — of the pixel art itself, or of the picture as the browser shows it. + if (aseprite) { + replace(sheet.body, carried, h('p', { class: 'import-status bad' }, error ? explain(error) : t('pic.ase-square'))); + toCanvas.disabled = true; + return; + } + const source = result && !error ? await bitmapOf(result.card) : await createImageBitmap(file).catch(() => null); + if (!source) { + replace(sheet.body, carried, h('p', { class: 'import-status bad' }, t('pic.unreadable'))); + toCanvas.disabled = true; + return; + } + return cropView(source, !!(result && !error)); + + // ------------------------------------------------------------- pixel art -- + function pixelView() { + let original = false; + const preview = h('button', { type: 'button', class: 'pic-tile', 'aria-label': t('import.toggle'), onclick: () => { if (sourceUrl) { original = !original; paint(); } } }); + const status = h('p', { class: 'import-status', 'aria-live': 'polite' }); + const palette = paletteSelect(opts.fit ? `fit:${opts.fit}` : '', (v) => { setPalette(v); autoFit = false; run(); paint(); }); + const extras = h('div', { class: 'group import-extras' }); + replace(sheet.body, carried, h('div', { class: 'pic-body' }, preview, status, h('div', { class: 'group' }, h('div', { class: 'field' }, h('label', {}, t('import.opt.palette')), palette)), extras)); + toCanvas.onclick = () => { if (result) place(result.card); }; + + function paint() { + stopPlay(); + const fits = !!(result && fromCard(result.card, builtinColors)); + if (result && !original) stopPlay = showCard(preview, result.card); + else replace(preview, sourceUrl ? h('img', { class: `import-original${result ? '' : ' dim'}`, src: sourceUrl, alt: '' }) : ''); + preview.append(sourceUrl && result ? h('span', { class: 'import-chip' }, t(original ? 'import.before' : 'import.after')) : ''); + status.classList.toggle('bad', !!error || !fits); + status.textContent = error ? explain(error) : result ? (fits ? report(result, opts, autoFit) : t('pic.not-square')) : ''; + palette.value = opts.fit ? `fit:${opts.fit}` : opts.palette != null ? String(opts.palette) : ''; + toCanvas.disabled = !fits; + const rows = []; + if (result?.scale > 1 || opts.keepScale) rows.push(toggle('keepScale', t('import.opt.keep-scale'))); + if ((result?.sourceFrames ?? 0) > 1 || opts.firstFrameOnly || error?.code === 'unequal_delays' || error?.code === 'too_many_frames') rows.push(toggle('firstFrameOnly', t('import.opt.first-frame'))); + extras.replaceChildren(...rows); + } + /** @param {'keepScale'|'firstFrameOnly'} key @param {string} label */ + function toggle(key, label) { + const box = /** @type {HTMLInputElement} */ (h('input', { type: 'checkbox' })); + box.checked = opts[key]; + box.addEventListener('change', () => { opts[key] = box.checked; run(); paint(); }); + return h('label', { class: 'check' }, box, h('span', {}, label)); + } + paint(); + } + + /** @param {string} v '' exact colours, 'fit:N' own colours fitted to N, else a built-in id */ + function setPalette(v) { + opts.fit = v.startsWith('fit:') ? Number(v.slice(4)) : undefined; + opts.palette = v === '' || v.startsWith('fit:') ? undefined : Number(v); + } + + // ------------------------------------------------------------------ crop -- + /** + * @param {ImageBitmap} src @param {boolean} pixel whether it is pixel art at + * its own size (cropped on its grid) rather than a picture to scale down + */ + function cropView(src, pixel) { + const W = src.width, H = src.height; + // Pixel art keeps every pixel: its square is never larger than the canvas. + const most = pixel ? Math.min(W, H, SIZES[SIZES.length - 1]) : Math.min(W, H); + let s = most, x = (W - s) / 2, y = (H - s) / 2; + let size = 32; + const crop = { palette: pixel ? '' : 'fit:32' }; + /** @type {any} */ + let made = null; + + const view = /** @type {HTMLCanvasElement} */ (h('canvas', { class: 'pic-source', width: W, height: H })); + /** @type {CanvasRenderingContext2D} */ (view.getContext('2d')).drawImage(src, 0, 0); + view.classList.toggle('pixel', pixel); + const box = h('div', { class: 'pic-crop' }, h('i', { class: 'c tl' }), h('i', { class: 'c tr' }), h('i', { class: 'c bl' }), h('i', { class: 'c br' })); + const stage = h('div', { class: 'pic-stage', 'aria-label': t('pic.crop.label') }, view, box); + const tile = h('div', { class: 'pic-tile small' }); + const status = h('p', { class: 'import-status', 'aria-live': 'polite' }); + const sizes = segmented({ + label: t('ed.set.size'), value: String(size), options: SIZES.map((n) => ({ value: String(n), label: String(n) })), + onChange: (v) => { size = Number(v); remake(); }, + }); + const palette = paletteSelect(crop.palette, (v) => { crop.palette = v; remake(); }); + replace(sheet.body, carried, h('div', { class: 'pic-body' }, + h('p', { class: 'hint pic-why' }, t(pixel ? 'pic.crop.pixel' : 'pic.crop.photo')), + stage, + h('div', { class: 'pic-result' }, tile, status), + pixel ? '' : h('div', { class: 'ed-set-row' }, h('h3', {}, t('ed.set.size')), sizes.el), + h('div', { class: 'group' }, h('div', { class: 'field' }, h('label', {}, t('import.opt.palette')), palette)))); + toCanvas.onclick = () => { if (made) place(made.card); }; + + /** The picture as large as the sheet allows; pixel art at a whole number of screen px a pixel. */ + const size2 = () => { + const room = sheet.body.clientWidth - 32, tall = Math.min(innerHeight * 0.52, 460); + let q = Math.min(room / W, tall / H); + if (pixel && q >= 1) q = Math.floor(q); + view.style.width = `${W * q}px`; + view.style.height = `${H * q}px`; + }; + /** Screen px per source px. */ + const k = () => view.getBoundingClientRect().width / W; + const lay = () => { + const q = k(); + Object.assign(box.style, { left: `${x * q}px`, top: `${y * q}px`, width: `${s * q}px`, height: `${s * q}px` }); + }; + /** Keeps the square whole and inside, on whole pixels for pixel art. */ + const fit = () => { + s = Math.max(Math.min(MIN_CROP, most), Math.min(s, most)); + if (pixel) { s = Math.round(s); x = Math.round(x); y = Math.round(y); } + x = Math.min(Math.max(0, x), W - s); + y = Math.min(Math.max(0, y), H - s); + }; + + // A finger on the square moves it; on a corner, it sizes it from the + // opposite one. Beside the square, nothing: the picture stays put. + /** @type {null | {id: number, mode: 'move'|'size', px: number, py: number, x0: number, y0: number, s0: number, ax: number, ay: number}} */ + let drag = null; + stage.addEventListener('pointerdown', (e) => { + const r = box.getBoundingClientRect(); + const near = (/** @type {number} */ cx, /** @type {number} */ cy) => Math.abs(e.clientX - cx) < CORNER && Math.abs(e.clientY - cy) < CORNER; + const corners = /** @type {const} */ ([[r.left, r.top, 1, 1], [r.right, r.top, 0, 1], [r.left, r.bottom, 1, 0], [r.right, r.bottom, 0, 0]]); + const hit = corners.find(([cx, cy]) => near(cx, cy)); + const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom; + if (!hit && !inside) return; + e.preventDefault(); + try { stage.setPointerCapture(e.pointerId); } catch { /* a pointer the browser no longer has */ } + // The anchor: the corner across from the one taken (in source px). + const ax = hit ? x + (hit[2] ? s : 0) : 0, ay = hit ? y + (hit[3] ? s : 0) : 0; + drag = { id: e.pointerId, mode: hit ? 'size' : 'move', px: e.clientX, py: e.clientY, x0: x, y0: y, s0: s, ax, ay }; + }); + stage.addEventListener('pointermove', (e) => { + if (!drag || e.pointerId !== drag.id) return; + const q = k(), dx = (e.clientX - drag.px) / q, dy = (e.clientY - drag.py) / q; + if (drag.mode === 'move') { + x = drag.x0 + dx; + y = drag.y0 + dy; + } else { + // The finger's point, in source px; the square spans from the anchor to it. + const fx = drag.ax === drag.x0 ? drag.x0 + drag.s0 + dx : drag.x0 + dx; + const fy = drag.ay === drag.y0 ? drag.y0 + drag.s0 + dy : drag.y0 + dy; + const side = Math.max(Math.abs(fx - drag.ax), Math.abs(fy - drag.ay)); + const room = Math.min(drag.ax === drag.x0 ? W - drag.ax : drag.ax, drag.ay === drag.y0 ? H - drag.ay : drag.ay); + s = Math.max(MIN_CROP, Math.min(side, room, most)); + x = drag.ax === drag.x0 ? drag.ax : drag.ax - s; + y = drag.ay === drag.y0 ? drag.ay : drag.ay - s; + } + fit(); + lay(); + remakeSoon(); + }); + const end = (/** @type {PointerEvent} */ e) => { + if (!drag || e.pointerId !== drag.id) return; + drag = null; + remake(); + }; + stage.addEventListener('pointerup', end); + stage.addEventListener('pointercancel', end); + // The wheel sizes the square about its middle, on a computer. + stage.addEventListener('wheel', (e) => { + e.preventDefault(); + const cx = x + s / 2, cy = y + s / 2; + s *= e.deltaY > 0 ? 1.08 : 1 / 1.08; + fit(); + x = cx - s / 2; + y = cy - s / 2; + fit(); + lay(); + remakeSoon(); + }, { passive: false }); + new ResizeObserver(() => { size2(); lay(); }).observe(sheet.body); + + let soon = 0; + function remakeSoon() { + if (soon) return; + soon = requestAnimationFrame(() => { soon = 0; remake(); }); + } + /** The square, at the size the canvas will have, through the core. */ + async function remake() { + const n = pixel ? Math.min(s, 64) : size; + const out = /** @type {HTMLCanvasElement} */ (h('canvas', { width: n, height: n })); + const g = /** @type {CanvasRenderingContext2D} */ (out.getContext('2d')); + g.imageSmoothingEnabled = !pixel; + g.imageSmoothingQuality = 'high'; + g.drawImage(src, x, y, s, s, 0, 0, n, n); + const png = await new Promise((ok) => out.toBlob(ok, 'image/png')); + if (!png) return; + const v = crop.palette; + try { + made = app.core.importPicture(new Uint8Array(await png.arrayBuffer()), { + keepScale: true, firstFrameOnly: true, + ...(v.startsWith('fit:') ? { fit: Number(v.slice(4)) } : v === '' ? {} : { palette: Number(v) }), + }); + status.classList.remove('bad'); + status.textContent = report(made, { fit: v.startsWith('fit:') ? 1 : undefined }, false); + } catch (e) { + // Exact colours that are too many: the nearest fitted palette instead. + if (/** @type {any} */ (e)?.code === 'too_many_colors' && v === '') { + crop.palette = 'fit:32'; + palette.value = 'fit:32'; + return remake(); + } + caught(e, 'picture'); + made = null; + status.classList.add('bad'); + status.textContent = explain(/** @type {any} */ (e)); + } + toCanvas.disabled = !made; + stopPlay(); + if (made) stopPlay = showCard(tile, made.card); + } + fit(); + requestAnimationFrame(() => { size2(); lay(); remake(); }); + } +} + +/** A card's picture in a tile, playing if it moves. @param {HTMLElement} tile @param {any} card @returns {() => void} */ +function showCard(tile, card) { + const images = Array.from({ length: card.frames }, (_, i) => { + const r = app.core.render(card, i); + return new ImageData(new Uint8ClampedArray(r.rgba), r.width, r.height); + }); + const cv = /** @type {HTMLCanvasElement} */ (h('canvas', { width: images[0].width, height: images[0].height })); + const g = /** @type {CanvasRenderingContext2D} */ (cv.getContext('2d')); + g.putImageData(images[0], 0, 0); + replace(tile, cv); + if (images.length < 2 || matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}; + let i = 0; + const timer = window.setInterval(() => g.putImageData(images[(i = (i + 1) % images.length)], 0, 0), Math.max(card.delay_ms, 20)); + return () => clearInterval(timer); +} + +/** Pixel art's own pixels, one source pixel a pixel. @param {any} card */ +async function bitmapOf(card) { + const r = app.core.render(card, 0); + return createImageBitmap(new ImageData(new Uint8ClampedArray(r.rgba), r.width, r.height)); +} + +/** @param {string} value @param {(v: string) => void} onChange */ +function paletteSelect(value, onChange) { + const sel = /** @type {HTMLSelectElement} */ (h('select', { onchange: () => onChange(sel.value) }, + h('option', { value: '' }, t('import.opt.palette.auto')), + h('option', { value: 'fit:32' }, t('import.opt.palette.fit', 32)), + h('option', { value: 'fit:16' }, t('import.opt.palette.fit', 16)), + ...palettes().map((/** @type {any} */ p) => h('option', { value: String(p.id) }, paletteLabel(p))))); + sel.value = value; + return sel; +} + +/** What the conversion did, in a line. @param {any} r @param {{fit?: number}} opts @param {boolean} autoFit */ +function report(r, opts, autoFit) { + const c = r.card; + const bits = []; + bits.push(r.scale > 1 ? t('import.size.scaled', r.sourceWidth, r.sourceHeight, c.width, c.height, r.scale) : t('import.size', c.width, c.height)); + if (c.frames > 1) bits.push(t('import.frames', c.frames, c.delay_ms)); + if (c.palette.builtin != null) bits.push(t('import.palette.builtin', paletteName(c.palette.builtin))); + else if (opts.fit != null && r.changedPixels) bits.push(t('import.palette.fitted', r.colors, c.palette.inline.length - (c.opaque ? 0 : 1))); + else bits.push(t('import.palette.own', r.colors)); + if (r.changedPixels && opts.fit == null) bits.push(t('import.changed', r.changedPixels)); + const { cost } = issuable(c, app.core.shareSize(c)); + bits.push(cost.level === 'one' ? t('import.code.single', cost.bytes) : t('import.code.carousel', cost.bytes, cost.codes, cost.seconds)); + return (autoFit ? `${t('import.fitted-auto')} ` : '') + bits.join(' · '); +} + +/** "Sweetie 16" with 16 colours reads "Sweetie · 16": the count once. @param {any} p */ +function paletteLabel(p) { + const n = p.colors.length; + const name = p.name.endsWith(` ${n}`) ? p.name.slice(0, -String(n).length - 1) : p.name; + return `${name} · ${n}`; +} + +/** @type {any[]|null} */ +let paletteCache = null; +const palettes = () => (paletteCache ??= app.core.palettes()); +/** @param {number} id */ +const builtinColors = (id) => palettes().find((/** @type {any} */ p) => p.id === id)?.colors; + +/** @param {number} id */ +function paletteName(id) { + const p = palettes().find((/** @type {any} */ x) => x.id === id); + return p ? paletteLabel(p).replace(/ · \d+$/, '') : `#${id}`; +} + +/** A translated reason, with the numbers the core attached. @param {{code: string, args?: number[], message?: string}} e */ +function explain(e) { + return reason('import.err', e, ...(e?.args ?? [])); +} + diff --git a/web/src/editor/settings.js b/web/src/editor/settings.js index 8043680..a4e5597 100644 --- a/web/src/editor/settings.js +++ b/web/src/editor/settings.js @@ -1,10 +1,12 @@ // @ts-check // The picture's settings: its size, the frame's colour, how long a frame -// is shown, and what the canvas shows (grid, which way the mirror works). +// is shown, and what the canvas shows (grid, which way the mirror works); +// first of all, the two ways to start a new card — a blank canvas, or a +// picture from a file. // On a phone they live in a sheet behind "More"; on a computer, beside // the canvas. The same element either way. -import { h } from '../dom.js'; +import { h, icon } from '../dom.js'; import { t } from '../i18n.js'; import { FRAME_SHADES } from '../ui/card.js'; import { segmented } from '../kit/segmented.js'; @@ -15,7 +17,7 @@ const DELAYS = [80, 120, 160, 250, 400]; /** * @param {{ * size: (n: number) => void, shade: (i: number) => void, delay: (ms: number) => void, - * grid: (on: boolean) => void, axes: (a: {x: boolean, y: boolean}) => void, fresh: () => void, + * grid: (on: boolean) => void, axes: (a: {x: boolean, y: boolean}) => void, fresh: () => void, picture: () => void, * }} o */ export function settingsPanel(o) { @@ -36,13 +38,18 @@ export function settingsPanel(o) { }); const grid = /** @type {HTMLInputElement} */ (h('input', { type: 'checkbox', checked: true, onchange: () => o.grid(grid.checked) })); let shade = -1; + /** @param {any} ic @param {string} label @param {string} note @param {() => void} run */ + const start = (ic, label, note, run) => h('button', { type: 'button', class: 'ed-new-tile', onclick: run }, icon(ic), h('b', {}, label), h('small', {}, note)); const el = h('div', { class: 'ed-settings' }, + h('div', { class: 'ed-set-row' }, h('h3', {}, t('ed.set.new')), + h('div', { class: 'ed-new' }, + start('plus', t('ed.new.blank'), t('ed.new.blank.note'), o.fresh), + start('image', t('ed.new.picture'), t('ed.new.picture.note'), o.picture))), h('div', { class: 'ed-set-row' }, h('h3', {}, t('ed.set.size')), size.el), h('div', { class: 'ed-set-row' }, h('h3', {}, t('ed.set.shade')), shades), delayRow, h('div', { class: 'ed-set-row' }, h('h3', {}, t('ed.set.axes')), axes.el), - h('label', { class: 'check' }, grid, h('span', {}, t('ed.set.grid'))), - h('button', { type: 'button', class: 'btn', onclick: o.fresh }, t('ed.set.new'))); + h('label', { class: 'check' }, grid, h('span', {}, t('ed.set.grid')))); return { el, diff --git a/web/src/editor/view.js b/web/src/editor/view.js index 40ff582..5b2ada7 100644 --- a/web/src/editor/view.js +++ b/web/src/editor/view.js @@ -29,6 +29,7 @@ import { FRAME_SHADES } from '../ui/card.js'; import { bottomSheet } from '../kit/bottom-sheet.js'; import { clamp } from '../kit/motion.js'; import { blank, createModel, T, SIZES } from './model.js'; +import { pickPicture } from './picture.js'; import { inside } from './raster.js'; import { parseHex, hex, fromRgb } from './color.js'; import { createCanvas } from './canvas.js'; @@ -136,6 +137,7 @@ export function editorView() { grid: (on) => { state.grid = on; canvas.grid(on); }, axes: (a) => { state.axes = a; state.mirror = true; paint(); }, fresh: () => fresh(), + picture: () => { more.close(); pickPicture((doc) => load(doc, t('ed.from-picture'))); }, }); const historyBtn = h('button', { type: 'button', class: 'btn', onclick: () => { more.close(); history.show(); } }, icon('history'), t('ed.history')); settings.el.append(historyBtn); @@ -301,12 +303,20 @@ export function editorView() { if (model.cropsAt(n) && !(await ask(t('ed.size.crop', n), { yes: t('ed.size.yes') }))) return settings.set(model.doc); model.resize(n); } - async function fresh() { + function fresh() { more.close(); - model.replace(blank(32, defaultPalette())); + load(blank(32, defaultPalette()), t('ed.fresh')); + } + /** + * A new picture on the canvas — blank, from a file, from a card — as + * one step: the one before is a tap away. @param {import('./model.js').Doc} doc @param {string} said + */ + function load(doc, said) { + stop(); + model.replace(doc); state.frame = 0; state.color = Math.min(4, model.doc.colors.length - 1); - toast(t('ed.fresh'), { label: t('toast.undo'), run: () => undo() }); + toast(said, { label: t('toast.undo'), run: () => undo() }); } // ------------------------------------------------------------ the card -- @@ -350,6 +360,11 @@ export function editorView() { async function signCard() { stop(); + // Nothing drawn, in any frame: nothing to sign — said, not done. + if (model.doc.frames.every((f) => f.every((v) => v === T))) { + shake(canvas.el); + return toast(t('ed.empty')); + } const r = toCard(model.doc, by()); if (!('card' in r)) return toast(t('ed.budget.colors.why')); // A card keeps its name for good, with everyone it reaches: ask for one @@ -481,7 +496,8 @@ export function editorView() { return { el, - async show() { + /** @param {{doc?: import('./model.js').Doc, said?: string}} [arg] a picture to start from */ + async show(arg) { hideTabs(true); if (!loaded) { loaded = true; @@ -492,6 +508,7 @@ export function editorView() { model.reset({ ...d.doc, frames: d.doc.frames.map((/** @type {any} */ f) => Uint8Array.from(f)) }, d.history); } } + if (arg?.doc) load(arg.doc, arg.said ?? t('ed.from-card')); requestAnimationFrame(() => { canvas.relayout(); paint('all'); }); }, hide() { diff --git a/web/src/i18n.js b/web/src/i18n.js index f3875f1..561897d 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -18,10 +18,8 @@ const ru = { 'back.edition-yours': 'ваш', 'back.edition-not-yours': 'выдан другому человеку', 'back.tech': (/** @type {number} */ w, /** @type {number} */ hh, /** @type {number} */ b, /** @type {number} */ f) => `${w}×${hh} · ${b} байт${f > 1 ? ` · ${f} ${plural(f, 'кадр', 'кадра', 'кадров')}` : ''}`, 'card.animated': (/** @type {number} */ n) => `Анимация: ${n} ${plural(n, 'кадр', 'кадра', 'кадров')}`, - 'link.confirm': (/** @type {string} */ host) => `Открыть ссылку на ${host}? Её оставил автор карточки, Pips её не проверял.`, - 'import.title': 'Новая карточка', 'import.reset': 'Начать заново', 'import.pick': 'Выбрать картинку', 'import.pick.short': 'PNG или Aseprite', 'import.toggle': 'Показать оригинал или карточку', - 'import.pick.hint': 'До 128×128 пикселей и 32 цветов. Увеличенный экспорт (×2, ×4…) уменьшится сам. Файл не покидает устройство.', - 'import.before': 'оригинал', 'import.after': 'карточка', 'import.other': 'Другой файл', 'import.save': 'Подписать и добавить', + 'link.confirm': (/** @type {string} */ host) => `Открыть ссылку на ${host}? Её оставил автор карточки, Pips её не проверял.`, 'import.toggle': 'Показать оригинал или карточку', + 'import.before': 'оригинал', 'import.after': 'карточка', 'import.saved': 'Карточка добавлена — её можно отдавать', 'import.size': (/** @type {number} */ w, /** @type {number} */ h) => `${w}×${h}`, 'import.size.scaled': (/** @type {number[]} */ ...a) => `${a[0]}×${a[1]} → ${a[2]}×${a[3]} (снято увеличение ×${a[4]})`, @@ -31,14 +29,12 @@ const ru = { 'import.changed': (/** @type {number} */ n) => `${n} ${plural(n, 'пиксель сменил', 'пикселя сменили', 'пикселей сменили')} цвет`, 'import.code.single': (/** @type {number} */ n) => `${n} байт, один QR`, 'import.code.carousel': (/** @type {number} */ n, /** @type {number} */ k, /** @type {number} */ s) => `${n} байт, карусель из ${k} QR, ~${s} с`, - 'import.over-app': (/** @type {number} */ w, /** @type {number} */ hh, /** @type {number} */ m) => `${w}×${hh}: в приложении карточка до ${m}×${m}. Уменьшите картинку.`, 'import.opt.keep-scale': 'Не уменьшать — оставить исходный размер', 'import.opt.first-frame': 'Только первый кадр', 'import.opt.palette': 'Палитра', 'import.opt.palette.auto': 'Точные цвета файла', 'import.opt.palette.fit': (/** @type {number} */ n) => `Свои, подобранные · ${n}`, 'import.palette.fitted': (/** @type {number} */ had, /** @type {number} */ n) => `${had > 256 ? 'больше 256' : had} ${plural(had, 'цвет', 'цвета', 'цветов')} файла → свои ${n}`, 'import.fitted-auto': 'Подобрана своя палитра — нажмите на карточку, чтобы сравнить.', - 'import.name': 'Название', 'import.name.ph': 'Как назвать карточку', 'import.shade': 'Рамка', 'import.err.unknown_format': 'Это не PNG и не Aseprite', 'import.err.too_large': 'Файл слишком большой для импорта', 'import.err.malformed': 'Файл повреждён или записан с ошибками', @@ -49,6 +45,8 @@ const ru = { 'import.err.too_many_frames': 'Больше 64 кадров', 'import.err.unequal_delays': 'У кадров разная длительность, а у карточки она одна. Выровняйте её в Aseprite или возьмите только первый кадр', 'import.err.delay_out_of_range': (/** @type {number} */ ms) => `Кадр длится ${ms} мс, а можно от 20 до 5000`, + 'give.title': 'Отдать', 'give.sub': 'покажите код — его отсканируют с вашего экрана', 'give.yours': 'Ваши карточки', 'give.hint': 'Пусть другой телефон отсканирует код.', 'give.new': 'Нарисовать новую', + 'give.empty.title': 'Пока нечего отдать', 'give.empty': 'Здесь будут ваши карточки — с кодом наготове.', 'give.empty.draw': 'Нарисовать', 'give.mode.copy': 'Копия', 'give.mode.edition': 'Экземпляр №', 'edition.my-code': 'Код получателя', 'edition.my-code.hint': (/** @type {string} */ nick) => `Покажите автору: он выдаст вам пронумерованный экземпляр на имя @${nick}.`, @@ -68,11 +66,11 @@ const ru = { 'edition.done': 'Готово', 'scan.loading': 'Подключаю распознавание QR…', 'scan.failed': 'Не удалось загрузить распознавание QR. Проверьте сеть и попробуйте снова', 'scan.none': 'На картинке не нашлось QR-кода', + 'scan.no-camera': 'Нет доступа к камере', 'scan.no-camera.how': 'Разрешите её в настройках браузера или откройте картинку', 'give.single': (/** @type {number} */ b) => `${b} байт · один код. Если не читается — прибавьте яркость.`, 'give.carousel': (/** @type {number} */ n) => `Большая карточка: коды сменяются. Пусть камера смотрит, пока не наберёт ${n} ${plural(n, 'часть', 'части', 'частей')}.`, 'give.copy-of': (/** @type {string} */ a) => `Вы передаёте копию. Автор — ${a}.`, 'receive.title': 'Сканер', 'receive.start': 'Включить камеру', 'receive.point': 'Наведите на код на чужом экране', - 'receive.no-camera': 'Камера недоступна. Можно открыть картинку с кодом или вставить ссылку.', 'receive.image': 'Из картинки', 'receive.file': 'Файл .pips', 'receive.paste': 'Вставить ссылку из буфера', 'receive.clipboard-empty': 'В буфере обмена пусто', 'receive.paste-here': 'Вставьте ссылку в поле', 'receive.paste.placeholder': 'Ссылка или текст кода', @@ -134,7 +132,11 @@ const ru = { 'onb.title': 'Pips', 'onb.lead': 'Обмен пиксель-арт-карточками через QR-коды. Без сервера и аккаунтов: всё хранится на этом устройстве.', 'onb.arrival': (/** @type {string} */ title, /** @type {string} */ a) => `Вам прислали карточку «${title}»${a ? ` от @${a}` : ''}. Создайте ключ или восстановите свой — и она сразу будет в коллекции.`, 'link.bad': 'Ссылка на карточку неполная — возможно, её обрезал мессенджер. Попросите прислать ещё раз', - 'share.link': 'Отправить ссылкой', 'share.link.copied': 'Ссылка скопирована', 'share.link.failed': 'Не удалось скопировать ссылку', + 'share.link': 'Ссылкой', 'share.file': 'Файлом', 'share.png': 'Картинкой', 'share.ways': 'Другие способы', + 'receive.no-card-in-picture': 'В этой картинке нет карточки Pips. Код с картинки читает «Из картинки»', + 'picture.scan': 'Откройте в Pips: Сканер → Из картинки', 'picture.file': 'Откройте файл в Pips: Сканер → Из картинки', + 'share.file.saved': (/** @type {string} */ n) => `Сохранено: ${n}`, + 'share.link.copied': 'Ссылка скопирована', 'share.link.failed': 'Не удалось скопировать ссылку', 'share.link.text': (/** @type {string} */ title) => `Карточка «${title}» — откройте в Pips`, 'onb.create': 'Создать ключ', 'onb.restore': 'У меня уже есть ключ', 'onb.words.title': 'Ваши 24 слова', @@ -162,8 +164,13 @@ const ru = { 'words.cell': (/** @type {number} */ n) => `Слово ${n}`, 'err.generic': 'Что-то пошло не так', 'core.failed': 'Не удалось загрузить ядро приложения.', + 'store.reload': 'Обновить страницу', + 'store.blocked.title': 'Нужно закрыть другие вкладки', 'store.blocked': 'Pips открыт ещё в одной вкладке, в старой версии. Закройте её — данные обновятся, и приложение откроется само.', + 'store.newer.title': 'Pips обновился', 'store.newer': 'Данные на этом устройстве уже записаны более новой версией. Обновите страницу, чтобы открыть её.', + 'store.replaced.title': 'Pips обновился', 'store.replaced': 'В другой вкладке открыта новая версия: она обновила данные. Обновите и эту страницу.', + 'store.upgrade-failed.title': 'Данные не обновились', 'store.upgrade-failed': 'Не удалось перевести данные на новую версию. Они остались прежними и целыми. Обновите страницу; если не поможет — откройте журнал и отправьте отчёт.', // The collection screen. - 'tab.draw': 'Рисовать', 'tab.import': 'Импорт', 'tab.scan': 'Сканер', 'tab.profile': 'Профиль', + 'tab.draw': 'Рисовать', 'tab.give': 'Отдать', 'tab.scan': 'Сканер', 'tab.profile': 'Профиль', 'collection.by-months': 'по месяцам', 'collection.by-weeks': 'по неделям', 'collection.by-authors': 'по авторам', 'collection.feed': 'лента', 'collection.card': 'карточка', 'collection.select': 'Выбрать', 'collection.done': 'Готово', 'collection.select-title': 'Выбор', @@ -220,7 +227,8 @@ const ru = { 'ed.mirror': 'Зеркало', 'ed.precise': 'Точный режим', 'ed.more': 'Ещё', 'ed.precise.hint': 'Ведите здесь — прицел идёт следом. Держите «Ставить» и ведите — получится линия.', 'ed.precise.put': 'Ставить', 'ed.precise.pen': 'Ставить', 'ed.precise.eraser': 'Стереть', 'ed.precise.fill': 'Залить', 'ed.precise.shapes': 'Фигура', 'ed.precise.select': 'Выделить', 'ed.precise.pick': 'Взять цвет', 'ed.precise.erase': 'Стереть под прицелом', - 'ed.frames': 'Кадры', 'ed.frame.add': 'Новый кадр: копия открытого', 'ed.frame.menu': 'Кадр', + 'ed.frames': 'Кадры', 'ed.frame.add': 'Новый кадр: копия открытого', 'ed.frame.first': 'Кадр анимации', + 'ed.empty': 'Холст пуст — сначала нарисуйте что-нибудь', 'ed.frame.menu': 'Кадр', 'ed.frame.n': (/** @type {any} */ n, /** @type {any} */ all) => `Кадр ${n} из ${all}`, 'ed.frame.open': (/** @type {any} */ n, /** @type {any} */ all) => `Кадр ${n} из ${all}, открыт`, 'ed.frame.copy': 'Копия кадра', 'ed.frame.left': 'Сдвинуть влево', 'ed.frame.right': 'Сдвинуть вправо', 'ed.frame.clear': 'Очистить кадр', 'ed.frame.remove': 'Удалить кадр', @@ -239,7 +247,19 @@ const ru = { 'ed.color.note-builtin': 'Готовая палитра станет своей: каждый цвет добавит к коду 3 байта.', 'ed.set.size': 'Размер холста', 'ed.set.shade': 'Цвет рамки', 'ed.set.shade.n': (/** @type {any} */ n) => `Рамка ${n}`, 'ed.set.delay': 'Кадр, мс', 'ed.set.axes': 'Зеркало', 'ed.set.axes.x': 'Слева направо', 'ed.set.axes.y': 'Сверху вниз', 'ed.set.axes.xy': 'Оба', - 'ed.set.grid': 'Сетка на холсте', 'ed.set.new': 'Начать новую карточку', + 'ed.set.grid': 'Сетка на холсте', 'ed.set.new': 'Новая карточка', + 'ed.new.blank': 'Пустой холст', 'ed.new.blank.note': 'прежний вернёт «Отменить»', 'ed.new.picture': 'Из картинки', 'ed.new.picture.note': 'PNG, Aseprite, фото', + 'act.derive': 'Новая из неё', 'act.derive.too-big': 'Эта карточка больше холста редактора', + 'ed.from-picture': 'Картинка на холсте', 'ed.from-card': 'Новая карточка из прежней: назовите её и подпишите', + 'pic.has-card': (/** @type {string} */ title, /** @type {string} */ by) => `В картинке — карточка «${title}», ${by}. Её можно добавить в коллекцию как есть, с подписью автора, или взять рисунок на холст ниже.`, + 'pic.add-card': 'Добавить в коллекцию', + 'pic.title': 'Из картинки', 'pic.place': 'На холст', 'pic.other': 'Другая картинка', + 'pic.not-square': 'Картинка больше 64×64 — выберите другие настройки или другую картинку', + 'pic.ase-square': 'Холст в Aseprite больше 64×64: уменьшите его там', + 'pic.unreadable': 'Не удалось открыть картинку: браузер не знает этот формат', + 'pic.crop.label': 'Область картинки: двигайте и тяните за углы', + 'pic.crop.pixel': 'Картинка больше холста: выберите квадрат — пиксели останутся как есть', + 'pic.crop.photo': 'Карточка квадратная: двигайте рамку, тяните за углы. Дальше можно дорисовать', 'ed.size.crop': (/** @type {any} */ n) => `При размере ${n}×${n} края рисунка обрежутся. Отменить можно будет кнопкой «Отменить».`, 'ed.size.yes': 'Обрезать', 'ed.fresh': 'Холст чистый', 'ed.history': 'История изменений', 'ed.hold-history': 'удерживайте — вся история', 'ed.undo': 'Отменить', 'ed.redo': 'Вернуть', 'ed.undo.what': (/** @type {any} */ w) => `Отменить: ${w}`, 'ed.redo.what': (/** @type {any} */ w) => `Вернуть: ${w}`, @@ -275,10 +295,8 @@ const en = { 'back.edition-yours': 'yours', 'back.edition-not-yours': 'issued to someone else', 'back.tech': (/** @type {number} */ w, /** @type {number} */ hh, /** @type {number} */ b, /** @type {number} */ f) => `${w}×${hh} · ${b} bytes${f > 1 ? ` · ${f} frames` : ''}`, 'card.animated': (/** @type {number} */ n) => `Animated: ${n} frames`, - 'link.confirm': (/** @type {string} */ host) => `Open a link to ${host}? The artist left it; Pips has not checked it.`, - 'import.title': 'New card', 'import.reset': 'Start over', 'import.pick': 'Choose a picture', 'import.pick.short': 'PNG or Aseprite', 'import.toggle': 'Show the original or the card', - 'import.pick.hint': 'Up to 128×128 pixels and 32 colours. An upscaled export (×2, ×4…) shrinks back by itself. The file never leaves this device.', - 'import.before': 'original', 'import.after': 'card', 'import.other': 'Another file', 'import.save': 'Sign and add', + 'link.confirm': (/** @type {string} */ host) => `Open a link to ${host}? The artist left it; Pips has not checked it.`, 'import.toggle': 'Show the original or the card', + 'import.before': 'original', 'import.after': 'card', 'import.saved': 'Card added — ready to give', 'import.size': (/** @type {number} */ w, /** @type {number} */ h) => `${w}×${h}`, 'import.size.scaled': (/** @type {number[]} */ ...a) => `${a[0]}×${a[1]} → ${a[2]}×${a[3]} (×${a[4]} upscaling removed)`, @@ -288,14 +306,12 @@ const en = { 'import.changed': (/** @type {number} */ n) => `${n} pixel${n === 1 ? '' : 's'} recoloured`, 'import.code.single': (/** @type {number} */ n) => `${n} bytes, one QR`, 'import.code.carousel': (/** @type {number} */ n, /** @type {number} */ k, /** @type {number} */ s) => `${n} bytes, a carousel of ${k} QR codes, ~${s} s`, - 'import.over-app': (/** @type {number} */ w, /** @type {number} */ hh, /** @type {number} */ m) => `${w}×${hh}: cards in the app are up to ${m}×${m}. Make the picture smaller.`, 'import.opt.keep-scale': 'Keep the original size', 'import.opt.first-frame': 'First frame only', 'import.opt.palette': 'Palette', 'import.opt.palette.auto': 'Exact colours of the file', 'import.opt.palette.fit': (/** @type {number} */ n) => `Own, fitted · ${n}`, 'import.palette.fitted': (/** @type {number} */ had, /** @type {number} */ n) => `${had > 256 ? 'over 256' : had} colours in the file → own ${n}`, 'import.fitted-auto': 'An own palette was fitted — tap the card to compare.', - 'import.name': 'Title', 'import.name.ph': 'Name the card', 'import.shade': 'Frame', 'import.err.unknown_format': 'Not a PNG or Aseprite file', 'import.err.too_large': 'The file is too large to import', 'import.err.malformed': 'The file is damaged or badly written', @@ -306,6 +322,8 @@ const en = { 'import.err.too_many_frames': 'More than 64 frames', 'import.err.unequal_delays': 'Frames have different durations and a card has one. Even them out in Aseprite or take the first frame only', 'import.err.delay_out_of_range': (/** @type {number} */ ms) => `A frame lasts ${ms} ms; 20 to 5000 are allowed`, + 'give.title': 'Give', 'give.sub': 'show the code — it is scanned off your screen', 'give.yours': 'Your cards', 'give.hint': 'Let the other phone scan the code.', 'give.new': 'Draw a new one', + 'give.empty.title': 'Nothing to give yet', 'give.empty': 'Your cards will be here, their codes ready.', 'give.empty.draw': 'Draw', 'give.mode.copy': 'Copy', 'give.mode.edition': 'Numbered', 'edition.my-code': 'Recipient code', 'edition.my-code.hint': (/** @type {string} */ nick) => `Show it to the artist: they will issue you a numbered copy in the name of @${nick}.`, @@ -325,11 +343,11 @@ const en = { 'edition.done': 'Done', 'scan.loading': 'Loading the QR reader…', 'scan.failed': 'Could not load the QR reader. Check the connection and try again', 'scan.none': 'No QR code found in the picture', + 'scan.no-camera': 'No access to the camera', 'scan.no-camera.how': 'Allow it in the browser settings, or open a picture', 'give.single': (/** @type {number} */ b) => `${b} bytes · one code. If it won’t scan, turn the brightness up.`, 'give.carousel': (/** @type {number} */ n) => `A large card: the codes cycle. Keep the camera on it until it has ${n} parts.`, 'give.copy-of': (/** @type {string} */ a) => `You are passing on a copy. The artist is ${a}.`, 'receive.title': 'Scan', 'receive.start': 'Start camera', 'receive.point': 'Point at the code on the other screen', - 'receive.no-camera': 'No camera available. Open a picture of the code or paste a link instead.', 'receive.image': 'From picture', 'receive.file': '.pips file', 'receive.paste': 'Paste a link from the clipboard', 'receive.clipboard-empty': 'The clipboard is empty', 'receive.paste-here': 'Paste the link into the field', 'receive.paste.placeholder': 'Link or code text', @@ -391,7 +409,11 @@ const en = { 'onb.title': 'Pips', 'onb.lead': 'Trade pixel-art cards through QR codes. No server, no accounts: everything stays on this device.', 'onb.arrival': (/** @type {string} */ title, /** @type {string} */ a) => `Someone sent you the card «${title}»${a ? ` by @${a}` : ''}. Create your key or restore yours — and it is in your collection at once.`, 'link.bad': 'The card link is not whole — a messenger may have cut it. Ask for it again', - 'share.link': 'Send as a link', 'share.link.copied': 'Link copied', 'share.link.failed': 'Could not copy the link', + 'share.link': 'Link', 'share.file': 'File', 'share.png': 'Picture', 'share.ways': 'Other ways', + 'receive.no-card-in-picture': 'This picture holds no Pips card. «From picture» reads a code off it', + 'picture.scan': 'Open in Pips: Scan → From picture', 'picture.file': 'Open the file in Pips: Scan → From picture', + 'share.file.saved': (/** @type {string} */ n) => `Saved: ${n}`, + 'share.link.copied': 'Link copied', 'share.link.failed': 'Could not copy the link', 'share.link.text': (/** @type {string} */ title) => `The card «${title}» — open it in Pips`, 'onb.create': 'Create my key', 'onb.restore': 'I already have a key', 'onb.words.title': 'Your 24 words', @@ -419,8 +441,13 @@ const en = { 'words.cell': (/** @type {number} */ n) => `Word ${n}`, 'err.generic': 'Something went wrong', 'core.failed': 'Could not load the app core.', + 'store.reload': 'Reload', + 'store.blocked.title': 'Other tabs need closing', 'store.blocked': 'Pips is open in another tab, an older version. Close it: the data will be updated and the app opens by itself.', + 'store.newer.title': 'Pips has been updated', 'store.newer': 'The data on this device was stored by a newer version. Reload to open it.', + 'store.replaced.title': 'Pips has been updated', 'store.replaced': 'A newer version, open in another tab, has updated the data. Reload this page too.', + 'store.upgrade-failed.title': 'The data was not updated', 'store.upgrade-failed': 'The data could not be moved to the new version. It is as it was, and whole. Reload; if that does not help, open the journal and send a report.', // The collection screen. - 'tab.draw': 'Draw', 'tab.import': 'Import', 'tab.scan': 'Scan', 'tab.profile': 'Profile', + 'tab.draw': 'Draw', 'tab.give': 'Give', 'tab.scan': 'Scan', 'tab.profile': 'Profile', 'collection.by-months': 'by month', 'collection.by-weeks': 'by week', 'collection.by-authors': 'by artist', 'collection.feed': 'feed', 'collection.card': 'card', 'collection.select': 'Select', 'collection.done': 'Done', 'collection.select-title': 'Select', @@ -477,7 +504,8 @@ const en = { 'ed.mirror': 'Mirror', 'ed.precise': 'Precise mode', 'ed.more': 'More', 'ed.precise.hint': 'Move a finger here — the cursor follows. Hold “Put” and move to draw a line.', 'ed.precise.put': 'Put', 'ed.precise.pen': 'Put', 'ed.precise.eraser': 'Erase', 'ed.precise.fill': 'Fill', 'ed.precise.shapes': 'Shape', 'ed.precise.select': 'Select', 'ed.precise.pick': 'Pick', 'ed.precise.erase': 'Erase under the cursor', - 'ed.frames': 'Frames', 'ed.frame.add': 'New frame: a copy of the open one', 'ed.frame.menu': 'Frame', + 'ed.frames': 'Frames', 'ed.frame.add': 'New frame: a copy of the open one', 'ed.frame.first': 'Animation frame', + 'ed.empty': 'The canvas is empty — draw something first', 'ed.frame.menu': 'Frame', 'ed.frame.n': (/** @type {any} */ n, /** @type {any} */ all) => `Frame ${n} of ${all}`, 'ed.frame.open': (/** @type {any} */ n, /** @type {any} */ all) => `Frame ${n} of ${all}, open`, 'ed.frame.copy': 'Copy the frame', 'ed.frame.left': 'Move left', 'ed.frame.right': 'Move right', 'ed.frame.clear': 'Clear the frame', 'ed.frame.remove': 'Delete the frame', @@ -496,7 +524,19 @@ const en = { 'ed.color.note-builtin': 'The ready palette becomes your own: each colour adds 3 bytes to the code.', 'ed.set.size': 'Canvas size', 'ed.set.shade': 'Frame colour', 'ed.set.shade.n': (/** @type {any} */ n) => `Frame ${n}`, 'ed.set.delay': 'Frame, ms', 'ed.set.axes': 'Mirror', 'ed.set.axes.x': 'Left to right', 'ed.set.axes.y': 'Top to bottom', 'ed.set.axes.xy': 'Both', - 'ed.set.grid': 'Grid on the canvas', 'ed.set.new': 'Start a new card', + 'ed.set.grid': 'Grid on the canvas', 'ed.set.new': 'New card', + 'ed.new.blank': 'Blank canvas', 'ed.new.blank.note': '«Undo» brings this one back', 'ed.new.picture': 'From a picture', 'ed.new.picture.note': 'PNG, Aseprite, photo', + 'act.derive': 'New from it', 'act.derive.too-big': 'This card is larger than the editor’s canvas', + 'ed.from-picture': 'The picture is on the canvas', 'ed.from-card': 'A new card from the old one: name it and sign', + 'pic.has-card': (/** @type {string} */ title, /** @type {string} */ by) => `This picture holds the card «${title}» by ${by}. Add it to the collection as it is, signed by its artist, or take the drawing onto the canvas below.`, + 'pic.add-card': 'Add to the collection', + 'pic.title': 'From a picture', 'pic.place': 'Onto the canvas', 'pic.other': 'Another picture', + 'pic.not-square': 'Larger than 64×64 — try other options or another picture', + 'pic.ase-square': 'The Aseprite canvas is larger than 64×64: make it smaller there', + 'pic.unreadable': 'Could not open the picture: the browser does not know this format', + 'pic.crop.label': 'Part of the picture: move it and pull the corners', + 'pic.crop.pixel': 'Larger than the canvas: choose a square — the pixels stay as they are', + 'pic.crop.photo': 'A card is square: move the frame, pull its corners. You can draw on it after', 'ed.size.crop': (/** @type {any} */ n) => `At ${n}×${n} the edges of the picture are cut off. “Undo” brings them back.`, 'ed.size.yes': 'Cut', 'ed.fresh': 'A clean canvas', 'ed.history': 'History of changes', 'ed.hold-history': 'hold for the whole history', 'ed.undo': 'Undo', 'ed.redo': 'Redo', 'ed.undo.what': (/** @type {any} */ w) => `Undo: ${w}`, 'ed.redo.what': (/** @type {any} */ w) => `Redo: ${w}`, diff --git a/web/src/links.js b/web/src/links.js index c6eec15..9c9e72b 100644 --- a/web/src/links.js +++ b/web/src/links.js @@ -3,6 +3,9 @@ // which the browser never sends to the server. A link opens the app and // the card arrives as if scanned. No DOM; the address is passed in. +/** A .pips file: these four bytes, then what a code carries (an envelope). */ +export const MAGIC = [0x50, 0x50, 0x1a, 0x0a]; + /** Where links point when the app runs somewhere a link cannot (plain http, a file). */ export const PUBLISHED = 'https://pips-app.github.io/pips/'; diff --git a/web/src/main.js b/web/src/main.js index 0405bec..b57ea5f 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -2,19 +2,19 @@ // Start-up: load the core, open storage, then either the first-run flow or // the app itself. -import { install, attach, locate, onUncaught } from './diagnostics.js'; +import { install, attach, locate, onUncaught, journal } from './diagnostics.js'; import { h } from './dom.js'; import { loadCore, isDevHost } from './core.js'; import { t, setLanguage, defaultLanguage } from './i18n.js'; import { app, load, hasIdentity } from './state.js'; import { cardInAddress } from './links.js'; import { arrive, peek } from './ui/arrival.js'; -import { getMeta } from './store.js'; +import { getMeta, onReplaced, onBlocked } from './store.js'; import { mountShell, go, toast, currentView } from './ui/shell.js'; import { openJournal } from './ui/journal-view.js'; import { collectionView } from './collection/view.js'; import { editorView } from './editor/view.js'; -import { importView } from './ui/import.js'; +import { giveView } from './ui/give.js'; import { receiveView } from './ui/receive.js'; import { meView } from './ui/me.js'; import { onboarding } from './ui/onboarding.js'; @@ -33,7 +33,19 @@ async function main() { root.append(h('div', { class: 'empty' }, h('b', {}, t('core.failed')), String(/** @type {any} */ (e)?.message ?? e))); return; } - await load(loaded.core, loaded.stub); + // Another tab with a newer Pips takes the data over: this one steps aside. + onReplaced(() => halt('store.replaced')); + onBlocked(() => halt('store.blocked', false)); + try { + await load(loaded.core, loaded.stub); + document.querySelector('.halt')?.remove(); // the other tabs let go + } catch (e) { + const code = /** @type {any} */ (e)?.code; + if (code !== 'newer_data' && code !== 'upgrade_failed') throw e; + journal.error(e, 'storage'); + halt(code === 'newer_data' ? 'store.newer' : 'store.upgrade-failed'); + return; + } await attach(); const lang = await getMeta('language'); if (lang) setLanguage(lang); @@ -46,7 +58,7 @@ async function main() { let incoming = fromAddress(); const start = () => { mountShell(root, { - collection: collectionView({ draw: () => go('draw') }), draw: editorView(), import: importView(), receive: receiveView(), me: meView(), + collection: collectionView({ draw: () => go('draw') }), draw: editorView(), give: giveView(), receive: receiveView(), me: meView(), }); go('collection'); locate(() => currentView() ?? ''); @@ -74,6 +86,42 @@ async function main() { if ('serviceWorker' in navigator && !isDevHost()) offerUpdates().catch(() => {}); } +/** + * The app cannot go on with the data as it is: says why, over everything, + * with the one thing to do. Nothing is written meanwhile. + * @param {string} key @param {boolean} [reload] whether reloading is the way on + */ +function halt(key, reload = true) { + document.querySelectorAll('.halt').forEach((e) => e.remove()); + document.body.append(h('div', { class: 'halt', role: 'alertdialog', 'aria-modal': 'true' }, + h('div', {}, h('h1', {}, t(`${key}.title`)), h('p', {}, t(key)), + reload ? h('button', { class: 'btn key wide', onclick: () => freshReload() }, t('store.reload')) : '', + // What went wrong can still be read and sent: the journal lives in memory until storage opens. + key === 'store.upgrade-failed' ? h('button', { class: 'btn wide', style: { marginTop: '8px' }, onclick: () => openJournal() }, t('journal.open')) : ''))); +} + +/** + * A reload that brings the newest app: an update already fetched in the + * background takes over first. Without it, this very page would come back + * from the cache — the old version that cannot open the data. + */ +async function freshReload() { + try { + const reg = await navigator.serviceWorker?.getRegistration(); + await reg?.update(); + const next = reg?.waiting ?? reg?.installing; + if (next) { + navigator.serviceWorker.addEventListener('controllerchange', () => location.reload(), { once: true }); + const go = () => { if (next.state === 'installed') next.postMessage('take-over'); }; + next.addEventListener('statechange', go); + go(); + setTimeout(() => location.reload(), 8000); // whatever happens, not stuck here + return; + } + } catch { /* no service worker here: a plain reload */ } + location.reload(); +} + /** * The envelope in the address, taken out of it; 'bad' for a link that is * not whole (cut short by a messenger, say). @returns {Uint8Array|'bad'|null} diff --git a/web/src/png-card.js b/web/src/png-card.js new file mode 100644 index 0000000..6f80faa --- /dev/null +++ b/web/src/png-card.js @@ -0,0 +1,110 @@ +// @ts-check +// A card inside a PNG picture of it: the same envelope a code carries, in a +// chunk of its own, so a picture shared as a file opens in Pips as the card +// itself — its author, its signatures — not as a drawing. Other programs +// skip the chunk: it is ancillary ('p'), private ('i') and safe to copy +// ('s'). A messenger that re-encodes the picture drops it; the code drawn +// on the picture is there for that. +// +// Reading is for files from anywhere: every length is checked before it +// is used, every chunk's CRC must hold, and the size has limits. The +// envelope found is only bytes: the core checks every signature in it. +// No DOM: runs under node for tests. + +const SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; +const TYPE = 'piPs'; +/** The first byte of the chunk's data: its layout, should it ever change. */ +const LAYOUT = 1; +/** No envelope is larger (pips-wasm MAX_LINK is far below this). */ +const MAX_DATA = 256 * 1024; +const MAX_CHUNKS = 10_000; + +/** @type {Uint32Array|null} */ +let table = null; +/** CRC-32 as PNG defines it. @param {Uint8Array} bytes */ +export function crc32(bytes) { + if (!table) { + table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c >>> 0; + } + } + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = table[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +const ascii = (/** @type {string} */ s) => Uint8Array.from(s, (c) => c.charCodeAt(0)); +/** @param {Uint8Array} b @param {number} at */ +const u32 = (b, at) => ((b[at] << 24) | (b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]) >>> 0; + +/** Whether the bytes begin as a PNG. @param {Uint8Array} b */ +export const isPng = (b) => b.length >= 8 && SIGNATURE.every((v, i) => b[i] === v); + +/** + * The PNG with the envelope in it, just before its end. A chunk of ours + * already there is replaced. @param {Uint8Array} png @param {Uint8Array} envelope + */ +export function withEnvelope(png, envelope) { + const chunks = walk(png); + if (!chunks) throw new Error('not a PNG'); + const end = chunks.find((c) => c.type === 'IEND'); + if (!end) throw new Error('a PNG without its end'); + const data = new Uint8Array(1 + envelope.length); + data[0] = LAYOUT; + data.set(envelope, 1); + const body = new Uint8Array(4 + data.length); + body.set(ascii(TYPE), 0); + body.set(data, 4); + const chunk = new Uint8Array(12 + data.length); + const view = new DataView(chunk.buffer); + view.setUint32(0, data.length); + chunk.set(body, 4); + view.setUint32(8 + data.length, crc32(body)); + // Everything but an old chunk of ours, ours, then the end. + const parts = [png.subarray(0, 8), ...chunks.filter((c) => c.type !== TYPE && c.type !== 'IEND').map((c) => png.subarray(c.at, c.end)), chunk, png.subarray(end.at, end.end)]; + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +} + +/** + * The envelope a PNG carries, or null: not a PNG, none in it, or one + * that does not hold together. @param {Uint8Array} bytes + */ +export function envelopeIn(bytes) { + const chunks = walk(bytes); + const c = chunks?.find((x) => x.type === TYPE); + if (!c || c.length < 2 || c.length > MAX_DATA) return null; + const data = bytes.subarray(c.at + 8, c.at + 8 + c.length); + if (data[0] !== LAYOUT) return null; + return data.slice(1); +} + +/** + * The chunks of a PNG — where each starts and ends, its type and length — + * or null if the file is not one, or breaks off, or a CRC is wrong. + * @param {Uint8Array} b + * @returns {{type: string, at: number, end: number, length: number}[] | null} + */ +function walk(b) { + if (!isPng(b)) return null; + const out = []; + let at = 8; + while (at < b.length) { + if (at + 12 > b.length || out.length >= MAX_CHUNKS) return null; + const length = u32(b, at); + const end = at + 12 + length; + if (length > 0x7fffffff || end > b.length) return null; + const type = String.fromCharCode(b[at + 4], b[at + 5], b[at + 6], b[at + 7]); + if (!/^[A-Za-z]{4}$/.test(type)) return null; + if (crc32(b.subarray(at + 4, at + 8 + length)) !== u32(b, at + 8 + length)) return null; + out.push({ type, at, end, length }); + at = end; + if (type === 'IEND') break; + } + return out.some((c) => c.type === 'IEND') ? out : null; +} diff --git a/web/src/schema.js b/web/src/schema.js new file mode 100644 index 0000000..1a1b2d6 --- /dev/null +++ b/web/src/schema.js @@ -0,0 +1,52 @@ +// @ts-check +// The layout of what is stored, and how it changes from one version of the +// app to the next. Its version is the database's own version number, so +// the browser keeps every tab honest: a newer app opening the data upgrades +// it in one transaction (all of it or none), the older tabs are told to +// step aside, and an older app can no longer open data it does not know. +// +// A step is never edited once released: a new layout is a new step at the +// end. Steps change records with plain functions, here, so that each one +// is tested apart from the browser; store.js walks the records through them. + +/** The layout this app writes. */ +export const SCHEMA = 2; + +/** + * @typedef {{ + * stores?: (db: IDBDatabase) => void, + * card?: (rec: any) => any, + * meta?: (key: string, value: any) => any, + * }} Step + * stores: object stores to create or remove; card and meta: each record, + * old in, new out (the same object back means "unchanged"). + */ + +/** STEPS[v] takes stored data from version v to v + 1. @type {Step[]} */ +export const STEPS = [ + // 0 → 1: a new device. + { + stores(db) { + db.createObjectStore('meta'); + db.createObjectStore('cards', { keyPath: 'digest' }); + }, + }, + // 1 → 2: the first change of layout. Before it, fields that came later + // were simply missing from older records; now every record has them all. + { card: fillCard }, +]; + +/** Every field of a card record, with what an older record lacked filled in. @param {any} rec */ +export function fillCard(rec) { + const full = { cert: null, edition: null, trashedAt: null, from: null, note: '', ...rec, tags: Array.isArray(rec.tags) ? rec.tags : [] }; + return Object.keys(full).length === Object.keys(rec).length && Array.isArray(rec.tags) ? rec : full; +} + +/** + * The steps from one version to another, in order. + * @param {number} from @param {number} [to] + */ +export function stepsFrom(from, to = SCHEMA) { + if (!Number.isInteger(from) || from < 0 || from > to) throw new RangeError(`no way from layout ${from} to ${to}`); + return STEPS.slice(from, to); +} diff --git a/web/src/store.js b/web/src/store.js index 06b2800..210a3ed 100644 --- a/web/src/store.js +++ b/web/src/store.js @@ -15,6 +15,8 @@ * from: {nick: string, key: string}|null, note: string, tags?: string[], * editions?: import('./state.js').EditionLog}} CardRecord */ +import { SCHEMA, stepsFrom } from './schema.js'; + /** @type {IDBDatabase} */ let db; /** When IndexedDB is unavailable (some private modes, blocked site data), @@ -22,11 +24,28 @@ let db; export let memoryOnly = false; const mem = { meta: new Map(), cards: new Map() }; -/** @param {string} name */ +/** Called when another tab, with a newer app, needs the data: this one must stop using it. */ +let replaced = () => {}; +/** @param {() => void} fn */ +export function onReplaced(fn) { replaced = fn; } +/** Called while an older tab still holds the data this one needs to upgrade. */ +let blocked = () => {}; +/** @param {() => void} fn */ +export function onBlocked(fn) { blocked = fn; } + +/** + * Opens the data, upgrading its layout first if it is older (see schema.js). + * Data from a newer app is refused with the code `newer_data` — falling + * back to memory then would show an empty app over a full collection. + * @param {string} name + */ export async function open(name) { try { await openDb(name); } catch (e) { + // Data that is there but cannot be used as it is must not be hidden + // behind an empty app; only a browser without storage falls back. + if (['newer_data', 'upgrade_failed'].includes(/** @type {any} */ (e)?.code)) throw e; console.warn('pips: IndexedDB unavailable, keeping data in memory', e); memoryOnly = true; } @@ -36,20 +55,59 @@ export async function open(name) { function openDb(name) { return new Promise((resolve, reject) => { if (!globalThis.indexedDB) throw new Error('no IndexedDB'); - const req = indexedDB.open(name, 1); - req.onupgradeneeded = () => { - const d = req.result; - d.createObjectStore('meta'); - d.createObjectStore('cards', { keyPath: 'digest' }); + const req = indexedDB.open(name, SCHEMA); + let upgrading = false; + req.onupgradeneeded = (e) => { + upgrading = e.oldVersion > 0; + upgrade(req.result, /** @type {IDBTransaction} */ (req.transaction), e.oldVersion); }; + // Tabs of an app from before onversionchange cannot be asked to let go: + // the person is, and the upgrade goes on by itself once they have. + req.onblocked = () => blocked(); req.onsuccess = () => { db = req.result; + db.onversionchange = () => { + db.close(); + replaced(); + }; resolve(undefined); }; - req.onerror = () => reject(req.error); + req.onerror = () => { + const err = req.error; + if (err?.name === 'VersionError') reject(Object.assign(new Error('the data was stored by a newer version of the app'), { code: 'newer_data' })); + // A failed upgrade changed nothing (its transaction is undone), but the + // data is not usable by this version as it is. + else if (upgrading) reject(Object.assign(new Error(`could not upgrade the stored data: ${err?.message ?? err}`), { code: 'upgrade_failed' })); + else reject(err); + }; }); } +/** + * Walks the data through every step from its version to this app's, inside + * the upgrade's own transaction: if anything fails, nothing has changed. + * @param {IDBDatabase} d @param {IDBTransaction} t @param {number} from + */ +function upgrade(d, t, from) { + const steps = stepsFrom(from); + // Stores first, all of them: a later step's records may live in them. + for (const s of steps) s.stores?.(d); + const card = (/** @type {any} */ rec) => steps.reduce((r, s) => (s.card ? s.card(r) : r), rec); + const meta = (/** @type {string} */ key, /** @type {any} */ value) => steps.reduce((v, s) => (s.meta ? s.meta(key, v) : v), value); + /** @param {string} store @param {(cur: IDBCursorWithValue) => any} next */ + const walk = (store, next) => { + t.objectStore(store).openCursor().onsuccess = (e) => { + const cur = /** @type {IDBCursorWithValue|null} */ (/** @type {IDBRequest} */ (e.target).result); + if (!cur) return; + const v = next(cur); + if (v !== cur.value) cur.update(v); + cur.continue(); + }; + }; + if (steps.some((s) => s.card)) walk('cards', (cur) => card(cur.value)); + if (steps.some((s) => s.meta)) walk('meta', (cur) => meta(String(cur.key), cur.value)); +} + /** @param {IDBRequest} req */ function done(req) { return new Promise((resolve, reject) => { diff --git a/web/src/ui/card-picture.js b/web/src/ui/card-picture.js new file mode 100644 index 0000000..0abf06c --- /dev/null +++ b/web/src/ui/card-picture.js @@ -0,0 +1,146 @@ +// @ts-check +// A card as one picture to post or send: the card on its paper — the +// drawing, its name, its artist — and, when the card fits one code, that +// code beside the name. The same envelope also goes inside the PNG +// (png-card.js). So the picture opens in Pips as the card itself: from the +// file as it was sent, or, after a messenger has re-encoded it or someone +// took a screenshot, by scanning the code on it ("From picture"). + +import { app } from '../state.js'; +import { FRAME_SHADES, cardTitle } from './card.js'; +import { withEnvelope } from '../png-card.js'; +import { t } from '../i18n.js'; + +const W = 1080, H = 1440, PAD = 60; +const ART = W - 2 * PAD; // the drawing's square +const QR = 380; // the code's square, quiet zone included: 3+ px a module +const QUIET = 4; + +/** + * @param {any} card @param {Uint8Array} envelope what a code of this card carries + * @returns {Promise} a PNG + */ +export async function cardPicture(card, envelope) { + await Promise.all(['600 64px Literata', '500 32px "IBM Plex Sans"', '400 26px "IBM Plex Sans"'].map((f) => document.fonts.load(f).catch(() => {}))); + const shade = FRAME_SHADES[card.frame] ?? FRAME_SHADES[0]; + const cv = document.createElement('canvas'); + cv.width = W; + cv.height = H; + const g = /** @type {CanvasRenderingContext2D} */ (cv.getContext('2d')); + g.fillStyle = shade.paper; + g.fillRect(0, 0, W, H); + + // The drawing, a whole number of picture pixels to each of its cells. + const r = app.core.render(card, 0); + const cell = Math.floor(ART / Math.max(r.width, r.height)); + const aw = r.width * cell, ah = r.height * cell; + const src = document.createElement('canvas'); + src.width = r.width; + src.height = r.height; + /** @type {CanvasRenderingContext2D} */ (src.getContext('2d')).putImageData(new ImageData(new Uint8ClampedArray(r.rgba), r.width, r.height), 0, 0); + g.imageSmoothingEnabled = false; + g.drawImage(src, PAD + (ART - aw) / 2, PAD + (ART - ah) / 2, aw, ah); + + // The code, bottom right, when one code holds the card. + const plan = app.core.qrPlan(envelope); + const top = PAD + ART + 44; + const textRight = plan.kind === 'single' ? W - PAD - QR - 32 : W - PAD; + if (plan.kind === 'single') drawQr(g, plan.text, W - PAD - QR, H - PAD - QR, QR, codeColours(shade)); + + // The name and the artist, as on the card. + g.textBaseline = 'top'; + g.fillStyle = shade.ink; + g.font = '600 64px Literata, Georgia, serif'; + const lines = wrap(g, cardTitle(card), textRight - PAD, 2); + lines.forEach((l, i) => g.fillText(l, PAD, top + i * 74)); + g.fillStyle = shade.dim; + g.font = '500 32px "IBM Plex Sans", system-ui, sans-serif'; + g.fillText(card.author ? `@${card.author}` : '—', PAD, top + lines.length * 74 + 12); + g.font = '400 26px "IBM Plex Sans", system-ui, sans-serif'; + g.fillText(t(plan.kind === 'single' ? 'picture.scan' : 'picture.file'), PAD, H - PAD - 26); + + const png = await new Promise((ok, fail) => cv.toBlob((b) => (b ? ok(b) : fail(new Error('the picture could not be made'))), 'image/png')); + return new Blob([withEnvelope(new Uint8Array(await /** @type {Blob} */ (png).arrayBuffer()), envelope)], { type: 'image/png' }); +} + +/** + * The code in the paper's own colours, on a small rounded ground of its + * own — a label on the card, alike on every paper. On light paper the + * ground is the paper lightened and the modules are the ink softened + * toward the paper's grey; on dark paper — where a code light on dark + * would not scan everywhere — the ground is the ink's light colour and the + * modules the paper's. Always dark on light, never below a contrast every + * reader takes (checked in the tests, after JPEG). + * @param {{paper: string, ink: string, dim: string}} shade + * @returns {{ground: string, modules: string}} + */ +export function codeColours(shade) { + const paper = rgb(shade.paper), ink = rgb(shade.ink), dim = rgb(shade.dim); + if (luma(paper) > luma(ink)) { + const ground = mix(paper, [255, 255, 255], 0.6); + // As far toward the grey as the contrast allows. + let best = ink; + for (let k = 0.05; k <= 0.6; k += 0.05) { + const c = mix(ink, dim, k); + if (contrast(ground, c) < MIN_CONTRAST) break; + best = c; + } + return { ground: hex(ground), modules: hex(best) }; + } + return { ground: hex(mix(ink, paper, 0.06)), modules: shade.paper }; +} +const MIN_CONTRAST = 7; // with 3+ px a module: read at full size and at 3/4, after JPEG (tests) + +/** A code with whole pixels to each module. @param {CanvasRenderingContext2D} g @param {string} text @param {number} x @param {number} y @param {number} side @param {{ground: string, modules: string}} c */ +function drawQr(g, text, x, y, side, c) { + const m = app.core.qrMatrix(text); + const n = m.size + 2 * QUIET, px = Math.floor(side / n), s = n * px; + const ox = x + side - s, oy = y + side - s; // flush with the corner + g.fillStyle = c.ground; + g.beginPath(); + g.roundRect(ox, oy, s, s, 14); + g.fill(); + g.fillStyle = c.modules; + for (let r = 0; r < m.size; r++) { + for (let k = 0; k < m.size; k++) if (m.modules[r * m.size + k]) g.fillRect(ox + (k + QUIET) * px, oy + (r + QUIET) * px, px, px); + } +} + +/** @param {string} h '#rrggbb' */ +const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); +/** @param {number[]} c */ +const hex = (c) => `#${c.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')}`; +/** @param {number[]} a @param {number[]} b @param {number} k */ +const mix = (a, b, k) => a.map((v, i) => v + (b[i] - v) * k); +/** Relative luminance (WCAG). @param {number[]} c */ +const luma = (c) => { + const [r, g, b] = c.map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +/** @param {number[]} a @param {number[]} b */ +const contrast = (a, b) => { const [x, y] = [luma(a), luma(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; + +/** The text in at most `max` lines of `width`, the last cut with an ellipsis. @param {CanvasRenderingContext2D} g @param {string} text @param {number} width @param {number} max */ +function wrap(g, text, width, max) { + const words = text.split(/\s+/), lines = []; + let line = ''; + for (const w of words) { + const next = line ? `${line} ${w}` : w; + if (g.measureText(next).width <= width || !line) line = next; + else { lines.push(line); line = w; } + } + if (line) lines.push(line); + if (lines.length > max) { + lines.length = max; + let last = lines[max - 1]; + while (last && g.measureText(`${last}…`).width > width) last = last.slice(0, -1); + lines[max - 1] = `${last}…`; + } + // A single word longer than the room is cut too. + return lines.map((l) => { + if (g.measureText(l).width <= width) return l; + let s = l; + while (s && g.measureText(`${s}…`).width > width) s = s.slice(0, -1); + return `${s}…`; + }); +} diff --git a/web/src/ui/give.js b/web/src/ui/give.js new file mode 100644 index 0000000..22cba9e --- /dev/null +++ b/web/src/ui/give.js @@ -0,0 +1,89 @@ +// @ts-check +// Give: one's own cards, ready to be handed over. The card chosen lies on +// its own paper with its code, exactly as the share sheet shows it (the +// same component: copy or numbered, link, file, picture); below, the +// person's cards as a row of small papers to choose from, the chosen one +// lifted. At a meeting it is one tap from the tab bar to a code. + +import { h, icon, replace, reveal } from '../dom.js'; +import { t } from '../i18n.js'; +import { app } from '../state.js'; +import { go } from './shell.js'; +import { artCanvas, FRAME_SHADES, cardTitle } from './card.js'; +import { fillShare } from '../collection/share.js'; + +/** The card last chosen here, kept while the app runs. @type {string|null} */ +let chosen = null; + +/** Own cards that can be given (not in the bin), newest first. */ +const ownCards = () => [...app.cards.values()] + .filter((e) => e.rec.mine && e.rec.state !== 'trash') + .sort((a, b) => b.rec.received - a.rec.received); + +/** The code's side here: as large as fits above the row of cards. */ +const sizeHere = () => Math.round(Math.max(180, Math.min(300, innerWidth - 96, innerHeight - 576))); + +export function giveView() { + const paper = h('div', { class: 'give-paper on-paper' }); + const body = h('div', { class: 'paper-body' }); + paper.append(body); + const deck = h('div', { class: 'give-deck', role: 'group', 'aria-label': t('give.yours') }); + const count = h('span', {}); + const deckBox = h('div', { class: 'give-deck-box' }, h('div', { class: 'give-deck-head' }, h('span', {}, t('give.yours')), count), deck); + const main = h('div', { class: 'scroll give-scroll' }, paper); + const el = h('section', { class: 'view give-view' }, + h('div', { class: 'bar' }, h('div', { class: 'title' }, h('h1', {}, t('give.title')), h('div', { class: 'sub' }, t('give.sub')))), + main, deckBox); + + let stop = () => {}; + + /** Lays the chosen card on the paper. @param {import('../state.js').Entry} e */ + function lay(e) { + stop(); + chosen = e.rec.digest; + const s = FRAME_SHADES[e.card.frame] ?? FRAME_SHADES[0]; + paper.style.setProperty('--shade', s.paper); + paper.style.setProperty('--shade-ink', s.ink); + paper.style.setProperty('--shade-dim', s.dim); + stop = fillShare(body, e, { size: sizeHere(), hint: t('give.hint') }); + for (const b of deck.querySelectorAll('.give-mini')) { + const on = /** @type {HTMLElement} */ (b).dataset.id === chosen; + b.setAttribute('aria-pressed', String(on)); + if (on) reveal(deck, b); + } + } + + function show() { + const list = ownCards(); + count.textContent = String(list.length); + if (!list.length) { + stop(); + stop = () => {}; + el.classList.add('empty-give'); + replace(main, h('div', { class: 'empty' }, + h('b', {}, t('give.empty.title')), t('give.empty'), + h('div', { style: { marginTop: '18px' } }, h('button', { class: 'btn key', onclick: () => go('draw') }, icon('brush'), t('give.empty.draw'))))); + deckBox.hidden = true; + return; + } + el.classList.remove('empty-give'); + deckBox.hidden = false; + if (!main.contains(paper)) replace(main, paper); + replace(deck, ...list.map((e) => { + const s = FRAME_SHADES[e.card.frame] ?? FRAME_SHADES[0]; + const b = h('button', { + type: 'button', class: 'give-mini', dataset: { id: e.rec.digest }, 'aria-label': cardTitle(e.card), title: cardTitle(e.card), + onclick: () => lay(e), + }, artCanvas(e.card, e.rec.digest)); + b.style.setProperty('--shade', s.paper); + return b; + }), h('button', { type: 'button', class: 'give-mini add', 'aria-label': t('give.new'), title: t('give.new'), onclick: () => go('draw') }, icon('plus'))); + lay(list.find((e) => e.rec.digest === chosen) ?? list[0]); + } + + return { + el, + show, + hide() { stop(); stop = () => {}; }, + }; +} diff --git a/web/src/ui/import.js b/web/src/ui/import.js deleted file mode 100644 index ca69a6e..0000000 --- a/web/src/ui/import.js +++ /dev/null @@ -1,306 +0,0 @@ -// @ts-check -// A new card from a PNG or Aseprite file: pick, see what the conversion -// did, adjust, name, sign. The file never leaves the device. -// -// A tab like the others: leaving it keeps what is under way (the file, the -// options, the title), so a person can look something up and come back. -// Only signing or the reset button start it over. -// -// The core does the work (pips-raster); this screen only shows its report -// and passes the options back. Each change re-runs the import from the -// original bytes, so options never compound. - -import { h, icon, byteLimit, fitBytes, shake } from '../dom.js'; -import { t, reason } from '../i18n.js'; -import { app, addOwn } from '../state.js'; -import { frame, FRAME_SHADES, BLANK_CARD } from './card.js'; -import { go, toast } from './shell.js'; -import { iconButton } from '../kit/icon-button.js'; -import { issuable, MAX_EDGE } from '../budget.js'; -import { caught } from '../diagnostics.js'; - -const ACCEPT = '.png,.aseprite,.ase,image/png'; - -export function importView() { - /** @type {Uint8Array|null} */ - let bytes = null; - let fileName = ''; - let autoTitle = ''; - /** @type {string|null} */ - let sourceUrl = null; // what the browser can show of the file, for "original" - let showOriginal = false; - const opts = { - keepScale: false, firstFrameOnly: false, - /** @type {number|undefined} */ palette: undefined, - /** @type {number|undefined} */ fit: undefined, - }; - let autoFit = false; // the screen chose "own, fitted" by itself - let shade = 0; - let timer = 0; - /** @type {any} */ - let result = null; - /** @type {{code: string, args?: number[], message?: string}|null} */ - let error = null; - - // One fixed skeleton; only the contents of its slots change, so nothing - // above the buttons ever moves. - const input = /** @type {HTMLInputElement} */ (h('input', { type: 'file', accept: ACCEPT, hidden: true })); - const preview = h('button', { class: 'import-preview', onclick: () => onPreview() }); - const status = h('p', { class: 'import-status', 'aria-live': 'polite' }); - const title = byteLimit(/** @type {HTMLInputElement} */ (h('input', { placeholder: t('import.name.ph'), autocomplete: 'off' })), 32); - // Values: '' exact colours, 'fit:N' own colours fitted to N, else a built-in id. - const palette = /** @type {HTMLSelectElement} */ (h('select', { - onchange: () => { - const v = palette.value; - opts.fit = v.startsWith('fit:') ? Number(v.slice(4)) : undefined; - opts.palette = v === '' || v.startsWith('fit:') ? undefined : Number(v); - autoFit = false; - run(); - }, - }, h('option', { value: '' }, t('import.opt.palette.auto')), - h('option', { value: 'fit:32' }, t('import.opt.palette.fit', 32)), - h('option', { value: 'fit:16' }, t('import.opt.palette.fit', 16)), - ...palettes().map((/** @type {any} */ p) => h('option', { value: String(p.id) }, paletteLabel(p))))); - const paletteField = h('div', { class: 'field' }, h('label', {}, t('import.opt.palette')), palette); - const shades = h('div', { class: 'shades', role: 'radiogroup', 'aria-label': t('import.shade') }); - const save = /** @type {HTMLButtonElement} */ (h('button', { class: 'btn key', onclick: () => sign() }, t('import.save'))); - const pickBtn = h('button', { class: 'btn', onclick: () => input.click() }); - const extras = h('div', { class: 'group import-extras' }); - - const body = h('div', { class: 'import-body' }, - preview, status, - h('div', { class: 'group' }, paletteField, - h('div', { class: 'field' }, h('label', {}, t('import.name')), title), - h('h2', {}, t('import.shade')), shades), - h('div', { class: 'row-btns import-actions' }, save, pickBtn), - extras); - - /** Back to an empty frame: after signing, or when the person asks. */ - const reset = () => { - clearInterval(timer); - if (sourceUrl) URL.revokeObjectURL(sourceUrl); - bytes = null; - fileName = autoTitle = ''; - sourceUrl = null; - showOriginal = autoFit = false; - Object.assign(opts, { keepScale: false, firstFrameOnly: false, palette: undefined, fit: undefined }); - palette.value = ''; - shade = 0; - result = error = null; - title.value = ''; - paint(); - }; - const resetBtn = iconButton({ icon: 'close', label: t('import.reset'), onClick: reset }); - const el = h('section', { class: 'view import-view' }, - h('div', { class: 'bar' }, h('div', { class: 'title' }, h('h1', {}, t('import.title'))), resetBtn.el), - body, input); - - title.addEventListener('input', () => { - const cap = preview.querySelector('.cap b'); - if (cap) cap.textContent = title.value || t('import.name.ph'); - resetBtn.el.hidden = !bytes && !title.value; - }); - - input.addEventListener('change', async () => { - const f = input.files?.[0]; - input.value = ''; - if (!f) return; - if (f.size > 16 * 1024 * 1024) return toast(t('import.err.too_large')); - bytes = new Uint8Array(await f.arrayBuffer()); - fileName = f.name; - if (sourceUrl) URL.revokeObjectURL(sourceUrl); - sourceUrl = /\.(aseprite|ase)$/i.test(f.name) ? null : URL.createObjectURL(f); - showOriginal = false; - // The file name is a starting title, replaced with the file, but never - // over something the person typed. - const fromName = fitBytes(f.name.replace(/\.[^.]+$/, ''), 32); - if (!title.value || title.value === autoTitle) title.value = fromName; - autoTitle = fromName; - Object.assign(opts, { keepScale: false, firstFrameOnly: false, palette: undefined, fit: undefined }); - autoFit = false; - palette.value = ''; - run(); - }); - - function onPreview() { - if (!bytes) return input.click(); - if (result && sourceUrl) { - showOriginal = !showOriginal; - paint(); - } - } - - function run() { - if (bytes) { - try { - result = app.core.importPicture(bytes, { ...opts }); - error = null; - } catch (e) { - caught(e, 'import'); - result = null; - error = /** @type {any} */ (e); - // Too many colours for exact: fit the picture's own palette rather - // than stop. It is said so in the status, and any palette can still - // be chosen; the original is a tap away for comparison. - if (error?.code === 'too_many_colors' && opts.palette == null && opts.fit == null) { - opts.fit = 32; - autoFit = true; - palette.value = 'fit:32'; - return run(); - } - } - } - paint(); - } - - function paint() { - clearInterval(timer); - resetBtn.el.hidden = !bytes && !title.value; - // Preview: always a card frame of the same size. - const card = result - ? { ...result.card, title: title.value, frame: shade, author: app.profile.nick } - : { ...BLANK_CARD, title: title.value, frame: shade, author: app.profile.nick }; - const f = frame({ card, rec: /** @type {any} */ ({ digest: '', received: Date.now() }), author: null }); - // The caption shows the title as typed, or a hint; the hint is not part - // of any card (and may be longer than a title may be). - const cap = f.querySelector('.cap b'); - if (cap && !title.value) cap.textContent = t('import.name.ph'); - const art = /** @type {HTMLElement} */ (f.querySelector('.art')); - if (!bytes) { - f.classList.add('blank'); - art.replaceChildren(h('div', { class: 'import-empty' }, icon('image'), h('b', {}, t('import.pick')), h('small', {}, t('import.pick.short')))); - } else if (!result || showOriginal) { - f.classList.toggle('blank', !result); - art.replaceChildren(sourceUrl - ? h('img', { class: `import-original${result ? '' : ' dim'}`, src: sourceUrl, alt: '' }) - : h('div', { class: 'import-empty' }, icon('file'), h('small', {}, fileName))); - } else { - animate(f, card); - } - const chip = bytes && result && sourceUrl ? h('span', { class: 'import-chip' }, t(showOriginal ? 'import.before' : 'import.after')) : null; - preview.replaceChildren(f, chip ?? ''); - preview.setAttribute('aria-label', !bytes ? t('import.pick') : t('import.toggle')); - - // The same rule as the editor's: what cannot be signed says why, here. - const check = result ? issuable(result.card, app.core.shareSize(result.card)) : null; - const refusal = check?.refused === 'too_big' ? t('import.over-app', result.card.width, result.card.height, MAX_EDGE) - : check?.refused === 'too_slow' ? t('ed.budget.over.why', check.cost.bytes) : ''; - status.classList.toggle('bad', !!error || !!refusal); - status.textContent = error ? explain(error) : result ? (refusal || report(result)) : t('import.pick.hint'); - paletteField.classList.toggle('attention', error?.code === 'too_many_colors' || autoFit); - - shades.replaceChildren(...FRAME_SHADES.map((s, i) => h('button', { - class: 'shade', role: 'radio', 'aria-checked': String(i === shade), style: { background: s.paper }, - onclick: () => { - shade = i; - paint(); - }, - }))); - save.disabled = !result || !!refusal; - pickBtn.textContent = t(bytes ? 'import.other' : 'import.pick'); - extras.replaceChildren(...options()); - } - - /** Plays an animated card inside its frame. @param {HTMLElement} el @param {any} card */ - function animate(el, card) { - if (card.frames < 2 || matchMedia('(prefers-reduced-motion: reduce)').matches) return; - const cv = /** @type {HTMLCanvasElement|null} */ (el.querySelector('.art canvas')); - if (!cv) return; - const g = /** @type {CanvasRenderingContext2D} */ (cv.getContext('2d')); - const images = Array.from({ length: card.frames }, (_, i) => { - const r = app.core.render(card, i); - return new ImageData(new Uint8ClampedArray(r.rgba), r.width, r.height); - }); - el.querySelector('.art')?.classList.add('playing'); // it plays here: no mark - let i = 0; - timer = window.setInterval(() => g.putImageData(images[(i = (i + 1) % images.length)], 0, 0), Math.max(card.delay_ms, 20)); - } - - /** @param {any} r */ - function report(r) { - const c = r.card; - const bits = []; - bits.push(r.scale > 1 - ? t('import.size.scaled', r.sourceWidth, r.sourceHeight, c.width, c.height, r.scale) - : t('import.size', c.width, c.height)); - if (c.frames > 1) bits.push(t('import.frames', c.frames, c.delay_ms)); - if (c.palette.builtin != null) bits.push(t('import.palette.builtin', paletteName(c.palette.builtin))); - else if (opts.fit != null && r.changedPixels) bits.push(t('import.palette.fitted', r.colors, c.palette.inline.length - (c.opaque ? 0 : 1))); - else bits.push(t('import.palette.own', r.colors)); - if (r.changedPixels && opts.fit == null) bits.push(t('import.changed', r.changedPixels)); - const { cost } = issuable(c, app.core.shareSize(c)); - bits.push(cost.level === 'one' ? t('import.code.single', cost.bytes) : t('import.code.carousel', cost.bytes, cost.codes, cost.seconds)); - return (autoFit ? `${t('import.fitted-auto')} ` : '') + bits.join(' · '); - } - - /** The tick boxes some files need; they live below the buttons. */ - function options() { - const rows = []; - if (result?.scale > 1 || opts.keepScale) rows.push(toggle('keepScale', t('import.opt.keep-scale'))); - if ((result?.sourceFrames ?? 0) > 1 || opts.firstFrameOnly || error?.code === 'unequal_delays' || error?.code === 'too_many_frames') { - rows.push(toggle('firstFrameOnly', t('import.opt.first-frame'))); - } - return rows; - } - - /** @param {'keepScale'|'firstFrameOnly'} key @param {string} label */ - function toggle(key, label) { - const box = /** @type {HTMLInputElement} */ (h('input', { type: 'checkbox' })); - box.checked = opts[key]; - box.addEventListener('change', () => { - opts[key] = box.checked; - run(); - }); - return h('label', { class: 'check' }, box, h('span', {}, label)); - } - - async function sign() { - if (!result || save.disabled) return; - if (!title.value.trim()) { - title.focus(); - shake(title.closest('.field') ?? title); - return toast(t('card.need-title')); - } - const p = app.profile; - const card = { ...result.card, title: title.value.trim(), frame: shade, author: p.nick, handle: p.handle, link: p.link }; - try { - const r = await addOwn(app.identity.signCard(card)); - reset(); - go('collection', { open: r.digest }); - toast(t('import.saved')); - } catch (e) { - caught(e, 'import'); - toast(explain(/** @type {any} */ (e))); - } - } - - paint(); - return { - el, - show: paint, // also restarts an animated preview - hide: () => clearInterval(timer), - }; -} - -/** "Sweetie 16" with 16 colours reads "Sweetie · 16": the count once. @param {any} p */ -function paletteLabel(p) { - const n = p.colors.length; - const name = p.name.endsWith(` ${n}`) ? p.name.slice(0, -String(n).length - 1) : p.name; - return `${name} · ${n}`; -} - -/** @type {any[]|null} */ -let paletteCache = null; -const palettes = () => (paletteCache ??= app.core.palettes()); - -/** @param {number} id */ -function paletteName(id) { - const p = palettes().find((/** @type {any} */ x) => x.id === id); - return p ? paletteLabel(p).replace(/ · \d+$/, '') : `#${id}`; -} - -/** A translated reason, with the numbers the core attached. - * @param {{code: string, args?: number[], message?: string}} e */ -function explain(e) { - return reason('import.err', e, ...(e?.args ?? [])); -} diff --git a/web/src/ui/receive.js b/web/src/ui/receive.js index c6957cb..b58c3a6 100644 --- a/web/src/ui/receive.js +++ b/web/src/ui/receive.js @@ -10,15 +10,18 @@ import { toast } from './shell.js'; import { scanner } from './scan.js'; import { caught } from '../diagnostics.js'; import { showRecipientCode } from './edition.js'; -import { FORGED, arrived } from './arrival.js'; +import { FORGED, arrived, arrive } from './arrival.js'; +import { MAGIC } from '../links.js'; +import { isPng, envelopeIn } from '../png-card.js'; -const MAGIC = [0x50, 0x50, 0x1a, 0x0a]; +/** The first byte of an envelope (pips-wasm). */ +const ENVELOPE = 0x45; // 'E' export function receiveView() { - const scan = scanner((text) => handle(text)); + const scan = scanner((text) => handle(text), undefined, { onEnvelope: (env) => { stopCamera(); arrive(env); } }); const bar = h('i'); const progress = h('div', { class: 'progress', hidden: true }, bar); - const fileInput = /** @type {HTMLInputElement} */ (h('input', { type: 'file', accept: '.pips,application/octet-stream', hidden: true, onchange: () => fromFile() })); + const fileInput = /** @type {HTMLInputElement} */ (h('input', { type: 'file', accept: '.pips,application/octet-stream,image/png', hidden: true, onchange: () => fromFile() })); const linkInput = /** @type {HTMLInputElement} */ (h('input', { type: 'url', placeholder: t('receive.paste.placeholder'), autocomplete: 'off' })); const progressText = h('small', {}); const el = h('section', { class: 'view' }, @@ -67,11 +70,28 @@ export function receiveView() { async function fromFile() { const f = fileInput.files?.[0]; fileInput.value = ''; - if (!f || f.size > 70 * 1024) { toast(t('receive.not-pips')); return; } + if (!f || f.size > 16 * 1024 * 1024) { toast(t('receive.not-pips')); return; } const bytes = new Uint8Array(await f.arrayBuffer()); + // A picture of a card from Pips holds the card itself. + if (isPng(bytes)) { + const env = envelopeIn(bytes); + if (!env) { toast(t('receive.no-card-in-picture')); return; } + stopCamera(); + await arrive(env); + return; + } + if (bytes.length > 70 * 1024) { toast(t('receive.not-pips')); return; } if (!MAGIC.every((b, i) => bytes[i] === b)) { toast(t('receive.not-pips')); return; } + const body = bytes.subarray(MAGIC.length); + // A file holds what a code holds — an envelope, every signature in it + // checked — or, from before envelopes, a bare signed card. + if (body[0] === ENVELOPE) { + stopCamera(); + await arrive(body); + return; + } try { - done(await receivePayload(bytes.subarray(MAGIC.length))); + done(await receivePayload(body)); } catch { toast(t('receive.not-pips')); } diff --git a/web/src/ui/scan.js b/web/src/ui/scan.js index 6052038..e16e23e 100644 --- a/web/src/ui/scan.js +++ b/web/src/ui/scan.js @@ -8,19 +8,42 @@ import { h, icon } from '../dom.js'; import { t } from '../i18n.js'; import { qrReader } from '../qrscan.js'; +import { envelopeIn } from '../png-card.js'; -/** @param {(text: string) => void} onText @param {string} [hint] */ -export function scanner(onText, hint = t('receive.point')) { +/** Pictures larger than this are only looked at for a code. */ +const MAX_PICTURE = 16 * 1024 * 1024; + +/** + * @param {(text: string) => void} onText @param {string} [hint] + * @param {{onEnvelope?: (envelope: Uint8Array) => void}} [o] onEnvelope: a picture + * from Pips carries its card inside (png-card.js): taken before any code is read + */ +export function scanner(onText, hint = t('receive.point'), o = {}) { const video = /** @type {HTMLVideoElement} */ (h('video', { playsInline: true, muted: true, hidden: true })); - const message = h('p', {}, hint); - const startBtn = h('button', { type: 'button', class: 'scanner-start', onclick: () => start() }, icon('camera'), h('b', {}, t('receive.start'))); + // Words have two places: inside the frame, under the button, while the + // camera is off; along the bottom, over the picture, while it is on. + const label = h('b', {}, t('receive.start')); + const note = h('small', {}, hint); + const message = h('p', { hidden: true }, hint); + const frame = h('div', { class: 'scanner' }, video, h('div', { class: 'corners' }), + h('button', { type: 'button', class: 'scanner-start', onclick: () => start() }, icon('camera'), label, note), message); const imageInput = /** @type {HTMLInputElement} */ (h('input', { type: 'file', accept: 'image/*', hidden: true, onchange: () => fromImage() })); const el = h('div', {}, - h('div', { class: 'scanner' }, video, h('div', { class: 'corners' }), startBtn, message), + frame, h('div', { class: 'group row-btns' }, h('button', { class: 'btn', onclick: () => imageInput.click() }, icon('image'), t('receive.image'))), imageInput); + /** Tells the person something where the frame has room for it now. @param {string} text */ + const say = (text) => { (stream ? message : note).textContent = text; }; + /** @param {boolean} on */ + const live = (on) => { + frame.classList.toggle('live', on); + video.hidden = !on; + message.hidden = !on; + message.textContent = note.textContent = hint; + }; + /** @type {MediaStream|null} */ let stream = null; let timer = 0; @@ -31,26 +54,28 @@ export function scanner(onText, hint = t('receive.point')) { try { return await qrReader(); } catch { - message.textContent = t('scan.failed'); + say(t('scan.failed')); return null; } } async function start() { if (stream) return; - message.textContent = t('scan.loading'); + say(t('scan.loading')); const r = await reader(); if (!r) return; try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false }); } catch { - message.textContent = t('receive.no-camera'); + // Refused, or none: the frame says so, and a tap asks again (the + // permission may have been given in the browser's settings meanwhile). + label.textContent = t('scan.no-camera'); + say(t('scan.no-camera.how')); return; } + label.textContent = t('receive.start'); video.srcObject = stream; - video.hidden = false; - message.textContent = hint; - startBtn.hidden = true; + live(true); await video.play(); // The browser's detector is fast; ours is given more time per frame. timer = window.setInterval(async () => { @@ -67,15 +92,17 @@ export function scanner(onText, hint = t('receive.point')) { clearInterval(timer); stream?.getTracks().forEach((tr) => tr.stop()); stream = null; - video.hidden = true; - startBtn.hidden = false; - message.textContent = hint; + live(false); } async function fromImage() { const f = imageInput.files?.[0]; imageInput.value = ''; if (!f) return; + if (o.onEnvelope && f.size <= MAX_PICTURE) { + const env = envelopeIn(new Uint8Array(await f.arrayBuffer())); + if (env) return o.onEnvelope(env); + } const r = await reader(); if (!r) return; // A file that is not a picture, or one too big to decode, simply holds no code. @@ -84,8 +111,5 @@ export function scanner(onText, hint = t('receive.point')) { for (const text of texts) onText(text); } - /** Tells the person something, in the scanner's own place (it may be shown before the app's toasts exist). @param {string} text */ - function say(text) { message.textContent = text; } - return { el, stop, say }; } diff --git a/web/src/ui/send.js b/web/src/ui/send.js new file mode 100644 index 0000000..d18e158 --- /dev/null +++ b/web/src/ui/send.js @@ -0,0 +1,72 @@ +// @ts-check +// Sending something out of the app, the device's way: its share sheet (a +// messenger, mail, nearby devices) where there is one, otherwise the +// clipboard for a link and a download for a file. The person changing +// their mind in the share sheet is not a failure. + +import { h } from '../dom.js'; +import { t } from '../i18n.js'; +import { toast } from './shell.js'; +import { caught } from '../diagnostics.js'; + +const aborted = (/** @type {unknown} */ e) => /** @type {any} */ (e)?.name === 'AbortError'; + +/** @param {string} url @param {string} title @param {string} text */ +export async function sendLink(url, title, text) { + if (navigator.share) { + try { + await navigator.share({ title, text, url }); + return; + } catch (e) { + if (aborted(e)) return; + } + } + try { + await navigator.clipboard.writeText(url); + toast(t('share.link.copied')); + } catch (e) { + caught(e, 'share'); + toast(t('share.link.failed')); + } +} + +/** @param {File} file @param {string} title */ +export async function sendFile(file, title) { + if (navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ files: [file], title }); + return; + } catch (e) { + if (aborted(e)) return; + } + } + // A download keeps to Latin letters: some browsers drop any other name + // for a plain «download», with no extension to open it by. + const a = /** @type {HTMLAnchorElement} */ (h('a', { href: URL.createObjectURL(file), download: latin(file.name) })); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(a.href), 10_000); + toast(t('share.file.saved', a.download)); +} + +/** A name a file system takes: the title without what paths forbid. @param {string} title @param {string} ext */ +export function fileName(title, ext) { + const base = title.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60) || 'pips'; + return `${base}.${ext}`; +} + +const CYR = 'а:a б:b в:v г:g д:d е:e ё:yo ж:zh з:z и:i й:y к:k л:l м:m н:n о:o п:p р:r с:s т:t у:u ф:f х:kh ц:ts ч:ch ш:sh щ:shch ъ: ы:y ь: э:e ю:yu я:ya'; +const TO_LATIN = new Map(CYR.split(' ').map((p) => /** @type {[string, string]} */ (p.split(':')))); + +/** A file name in Latin letters: Cyrillic spelled out, anything else left out. @param {string} name */ +export function latin(name) { + const dot = name.lastIndexOf('.'); + const [base, ext] = dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, '']; + const out = [...base].map((c) => { + const l = TO_LATIN.get(c.toLowerCase()); + if (l == null) return /[\x20-\x7e]/.test(c) ? c : ''; + return c === c.toLowerCase() ? l : l.charAt(0).toUpperCase() + l.slice(1); + }).join('').replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + return `${out || 'pips'}${ext}`; +} diff --git a/web/src/ui/shell.js b/web/src/ui/shell.js index 07da8ff..f8cb374 100644 --- a/web/src/ui/shell.js +++ b/web/src/ui/shell.js @@ -18,7 +18,7 @@ const TABS = /** @type {const} */ ([ { name: 'collection', icon: 'collection', label: 'tab.collection' }, { name: 'draw', icon: 'brush', label: 'tab.draw' }, { name: 'receive', icon: 'scan', label: 'tab.scan', main: true }, - { name: 'import', icon: 'receive', label: 'tab.import' }, + { name: 'give', icon: 'give', label: 'tab.give' }, { name: 'me', icon: 'me', label: 'tab.profile' }, ]);