93 lines
3.9 KiB
TypeScript
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;
|
|
}
|