diff --git a/src/Handlers/ToolsHandler/assignUtil.js b/src/Handlers/ToolsHandler/assignUtil.js index 59452e6..fd00cb2 100644 --- a/src/Handlers/ToolsHandler/assignUtil.js +++ b/src/Handlers/ToolsHandler/assignUtil.js @@ -4,6 +4,7 @@ import { Font, Diamond, Polygon, + Cylinder, Arrow, Line, Eraser, @@ -16,6 +17,7 @@ const toolFactory = { [TOOL_CONSTANTS.FONT]: new Font(), [TOOL_CONSTANTS.DIAMOND]: new Diamond(), [TOOL_CONSTANTS.POLYGON]: new Polygon(), + [TOOL_CONSTANTS.CYLINDER]: new Cylinder(), [TOOL_CONSTANTS.ARROW]: new Arrow(), [TOOL_CONSTANTS.LINE]: new Line(), [TOOL_CONSTANTS.ERASER]: new Eraser(), diff --git a/src/Handlers/ToolsHandler/tools/cylinder.js b/src/Handlers/ToolsHandler/tools/cylinder.js new file mode 100644 index 0000000..5a25e4e --- /dev/null +++ b/src/Handlers/ToolsHandler/tools/cylinder.js @@ -0,0 +1,102 @@ +import { fabric } from "fabric"; +import { Tool } from "../toolGeneric"; +import { resolveToolStyle } from "../toolStyle"; + +// Default size for a plain click (no drag), like the other shapes. +const DEFAULT_W = 80; +const DEFAULT_H = 100; + +// SVG path for a database cylinder in a w x h box (top-left origin). Built from +// cubic beziers (not SVG arcs) so the rim curve directions are unambiguous and +// fabric fills it cleanly. `body` is the closed silhouette — straight sides, a +// back top rim (bulges up) and a front bottom rim (bulges down), so its fill +// covers the whole cylinder incl. the top ellipse band. `lid` is the top front +// rim, stroked on top so the lid line reads. k = ellipse bezier constant. +const cylinderPath = (w, h) => { + const rx = w / 2; + const ry = Math.max(1, Math.min(h * 0.16, w * 0.35, h / 2 - 0.5)); + const k = 0.5523; + const kx = rx * k; + const ky = ry * k; + const topC = ry; // centre line of the top ellipse + const botC = h - ry; // centre line of the bottom ellipse + const body = + `M 0 ${topC} L 0 ${botC} ` + + `C 0 ${botC + ky} ${rx - kx} ${botC + ry} ${rx} ${botC + ry} ` + + `C ${rx + kx} ${botC + ry} ${w} ${botC + ky} ${w} ${botC} ` + + `L ${w} ${topC} ` + + `C ${w} ${topC - ky} ${rx + kx} ${topC - ry} ${rx} ${topC - ry} ` + + `C ${rx - kx} ${topC - ry} 0 ${topC - ky} 0 ${topC} Z`; + const lid = + `M 0 ${topC} ` + + `C 0 ${topC + ky} ${rx - kx} ${topC + ry} ${rx} ${topC + ry} ` + + `C ${rx + kx} ${topC + ry} ${w} ${topC + ky} ${w} ${topC}`; + return `${body} ${lid}`; +}; + +export class Cylinder extends Tool { + constructor() { + super(); + this.origX = null; + this.origY = null; + this.pointer = null; + this.cylinder = null; + } + + // Rewrite the path for the given box and pin its top-left, so the shape grows + // from the drag start (like the rectangle) without accumulating scale. The + // rim arcs bulge to exactly the box edges, but fabric's arc bbox misses the + // bottom bulge — set width/height/pathOffset to the true box so selection and + // binding match what's drawn. + applyBox(left, top, width, height) { + this.cylinder._setPath(fabric.util.parsePath(cylinderPath(width, height))); + this.cylinder.set({ + width, + height, + pathOffset: { x: width / 2, y: height / 2 }, + left, + top, + }); + this.cylinder.setCoords(); + } + + create(canvas, event) { + this.pointer = canvas.getPointer(event.e); + this.origX = this.pointer.x; + this.origY = this.pointer.y; + this.cylinder = new fabric.Path(cylinderPath(1, 1), { + left: this.origX, + top: this.origY, + originX: "left", + originY: "top", + ...resolveToolStyle(canvas), + objectCaching: false, // recompute the bounding box as it is dragged + selectable: true, + }); + canvas.add(this.cylinder); + } + + draw(canvas, event) { + if (!this.cylinder) { + return; + } + this.pointer = canvas.getPointer(event.e); + // min() (not abs) so dragging up/left works in negative scene coords too. + const left = Math.min(this.origX, this.pointer.x); + const top = Math.min(this.origY, this.pointer.y); + const width = Math.max(Math.abs(this.origX - this.pointer.x), 1); + const height = Math.max(Math.abs(this.origY - this.pointer.y), 1); + this.applyBox(left, top, width, height); + } + + done() { + if (!this.cylinder) { + return; + } + // A plain click leaves a near-zero shape — drop a default-sized one instead. + if (this.cylinder.width < 5 || this.cylinder.height < 5) { + this.applyBox(this.origX, this.origY, DEFAULT_W, DEFAULT_H); + } + this.cylinder.setCoords(); + } +} diff --git a/src/Handlers/ToolsHandler/tools/cylinder.test.js b/src/Handlers/ToolsHandler/tools/cylinder.test.js new file mode 100644 index 0000000..3197165 --- /dev/null +++ b/src/Handlers/ToolsHandler/tools/cylinder.test.js @@ -0,0 +1,52 @@ +import { Cylinder } from "./cylinder"; +import { isBindable } from "../../../utils/binding"; + +// Minimal canvas stub: the tool only needs getPointer + add. +const mockCanvas = () => { + const objects = []; + return { + objects, + add: (o) => objects.push(o), + getPointer: (e) => ({ x: e.clientX, y: e.clientY }), + }; +}; +const evt = (x, y) => ({ e: { clientX: x, clientY: y } }); + +const drawCylinder = (from, to) => { + const canvas = mockCanvas(); + const tool = new Cylinder(); + tool.create(canvas, evt(from.x, from.y)); + tool.draw(canvas, evt(to.x, to.y)); + tool.done(canvas); + return { canvas, shape: canvas.objects[0] }; +}; + +describe("Cylinder tool", () => { + test("drag-to-size builds a path sized to the drag box", () => { + const { shape } = drawCylinder({ x: 100, y: 100 }, { x: 260, y: 300 }); + expect(shape.type).toBe("path"); + expect(Math.round(shape.width)).toBe(160); + expect(Math.round(shape.height)).toBe(200); + expect(Math.round(shape.left)).toBe(100); + expect(Math.round(shape.top)).toBe(100); + }); + + test("dragging up/left still sizes from the top-left corner", () => { + const { shape } = drawCylinder({ x: 300, y: 300 }, { x: 200, y: 220 }); + expect(Math.round(shape.left)).toBe(200); + expect(Math.round(shape.top)).toBe(220); + expect(Math.round(shape.width)).toBe(100); + expect(Math.round(shape.height)).toBe(80); + }); + + test("a plain click (no drag) drops a default-sized cylinder", () => { + const { shape } = drawCylinder({ x: 50, y: 50 }, { x: 51, y: 51 }); + expect(shape.width).toBeGreaterThan(10); + expect(shape.height).toBeGreaterThan(10); + }); + + test("the cylinder is a bindable shape (arrows can attach to it)", () => { + const { shape } = drawCylinder({ x: 0, y: 0 }, { x: 120, y: 120 }); + expect(isBindable(shape)).toBe(true); + }); +}); diff --git a/src/Handlers/ToolsHandler/tools/index.js b/src/Handlers/ToolsHandler/tools/index.js index 200dc75..32db75e 100644 --- a/src/Handlers/ToolsHandler/tools/index.js +++ b/src/Handlers/ToolsHandler/tools/index.js @@ -3,6 +3,7 @@ export { Circle } from "./circle"; export { Font } from "./font"; export { Diamond } from "./diamond"; export { Polygon } from "./polygon"; +export { Cylinder } from "./cylinder"; export { Arrow } from "./arrow"; export { Line } from "./line"; export { Eraser } from "./eraser"; diff --git a/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx b/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx index 48d9756..bd18bef 100644 --- a/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx +++ b/src/components/CanvasEditor/PropertiesPanel/PropertiesPanel.jsx @@ -46,6 +46,7 @@ const STYLEABLE_TOOLS = new Set([ TOOL_CONSTANTS.CIRCLE, TOOL_CONSTANTS.DIAMOND, TOOL_CONSTANTS.POLYGON, + TOOL_CONSTANTS.CYLINDER, TOOL_CONSTANTS.LINE, TOOL_CONSTANTS.ARROW, TOOL_CONSTANTS.FONT, diff --git a/src/constants/IconTools.js b/src/constants/IconTools.js index 3522b67..681996c 100644 --- a/src/constants/IconTools.js +++ b/src/constants/IconTools.js @@ -5,6 +5,7 @@ import { Circle, Diamond, Hexagon, + Cylinder, Minus, ArrowRight, Type, @@ -22,6 +23,7 @@ export const iconToolsMaps = [ { icon: Circle, title: "Circle", id: TOOL_CONSTANTS.CIRCLE }, { icon: Diamond, title: "Diamond", id: TOOL_CONSTANTS.DIAMOND }, { icon: Hexagon, title: "Polygon", id: TOOL_CONSTANTS.POLYGON }, + { icon: Cylinder, title: "Cylinder", id: TOOL_CONSTANTS.CYLINDER }, { icon: Minus, title: "Line", id: TOOL_CONSTANTS.LINE }, { icon: ArrowRight, title: "Arrow", id: TOOL_CONSTANTS.ARROW }, { icon: Type, title: "Text", id: TOOL_CONSTANTS.FONT }, diff --git a/src/constants/tools.js b/src/constants/tools.js index 2960ced..db10376 100644 --- a/src/constants/tools.js +++ b/src/constants/tools.js @@ -10,6 +10,7 @@ export const TOOL_CONSTANTS = { BACKGROUND_COLOR: "bgColors", DIAMOND: "diamond", POLYGON: "polygon", + CYLINDER: "cylinder", ERASER: "eraser", ICONS: "icons", }; @@ -59,6 +60,10 @@ export const TOOL_FUNCTIONS = { createOnClick: true, onMove: true, }, + [TOOL_CONSTANTS.CYLINDER]: { + createOnClick: true, + onMove: true, + }, [TOOL_CONSTANTS.ERASER]: { createOnClick: true, onMove: true,