diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index e8f7129..ae40bf9 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -11,9 +11,11 @@ import { useTransformHandlers } from "./canvas/useTransformHandlers"; import { useObjectHandlers } from "./canvas/useObjectHandlers"; import { useMaskGroupState } from "./canvas/useMaskGroupState"; import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing"; +import { usePencilDrawing } from "./canvas/usePencilDrawing"; import ObjectsLayer from "./canvas/ObjectsLayer"; import GuidesLayer from "./canvas/GuidesLayer"; import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer"; +import PencilDrawingLayer from "./canvas/PencilDrawingLayer"; import { clearSmartGuides, drawSmartGuides, @@ -101,6 +103,12 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers(); const { drawingState, handleStageClick: handleCustomShapeClick, handleStageMouseMove: handleCustomShapeMouseMove } = useCustomShapeDrawing(); + const { + drawingState: pencilDrawingState, + handleStagePointerDown: handlePencilPointerDown, + handleStagePointerMove: handlePencilPointerMove, + handleStagePointerUp: handlePencilPointerUp, + } = usePencilDrawing(stageRef); useKeyboardMovement(); const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers(); @@ -372,14 +380,32 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { if (e.target?.name() !== "guide-line") { setSelectedGuideId(null); } + if (tool === "pencil") return; handleStageMouseDown(e); }; + const handleStagePointerDownWithGuideReset = (e: Konva.KonvaEventObject) => { + if (e.target?.name() !== "guide-line") { + setSelectedGuideId(null); + } + if (tool === "pencil") { + handlePencilPointerDown(e); + } + }; + const handleCombinedMouseMove = (e: Konva.KonvaEventObject) => { handleStageMouseMove(e); handleCustomShapeMouseMove(e); }; + const handleCombinedPointerMove = (e: Konva.KonvaEventObject) => { + handlePencilPointerMove(e); + }; + + const handleCombinedPointerUp = (e: Konva.KonvaEventObject) => { + handlePencilPointerUp(e); + }; + const scaledW = stageSize.width * finalScale; const scaledH = stageSize.height * finalScale; @@ -511,10 +537,13 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { height={stageSize.height * finalScale} scaleX={finalScale} scaleY={finalScale} - style={{ cursor: tool === "custom-shape" ? "crosshair" : undefined }} + style={{ cursor: tool === "custom-shape" || tool === "pencil" ? "crosshair" : undefined }} onMouseDown={handleStageMouseDownWithGuideReset} onMouseMove={handleCombinedMouseMove} onMouseUp={handleStageMouseUp} + onPointerDown={handleStagePointerDownWithGuideReset} + onPointerMove={handleCombinedPointerMove} + onPointerUp={handleCombinedPointerUp} onClick={handleCustomShapeClick} onDragMove={handleDragMove} onDragEnd={handleDragEnd} @@ -575,6 +604,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { /> + diff --git a/src/pages/editor/components/LayersList.tsx b/src/pages/editor/components/LayersList.tsx index 91820be..f92c8de 100644 --- a/src/pages/editor/components/LayersList.tsx +++ b/src/pages/editor/components/LayersList.tsx @@ -17,6 +17,7 @@ import { MouseSquare, Sticker, Polygon, + Brush, } from 'iconsax-react' import { useState } from 'react' import { useEditorStore } from '../store/editorStore' @@ -66,6 +67,8 @@ const LayersList = () => { return case 'custom-shape': return + case 'pencil': + return default: return } @@ -117,6 +120,8 @@ const LayersList = () => { return 'استیکر' case 'custom-shape': return 'شکل سفارشی' + case 'pencil': + return 'نقاشی' default: return 'لایه' } diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx index b2802f2..d66f143 100644 --- a/src/pages/editor/components/canvas/ObjectRenderer.tsx +++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx @@ -16,6 +16,7 @@ import { VideoShape, AudioShape, CustomShape, + PencilShape, } from "../tools"; import EditorTable from "@/components/EditorTable"; @@ -278,6 +279,9 @@ const ObjectRenderer = ({ case "custom-shape": shapeElement = ; break; + case "pencil": + shapeElement = ; + break; case "group": // Render group as a simple selection box // Objects inside group are rendered separately in EditorCanvas diff --git a/src/pages/editor/components/canvas/ObjectsLayer.tsx b/src/pages/editor/components/canvas/ObjectsLayer.tsx index 5722449..d67434d 100644 --- a/src/pages/editor/components/canvas/ObjectsLayer.tsx +++ b/src/pages/editor/components/canvas/ObjectsLayer.tsx @@ -58,7 +58,7 @@ const ObjectsLayer = ({ stageHeight, }: ObjectsLayerProps) => { return ( - + {/* Render group objects first */} {objects .filter((obj) => obj.type === "group") diff --git a/src/pages/editor/components/canvas/PencilDrawingLayer.tsx b/src/pages/editor/components/canvas/PencilDrawingLayer.tsx new file mode 100644 index 0000000..6d74156 --- /dev/null +++ b/src/pages/editor/components/canvas/PencilDrawingLayer.tsx @@ -0,0 +1,30 @@ +import { Layer, Line } from "react-konva"; +import type { PencilDrawingState } from "./usePencilDrawing"; + +type PencilDrawingLayerProps = { + drawingState: PencilDrawingState; +}; + +const PencilDrawingLayer = ({ drawingState }: PencilDrawingLayerProps) => { + const { isDrawing, points, stroke, strokeWidth } = drawingState; + + if (!isDrawing || points.length < 1) return null; + + const linePoints = points.flatMap((p) => [p.x, p.y]); + + return ( + + + + ); +}; + +export default PencilDrawingLayer; diff --git a/src/pages/editor/components/canvas/useDrawingHandlers.ts b/src/pages/editor/components/canvas/useDrawingHandlers.ts index 3a9de74..54d8862 100644 --- a/src/pages/editor/components/canvas/useDrawingHandlers.ts +++ b/src/pages/editor/components/canvas/useDrawingHandlers.ts @@ -27,8 +27,8 @@ export const useDrawingHandlers = () => { }; const handleStageMouseDown = (e: Konva.KonvaEventObject) => { - // custom-shape drawing is fully managed by useCustomShapeDrawing - if (tool === "custom-shape") return; + // custom-shape and pencil drawing are managed by dedicated hooks + if (tool === "custom-shape" || tool === "pencil") return; const stage = e.target.getStage(); if (!stage) return; @@ -98,7 +98,7 @@ export const useDrawingHandlers = () => { }; const handleStageMouseMove = (e: Konva.KonvaEventObject) => { - if (!isDrawing || tool === "select" || tool === "text" || tool === "custom-shape") return; + if (!isDrawing || tool === "select" || tool === "text" || tool === "custom-shape" || tool === "pencil") return; const stage = e.target.getStage(); if (!stage) return; diff --git a/src/pages/editor/components/canvas/usePencilDrawing.ts b/src/pages/editor/components/canvas/usePencilDrawing.ts new file mode 100644 index 0000000..dd30812 --- /dev/null +++ b/src/pages/editor/components/canvas/usePencilDrawing.ts @@ -0,0 +1,276 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import Konva from "konva"; +import { useEditorStore } from "@/pages/editor/store/editorStore"; +import type { EditorObject } from "@/pages/editor/store/editorStore"; +import { usePencilDefaultsStore } from "@/pages/editor/store/pencilDefaultsStore"; + +const MIN_POINT_DISTANCE = 1.5; +const MIN_POINTS = 2; +const MIN_DRAW_DISTANCE = 4; +const DEFAULT_TENSION = 0.5; + +export type DrawPoint = { x: number; y: number }; + +export type PencilDrawingState = { + isDrawing: boolean; + points: DrawPoint[]; + stroke: string; + strokeWidth: number; +}; + +const INITIAL_STATE: PencilDrawingState = { + isDrawing: false, + points: [], + stroke: "#000000", + strokeWidth: 3, +}; + +const getCanvasPos = (stage: Konva.Stage): DrawPoint | null => { + const pos = stage.getPointerPosition(); + if (!pos) return null; + return { + x: pos.x / stage.scaleX(), + y: pos.y / stage.scaleY(), + }; +}; + +const getStagePosFromClient = ( + stage: Konva.Stage, + clientX: number, + clientY: number, +): DrawPoint | null => { + const container = stage.container(); + const rect = container.getBoundingClientRect(); + const scaleX = stage.scaleX(); + const scaleY = stage.scaleY(); + return { + x: (clientX - rect.left) / scaleX, + y: (clientY - rect.top) / scaleY, + }; +}; + +const isFarEnough = (a: DrawPoint, b: DrawPoint, minDistance: number): boolean => { + const dx = a.x - b.x; + const dy = a.y - b.y; + return dx * dx + dy * dy >= minDistance * minDistance; +}; + +const getPathLength = (points: DrawPoint[]): number => { + let length = 0; + for (let i = 1; i < points.length; i += 1) { + length += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y); + } + return length; +}; + +const buildPencilObject = ( + points: DrawPoint[], + stroke: string, + strokeWidth: number, +): EditorObject => { + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + + const normalizedPoints = points.flatMap((p) => [p.x - minX, p.y - minY]); + + return { + id: `pencil-${Date.now()}`, + type: "pencil", + x: minX, + y: minY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY), + points: normalizedPoints, + closed: false, + stroke, + strokeWidth, + tension: DEFAULT_TENSION, + opacity: 100, + rotation: 0, + }; +}; + +export const usePencilDrawing = (stageRef: React.RefObject) => { + const pointsRef = useRef([]); + const isDrawingRef = useRef(false); + const pointerIdRef = useRef(null); + const strokeRef = useRef("#000000"); + const strokeWidthRef = useRef(3); + + const [drawingState, setDrawingState] = useState(INITIAL_STATE); + + const { tool, addObject } = useEditorStore(); + const { defaults } = usePencilDefaultsStore(); + + const reset = useCallback(() => { + pointsRef.current = []; + isDrawingRef.current = false; + pointerIdRef.current = null; + setDrawingState(INITIAL_STATE); + }, []); + + useEffect(() => { + if (tool !== "pencil") { + reset(); + } + }, [tool, reset]); + + const releasePointerCapture = useCallback(() => { + const stage = stageRef.current; + const pointerId = pointerIdRef.current; + if (!stage || pointerId === null) return; + + try { + stage.container().releasePointerCapture(pointerId); + } catch { + // pointer may already be released + } + pointerIdRef.current = null; + }, [stageRef]); + + const commitStroke = useCallback(() => { + const points = pointsRef.current; + releasePointerCapture(); + + if (points.length < MIN_POINTS || getPathLength(points) < MIN_DRAW_DISTANCE) { + reset(); + return; + } + + const obj = buildPencilObject(points, strokeRef.current, strokeWidthRef.current); + useEditorStore.getState().commitObjectHistoryBeforeChange(); + addObject(obj); + reset(); + }, [addObject, releasePointerCapture, reset]); + + const appendPoint = useCallback((pos: DrawPoint) => { + const points = pointsRef.current; + const last = points[points.length - 1]; + if (last && !isFarEnough(last, pos, MIN_POINT_DISTANCE)) { + return; + } + + pointsRef.current = [...points, pos]; + setDrawingState((prev) => ({ + ...prev, + points: pointsRef.current, + })); + }, []); + + const startDrawing = useCallback( + (pos: DrawPoint, pointerId: number) => { + strokeRef.current = defaults.stroke; + strokeWidthRef.current = defaults.strokeWidth; + isDrawingRef.current = true; + pointerIdRef.current = pointerId; + pointsRef.current = [pos]; + + setDrawingState({ + isDrawing: true, + points: [pos], + stroke: defaults.stroke, + strokeWidth: defaults.strokeWidth, + }); + }, + [defaults.stroke, defaults.strokeWidth], + ); + + const handleStagePointerDown = useCallback( + (e: Konva.KonvaEventObject) => { + if (tool !== "pencil") return; + if (e.evt.button !== 0) return; + + e.evt.preventDefault(); + e.cancelBubble = true; + + const stage = stageRef.current ?? e.target.getStage(); + if (!stage) return; + + const pos = getCanvasPos(stage); + if (!pos) return; + + try { + stage.container().setPointerCapture(e.evt.pointerId); + } catch { + // ignore if capture is unavailable + } + + startDrawing(pos, e.evt.pointerId); + }, + [tool, stageRef, startDrawing], + ); + + const handleStagePointerMove = useCallback( + (e: Konva.KonvaEventObject) => { + if (tool !== "pencil" || !isDrawingRef.current) return; + if (pointerIdRef.current !== null && e.evt.pointerId !== pointerIdRef.current) return; + + const stage = stageRef.current ?? e.target.getStage(); + if (!stage) return; + + const pos = getCanvasPos(stage); + if (!pos) return; + + appendPoint(pos); + }, + [tool, appendPoint, stageRef], + ); + + const handleStagePointerUp = useCallback( + (e: Konva.KonvaEventObject) => { + if (tool !== "pencil" || !isDrawingRef.current) return; + if (pointerIdRef.current !== null && e.evt.pointerId !== pointerIdRef.current) return; + + e.evt.preventDefault(); + e.cancelBubble = true; + commitStroke(); + }, + [tool, commitStroke], + ); + + // Keep drawing smooth when pointer leaves the canvas while still pressed + useEffect(() => { + if (tool !== "pencil") return; + + const handleWindowPointerMove = (e: PointerEvent) => { + if (!isDrawingRef.current) return; + if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return; + + const stage = stageRef.current; + if (!stage) return; + + const pos = getStagePosFromClient(stage, e.clientX, e.clientY); + if (!pos) return; + + appendPoint(pos); + }; + + const handleWindowPointerUp = (e: PointerEvent) => { + if (!isDrawingRef.current) return; + if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return; + + commitStroke(); + }; + + window.addEventListener("pointermove", handleWindowPointerMove); + window.addEventListener("pointerup", handleWindowPointerUp); + window.addEventListener("pointercancel", handleWindowPointerUp); + + return () => { + window.removeEventListener("pointermove", handleWindowPointerMove); + window.removeEventListener("pointerup", handleWindowPointerUp); + window.removeEventListener("pointercancel", handleWindowPointerUp); + }; + }, [tool, appendPoint, commitStroke, stageRef]); + + return { + drawingState, + handleStagePointerDown, + handleStagePointerMove, + handleStagePointerUp, + }; +}; diff --git a/src/pages/editor/components/canvas/useSelectionHandlers.ts b/src/pages/editor/components/canvas/useSelectionHandlers.ts index bf739a1..bf64d6d 100644 --- a/src/pages/editor/components/canvas/useSelectionHandlers.ts +++ b/src/pages/editor/components/canvas/useSelectionHandlers.ts @@ -20,6 +20,10 @@ export const useSelectionHandlers = () => { setSelectedCellId, } = useEditorStore.getState(); + if (tool === "pencil" || tool === "custom-shape") { + return; + } + if (tool !== "select") { setTool("select"); } diff --git a/src/pages/editor/components/canvas/useTransformHandlers.ts b/src/pages/editor/components/canvas/useTransformHandlers.ts index e565b92..b85655c 100644 --- a/src/pages/editor/components/canvas/useTransformHandlers.ts +++ b/src/pages/editor/components/canvas/useTransformHandlers.ts @@ -495,7 +495,7 @@ export const useTransformHandlers = ( : undefined, }); } - } else if (selectedObject.type === "custom-shape") { + } else if (selectedObject.type === "custom-shape" || selectedObject.type === "pencil") { // Scale each vertex in-place; no width/height anchor needed const oldPoints = selectedObject.points ?? []; const newPoints = oldPoints.map((val, i) => diff --git a/src/pages/editor/components/sidebar/ObjectSettings.tsx b/src/pages/editor/components/sidebar/ObjectSettings.tsx index b303a4b..ede5b4f 100644 --- a/src/pages/editor/components/sidebar/ObjectSettings.tsx +++ b/src/pages/editor/components/sidebar/ObjectSettings.tsx @@ -77,7 +77,7 @@ const ObjectSettings = ({ selectedObject, pageWidth, pageHeight, onUpdate, onDel {roundedSelectedObject.type === "rectangle" && } - {(roundedSelectedObject.type === "line" || roundedSelectedObject.type === "arrow") && } + {(roundedSelectedObject.type === "line" || roundedSelectedObject.type === "arrow" || roundedSelectedObject.type === "pencil") && } {roundedSelectedObject.type === "image" && } diff --git a/src/pages/editor/components/sidebar/ToolInstructions.tsx b/src/pages/editor/components/sidebar/ToolInstructions.tsx index 8d54afb..c86bf3b 100644 --- a/src/pages/editor/components/sidebar/ToolInstructions.tsx +++ b/src/pages/editor/components/sidebar/ToolInstructions.tsx @@ -37,6 +37,7 @@ const ToolInstructions = ({ tool }: ToolInstructionsProps) => { rectangle: , line: , arrow: , + pencil: , "custom-shape": , text: , sticker: , diff --git a/src/pages/editor/components/sidebar/ToolsBar.tsx b/src/pages/editor/components/sidebar/ToolsBar.tsx index 3695e5e..fcfe8f5 100644 --- a/src/pages/editor/components/sidebar/ToolsBar.tsx +++ b/src/pages/editor/components/sidebar/ToolsBar.tsx @@ -35,7 +35,7 @@ const ToolsBar = ({ tool, onToolClick }: ToolsBarProps) => { {tools.map((item, index) => { const isActive = item.tool === "rectangle" - ? tool === "rectangle" || tool === "line" || tool === "arrow" || tool === "custom-shape" + ? tool === "rectangle" || tool === "line" || tool === "arrow" || tool === "custom-shape" || tool === "pencil" : tool === item.tool; const iconColor = "black"; const iconVariant: "Bold" | "Outline" | "Broken" | "Bulk" | "Linear" | "TwoTone" | undefined = isActive ? "Bold" : "Outline"; diff --git a/src/pages/editor/components/sidebar/instructions/CustomShapeInstruction.tsx b/src/pages/editor/components/sidebar/instructions/CustomShapeInstruction.tsx index ba36d7b..f3a9d60 100644 --- a/src/pages/editor/components/sidebar/instructions/CustomShapeInstruction.tsx +++ b/src/pages/editor/components/sidebar/instructions/CustomShapeInstruction.tsx @@ -1,12 +1,15 @@ const CustomShapeInstruction = () => (
-

رسم شکل سفارشی

+

رسم نقطه‌به‌نقطه (Pen)

  • • روی کانوس کلیک کنید تا نقاط اضافه شود
  • • روی نقطه اول کلیک کنید تا شکل بسته شود
  • • دوبار کلیک کنید تا شکل تکمیل شود
  • • Escape را بزنید تا لغو شود
+

+ برای نقاشی آزاد (کلیک و بکش) از ابزار «قلم» استفاده کنید. +

); diff --git a/src/pages/editor/components/sidebar/instructions/PencilInstruction.tsx b/src/pages/editor/components/sidebar/instructions/PencilInstruction.tsx new file mode 100644 index 0000000..97d9a8c --- /dev/null +++ b/src/pages/editor/components/sidebar/instructions/PencilInstruction.tsx @@ -0,0 +1,43 @@ +import ColorPicker from "@/components/ColorPicker"; +import Input from "@/components/Input"; +import { usePencilDefaultsStore } from "@/pages/editor/store/pencilDefaultsStore"; +import { Brush } from "iconsax-react"; +import { type FC } from "react"; + +const PencilInstruction: FC = () => { + const { defaults, updateDefaults } = usePencilDefaultsStore(); + + return ( +
+
+ +
+

کلیک کرده و بکشید تا آزاد نقاشی کنید (مانند Pencil در Figma).

+

+ برای رسم نقطه‌به‌نقطه از ابزار «سفارشی» استفاده کنید. +

+
+
+ + updateDefaults({ stroke: value })} + /> + + updateDefaults({ + strokeWidth: Math.max(1, parseInt(e.target.value) || 1), + }) + } + /> +
+ ); +}; + +export default PencilInstruction; diff --git a/src/pages/editor/components/sidebar/instructions/RectangleInstruction.tsx b/src/pages/editor/components/sidebar/instructions/RectangleInstruction.tsx index 658f079..866f12b 100644 --- a/src/pages/editor/components/sidebar/instructions/RectangleInstruction.tsx +++ b/src/pages/editor/components/sidebar/instructions/RectangleInstruction.tsx @@ -6,9 +6,10 @@ import { Switch } from "@/components/ui/switch"; import { clx } from "@/helpers/utils"; import { useEditorStore } from "@/pages/editor/store/editorStore"; import { useShapeStore, type ShapeType } from "@/pages/editor/store/shapeStore"; -import { ArrowLeft, Minus, PenTool } from "iconsax-react"; +import { ArrowLeft, Brush, Minus, PenTool } from "iconsax-react"; import { type FC } from "react"; import CustomShapeInstruction from "./CustomShapeInstruction"; +import PencilInstruction from "./PencilInstruction"; const shapeOptions: Array<{ id: ShapeType; label: string; icon: string; alt: string }> = [ { id: "square", label: "مربع", icon: SquareIcon, alt: "square" }, @@ -18,6 +19,7 @@ const shapeOptions: Array<{ id: ShapeType; label: string; icon: string; alt: str ]; const drawOptions = [ + { tool: "pencil" as const, label: "قلم", Icon: Brush }, { tool: "line" as const, label: "خط", Icon: Minus }, { tool: "arrow" as const, label: "پیکان", Icon: ArrowLeft }, { tool: "custom-shape" as const, label: "سفارشی", Icon: PenTool }, @@ -81,6 +83,8 @@ const RectangleInstruction: FC = () => {

{tool === "line" ? "برای رسم خط، روی کانوس کلیک کرده و بکشید" : "برای رسم پیکان، روی کانوس کلیک کرده و بکشید"}

)} + {tool === "pencil" && } + {tool === "custom-shape" && (
diff --git a/src/pages/editor/components/tools/PencilShape.tsx b/src/pages/editor/components/tools/PencilShape.tsx new file mode 100644 index 0000000..26674a1 --- /dev/null +++ b/src/pages/editor/components/tools/PencilShape.tsx @@ -0,0 +1,61 @@ +import { useRef } from "react"; +import { Line } from "react-konva"; +import Konva from "konva"; +import type { ShapeProps } from "./types"; + +const PencilShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { + const shapeRef = useRef(null); + + const points = obj.points ?? []; + if (points.length < 4) return null; + + const actualStrokeWidth = obj.strokeWidth ?? 3; + const hasStroke = actualStrokeWidth > 0; + + const displayStroke = isSelected + ? (hasStroke ? obj.stroke : "#3b82f6") + : (hasStroke ? obj.stroke : "#000000"); + + const displayStrokeWidth = isSelected + ? (hasStroke ? actualStrokeWidth : 3) + : actualStrokeWidth; + + return ( + { + if (shapeRef.current) { + onSelect(obj.id, shapeRef.current, e); + } + }} + onDragMove={(e) => { + e.target.getLayer()?.batchDraw(); + }} + onDragEnd={(e) => { + const node = e.target; + onUpdate(obj.id, { + x: node.x(), + y: node.y(), + }); + }} + /> + ); +}; + +export default PencilShape; diff --git a/src/pages/editor/components/tools/index.ts b/src/pages/editor/components/tools/index.ts index 4325a84..7b8f618 100644 --- a/src/pages/editor/components/tools/index.ts +++ b/src/pages/editor/components/tools/index.ts @@ -1,5 +1,6 @@ export { default as RectangleShape } from "./RectangleShape"; export { default as CustomShape } from "./CustomShape"; +export { default as PencilShape } from "./PencilShape"; export { default as CircleShape } from "./CircleShape"; export { default as TriangleShape } from "./TriangleShape"; export { default as AbstractShape } from "./AbstractShape"; diff --git a/src/pages/editor/store/editorStore.helpers.ts b/src/pages/editor/store/editorStore.helpers.ts index 3416214..be5e6d1 100644 --- a/src/pages/editor/store/editorStore.helpers.ts +++ b/src/pages/editor/store/editorStore.helpers.ts @@ -1,5 +1,5 @@ import type { EditorObject, Page, TableCell } from "./editorStore.types"; -import { normalizeCustomShapeObject } from "../utils/customShape"; +import { normalizeCustomShapeObject, normalizePencilObject } from "../utils/customShape"; export const createTableCellId = (row: number, col: number) => `cell-${row}-${col}`; @@ -390,4 +390,6 @@ export const ensureMissingGroupObjects = ( }; export const normalizePageObjects = (objects: EditorObject[]): EditorObject[] => - ensureMissingGroupObjects(objects).map(normalizeCustomShapeObject); + ensureMissingGroupObjects(objects) + .map(normalizeCustomShapeObject) + .map(normalizePencilObject); diff --git a/src/pages/editor/store/editorStore.types.ts b/src/pages/editor/store/editorStore.types.ts index 711b130..b372941 100644 --- a/src/pages/editor/store/editorStore.types.ts +++ b/src/pages/editor/store/editorStore.types.ts @@ -26,7 +26,8 @@ export type ToolType = | "link" | "grid" | "group" - | "custom-shape"; + | "custom-shape" + | "pencil"; export type TableCell = { id: string; @@ -99,6 +100,8 @@ export type EditorObject = { points?: number[]; /** آیا شکل چندضلعی بسته است (پیش‌فرض true برای custom-shape کامل‌شده) */ closed?: boolean; + /** نرمی منحنی برای مسیرهای آزاد (pencil) — مقدار Konva Line.tension */ + tension?: number; }; export type PageGuide = { diff --git a/src/pages/editor/store/pencilDefaultsStore.ts b/src/pages/editor/store/pencilDefaultsStore.ts new file mode 100644 index 0000000..9099ddb --- /dev/null +++ b/src/pages/editor/store/pencilDefaultsStore.ts @@ -0,0 +1,22 @@ +import { create } from "zustand"; + +export type PencilDefaults = { + stroke: string; + strokeWidth: number; +}; + +type PencilDefaultsStoreType = { + defaults: PencilDefaults; + updateDefaults: (updates: Partial) => void; +}; + +export const usePencilDefaultsStore = create((set) => ({ + defaults: { + stroke: "#000000", + strokeWidth: 3, + }, + updateDefaults: (updates) => + set((state) => ({ + defaults: { ...state.defaults, ...updates }, + })), +})); diff --git a/src/pages/editor/utils/customShape.ts b/src/pages/editor/utils/customShape.ts index ae3885a..b830703 100644 --- a/src/pages/editor/utils/customShape.ts +++ b/src/pages/editor/utils/customShape.ts @@ -71,3 +71,44 @@ export const normalizeCustomShapeObject = ( return next; }; + +const MIN_PENCIL_POINTS = 4; + +/** + * Ensures pencil stroke points are relative to obj.x/obj.y and bbox matches width/height. + */ +export const normalizePencilObject = (obj: EditorObject): EditorObject => { + if (obj.type !== "pencil" || !obj.points || obj.points.length < MIN_PENCIL_POINTS) { + return obj; + } + + let next: EditorObject = { ...obj, closed: false }; + + if ( + next.opacity !== undefined && + next.opacity > 0 && + next.opacity <= 1 + ) { + next = { ...next, opacity: next.opacity * 100 }; + } + + const points = next.points!; + const { minX, minY, width, height } = getCustomShapePointBounds(points); + + if (minX !== 0 || minY !== 0) { + next = { + ...next, + x: (next.x ?? 0) + minX, + y: (next.y ?? 0) + minY, + width, + height, + points: points.map((val, i) => + i % 2 === 0 ? val - minX : val - minY, + ), + }; + } else if (next.width !== width || next.height !== height) { + next = { ...next, width, height }; + } + + return next; +}; diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx index 6d39fa0..cc58033 100644 --- a/src/pages/viewer/components/BookPage.tsx +++ b/src/pages/viewer/components/BookPage.tsx @@ -720,6 +720,56 @@ const BookPage = forwardRef( ); } + case 'pencil': { + const rawPoints = obj.points ?? []; + if (rawPoints.length < 4) return null; + + const { minX, minY, width: bboxW, height: bboxH } = + getCustomShapePointBounds(rawPoints); + const width = bboxW * scale; + const height = bboxH * scale; + const pointsStr = Array.from( + { length: rawPoints.length / 2 }, + (_, i) => + `${(rawPoints[i * 2] - minX) * scale},${(rawPoints[i * 2 + 1] - minY) * scale}`, + ).join(' '); + + const strokeWidth = actualStrokeWidth * scale; + const strokeColor = obj.stroke || '#000000'; + + return ( + + + + ); + } + case 'grid': { if (!obj.tableData) return null; diff --git a/src/pages/viewer/utils/dataTransformer.ts b/src/pages/viewer/utils/dataTransformer.ts index b95fc2f..c74f8ab 100644 --- a/src/pages/viewer/utils/dataTransformer.ts +++ b/src/pages/viewer/utils/dataTransformer.ts @@ -1,5 +1,5 @@ import type { EditorObject, TableData } from "@/pages/editor/store/editorStore"; -import { normalizeCustomShapeObject } from "@/pages/editor/utils/customShape"; +import { normalizeCustomShapeObject, normalizePencilObject } from "@/pages/editor/utils/customShape"; import type { PageData } from "../types"; type ViewerDataPage = { @@ -53,6 +53,7 @@ type ViewerDataPage = { maskInvert?: boolean; points?: number[]; closed?: boolean; + tension?: number; }>; }; @@ -176,7 +177,18 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] { if (obj.closed !== undefined) baseObject.closed = obj.closed; } - return normalizeCustomShapeObject(baseObject); + if (obj.type === "pencil") { + if (obj.points !== undefined) baseObject.points = obj.points; + if (obj.closed !== undefined) baseObject.closed = obj.closed; + if (obj.tension !== undefined) baseObject.tension = obj.tension; + } + + const normalized = + obj.type === "pencil" + ? normalizePencilObject(baseObject) + : normalizeCustomShapeObject(baseObject); + + return normalized; }); return {