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:
@@ -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<boolean> => {
|
||||
const exportPhoto = useCallback(async (target: number | null, share?: ShareSite): Promise<boolean> => {
|
||||
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}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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…',
|
||||
|
||||
|
||||
@@ -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…',
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })}
|
||||
</p>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<div className="export-share" data-key="export-share">
|
||||
<p className="hint">{t('export.share')}</p>
|
||||
<div className="export-share-row">
|
||||
{(Object.keys(SHARE_SITES) as ShareSite[]).map((site) => (
|
||||
<button
|
||||
key={site}
|
||||
type="button"
|
||||
className="btn ghost"
|
||||
data-key={`export-share-${site}`}
|
||||
onClick={() => onShare(site)}
|
||||
>
|
||||
{site === 'x' ? 'X' : site.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="hint" data-key="export-share-hint">
|
||||
{t('export.shareHint')}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn ghost" data-key="export-cancel" onClick={onClose}>
|
||||
{t('clear.cancel')}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user