import { newId } from "./id"; /** 編輯器內附圖(本地選檔或 mock 產圖) */ export type AttachedImage = { id: string; /** 預覽用(data URL 或已上傳的 https) */ url: string; name?: string; /** 產圖 prompt;選檔可無 */ prompt?: string; /** 已上傳的公開 https(Meta 可抓);有值即可送出 */ 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 { 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 { 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 { 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 { 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; }