@@ -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<PointerEvent>) => {
|
||||
if (e.target?.name() !== "guide-line") {
|
||||
setSelectedGuideId(null);
|
||||
}
|
||||
if (tool === "pencil") {
|
||||
handlePencilPointerDown(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCombinedMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
handleStageMouseMove(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 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) => {
|
||||
/>
|
||||
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
|
||||
<CustomShapeDrawingLayer drawingState={drawingState} />
|
||||
<PencilDrawingLayer drawingState={pencilDrawingState} />
|
||||
</Stage>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 <Sticker size={20} color="black" />
|
||||
case 'custom-shape':
|
||||
return <Polygon size={20} color="black" />
|
||||
case 'pencil':
|
||||
return <Brush size={20} color="black" />
|
||||
default:
|
||||
return <Shapes size={20} color="black" />
|
||||
}
|
||||
@@ -117,6 +120,8 @@ const LayersList = () => {
|
||||
return 'استیکر'
|
||||
case 'custom-shape':
|
||||
return 'شکل سفارشی'
|
||||
case 'pencil':
|
||||
return 'نقاشی'
|
||||
default:
|
||||
return 'لایه'
|
||||
}
|
||||
|
||||
@@ -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 = <CustomShape key={obj.id} {...commonProps} />;
|
||||
break;
|
||||
case "pencil":
|
||||
shapeElement = <PencilShape key={obj.id} {...commonProps} />;
|
||||
break;
|
||||
case "group":
|
||||
// Render group as a simple selection box
|
||||
// Objects inside group are rendered separately in EditorCanvas
|
||||
|
||||
@@ -58,7 +58,7 @@ const ObjectsLayer = ({
|
||||
stageHeight,
|
||||
}: ObjectsLayerProps) => {
|
||||
return (
|
||||
<Layer ref={layerRef}>
|
||||
<Layer ref={layerRef} listening={tool !== "pencil"}>
|
||||
{/* Render group objects first */}
|
||||
{objects
|
||||
.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>) => {
|
||||
// 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<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();
|
||||
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,
|
||||
} = useEditorStore.getState();
|
||||
|
||||
if (tool === "pencil" || tool === "custom-shape") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tool !== "select") {
|
||||
setTool("select");
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -77,7 +77,7 @@ const ObjectSettings = ({ selectedObject, pageWidth, pageHeight, onUpdate, onDel
|
||||
|
||||
{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} />}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
|
||||
rectangle: <RectangleInstruction />,
|
||||
line: <RectangleInstruction />,
|
||||
arrow: <RectangleInstruction />,
|
||||
pencil: <RectangleInstruction />,
|
||||
"custom-shape": <RectangleInstruction />,
|
||||
text: <TextInstruction />,
|
||||
sticker: <StickerInstruction />,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
const CustomShapeInstruction = () => (
|
||||
<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">
|
||||
<li>• روی کانوس کلیک کنید تا نقاط اضافه شود</li>
|
||||
<li>• روی نقطه اول کلیک کنید تا شکل بسته شود</li>
|
||||
<li>• دوبار کلیک کنید تا شکل تکمیل شود</li>
|
||||
<li>• Escape را بزنید تا لغو شود</li>
|
||||
</ul>
|
||||
<p className="text-xs text-gray-500 pt-1">
|
||||
برای نقاشی آزاد (کلیک و بکش) از ابزار «قلم» استفاده کنید.
|
||||
</p>
|
||||
</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 { 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 = () => {
|
||||
<p className="mt-9 text-sm text-gray-600">{tool === "line" ? "برای رسم خط، روی کانوس کلیک کرده و بکشید" : "برای رسم پیکان، روی کانوس کلیک کرده و بکشید"}</p>
|
||||
)}
|
||||
|
||||
{tool === "pencil" && <PencilInstruction />}
|
||||
|
||||
{tool === "custom-shape" && (
|
||||
<div className="mt-9">
|
||||
<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 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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 },
|
||||
})),
|
||||
}));
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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': {
|
||||
if (!obj.tableData) return null;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user