Compare commits
3 Commits
bd327e8564
...
f23c847e13
| Author | SHA1 | Date | |
|---|---|---|---|
| f23c847e13 | |||
| 2ede253b39 | |||
| 07aa33d30e |
@@ -3,6 +3,8 @@ export async function encodeUploadImage(file: File) {
|
|||||||
|
|
||||||
const bitmap = await createBitmap(file);
|
const bitmap = await createBitmap(file);
|
||||||
try {
|
try {
|
||||||
|
if (imageMayContainAlpha(file) && bitmapHasAlpha(bitmap)) return file;
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = bitmap.width;
|
canvas.width = bitmap.width;
|
||||||
canvas.height = bitmap.height;
|
canvas.height = bitmap.height;
|
||||||
@@ -29,9 +31,39 @@ function shouldEncodeAsWebp(file: File) {
|
|||||||
if (type === "image/webp" || name.endsWith(".webp")) return false;
|
if (type === "image/webp" || name.endsWith(".webp")) return false;
|
||||||
if (type === "image/gif" || name.endsWith(".gif")) return false;
|
if (type === "image/gif" || name.endsWith(".gif")) return false;
|
||||||
if (type === "image/svg+xml" || name.endsWith(".svg")) return false;
|
if (type === "image/svg+xml" || name.endsWith(".svg")) return false;
|
||||||
|
if (type === "image/png" || name.endsWith(".png")) return false;
|
||||||
|
if (type === "image/avif" || name.endsWith(".avif")) return false;
|
||||||
|
if (type === "image/bmp" || name.endsWith(".bmp")) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageMayContainAlpha(file: File) {
|
||||||
|
const type = file.type.toLowerCase();
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
if (type.includes("png") || name.endsWith(".png")) return true;
|
||||||
|
if (type.includes("avif") || name.endsWith(".avif")) return true;
|
||||||
|
if (type.includes("bmp") || name.endsWith(".bmp")) return true;
|
||||||
|
return !type.includes("jpeg") && !type.includes("jpg") && !name.endsWith(".jpg") && !name.endsWith(".jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitmapHasAlpha(bitmap: ImageBitmap | HTMLImageElement) {
|
||||||
|
const width = "naturalWidth" in bitmap ? bitmap.naturalWidth || bitmap.width : bitmap.width;
|
||||||
|
const height = "naturalHeight" in bitmap ? bitmap.naturalHeight || bitmap.height : bitmap.height;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!context) return true;
|
||||||
|
|
||||||
|
context.clearRect(0, 0, width, height);
|
||||||
|
context.drawImage(bitmap, 0, 0);
|
||||||
|
const data = context.getImageData(0, 0, width, height).data;
|
||||||
|
for (let index = 3; index < data.length; index += 4) {
|
||||||
|
if (data[index] < 255) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function webpFileName(fileName: string) {
|
function webpFileName(fileName: string) {
|
||||||
const name = fileName.trim() || "upload";
|
const name = fileName.trim() || "upload";
|
||||||
const dotIndex = name.lastIndexOf(".");
|
const dotIndex = name.lastIndexOf(".");
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const CanvasAgentComposer = forwardRef<PromptComposerHandle, CanvasAgentC
|
|||||||
onReferenceRemoved={onReferenceRemoved}
|
onReferenceRemoved={onReferenceRemoved}
|
||||||
onAnnotationsChange={onAnnotationsChange}
|
onAnnotationsChange={onAnnotationsChange}
|
||||||
onAnnotationPreviewChange={onAnnotationPreviewChange}
|
onAnnotationPreviewChange={onAnnotationPreviewChange}
|
||||||
|
onPasteFile={onUploadFile}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
/>
|
/>
|
||||||
{previewSlot}
|
{previewSlot}
|
||||||
|
|||||||
@@ -311,6 +311,45 @@ function pastedReferenceImage(reference: PromptImageReference, index: number): U
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isImageFile(file: File | null): file is File {
|
||||||
|
return Boolean(file && (file.type.startsWith("image/") || /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(file.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageExtension(type: string) {
|
||||||
|
const subtype = type.split("/")[1]?.toLowerCase().split(";")[0];
|
||||||
|
if (!subtype) return "png";
|
||||||
|
if (subtype === "jpeg") return "jpg";
|
||||||
|
if (subtype === "svg+xml") return "svg";
|
||||||
|
return subtype.replace(/[^a-z0-9]+/g, "") || "png";
|
||||||
|
}
|
||||||
|
|
||||||
|
function namedClipboardImageFile(file: File, index: number) {
|
||||||
|
if (file.name.trim()) return file;
|
||||||
|
return new File([file], `pasted-image-${index + 1}.${imageExtension(file.type)}`, {
|
||||||
|
type: file.type || "image/png",
|
||||||
|
lastModified: file.lastModified || Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestClipboardImageFile(dataTransfer: DataTransfer) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const files = [
|
||||||
|
...Array.from(dataTransfer.files),
|
||||||
|
...Array.from(dataTransfer.items)
|
||||||
|
.filter((item) => item.kind === "file")
|
||||||
|
.map((item) => item.getAsFile())
|
||||||
|
]
|
||||||
|
.filter(isImageFile)
|
||||||
|
.filter((file) => {
|
||||||
|
const key = `${file.name}:${file.type}:${file.size}:${file.lastModified}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(namedClipboardImageFile);
|
||||||
|
return files.at(-1) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function closestAnnotationChip(target: EventTarget | Node | null) {
|
function closestAnnotationChip(target: EventTarget | Node | null) {
|
||||||
if (isElement(target)) return target.closest<HTMLElement>("[data-annotation-id]");
|
if (isElement(target)) return target.closest<HTMLElement>("[data-annotation-id]");
|
||||||
return target instanceof Node ? target.parentElement?.closest<HTMLElement>("[data-annotation-id]") ?? null : null;
|
return target instanceof Node ? target.parentElement?.closest<HTMLElement>("[data-annotation-id]") ?? null : null;
|
||||||
@@ -549,6 +588,7 @@ export const PromptComposer = forwardRef<
|
|||||||
onReferenceRemoved: (reference: UploadedReferenceImage) => void;
|
onReferenceRemoved: (reference: UploadedReferenceImage) => void;
|
||||||
onAnnotationsChange?: (annotations: CanvasAnnotation[]) => void;
|
onAnnotationsChange?: (annotations: CanvasAnnotation[]) => void;
|
||||||
onAnnotationPreviewChange?: (preview: AnnotationPreviewRequest | null) => void;
|
onAnnotationPreviewChange?: (preview: AnnotationPreviewRequest | null) => void;
|
||||||
|
onPasteFile?: (file: File | null) => void | Promise<void>;
|
||||||
onSubmit?: () => void;
|
onSubmit?: () => void;
|
||||||
}
|
}
|
||||||
>(function PromptComposer(
|
>(function PromptComposer(
|
||||||
@@ -565,6 +605,7 @@ export const PromptComposer = forwardRef<
|
|||||||
onReferenceRemoved,
|
onReferenceRemoved,
|
||||||
onAnnotationsChange,
|
onAnnotationsChange,
|
||||||
onAnnotationPreviewChange,
|
onAnnotationPreviewChange,
|
||||||
|
onPasteFile,
|
||||||
onSubmit
|
onSubmit
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
@@ -849,7 +890,25 @@ export const PromptComposer = forwardRef<
|
|||||||
onAnnotationPreviewChange?.(null);
|
onAnnotationPreviewChange?.(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uploadPastedImageFile = async (file: File) => {
|
||||||
|
if (!onPasteFile) return;
|
||||||
|
await onPasteFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
|
const pastedImage = latestClipboardImageFile(event.clipboardData);
|
||||||
|
if (pastedImage) {
|
||||||
|
event.preventDefault();
|
||||||
|
const editor = editorRef.current;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (editor && selection?.rangeCount) {
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
if (rangeBelongsToEditor(range, editor)) savedRangeRef.current = range.cloneRange();
|
||||||
|
}
|
||||||
|
void uploadPastedImageFile(pastedImage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pastedText = event.clipboardData.getData("text/plain");
|
const pastedText = event.clipboardData.getData("text/plain");
|
||||||
if (!pastedText) return;
|
if (!pastedText) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -75,6 +75,22 @@ export function serializePromptImageReference(reference: Pick<PromptImageReferen
|
|||||||
return `[@image:#${fallbackIndex}:${name}:${reference.url}]`;
|
return `[@image:#${fallbackIndex}:${name}:${reference.url}]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function visiblePromptText(content: string) {
|
||||||
|
const text = parsePromptImageReferences(content)
|
||||||
|
.map((part) => (part.type === "text" ? part.text : ""))
|
||||||
|
.join("");
|
||||||
|
return text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => {
|
||||||
|
const lower = line.toLowerCase();
|
||||||
|
return line && !lower.startsWith("image generator target node:") && !lower.startsWith("selected model:") && !line.startsWith("模型选择");
|
||||||
|
})
|
||||||
|
.join(" ")
|
||||||
|
.replace(/\s{2,}/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function collectReferenceMatches(content: string) {
|
function collectReferenceMatches(content: string) {
|
||||||
const matches: ReferenceMatch[] = [];
|
const matches: ReferenceMatch[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { loadMotevaFontCatalog } from "@/ui/components/TextStyleToolbar/fontCata
|
|||||||
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
|
import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
|
||||||
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
|
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
|
||||||
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
|
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
|
||||||
|
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
|
||||||
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
|
import { isImageGeneratorThreadMessage, visibleAgentMessages } from "./canvas/agentMessageFilters";
|
||||||
import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
|
import { CanvasColorPopover } from "./canvas/CanvasColorPopover";
|
||||||
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
|
import { buildAlignmentGuides, clamp, minGroupNodeScale, nodeBounds, nodeIntersectsRect, normalizeScreenRect, pointInsideNode, scaleGroupNode, screenBoundsForNodes, screenRectToWorldRect, type AlignmentGuide, type NodeAlignment, type NodeDistribution, type NodeTransform, type ResizeHandle, type SelectionBox } from "@/ui/pages/CanvasWorkspace/canvas/canvasGeometry";
|
||||||
@@ -600,7 +601,6 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
|
const [alignmentGuides, setAlignmentGuides] = useState<AlignmentGuide[]>([]);
|
||||||
const [canvasFileDragging, setCanvasFileDragging] = useState(false);
|
const [canvasFileDragging, setCanvasFileDragging] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [clipboardNode, setClipboardNode] = useState<CanvasNode | null>(null);
|
|
||||||
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(() => new Set());
|
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<string>>(() => new Set());
|
||||||
const [lockedNodeIds, setLockedNodeIds] = useState<Set<string>>(() => new Set());
|
const [lockedNodeIds, setLockedNodeIds] = useState<Set<string>>(() => new Set());
|
||||||
const [toolbarVisibility, setToolbarVisibility] = useState<ImageToolbarVisibility>(defaultToolbarVisibility);
|
const [toolbarVisibility, setToolbarVisibility] = useState<ImageToolbarVisibility>(defaultToolbarVisibility);
|
||||||
@@ -1837,20 +1837,11 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const copyNode = (node: CanvasNode) => {
|
const copyNode = (node: CanvasNode) => {
|
||||||
setClipboardNode(node);
|
|
||||||
void writeCanvasNodeToSystemClipboard(node)
|
void writeCanvasNodeToSystemClipboard(node)
|
||||||
.then(() => showCanvasToast(t("clipboardCopied")))
|
.then(() => showCanvasToast(t("clipboardCopied")))
|
||||||
.catch(() => showCanvasToast(t("clipboardCopyFailed"), "error"));
|
.catch(() => showCanvasToast(t("clipboardCopyFailed"), "error"));
|
||||||
};
|
};
|
||||||
|
|
||||||
const pasteNode = (sourceNode = clipboardNode) => {
|
|
||||||
if (!sourceNode) return;
|
|
||||||
const copy = cloneCanvasNode(sourceNode, cryptoFallbackId());
|
|
||||||
setNodes((current) => [...current, copy]);
|
|
||||||
selectOnlyNode(copy.id);
|
|
||||||
setDirty(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => {
|
const reorderNode = (nodeId: string, action: "forward" | "backward" | "front" | "back") => {
|
||||||
setNodes((current) => reorderNodeOp(current, nodeId, action));
|
setNodes((current) => reorderNodeOp(current, nodeId, action));
|
||||||
setDirty(true);
|
setDirty(true);
|
||||||
@@ -2037,6 +2028,74 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
setDirty(true);
|
setDirty(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clipboardPasteAnchor = () => {
|
||||||
|
const pointer = lastCanvasPointerRef.current;
|
||||||
|
return pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pasteClipboardText = (value: string, anchor = clipboardPasteAnchor()) => {
|
||||||
|
const content = value.replace(/\u00a0/g, " ");
|
||||||
|
if (!content.trim()) return false;
|
||||||
|
|
||||||
|
const currentViewport = viewportRef.current;
|
||||||
|
const zoom = Math.max(currentViewport.k, 0.01);
|
||||||
|
const size = { width: defaultTextVisibleBox.width / zoom, height: defaultTextVisibleBox.height / zoom };
|
||||||
|
const node = fitTextNodeToContent({
|
||||||
|
id: cryptoFallbackId(),
|
||||||
|
type: "text",
|
||||||
|
title: t("newTextNode"),
|
||||||
|
x: anchor.x - size.width / 2,
|
||||||
|
y: anchor.y - size.height / 2,
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
content,
|
||||||
|
tone: "ink",
|
||||||
|
status: "draft",
|
||||||
|
layerRole: "editable-text",
|
||||||
|
fontFamily: "Inter",
|
||||||
|
fontWeight: "Regular",
|
||||||
|
fontSize: defaultTextVisibleFontSize / zoom,
|
||||||
|
color: "#000000",
|
||||||
|
textAlign: "left",
|
||||||
|
opacity: 1,
|
||||||
|
strokeColor: "#000000",
|
||||||
|
strokeWidth: 0,
|
||||||
|
lineHeight: 0,
|
||||||
|
letterSpacing: 0,
|
||||||
|
textDecoration: "none",
|
||||||
|
textTransform: "none"
|
||||||
|
});
|
||||||
|
setNodes((current) => {
|
||||||
|
const next = [...current, node];
|
||||||
|
nodesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
selectOnlyNode(node.id);
|
||||||
|
setDirty(true);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pasteFromClipboardData = (dataTransfer: DataTransfer) => {
|
||||||
|
const anchor = clipboardPasteAnchor();
|
||||||
|
const imageFile = latestDataTransferImageFile(dataTransfer, "clipboard-image");
|
||||||
|
if (imageFile) {
|
||||||
|
void uploadImagesToCanvas([imageFile], anchor);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return pasteClipboardText(dataTransfer.getData("text/plain"), anchor);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pasteFromSystemClipboard = async () => {
|
||||||
|
const anchor = clipboardPasteAnchor();
|
||||||
|
const imageFile = await latestNavigatorClipboardImageFile("clipboard-image").catch(() => null);
|
||||||
|
if (imageFile) {
|
||||||
|
await uploadImagesToCanvas([imageFile], anchor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = await navigator.clipboard?.readText?.().catch(() => "");
|
||||||
|
if (text) pasteClipboardText(text, anchor);
|
||||||
|
};
|
||||||
|
|
||||||
const sendNodeToAgentComposer = (node: CanvasNode) => {
|
const sendNodeToAgentComposer = (node: CanvasNode) => {
|
||||||
if (isRealCanvasImageNode(node)) {
|
if (isRealCanvasImageNode(node)) {
|
||||||
insertCanvasImageReference(node, { focus: true });
|
insertCanvasImageReference(node, { focus: true });
|
||||||
@@ -2056,7 +2115,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
copyNode(node);
|
copyNode(node);
|
||||||
removeNode(node.id);
|
removeNode(node.id);
|
||||||
},
|
},
|
||||||
paste: pasteNode,
|
paste: () => void pasteFromSystemClipboard(),
|
||||||
duplicate: () => duplicateNode(node),
|
duplicate: () => duplicateNode(node),
|
||||||
bringForward: () => reorderNode(node.id, "forward"),
|
bringForward: () => reorderNode(node.id, "forward"),
|
||||||
sendBackward: () => reorderNode(node.id, "backward"),
|
sendBackward: () => reorderNode(node.id, "backward"),
|
||||||
@@ -2143,33 +2202,10 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePaste = (event: ClipboardEvent) => {
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
if (isEditableShortcutTarget(event.target)) return;
|
if (isEditableShortcutTarget(event.target)) return;
|
||||||
|
if (!event.clipboardData) return;
|
||||||
const imageFiles = event.clipboardData ? dataTransferImageFiles(event.clipboardData, "clipboard-image") : [];
|
if (pasteFromClipboardData(event.clipboardData)) {
|
||||||
const pasteImageFiles = () => {
|
|
||||||
const pointer = lastCanvasPointerRef.current;
|
|
||||||
const anchor = pointer ? canvasPointFromClient(pointer.clientX, pointer.clientY) : canvasCenterPoint();
|
|
||||||
void uploadImagesToCanvas(imageFiles, anchor);
|
|
||||||
};
|
|
||||||
if (clipboardNode && imageFiles.length > 0 && isTransparentClipboardNode(clipboardNode)) {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void shouldUseInternalClipboardNode(clipboardNode, imageFiles).then((useInternalNode) => {
|
|
||||||
if (useInternalNode) {
|
|
||||||
pasteNode(clipboardNode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pasteImageFiles();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (imageFiles.length > 0) {
|
|
||||||
event.preventDefault();
|
|
||||||
pasteImageFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clipboardNode) return;
|
|
||||||
event.preventDefault();
|
|
||||||
pasteNode();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("paste", handlePaste);
|
window.addEventListener("paste", handlePaste);
|
||||||
@@ -3095,6 +3131,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
const previewUrl = URL.createObjectURL(file);
|
const previewUrl = URL.createObjectURL(file);
|
||||||
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
||||||
const size = fitCanvasImageSize(dimensions.width, dimensions.height);
|
const size = fitCanvasImageSize(dimensions.width, dimensions.height);
|
||||||
|
const tone = imageMaySupportTransparency(file) ? "transparent-image" : "natural";
|
||||||
preparedUploads.push({
|
preparedUploads.push({
|
||||||
file,
|
file,
|
||||||
previewUrl,
|
previewUrl,
|
||||||
@@ -3107,7 +3144,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
content: previewUrl,
|
content: previewUrl,
|
||||||
tone: "natural",
|
tone,
|
||||||
status: "uploading"
|
status: "uploading"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -3321,7 +3358,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
}}
|
}}
|
||||||
selectionScale={viewport.k}
|
selectionScale={viewport.k}
|
||||||
viewportScale={viewport.k}
|
viewportScale={viewport.k}
|
||||||
hasClipboard={Boolean(clipboardNode)}
|
hasClipboard
|
||||||
contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null}
|
contextMenuHandlers={canOperateNode && canUseNodeContextMenu(node) ? createNodeContextMenuHandlers(node) : null}
|
||||||
onPointerDown={(event) => handleNodePointerDown(event, node)}
|
onPointerDown={(event) => handleNodePointerDown(event, node)}
|
||||||
onContextMenu={() => {
|
onContextMenu={() => {
|
||||||
@@ -3720,7 +3757,7 @@ export function CanvasWorkspace({ projectId }: { projectId: string }) {
|
|||||||
<aside className="assistant-panel">
|
<aside className="assistant-panel">
|
||||||
<div className="assistant-header">
|
<div className="assistant-header">
|
||||||
<div className="assistant-header-title">
|
<div className="assistant-header-title">
|
||||||
<strong>{project.brief || project.title || t("canvasUntitled")}</strong>
|
<strong>{agentHeaderTitle(messages, project, t("canvasUntitled"))}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="assistant-header-actions">
|
<div className="assistant-header-actions">
|
||||||
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!hasCurrentConversation}>
|
<button className="assistant-header-icon" type="button" aria-label={t("newChat")} title={t("newChat")} onClick={beginNewAgentConversation} disabled={!hasCurrentConversation}>
|
||||||
@@ -5024,21 +5061,6 @@ function canvasBackgroundStorageKey(projectId: string) {
|
|||||||
return `fluxo:canvas-background:${projectId}`;
|
return `fluxo:canvas-background:${projectId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransparentClipboardNode(node: CanvasNode) {
|
|
||||||
return node.type === "image" && (node.layerRole === "background-removed" || node.tone === "transparent-image");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shouldUseInternalClipboardNode(node: CanvasNode, imageFiles: File[]) {
|
|
||||||
const imageFile = imageFiles[0];
|
|
||||||
if (!imageFile) return false;
|
|
||||||
try {
|
|
||||||
const dimensions = await readImageDimensions(imageFile);
|
|
||||||
return dimensions.width === Math.max(1, Math.round(node.width)) && dimensions.height === Math.max(1, Math.round(node.height));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeCanvasNodeToSystemClipboard(node: CanvasNode) {
|
async function writeCanvasNodeToSystemClipboard(node: CanvasNode) {
|
||||||
if (node.type === "text") {
|
if (node.type === "text") {
|
||||||
await writeClipboardText(node.content || node.title);
|
await writeClipboardText(node.content || node.title);
|
||||||
@@ -5095,6 +5117,23 @@ function isImageFile(file: File) {
|
|||||||
return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(file.name);
|
return file.type.startsWith("image/") || /\.(png|jpe?g|webp|gif|avif|svg)$/i.test(file.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageMaySupportTransparency(file: File) {
|
||||||
|
const type = file.type.toLowerCase();
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
return (
|
||||||
|
type.includes("png") ||
|
||||||
|
type.includes("webp") ||
|
||||||
|
type.includes("avif") ||
|
||||||
|
type.includes("gif") ||
|
||||||
|
type.includes("svg") ||
|
||||||
|
name.endsWith(".png") ||
|
||||||
|
name.endsWith(".webp") ||
|
||||||
|
name.endsWith(".avif") ||
|
||||||
|
name.endsWith(".gif") ||
|
||||||
|
name.endsWith(".svg")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
|
function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
...Array.from(dataTransfer.files),
|
...Array.from(dataTransfer.files),
|
||||||
@@ -5114,6 +5153,23 @@ function dataTransferImageFiles(dataTransfer: DataTransfer, fallbackBaseName = "
|
|||||||
}, []);
|
}, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function latestDataTransferImageFile(dataTransfer: DataTransfer, fallbackBaseName = "canvas-image") {
|
||||||
|
return dataTransferImageFiles(dataTransfer, fallbackBaseName).at(-1) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestNavigatorClipboardImageFile(fallbackBaseName = "clipboard-image") {
|
||||||
|
if (!navigator.clipboard?.read) return null;
|
||||||
|
const items = await navigator.clipboard.read();
|
||||||
|
for (let itemIndex = items.length - 1; itemIndex >= 0; itemIndex -= 1) {
|
||||||
|
const item = items[itemIndex];
|
||||||
|
const type = item.types.filter((itemType) => itemType.startsWith("image/")).at(-1);
|
||||||
|
if (!type) continue;
|
||||||
|
const blob = await item.getType(type);
|
||||||
|
return ensureImageFileName(new File([blob], "", { type: blob.type || type, lastModified: Date.now() }), `${fallbackBaseName}-${Date.now()}-${itemIndex + 1}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function dataTransferHasImage(dataTransfer: DataTransfer) {
|
function dataTransferHasImage(dataTransfer: DataTransfer) {
|
||||||
const files = dataTransferImageFiles(dataTransfer);
|
const files = dataTransferImageFiles(dataTransfer);
|
||||||
if (files.length > 0) return true;
|
if (files.length > 0) return true;
|
||||||
@@ -5234,7 +5290,7 @@ function fallbackAgentThreads(project: Project | null, hiddenThreadIds: Readonly
|
|||||||
}
|
}
|
||||||
|
|
||||||
function agentThreadTitle(thread: AgentThreadSummary) {
|
function agentThreadTitle(thread: AgentThreadSummary) {
|
||||||
return thread.text.trim() || "新对话";
|
return visiblePromptText(thread.text) || "新对话";
|
||||||
}
|
}
|
||||||
|
|
||||||
function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlySet<string>) {
|
function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlySet<string>) {
|
||||||
@@ -5243,14 +5299,14 @@ function latestAgentThreadId(project: Project | null, hiddenThreadIds: ReadonlyS
|
|||||||
}
|
}
|
||||||
|
|
||||||
function agentThreadTitleText(content: string) {
|
function agentThreadTitleText(content: string) {
|
||||||
const lines = content
|
return visiblePromptText(content);
|
||||||
.split("\n")
|
}
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter((line) => {
|
function agentHeaderTitle(messages: AgentMessage[], project: Project, fallback: string) {
|
||||||
const lower = line.toLowerCase();
|
const firstUserMessage = messages.find((message) => message.role === "user");
|
||||||
return line && !lower.startsWith("image generator target node:") && !lower.startsWith("selected model:") && !line.startsWith("模型选择");
|
const firstUserText = firstUserMessage ? visiblePromptText(firstUserMessage.content) : "";
|
||||||
});
|
if (firstUserText) return firstUserText;
|
||||||
return lines.join(" ").trim() || content.trim();
|
return visiblePromptText(project.brief) || project.title.trim() || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProjectGenerating(project: Project) {
|
function isProjectGenerating(project: Project) {
|
||||||
|
|||||||
@@ -734,6 +734,7 @@ export function HomePage() {
|
|||||||
onTextChange={setPrompt}
|
onTextChange={setPrompt}
|
||||||
onReferencesChange={syncReferenceImages}
|
onReferencesChange={syncReferenceImages}
|
||||||
onReferenceRemoved={handleReferenceRemoved}
|
onReferenceRemoved={handleReferenceRemoved}
|
||||||
|
onPasteFile={attachImage}
|
||||||
onSubmit={() => createProject()}
|
onSubmit={() => createProject()}
|
||||||
/>
|
/>
|
||||||
<div className="prompt-actions">
|
<div className="prompt-actions">
|
||||||
|
|||||||
@@ -44,6 +44,44 @@ func TestAgentThreadsOnlyListsAgentConversationThreads(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAgentThreadsSummaryHidesPromptReferenceDirectives(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := memory.NewProjectRepository()
|
||||||
|
service := NewDesignService(repo, nil, nil, nil, nil)
|
||||||
|
now := time.Now()
|
||||||
|
project := design.Project{
|
||||||
|
ID: "project-agent-reference-history",
|
||||||
|
Title: "Agent history",
|
||||||
|
Status: design.StatusReady,
|
||||||
|
LastThreadID: "thread-agent",
|
||||||
|
UpdatedAt: now,
|
||||||
|
Messages: []design.Message{
|
||||||
|
{
|
||||||
|
ID: "agent-user",
|
||||||
|
Role: "user",
|
||||||
|
Type: "user",
|
||||||
|
Content: "Reference image 1 (image): http://localhost:19000/canvas-assets/reference.png 灰裤子拆成单件衣服,为了后面电商配图。\nSelected model: GPT Image 2",
|
||||||
|
ThreadID: "thread-agent",
|
||||||
|
CreatedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repo.Save(ctx, project); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
threads, err := service.AgentThreads(ctx, project.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(threads) != 1 {
|
||||||
|
t.Fatalf("expected one agent thread, got %#v", threads)
|
||||||
|
}
|
||||||
|
if threads[0].Text != "灰裤子拆成单件衣服,为了后面电商配图。" {
|
||||||
|
t.Fatalf("expected visible prompt text only, got %q", threads[0].Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestProjectScopedToAgentThreadKeepsOnlyCurrentConversationContext(t *testing.T) {
|
func TestProjectScopedToAgentThreadKeepsOnlyCurrentConversationContext(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
project := design.Project{
|
project := design.Project{
|
||||||
|
|||||||
@@ -824,6 +824,7 @@ func isNonAgentConversationMessage(message design.Message) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func agentThreadSummaryText(content string) string {
|
func agentThreadSummaryText(content string) string {
|
||||||
|
content = stripPromptReferenceDirectives(content)
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
kept := make([]string, 0, len(lines))
|
kept := make([]string, 0, len(lines))
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
@@ -837,7 +838,7 @@ func agentThreadSummaryText(content string) string {
|
|||||||
if text := strings.TrimSpace(strings.Join(kept, " ")); text != "" {
|
if text := strings.TrimSpace(strings.Join(kept, " ")); text != "" {
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(content)
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func projectScopedToAgentThread(project design.Project, threadID string) design.Project {
|
func projectScopedToAgentThread(project design.Project, threadID string) design.Project {
|
||||||
@@ -4530,7 +4531,7 @@ func hasUserPromptTextInAgentMessages(messages []design.AgentChatMessage) bool {
|
|||||||
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
|
if strings.EqualFold(strings.TrimSpace(content.TextSource), "settings") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
text := stripPromptReferenceDirectiveLines(stripImageGeneratorDirectiveLines(content.Text))
|
text := stripPromptReferenceDirectives(stripImageGeneratorDirectiveLines(content.Text))
|
||||||
if design.NormalizePrompt(text) != "" {
|
if design.NormalizePrompt(text) != "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -4551,12 +4552,14 @@ func stripImageGeneratorDirectiveLines(text string) string {
|
|||||||
return strings.TrimSpace(strings.Join(filtered, "\n"))
|
return strings.TrimSpace(strings.Join(filtered, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripPromptReferenceDirectiveLines(text string) string {
|
func stripPromptReferenceDirectives(text string) string {
|
||||||
|
text = promptImageTokenPattern.ReplaceAllString(text, "")
|
||||||
|
text = promptImageDirectivePattern.ReplaceAllString(text, "")
|
||||||
lines := strings.Split(text, "\n")
|
lines := strings.Split(text, "\n")
|
||||||
filtered := make([]string, 0, len(lines))
|
filtered := make([]string, 0, len(lines))
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
trimmed := strings.TrimSpace(strings.ReplaceAll(line, "\u00a0", " "))
|
trimmed := strings.TrimSpace(strings.ReplaceAll(line, "\u00a0", " "))
|
||||||
if isPromptReferenceDirectiveLine(trimmed) {
|
if trimmed == "" || isPromptReferenceDirectiveLine(trimmed) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
filtered = append(filtered, line)
|
filtered = append(filtered, line)
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
|
|||||||
drawTextLayer(canvas, node, bounds, scale)
|
drawTextLayer(canvas, node, bounds, scale)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hasTransparency := mergedLayerHasTransparency(canvas)
|
||||||
|
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
if err := png.Encode(&output, canvas); err != nil {
|
if err := png.Encode(&output, canvas); err != nil {
|
||||||
@@ -338,6 +339,10 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
title = "Merged Layer"
|
title = "Merged Layer"
|
||||||
}
|
}
|
||||||
|
tone := "natural"
|
||||||
|
if hasTransparency {
|
||||||
|
tone = "transparent-image"
|
||||||
|
}
|
||||||
return design.Node{
|
return design.Node{
|
||||||
ID: newID(),
|
ID: newID(),
|
||||||
Type: design.NodeTypeImage,
|
Type: design.NodeTypeImage,
|
||||||
@@ -347,11 +352,25 @@ func (s *DesignService) composeMergedLayer(ctx context.Context, projectID string
|
|||||||
Width: bounds.Width,
|
Width: bounds.Width,
|
||||||
Height: bounds.Height,
|
Height: bounds.Height,
|
||||||
Content: content,
|
Content: content,
|
||||||
Tone: "natural",
|
Tone: tone,
|
||||||
Status: "success",
|
Status: "success",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mergedLayerHasTransparency(img *image.NRGBA) bool {
|
||||||
|
bounds := img.Bounds()
|
||||||
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
|
offset := img.PixOffset(bounds.Min.X, y)
|
||||||
|
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||||
|
if img.Pix[offset+3] < 255 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
offset += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func boundsForLayerMerge(nodes []design.Node) layerMergeBounds {
|
func boundsForLayerMerge(nodes []design.Node) layerMergeBounds {
|
||||||
left := math.Inf(1)
|
left := math.Inf(1)
|
||||||
top := math.Inf(1)
|
top := math.Inf(1)
|
||||||
@@ -446,8 +465,8 @@ func sampleLayerColor(src image.Image, srcBounds image.Rectangle, srcWidth int,
|
|||||||
}
|
}
|
||||||
u = clampFloat(u, 0, 1)
|
u = clampFloat(u, 0, 1)
|
||||||
v = clampFloat(v, 0, 1)
|
v = clampFloat(v, 0, 1)
|
||||||
fx := u * float64(srcWidth-1)
|
fx := clampFloat(u*float64(srcWidth)-0.5, 0, float64(srcWidth-1))
|
||||||
fy := v * float64(srcHeight-1)
|
fy := clampFloat(v*float64(srcHeight)-0.5, 0, float64(srcHeight-1))
|
||||||
x0 := int(math.Floor(fx))
|
x0 := int(math.Floor(fx))
|
||||||
y0 := int(math.Floor(fy))
|
y0 := int(math.Floor(fy))
|
||||||
x1 := clampInt(x0+1, 0, srcWidth-1)
|
x1 := clampInt(x0+1, 0, srcWidth-1)
|
||||||
|
|||||||
@@ -129,6 +129,71 @@ func TestMergeLayersFlattensTextOverImage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMergeLayersPreservesTransparentPixels(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repo := memory.NewProjectRepository()
|
||||||
|
service := NewDesignService(repo, nil, nil, nil, nil)
|
||||||
|
transparentLayer := image.NewNRGBA(image.Rect(0, 0, 3, 2))
|
||||||
|
transparentLayer.SetNRGBA(1, 0, color.NRGBA{R: 255, A: 255})
|
||||||
|
project := design.Project{
|
||||||
|
ID: "project-merge-transparent",
|
||||||
|
Title: "Merge transparent",
|
||||||
|
Status: design.StatusReady,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Nodes: []design.Node{
|
||||||
|
{
|
||||||
|
ID: "transparent",
|
||||||
|
Type: design.NodeTypeImage,
|
||||||
|
Title: "Transparent",
|
||||||
|
X: 0,
|
||||||
|
Y: 0,
|
||||||
|
Width: 3,
|
||||||
|
Height: 2,
|
||||||
|
Content: testPNGDataURLFromImage(transparentLayer),
|
||||||
|
Status: "success",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "blue",
|
||||||
|
Type: design.NodeTypeImage,
|
||||||
|
Title: "Blue",
|
||||||
|
X: 2,
|
||||||
|
Y: 1,
|
||||||
|
Width: 1,
|
||||||
|
Height: 1,
|
||||||
|
Content: testPNGDataURL(color.NRGBA{B: 255, A: 255}, 1, 1),
|
||||||
|
Status: "success",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repo.Save(ctx, project); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := service.MergeLayers(ctx, project.ID, []string{"transparent", "blue"}, "Transparent merge"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := waitForMergedProject(t, repo, project.ID)
|
||||||
|
node := merged.Nodes[0]
|
||||||
|
if node.Tone != "transparent-image" {
|
||||||
|
t.Fatalf("expected transparent merged image tone, got %q", node.Tone)
|
||||||
|
}
|
||||||
|
imageData, _, _, ok, err := decodeDataImage(node.Content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected merged content data URL, got %s", node.Content)
|
||||||
|
}
|
||||||
|
decoded, err := png.Decode(bytes.NewReader(imageData))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
assertPixel(t, decoded, 0, 0, color.NRGBA{})
|
||||||
|
assertPixel(t, decoded, 1, 0, color.NRGBA{R: 255, A: 255})
|
||||||
|
assertPixel(t, decoded, 2, 1, color.NRGBA{B: 255, A: 255})
|
||||||
|
}
|
||||||
|
|
||||||
func waitForMergedProject(t *testing.T, repo design.Repository, projectID string) design.Project {
|
func waitForMergedProject(t *testing.T, repo design.Repository, projectID string) design.Project {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
deadline := time.After(2 * time.Second)
|
deadline := time.After(2 * time.Second)
|
||||||
@@ -160,6 +225,10 @@ func testPNGDataURL(fill color.NRGBA, width int, height int) string {
|
|||||||
img.SetNRGBA(x, y, fill)
|
img.SetNRGBA(x, y, fill)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return testPNGDataURLFromImage(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPNGDataURLFromImage(img image.Image) string {
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := png.Encode(&buf, img); err != nil {
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
Reference in New Issue
Block a user