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

155 lines
4.9 KiB
TypeScript
Raw Normal View History

2026-07-10 05:10:31 +00:00
import { newId } from "./id";
/** 編輯器內附圖(本地選檔或 mock 產圖) */
export type AttachedImage = {
id: string;
2026-07-13 01:15:30 +00:00
/** 預覽用data URL 或已上傳的 https */
2026-07-10 05:10:31 +00:00
url: string;
name?: string;
/** 產圖 prompt選檔可無 */
prompt?: string;
2026-07-13 01:15:30 +00:00
/** 已上傳的公開 httpsMeta 可抓);有值即可送出 */
remoteUrl?: string;
/** 背景上傳中 */
uploading?: boolean;
/** 上傳失敗訊息 */
uploadError?: string;
2026-07-10 05:10:31 +00:00
};
export const MAX_ATTACHED_IMAGES = 10;
2026-07-13 01:15:30 +00:00
/** 單檔上限(與後端 media upload 5MB 對齊) */
const MAX_FILE_BYTES = 5 * 1024 * 1024;
2026-07-10 05:10:31 +00:00
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);
});
}
2026-07-13 01:15:30 +00:00
/**
* data URL maxEdgeJPEG 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;
});
}
2026-07-10 05:10:31 +00:00
/**
* data URL
2026-07-13 01:15:30 +00:00
* data URL
2026-07-10 05:10:31 +00:00
*/
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) {
2026-07-13 01:15:30 +00:00
throw new Error(`${file.name} 超過 5MB`);
2026-07-10 05:10:31 +00:00
}
const url = await readFileAsDataUrl(file);
if (!url.startsWith("data:image")) throw new Error(`無法讀取:${file.name}`);
out.push({
id: newId("img"),
url,
name: file.name,
2026-07-13 01:15:30 +00:00
uploading: true,
2026-07-10 05:10:31 +00:00
});
}
return out;
}
2026-07-13 01:15:30 +00:00
/** 送出用:優先 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);
}
2026-07-10 05:10:31 +00:00
/** 大 data URL 不寫進 localStorage改成可顯示的 mock 縮圖 seed */
export function toPersistableImageUrl(img: AttachedImage): string {
2026-07-13 01:15:30 +00:00
if (img.remoteUrl) return img.remoteUrl;
2026-07-10 05:10:31 +00:00
if (img.url.startsWith("data:") && img.url.length > 1800) {
const seed = encodeURIComponent((img.name || img.id || "attach").slice(0, 40));
2026-07-20 06:33:14 +00:00
return `https://api.dicebear.com/9.x/shapes/svg?seed=${seed}&backgroundColor=20b49c,69d4c5,ff7b73,b6f3e4,ffd5dc`;
2026-07-10 05:10:31 +00:00
}
return img.url;
}