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
|
|
|
|
/** 已上傳的公開 https(Meta 可抓);有值即可送出 */
|
|
|
|
|
|
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:長邊 ≤ 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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|