Files
RecipesCam/docker/frontend/src/engine/share.ts
T
3dtours bb4e574486 feat(studio): hand an export straight to a social network
EXPORT only ever wrote a file, and posting it meant leaving the studio, finding
the file and uploading it again. The dialog now offers FACEBOOK, INSTAGRAM,
REDDIT and X beside the sizes, and the render goes to the network instead of the
download folder.

What the browser can do decides how, since a page may not touch another origin's
composer: a phone's share sheet opens the target app with the picture already
attached, a desktop gets the picture on the clipboard and the site in a new tab
for one paste, and an origin with no clipboard API downloads the file beside the
open site. The line under the row says so before the click.
2026-10-08 14:38:22 +07:00

73 lines
3.2 KiB
TypeScript

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<Blob> {
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<Blob>((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<SharePath> {
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';
}
}