feat: refine canvas image selection style and drag-safe renaming
This commit is contained in:
parent
740860fde7
commit
435fbd10f7
@ -37,6 +37,9 @@ function isNodeInViewport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CanvasWorkspace() {
|
export function CanvasWorkspace() {
|
||||||
|
const SNAP_THRESHOLD = 10;
|
||||||
|
const SNAP_GAP = 12;
|
||||||
|
const SELECTION_COLOR = "#3182ed";
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isPanning, setIsPanning] = useState(false);
|
const [isPanning, setIsPanning] = useState(false);
|
||||||
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
const [panStart, setPanStart] = useState<Position>({ x: 0, y: 0 });
|
||||||
@ -172,6 +175,89 @@ export function CanvasWorkspace() {
|
|||||||
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const hasBlockingDialogOpen = useCallback(() => {
|
||||||
|
if (typeof document === "undefined") return false;
|
||||||
|
return Boolean(document.querySelector('[role="dialog"][data-state="open"]'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getSnappedCanvasPosition = useCallback(
|
||||||
|
(
|
||||||
|
node: CanvasNode,
|
||||||
|
target: Position,
|
||||||
|
ignoreNodeIds: Set<string> = new Set(),
|
||||||
|
): Position => {
|
||||||
|
if (node.type !== "image") {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = nodes.filter(
|
||||||
|
(item) =>
|
||||||
|
item.type === "image" &&
|
||||||
|
item.id !== node.id &&
|
||||||
|
!ignoreNodeIds.has(item.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bestOffsetX = 0;
|
||||||
|
let bestDeltaX = Number.POSITIVE_INFINITY;
|
||||||
|
let bestOffsetY = 0;
|
||||||
|
let bestDeltaY = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
const selfLeft = target.x;
|
||||||
|
const selfCenterX = target.x + node.size.width / 2;
|
||||||
|
const selfRight = target.x + node.size.width;
|
||||||
|
const selfTop = target.y;
|
||||||
|
const selfCenterY = target.y + node.size.height / 2;
|
||||||
|
const selfBottom = target.y + node.size.height;
|
||||||
|
|
||||||
|
const pickBestX = (delta: number) => {
|
||||||
|
const abs = Math.abs(delta);
|
||||||
|
if (abs <= SNAP_THRESHOLD && abs < bestDeltaX) {
|
||||||
|
bestDeltaX = abs;
|
||||||
|
bestOffsetX = delta;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickBestY = (delta: number) => {
|
||||||
|
const abs = Math.abs(delta);
|
||||||
|
if (abs <= SNAP_THRESHOLD && abs < bestDeltaY) {
|
||||||
|
bestDeltaY = abs;
|
||||||
|
bestOffsetY = delta;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const targetLeft = candidate.position.x;
|
||||||
|
const targetCenterX = candidate.position.x + candidate.size.width / 2;
|
||||||
|
const targetRight = candidate.position.x + candidate.size.width;
|
||||||
|
const targetTop = candidate.position.y;
|
||||||
|
const targetCenterY = candidate.position.y + candidate.size.height / 2;
|
||||||
|
const targetBottom = candidate.position.y + candidate.size.height;
|
||||||
|
|
||||||
|
pickBestX(targetLeft - selfLeft);
|
||||||
|
pickBestX(targetCenterX - selfCenterX);
|
||||||
|
pickBestX(targetRight - selfRight);
|
||||||
|
pickBestX(targetRight + SNAP_GAP - selfLeft);
|
||||||
|
pickBestX(targetLeft - SNAP_GAP - selfRight);
|
||||||
|
|
||||||
|
pickBestY(targetTop - selfTop);
|
||||||
|
pickBestY(targetCenterY - selfCenterY);
|
||||||
|
pickBestY(targetBottom - selfBottom);
|
||||||
|
pickBestY(targetBottom + SNAP_GAP - selfTop);
|
||||||
|
pickBestY(targetTop - SNAP_GAP - selfBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: target.x + (Number.isFinite(bestDeltaX) ? bestOffsetX : 0),
|
||||||
|
y: target.y + (Number.isFinite(bestDeltaY) ? bestOffsetY : 0),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[nodes, SNAP_GAP],
|
||||||
|
);
|
||||||
|
|
||||||
const toCanvasPosition = useCallback((clientX: number, clientY: number): Position => {
|
const toCanvasPosition = useCallback((clientX: number, clientY: number): Position => {
|
||||||
const rect = containerRef.current?.getBoundingClientRect();
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
if (!rect) return { x: 0, y: 0 };
|
if (!rect) return { x: 0, y: 0 };
|
||||||
@ -193,6 +279,7 @@ export function CanvasWorkspace() {
|
|||||||
|
|
||||||
// 处理画布点击(取消选择)
|
// 处理画布点击(取消选择)
|
||||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (hasBlockingDialogOpen()) return;
|
||||||
if (!isEventFromCanvasContainer(e.target)) return;
|
if (!isEventFromCanvasContainer(e.target)) return;
|
||||||
if (suppressCanvasClickRef.current) {
|
if (suppressCanvasClickRef.current) {
|
||||||
suppressCanvasClickRef.current = false;
|
suppressCanvasClickRef.current = false;
|
||||||
@ -201,10 +288,11 @@ export function CanvasWorkspace() {
|
|||||||
if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) {
|
if (!isPointOnNodeElement(e.target) && !isMarqueeSelecting) {
|
||||||
clearSelection();
|
clearSelection();
|
||||||
}
|
}
|
||||||
}, [clearSelection, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
|
}, [clearSelection, hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPointOnNodeElement]);
|
||||||
|
|
||||||
// 处理画布平移开始
|
// 处理画布平移开始
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (hasBlockingDialogOpen()) return;
|
||||||
if (!isEventFromCanvasContainer(e.target)) return;
|
if (!isEventFromCanvasContainer(e.target)) return;
|
||||||
if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) {
|
if (tool === 'pan' || e.button === 1 || (e.button === 0 && e.altKey)) {
|
||||||
setIsPanning(true);
|
setIsPanning(true);
|
||||||
@ -219,10 +307,14 @@ export function CanvasWorkspace() {
|
|||||||
setMarqueeStart(start);
|
setMarqueeStart(start);
|
||||||
setMarqueeEnd(start);
|
setMarqueeEnd(start);
|
||||||
}
|
}
|
||||||
}, [isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]);
|
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isPointOnNodeElement, toCanvasPosition, tool, viewport]);
|
||||||
|
|
||||||
// 处理画布平移
|
// 处理画布平移
|
||||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||||
|
if (hasBlockingDialogOpen()) {
|
||||||
|
if (isPanning) setIsPanning(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isEventFromCanvasContainer(e.target)) return;
|
if (!isEventFromCanvasContainer(e.target)) return;
|
||||||
dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY);
|
dragPointerRef.current = toCanvasPosition(e.clientX, e.clientY);
|
||||||
if (isPanning) {
|
if (isPanning) {
|
||||||
@ -236,7 +328,7 @@ export function CanvasWorkspace() {
|
|||||||
if (isMarqueeSelecting) {
|
if (isMarqueeSelecting) {
|
||||||
setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY));
|
setMarqueeEnd(toCanvasPosition(e.clientX, e.clientY));
|
||||||
}
|
}
|
||||||
}, [isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]);
|
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMarqueeSelecting, isPanning, panStart, setViewport, toCanvasPosition]);
|
||||||
|
|
||||||
// 处理画布平移结束
|
// 处理画布平移结束
|
||||||
const handleMouseUp = useCallback((e?: React.MouseEvent) => {
|
const handleMouseUp = useCallback((e?: React.MouseEvent) => {
|
||||||
@ -313,6 +405,7 @@ export function CanvasWorkspace() {
|
|||||||
|
|
||||||
// 处理滚轮缩放
|
// 处理滚轮缩放
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
|
if (hasBlockingDialogOpen()) return;
|
||||||
if (!isEventFromCanvasContainer(e.target)) return;
|
if (!isEventFromCanvasContainer(e.target)) return;
|
||||||
if (e.cancelable) {
|
if (e.cancelable) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -340,7 +433,7 @@ export function CanvasWorkspace() {
|
|||||||
const newX = mouseX - (mouseX - viewport.x) * scaleChange;
|
const newX = mouseX - (mouseX - viewport.x) * scaleChange;
|
||||||
const newY = mouseY - (mouseY - viewport.y) * scaleChange;
|
const newY = mouseY - (mouseY - viewport.y) * scaleChange;
|
||||||
setViewport({ x: newX, y: newY, zoom: newZoom });
|
setViewport({ x: newX, y: newY, zoom: newZoom });
|
||||||
}, [isEventFromCanvasContainer, isMacPlatform, setViewport, viewport.x, viewport.y, viewport.zoom]);
|
}, [hasBlockingDialogOpen, isEventFromCanvasContainer, isMacPlatform, setViewport, viewport.x, viewport.y, viewport.zoom]);
|
||||||
|
|
||||||
const getImageDimensionsFromFile = useCallback((file: File): Promise<{
|
const getImageDimensionsFromFile = useCallback((file: File): Promise<{
|
||||||
width: number;
|
width: number;
|
||||||
@ -587,6 +680,61 @@ export function CanvasWorkspace() {
|
|||||||
// 渲染节点
|
// 渲染节点
|
||||||
const renderNode = (node: CanvasNode) => {
|
const renderNode = (node: CanvasNode) => {
|
||||||
const isSelected = selectedNodeIds.includes(node.id);
|
const isSelected = selectedNodeIds.includes(node.id);
|
||||||
|
const isImageNode = node.type === "image";
|
||||||
|
const enableImageResizeHandles =
|
||||||
|
isSelected && tool === "select" && isImageNode
|
||||||
|
? {
|
||||||
|
top: false,
|
||||||
|
right: false,
|
||||||
|
bottom: false,
|
||||||
|
left: false,
|
||||||
|
topRight: true,
|
||||||
|
bottomRight: true,
|
||||||
|
bottomLeft: true,
|
||||||
|
topLeft: true,
|
||||||
|
}
|
||||||
|
: isSelected && tool === "select";
|
||||||
|
|
||||||
|
const imageResizeHandleStyles = isImageNode
|
||||||
|
? {
|
||||||
|
topRight: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
right: -6,
|
||||||
|
top: -6,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `2px solid ${SELECTION_COLOR}`,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
bottomRight: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
right: -6,
|
||||||
|
bottom: -6,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `2px solid ${SELECTION_COLOR}`,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
bottomLeft: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
left: -6,
|
||||||
|
bottom: -6,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `2px solid ${SELECTION_COLOR}`,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
topLeft: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
left: -6,
|
||||||
|
top: -6,
|
||||||
|
borderRadius: 999,
|
||||||
|
border: `2px solid ${SELECTION_COLOR}`,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Rnd
|
<Rnd
|
||||||
@ -644,12 +792,29 @@ export function CanvasWorkspace() {
|
|||||||
if (group && group.nodeId === node.id && selectedNodeIds.length > 1) {
|
if (group && group.nodeId === node.id && selectedNodeIds.length > 1) {
|
||||||
const deltaCanvasX = (d.x - group.startX) / viewport.zoom;
|
const deltaCanvasX = (d.x - group.startX) / viewport.zoom;
|
||||||
const deltaCanvasY = (d.y - group.startY) / viewport.zoom;
|
const deltaCanvasY = (d.y - group.startY) / viewport.zoom;
|
||||||
|
const origin = group.originPositions[node.id];
|
||||||
|
const rawAnchor = origin
|
||||||
|
? {
|
||||||
|
x: origin.x + deltaCanvasX,
|
||||||
|
y: origin.y + deltaCanvasY,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
x: node.position.x + deltaCanvasX,
|
||||||
|
y: node.position.y + deltaCanvasY,
|
||||||
|
};
|
||||||
|
const snappedAnchor = getSnappedCanvasPosition(
|
||||||
|
node,
|
||||||
|
rawAnchor,
|
||||||
|
new Set(selectedNodeIds),
|
||||||
|
);
|
||||||
|
const offsetX = origin ? snappedAnchor.x - origin.x : deltaCanvasX;
|
||||||
|
const offsetY = origin ? snappedAnchor.y - origin.y : deltaCanvasY;
|
||||||
selectedNodeIds.forEach((id) => {
|
selectedNodeIds.forEach((id) => {
|
||||||
const origin = group.originPositions[id];
|
const itemOrigin = group.originPositions[id];
|
||||||
if (!origin) return;
|
if (!itemOrigin) return;
|
||||||
moveNode(id, {
|
moveNode(id, {
|
||||||
x: origin.x + deltaCanvasX,
|
x: itemOrigin.x + offsetX,
|
||||||
y: origin.y + deltaCanvasY,
|
y: itemOrigin.y + offsetY,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
groupDragRef.current = null;
|
groupDragRef.current = null;
|
||||||
@ -659,7 +824,8 @@ export function CanvasWorkspace() {
|
|||||||
|
|
||||||
const newX = (d.x - viewport.x) / viewport.zoom;
|
const newX = (d.x - viewport.x) / viewport.zoom;
|
||||||
const newY = (d.y - viewport.y) / viewport.zoom;
|
const newY = (d.y - viewport.y) / viewport.zoom;
|
||||||
moveNode(node.id, { x: newX, y: newY });
|
const snapped = getSnappedCanvasPosition(node, { x: newX, y: newY });
|
||||||
|
moveNode(node.id, snapped);
|
||||||
groupDragRef.current = null;
|
groupDragRef.current = null;
|
||||||
pushHistory();
|
pushHistory();
|
||||||
}}
|
}}
|
||||||
@ -674,8 +840,10 @@ export function CanvasWorkspace() {
|
|||||||
pushHistory();
|
pushHistory();
|
||||||
}}
|
}}
|
||||||
bounds="parent"
|
bounds="parent"
|
||||||
enableResizing={isSelected && tool === 'select'}
|
enableResizing={enableImageResizeHandles}
|
||||||
|
resizeHandleStyles={imageResizeHandleStyles}
|
||||||
disableDragging={tool !== 'select'}
|
disableDragging={tool !== 'select'}
|
||||||
|
cancel={'input, textarea, button, a, [contenteditable="true"], [data-canvas-no-drag="true"]'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute",
|
"absolute",
|
||||||
tool === "select" && "cursor-pointer"
|
tool === "select" && "cursor-pointer"
|
||||||
|
|||||||
@ -1437,8 +1437,12 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
<Input
|
<Input
|
||||||
value={draftTitle}
|
value={draftTitle}
|
||||||
autoFocus
|
autoFocus
|
||||||
|
data-canvas-no-drag="true"
|
||||||
className="h-6 px-2 py-0 text-xs w-[160px]"
|
className="h-6 px-2 py-0 text-xs w-[160px]"
|
||||||
onChange={(e) => setDraftTitle(e.target.value)}
|
onChange={(e) => setDraftTitle(e.target.value)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
onBlur={handleSaveTitle}
|
onBlur={handleSaveTitle}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
@ -1471,9 +1475,9 @@ export function ImageNode({ node, isSelected }: ImageNodeProps) {
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full h-full bg-card rounded-lg overflow-hidden border-2 shadow-sm transition-colors",
|
"relative w-full h-full bg-card rounded-[4px] overflow-hidden border shadow-sm transition-colors",
|
||||||
isSelected
|
isSelected
|
||||||
? "border-transparent shadow-[0_0_0_1px_rgba(59,130,246,0.18),0_14px_28px_-18px_rgba(59,130,246,0.45)]"
|
? "border-[#3182ed] shadow-none"
|
||||||
: "border-transparent"
|
: "border-transparent"
|
||||||
)}
|
)}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user