Files
NianAIGC/lib/client/clipboard-images.ts
2026-09-30 13:22:53 +08:00

93 lines
3.9 KiB
TypeScript

type ClipboardImageSource = Pick<DataTransfer, "items" | "files">;
const imageExtensions: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
"image/bmp": "bmp",
"image/avif": "avif",
"image/tiff": "tiff",
"image/svg+xml": "svg"
};
/** Extract actual clipboard image files without interpreting text, HTML, or URLs. */
export function clipboardImageFiles(clipboardData: ClipboardImageSource | null | undefined): File[] {
if (!clipboardData) return [];
const fromItems: File[] = [];
for (const item of Array.from(clipboardData.items || [])) {
if (item.kind !== "file") continue;
const file = item.getAsFile();
if (!file) continue;
const mime = file.type || item.type;
if (mime.startsWith("image/")) fromItems.push(withUsableImageName(file, mime));
}
if (fromItems.length > 0) return fromItems;
return Array.from(clipboardData.files || [])
.filter((file) => file.type.startsWith("image/"))
.map((file) => withUsableImageName(file, file.type));
}
/** Recognize a single pasted HTTP(S) URL; the server must verify it is a safe image. */
export function clipboardImageURL(clipboardData: Pick<DataTransfer, "getData"> | null | undefined): string | null {
const value = clipboardData?.getData("text/plain").trim() || "";
if (!value || value.length > 8192 || /\s|[\u0000-\u001f\u007f]/.test(value)) return null;
if (!/^https?:\/\//i.test(value)) return null;
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
const authority = value.match(/^https?:\/\/([^/?#]*)/i)?.[1] || "";
if (!parsed.hostname || authority.includes("@") || parsed.username || parsed.password) return null;
return value;
} catch {
return null;
}
}
function withUsableImageName(file: File, mime: string): File {
const extension = imageExtensions[mime.toLowerCase()];
const safeName = file.name.replace(/[\\/\u0000-\u001f\u007f]/g, "_").trim();
const genericName = !safeName || /^(?:image|blob|unknown)(?:\.image)?$/i.test(safeName);
const hasExtension = /\.[a-z\d]{1,10}$/i.test(safeName) && !/\.image$/i.test(safeName);
if (safeName === file.name && hasExtension && file.type) return file;
const stem = genericName ? "pasted-image" : safeName.replace(/\.image$/i, "");
const name = extension && !hasExtension ? `${stem}.${extension}` : stem || "pasted-image";
if (name === file.name && file.type) return file;
return new File([file], name, { type: file.type || mime, lastModified: file.lastModified });
}
/** Insert newly uploaded image references without replacing edits made during upload. */
export function insertClipboardImageReferences(
currentPrompt: string,
labels: string[],
selection?: { text: string; start: number; end: number }
): string {
const replaceSelection = selection?.text === currentPrompt
&& Number.isInteger(selection.start)
&& Number.isInteger(selection.end)
&& selection.start >= 0
&& selection.end >= selection.start
&& selection.end <= currentPrompt.length;
const before = replaceSelection ? currentPrompt.slice(0, selection.start) : currentPrompt;
const after = replaceSelection ? currentPrompt.slice(selection.end) : "";
const retainedPrompt = before + after;
const references: string[] = [];
for (const label of labels) {
const normalized = label.startsWith("@") ? label : `@${label}`;
if (!/^@图片\d+$/.test(normalized) || references.includes(normalized)) continue;
const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (!new RegExp(`${escaped}(?!\\d)`).test(retainedPrompt)) references.push(normalized);
}
if (references.length === 0) return currentPrompt;
const inserted = references.join(" ");
const leadingSpace = before && !/\s$/.test(before) ? " " : "";
const trailingSpace = after && !/^\s/.test(after) ? " " : "";
return before + leadingSpace + inserted + trailingSpace + after;
}