paint
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
hamid zarghami
2026-06-20 15:32:02 +03:30
parent b13a46ec65
commit ca02a621da
23 changed files with 607 additions and 15 deletions
+31 -1
View File
@@ -11,9 +11,11 @@ import { useTransformHandlers } from "./canvas/useTransformHandlers";
import { useObjectHandlers } from "./canvas/useObjectHandlers"; import { useObjectHandlers } from "./canvas/useObjectHandlers";
import { useMaskGroupState } from "./canvas/useMaskGroupState"; import { useMaskGroupState } from "./canvas/useMaskGroupState";
import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing"; import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing";
import { usePencilDrawing } from "./canvas/usePencilDrawing";
import ObjectsLayer from "./canvas/ObjectsLayer"; import ObjectsLayer from "./canvas/ObjectsLayer";
import GuidesLayer from "./canvas/GuidesLayer"; import GuidesLayer from "./canvas/GuidesLayer";
import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer"; import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer";
import PencilDrawingLayer from "./canvas/PencilDrawingLayer";
import { import {
clearSmartGuides, clearSmartGuides,
drawSmartGuides, drawSmartGuides,
@@ -101,6 +103,12 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers(); const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
const { drawingState, handleStageClick: handleCustomShapeClick, handleStageMouseMove: handleCustomShapeMouseMove } = const { drawingState, handleStageClick: handleCustomShapeClick, handleStageMouseMove: handleCustomShapeMouseMove } =
useCustomShapeDrawing(); useCustomShapeDrawing();
const {
drawingState: pencilDrawingState,
handleStagePointerDown: handlePencilPointerDown,
handleStagePointerMove: handlePencilPointerMove,
handleStagePointerUp: handlePencilPointerUp,
} = usePencilDrawing(stageRef);
useKeyboardMovement(); useKeyboardMovement();
const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers(); const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers();
@@ -372,14 +380,32 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
if (e.target?.name() !== "guide-line") { if (e.target?.name() !== "guide-line") {
setSelectedGuideId(null); setSelectedGuideId(null);
} }
if (tool === "pencil") return;
handleStageMouseDown(e); handleStageMouseDown(e);
}; };
const handleStagePointerDownWithGuideReset = (e: Konva.KonvaEventObject<PointerEvent>) => {
if (e.target?.name() !== "guide-line") {
setSelectedGuideId(null);
}
if (tool === "pencil") {
handlePencilPointerDown(e);
}
};
const handleCombinedMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => { const handleCombinedMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
handleStageMouseMove(e); handleStageMouseMove(e);
handleCustomShapeMouseMove(e); handleCustomShapeMouseMove(e);
}; };
const handleCombinedPointerMove = (e: Konva.KonvaEventObject<PointerEvent>) => {
handlePencilPointerMove(e);
};
const handleCombinedPointerUp = (e: Konva.KonvaEventObject<PointerEvent>) => {
handlePencilPointerUp(e);
};
const scaledW = stageSize.width * finalScale; const scaledW = stageSize.width * finalScale;
const scaledH = stageSize.height * finalScale; const scaledH = stageSize.height * finalScale;
@@ -511,10 +537,13 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
height={stageSize.height * finalScale} height={stageSize.height * finalScale}
scaleX={finalScale} scaleX={finalScale}
scaleY={finalScale} scaleY={finalScale}
style={{ cursor: tool === "custom-shape" ? "crosshair" : undefined }} style={{ cursor: tool === "custom-shape" || tool === "pencil" ? "crosshair" : undefined }}
onMouseDown={handleStageMouseDownWithGuideReset} onMouseDown={handleStageMouseDownWithGuideReset}
onMouseMove={handleCombinedMouseMove} onMouseMove={handleCombinedMouseMove}
onMouseUp={handleStageMouseUp} onMouseUp={handleStageMouseUp}
onPointerDown={handleStagePointerDownWithGuideReset}
onPointerMove={handleCombinedPointerMove}
onPointerUp={handleCombinedPointerUp}
onClick={handleCustomShapeClick} onClick={handleCustomShapeClick}
onDragMove={handleDragMove} onDragMove={handleDragMove}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
@@ -575,6 +604,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
/> />
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} /> <Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
<CustomShapeDrawingLayer drawingState={drawingState} /> <CustomShapeDrawingLayer drawingState={drawingState} />
<PencilDrawingLayer drawingState={pencilDrawingState} />
</Stage> </Stage>
</div> </div>
</div> </div>
@@ -17,6 +17,7 @@ import {
MouseSquare, MouseSquare,
Sticker, Sticker,
Polygon, Polygon,
Brush,
} from 'iconsax-react' } from 'iconsax-react'
import { useState } from 'react' import { useState } from 'react'
import { useEditorStore } from '../store/editorStore' import { useEditorStore } from '../store/editorStore'
@@ -66,6 +67,8 @@ const LayersList = () => {
return <Sticker size={20} color="black" /> return <Sticker size={20} color="black" />
case 'custom-shape': case 'custom-shape':
return <Polygon size={20} color="black" /> return <Polygon size={20} color="black" />
case 'pencil':
return <Brush size={20} color="black" />
default: default:
return <Shapes size={20} color="black" /> return <Shapes size={20} color="black" />
} }
@@ -117,6 +120,8 @@ const LayersList = () => {
return 'استیکر' return 'استیکر'
case 'custom-shape': case 'custom-shape':
return 'شکل سفارشی' return 'شکل سفارشی'
case 'pencil':
return 'نقاشی'
default: default:
return 'لایه' return 'لایه'
} }
@@ -16,6 +16,7 @@ import {
VideoShape, VideoShape,
AudioShape, AudioShape,
CustomShape, CustomShape,
PencilShape,
} from "../tools"; } from "../tools";
import EditorTable from "@/components/EditorTable"; import EditorTable from "@/components/EditorTable";
@@ -278,6 +279,9 @@ const ObjectRenderer = ({
case "custom-shape": case "custom-shape":
shapeElement = <CustomShape key={obj.id} {...commonProps} />; shapeElement = <CustomShape key={obj.id} {...commonProps} />;
break; break;
case "pencil":
shapeElement = <PencilShape key={obj.id} {...commonProps} />;
break;
case "group": case "group":
// Render group as a simple selection box // Render group as a simple selection box
// Objects inside group are rendered separately in EditorCanvas // Objects inside group are rendered separately in EditorCanvas
@@ -58,7 +58,7 @@ const ObjectsLayer = ({
stageHeight, stageHeight,
}: ObjectsLayerProps) => { }: ObjectsLayerProps) => {
return ( return (
<Layer ref={layerRef}> <Layer ref={layerRef} listening={tool !== "pencil"}>
{/* Render group objects first */} {/* Render group objects first */}
{objects {objects
.filter((obj) => obj.type === "group") .filter((obj) => obj.type === "group")
@@ -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 (
<Layer listening={false}>
<Line
points={linePoints}
stroke={stroke}
strokeWidth={strokeWidth}
tension={0.5}
lineCap="round"
lineJoin="round"
perfectDrawEnabled={false}
/>
</Layer>
);
};
export default PencilDrawingLayer;
@@ -27,8 +27,8 @@ export const useDrawingHandlers = () => {
}; };
const handleStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => { const handleStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
// custom-shape drawing is fully managed by useCustomShapeDrawing // custom-shape and pencil drawing are managed by dedicated hooks
if (tool === "custom-shape") return; if (tool === "custom-shape" || tool === "pencil") return;
const stage = e.target.getStage(); const stage = e.target.getStage();
if (!stage) return; if (!stage) return;
@@ -98,7 +98,7 @@ export const useDrawingHandlers = () => {
}; };
const handleStageMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => { const handleStageMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
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(); const stage = e.target.getStage();
if (!stage) return; if (!stage) return;
@@ -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<Konva.Stage | null>) => {
const pointsRef = useRef<DrawPoint[]>([]);
const isDrawingRef = useRef(false);
const pointerIdRef = useRef<number | null>(null);
const strokeRef = useRef("#000000");
const strokeWidthRef = useRef(3);
const [drawingState, setDrawingState] = useState<PencilDrawingState>(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<PointerEvent>) => {
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<PointerEvent>) => {
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<PointerEvent>) => {
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,
};
};
@@ -20,6 +20,10 @@ export const useSelectionHandlers = () => {
setSelectedCellId, setSelectedCellId,
} = useEditorStore.getState(); } = useEditorStore.getState();
if (tool === "pencil" || tool === "custom-shape") {
return;
}
if (tool !== "select") { if (tool !== "select") {
setTool("select"); setTool("select");
} }
@@ -495,7 +495,7 @@ export const useTransformHandlers = (
: undefined, : 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 // Scale each vertex in-place; no width/height anchor needed
const oldPoints = selectedObject.points ?? []; const oldPoints = selectedObject.points ?? [];
const newPoints = oldPoints.map((val, i) => const newPoints = oldPoints.map((val, i) =>
@@ -77,7 +77,7 @@ const ObjectSettings = ({ selectedObject, pageWidth, pageHeight, onUpdate, onDel
{roundedSelectedObject.type === "rectangle" && <ShapeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />} {roundedSelectedObject.type === "rectangle" && <ShapeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
{(roundedSelectedObject.type === "line" || roundedSelectedObject.type === "arrow") && <LineSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />} {(roundedSelectedObject.type === "line" || roundedSelectedObject.type === "arrow" || roundedSelectedObject.type === "pencil") && <LineSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
{roundedSelectedObject.type === "image" && <SizeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />} {roundedSelectedObject.type === "image" && <SizeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
@@ -37,6 +37,7 @@ const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
rectangle: <RectangleInstruction />, rectangle: <RectangleInstruction />,
line: <RectangleInstruction />, line: <RectangleInstruction />,
arrow: <RectangleInstruction />, arrow: <RectangleInstruction />,
pencil: <RectangleInstruction />,
"custom-shape": <RectangleInstruction />, "custom-shape": <RectangleInstruction />,
text: <TextInstruction />, text: <TextInstruction />,
sticker: <StickerInstruction />, sticker: <StickerInstruction />,
@@ -35,7 +35,7 @@ const ToolsBar = ({ tool, onToolClick }: ToolsBarProps) => {
{tools.map((item, index) => { {tools.map((item, index) => {
const isActive = const isActive =
item.tool === "rectangle" 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; : tool === item.tool;
const iconColor = "black"; const iconColor = "black";
const iconVariant: "Bold" | "Outline" | "Broken" | "Bulk" | "Linear" | "TwoTone" | undefined = isActive ? "Bold" : "Outline"; const iconVariant: "Bold" | "Outline" | "Broken" | "Bulk" | "Linear" | "TwoTone" | undefined = isActive ? "Bold" : "Outline";
@@ -1,12 +1,15 @@
const CustomShapeInstruction = () => ( const CustomShapeInstruction = () => (
<div className="space-y-2 text-sm text-gray-600"> <div className="space-y-2 text-sm text-gray-600">
<p className="font-medium text-gray-700">رسم شکل سفارشی</p> <p className="font-medium text-gray-700">رسم نقطهبهنقطه (Pen)</p>
<ul className="space-y-1"> <ul className="space-y-1">
<li> روی کانوس کلیک کنید تا نقاط اضافه شود</li> <li> روی کانوس کلیک کنید تا نقاط اضافه شود</li>
<li> روی نقطه اول کلیک کنید تا شکل بسته شود</li> <li> روی نقطه اول کلیک کنید تا شکل بسته شود</li>
<li> دوبار کلیک کنید تا شکل تکمیل شود</li> <li> دوبار کلیک کنید تا شکل تکمیل شود</li>
<li> Escape را بزنید تا لغو شود</li> <li> Escape را بزنید تا لغو شود</li>
</ul> </ul>
<p className="text-xs text-gray-500 pt-1">
برای نقاشی آزاد (کلیک و بکش) از ابزار «قلم» استفاده کنید.
</p>
</div> </div>
); );
@@ -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 (
<div className="mt-9 space-y-4">
<div className="flex items-start gap-2 text-sm text-gray-600">
<Brush size={18} color="#666" variant="Outline" className="mt-0.5 shrink-0" />
<div className="space-y-1">
<p>کلیک کرده و بکشید تا آزاد نقاشی کنید (مانند Pencil در Figma).</p>
<p className="text-xs text-gray-500">
برای رسم نقطهبهنقطه از ابزار «سفارشی» استفاده کنید.
</p>
</div>
</div>
<ColorPicker
label="رنگ قلم"
value={defaults.stroke}
onChange={(value) => updateDefaults({ stroke: value })}
/>
<Input
label="ضخامت قلم"
type="number"
min={1}
max={50}
value={defaults.strokeWidth}
onChange={(e) =>
updateDefaults({
strokeWidth: Math.max(1, parseInt(e.target.value) || 1),
})
}
/>
</div>
);
};
export default PencilInstruction;
@@ -6,9 +6,10 @@ import { Switch } from "@/components/ui/switch";
import { clx } from "@/helpers/utils"; import { clx } from "@/helpers/utils";
import { useEditorStore } from "@/pages/editor/store/editorStore"; import { useEditorStore } from "@/pages/editor/store/editorStore";
import { useShapeStore, type ShapeType } from "@/pages/editor/store/shapeStore"; 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 { type FC } from "react";
import CustomShapeInstruction from "./CustomShapeInstruction"; import CustomShapeInstruction from "./CustomShapeInstruction";
import PencilInstruction from "./PencilInstruction";
const shapeOptions: Array<{ id: ShapeType; label: string; icon: string; alt: string }> = [ const shapeOptions: Array<{ id: ShapeType; label: string; icon: string; alt: string }> = [
{ id: "square", label: "مربع", icon: SquareIcon, alt: "square" }, { id: "square", label: "مربع", icon: SquareIcon, alt: "square" },
@@ -18,6 +19,7 @@ const shapeOptions: Array<{ id: ShapeType; label: string; icon: string; alt: str
]; ];
const drawOptions = [ const drawOptions = [
{ tool: "pencil" as const, label: "قلم", Icon: Brush },
{ tool: "line" as const, label: "خط", Icon: Minus }, { tool: "line" as const, label: "خط", Icon: Minus },
{ tool: "arrow" as const, label: "پیکان", Icon: ArrowLeft }, { tool: "arrow" as const, label: "پیکان", Icon: ArrowLeft },
{ tool: "custom-shape" as const, label: "سفارشی", Icon: PenTool }, { tool: "custom-shape" as const, label: "سفارشی", Icon: PenTool },
@@ -81,6 +83,8 @@ const RectangleInstruction: FC = () => {
<p className="mt-9 text-sm text-gray-600">{tool === "line" ? "برای رسم خط، روی کانوس کلیک کرده و بکشید" : "برای رسم پیکان، روی کانوس کلیک کرده و بکشید"}</p> <p className="mt-9 text-sm text-gray-600">{tool === "line" ? "برای رسم خط، روی کانوس کلیک کرده و بکشید" : "برای رسم پیکان، روی کانوس کلیک کرده و بکشید"}</p>
)} )}
{tool === "pencil" && <PencilInstruction />}
{tool === "custom-shape" && ( {tool === "custom-shape" && (
<div className="mt-9"> <div className="mt-9">
<CustomShapeInstruction /> <CustomShapeInstruction />
@@ -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<Konva.Line>(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 (
<Line
ref={shapeRef}
id={obj.id}
name="canvas-object"
x={obj.x}
y={obj.y}
points={points}
closed={false}
stroke={displayStroke}
strokeWidth={displayStrokeWidth}
tension={obj.tension ?? 0.5}
lineCap="round"
lineJoin="round"
hitStrokeWidth={Math.max(20, displayStrokeWidth + 10)}
perfectDrawEnabled={false}
rotation={obj.rotation ?? 0}
opacity={(obj.opacity ?? 100) / 100}
draggable={draggable}
onClick={(e) => {
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;
@@ -1,5 +1,6 @@
export { default as RectangleShape } from "./RectangleShape"; export { default as RectangleShape } from "./RectangleShape";
export { default as CustomShape } from "./CustomShape"; export { default as CustomShape } from "./CustomShape";
export { default as PencilShape } from "./PencilShape";
export { default as CircleShape } from "./CircleShape"; export { default as CircleShape } from "./CircleShape";
export { default as TriangleShape } from "./TriangleShape"; export { default as TriangleShape } from "./TriangleShape";
export { default as AbstractShape } from "./AbstractShape"; export { default as AbstractShape } from "./AbstractShape";
@@ -1,5 +1,5 @@
import type { EditorObject, Page, TableCell } from "./editorStore.types"; 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) => export const createTableCellId = (row: number, col: number) =>
`cell-${row}-${col}`; `cell-${row}-${col}`;
@@ -390,4 +390,6 @@ export const ensureMissingGroupObjects = (
}; };
export const normalizePageObjects = (objects: EditorObject[]): EditorObject[] => export const normalizePageObjects = (objects: EditorObject[]): EditorObject[] =>
ensureMissingGroupObjects(objects).map(normalizeCustomShapeObject); ensureMissingGroupObjects(objects)
.map(normalizeCustomShapeObject)
.map(normalizePencilObject);
+4 -1
View File
@@ -26,7 +26,8 @@ export type ToolType =
| "link" | "link"
| "grid" | "grid"
| "group" | "group"
| "custom-shape"; | "custom-shape"
| "pencil";
export type TableCell = { export type TableCell = {
id: string; id: string;
@@ -99,6 +100,8 @@ export type EditorObject = {
points?: number[]; points?: number[];
/** آیا شکل چندضلعی بسته است (پیش‌فرض true برای custom-shape کامل‌شده) */ /** آیا شکل چندضلعی بسته است (پیش‌فرض true برای custom-shape کامل‌شده) */
closed?: boolean; closed?: boolean;
/** نرمی منحنی برای مسیرهای آزاد (pencil) — مقدار Konva Line.tension */
tension?: number;
}; };
export type PageGuide = { export type PageGuide = {
@@ -0,0 +1,22 @@
import { create } from "zustand";
export type PencilDefaults = {
stroke: string;
strokeWidth: number;
};
type PencilDefaultsStoreType = {
defaults: PencilDefaults;
updateDefaults: (updates: Partial<PencilDefaults>) => void;
};
export const usePencilDefaultsStore = create<PencilDefaultsStoreType>((set) => ({
defaults: {
stroke: "#000000",
strokeWidth: 3,
},
updateDefaults: (updates) =>
set((state) => ({
defaults: { ...state.defaults, ...updates },
})),
}));
+41
View File
@@ -71,3 +71,44 @@ export const normalizeCustomShapeObject = (
return next; 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;
};
+50
View File
@@ -720,6 +720,56 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
); );
} }
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 (
<svg
key={obj.id || index}
style={applyStyle(
{
position: 'absolute',
left: `${((obj.x || 0) + minX) * scale}px`,
top: `${((obj.y || 0) + minY) * scale}px`,
width: `${width}px`,
height: `${height}px`,
opacity: (obj.opacity ?? 100) / 100,
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
transformOrigin: 'top left',
zIndex: index,
overflow: 'visible',
},
obj,
index,
)}
viewBox={`0 0 ${width} ${height}`}
>
<polyline
points={pointsStr}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
case 'grid': { case 'grid': {
if (!obj.tableData) return null; if (!obj.tableData) return null;
+14 -2
View File
@@ -1,5 +1,5 @@
import type { EditorObject, TableData } from "@/pages/editor/store/editorStore"; 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"; import type { PageData } from "../types";
type ViewerDataPage = { type ViewerDataPage = {
@@ -53,6 +53,7 @@ type ViewerDataPage = {
maskInvert?: boolean; maskInvert?: boolean;
points?: number[]; points?: number[];
closed?: boolean; closed?: boolean;
tension?: number;
}>; }>;
}; };
@@ -176,7 +177,18 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
if (obj.closed !== undefined) baseObject.closed = obj.closed; 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 { return {