Compare commits

...
Author SHA1 Message Date
bflatastic b49e39de20 cursor crosshair during shape tool 2025-07-09 15:52:19 +07:00
bflatastic ef48e48080 change to triangle icon 2025-07-09 15:47:51 +07:00
bflatastic 926e7a0f68 shape drawing tool 2025-07-09 15:46:47 +07:00
4 changed files with 797 additions and 442 deletions
@@ -238,24 +238,24 @@ const PageDraw = () => {
const autoFitCanvas = async () => {
let attempts = 0;
const maxAttempts = 20; // Increased attempts
const tryFit = async () => {
const stage = stageRef.current;
if (stage && stage.container && stage.container().offsetWidth > 0) {
// Wait a bit more to ensure everything is rendered
await new Promise(resolve => setTimeout(resolve, 50));
await new Promise((resolve) => setTimeout(resolve, 50));
onFitPressed();
return true;
}
attempts++;
if (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
return tryFit();
}
return false;
};
await tryFit();
};
@@ -315,17 +315,13 @@ const PageDraw = () => {
onSelect={(): void => {
store.setActiveTool("select");
}}
onAddShape={(shape: "rectangle" | "circle" | "triangle"): void => {
// Calculate center position based on canvas dimensions
const centerX = canvasWidth.current / 3;
const centerY = canvasHeight.current / 3;
if (shape === "rectangle") {
store.createRectangle(centerX, centerY);
} else if (shape === "circle") {
store.createCircle(centerX, centerY);
} else if (shape === "triangle") {
store.createTriangle(centerX, centerY);
}
onActivateShapeTool={(
shape: "rectangle" | "circle" | "triangle",
): void => {
store.selectNode(null);
store.setCurrentShape(shape);
store.setActiveTool("shape");
store.selectNode(null);
}}
onPaintBrush={(hex: string, size: number, opacity: number): void => {
store.setActiveTool("draw");
@@ -333,10 +329,6 @@ const PageDraw = () => {
store.setBrushSize(size);
store.setBrushOpacity(opacity);
}}
onEraser={(size: number): void => {
store.setActiveTool("eraser");
store.setBrushSize(size);
}}
onCanvasBackground={(hex: string): void => {
console.log("Canvas background activated", { color: hex });
// Add background change logic here
@@ -344,10 +336,6 @@ const PageDraw = () => {
// Debounce also causes issues with real time color change.
store.setFillColor(hex);
}}
onGenerateImage={(): void => {
console.log("Generate image activated");
// Add image generation logic here
}}
onUploadImage={(): void => {
// Create input element dynamically like in PromptEditor
console.log("Upload image activated");
@@ -393,6 +381,7 @@ const PageDraw = () => {
store.deleteSelectedItems();
}}
activeToolId={store.activeTool}
currentShape={store.currentShape}
/>
<div className="relative z-0">
<ContextMenuContainer
@@ -3,6 +3,7 @@ import {
Stage,
Layer,
Rect,
Ellipse,
Circle,
Text,
Line,
@@ -97,6 +98,20 @@ export const PaintSurface = ({
// Add state to track the current line being drawn
const [currentLineId, setCurrentLineId] = useState<string | null>(null);
/* ---------- shape drawing state ---------- */
const [isDrawingShape, setIsDrawingShape] = useState(false);
const [currentShapeId, setCurrentShapeId] = useState<string | null>(null);
const [shapeStartPoint, setShapeStartPoint] = useState<{
x: number;
y: number;
} | null>(null);
const [shapePreview, setShapePreview] = useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
// Add useLayoutEffect to measure container dimensions
useLayoutEffect(() => {
const updateDimensions = () => {
@@ -191,7 +206,12 @@ export const PaintSurface = ({
activeTool: string,
nodeDraggable: boolean,
): boolean => {
return activeTool !== "draw" && activeTool !== "eraser" && nodeDraggable;
return (
activeTool !== "draw" &&
activeTool !== "eraser" &&
activeTool !== "shape" &&
nodeDraggable
);
};
// Combined mouse handlers
@@ -271,6 +291,53 @@ export const PaintSurface = ({
endY: clampedPoint.y,
});
}
// Handle shape drawing tool
if (
activeTool === "shape" &&
store.currentShape &&
isWithinLeftPanel(stagePoint) &&
e.target === e.target.getStage()
) {
const id = `node-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const commonProps = {
id,
x: stagePoint.x,
y: stagePoint.y,
width: 1,
height: 1,
fill: "#e0e0e0",
stroke: "#444",
strokeWidth: 2,
draggable: true,
} as const;
let newNode: Node;
switch (store.currentShape) {
case "circle":
newNode = new Node({ ...commonProps, type: "circle" });
break;
case "triangle":
newNode = new Node({ ...commonProps, type: "triangle" });
break;
case "rectangle":
default:
newNode = new Node({ ...commonProps, type: "rectangle" });
break;
}
store.addNode(newNode);
setIsDrawingShape(true);
setCurrentShapeId(id);
setShapeStartPoint(stagePoint);
setShapePreview({
x: stagePoint.x,
y: stagePoint.y,
width: 0,
height: 0,
});
return;
}
};
const handleStageMouseMove = (
@@ -286,9 +353,9 @@ export const PaintSurface = ({
x: (pointer.x - stage.x()) / stage.scaleX(),
y: (pointer.y - stage.y()) / stage.scaleY(),
};
const isWithinCanvas = isWithinLeftPanel(stagePoint);
// Update cursor based on position and tool
if (activeTool === "draw" || activeTool === "eraser") {
if (isWithinCanvas) {
@@ -299,6 +366,13 @@ export const PaintSurface = ({
stage.container().style.cursor = "grab";
store.setCursorVisible(false);
}
} else if (activeTool === "shape") {
if (isWithinCanvas) {
stage.container().style.cursor = "crosshair";
} else {
stage.container().style.cursor = "grab";
}
store.setCursorVisible(false);
} else {
if (isWithinCanvas) {
stage.container().style.cursor = "default";
@@ -313,15 +387,15 @@ export const PaintSurface = ({
if (isDragging) {
const currentStage = e.target.getStage();
if (!currentStage) return;
// Update cursor to grabbing while dragging
currentStage.container().style.cursor = "grabbing";
// Use a multiplier to make middle mouse dragging more responsive
const dragSensitivity = 3.0; // Much higher sensitivity for faster dragging
// Use a multiplier to make middle mouse dragging more responsive
const dragSensitivity = 3.0; // Much higher sensitivity for faster dragging
const newPos = {
x: currentStage.x() + (e.evt.movementX * dragSensitivity),
y: currentStage.y() + (e.evt.movementY * dragSensitivity),
x: currentStage.x() + e.evt.movementX * dragSensitivity,
y: currentStage.y() + e.evt.movementY * dragSensitivity,
};
currentStage.position(newPos);
return;
@@ -362,6 +436,52 @@ export const PaintSurface = ({
}
}
// Handle shape resizing during drawing
if (isDrawingShape && currentShapeId && shapeStartPoint) {
const point = stage.getPointerPosition();
if (!point) return;
const stagePoint = {
x: (point.x - stage.x()) / stage.scaleX(),
y: (point.y - stage.y()) / stage.scaleY(),
};
// Clamp within bounds
const clamped = clampToLeftPanel(stagePoint);
const start = shapeStartPoint;
// Deltas
let dx = clamped.x - start.x;
let dy = clamped.y - start.y;
// If Shift key is held, constrain to proportional (square / circle / equilateral triangle)
const shiftHeld = (e.evt as MouseEvent).shiftKey;
const keepSquare = shiftHeld;
if (keepSquare) {
const side = Math.max(Math.abs(dx), Math.abs(dy));
dx = dx < 0 ? -side : side;
dy = dy < 0 ? -side : side;
}
// Compute top-left corner & positive width/height
let newX = dx < 0 ? start.x + dx : start.x;
let newY = dy < 0 ? start.y + dy : start.y;
const newWidth = Math.abs(dx);
const newHeight = Math.abs(dy);
const updateProps = {
x: newX,
y: newY,
width: newWidth,
height: newHeight,
};
store.updateNode(currentShapeId, updateProps, false);
setShapePreview({ x: newX, y: newY, width: newWidth, height: newHeight });
}
// Handle selection rectangle - Update directly through Konva
if (isSelecting && selectionRectRef.current) {
const point = stage.getPointerPosition();
@@ -403,6 +523,13 @@ export const PaintSurface = ({
store.saveState(); // Save state only when the stroke is complete
}
if (isDrawingShape && currentShapeId) {
store.saveState();
// Automatically return to select tool after shape creation and select the new node
store.setActiveTool("select");
store.selectNode(currentShapeId);
}
if (isSelecting && selectionRect) {
// Calculate the bounds of the selection rectangle
const left = Math.min(selectionRect.startX, selectionRect.endX);
@@ -461,10 +588,16 @@ export const PaintSurface = ({
onSelectionChange?.(false);
setSelectionRect(null);
setLastPoint(null);
setIsDrawingShape(false);
setCurrentShapeId(null);
setShapeStartPoint(null);
setShapePreview(null);
};
// State for tracking pinch gestures
const [lastPinchDistance, setLastPinchDistance] = useState<number | null>(null);
const [lastPinchDistance, setLastPinchDistance] = useState<number | null>(
null,
);
const [isPinching, setIsPinching] = useState(false);
// Helper function to get distance between two touch points
@@ -473,8 +606,8 @@ export const PaintSurface = ({
const touch1 = touches[0];
const touch2 = touches[1];
return Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2),
);
};
@@ -506,16 +639,16 @@ export const PaintSurface = ({
const isPinchGesture = e.evt.ctrlKey;
const deltaY = e.evt.deltaY;
const absDelta = Math.abs(deltaY);
// Detect Mac trackpad vs mouse wheel more precisely
const isMac = navigator.userAgent.includes('Mac');
const isMac = navigator.userAgent.includes("Mac");
// Track-pad scroll on Mac: pixel-based (deltaMode===0) and very small deltas (|delta|≤10)
const isMacTrackpadScroll =
isMac && !isPinchGesture && e.evt.deltaMode === 0 && absDelta <= 10;
let zoomFactor;
if (isPinchGesture) {
// Mac trackpad pinch gesture – fast & smooth
const pinchSensitivity = 0.2;
@@ -531,7 +664,7 @@ export const PaintSurface = ({
// Apply zoom limits
const newScale = Math.max(0.1, Math.min(10, oldScale * zoomFactor));
// Only apply zoom if within limits
if (newScale !== oldScale) {
// Calculate new position for zoom-to-cursor
@@ -558,37 +691,37 @@ export const PaintSurface = ({
const touches = e.evt.touches;
if (touches.length === 2 && isPinching && lastPinchDistance) {
e.evt.preventDefault();
const stage = e.target.getStage();
if (!stage) return;
const currentDistance = getTouchDistance(touches);
const center = getTouchCenter(touches);
if (!center) return;
const oldScale = stage.scaleX();
const scaleChange = currentDistance / lastPinchDistance;
// Apply smooth scaling with limits
const newScale = Math.max(0.1, Math.min(10, oldScale * scaleChange));
if (newScale !== oldScale) {
// Get stage coordinates for the center point
const stageCenter = {
x: (center.x - stage.x()) / oldScale,
y: (center.y - stage.y()) / oldScale,
};
// Calculate new position to zoom toward the center of the pinch
const newPos = {
x: center.x - stageCenter.x * newScale,
y: center.y - stageCenter.y * newScale,
};
stage.scale({ x: newScale, y: newScale });
stage.position(newPos);
}
setLastPinchDistance(currentDistance);
}
};
@@ -717,8 +850,12 @@ export const PaintSurface = ({
e: Konva.KonvaEventObject<MouseEvent | TouchEvent>,
nodeId: string,
) => {
// Don't select nodes when draw or erase tools are active
if (activeTool === "draw" || activeTool === "eraser") {
// Don't select nodes when draw, erase, or shape tools are active
if (
activeTool === "draw" ||
activeTool === "eraser" ||
activeTool === "shape"
) {
return;
}
@@ -766,7 +903,16 @@ export const PaintSurface = ({
targetNode.lastX = targetNode.x();
targetNode.lastY = targetNode.y();
store.moveNode(nodeId, targetNode.x(), targetNode.y(), 0, 0, false);
let newX = targetNode.x();
let newY = targetNode.y();
const draggedNode =
nodes.find((n) => n.id === nodeId) ||
(store.lineNodes as any).find((n: any) => n.id === nodeId);
if (draggedNode && draggedNode.type === "circle") {
newX = targetNode.x() - draggedNode.width / 2;
newY = targetNode.y() - draggedNode.height / 2;
}
store.moveNode(nodeId, newX, newY, 0, 0, false);
};
const handleNodeDragMove = (
@@ -786,7 +932,16 @@ export const PaintSurface = ({
targetNode.lastX = targetNode.x();
targetNode.lastY = targetNode.y();
store.moveNode(nodeId, targetNode.x(), targetNode.y(), dx, dy, false);
let newXMove = targetNode.x();
let newYMove = targetNode.y();
const movingNode =
nodes.find((n) => n.id === nodeId) ||
(store.lineNodes as any).find((n: any) => n.id === nodeId);
if (movingNode && movingNode.type === "circle") {
newXMove = targetNode.x() - movingNode.width / 2;
newYMove = targetNode.y() - movingNode.height / 2;
}
store.moveNode(nodeId, newXMove, newYMove, dx, dy, false);
};
const handleNodeDragEnd = (
@@ -800,7 +955,16 @@ export const PaintSurface = ({
targetNode.lastX = targetNode.x();
targetNode.lastY = targetNode.y();
store.moveNode(nodeId, targetNode.x(), targetNode.y(), 0, 0, true);
let endX = targetNode.x();
let endY = targetNode.y();
const endNode =
nodes.find((n) => n.id === nodeId) ||
(store.lineNodes as any).find((n: any) => n.id === nodeId);
if (endNode && endNode.type === "circle") {
endX = targetNode.x() - endNode.width / 2;
endY = targetNode.y() - endNode.height / 2;
}
store.moveNode(nodeId, endX, endY, 0, 0, true);
};
const isSelected = selectedNodeIds.includes(node.id);
@@ -898,6 +1062,8 @@ export const PaintSurface = ({
return null;
};
const listeningEnabled = activeTool !== "shape";
if (node.type === "line") {
const lineNode = node as LineNode;
@@ -977,6 +1143,7 @@ export const PaintSurface = ({
offsetX={lineNode.offsetX || 0}
offsetY={lineNode.offsetY || 0}
zIndex={lineNode.zIndex}
listening={listeningEnabled}
/>
{renderTransformer()}
</React.Fragment>
@@ -986,12 +1153,12 @@ export const PaintSurface = ({
if (node.type === "circle") {
return (
<React.Fragment key={node.id}>
<Circle
<Ellipse
id={node.id}
x={node.x}
y={node.y}
width={node.width}
height={node.height}
x={node.x + node.width / 2}
y={node.y + node.height / 2}
radiusX={node.width / 2}
radiusY={node.height / 2}
fill={node.fill}
stroke={node.stroke}
strokeWidth={0}
@@ -1012,6 +1179,7 @@ export const PaintSurface = ({
onDragMove={(e) => handleNodeDragMove(e, node.id)}
onDragStart={(e) => handleNodeDragStart(e, node.id)}
onDragEnd={(e) => handleNodeDragEnd(e, node.id)}
listening={listeningEnabled}
/>
{renderTransformer()}
</React.Fragment>
@@ -1047,6 +1215,7 @@ export const PaintSurface = ({
onDragMove={(e) => handleNodeDragMove(e, node.id)}
onDragStart={(e) => handleNodeDragStart(e, node.id)}
onDragEnd={(e) => handleNodeDragEnd(e, node.id)}
listening={listeningEnabled}
/>
{renderTransformer()}
</React.Fragment>
@@ -1054,14 +1223,23 @@ export const PaintSurface = ({
}
if (node.type === "triangle") {
const points = [
0,
node.height, // bottom-left
node.width / 2,
0, // top-center
node.width,
node.height, // bottom-right
];
return (
<React.Fragment key={node.id}>
<RegularPolygon
<Line
id={node.id}
x={node.x}
y={node.y}
sides={3}
radius={Math.min(node.width, node.height) / 2}
points={points}
closed
fill={node.fill}
stroke={node.stroke}
strokeWidth={0}
@@ -1082,6 +1260,7 @@ export const PaintSurface = ({
onDragMove={(e) => handleNodeDragMove(e, node.id)}
onDragStart={(e) => handleNodeDragStart(e, node.id)}
onDragEnd={(e) => handleNodeDragEnd(e, node.id)}
listening={listeningEnabled}
/>
{renderTransformer()}
</React.Fragment>
@@ -1133,6 +1312,7 @@ export const PaintSurface = ({
onDragMove={(e) => handleNodeDragMove(e, node.id)}
onDragStart={(e) => handleNodeDragStart(e, node.id)}
onDragEnd={(e) => handleNodeDragEnd(e, node.id)}
listening={listeningEnabled}
/>
{renderTransformer()}
</React.Fragment>
@@ -1251,6 +1431,20 @@ export const PaintSurface = ({
cornerRadius={2}
/>
)}
{/* Shape preview bounding box */}
{shapePreview && (
<Rect
x={shapePreview.x}
y={shapePreview.y}
width={shapePreview.width}
height={shapePreview.height}
stroke="#3b82f6"
dash={[4, 4]}
strokeWidth={1}
listening={false}
/>
)}
</Layer>
<Layer ref={cursorLayerRef} listening={false} draggable={false}>
<Circle ref={cursorShapeRef} visible={false} />
@@ -8,10 +8,10 @@ import {
faImage,
faSquare,
faCircle,
faPlay,
faUndo,
faRedo,
faPaintBrush,
faTriangle,
} from "@fortawesome/pro-solid-svg-icons";
import "../../App.css";
import { HsvaColorPicker, HsvaColor } from "react-colorful";
@@ -38,7 +38,8 @@ function useDebounced<T extends (...args: A) => void, A extends unknown[]>(
export interface SideToolbarProps {
onSelect: () => void;
onAddShape: (shape: "rectangle" | "circle" | "triangle") => void;
onActivateShapeTool: (shape: "rectangle" | "circle" | "triangle") => void;
currentShape: "rectangle" | "circle" | "triangle" | null;
onPaintBrush: (hex: string, size: number, opacity: number) => void;
onCanvasBackground: (hex: string) => void;
onUploadImage: () => void;
@@ -49,7 +50,8 @@ export interface SideToolbarProps {
const SideToolbar: React.FC<SideToolbarProps> = ({
onSelect,
onAddShape,
onActivateShapeTool,
currentShape,
onPaintBrush,
onCanvasBackground,
onUploadImage,
@@ -153,21 +155,23 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
id: "add-shape",
label: "Add Shape",
icon: <FontAwesomeIcon icon={faShapes} className="h-5 w-5" />,
onClick: () => {
// Activate shape tool using currently selected shape if any and clear selection
store.selectNode(null);
if (!store.currentShape) {
store.setCurrentShape("rectangle");
}
store.setActiveTool("shape");
},
popout: (
<div
className={`flex items-center gap-1.5 rounded-full px-1.5 py-1.5 shadow-lg`}
>
{[
faSquare,
faCircle,
faPlay, // triangle
].map((faIcon, i) => (
<div className="flex items-center gap-1.5 rounded-full px-1.5 py-1.5 shadow-lg">
{[faSquare, faCircle, faTriangle].map((faIcon, i) => (
<button
key={i}
className={shapeIconBtn}
onClick={() => {
const shapes = ["rectangle", "circle", "triangle"] as const;
onAddShape(shapes[i]);
onActivateShapeTool(shapes[i]);
setOpen(null);
}}
>
@@ -177,14 +181,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
</div>
),
},
// {
// id: "generate",
// label: "Generate Image",
// icon: <FontAwesomeIcon icon={faSparkles} className="h-5 w-5" />,
// onClick: () => {
// onGenerateImage();
// },
// },
{
id: "upload",
label: "Upload Image",
@@ -204,27 +200,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
},
popout: BrushPopout,
},
// Commented out for now
// {
// id: "eraser",
// label: "Eraser",
// icon: <FontAwesomeIcon icon={faEraser} className="h-5 w-5" />,
// onClick: () => {
// onEraser(brushSize);
// },
// popout: (
// <div className="p-4">
// <SliderWithIndicator
// value={brushSize}
// onChange={(size) => {
// setBrushSize(size);
// onEraser(size);
// }}
// label="Eraser Size"
// />
// </div>
// ),
// },
{
id: "delete",
label: "Delete",
@@ -261,36 +236,6 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
store.redo(); // Assuming store has a redo method
},
},
// {
// id: "save-scene",
// label: "Save Scene",
// icon: <FontAwesomeIcon icon={faFileExport} className="h-5 w-5" />,
// onClick: () => {
// store.saveSceneToFile();
// },
// },
// {
// id: "load-scene",
// label: "Load Scene",
// icon: <FontAwesomeIcon icon={faFileImport} className="h-5 w-5" />,
// onClick: () => {
// const input = document.createElement("input");
// input.type = "file";
// input.accept = ".json";
// input.onchange = async (e: Event) => {
// const target = e.target as HTMLInputElement;
// if (target.files && target.files[0]) {
// const success = await store.loadSceneFromFile(target.files[0]);
// if (success) {
// console.log("Scene loaded successfully");
// } else {
// console.error("Failed to load scene");
// }
// }
// };
// input.click();
// },
// },
];
/* ------------------------------------------------ render ---------- */
@@ -307,7 +252,9 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
}
const { id, icon, onClick, popout, label } = tool;
const active = id === activeToolId;
const active =
id === activeToolId ||
(id === "add-shape" && activeToolId === "shape");
const btnStyle = active
? "bg-primary/30 border-2 !border-primary text-white"
: "hover:bg-white/10 text-white";
@@ -325,6 +272,21 @@ const SideToolbar: React.FC<SideToolbarProps> = ({
);
}
// Dynamic icon for shape tool
if (id === "add-shape" && activeToolId === "shape" && currentShape) {
const shapeIcons = {
rectangle: faSquare,
circle: faCircle,
triangle: faTriangle,
} as const;
displayIcon = (
<FontAwesomeIcon
icon={shapeIcons[currentShape]}
className="h-5 w-5"
/>
);
}
return (
<div key={id} className="relative">
<Tooltip
File diff suppressed because it is too large Load Diff