fix: polish canvas icons, context menu and panel layout
@ -10254,7 +10254,10 @@ export default function CanvasEditorLegacyPage({
|
||||
<TooltipProvider>
|
||||
<div
|
||||
data-canvas-panel-open={isPanelOpen ? "true" : "false"}
|
||||
className={cn("relative h-dvh w-full overflow-hidden overscroll-none", !isVideoMode && "lovart-canvas-surface")}
|
||||
className={cn(
|
||||
"h-dvh w-full overflow-hidden overscroll-none",
|
||||
isVideoMode ? "relative" : "lovart-canvas-surface fixed inset-0"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--canvas-surface-background": canvasSurfaceBackground,
|
||||
@ -11225,6 +11228,8 @@ export default function CanvasEditorLegacyPage({
|
||||
active={tool === "pan"}
|
||||
className="h-4 w-4"
|
||||
fallback={Hand}
|
||||
referenceName={isVideoMode ? undefined : "pan"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "panTool")}
|
||||
/>
|
||||
</Button>
|
||||
@ -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({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
referenceName={isVideoMode ? undefined : "style-controls"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"stylePro"
|
||||
@ -11461,7 +11476,7 @@ export default function CanvasEditorLegacyPage({
|
||||
className="h-4 w-4"
|
||||
fallback={Layers}
|
||||
referenceName={isVideoMode ? undefined : "layers"}
|
||||
referenceSize={18}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
"batchProcessing"
|
||||
@ -11494,7 +11509,7 @@ export default function CanvasEditorLegacyPage({
|
||||
className="h-4 w-4"
|
||||
fallback={ImagePlus}
|
||||
referenceName={isVideoMode ? undefined : "image"}
|
||||
referenceSize={18}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
isVideoMode ? "addImageInput" : "addImage"
|
||||
@ -11527,6 +11542,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Type}
|
||||
referenceName={isVideoMode ? undefined : "text"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(
|
||||
canvasToolbarConfig,
|
||||
isVideoMode ? "addScriptNode" : "addText"
|
||||
@ -11684,6 +11701,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={PenTool}
|
||||
referenceName={isVideoMode ? undefined : "draw"}
|
||||
referenceSize={20}
|
||||
item={resolveCanvasToolbarItemWithFallback(
|
||||
canvasToolbarConfig,
|
||||
"realtimeCanvas",
|
||||
@ -11731,6 +11750,8 @@ export default function CanvasEditorLegacyPage({
|
||||
<CanvasToolbarIcon
|
||||
className="h-4 w-4"
|
||||
fallback={Sparkles}
|
||||
referenceName={isVideoMode ? undefined : "image-enhance"}
|
||||
referenceSize={20}
|
||||
item={resolveCanvasToolbarItemWithFallback(
|
||||
canvasToolbarConfig,
|
||||
"enhanceCanvas",
|
||||
@ -11785,7 +11806,7 @@ export default function CanvasEditorLegacyPage({
|
||||
className="h-4 w-4"
|
||||
fallback={Box}
|
||||
referenceName={isVideoMode ? undefined : "model-auto"}
|
||||
referenceSize={18}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "model3d")}
|
||||
/>
|
||||
</Button>
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
115
components/canvas/CanvasBlankContextMenu.tsx
Normal file
@ -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) => (
|
||||
<span className="ml-2 whitespace-nowrap text-[13px] text-[#aaa]">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={position !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
data-canvas-no-drag="true"
|
||||
className="pointer-events-none fixed h-px w-px opacity-0"
|
||||
style={{ left: position?.x ?? 0, top: position?.y ?? 0 }}
|
||||
type="button"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
aria-label="画布右键菜单"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
collisionPadding={8}
|
||||
data-canvas-blank-menu="true"
|
||||
data-canvas-no-drag="true"
|
||||
className="z-[10005] w-[200px] rounded-[12px] border-[#ededed] bg-white px-3 py-[5px] text-[#242424] shadow-[0_8px_20px_rgba(0,0,0,0.06)] ring-0 dark:border-white/10 dark:bg-zinc-900 dark:text-white"
|
||||
style={{
|
||||
fontFamily:
|
||||
'Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
|
||||
}}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem className={itemClass} onSelect={onUploadImage}>
|
||||
上传图片
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className={itemClass}
|
||||
disabled={videoUploading}
|
||||
onSelect={onUploadVideo}
|
||||
>
|
||||
上传视频
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
|
||||
<DropdownMenuItem className={itemClass} onSelect={onPaste}>
|
||||
粘贴{shortcut(`${modifier}V`)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="mx-0 my-[6.5px] bg-[#ededed] dark:bg-white/10" />
|
||||
<DropdownMenuItem className={itemClass} onSelect={onZoomIn}>
|
||||
放大{shortcut(`${modifier}+`)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={onZoomOut}>
|
||||
缩小{shortcut(`${modifier}-`)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={onFitAll}>
|
||||
显示画布所有元素{shortcut(isMac ? "⇧1" : "Shift+1")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={itemClass} onSelect={onResetZoom}>
|
||||
缩放至100%{shortcut(`${modifier}0`)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@ -3325,7 +3325,6 @@ export function CanvasChatPanel({
|
||||
const [isComposerModeMenuOpen, setIsComposerModeMenuOpen] = useState(false);
|
||||
const [selectedSkillTagId, setSelectedSkillTagId] = useState<string | null>(null);
|
||||
const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false);
|
||||
const canvasSkillPickerRef = useRef<HTMLDivElement>(null);
|
||||
const [canvasSkillTagSearchQuery, setCanvasSkillTagSearchQuery] = useState("");
|
||||
const [selectedCanvasSkillCategoryId, setSelectedCanvasSkillCategoryId] =
|
||||
useState("all");
|
||||
@ -15900,7 +15899,7 @@ export function CanvasChatPanel({
|
||||
</div>
|
||||
|
||||
{canvasSkillTags.length > 0 ? (
|
||||
<div ref={canvasSkillPickerRef} data-canvas-skill-picker="true" className="mt-6 w-full shrink-0 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
|
||||
<div data-canvas-skill-picker="true" className="mt-6 w-full shrink-0 rounded-[16px] border border-[#e4e7ec] bg-white px-4 py-4 text-center dark:border-white/10 dark:bg-zinc-950/70">
|
||||
<h3 className="text-[14px] font-semibold leading-none text-[#1f2328] dark:text-zinc-100">
|
||||
{canvasSkillTagsTitle}
|
||||
</h3>
|
||||
@ -16098,15 +16097,6 @@ export function CanvasChatPanel({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-5 grid w-full shrink-0 grid-cols-2 gap-2">
|
||||
<button type="button" className="canvas-welcome-action" onClick={() => fileInputRef.current?.click()}><ReferenceIcon name="reference" size={16} className="text-[#a187ff]" />上传本地参考</button>
|
||||
{canvasSkillTags.length > 0 ? <button type="button" className="canvas-welcome-action" onClick={() => {
|
||||
canvasSkillPickerRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
canvasSkillPickerRef.current?.querySelector("input")?.focus({ preventScroll: true });
|
||||
}}><ReferenceIcon name="skill-filled" size={16} className="text-[#657dff]" />选择 Skill 开始创作</button> : null}
|
||||
<button type="button" className="canvas-welcome-action" onClick={() => fileInputRef.current?.click()}><ReferenceIcon name="brand-filled" size={16} className="text-[#ee9b38]" />上传品牌素材</button>
|
||||
{onOpenFileList ? <button type="button" className="canvas-welcome-action" onClick={onOpenFileList}><ReferenceIcon name="brand" size={16} className="text-[#727272]" />查看画布素材</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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<HTMLDivElement>(null);
|
||||
const contentLayerRef = useRef<HTMLDivElement>(null);
|
||||
const contextImageInputRef = useRef<HTMLInputElement>(null);
|
||||
const contextVideoInputRef = useRef<HTMLInputElement>(null);
|
||||
const contextCanvasPointRef = useRef<Position>({ x: 0, y: 0 });
|
||||
const [blankContextPosition, setBlankContextPosition] = useState<Position | null>(null);
|
||||
const [isContextVideoUploading, setIsContextVideoUploading] = useState(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const [panStart, setPanStart] = useState<Position>({ 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 && (
|
||||
<>
|
||||
<input
|
||||
ref={contextImageInputRef} type="file" accept="image/*" multiple className="hidden"
|
||||
aria-label="从右键菜单上传图片"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
const point = getContextUploadClientPoint();
|
||||
void processDroppedFiles(files, point.x, point.y);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={contextVideoInputRef} type="file" accept="video/mp4,video/quicktime,video/webm,.mp4,.mov,.webm" multiple className="hidden"
|
||||
aria-label="从右键菜单上传视频"
|
||||
onChange={(event) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
event.target.value = "";
|
||||
void uploadContextVideos(files);
|
||||
}}
|
||||
/>
|
||||
<CanvasBlankContextMenu
|
||||
position={blankContextPosition} isMac={isMacPlatform} videoUploading={isContextVideoUploading}
|
||||
onClose={() => 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 ? (
|
||||
<>
|
||||
<div className="pointer-events-none absolute inset-0 pattern-grid-lg text-white opacity-[0.065]" />
|
||||
@ -2566,10 +2746,14 @@ export function CanvasWorkspace({
|
||||
)}
|
||||
{!canvasImageSelectionSession && selectedImageNodes.length > 0 && (
|
||||
<div
|
||||
className="absolute bottom-16 left-4 z-30 flex items-center gap-2"
|
||||
className={cn(
|
||||
"absolute left-4 z-30 flex items-center gap-2",
|
||||
isWorkflowCanvas ? "bottom-16" : "bottom-[20px]"
|
||||
)}
|
||||
data-canvas-overlay
|
||||
data-canvas-selection-actions="true"
|
||||
>
|
||||
{selectedImageLocalEditEnabled && selectedImageNodes.length === 1 ? (
|
||||
{isWorkflowCanvas && selectedImageNodes.length === 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
@ -2591,7 +2775,7 @@ export function CanvasWorkspace({
|
||||
{selectedImageLocalEditItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedImageLayoutReplaceEnabled &&
|
||||
{isWorkflowCanvas &&
|
||||
selectedImageNodes.length >= 1 ? (
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
@ -2620,27 +2804,48 @@ export function CanvasWorkspace({
|
||||
{selectedImageLayoutReplaceItem.label}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"h-8 gap-1.5 px-3 text-xs shadow-sm border border-border/60",
|
||||
isWorkflowCanvas &&
|
||||
"rounded-full border-[#dbe2ee] bg-white text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||
)}
|
||||
onClick={handleDownloadSelectedImages}
|
||||
disabled={isDownloadSubmitting}
|
||||
title={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
className="h-3.5 w-3.5"
|
||||
fallback={Download}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||
/>
|
||||
{isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isWorkflowCanvas ? "outline" : "secondary"}
|
||||
size={isWorkflowCanvas ? "sm" : "icon"}
|
||||
className={cn(
|
||||
isWorkflowCanvas
|
||||
? "h-8 gap-1.5 rounded-full border border-[#dbe2ee] bg-white px-3 text-xs text-[#3b4860] shadow-[0_0_0_1px_rgba(219,226,238,0.26)] hover:bg-[#f7f9ff]"
|
||||
: "h-[32px] w-[32px] rounded-[8px] border border-[#e9e9e9] bg-white p-0 text-[#333333] shadow-[0_1px_3px_rgba(0,0,0,0.035)] hover:bg-[#f5f5f5] dark:border-white/10 dark:bg-zinc-950 dark:text-white dark:hover:bg-white/10"
|
||||
)}
|
||||
onClick={handleDownloadSelectedImages}
|
||||
disabled={isDownloadSubmitting}
|
||||
aria-label={
|
||||
isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
||||
}
|
||||
>
|
||||
{!isWorkflowCanvas && isDownloadSubmitting ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<CanvasToolbarIcon
|
||||
className={isWorkflowCanvas ? "h-3.5 w-3.5" : "h-5 w-5"}
|
||||
fallback={Download}
|
||||
referenceName={isWorkflowCanvas ? undefined : "image-download"}
|
||||
referenceSize={20}
|
||||
item={getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder")}
|
||||
/>
|
||||
)}
|
||||
{isWorkflowCanvas
|
||||
? isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`
|
||||
: null}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{isDownloadSubmitting
|
||||
? "打包中..."
|
||||
: `${getCanvasToolbarItem(canvasToolbarConfig, "downloadFolder").label}(${selectedImageNodes.length})`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
<span>{imageLayoutReplaceItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
@ -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}
|
||||
<ImageActionCreditBadge
|
||||
@ -10756,6 +10760,8 @@ export function ImageNode({
|
||||
className="mr-2 h-4 w-4"
|
||||
fallback={LayoutTemplate}
|
||||
item={imageLayoutReplaceItem}
|
||||
referenceName={useReferenceImageToolbar ? "image-layout-replace" : undefined}
|
||||
referenceSize={18}
|
||||
/>
|
||||
<span>{imageLayoutReplaceItem.label}</span>
|
||||
<ImageActionCreditBadge label={localEditCostShortLabel} />
|
||||
|
||||
@ -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<Record<LovartHomeSidebarItemKey, ReferenceIconName>> = {
|
||||
const referenceIcons: Record<LovartHomeSidebarItemKey, ReferenceIconName> = {
|
||||
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 <ReferenceIcon name={reference} size={key === "newProject" ? 18 : 20} className={options?.filled ? "text-white" : undefined} />;
|
||||
return <ReferenceIcon name={reference} size={20} className={options?.filled ? "text-white" : undefined} />;
|
||||
}
|
||||
|
||||
if (options?.filled) {
|
||||
|
||||
@ -114,7 +114,7 @@ export function LovartSidebar({
|
||||
>
|
||||
{renderIcon(key, {
|
||||
active: options?.active,
|
||||
sizeClassName: "h-4 w-4",
|
||||
sizeClassName: "h-[20px] w-[20px]",
|
||||
})}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@ -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]" })}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
@ -210,7 +210,7 @@ export function LovartSidebar({
|
||||
title={getItem("newProject").label}
|
||||
>
|
||||
{renderIcon("newProject", {
|
||||
sizeClassName: "h-4 w-4",
|
||||
sizeClassName: "h-[20px] w-[20px]",
|
||||
})}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@ -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]" })}
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
@ -283,9 +283,6 @@ export function LovartSidebar({
|
||||
</HoverCard>
|
||||
) : 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"))}
|
||||
|
||||
@ -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"
|
||||
|
||||
41
lib/canvas/viewport-fit.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import type { CanvasNode, Viewport } from "@/types/canvas";
|
||||
|
||||
export function getCanvasFitViewport(
|
||||
nodes: Pick<CanvasNode, "position" | "size">[],
|
||||
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,
|
||||
};
|
||||
}
|
||||
@ -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.
|
||||
|
||||
7
public/icons/lovart/image-enhance.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3m13-5h3a2 2 0 0 1 2 2v3M3 16v3a2 2 0 0 0 2 2h3m8 0h3a2 2 0 0 0 2-2v-3"/>
|
||||
<path d="m6 16 4-4 3 3 2-2 3 3"/>
|
||||
<circle cx="15.5" cy="8.5" r="1.5"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 405 B |
7
public/icons/lovart/image-layout-replace.svg
Normal file
@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9">
|
||||
<rect x="3" y="3" width="18" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="9" height="7" rx="1"/>
|
||||
<rect x="16" y="14" width="5" height="7" rx="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 378 B |
8
public/icons/lovart/pan.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9">
|
||||
<path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/>
|
||||
<path d="M14 10V4a2 2 0 0 0-4 0v2"/>
|
||||
<path d="M10 10.5V6a2 2 0 0 0-4 0v8"/>
|
||||
<path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 469 B |
1
public/icons/lovart/profile.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
||||
|
After Width: | Height: | Size: 301 B |
1
public/icons/lovart/settings.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path><circle cx="12" cy="12" r="3"></circle></svg>
|
||||
|
After Width: | Height: | Size: 826 B |
6
public/icons/lovart/sparkles.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9">
|
||||
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
|
||||
<path d="M20 3v4M22 5h-4M4 17v2M5 18H3"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 532 B |
8
public/icons/lovart/style-controls.svg
Normal file
@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9">
|
||||
<path d="M3 5h4m4 0h10M3 12h10m4 0h4M3 19h4m4 0h10"/>
|
||||
<rect x="7" y="2.5" width="4" height="5" rx="1"/>
|
||||
<rect x="13" y="9.5" width="4" height="5" rx="1"/>
|
||||
<rect x="7" y="16.5" width="4" height="5" rx="1"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 440 B |
5
public/icons/lovart/text.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9">
|
||||
<path d="M4 7V4h16v3M12 4v16M9 20h6"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
1
public/icons/lovart/toolbox.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"><path d="M16 20V4a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"></path><rect width="20" height="14" x="2" y="6" rx="2"></rect></svg>
|
||||
|
After Width: | Height: | Size: 319 B |
1
public/icons/lovart/tutorial.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"><circle cx="12" cy="12" r="10"></circle><polygon points="10 8 16 12 10 16 10 8"></polygon></svg>
|
||||
|
After Width: | Height: | Size: 294 B |
1
public/icons/lovart/video.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"><path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z"></path><path d="m6.2 5.3 3.1 3.9"></path><path d="m12.4 3.4 3.1 4"></path><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 419 B |