feat: image compress before upload

This commit is contained in:
gosangam
2026-05-31 09:56:53 +05:30
parent 5506afa758
commit 8bd82a0e8a
10 changed files with 673 additions and 60 deletions
+6 -1
View File
@@ -20,6 +20,7 @@ import Image from "next/image";
import { ImagePlus, Save, X } from "lucide-react";
import { useAuth } from "@/components/auth/auth-provider";
import { auth, AuthError, type User } from "@/lib/auth";
import { compressImage } from "@/lib/compress-image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -74,7 +75,11 @@ export function ProfileEditForm({
setError(null);
setUploading(true);
try {
const publicURL = await auth.uploadAvatar(file);
// Avatars display at ≤ 200 px in every surface, so cap the
// longest edge at 800 px — plenty of retina headroom while
// keeping the stored bytes small.
const compressed = await compressImage(file, { maxDimension: 800 });
const publicURL = await auth.uploadAvatar(compressed);
setAvatarUrl(publicURL);
} catch (err) {
setError(err instanceof AuthError ? err.message : "Upload failed.");
@@ -27,6 +27,7 @@ import {
import { posts, SocialError } from "@/lib/social";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference";
import { compressImage } from "@/lib/compress-image";
import { useSharePost } from "@/components/social/share-post-provider";
const MAX_IMAGES = 5;
@@ -75,7 +76,12 @@ export function ComposePost({
await Promise.all(
picked.map(async (file, i) => {
try {
const url = await posts.uploadImage(file);
// Compress in the browser before the presigned PUT — keeps
// R2 storage bounded, makes share pre-fetch fast, strips
// EXIF as a side effect. Falls back to the original file
// on any decode failure.
const compressed = await compressImage(file);
const url = await posts.uploadImage(compressed);
setImages((prev) => {
const next = [...prev];
next[baseIndex + i] = { url, uploading: false };
+3 -1
View File
@@ -47,6 +47,7 @@ import {
type Collection,
type Post,
} from "@/lib/social";
import { compressImage } from "@/lib/compress-image";
type SavedShape = {
collection: Collection;
@@ -142,7 +143,8 @@ export function EditCollectionDrawer({
if (!f) return;
setUploadingCover(true);
try {
const url = await collections.uploadCover(f);
const compressed = await compressImage(f);
const url = await collections.uploadCover(compressed);
setCoverURL(url);
} catch (err) {
toast.error(
+95 -3
View File
@@ -22,6 +22,7 @@
*/
import { useState } from "react";
import { Loader2 } from "lucide-react";
import {
HiOutlineArrowPath,
HiOutlineChatBubbleOvalLeft,
@@ -32,6 +33,8 @@ import {
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { posts as postsApi, SocialError, type Post } from "@/lib/social";
import { createShortLink } from "@/lib/share";
import { structuredReferenceForPost } from "@/lib/source-reference";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
@@ -49,6 +52,9 @@ export function PostActionsFooter({ post }: { post: Post }) {
const isMobile = useMediaQuery("(max-width: 767px)");
const [collectionsOpen, setCollectionsOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
// Tracks the short-URL fetch on the direct-mobile-share path so the
// Send icon can render a spinner while the user waits.
const [sharePending, setSharePending] = useState(false);
// Optimistic local mirror of post.viewer_has_reshared so the swap
// happens the instant the user taps Reshare. Reconciles to the
// server's value via rollback if the API call fails.
@@ -60,6 +66,11 @@ export function PostActionsFooter({ post }: { post: Post }) {
// Owners and resharers both see Send — the post is "on their account"
// either way, so the action they need is external share, not reshare.
const showSend = isOwner || reshared;
// Capability-check the Web Share API once per render — supported on
// mobile Safari/Chrome and some desktop Safari builds. We only want
// the native-share short-circuit on phones.
const nativeShareSupported =
typeof navigator !== "undefined" && typeof navigator.share === "function";
async function withAuth(fn: () => void | Promise<void>) {
if (!(await requireAuth())) return;
@@ -80,6 +91,69 @@ export function PostActionsFooter({ post }: { post: Post }) {
}
}
// Direct-mobile share path:
// 1. User taps Send (consumes iOS Safari's user activation).
// 2. We fetch the short URL — short enough to fit comfortably
// inside the same activation window. Loading spinner replaces
// the icon while we wait.
// 3. navigator.share fires within the same handler. iOS sees the
// activation token, opens the system share sheet.
// 4. If activation has expired by the time we call share (slow
// network) or anything else throws non-AbortError, fall back
// to opening the custom in-app sheet so the user isn't stuck.
//
// Desktop / no native share API → straight to the custom sheet,
// which handles its own short-URL fetch + image pre-fetch.
async function onSendClick() {
if (!isMobile || !nativeShareSupported) {
setShareOpen(true);
return;
}
if (sharePending) return;
const handle = post.author_handle ?? user?.handle ?? "";
if (!handle) {
// Without a handle we can't build the post URL; fall back to
// the custom sheet which has its own resolution path.
setShareOpen(true);
return;
}
setSharePending(true);
try {
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
const code = await createShortLink(longPath);
const origin = window.location.origin;
const url = code
? new URL(`/share/${code}`, origin).toString()
: new URL(longPath, origin).toString();
const ref = structuredReferenceForPost(
post.source_url,
post.source_title,
);
const lines = [`“${post.caption}”`, "", "Source:", ref.title];
if (ref.detail) lines.push(ref.detail);
lines.push(url);
const text = lines.join("\n");
const title = ref.detail ? `${ref.title} — ${ref.detail}` : ref.title;
try {
await navigator.share({ title, text });
} catch (err) {
// User-cancel = AbortError = leave them alone. Any other
// failure (most often iOS NotAllowedError when the awaited
// fetch overran the user-activation window) → fall back to
// the in-app sheet so the user can still complete the share.
if (err instanceof DOMException && err.name === "AbortError") return;
console.warn("[share] navigator.share failed, opening custom sheet", err);
setShareOpen(true);
}
} finally {
setSharePending(false);
}
}
return (
<>
<div className="flex items-center justify-between px-2">
@@ -108,9 +182,19 @@ export function PostActionsFooter({ post }: { post: Post }) {
state. */}
{showSend ? (
<ActionButton
label="Send"
onClick={() => setShareOpen(true)}
icon={<HiOutlinePaperAirplane className={ICON_CLASS} />}
label={sharePending ? "Preparing share link…" : "Send"}
onClick={onSendClick}
disabled={sharePending}
icon={
sharePending ? (
<Loader2
className={cn(ICON_CLASS, "animate-spin")}
aria-hidden
/>
) : (
<HiOutlinePaperAirplane className={ICON_CLASS} />
)
}
/>
) : (
<ActionButton
@@ -150,21 +234,29 @@ function ActionButton({
label,
onClick,
icon,
disabled = false,
}: {
label: string;
onClick: () => void;
icon: React.ReactNode;
/** Greys out the button and blocks clicks. Used by the Send button
* while the short URL is being resolved on the mobile native-share
* path so users can't fire navigator.share before the URL is
* ready. */
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={cn(
"size-10 inline-flex items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors",
"disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:bg-transparent",
)}
>
{icon}
+280
View File
@@ -0,0 +1,280 @@
"use client";
/**
* PostCardMenu — three-dot overflow menu shown in the post header.
*
* Items rendered are decided per viewer:
*
* • Author (you wrote the post) — only `Delete post`.
* • Authenticated non-author — `Block @author` and `Report post`.
* • Anonymous non-author — `Report post` only.
*
* The author never sees Block / Report on their own post (you can't
* block yourself, and self-report doesn't make sense). Block + Report
* are placeholder coming-soon toasts today; the slot is wired up so
* shipping the actual flows is a one-line swap each.
*
* Trigger renders a dropdown on desktop and a bottom drawer on mobile,
* matching the SharePostSheet / CollectionsDrawer convention used
* elsewhere in the post action UI.
*/
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Ban, Flag, MoreVertical, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import type { Post } from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
type MenuItem = {
key: string;
label: string;
icon: React.ReactNode;
onClick: () => void;
variant?: "default" | "destructive";
};
export function PostCardMenu({
post,
onDelete,
}: {
post: Post;
/** Owner-only: when provided AND the viewer is the author, the menu
* exposes a Delete option. Parent receives the post id and is
* expected to confirm + invoke the delete itself (matches the
* existing /u/me Delete-with-modal pattern). */
onDelete?: (id: string) => void;
}) {
const { user, loading } = useAuth();
const isMobile = useMediaQuery("(max-width: 767px)");
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const [popPos, setPopPos] = useState<{ top: number; left: number } | null>(
null,
);
const isOwner = !loading && !!user && user.id === post.user_id;
const isAuthed = !loading && !!user;
const authorHandle = post.author_handle ?? null;
const authorDisplay = authorHandle
? `@${authorHandle}`
: post.author_name || "this user";
// Desktop popover positioning: anchor below the trigger, right-aligned.
// Recompute every time we open + on resize so a viewport change between
// renders doesn't strand the popover at a stale coordinate.
useEffect(() => {
if (!open || isMobile) {
setPopPos(null);
return;
}
function recompute() {
const btn = triggerRef.current;
if (!btn) return;
const rect = btn.getBoundingClientRect();
const popoverW = 240;
const pad = 8;
// Right-align the popover to the trigger's right edge — feels
// natural for an overflow menu sitting in the top-right corner.
let left = rect.right - popoverW;
if (left < pad) left = pad;
if (left + popoverW > window.innerWidth - pad) {
left = window.innerWidth - popoverW - pad;
}
const top = rect.bottom + 4;
setPopPos({ top, left });
}
recompute();
window.addEventListener("resize", recompute);
window.addEventListener("scroll", recompute, true);
return () => {
window.removeEventListener("resize", recompute);
window.removeEventListener("scroll", recompute, true);
};
}, [open, isMobile]);
// Click-outside + Escape to close (desktop only; the drawer's own
// overlay handles dismissal on mobile).
useEffect(() => {
if (!open || isMobile) return;
function onPointer(e: PointerEvent) {
const popover = popoverRef.current;
const trigger = triggerRef.current;
if (popover && popover.contains(e.target as Node)) return;
if (trigger && trigger.contains(e.target as Node)) return;
setOpen(false);
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
// Delay the listener install one tick so the click that opened us
// doesn't immediately close us in the same task.
const t = setTimeout(() => {
document.addEventListener("pointerdown", onPointer);
document.addEventListener("keydown", onKey);
}, 0);
return () => {
clearTimeout(t);
document.removeEventListener("pointerdown", onPointer);
document.removeEventListener("keydown", onKey);
};
}, [open, isMobile]);
// Build the items list AFTER all hooks have run so a conditional
// early-return doesn't break the rules-of-hooks contract on
// re-renders where viewer state changes.
const items: MenuItem[] = [];
if (isOwner) {
if (onDelete) {
items.push({
key: "delete",
label: "Delete post",
icon: <Trash2 className="size-4" />,
onClick: () => {
setOpen(false);
onDelete(post.id);
},
variant: "destructive",
});
}
} else {
if (isAuthed) {
items.push({
key: "block",
label: `Block ${authorDisplay}`,
icon: <Ban className="size-4" />,
onClick: () => {
setOpen(false);
toast(`Blocking ${authorDisplay} is coming soon`, {
duration: 2500,
});
},
});
}
items.push({
key: "report",
label: "Report post",
icon: <Flag className="size-4" />,
onClick: () => {
setOpen(false);
toast("Post reporting is coming soon", { duration: 2500 });
},
});
}
// Don't render the trigger when there's literally nothing to click —
// e.g. an owner-viewer on a surface that doesn't grant delete.
if (items.length === 0) return null;
const trigger = (
<button
ref={triggerRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-label="Post options"
aria-haspopup="menu"
aria-expanded={open}
className={cn(
"size-8 inline-flex items-center justify-center rounded-full shrink-0",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<MoreVertical className="size-4" />
</button>
);
// Mobile: bottom drawer
if (isMobile) {
return (
<>
{trigger}
<Drawer open={open} onOpenChange={(o) => setOpen(o)}>
<DrawerContent className="bg-card">
<DrawerHeader className="text-left border-b border-border pb-3">
<DrawerTitle>Post options</DrawerTitle>
</DrawerHeader>
<div className="p-3 space-y-1">
{items.map((item) => (
<MenuItemButton key={item.key} item={item} mobile />
))}
</div>
</DrawerContent>
</Drawer>
</>
);
}
// Desktop: floating popover anchored to the trigger.
return (
<>
{trigger}
{open && popPos
? createPortal(
<div
ref={popoverRef}
role="menu"
style={{
position: "fixed",
top: popPos.top,
left: popPos.left,
zIndex: 60,
}}
className={cn(
"w-60 rounded-lg border border-border bg-card shadow-lg py-1",
"animate-in fade-in-0 zoom-in-95",
)}
>
{items.map((item) => (
<MenuItemButton key={item.key} item={item} />
))}
</div>,
document.body,
)
: null}
</>
);
}
function MenuItemButton({
item,
mobile = false,
}: {
item: MenuItem;
mobile?: boolean;
}) {
const destructive = item.variant === "destructive";
return (
<button
type="button"
role="menuitem"
onClick={item.onClick}
className={cn(
"w-full flex items-center gap-3 text-left transition-colors rounded-md",
mobile ? "px-3 py-3" : "px-3 py-2",
destructive
? "text-destructive hover:bg-destructive/10"
: "text-foreground hover:bg-muted",
)}
>
<span
className={cn(
"shrink-0",
destructive ? "text-destructive" : "text-muted-foreground",
)}
>
{item.icon}
</span>
<span className="text-sm font-medium truncate">{item.label}</span>
</button>
);
}
+68 -15
View File
@@ -3,10 +3,16 @@
/**
* PostCard — renders one post in the public feed and the /u/me feed.
*
* • Author header — avatar + display name + @handle on the left,
* three-dot overflow menu on the right. Menu contents vary by
* viewer (see PostCardMenu): author sees Delete; signed-in
* non-author sees Block + Report; anonymous sees Report.
* • Caption in serif (locked, can't be edited)
* • Image grid (max 5; collapses to one column on the cheap)
* • "Source" link back to the page the quote came from
* • Optional onDelete callback shown only when the owner is viewing
* • Optional onDelete callback — passed through to PostCardMenu so
* Delete actually does something on owner views (parent confirms +
* calls the API; same pattern as before the menu refactor).
*
* The source link always lands the user on the original passage with `?hl=`
* highlighting. If the stored source_url already carries `?hl=` we use it
@@ -16,11 +22,12 @@
import Image from "next/image";
import Link from "next/link";
import { Trash2, ExternalLink } from "lucide-react";
import { ExternalLink } from "lucide-react";
import type { Post } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { appendHighlight, hasHighlight } from "@/lib/text-highlight";
import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu";
export function PostCard({
post,
@@ -34,8 +41,39 @@ export function PostCard({
? post.source_url
: appendHighlight(post.source_url, post.caption);
const authorName = post.author_name || "Unknown";
const authorHandle = post.author_handle ?? null;
return (
<article className="rounded-xl bg-card ring-1 ring-foreground/10 p-5">
{/* Author header — identity on the left, overflow menu on the
right. The menu trigger only renders when there are items to
show (delete for owners, block/report for everyone else), so
a view with nothing to expose simply hides the dot. */}
<header className="flex items-center gap-3 mb-4">
<AuthorAvatar src={post.author_avatar_url ?? null} name={authorName} />
<div className="min-w-0 flex-1">
{authorHandle ? (
<Link
href={`/u/${authorHandle}`}
className="block text-sm font-semibold text-foreground truncate hover:underline"
>
{authorName}
</Link>
) : (
<p className="text-sm font-semibold text-foreground truncate">
{authorName}
</p>
)}
{authorHandle ? (
<p className="text-xs text-muted-foreground truncate">
@{authorHandle}
</p>
) : null}
</div>
<PostCardMenu post={post} onDelete={onDelete} />
</header>
<p className="font-[family-name:var(--font-title)] text-lg text-foreground leading-relaxed">
“{post.caption}”
</p>
@@ -76,19 +114,7 @@ export function PostCard({
<ExternalLink className="size-3" />
{displaySourceLabel(post.source_url, post.source_title)}
</Link>
<div className="flex items-center gap-3">
<span>{new Date(post.created_at).toLocaleDateString()}</span>
{onDelete ? (
<button
type="button"
onClick={() => onDelete(post.id)}
className="inline-flex items-center gap-1 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Delete post"
>
<Trash2 className="size-3.5" />
</button>
) : null}
</div>
<span>{new Date(post.created_at).toLocaleDateString()}</span>
</div>
{/* Action footer — like / comment / reshare on the left, add to
@@ -99,3 +125,30 @@ export function PostCard({
</article>
);
}
function AuthorAvatar({
src,
name,
}: {
src: string | null;
name: string;
}) {
if (src) {
return (
<Image
src={src}
alt={name}
width={36}
height={36}
className="size-9 rounded-full object-cover ring-1 ring-foreground/10 shrink-0"
unoptimized
/>
);
}
const initial = name.charAt(0).toUpperCase() || "?";
return (
<div className="size-9 rounded-full bg-primary/15 text-primary flex items-center justify-center text-sm font-semibold ring-1 ring-foreground/10 shrink-0">
{initial}
</div>
);
}
+7 -38
View File
@@ -67,7 +67,7 @@ import type { Post } from "@/lib/social";
import { createShortLink } from "@/lib/share";
import {
displaySourceLabel,
referenceFromUrl,
structuredReferenceForPost,
} from "@/lib/source-reference";
import { useAuth } from "@/components/auth/auth-provider";
@@ -85,43 +85,12 @@ export function SharePostSheet({
// `reference` is the single-line citation used for UI chrome — the
// drawer header, email subject, etc. where multi-line doesn't render.
const reference = displaySourceLabel(post.source_url, post.source_title);
// `structuredRef` is the same citation split into its `{ title, detail }`
// halves so the share text can lay them on separate lines — matches
// the formatCopyText pattern from selection-toolbar/shared.tsx:
//
// "caption text"
//
// Source:
// Letter to Mukunda
// 15 January, 1968 · Calcutta
// short URL
//
// Two routes to the structured shape:
//
// 1. Book URLs → `referenceFromUrl` parses the path and returns
// a clean { title, detail } (e.g. Bhagavad-gītā /
// Chapter 2, Verse 13).
// 2. Everything else (letters, essays, transcriptions, magazines)
// → `source_title` was composed by compose-post.tsx
// as "Title — Detail" using ` — ` as the join.
// We split on the first ` — ` to recover the
// structured fields. Falls back to title-only if
// no separator is present (book-index pages,
// legacy posts, etc.).
const structuredRef = useMemo<{ title: string; detail: string | null }>(
() => {
const r = referenceFromUrl(post.source_url);
if (r) return r;
const stored = post.source_title?.trim() || "Source";
const idx = stored.indexOf(" — ");
if (idx === -1) {
return { title: stored, detail: null };
}
return {
title: stored.slice(0, idx).trim(),
detail: stored.slice(idx + 3).trim() || null,
};
},
// Structured `{ title, detail }` for the multi-line "Source:" block
// in the share payload. Same helper that the direct-mobile native
// share path in post-actions-footer.tsx uses, so both surfaces
// produce byte-identical citation lines.
const structuredRef = useMemo(
() => structuredReferenceForPost(post.source_url, post.source_title),
[post.source_url, post.source_title],
);
const firstImage = post.images?.[0]?.url ?? null;
@@ -26,6 +26,7 @@ import {
DrawerTitle,
} from "@/components/ui/drawer";
import { posts, SocialError, type Post } from "@/lib/social";
import { compressImage } from "@/lib/compress-image";
type Busy = "upload" | "remove" | null;
@@ -69,7 +70,8 @@ export function UpdateBackgroundModal({
if (!file) return;
setBusy("upload");
try {
const url = await posts.uploadImage(file);
const compressed = await compressImage(file);
const url = await posts.uploadImage(compressed);
const updated = await posts.replaceImages(post.id, [url]);
toast.success("Background updated");
onUpdated?.(updated);
+173
View File
@@ -0,0 +1,173 @@
/**
* Browser-side image compression.
*
* Resizes the longest dimension to `maxDimension` (preserving aspect
* ratio) and re-encodes as JPEG at `quality`. EXIF is stripped as a
* side effect of the canvas round-trip — smaller files plus a tiny
* privacy win since location data doesn't ride along to R2.
*
* Why client-side: every upload path (post images, collection covers,
* avatars, post background swaps) presigns directly to R2. Putting
* the size cut in the browser keeps that flow intact — no proxy hop,
* no queue, no temp storage. The trade-off is that the source bytes
* are lossy from the moment they leave the device; for a photo-share
* product this is the right call.
*
* Safe-by-default failure modes — any of these return the ORIGINAL
* file unchanged rather than blocking the upload:
*
* - The file is not an image (defensive — input is gated by accept= too).
* - The file is a GIF (canvas re-encode would flatten the animation).
* - The decoder fails (e.g. an old browser meeting a HEIC).
* - Compression actually grew the file (rare; tiny already-compressed inputs).
*
* Callers can therefore wrap their existing upload calls without
* adding new error paths.
*/
export type CompressOptions = {
/** Longest edge in px after resize. Default 1920. */
maxDimension?: number;
/** JPEG quality 0–1. Default 0.85 (visually lossless for photos). */
quality?: number;
/** Output MIME type. Default "image/jpeg". */
mimeType?: string;
};
const DEFAULT_MAX = 1920;
const DEFAULT_QUALITY = 0.85;
const DEFAULT_MIME = "image/jpeg";
export async function compressImage(
file: File,
opts: CompressOptions = {},
): Promise<File> {
const maxDimension = opts.maxDimension ?? DEFAULT_MAX;
const quality = opts.quality ?? DEFAULT_QUALITY;
const mimeType = opts.mimeType ?? DEFAULT_MIME;
// Bail-outs that should return the original untouched.
if (typeof window === "undefined") return file;
if (!file.type.startsWith("image/")) return file;
if (file.type === "image/gif") return file;
let drawable: ImageBitmap | HTMLImageElement;
try {
drawable = await loadDrawable(file);
} catch {
// Decoder couldn't read this image (e.g. corrupt, or HEIC on a
// browser that doesn't decode HEIC). Send the original up — R2
// will accept it; the share-image proxy + Cloudflare will sort
// out display sizing downstream.
return file;
}
const { width: srcW, height: srcH } = drawableDimensions(drawable);
const { width, height } = scaleToFit(srcW, srcH, maxDimension);
// No-op fast path: already within the cap AND a reasonable size.
// Skip the canvas dance because re-encoding a 200 KB image as JPEG
// q=85 might actually grow it.
if (width === srcW && height === srcH && file.size < 500_000) {
closeBitmap(drawable);
return file;
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) {
closeBitmap(drawable);
return file;
}
// White backdrop so PNG/HEIC transparency doesn't become black
// pixels in the JPEG output.
if (mimeType === "image/jpeg") {
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, width, height);
}
ctx.drawImage(drawable, 0, 0, width, height);
closeBitmap(drawable);
const blob = await canvasToBlob(canvas, mimeType, quality);
if (!blob) return file;
// If the "compressed" version ended up larger (small already, or a
// weird input), keep the original.
if (blob.size >= file.size) return file;
const ext =
mimeType === "image/jpeg"
? "jpg"
: mimeType.split("/")[1]?.toLowerCase() || "bin";
const newName = file.name.replace(/\.[^.]+$/, "") + `.${ext}`;
return new File([blob], newName, {
type: mimeType,
lastModified: Date.now(),
});
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
async function loadDrawable(
file: File,
): Promise<ImageBitmap | HTMLImageElement> {
// createImageBitmap is the fast path on modern browsers — it decodes
// off the main thread when supported.
if (typeof createImageBitmap === "function") {
try {
return await createImageBitmap(file);
} catch {
// Fall through to the HTMLImageElement path.
}
}
const url = URL.createObjectURL(file);
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("image decode failed"));
img.src = url;
});
// Defer revoke so the image stays valid through the next canvas
// draw — we revoke a tick later when it's safe.
setTimeout(() => URL.revokeObjectURL(url), 0);
return img;
}
function drawableDimensions(
d: ImageBitmap | HTMLImageElement,
): { width: number; height: number } {
if ("naturalWidth" in d) {
return { width: d.naturalWidth, height: d.naturalHeight };
}
return { width: d.width, height: d.height };
}
function scaleToFit(
width: number,
height: number,
max: number,
): { width: number; height: number } {
if (width <= max && height <= max) return { width, height };
const ratio = width > height ? max / width : max / height;
return {
width: Math.round(width * ratio),
height: Math.round(height * ratio),
};
}
function canvasToBlob(
canvas: HTMLCanvasElement,
type: string,
quality: number,
): Promise<Blob | null> {
return new Promise((resolve) => {
canvas.toBlob((b) => resolve(b), type, quality);
});
}
function closeBitmap(d: ImageBitmap | HTMLImageElement) {
if ("close" in d && typeof d.close === "function") d.close();
}
+31
View File
@@ -351,6 +351,37 @@ export function referenceFromUrl(
return { title: bookName, detail: null };
}
/**
* Resolve a post's source into structured `{ title, detail }` for
* multi-line share/copy payloads. Mirrors the formatCopyText pattern
* from selection-toolbar/shared.tsx so Send + Copy produce the same
* citation block.
*
* Two routes to the structured shape:
*
* 1. Book URLs → `referenceFromUrl` parses the path.
* 2. Anything else (letters, essays, transcriptions, magazines):
* `source_title` was composed by compose-post.tsx as
* "Title — Detail" using ` — ` as the join. Split on the first
* occurrence to recover the structured fields.
*
* Falls back to title-only with no detail when no separator is present.
*/
export function structuredReferenceForPost(
sourceUrl: string,
sourceTitle: string | null | undefined,
): { title: string; detail: string | null } {
const r = referenceFromUrl(sourceUrl);
if (r) return r;
const stored = sourceTitle?.trim() || "Source";
const idx = stored.indexOf(" — ");
if (idx === -1) return { title: stored, detail: null };
return {
title: stored.slice(0, idx).trim(),
detail: stored.slice(idx + 3).trim() || null,
};
}
/**
* Render a one-line citation string for a post/bookmark card.
*