mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-07 01:54:24 +08:00
feat(canvas): enhance canvas toolbar for mode switching and improve selection box behavior
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Segmented, Switch } from "antd";
|
||||
import { CircleDot, Eraser, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
||||
import { CircleDot, Eraser, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, MousePointer2, Music2, Palette, Puzzle, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { getNodePluginId, listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
export function CanvasToolbar({
|
||||
selectedCount,
|
||||
canvasTool,
|
||||
canUndo,
|
||||
canRedo,
|
||||
backgroundMode,
|
||||
@@ -27,11 +28,12 @@ export function CanvasToolbar({
|
||||
onUpload,
|
||||
onDelete,
|
||||
onClear,
|
||||
onDeselect,
|
||||
onCanvasToolChange,
|
||||
onBackgroundModeChange,
|
||||
onShowImageInfoChange,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
canvasTool: "select" | "pan";
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
backgroundMode: CanvasBackgroundMode;
|
||||
@@ -48,7 +50,7 @@ export function CanvasToolbar({
|
||||
onUpload: () => void;
|
||||
onDelete: () => void;
|
||||
onClear: () => void;
|
||||
onDeselect: () => void;
|
||||
onCanvasToolChange: (tool: "select" | "pan") => void;
|
||||
onBackgroundModeChange: (mode: CanvasBackgroundMode) => void;
|
||||
onShowImageInfoChange: (show: boolean) => void;
|
||||
}) {
|
||||
@@ -89,8 +91,8 @@ export function CanvasToolbar({
|
||||
<div ref={rootRef} className="pointer-events-none absolute bottom-5 z-50 flex justify-center" style={{ left: 300, right: 16 }}>
|
||||
{tip ? <DockTip label={tip} x={tipX} theme={theme} /> : null}
|
||||
<div ref={wrapRef} className="thin-scrollbar pointer-events-auto flex h-14 max-w-full items-center gap-1 overflow-x-auto rounded-xl border px-2 shadow-lg backdrop-blur [&>*]:shrink-0" style={dockStyle}>
|
||||
<ToolbarButton id="tool-hand" label={t("canvas.toolbar.move")} active={!selectedCount} hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onDeselect}>
|
||||
<Hand className="size-4.5" />
|
||||
<ToolbarButton id={`tool-${canvasTool}`} label={t(`canvas.toolbar.${canvasTool}`)} active hovered={hovered} activeStyle={activeStyle} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={() => onCanvasToolChange(canvasTool === "select" ? "pan" : "select")}>
|
||||
{canvasTool === "select" ? <MousePointer2 className="size-4.5" /> : <Hand className="size-4.5" />}
|
||||
</ToolbarButton>
|
||||
<ToolbarButton id="tool-undo" label={t("canvas.undo")} disabled={!canUndo} hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUndo}>
|
||||
<Undo2 className="size-4.5" />
|
||||
@@ -350,7 +352,8 @@ function DockTip({ label, x, theme }: { label: string; x: number; theme: CanvasT
|
||||
}
|
||||
|
||||
function toolLabel(id: string, t: (key: string) => string) {
|
||||
if (id === "tool-hand") return t("canvas.toolbar.move");
|
||||
if (id === "tool-select") return t("canvas.toolbar.select");
|
||||
if (id === "tool-pan") return t("canvas.toolbar.pan");
|
||||
if (id === "tool-undo") return t("canvas.undo");
|
||||
if (id === "tool-redo") return t("canvas.redo");
|
||||
if (id === "tool-text") return t("canvas.toolbar.text");
|
||||
|
||||
@@ -154,11 +154,11 @@ export function CanvasTopBar({
|
||||
</div>
|
||||
<Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
|
||||
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
|
||||
<Shortcut keys={[t("canvas.shortcut.dragCanvas")]} value={t("canvas.shortcut.pan")} />
|
||||
<Shortcut keys={["Ctrl / Space", t("canvas.shortcut.drag")]} value={t("canvas.shortcut.pan")} />
|
||||
<Shortcut keys={[t("canvas.shortcut.wheel")]} value={t("canvas.shortcut.zoom")} />
|
||||
<Shortcut keys={[t("canvas.shortcut.zoomSlider")]} value={t("canvas.shortcut.preciseZoom")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", t("canvas.shortcut.drag")]} value={t("canvas.shortcut.boxSelect")} />
|
||||
<Shortcut keys={["Shift / Ctrl / Cmd", t("canvas.shortcut.click")]} value={t("canvas.shortcut.addSelection")} />
|
||||
<Shortcut keys={[t("canvas.shortcut.drag")]} value={t("canvas.shortcut.boxSelect")} />
|
||||
<Shortcut keys={["Shift / Cmd", t("canvas.shortcut.click")]} value={t("canvas.shortcut.addSelection")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", "A"]} value={t("canvas.shortcut.selectAll")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value={t("canvas.shortcut.copyPaste")} />
|
||||
<Shortcut keys={["Ctrl / Cmd", "Z"]} value={t("canvas.undo")} />
|
||||
|
||||
@@ -61,10 +61,10 @@ export function CanvasZoomControls({ scale, onScaleChange, onReset, isMiniMapOpe
|
||||
</div>
|
||||
<Modal title={t("canvas.shortcuts")} open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
|
||||
<div className="space-y-3 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
|
||||
<Shortcut label={t("canvas.shortcut.dragCanvas")} value={t("canvas.shortcut.pan")} />
|
||||
<Shortcut label={`Ctrl / Space + ${t("canvas.shortcut.drag")}`} value={t("canvas.shortcut.pan")} />
|
||||
<Shortcut label={t("canvas.shortcut.wheel")} value={t("canvas.shortcut.zoom")} />
|
||||
<Shortcut label={`Ctrl / Cmd + ${t("canvas.shortcut.drag")}`} value={t("canvas.shortcut.boxSelect")} />
|
||||
<Shortcut label={`Shift / Ctrl / Cmd + ${t("canvas.shortcut.click")}`} value={t("canvas.shortcut.addSelection")} />
|
||||
<Shortcut label={t("canvas.shortcut.drag")} value={t("canvas.shortcut.boxSelect")} />
|
||||
<Shortcut label={`Shift / Cmd + ${t("canvas.shortcut.click")}`} value={t("canvas.shortcut.addSelection")} />
|
||||
<Shortcut label="Ctrl / Cmd + C / V" value={t("canvas.shortcut.copyPasteNodes")} />
|
||||
<Shortcut label="Delete / Backspace" value={t("canvas.shortcut.delete")} />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ViewportTransform } from "@/types/canvas";
|
||||
type InfiniteCanvasProps = {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
viewport: ViewportTransform;
|
||||
tool: "select" | "pan";
|
||||
backgroundMode?: CanvasBackgroundMode;
|
||||
onViewportChange: (viewport: ViewportTransform) => void;
|
||||
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
|
||||
@@ -17,7 +18,7 @@ type InfiniteCanvasProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onCanvasDoubleClick, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
|
||||
export function InfiniteCanvas({ containerRef, viewport, tool, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onCanvasDoubleClick, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const panState = useRef({
|
||||
isPanning: false,
|
||||
@@ -26,11 +27,14 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
initialX: 0,
|
||||
initialY: 0,
|
||||
hasMoved: false,
|
||||
startedOnBackground: false,
|
||||
});
|
||||
const scaleRef = useRef(viewport.k);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const nextViewportRef = useRef<ViewportTransform | null>(null);
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const [isControlPressed, setIsControlPressed] = useState(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
scaleRef.current = viewport.k;
|
||||
@@ -45,20 +49,30 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) return;
|
||||
setIsSpacePressed(true);
|
||||
if (event.key === "Control") setIsControlPressed(true);
|
||||
if (event.code === "Space" && !(event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement)) setIsSpacePressed(true);
|
||||
};
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code === "Space") setIsSpacePressed(false);
|
||||
if (event.key === "Control") setIsControlPressed(false);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsSpacePressed(false);
|
||||
setIsControlPressed(false);
|
||||
panState.current.isPanning = false;
|
||||
setIsPanning(false);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
window.addEventListener("blur", handleBlur);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
window.removeEventListener("blur", handleBlur);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -89,15 +103,9 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
if (target?.closest("[data-canvas-no-zoom]")) return;
|
||||
if (target?.closest("[data-connection-create-menu]")) return;
|
||||
const isBackgroundClick = !target?.closest("[data-node-id],[data-connection-id]");
|
||||
const shouldPan = event.button === 1 || (event.button === 0 && (tool === "pan" || event.ctrlKey || isSpacePressed));
|
||||
|
||||
if (event.button === 0 && (event.ctrlKey || event.metaKey) && isBackgroundClick) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
onCanvasMouseDown?.(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button === 1 || (event.button === 0 && !isSpacePressed && isBackgroundClick)) {
|
||||
if (shouldPan) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
panState.current = {
|
||||
@@ -107,13 +115,17 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
initialX: viewport.x,
|
||||
initialY: viewport.y,
|
||||
hasMoved: false,
|
||||
startedOnBackground: isBackgroundClick,
|
||||
};
|
||||
setIsPanning(true);
|
||||
document.body.style.cursor = "grabbing";
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.button === 0 && isSpacePressed && isBackgroundClick) {
|
||||
if (event.button === 0 && isBackgroundClick) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
onCanvasMouseDown?.(event);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -148,18 +160,22 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
const handlePointerUp = () => {
|
||||
if (!panState.current.isPanning) return;
|
||||
|
||||
if (!panState.current.hasMoved) {
|
||||
if (!panState.current.hasMoved && panState.current.startedOnBackground) {
|
||||
onCanvasDeselect?.();
|
||||
}
|
||||
panState.current.isPanning = false;
|
||||
setIsPanning(false);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
window.removeEventListener("pointercancel", handlePointerUp);
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
}, [onCanvasDeselect, onViewportChange]);
|
||||
|
||||
@@ -177,11 +193,13 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
return () => container.removeEventListener("wheel", preventWheelScroll);
|
||||
}, [containerRef]);
|
||||
|
||||
const cursor = isPanning ? "grabbing" : tool === "pan" || isControlPressed || isSpacePressed ? "grab" : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative h-full w-full cursor-grab select-none overflow-hidden"
|
||||
style={{ background: theme.canvas.background }}
|
||||
className="relative h-full w-full select-none overflow-hidden"
|
||||
style={{ background: theme.canvas.background, cursor }}
|
||||
onPointerDown={handlePointerDown}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onWheel={handleWheel}
|
||||
|
||||
@@ -242,7 +242,7 @@ export default {
|
||||
openAgent: "Open local Codex panel",
|
||||
nodeTypes: { image: "Image", text: "Text", config: "Generation config", video: "Video", audio: "Audio", group: "Group" },
|
||||
toolbar: {
|
||||
move: "Move/select", text: "Text", image: "Image", video: "Video", audio: "Audio", config: "Generation config", group: "Group", extensions: "Extension nodes", upload: "Upload assets", appearance: "Canvas appearance", clear: "Clear canvas",
|
||||
select: "Select", pan: "Move", text: "Text", image: "Image", video: "Video", audio: "Audio", config: "Generation config", group: "Group", extensions: "Extension nodes", upload: "Upload assets", appearance: "Canvas appearance", clear: "Clear canvas",
|
||||
themeMode: "Theme", light: "Light", dark: "Dark", gridStyle: "Grid style", dots: "Dots", lines: "Lines", blank: "Blank", imageInfo: "Image info",
|
||||
},
|
||||
project: {
|
||||
|
||||
@@ -242,7 +242,7 @@ export default {
|
||||
openAgent: "打开本地 Codex 面板",
|
||||
nodeTypes: { image: "图片", text: "文本", config: "生成配置", video: "视频", audio: "音频", group: "组" },
|
||||
toolbar: {
|
||||
move: "移动/选择", text: "文本", image: "图片", video: "视频", audio: "音频", config: "生成配置", group: "组", extensions: "扩展节点", upload: "上传资产", appearance: "画布外观", clear: "清空画布",
|
||||
select: "选择", pan: "移动", text: "文本", image: "图片", video: "视频", audio: "音频", config: "生成配置", group: "组", extensions: "扩展节点", upload: "上传资产", appearance: "画布外观", clear: "清空画布",
|
||||
themeMode: "主题模式", light: "浅色", dark: "深色", gridStyle: "网格样式", dots: "点", lines: "线", blank: "空白", imageInfo: "图片信息",
|
||||
},
|
||||
project: {
|
||||
|
||||
@@ -199,6 +199,7 @@ function InfiniteCanvasPage() {
|
||||
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(null);
|
||||
const [viewport, setViewport] = useState<ViewportTransform>({ x: 0, y: 0, k: 1 });
|
||||
const [canvasTool, setCanvasTool] = useState<"select" | "pan">("select");
|
||||
const [size, setSize] = useState({ width: 1200, height: 720 });
|
||||
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
|
||||
@@ -977,13 +978,6 @@ function InfiniteCanvasPage() {
|
||||
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
|
||||
if (event.button !== 0) return;
|
||||
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
setSelectionBox(null);
|
||||
setSelectedNodeIds(new Set());
|
||||
setSelectedConnectionId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const world = screenToCanvas(event.clientX, event.clientY);
|
||||
const nextSelectionBox = {
|
||||
startWorldX: world.x,
|
||||
@@ -2775,6 +2769,7 @@ function InfiniteCanvasPage() {
|
||||
<InfiniteCanvas
|
||||
containerRef={containerRef}
|
||||
viewport={viewport}
|
||||
tool={canvasTool}
|
||||
backgroundMode={backgroundMode}
|
||||
onViewportChange={(next) => {
|
||||
setViewport(next);
|
||||
@@ -2863,17 +2858,17 @@ function InfiniteCanvasPage() {
|
||||
))}
|
||||
|
||||
{selectionBox ? (
|
||||
<div
|
||||
className="pointer-events-none absolute z-[100] border"
|
||||
<svg
|
||||
className="pointer-events-none absolute z-[100] overflow-visible"
|
||||
style={{
|
||||
left: Math.min(selectionBox.startWorldX, selectionBox.currentWorldX),
|
||||
top: Math.min(selectionBox.startWorldY, selectionBox.currentWorldY),
|
||||
width: Math.abs(selectionBox.currentWorldX - selectionBox.startWorldX),
|
||||
height: Math.abs(selectionBox.currentWorldY - selectionBox.startWorldY),
|
||||
borderColor: theme.canvas.selectionStroke,
|
||||
background: theme.canvas.selectionFill,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<rect width="100%" height="100%" fill={theme.canvas.selectionFill} stroke={theme.canvas.selectionStroke} strokeWidth={1.5 / viewport.k} strokeDasharray={`${10 / viewport.k} ${6 / viewport.k}`} />
|
||||
</svg>
|
||||
) : null}
|
||||
{pendingConnectionCreate ? <ConnectionCreateMenu pending={pendingConnectionCreate} onCreate={(type) => createConnectedNode(type, pendingConnectionCreate)} onClose={cancelPendingConnectionCreate} /> : null}
|
||||
{nodeCreatePosition ? (
|
||||
@@ -2918,6 +2913,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
<CanvasToolbar
|
||||
selectedCount={selectedNodeIds.size}
|
||||
canvasTool={canvasTool}
|
||||
canUndo={historyState.canUndo}
|
||||
canRedo={historyState.canRedo}
|
||||
backgroundMode={backgroundMode}
|
||||
@@ -2934,7 +2930,7 @@ function InfiniteCanvasPage() {
|
||||
onUpload={() => handleUploadRequest()}
|
||||
onDelete={() => deleteNodes(new Set(selectedNodeIds))}
|
||||
onClear={() => setClearConfirmOpen(true)}
|
||||
onDeselect={deselectCanvas}
|
||||
onCanvasToolChange={setCanvasTool}
|
||||
onBackgroundModeChange={setBackgroundMode}
|
||||
onShowImageInfoChange={setShowImageInfo}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user