thread-master/apps/web/src/lib/attachImage.ts

155 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { newId } from "./id";
/** 編輯器內附圖(本地選檔或 mock 產圖) */
export type AttachedImage = {
id: string;
/** 預覽用data URL 或已上傳的 https */
url: string;
name?: string;
/** 產圖 prompt選檔可無 */
prompt?: string;
/** 已上傳的公開 httpsMeta 可抓);有值即可送出 */
remoteUrl?: string;
/** 背景上傳中 */
uploading?: boolean;
/** 上傳失敗訊息 */
uploadError?: string;
};
export const MAX_ATTACHED_IMAGES = 10;
/** 單檔上限(與後端 media upload 5MB 對齊) */
const MAX_FILE_BYTES = 5 * 1024 * 1024;
export function withAttachedImageNote(text: string, count: number): string {
const body = text.trim();
if (!count || count <= 0) return body;
if (/(附圖\s*\d+\s*張)/.test(body)) return body;
return `${body}\n\n附圖 ${count} 張)`;
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ""));
reader.onerror = () => reject(new Error(`讀取失敗:${file.name}`));
reader.readAsDataURL(file);
});
}
/**
* 壓縮 data URL長邊 ≤ maxEdge、JPEG quality大幅縮短上傳 JSON。
* 失敗則回傳原圖。
*/
export async function compressDataUrlForUpload(
dataUrl: string,
opts?: { maxEdge?: number; quality?: number },
): Promise<string> {
const maxEdge = opts?.maxEdge ?? 1600;
const quality = opts?.quality ?? 0.82;
if (!dataUrl.startsWith("data:image")) return dataUrl;
try {
const img = await loadImage(dataUrl);
let { width, height } = img;
if (!width || !height) return dataUrl;
const scale = Math.min(1, maxEdge / Math.max(width, height));
width = Math.max(1, Math.round(width * scale));
height = Math.max(1, Math.round(height * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return dataUrl;
ctx.drawImage(img, 0, 0, width, height);
// JPEG 體積小;有透明感的 PNG 也轉 JPEG白底以加速上傳
const out = canvas.toDataURL("image/jpeg", quality);
// 若壓縮後反而更大(極小圖),用原檔
if (out.length >= dataUrl.length * 0.95 && dataUrl.length < 200_000) {
return dataUrl;
}
return out;
} catch {
return dataUrl;
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("image decode failed"));
img.src = src;
});
}
/**
* 把本機圖檔讀成可預覽的 data URL。
* 選完後應背景上傳;不要在「送出」時才塞巨大 data URL。
*/
export async function filesToAttachedImages(
files: FileList | File[],
opts?: { max?: number; existingCount?: number },
): Promise<AttachedImage[]> {
const max = opts?.max ?? MAX_ATTACHED_IMAGES;
const existing = opts?.existingCount ?? 0;
const room = Math.max(0, max - existing);
const list = Array.from(files).filter((f) => f.type.startsWith("image/"));
if (list.length === 0) throw new Error("請選擇圖片檔");
const take = list.slice(0, room);
if (take.length === 0) throw new Error(`最多附 ${max} 張圖`);
const out: AttachedImage[] = [];
for (const file of take) {
if (file.size > MAX_FILE_BYTES) {
throw new Error(`${file.name} 超過 5MB`);
}
const url = await readFileAsDataUrl(file);
if (!url.startsWith("data:image")) throw new Error(`無法讀取:${file.name}`);
out.push({
id: newId("img"),
url,
name: file.name,
uploading: true,
});
}
return out;
}
/** 送出用:優先 remoteUrl其次已是 http(s) 的 url */
export function resolvePublishImageUrls(images: AttachedImage[]): string[] {
const out: string[] = [];
for (const img of images) {
const remote = (img.remoteUrl || "").trim();
if (remote.startsWith("https://") || remote.startsWith("http://")) {
out.push(remote);
continue;
}
const u = (img.url || "").trim();
if (u.startsWith("https://") || u.startsWith("http://")) {
out.push(u);
}
}
return out;
}
export function anyImageUploading(images: AttachedImage[]): boolean {
return images.some((i) => i.uploading);
}
export function anyImageUploadFailed(images: AttachedImage[]): boolean {
return images.some((i) => !!i.uploadError);
}
/** 大 data URL 不寫進 localStorage改成可顯示的 mock 縮圖 seed */
export function toPersistableImageUrl(img: AttachedImage): string {
if (img.remoteUrl) return img.remoteUrl;
if (img.url.startsWith("data:") && img.url.length > 1800) {
const seed = encodeURIComponent((img.name || img.id || "attach").slice(0, 40));
return `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,ff7b73,b6f3e4,ffd5dc`;
}
return img.url;
}