diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 7c79683..5671ce7 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -17,6 +17,7 @@ import { loadEngine, MARK_FACES } from './engine/assets'; import { renderPhoto, type RenderAssets } from './engine/exportEngine'; import { download, fileToBytes, readCapturedAt, readGps, readIso, readSpecs, resizedJpeg, scaledJpeg, type ShotSpecs } from './engine/imageOps'; import { developRaw, isRawName } from './engine/rawDevelop'; +import { sharePhoto, type ShareSite } from './engine/share'; import { heicToJpeg, isHeicName } from './engine/heicDevelop'; import { clearRaw, readRaw, storeRaw } from './engine/rawStore'; import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library'; @@ -1748,7 +1749,7 @@ export function Workspace() { // `true` when a file was handed over, so CLEAR knows the export it offered // actually happened before the photo comes off the stage. - const exportPhoto = useCallback(async (target: number | null): Promise => { + const exportPhoto = useCallback(async (target: number | null, share?: ShareSite): Promise => { if (!engine || !source) return false; if (!pro && proLookInUse()) { promptPro(); @@ -1833,7 +1834,11 @@ export function Workspace() { source.bytes ); const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14); - download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`); + const name = `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`; + // SHARE hands the same bytes to the browser's share sheet instead of the + // download folder, so the file never lands on the machine on the way out. + if (share) await sharePhoto(bytes, name, share); + else download(bytes, name); // The moment the app has done the visitor's work is the moment it has earned // a place on the machine: the install offer waits for this instead of // opening on the way in (which nobody reads). @@ -4141,6 +4146,10 @@ export function Workspace() { setChoosingExport(false); void exportPhoto(target); }} + onShare={(site) => { + setChoosingExport(false); + void exportPhoto(null, site); + }} onClose={() => setChoosingExport(false)} /> ) : null} diff --git a/docker/frontend/src/engine/share.ts b/docker/frontend/src/engine/share.ts new file mode 100644 index 0000000..a1f08d2 --- /dev/null +++ b/docker/frontend/src/engine/share.ts @@ -0,0 +1,72 @@ +import { download } from './imageOps'; + +// Posting the export to a social site. A page cannot reach into another +// origin's composer: the cookie is the browser's to send, never ours to read, +// and the window we open has no DOM we may touch — which is the same rule that +// keeps any other site from posting as the visitor. So there is no way to drop +// the picture into the site's own picker from here. Two things do work, and +// this takes the best one the browser offers: +// +// sheet on a phone the OS share sheet opens and the target app — +// Facebook, Instagram, Reddit — comes up with the picture already +// in its composer. Only the caption is left to type. +// clipboard the desktop path: the picture goes on the clipboard and the site +// opens beside it, so one paste (Ctrl+V) puts it in the composer. +// download no share sheet and no clipboard — an http origin is not a secure +// context, so the API is not even there: the file is handed over the +// old way and the site opens beside it. +// +// ponytail: the four sites are listed here rather than in a config file. Add a +// network when someone asks for one; these three paths serve any site alike. +export const SHARE_SITES = { + facebook: 'https://www.facebook.com/', + instagram: 'https://www.instagram.com/', + reddit: 'https://www.reddit.com/submit', + x: 'https://x.com/compose/post', +} as const; + +export type ShareSite = keyof typeof SHARE_SITES; + +export type SharePath = 'sheet' | 'clipboard' | 'download'; + +// The clipboard takes PNG in every engine — Chromium silently refuses a JPEG — +// so the file is drawn to a canvas once. No resample: the bytes handed in are +// already the size the visitor chose. +async function pngOf(file: File): Promise { + const bitmap = await createImageBitmap(file); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('no 2d context'); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + return await new Promise((resolve, reject) => + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png') + ); +} + +export async function sharePhoto(bytes: Uint8Array, filename: string, site: ShareSite): Promise { + const file = new File([bytes as BlobPart], filename, { type: 'image/jpeg' }); + // A phone is the only place the picture reaches the target app on its own: + // the sheet hands the file over and that app owns its own composer. + if (navigator.canShare?.({ files: [file] })) { + try { + await navigator.share({ files: [file] }); + } catch (err) { + // The sheet was closed: a "not now", not a failure, and nothing else opens + // on top of the choice the visitor just made. + if (!(err instanceof DOMException && err.name === 'AbortError')) throw err; + } + return 'sheet'; + } + try { + await navigator.clipboard.write([new ClipboardItem({ 'image/png': await pngOf(file) })]); + window.open(SHARE_SITES[site], '_blank', 'noopener'); + return 'clipboard'; + } catch { + window.open(SHARE_SITES[site], '_blank', 'noopener'); + download(bytes, filename); + return 'download'; + } +} diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index c74429a..6b54233 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -219,6 +219,9 @@ export const en: Dict = { 'export.customLabel': 'Longest edge (px)', 'export.custom': 'EXPORT', 'export.cap': 'This account can export up to {px}px.', + 'export.share': 'Share to', + 'export.shareHint': + "On a phone the network's own app opens with the picture already in its composer. On a desktop the picture goes to the clipboard — paste it (Ctrl+V) into the composer that opened; where pasting is refused, the file downloads for you to drop in.", 'photo.uploading': 'UPLOADING…', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index bafd521..88e67ae 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -231,6 +231,9 @@ export const vi = { 'export.customLabel': 'Cạnh dài nhất (px)', 'export.custom': 'XUẤT', 'export.cap': 'Tài khoản này xuất tối đa {px}px.', + 'export.share': 'Chia sẻ lên', + 'export.shareHint': + 'Điện thoại: app của mạng mở ra với ảnh đã nằm trong ô đăng. Máy tính: ảnh được copy vào clipboard, dán (Ctrl+V) vào ô đăng vừa mở; nơi không cho dán thì ảnh được tải về để bạn kéo vào.', 'photo.uploading': 'ĐANG TẢI…', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 9323ae9..a1fa246 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -1046,6 +1046,28 @@ button.hint:disabled { cursor: default; } } .export-custom .field { width: 96px; } +/* Hand the export to a network. A hairline over it, so the row reads as the + second way out of the dialog and not as a sixth size. */ +.export-share { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); + text-align: left; +} +.export-share-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.export-share-row .btn { + flex: 1 1 auto; + font-size: 12px; + letter-spacing: 0.06em; +} + /* --- strip moderation (/admin) ------------------------------------------ */ .adm { height: 100%; overflow-y: auto; padding: 0 22px 40px; } /* Sticky: the mark and the page name stay in the top-left corner while the diff --git a/docker/frontend/src/ui/ExportModal.tsx b/docker/frontend/src/ui/ExportModal.tsx index 7796e79..543f948 100644 --- a/docker/frontend/src/ui/ExportModal.tsx +++ b/docker/frontend/src/ui/ExportModal.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useI18n } from '../i18n/I18nProvider'; +import { SHARE_SITES, type ShareSite } from '../engine/share'; // Export size. The photo on the stage has a longest edge; the three fixed // answers name the sizes people ask for by name (2048/4096), the fourth takes a @@ -12,6 +13,7 @@ export function ExportModal({ longest, maxDim, onPick, + onShare, onClose, }: { /** Longest edge of the photo as it stands, crop included. */ @@ -19,6 +21,10 @@ export function ExportModal({ /** The tier's own ceiling, or null for none. */ maxDim: number | null; onPick: (target: number | null) => void; + /** Hand the file straight to a network. Always the photo unchanged: the + sites resize to their own feed anyway, so an upscale here is pixels + bought for nothing. */ + onShare: (site: ShareSite) => void; onClose: () => void; }) { const { t } = useI18n(); @@ -85,6 +91,28 @@ export function ExportModal({ {t('export.cap', { px: maxDim })}

)} + {/* Straight to a network, no file on the way. What happens next is the + browser's to decide — see engine/share — so the line under the row + says which of the three it will be before the click. */} +
+

{t('export.share')}

+
+ {(Object.keys(SHARE_SITES) as ShareSite[]).map((site) => ( + + ))} +
+

+ {t('export.shareHint')} +

+