155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
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<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;
|
||
}
|