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.
This commit is contained in:
2026-10-08 14:38:22 +07:00
parent f1d7eeff99
commit 8f286506b2
6 changed files with 139 additions and 2 deletions
+72
View File
@@ -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<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';
}
}