diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 7cd560b04..75a2b3bf8 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -10254,7 +10254,10 @@ export default function CanvasEditorLegacyPage({
@@ -11402,6 +11407,14 @@ export default function CanvasEditorLegacyPage({ fallback={ shouldUseStyleModeButtonForStylePro ? Sparkles : Wand2 } + referenceName={ + isVideoMode + ? undefined + : shouldUseStyleModeButtonForStylePro + ? "style-controls" + : "creativity" + } + referenceSize={20} item={getCanvasToolbarItem( canvasToolbarConfig, "styleMode" @@ -11431,6 +11444,8 @@ export default function CanvasEditorLegacyPage({ @@ -11876,7 +11897,7 @@ export default function CanvasEditorLegacyPage({ className="h-4 w-4" fallback={Plus} referenceName={isVideoMode ? undefined : "plus"} - referenceSize={18} + referenceSize={20} item={getCanvasToolbarItem( canvasToolbarConfig, isVideoMode ? "newVideoCanvas" : "newCanvas" diff --git a/app/globals.css b/app/globals.css index 0f09c5a09..9d86ed491 100644 --- a/app/globals.css +++ b/app/globals.css @@ -554,7 +554,7 @@ transition: background-color 150ms, border-color 150ms; } .lovart-start-action:hover { background: #f7f7f7; border-color: #d4d4d4; } -.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-bottom: 1px solid #ededed; } +.lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-header { border-bottom: 0; } .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer { padding: 0 8px 8px; } .lovart-canvas-surface [data-lovart-chat="true"] .lovart-chat-composer > [data-canvas-chat-input-anchor] { min-height: 120px; diff --git a/components/canvas/CanvasBlankContextMenu.tsx b/components/canvas/CanvasBlankContextMenu.tsx new file mode 100644 index 000000000..76b93df3f --- /dev/null +++ b/components/canvas/CanvasBlankContextMenu.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { Position } from "@/types/canvas"; + +type Props = { + position: Position | null; + isMac: boolean; + videoUploading: boolean; + onClose: () => void; + onUploadImage: () => void; + onUploadVideo: () => void; + onPaste: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onFitAll: () => void; + onResetZoom: () => void; +}; + +export function CanvasBlankContextMenu({ + position, + isMac, + videoUploading, + onClose, + onUploadImage, + onUploadVideo, + onPaste, + onZoomIn, + onZoomOut, + onFitAll, + onResetZoom, +}: Props) { + const modifier = isMac ? "⌘" : "Ctrl+"; + const itemClass = + "h-8 cursor-pointer justify-between whitespace-nowrap rounded-[5px] px-0 text-[14px] font-normal focus:bg-[#f5f5f5] dark:focus:bg-white/10"; + const shortcut = (label: string) => ( + + {label} + + ); + return ( + { + if (!open) onClose(); + }} + > + +
{canvasSkillTags.length > 0 ? ( -
+

{canvasSkillTagsTitle}

@@ -16098,15 +16097,6 @@ export function CanvasChatPanel({ ) : null}
) : null} -
- - {canvasSkillTags.length > 0 ? : null} - - {onOpenFileList ? : null} -
)} diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index 1b13cc527..ea7ebfe40 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -12,8 +12,12 @@ import { UiScreenNode } from "./nodes/UiScreenNode"; import { VideoNode } from "./nodes/VideoNode"; import { WorkflowNode } from "./nodes/WorkflowNode"; import { CanvasToolbarIcon } from "./CanvasToolbarIcon"; +import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu"; +import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit"; +import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { toast } from "sonner"; import { imageGenerationService } from "@/services/canvas"; import { @@ -28,6 +32,7 @@ import { LayoutTemplate, LayoutGrid, Lightbulb, + Loader2, Minus, Pin, Plus, @@ -390,6 +395,11 @@ export function CanvasWorkspace({ } | null; const containerRef = useRef(null); const contentLayerRef = useRef(null); + const contextImageInputRef = useRef(null); + const contextVideoInputRef = useRef(null); + const contextCanvasPointRef = useRef({ x: 0, y: 0 }); + const [blankContextPosition, setBlankContextPosition] = useState(null); + const [isContextVideoUploading, setIsContextVideoUploading] = useState(false); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); const [isMarqueeSelecting, setIsMarqueeSelecting] = useState(false); @@ -727,10 +737,6 @@ export function CanvasWorkspace({ canvasToolbarConfig, "imageLayoutReplace" ); - // 局部编辑属于主无限画布图片的固定能力,旧配置缺少该 key 时也保持可见。 - const selectedImageLocalEditEnabled = true; - // 排版替换同样是主无限画布图片的核心 AI 入口,旧配置缺少该 key 时也保持可见。 - const selectedImageLayoutReplaceEnabled = true; // Store actions const selectNode = useCanvasStore((state) => state.selectNode); @@ -1106,42 +1112,61 @@ export function CanvasWorkspace({ ); const fitAllImageNodesIntoViewport = useCallback(() => { - const imageNodes = nodes.filter( - (node) => node.type === "image" && Boolean((node.data as any)?.src) + const fit = getCanvasFitViewport( + nodes.filter((node) => node.type === "image" && Boolean((node.data as any)?.src)), + containerSize, occlusionRight ); - if (imageNodes.length === 0) return; - if (containerSize.width <= 0 || containerSize.height <= 0) return; + if (fit) setViewport(fit); + }, [containerSize, nodes, occlusionRight, setViewport]); - const padding = 80; - const minX = Math.min(...imageNodes.map((node) => node.position.x)); - const minY = Math.min(...imageNodes.map((node) => node.position.y)); - const maxX = Math.max( - ...imageNodes.map((node) => node.position.x + node.size.width) - ); - const maxY = Math.max( - ...imageNodes.map((node) => node.position.y + node.size.height) - ); + const fitAllCanvasNodesIntoViewport = useCallback(() => { + const fit = getCanvasFitViewport(nodes, containerSize, occlusionRight); + if (fit) setViewport(fit); + }, [containerSize, nodes, occlusionRight, setViewport]); - const contentWidth = Math.max(1, maxX - minX + padding * 2); - const contentHeight = Math.max(1, maxY - minY + padding * 2); + const handleBlankCanvasContextMenu = useCallback((event: React.MouseEvent) => { + if (isWorkflowCanvas || hasBlockingDialogOpen() || canvasImageSelectionSession) return; + if (!(event.target instanceof Element) || event.target.closest( + '[data-canvas-node-id], [data-canvas-overlay], [data-canvas-no-drag="true"], button, input, textarea, select, a, [contenteditable="true"]' + )) return; + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + event.preventDefault(); + event.stopPropagation(); + contextCanvasPointRef.current = { + x: (event.clientX - rect.left - viewportRef.current.x) / viewportRef.current.zoom, + y: (event.clientY - rect.top - viewportRef.current.y) / viewportRef.current.zoom, + }; + setBlankContextPosition({ x: event.clientX, y: event.clientY }); + setAgentSlotContextMenu(null); + }, [canvasImageSelectionSession, hasBlockingDialogOpen, isWorkflowCanvas]); - const visibleWidth = Math.max(240, containerSize.width - Math.max(0, occlusionRight)); - const fitZoomX = visibleWidth / contentWidth; - const fitZoomY = containerSize.height / contentHeight; - const targetZoom = Math.max(0.05, Math.min(4, Math.min(fitZoomX, fitZoomY))); + const zoomCanvasFromContextMenu = useCallback((factor: number) => { + const current = useCanvasStore.getState().viewport; + const zoom = Math.max(0.1, Math.min(4, current.zoom * factor)); + const centerX = Math.max(1, containerSize.width - occlusionRight) / 2; + const centerY = containerSize.height / 2; + const ratio = zoom / current.zoom; + setViewport({ x: centerX - (centerX - current.x) * ratio, y: centerY - (centerY - current.y) * ratio, zoom }); + }, [containerSize, occlusionRight, setViewport]); - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - const visibleCenterX = visibleWidth / 2; - const nextViewportX = visibleCenterX - centerX * targetZoom; - const nextViewportY = containerSize.height / 2 - centerY * targetZoom; - - setViewport({ - x: nextViewportX, - y: nextViewportY, - zoom: targetZoom, - }); - }, [containerSize.height, containerSize.width, nodes, occlusionRight, setViewport]); + React.useEffect(() => { + if (isWorkflowCanvas) return; + const handleFitShortcut = (event: KeyboardEvent) => { + if (!event.shiftKey || event.metaKey || event.ctrlKey || event.altKey || event.code !== "Digit1") return; + if (hasBlockingDialogOpen() || (event.target instanceof Element && event.target.closest( + 'input, textarea, [contenteditable="true"], [role="textbox"]' + ))) return; + const active = document.activeElement; + if (active && active !== document.body && !containerRef.current?.contains(active) + && !active.closest('[data-canvas-floating-toolbar], [data-canvas-blank-menu="true"]')) return; + event.preventDefault(); + fitAllCanvasNodesIntoViewport(); + setBlankContextPosition(null); + }; + window.addEventListener("keydown", handleFitShortcut); + return () => window.removeEventListener("keydown", handleFitShortcut); + }, [fitAllCanvasNodesIntoViewport, hasBlockingDialogOpen, isWorkflowCanvas]); const getNodeInputAnchor = useCallback( (node: CanvasNode): Position => ({ @@ -1258,6 +1283,7 @@ export function CanvasWorkspace({ if (canvasImageSelectionSession) return; if (hasBlockingDialogOpen()) return; setAgentSlotContextMenu(null); + setBlankContextPosition(null); if (!isEventFromCanvasContainer(e.target)) return; if (suppressCanvasClickRef.current) { suppressCanvasClickRef.current = false; @@ -1294,7 +1320,7 @@ export function CanvasWorkspace({ !isPointOnNodeElement(e.target); if ( - tool === 'pan' || + (tool === 'pan' && e.button === 0) || e.button === 1 || (e.button === 0 && e.altKey) || shouldPanBlankWorkflowCanvas @@ -1657,6 +1683,105 @@ export function CanvasWorkspace({ } }, [addNode, clearLatestGeneratedImageHighlights, getImageDimensionsFromFile, updateNode, viewport.x, viewport.y, viewport.zoom]); + const getContextUploadClientPoint = useCallback(() => { + const rect = containerRef.current?.getBoundingClientRect(); + const current = viewportRef.current; + return { + x: (rect?.left ?? 0) + current.x + contextCanvasPointRef.current.x * current.zoom, + y: (rect?.top ?? 0) + current.y + contextCanvasPointRef.current.y * current.zoom, + }; + }, []); + + const pasteCanvasText = useCallback((text: string, point: Position) => { + if (!text.trim()) return; + addNode({ + type: "text", position: point, size: { width: 320, height: 160 }, + data: { content: text, color: "#111827" }, connections: [], + }); + }, [addNode]); + + const uploadContextVideos = useCallback(async (files: File[]) => { + if (!files.length || isContextVideoUploading) return; + const position = { ...contextCanvasPointRef.current }; + setIsContextVideoUploading(true); + try { + for (const [index, file] of files.entries()) { + if (!["video/mp4", "video/quicktime", "video/webm"].includes(file.type)) { + toast.error("请选择 MP4、MOV 或 WebM 视频"); + continue; + } + if (file.size > 50 * 1024 * 1024) { + toast.error("视频文件不能超过 50MB"); + continue; + } + const loadingToast = toast.loading(`正在上传 ${file.name}`); + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("purpose", "canvas-video-process"); + const response = await fetchUploadWithRetry(formData); + const result = await response.json(); + if (!response.ok || !result.url) throw new Error(result.error || "视频上传失败"); + addNode({ + type: "video", position: { x: position.x + index * 24, y: position.y + index * 24 }, + size: { width: 320, height: 180 }, + data: { src: result.url, assetId: result.assetId, status: "completed", prompt: file.name, + metadata: { source: "local-upload", fileName: file.name, contentType: file.type } }, + connections: [], + }); + toast.success("视频已添加到画布"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "视频上传失败,请重试"); + } finally { + toast.dismiss(loadingToast); + } + } + } finally { + setIsContextVideoUploading(false); + } + }, [addNode, isContextVideoUploading]); + + const pasteFromContextMenu = useCallback(async () => { + const pastePoint = { ...contextCanvasPointRef.current }; + const clientPoint = getContextUploadClientPoint(); + try { + const files: File[] = []; + let text = ""; + if (typeof navigator.clipboard?.read === "function") { + const items = await navigator.clipboard.read(); + for (const [index, item] of items.entries()) { + const imageType = item.types.find((type) => type.startsWith("image/")); + if (imageType) { + const blob = await item.getType(imageType); + files.push(new File([blob], `粘贴图片-${Date.now()}-${index}.${imageType.split("/")[1]}`, { type: imageType })); + } else if (item.types.includes("text/plain")) { + text += await (await item.getType("text/plain")).text(); + } + } + } else if (typeof navigator.clipboard?.readText === "function") { + text = await navigator.clipboard.readText(); + } else { + throw new Error("Clipboard unavailable"); + } + if (files.length) { + await processDroppedFiles(files, clientPoint.x, clientPoint.y); + } else if (text.trim()) { + pasteCanvasText(text, pastePoint); + } else { + toast.info("剪贴板中没有可粘贴的图片或文字"); + } + } catch { + toast.info(`无法读取剪贴板,请使用 ${isMacPlatform ? "⌘V" : "Ctrl+V"} 粘贴`); + } + }, [getContextUploadClientPoint, isMacPlatform, pasteCanvasText, processDroppedFiles]); + + React.useEffect(() => { + if (!blankContextPosition) return; + const close = () => setBlankContextPosition(null); + window.addEventListener("resize", close); + return () => window.removeEventListener("resize", close); + }, [blankContextPosition]); + React.useEffect(() => { const handleWindowDrop = async (e: DragEvent) => { if (!isFileDrag(e.dataTransfer)) return; @@ -1719,24 +1844,44 @@ export function CanvasWorkspace({ React.useEffect(() => { const handlePaste = async (e: ClipboardEvent) => { - if (hasBlockingDialogOpen() || isEditablePasteTarget(e.target)) return; + const activeElement = document.activeElement; + const fromContextMenu = Boolean(activeElement?.closest('[data-canvas-blank-menu="true"]')); + if (hasBlockingDialogOpen() || (!fromContextMenu && isEditablePasteTarget(e.target))) return; const container = containerRef.current; if (!container) return; - const activeElement = document.activeElement; if ( activeElement instanceof HTMLElement && activeElement !== document.body && - !container.contains(activeElement) + !container.contains(activeElement) && !fromContextMenu ) { return; } const files = extractImageFilesFromDataTransfer(e.clipboardData); - if (files.length === 0) return; + const text = e.clipboardData?.getData("text/plain") || ""; + if (files.length === 0 && (isWorkflowCanvas || !text.trim())) return; e.preventDefault(); const rect = container.getBoundingClientRect(); + if (fromContextMenu) { + setBlankContextPosition(null); + if (files.length) { + const point = getContextUploadClientPoint(); + await processDroppedFiles(files, point.x, point.y); + } else { + pasteCanvasText(text, { ...contextCanvasPointRef.current }); + } + return; + } + if (files.length === 0) { + const current = viewportRef.current; + pasteCanvasText(text, { + x: (rect.width / 2 - current.x) / current.zoom, + y: (rect.height / 2 - current.y) / current.zoom, + }); + return; + } await processDroppedFiles( files, rect.left + rect.width / 2, @@ -1746,7 +1891,7 @@ export function CanvasWorkspace({ window.addEventListener("paste", handlePaste); return () => window.removeEventListener("paste", handlePaste); - }, [hasBlockingDialogOpen, processDroppedFiles]); + }, [getContextUploadClientPoint, hasBlockingDialogOpen, isWorkflowCanvas, pasteCanvasText, processDroppedFiles]); const handleDownloadSelectedImages = useCallback(async () => { if (selectedImageNodes.length === 0) { @@ -2269,12 +2414,47 @@ export function CanvasWorkspace({ onMouseUp={handleMouseUp} onMouseLeave={handleMouseUp} onDoubleClick={handleBlankCanvasDoubleClick} + onContextMenu={handleBlankCanvasContextMenu} onWheel={handleWheel} onDragEnter={handleDragEnter} onDragLeave={handleDragLeave} onDragOver={handleDragOver} onDrop={handleDrop} > + {!isWorkflowCanvas && ( + <> + { + const files = Array.from(event.target.files || []); + event.target.value = ""; + const point = getContextUploadClientPoint(); + void processDroppedFiles(files, point.x, point.y); + }} + /> + { + const files = Array.from(event.target.files || []); + event.target.value = ""; + void uploadContextVideos(files); + }} + /> + setBlankContextPosition(null)} + onUploadImage={() => contextImageInputRef.current?.click()} + onUploadVideo={() => contextVideoInputRef.current?.click()} + onPaste={() => { void pasteFromContextMenu(); }} + onZoomIn={() => zoomCanvasFromContextMenu(1.2)} + onZoomOut={() => zoomCanvasFromContextMenu(1 / 1.2)} + onFitAll={fitAllCanvasNodesIntoViewport} + onResetZoom={() => setViewport({ x: 0, y: 0, zoom: 1 })} + /> + + )} {isWorkflowCanvas ? ( <>
@@ -2566,10 +2746,14 @@ export function CanvasWorkspace({ )} {!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
- {selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? ( + {isWorkflowCanvas && selectedImageNodes.length === 1 ? ( + + + + + + {isDownloadSubmitting + ? "打包中..." + : `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`} + +
)} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 2504dd72d..e220ad129 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -9646,6 +9646,8 @@ export function ImageNode({ className="mr-2 h-4 w-4" fallback={LayoutTemplate} item={imageLayoutReplaceItem} + referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined} + referenceSize={18} /> {imageLayoutReplaceItem.label} @@ -10431,6 +10433,8 @@ export function ImageNode({ className="mr-1.5 h-4 w-4" fallback={LayoutTemplate} item={imageLayoutReplaceItem} + referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined} + referenceSize={18} /> {imageLayoutReplaceItem.label} {imageLayoutReplaceItem.label} diff --git a/components/home/lovart-home.tsx b/components/home/lovart-home.tsx index 03f0e7b22..b52d69ded 100644 --- a/components/home/lovart-home.tsx +++ b/components/home/lovart-home.tsx @@ -1637,18 +1637,30 @@ export function LovartHome({ ) => { const item = getLovartSidebarItem(key); const FallbackIcon = LOVART_HOME_SIDEBAR_FALLBACK_ICONS[key]; - const sizeClassName = options?.sizeClassName || "h-[18px] w-[18px]"; + const sizeClassName = options?.sizeClassName || "h-[20px] w-[20px]"; - const referenceIcons: Partial> = { + const referenceIcons: Record = { newProject: "plus", home: "home", projects: "folder", + profile: "profile", gallery: "brand", + tutorial: "tutorial", + imageEditor: "image-local-edit", + aiCad: "model-auto", + model3d: "model-auto", + scene: "layers", agent: "skill", + videoCanvas: "video", + flowBoard: "connections", + productPlanning: "page", + toolbox1: "toolbox", + toolbox2: "toolbox", + settings: "settings", }; const reference = referenceIcons[key]; if (reference && !item.iconUrl) { - return ; + return ; } if (options?.filled) { diff --git a/components/home/lovart/lovart-sidebar.tsx b/components/home/lovart/lovart-sidebar.tsx index c9183df7f..a637a14e3 100644 --- a/components/home/lovart/lovart-sidebar.tsx +++ b/components/home/lovart/lovart-sidebar.tsx @@ -114,7 +114,7 @@ export function LovartSidebar({ > {renderIcon(key, { active: options?.active, - sizeClassName: "h-4 w-4", + sizeClassName: "h-[20px] w-[20px]", })} @@ -135,7 +135,7 @@ export function LovartSidebar({ className={sidebarButtonClass()} title={getItem(key).label} > - {renderIcon(key, { sizeClassName: "h-4 w-4" })} + {renderIcon(key, { sizeClassName: "h-[20px] w-[20px]" })} {renderIcon("newProject", { - sizeClassName: "h-4 w-4", + sizeClassName: "h-[20px] w-[20px]", })} @@ -230,7 +230,7 @@ export function LovartSidebar({ className={sidebarButtonClass()} title={getItem("tutorial").label} > - {renderIcon("tutorial", { sizeClassName: "h-4 w-4" })} + {renderIcon("tutorial", { sizeClassName: "h-[20px] w-[20px]" })} ) : null} {tooltipButton("imageEditor", onImageEditor)} - {tooltipButton("aiCad", () => onRoute("/ai-cad"))} - {tooltipButton("model3d", () => onRoute("/model3d"))} - {tooltipButton("scene", () => onRoute("/pascal-editor"))} {agentEnabled ? tooltipButton("agent", () => onRoute("/canvas?mode=agent")) : null} {videoCanvasEnabled ? tooltipButton("videoCanvas", () => onRoute("/video-canvas")) : null} {tooltipButton("flowBoard", () => onRoute("/flow-board"))} diff --git a/components/lovart/reference-icon.tsx b/components/lovart/reference-icon.tsx index 79c4339ea..8aba3e27b 100644 --- a/components/lovart/reference-icon.tsx +++ b/components/lovart/reference-icon.tsx @@ -17,6 +17,16 @@ export type ReferenceIconName = | "connections" | "clipper" | "cursor" + | "pan" + | "text" + | "sparkles" + | "style-controls" + | "image-enhance" + | "tutorial" + | "video" + | "toolbox" + | "settings" + | "profile" | "comment" | "draw" | "image" @@ -42,6 +52,7 @@ export type ReferenceIconName = | "model-ideogram" | "model-recraft" | "image-local-edit" + | "image-layout-replace" | "image-mark" | "image-upscale" | "image-remove-background" diff --git a/lib/canvas/viewport-fit.ts b/lib/canvas/viewport-fit.ts new file mode 100644 index 000000000..c88de0b3d --- /dev/null +++ b/lib/canvas/viewport-fit.ts @@ -0,0 +1,41 @@ +import type { CanvasNode, Viewport } from "@/types/canvas"; + +export function getCanvasFitViewport( + nodes: Pick[], + container: { width: number; height: number }, + occlusionRight = 0 +): Viewport | null { + const visibleWidth = container.width - Math.max(0, occlusionRight); + if (visibleWidth <= 0 || container.height <= 0) return null; + const validNodes = nodes.filter( + (node) => + [ + node.position.x, + node.position.y, + node.size.width, + node.size.height, + ].every(Number.isFinite) && + node.size.width > 0 && + node.size.height > 0 + ); + if (!validNodes.length) return null; + const minX = Math.min(...validNodes.map((node) => node.position.x)); + const minY = Math.min(...validNodes.map((node) => node.position.y)); + const maxX = Math.max( + ...validNodes.map((node) => node.position.x + node.size.width) + ); + const maxY = Math.max( + ...validNodes.map((node) => node.position.y + node.size.height) + ); + const padding = Math.min(80, visibleWidth / 4, container.height / 4); + const zoom = Math.min( + 4, + visibleWidth / (maxX - minX + 2 * padding), + container.height / (maxY - minY + 2 * padding) + ); + return { + x: visibleWidth / 2 - ((minX + maxX) / 2) * zoom, + y: container.height / 2 - ((minY + maxY) / 2) * zoom, + zoom, + }; +} diff --git a/public/icons/lovart/README.md b/public/icons/lovart/README.md index 09af7de16..ff372a5a7 100644 --- a/public/icons/lovart/README.md +++ b/public/icons/lovart/README.md @@ -10,3 +10,7 @@ The model families are GPT/OpenAI, Nano Banana, Seedream, Flux, Luma, Midjourney Selected-image toolbar and overflow-menu shapes were captured from the same reference with a temporary test image: annotation, HD upscale, background removal, eraser, layer extraction, text editing, angle editing, motion, more, download, object move, mockup, outpaint, adjustment, crop, vector and flip/rotate. The toolbar keeps the existing configured actions and custom icon overrides. The local editing icon is an application-neutral selection/brush drawing rather than the reference service’s branded quick-edit logo. Toolbar customization uses local icons and stores only tool order, pinned tools and label visibility in the browser. + +Application-specific actions use local neutral icons adapted from the existing Lucide geometry: pan, text, sparkle, tutorial, video, toolbox, profile, settings and layout replacement. Their 1.5-unit strokes match the reference icon set. Home navigation and the main image-canvas toolbar render at 20px, including configured icon overrides in the home navigation. + +The style controls and image enhancement actions use local slider and framed-landscape drawings in place of sparkle symbols, with the same 24-unit viewBox, 1.5-unit strokes and 20px toolbar size. diff --git a/public/icons/lovart/image-enhance.svg b/public/icons/lovart/image-enhance.svg new file mode 100644 index 000000000..667802353 --- /dev/null +++ b/public/icons/lovart/image-enhance.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons/lovart/image-layout-replace.svg b/public/icons/lovart/image-layout-replace.svg new file mode 100644 index 000000000..19eda0087 --- /dev/null +++ b/public/icons/lovart/image-layout-replace.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/icons/lovart/pan.svg b/public/icons/lovart/pan.svg new file mode 100644 index 000000000..35731565a --- /dev/null +++ b/public/icons/lovart/pan.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/icons/lovart/profile.svg b/public/icons/lovart/profile.svg new file mode 100644 index 000000000..da3d4a962 --- /dev/null +++ b/public/icons/lovart/profile.svg @@ -0,0 +1 @@ + diff --git a/public/icons/lovart/settings.svg b/public/icons/lovart/settings.svg new file mode 100644 index 000000000..cb39b76cb --- /dev/null +++ b/public/icons/lovart/settings.svg @@ -0,0 +1 @@ + diff --git a/public/icons/lovart/sparkles.svg b/public/icons/lovart/sparkles.svg new file mode 100644 index 000000000..85cd73883 --- /dev/null +++ b/public/icons/lovart/sparkles.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/icons/lovart/style-controls.svg b/public/icons/lovart/style-controls.svg new file mode 100644 index 000000000..8c9530cc3 --- /dev/null +++ b/public/icons/lovart/style-controls.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/icons/lovart/text.svg b/public/icons/lovart/text.svg new file mode 100644 index 000000000..2347cf1ea --- /dev/null +++ b/public/icons/lovart/text.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/lovart/toolbox.svg b/public/icons/lovart/toolbox.svg new file mode 100644 index 000000000..57fd27578 --- /dev/null +++ b/public/icons/lovart/toolbox.svg @@ -0,0 +1 @@ + diff --git a/public/icons/lovart/tutorial.svg b/public/icons/lovart/tutorial.svg new file mode 100644 index 000000000..5938a7bbf --- /dev/null +++ b/public/icons/lovart/tutorial.svg @@ -0,0 +1 @@ + diff --git a/public/icons/lovart/video.svg b/public/icons/lovart/video.svg new file mode 100644 index 000000000..348299d17 --- /dev/null +++ b/public/icons/lovart/video.svg @@ -0,0 +1 @@ +