feat(canvas): import DWG/DXF CAD drawings as canvas assets
Accept DWG and DXF files (up to 24 MB) through the canvas asset picker and drag-and-drop. Conversion runs locally in a browser worker via acad-ts, uploads a sanitized derived SVG, and leaves the source file untouched. DXF covers ASCII and binary drawings; DWG covers AutoCAD R14 through 2018. Rename the image-only upload input/handler to a generic canvas asset flow, add vector-image node detection, tag imported SVGs as vector-image nodes, localize CAD import status/error messages, and switch the Next build to the webpack compiler so the worker bundles correctly. Add a node --test suite covering CAD conversion and vector image helpers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,8 @@ import { WorkspaceTitle } from "@/ui/components/WorkspaceTitle";
|
||||
import { SharePopover } from "@/ui/components/SharePopover";
|
||||
import { autoImageAdjustments, defaultImageAdjustments, parseImageAdjustments, serializeImageAdjustments } from "@/ui/lib/imageAdjustments";
|
||||
import { friendlyProjectFailureMessage } from "@/ui/lib/friendlyAgentErrors";
|
||||
import { cadImportMessageKey, isCadFile, isCanvasImportFile, prepareCanvasImportFile } from "@/ui/lib/cadImport";
|
||||
import { isSvgSource, isVectorImageNode } from "@/ui/lib/vectorImage";
|
||||
import { canvasImageUrl } from "@/ui/lib/imageDelivery";
|
||||
import { createModelAgentContentPreparer } from "@/ui/lib/modelImageReferences";
|
||||
import { visiblePromptText } from "@/ui/lib/promptImageReferences";
|
||||
@@ -558,7 +560,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const { showToast } = useGlobalToast();
|
||||
const showGenerationError = useCallback((message: string) => showToast(message, "error"), [showToast]);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const canvasAssetInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const agentComposerRef = useRef<PromptComposerHandle | null>(null);
|
||||
const assistantFeedRef = useRef<HTMLDivElement | null>(null);
|
||||
const assistantFooterRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -3420,13 +3422,32 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
|
||||
const uploadImagesToCanvas = async (files: File[], anchor = canvasCenterPoint()) => {
|
||||
const currentProject = projectRef.current;
|
||||
const imageFiles = files.filter(isImageFile);
|
||||
if (!currentProject || imageFiles.length === 0) return;
|
||||
const importFiles = files.filter(isCanvasImportFile);
|
||||
if (!currentProject || importFiles.length === 0) return;
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
const preparedUploads: Array<{ file: File; previewUrl: string; node: CanvasNode }> = [];
|
||||
try {
|
||||
const imageFiles: File[] = [];
|
||||
let cadImportCount = 0;
|
||||
let cadImportError: unknown = null;
|
||||
if (importFiles.some(isCadFile)) showCanvasToast(t("cadImportConverting"));
|
||||
for (const file of importFiles) {
|
||||
try {
|
||||
imageFiles.push(await prepareCanvasImportFile(file));
|
||||
if (isCadFile(file)) cadImportCount += 1;
|
||||
} catch (error) {
|
||||
cadImportError ??= error;
|
||||
}
|
||||
}
|
||||
if (cadImportError) {
|
||||
const message = t(cadImportMessageKey(cadImportError));
|
||||
setError(message);
|
||||
showCanvasToast(message, "error");
|
||||
}
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
for (const [index, file] of imageFiles.entries()) {
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
const dimensions = await readImageDimensions(file).catch(() => ({ width: 512, height: 512 }));
|
||||
@@ -3445,6 +3466,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
height: size.height,
|
||||
content: previewUrl,
|
||||
tone,
|
||||
layerRole: isSvgSource(file.name) || file.type.toLowerCase().includes("svg") ? "vector-image" : undefined,
|
||||
status: "uploading"
|
||||
}
|
||||
});
|
||||
@@ -3491,7 +3513,9 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
const { project: savedProject } = await designGateway.saveCanvas(currentProject.id, viewportRef.current, finalNodes, [], currentProject);
|
||||
applyProject(savedProject);
|
||||
setDirty(false);
|
||||
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) showCanvasToast(t("imageUploadSuccess"));
|
||||
if (failedIds.size === 0 && finalUploadedNodeIds.size > 0) {
|
||||
showCanvasToast(cadImportCount > 0 ? t("cadImportSuccess", { count: cadImportCount }) : t("imageUploadSuccess"));
|
||||
}
|
||||
} catch (err) {
|
||||
preparedUploads.forEach((item) => URL.revokeObjectURL(item.previewUrl));
|
||||
const failedUploadIds = new Set(preparedUploads.map((item) => item.node.id));
|
||||
@@ -3507,7 +3531,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
}
|
||||
};
|
||||
|
||||
const handleCanvasImageInput = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleCanvasAssetInput = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? []);
|
||||
event.target.value = "";
|
||||
void uploadImagesToCanvas(files);
|
||||
@@ -3521,7 +3545,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
};
|
||||
|
||||
const handleCanvasDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (!dataTransferHasImage(event.dataTransfer)) return;
|
||||
if (!dataTransferHasCanvasFile(event.dataTransfer)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
setCanvasFileDragging(true);
|
||||
@@ -3533,7 +3557,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
};
|
||||
|
||||
const handleCanvasDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
const files = dataTransferImageFiles(event.dataTransfer);
|
||||
const files = dataTransferCanvasFiles(event.dataTransfer);
|
||||
if (files.length === 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -3586,7 +3610,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
onDragLeave={canEdit ? handleCanvasDragLeave : undefined}
|
||||
onDrop={canEdit ? handleCanvasDrop : undefined}
|
||||
>
|
||||
<input ref={canvasImageInputRef} type="file" accept="image/*" multiple hidden disabled={!canEdit} onChange={handleCanvasImageInput} />
|
||||
<input ref={canvasAssetInputRef} type="file" accept="image/*,.dwg,.dxf" multiple hidden disabled={!canEdit} onChange={handleCanvasAssetInput} />
|
||||
<div className={`workspace-stage-title ${generatedFilesPanelOpen || layersPanelOpen ? "is-offset-by-canvas-panel" : ""}`}>
|
||||
<WorkspaceTitle
|
||||
title={displayProjectTitle}
|
||||
@@ -3648,7 +3672,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
messages={project?.messages ?? []}
|
||||
selectedIds={selectedIds}
|
||||
onSelect={selectOnlyNode}
|
||||
onDownload={(node) => exportImageNode(node, isVectorizedImageNode(node) ? "svg" : "png")}
|
||||
onDownload={(node) => exportImageNode(node, isVectorImageNode(node) ? "svg" : "png")}
|
||||
onClose={() => setGeneratedFilesPanelOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -3773,7 +3797,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
|
||||
{selectedImageNode && selectedNodeBounds && (
|
||||
<>
|
||||
{isVectorizedImageNode(selectedImageNode) ? (
|
||||
{isVectorImageNode(selectedImageNode) ? (
|
||||
<VectorizedImageToolbar bounds={selectedNodeBounds} leftInset={floatingToolbarLeftInset} onDownload={() => exportImageNode(selectedImageNode, "svg")} />
|
||||
) : (
|
||||
<>
|
||||
@@ -4095,7 +4119,7 @@ export function CanvasWorkspace({ projectId, shareAccess = ownerWorkspaceAccess
|
||||
onChooseTool={chooseCanvasTool}
|
||||
onChooseShapeKind={setSelectedShapeKind}
|
||||
onToggleGrid={() => setShowGrid((value) => !value)}
|
||||
onUploadImage={() => canvasImageInputRef.current?.click()}
|
||||
onUploadAsset={() => canvasAssetInputRef.current?.click()}
|
||||
/>
|
||||
{canEdit && activeTool === "pen" && (
|
||||
<PenToolControls
|
||||
@@ -5554,6 +5578,30 @@ function dataTransferHasImage(dataTransfer: DataTransfer) {
|
||||
return Array.from(dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"));
|
||||
}
|
||||
|
||||
function dataTransferCanvasFiles(dataTransfer: DataTransfer, fallbackBaseName = "canvas-asset") {
|
||||
const candidates = [
|
||||
...Array.from(dataTransfer.files),
|
||||
...Array.from(dataTransfer.items)
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => Boolean(file))
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return candidates.filter(isCanvasImportFile).reduce<File[]>((files, file, index) => {
|
||||
const normalizedFile = isImageFile(file) ? ensureImageFileName(file, `${fallbackBaseName}-${Date.now()}-${index + 1}`) : file;
|
||||
const key = `${normalizedFile.name}:${normalizedFile.type}:${normalizedFile.size}:${normalizedFile.lastModified}`;
|
||||
if (seen.has(key)) return files;
|
||||
seen.add(key);
|
||||
files.push(normalizedFile);
|
||||
return files;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function dataTransferHasCanvasFile(dataTransfer: DataTransfer) {
|
||||
if (dataTransferCanvasFiles(dataTransfer).length > 0) return true;
|
||||
return Array.from(dataTransfer.items).some((item) => item.kind === "file" && item.type.startsWith("image/"));
|
||||
}
|
||||
|
||||
function ensureImageFileName(file: File, baseName: string) {
|
||||
if (file.name.trim()) return file;
|
||||
const type = file.type || "image/png";
|
||||
@@ -5806,10 +5854,6 @@ function visualAnnotationToolLabel(tool: VisualAnnotationTool, t: (key: string)
|
||||
return t(keys[tool]);
|
||||
}
|
||||
|
||||
function isVectorizedImageNode(node: CanvasNode) {
|
||||
return node.type === "image" && node.layerRole === "vectorized-image";
|
||||
}
|
||||
|
||||
function createOptimisticAgentMessages(threadId: string, contents: AgentContent[]): AgentMessage[] {
|
||||
const now = Date.now();
|
||||
const promptText = agentContentsToDisplayText(contents);
|
||||
|
||||
Reference in New Issue
Block a user