增加复制粘贴交互
This commit is contained in:
1 parent
4195525dc7
commit
deb9cc5bcf
14 files changed
+1144
-31
No files matched your search
@@ -7,11 +7,18 @@ describe("assets HTTP v1 compatibility contract", () => {
|
||||
expect(contract.version).toBe(1);
|
||||
expect(contract.limits).toEqual({ jsonBytes: 1_048_576, uploadBytes: 20_971_520, remoteBytes: 20_971_520 });
|
||||
expect(contract.routes.map((route: { path: string }) => route.path)).toEqual([
|
||||
"/api/assets", "/api/assets/upload", "/api/assets/{id}", "/api/assets/{id}/download",
|
||||
"/api/assets", "/api/assets/upload", "/api/assets/import-image", "/api/assets/{id}", "/api/assets/{id}/download",
|
||||
"/api/v1/assets", "/api/v1/assets/{id}", "/api/v1/assets/{id}/download",
|
||||
"/uploads/{path...}", "/generated-results/{path...}"
|
||||
]);
|
||||
expect(contract.errors.internal.body).toEqual({ error: "Internal server error." });
|
||||
expect(contract.errors.importImageUnsupported).toEqual({ status: 415, body: { error: "不支持的图片格式" } });
|
||||
expect(contract.importImage).toEqual({
|
||||
method: "POST", path: "/api/assets/import-image", auth: "platform-session", contentType: "application/json",
|
||||
request: { url: "string" }, success: { status: 201, body: { asset: "Asset" } }, source: "upload",
|
||||
formats: ["png", "jpeg", "webp", "gif", "bmp"], maxURLBytes: 8192, maxImageBytes: 20_971_520,
|
||||
maxImagePixels: 25_000_000
|
||||
});
|
||||
expect(contract.methods).toEqual({ headExecutesGet: true, optionsStatus: 204, unsupportedStatus: 405, unsupportedBody: "empty" });
|
||||
expect(contract.binary).toEqual({
|
||||
downloadCacheControl: "private, no-store",
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { clipboardImageFiles, clipboardImageURL, insertClipboardImageReferences } from "@/lib/client/clipboard-images";
|
||||
|
||||
function clipboard(items: Array<{ kind: string; type: string; getAsFile: () => File | null }>, files: File[] = []) {
|
||||
return { items, files } as unknown as Pick<DataTransfer, "items" | "files">;
|
||||
}
|
||||
|
||||
function item(file: File | null, type = file?.type || "image/png") {
|
||||
return { kind: "file", type, getAsFile: () => file };
|
||||
}
|
||||
|
||||
describe("clipboardImageFiles", () => {
|
||||
it("accepts image items and avoids their duplicate files representation", () => {
|
||||
const png = new File(["png bytes"], "image", { type: "image/png" });
|
||||
const jpeg = new File(["jpeg bytes"], "photo.jpeg", { type: "image/jpeg" });
|
||||
const result = clipboardImageFiles(clipboard([item(png), item(jpeg)], [png, jpeg]));
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((file) => file.name)).toEqual(["pasted-image.png", "photo.jpeg"]);
|
||||
expect(result[1]).toBe(jpeg);
|
||||
expect(result[0].type).toBe("image/png");
|
||||
});
|
||||
|
||||
it("falls back to files when clipboard items do not expose an image", () => {
|
||||
const webp = new File(["webp bytes"], "screenshot", { type: "image/webp" });
|
||||
const text = new File(["https://example.test/image.png"], "link.txt", { type: "text/plain" });
|
||||
const result = clipboardImageFiles(clipboard([item(null), { kind: "string", type: "text/plain", getAsFile: () => null }], [text, webp]));
|
||||
|
||||
expect(result.map((file) => file.name)).toEqual(["screenshot.webp"]);
|
||||
});
|
||||
|
||||
it("ignores text, HTML, URLs, null files and non-image files", () => {
|
||||
const text = new File(["image URL"], "photo.png", { type: "text/plain" });
|
||||
expect(clipboardImageFiles(clipboard([
|
||||
{ kind: "string", type: "text/html", getAsFile: () => null },
|
||||
{ kind: "string", type: "text/uri-list", getAsFile: () => null },
|
||||
item(null),
|
||||
item(text)
|
||||
], [text]))).toEqual([]);
|
||||
expect(clipboardImageFiles(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("normalizes generic screenshot names but keeps usable names and image bytes", async () => {
|
||||
const original = new File([Uint8Array.from([1, 2, 3, 4])], "capture.image", { type: "image/gif" });
|
||||
const good = new File(["jpeg"], "holiday.jpg", { type: "image/jpeg" });
|
||||
const [renamed, preserved] = clipboardImageFiles(clipboard([item(original), item(good)]));
|
||||
|
||||
expect(renamed.name).toBe("capture.gif");
|
||||
expect(new Uint8Array(await renamed.arrayBuffer())).toEqual(Uint8Array.from([1, 2, 3, 4]));
|
||||
expect(preserved).toBe(good);
|
||||
});
|
||||
|
||||
it("fills a missing File MIME from the image clipboard item even when its filename is usable", async () => {
|
||||
const original = new File([Uint8Array.from([7, 8, 9])], "screenshot.png");
|
||||
const [file] = clipboardImageFiles(clipboard([item(original, "image/png")]));
|
||||
|
||||
expect(file.name).toBe("screenshot.png");
|
||||
expect(file.type).toBe("image/png");
|
||||
expect(new Uint8Array(await file.arrayBuffer())).toEqual(Uint8Array.from([7, 8, 9]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("insertClipboardImageReferences", () => {
|
||||
it("replaces a selected range when the prompt has not changed", () => {
|
||||
expect(insertClipboardImageReferences("请修改这里的衣服", ["@图片1"], {
|
||||
text: "请修改这里的衣服", start: 3, end: 5
|
||||
})).toBe("请修改 @图片1 的衣服");
|
||||
expect(insertClipboardImageReferences("请修改 衣服", ["图片2"], {
|
||||
text: "请修改 衣服", start: 4, end: 5
|
||||
})).toBe("请修改 @图片2 衣服");
|
||||
});
|
||||
|
||||
it("appends to the latest prompt when the user edited it during upload", () => {
|
||||
expect(insertClipboardImageReferences("原提示词又加了内容", ["@图片3"], {
|
||||
text: "原提示词", start: 0, end: 4
|
||||
})).toBe("原提示词又加了内容 @图片3");
|
||||
});
|
||||
|
||||
it("deduplicates exact references without confusing image 1 and image 10", () => {
|
||||
expect(insertClipboardImageReferences("看 @图片10", ["@图片1", "@图片1", "@图片10"]))
|
||||
.toBe("看 @图片10 @图片1");
|
||||
expect(insertClipboardImageReferences("看 @图片1", ["@图片1"]))
|
||||
.toBe("看 @图片1");
|
||||
});
|
||||
|
||||
it("does not erase selection text if all requested references already exist", () => {
|
||||
expect(insertClipboardImageReferences("替换这里 @图片1", ["@图片1"], {
|
||||
text: "替换这里 @图片1", start: 2, end: 4
|
||||
})).toBe("替换这里 @图片1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("clipboardImageURL", () => {
|
||||
const asClipboard = (plain: string, html = "") => ({
|
||||
getData: (type: string) => type === "text/plain" ? plain : html
|
||||
}) as Pick<DataTransfer, "getData">;
|
||||
|
||||
it("preserves a single signed URL exactly after trimming, regardless of suffix", () => {
|
||||
const signed = "https://images.example.test/asset.image?Expires=123&Signature=A%2FB%3D&key=abc";
|
||||
expect(clipboardImageURL(asClipboard(` ${signed} `))).toBe(signed);
|
||||
expect(clipboardImageURL(asClipboard("http://images.example.test/opaque?id=1")))
|
||||
.toBe("http://images.example.test/opaque?id=1");
|
||||
});
|
||||
|
||||
it("does not extract URLs from prose or accept more than one URL", () => {
|
||||
expect(clipboardImageURL(asClipboard("请看 https://images.example.test/a.png"))).toBeNull();
|
||||
expect(clipboardImageURL(asClipboard("https://images.example.test/a.png\nhttps://images.example.test/b.png"))).toBeNull();
|
||||
expect(clipboardImageURL(asClipboard("https://images.example.test/a.png more"))).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores HTML and rejects non-HTTP schemes and userinfo", () => {
|
||||
expect(clipboardImageURL(asClipboard("", '<img src="https://images.example.test/a.png">'))).toBeNull();
|
||||
for (const value of [
|
||||
"data:image/png;base64,AAAA", "file:///tmp/a.png", "javascript:alert(1)",
|
||||
"https://user:pass@images.example.test/a.png", "https://@images.example.test/a.png",
|
||||
"images.example.test/a.png"
|
||||
]) expect(clipboardImageURL(asClipboard(value))).toBeNull();
|
||||
expect(clipboardImageURL(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects oversized URLs and embedded whitespace", () => {
|
||||
expect(clipboardImageURL(asClipboard(`https://images.example.test/${"a".repeat(8192)}`))).toBeNull();
|
||||
expect(clipboardImageURL(asClipboard("https://images.example.test/a\tb"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const createStudioUrl = new URL("../components/create-studio.tsx", import.meta.url);
|
||||
|
||||
async function pasteFlowSource() {
|
||||
const source = await readFile(createStudioUrl, "utf8");
|
||||
const start = source.indexOf("function handleImagePaste(");
|
||||
const end = source.indexOf("async function uploadTemplatePreview(", start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return { source, flow: source.slice(start, end) };
|
||||
}
|
||||
|
||||
describe("create studio image paste wiring", () => {
|
||||
it("handles paste in both the prompt and workspace, leaving text paste to the browser", async () => {
|
||||
const { source, flow } = await pasteFlowSource();
|
||||
expect(source).toContain("onPaste={handleImagePaste}");
|
||||
expect(source).toContain("onPasteImage={handleImagePaste}");
|
||||
expect(flow.indexOf("if (!files.length && !imageURL) return;")).toBeLessThan(flow.indexOf("event.preventDefault()"));
|
||||
expect(flow).toContain("clipboardImageFiles(event.clipboardData)");
|
||||
});
|
||||
|
||||
it("requires confirmation before downloading an entire URL and keeps canceled links as native text paste", async () => {
|
||||
const { flow } = await pasteFlowSource();
|
||||
expect(flow).toContain("files.length ? null : clipboardImageURL(event.clipboardData)");
|
||||
const confirmation = flow.indexOf("if (imageURL && !window.confirm(");
|
||||
expect(confirmation).toBeGreaterThanOrEqual(0);
|
||||
expect(confirmation).toBeLessThan(flow.indexOf("event.preventDefault()"));
|
||||
expect(flow).toContain('fetch("/api/assets/import-image"');
|
||||
expect(flow).toContain('headers: { "Content-Type": "application/json" }');
|
||||
expect(flow).toContain("receiveMaterialAssets(() => importImageURL(imageURL), options, 1)");
|
||||
expect(flow).toContain("receiveMaterialAssets(() => uploadAssetFiles(selectedFiles), options, allFiles.length)");
|
||||
expect(flow).toContain("new URL(imageURL).hostname");
|
||||
expect(flow).toContain('asset.kind !== "image"');
|
||||
expect(flow).toContain('options.importedURL && options.seedreamSourceMode === "single"');
|
||||
});
|
||||
|
||||
it("blocks concurrent uploads and discards a stale result after a mode or target switch", async () => {
|
||||
const { flow } = await pasteFlowSource();
|
||||
expect(flow).toContain("if (uploadingRef.current || busy)");
|
||||
expect(flow).toContain("uploadingRef.current = true");
|
||||
expect(flow).toContain("uploadingRef.current = false");
|
||||
expect(flow).toContain("options.paste.contextKey !== pasteContextRef.current");
|
||||
expect(flow.indexOf("options.paste.contextKey !== pasteContextRef.current"))
|
||||
.toBeLessThan(flow.indexOf("setMaterials(next)"));
|
||||
});
|
||||
|
||||
it("routes special modes through existing source limits and adds references only for basic image or video", async () => {
|
||||
const { flow } = await pasteFlowSource();
|
||||
expect(flow).toContain('imageCreationMode === "interactive" ? "append" : "single"');
|
||||
expect(flow).toContain("isSeedreamLayerInputFile(files[0])");
|
||||
expect(flow).toContain("if (selectedSeedreamSource && !window.confirm(");
|
||||
expect(flow).toContain("options.maxFiles = 1");
|
||||
expect(flow).toContain("options.replaceAll = true");
|
||||
expect(flow).toContain("options.maxFiles = remaining");
|
||||
expect(flow).toContain("insertReferences: !specializedImageMode");
|
||||
expect(flow).toContain('const labels = addedMaterials.map((material) => material.label || "");');
|
||||
expect(flow).toContain("insertClipboardImageReferences(items[pasted.mode], labels, selection)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user