costum shape
This commit is contained in:
@@ -10,8 +10,10 @@ import { useSelectionHandlers } from "./canvas/useSelectionHandlers";
|
|||||||
import { useTransformHandlers } from "./canvas/useTransformHandlers";
|
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 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 {
|
import {
|
||||||
clearSmartGuides,
|
clearSmartGuides,
|
||||||
drawSmartGuides,
|
drawSmartGuides,
|
||||||
@@ -97,6 +99,8 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
|
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
|
||||||
|
const { drawingState, handleStageClick: handleCustomShapeClick, handleStageMouseMove: handleCustomShapeMouseMove } =
|
||||||
|
useCustomShapeDrawing();
|
||||||
useKeyboardMovement();
|
useKeyboardMovement();
|
||||||
|
|
||||||
const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers();
|
const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers();
|
||||||
@@ -371,6 +375,11 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
handleStageMouseDown(e);
|
handleStageMouseDown(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCombinedMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
handleStageMouseMove(e);
|
||||||
|
handleCustomShapeMouseMove(e);
|
||||||
|
};
|
||||||
|
|
||||||
const scaledW = stageSize.width * finalScale;
|
const scaledW = stageSize.width * finalScale;
|
||||||
const scaledH = stageSize.height * finalScale;
|
const scaledH = stageSize.height * finalScale;
|
||||||
|
|
||||||
@@ -502,9 +511,11 @@ 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 }}
|
||||||
onMouseDown={handleStageMouseDownWithGuideReset}
|
onMouseDown={handleStageMouseDownWithGuideReset}
|
||||||
onMouseMove={handleStageMouseMove}
|
onMouseMove={handleCombinedMouseMove}
|
||||||
onMouseUp={handleStageMouseUp}
|
onMouseUp={handleStageMouseUp}
|
||||||
|
onClick={handleCustomShapeClick}
|
||||||
onDragMove={handleDragMove}
|
onDragMove={handleDragMove}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
@@ -563,6 +574,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
onGuideDragEnd={handleGuideDragEnd}
|
onGuideDragEnd={handleGuideDragEnd}
|
||||||
/>
|
/>
|
||||||
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
|
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
|
||||||
|
<CustomShapeDrawingLayer drawingState={drawingState} />
|
||||||
</Stage>
|
</Stage>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
Layer,
|
Layer,
|
||||||
MouseSquare,
|
MouseSquare,
|
||||||
Sticker,
|
Sticker,
|
||||||
|
Polygon,
|
||||||
} from 'iconsax-react'
|
} from 'iconsax-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useEditorStore } from '../store/editorStore'
|
import { useEditorStore } from '../store/editorStore'
|
||||||
@@ -63,6 +64,8 @@ const LayersList = () => {
|
|||||||
return <MouseSquare size={20} color="black" />
|
return <MouseSquare size={20} color="black" />
|
||||||
case 'sticker':
|
case 'sticker':
|
||||||
return <Sticker size={20} color="black" />
|
return <Sticker size={20} color="black" />
|
||||||
|
case 'custom-shape':
|
||||||
|
return <Polygon size={20} color="black" />
|
||||||
default:
|
default:
|
||||||
return <Shapes size={20} color="black" />
|
return <Shapes size={20} color="black" />
|
||||||
}
|
}
|
||||||
@@ -112,6 +115,8 @@ const LayersList = () => {
|
|||||||
return 'انتخاب'
|
return 'انتخاب'
|
||||||
case 'sticker':
|
case 'sticker':
|
||||||
return 'استیکر'
|
return 'استیکر'
|
||||||
|
case 'custom-shape':
|
||||||
|
return 'شکل سفارشی'
|
||||||
default:
|
default:
|
||||||
return 'لایه'
|
return 'لایه'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Layer, Line, Circle } from "react-konva";
|
||||||
|
import type { CustomShapeDrawingState } from "./useCustomShapeDrawing";
|
||||||
|
|
||||||
|
const CLOSE_THRESHOLD = 12;
|
||||||
|
const ANCHOR_RADIUS = 5;
|
||||||
|
const FIRST_ANCHOR_COLOR = "#ef4444"; // red — marks the closing vertex
|
||||||
|
const ANCHOR_COLOR = "#3b82f6";
|
||||||
|
const PREVIEW_LINE_COLOR = "#3b82f6";
|
||||||
|
const CLOSE_HINT_FILL = "rgba(239, 68, 68, 0.15)";
|
||||||
|
|
||||||
|
type CustomShapeDrawingLayerProps = {
|
||||||
|
drawingState: CustomShapeDrawingState;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konva Layer rendered on top of all objects while the user is drawing a custom polygon.
|
||||||
|
* Shows:
|
||||||
|
* • A dashed preview line through all placed vertices + the current cursor position
|
||||||
|
* • Circular anchor handles at each vertex (red for the first, blue for the rest)
|
||||||
|
* • A translucent red ring around the first vertex when the cursor is close enough to close the shape
|
||||||
|
*
|
||||||
|
* This layer has `listening={false}` so it never intercepts mouse events.
|
||||||
|
*/
|
||||||
|
const CustomShapeDrawingLayer = ({ drawingState }: CustomShapeDrawingLayerProps) => {
|
||||||
|
const { isActive, points, previewPoint } = drawingState;
|
||||||
|
|
||||||
|
if (!isActive || points.length === 0) return null;
|
||||||
|
|
||||||
|
// Build the flat points array for the preview line
|
||||||
|
const allDisplayPoints = previewPoint ? [...points, previewPoint] : points;
|
||||||
|
const linePoints = allDisplayPoints.flatMap((p) => [p.x, p.y]);
|
||||||
|
|
||||||
|
const showCloseHint =
|
||||||
|
previewPoint !== null &&
|
||||||
|
points.length >= 3 &&
|
||||||
|
Math.hypot(previewPoint.x - points[0].x, previewPoint.y - points[0].y) <=
|
||||||
|
CLOSE_THRESHOLD;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layer listening={false}>
|
||||||
|
{/* Dashed preview line */}
|
||||||
|
<Line
|
||||||
|
points={linePoints}
|
||||||
|
stroke={PREVIEW_LINE_COLOR}
|
||||||
|
strokeWidth={2}
|
||||||
|
dash={[6, 3]}
|
||||||
|
perfectDrawEnabled={false}
|
||||||
|
lineCap="round"
|
||||||
|
lineJoin="round"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Vertex anchor dots */}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<Circle
|
||||||
|
key={i}
|
||||||
|
x={p.x}
|
||||||
|
y={p.y}
|
||||||
|
radius={ANCHOR_RADIUS}
|
||||||
|
fill={i === 0 ? FIRST_ANCHOR_COLOR : ANCHOR_COLOR}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
perfectDrawEnabled={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Close-shape hint: translucent ring on first vertex */}
|
||||||
|
{showCloseHint && (
|
||||||
|
<Circle
|
||||||
|
x={points[0].x}
|
||||||
|
y={points[0].y}
|
||||||
|
radius={CLOSE_THRESHOLD}
|
||||||
|
stroke={FIRST_ANCHOR_COLOR}
|
||||||
|
strokeWidth={1.5}
|
||||||
|
fill={CLOSE_HINT_FILL}
|
||||||
|
dash={[4, 2]}
|
||||||
|
perfectDrawEnabled={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Layer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomShapeDrawingLayer;
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
LinkShape,
|
LinkShape,
|
||||||
VideoShape,
|
VideoShape,
|
||||||
AudioShape,
|
AudioShape,
|
||||||
|
CustomShape,
|
||||||
} from "../tools";
|
} from "../tools";
|
||||||
import EditorTable from "@/components/EditorTable";
|
import EditorTable from "@/components/EditorTable";
|
||||||
|
|
||||||
@@ -257,6 +258,9 @@ const ObjectRenderer = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "custom-shape":
|
||||||
|
shapeElement = <CustomShape 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
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
|
||||||
|
/** Radius (in canvas units, before zoom) within which clicking snaps to / closes on the first vertex. */
|
||||||
|
const CLOSE_THRESHOLD = 12;
|
||||||
|
|
||||||
|
/** Minimum number of vertices required to close a shape. */
|
||||||
|
const MIN_POINTS = 3;
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
/** A 2-D point in canvas (stage-local) coordinates. */
|
||||||
|
export type DrawPoint = { x: number; y: number };
|
||||||
|
|
||||||
|
/** Reactive state consumed by CustomShapeDrawingLayer for rendering the in-progress polygon. */
|
||||||
|
export type CustomShapeDrawingState = {
|
||||||
|
isActive: boolean;
|
||||||
|
points: DrawPoint[];
|
||||||
|
previewPoint: DrawPoint | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_STATE: CustomShapeDrawingState = {
|
||||||
|
isActive: false,
|
||||||
|
points: [],
|
||||||
|
previewPoint: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
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 isNearPoint = (a: DrawPoint, b: DrawPoint, threshold: number): boolean => {
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = a.y - b.y;
|
||||||
|
return dx * dx + dy * dy <= threshold * threshold;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts absolute canvas points into an EditorObject.
|
||||||
|
* The bounding-box top-left becomes (obj.x, obj.y); all points are stored relative to it.
|
||||||
|
* width/height reflect the axis-aligned bounding box so the transformer can display correct handles.
|
||||||
|
*/
|
||||||
|
const buildCustomShapeObject = (points: DrawPoint[]): 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);
|
||||||
|
|
||||||
|
// Flat [x1-minX, y1-minY, x2-minX, y2-minY, …] — Konva.Line.points format
|
||||||
|
const normalizedPoints = points.flatMap((p) => [p.x - minX, p.y - minY]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `custom-shape-${Date.now()}`,
|
||||||
|
type: "custom-shape",
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: maxX - minX,
|
||||||
|
height: maxY - minY,
|
||||||
|
points: normalizedPoints,
|
||||||
|
closed: true,
|
||||||
|
fill: "#3b82f6",
|
||||||
|
fillType: "solid",
|
||||||
|
stroke: "#1e40af",
|
||||||
|
strokeWidth: 2,
|
||||||
|
opacity: 1,
|
||||||
|
rotation: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Hook ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the click-by-click custom polygon drawing state machine.
|
||||||
|
*
|
||||||
|
* Usage in EditorCanvas:
|
||||||
|
* - Attach handleStageClick → Stage onClick
|
||||||
|
* - Attach handleStageMouseMove → Stage onMouseMove
|
||||||
|
* - Render <CustomShapeDrawingLayer drawingState={drawingState} />
|
||||||
|
*
|
||||||
|
* The hook integrates with the Zustand editor store (addObject, undo history, tool switch)
|
||||||
|
* and cleans up its own keyboard listener on unmount / tool change.
|
||||||
|
*/
|
||||||
|
export const useCustomShapeDrawing = () => {
|
||||||
|
// Use a ref for the mutable points array so event callbacks always see the latest
|
||||||
|
// value without needing to be recreated on every state update.
|
||||||
|
const pointsRef = useRef<DrawPoint[]>([]);
|
||||||
|
|
||||||
|
const [drawingState, setDrawingState] = useState<CustomShapeDrawingState>(INITIAL_STATE);
|
||||||
|
|
||||||
|
const { tool, addObject, setSelectedObjectId, setTool } = useEditorStore();
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
pointsRef.current = [];
|
||||||
|
setDrawingState(INITIAL_STATE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cancel drawing when user switches away from the custom-shape tool
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool !== "custom-shape") {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [tool, reset]);
|
||||||
|
|
||||||
|
// Escape key cancels in-progress drawing
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool !== "custom-shape") return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (
|
||||||
|
target.tagName === "INPUT" ||
|
||||||
|
target.tagName === "TEXTAREA" ||
|
||||||
|
target.isContentEditable
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
reset();
|
||||||
|
setTool("select");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [tool, reset, setTool]);
|
||||||
|
|
||||||
|
/** Finalise the polygon, persist it to the store, and switch to select tool. */
|
||||||
|
const commitShape = useCallback(() => {
|
||||||
|
const points = pointsRef.current;
|
||||||
|
if (points.length < MIN_POINTS) return;
|
||||||
|
|
||||||
|
const obj = buildCustomShapeObject(points);
|
||||||
|
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
||||||
|
addObject(obj);
|
||||||
|
setSelectedObjectId(obj.id);
|
||||||
|
setTool("select");
|
||||||
|
reset();
|
||||||
|
}, [addObject, setSelectedObjectId, setTool, reset]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stage click handler.
|
||||||
|
*
|
||||||
|
* Only acts when tool === "custom-shape" and the click target is the Stage itself
|
||||||
|
* (not an existing shape). Uses MouseEvent.detail to distinguish single vs double clicks:
|
||||||
|
* - detail === 1 → single click → add vertex or close shape if near first vertex
|
||||||
|
* - detail >= 2 → second click of a double-click → close the shape immediately
|
||||||
|
*/
|
||||||
|
const handleStageClick = useCallback(
|
||||||
|
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
if (tool !== "custom-shape") return;
|
||||||
|
// Only act on bare-stage clicks; ignore clicks that land on existing objects
|
||||||
|
const stage = e.target.getStage();
|
||||||
|
if (!stage || e.target !== stage) return;
|
||||||
|
|
||||||
|
const pos = getCanvasPos(stage);
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
// Double-click (detail >= 2): close the shape without adding another vertex
|
||||||
|
if (e.evt.detail >= 2) {
|
||||||
|
if (pointsRef.current.length >= MIN_POINTS) {
|
||||||
|
commitShape();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = pointsRef.current;
|
||||||
|
|
||||||
|
// Snap-to-close: if cursor is within threshold of the first vertex, close the shape
|
||||||
|
if (
|
||||||
|
points.length >= MIN_POINTS &&
|
||||||
|
isNearPoint(pos, points[0], CLOSE_THRESHOLD)
|
||||||
|
) {
|
||||||
|
commitShape();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add vertex
|
||||||
|
pointsRef.current = [...points, pos];
|
||||||
|
setDrawingState({
|
||||||
|
isActive: true,
|
||||||
|
points: pointsRef.current,
|
||||||
|
previewPoint: pos,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[tool, commitShape],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Updates the live preview segment as the mouse moves across the canvas. */
|
||||||
|
const handleStageMouseMove = useCallback(
|
||||||
|
(e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
if (tool !== "custom-shape" || !drawingState.isActive) return;
|
||||||
|
|
||||||
|
const stage = e.target.getStage();
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const pos = getCanvasPos(stage);
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
setDrawingState((prev) => ({ ...prev, previewPoint: pos }));
|
||||||
|
},
|
||||||
|
[tool, drawingState.isActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawingState,
|
||||||
|
handleStageClick,
|
||||||
|
handleStageMouseMove,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -27,6 +27,9 @@ export const useDrawingHandlers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
// custom-shape drawing is fully managed by useCustomShapeDrawing
|
||||||
|
if (tool === "custom-shape") return;
|
||||||
|
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
|
|
||||||
@@ -95,7 +98,7 @@ export const useDrawingHandlers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStageMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleStageMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
if (!isDrawing || tool === "select" || tool === "text") return;
|
if (!isDrawing || tool === "select" || tool === "text" || tool === "custom-shape") return;
|
||||||
|
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
|
|||||||
@@ -481,6 +481,29 @@ export const useTransformHandlers = (
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (selectedObject.type === "custom-shape") {
|
||||||
|
// Scale each vertex in-place; no width/height anchor needed
|
||||||
|
const oldPoints = selectedObject.points ?? [];
|
||||||
|
const newPoints = oldPoints.map((val, i) =>
|
||||||
|
i % 2 === 0 ? val * scaleX : val * scaleY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recompute axis-aligned bounding box from the scaled points
|
||||||
|
const xs = newPoints.filter((_, i) => i % 2 === 0);
|
||||||
|
const ys = newPoints.filter((_, i) => i % 2 !== 0);
|
||||||
|
const newWidth =
|
||||||
|
xs.length > 0 ? Math.max(...xs) - Math.min(...xs) : 0;
|
||||||
|
const newHeight =
|
||||||
|
ys.length > 0 ? Math.max(...ys) - Math.min(...ys) : 0;
|
||||||
|
|
||||||
|
updateObject(selectedObject.id, {
|
||||||
|
x: node.x(),
|
||||||
|
y: node.y(),
|
||||||
|
rotation: node.rotation(),
|
||||||
|
points: newPoints,
|
||||||
|
width: Math.max(1, newWidth),
|
||||||
|
height: Math.max(1, newHeight),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Default for other objects (image, etc.)
|
// Default for other objects (image, etc.)
|
||||||
updateObject(selectedObject.id, {
|
updateObject(selectedObject.id, {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import TextInstruction from "./instructions/TextInstruction";
|
|||||||
import {
|
import {
|
||||||
AlignmentSettings,
|
AlignmentSettings,
|
||||||
AudioSettings,
|
AudioSettings,
|
||||||
|
CustomShapeSettings,
|
||||||
EntranceAnimationSettings,
|
EntranceAnimationSettings,
|
||||||
GridSettings,
|
GridSettings,
|
||||||
LineSettings,
|
LineSettings,
|
||||||
@@ -97,6 +98,10 @@ const ObjectSettings = ({ selectedObject, pageWidth, pageHeight, onUpdate, onDel
|
|||||||
|
|
||||||
{roundedSelectedObject.type === "grid" && <GridSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
{roundedSelectedObject.type === "grid" && <GridSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
||||||
|
|
||||||
|
{roundedSelectedObject.type === "custom-shape" && (
|
||||||
|
<CustomShapeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||||
|
)}
|
||||||
|
|
||||||
<EntranceAnimationSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
<EntranceAnimationSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||||
|
|
||||||
<TransformSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
<TransformSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ArrowInstruction,
|
ArrowInstruction,
|
||||||
StickerInstruction,
|
StickerInstruction,
|
||||||
GridInstruction,
|
GridInstruction,
|
||||||
|
CustomShapeInstruction,
|
||||||
} from "./instructions";
|
} from "./instructions";
|
||||||
|
|
||||||
type ToolInstructionsProps = {
|
type ToolInstructionsProps = {
|
||||||
@@ -42,6 +43,7 @@ const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
|
|||||||
arrow: <ArrowInstruction />,
|
arrow: <ArrowInstruction />,
|
||||||
sticker: <StickerInstruction />,
|
sticker: <StickerInstruction />,
|
||||||
grid: <GridInstruction />,
|
grid: <GridInstruction />,
|
||||||
|
"custom-shape": <CustomShapeInstruction />,
|
||||||
};
|
};
|
||||||
|
|
||||||
return simpleInstructions[tool] ?? null;
|
return simpleInstructions[tool] ?? null;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Music,
|
Music,
|
||||||
Minus,
|
Minus,
|
||||||
Link2,
|
Link2,
|
||||||
|
Magicpen,
|
||||||
} from "iconsax-react";
|
} from "iconsax-react";
|
||||||
import type { ToolType } from "../../store/editorStore";
|
import type { ToolType } from "../../store/editorStore";
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ const tools: Array<{ icon: (color: string, variant?: "Bold" | "Outline" | "Broke
|
|||||||
{ icon: (color, variant) => <ArrowLeft size={20} color={color} variant={variant} />, tool: "arrow", label: "پیکان" },
|
{ icon: (color, variant) => <ArrowLeft size={20} color={color} variant={variant} />, tool: "arrow", label: "پیکان" },
|
||||||
{ icon: (color, variant) => <Minus size={20} color={color} variant={variant} />, tool: "line", label: "خط" },
|
{ icon: (color, variant) => <Minus size={20} color={color} variant={variant} />, tool: "line", label: "خط" },
|
||||||
{ icon: (color, variant) => <Sticker size={20} color={color} variant={variant} />, tool: "sticker", label: "استیکر" },
|
{ icon: (color, variant) => <Sticker size={20} color={color} variant={variant} />, tool: "sticker", label: "استیکر" },
|
||||||
|
{ icon: (color, variant) => <Magicpen size={20} color={color} variant={variant} />, tool: "custom-shape", label: "شکل سفارشی" },
|
||||||
];
|
];
|
||||||
|
|
||||||
type ToolsBarProps = {
|
type ToolsBarProps = {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const CustomShapeInstruction = () => (
|
||||||
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
|
<p className="font-medium text-gray-700">رسم شکل سفارشی</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
<li>• روی کانوس کلیک کنید تا نقاط اضافه شود</li>
|
||||||
|
<li>• روی نقطه اول کلیک کنید تا شکل بسته شود</li>
|
||||||
|
<li>• دوبار کلیک کنید تا شکل تکمیل شود</li>
|
||||||
|
<li>• Escape را بزنید تا لغو شود</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default CustomShapeInstruction;
|
||||||
@@ -12,3 +12,4 @@ export { default as LineInstruction } from "./LineInstruction";
|
|||||||
export { default as ArrowInstruction } from "./ArrowInstruction";
|
export { default as ArrowInstruction } from "./ArrowInstruction";
|
||||||
export { default as StickerInstruction } from "./StickerInstruction";
|
export { default as StickerInstruction } from "./StickerInstruction";
|
||||||
export { default as GridInstruction } from "./GridInstruction";
|
export { default as GridInstruction } from "./GridInstruction";
|
||||||
|
export { default as CustomShapeInstruction } from "./CustomShapeInstruction";
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import ColorPicker from "@/components/ColorPicker";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import type { EditorObject } from "@/pages/editor/store/editorStore";
|
||||||
|
|
||||||
|
type CustomShapeSettingsProps = {
|
||||||
|
selectedObject: EditorObject;
|
||||||
|
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar settings panel for a completed custom-shape polygon.
|
||||||
|
* Supports solid fill, gradient fill, stroke colour, and stroke width.
|
||||||
|
* Mirrors the visual and UX conventions of ShapeSettings.
|
||||||
|
*/
|
||||||
|
const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsProps) => {
|
||||||
|
const fill = selectedObject.fill ?? "#3b82f6";
|
||||||
|
const fillType = selectedObject.fillType ?? "solid";
|
||||||
|
const gradient = selectedObject.gradient ?? {
|
||||||
|
from: "#3b82f6",
|
||||||
|
to: "#1d4ed8",
|
||||||
|
angle: 135,
|
||||||
|
};
|
||||||
|
const stroke = selectedObject.stroke ?? "#1e40af";
|
||||||
|
const strokeWidth = selectedObject.strokeWidth ?? 2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ColorPicker
|
||||||
|
label="رنگ پر"
|
||||||
|
value={fill}
|
||||||
|
readOnly={fillType === "gradient"}
|
||||||
|
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm">نوع Fill</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUpdate(selectedObject.id, { fillType: "solid" })}
|
||||||
|
className={`px-3 py-1.5 rounded-lg border text-xs ${
|
||||||
|
fillType === "solid"
|
||||||
|
? "border-gray-800 text-gray-900"
|
||||||
|
: "border-border text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
رنگ ساده
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
onUpdate(selectedObject.id, {
|
||||||
|
fillType: "gradient",
|
||||||
|
gradient: selectedObject.gradient ?? gradient,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`px-3 py-1.5 rounded-lg border text-xs ${
|
||||||
|
fillType === "gradient"
|
||||||
|
? "border-gray-800 text-gray-900"
|
||||||
|
: "border-border text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
گرادیانت
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fillType === "gradient" && (
|
||||||
|
<div className="space-y-3 border border-border rounded-xl p-3">
|
||||||
|
<ColorPicker
|
||||||
|
label="رنگ شروع"
|
||||||
|
value={gradient.from}
|
||||||
|
onChange={(value) =>
|
||||||
|
onUpdate(selectedObject.id, { gradient: { ...gradient, from: value } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ColorPicker
|
||||||
|
label="رنگ پایان"
|
||||||
|
value={gradient.to}
|
||||||
|
onChange={(value) =>
|
||||||
|
onUpdate(selectedObject.id, { gradient: { ...gradient, to: value } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-sm">زاویه گرادیانت</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
value={gradient.angle}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate(selectedObject.id, {
|
||||||
|
gradient: { ...gradient, angle: Number(e.target.value) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={360}
|
||||||
|
value={gradient.angle}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate(selectedObject.id, {
|
||||||
|
gradient: { ...gradient, angle: Number(e.target.value) || 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-16 h-9 rounded-lg border border-border px-2 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ColorPicker
|
||||||
|
label="رنگ خط"
|
||||||
|
value={stroke}
|
||||||
|
onChange={(value) => onUpdate(selectedObject.id, { stroke: value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
label="ضخامت خط"
|
||||||
|
type="number"
|
||||||
|
value={strokeWidth}
|
||||||
|
min={0}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdate(selectedObject.id, {
|
||||||
|
strokeWidth: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomShapeSettings;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as TextSettings } from "./TextSettings";
|
export { default as TextSettings } from "./TextSettings";
|
||||||
|
export { default as CustomShapeSettings } from "./CustomShapeSettings";
|
||||||
export { default as ShapeSettings } from "./ShapeSettings";
|
export { default as ShapeSettings } from "./ShapeSettings";
|
||||||
export { default as LineSettings } from "./LineSettings";
|
export { default as LineSettings } from "./LineSettings";
|
||||||
export { default as SizeSettings } from "./SizeSettings";
|
export { default as SizeSettings } from "./SizeSettings";
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Line } from "react-konva";
|
||||||
|
import Konva from "konva";
|
||||||
|
import type { ShapeProps } from "./types";
|
||||||
|
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a completed custom polygon shape using Konva.Line with closed=true.
|
||||||
|
*
|
||||||
|
* Points are stored as a flat array [x1,y1, x2,y2, …] relative to obj.x/obj.y,
|
||||||
|
* matching Konva's native Line.points format. This design leaves room for future
|
||||||
|
* Bézier curve support (by adding a separate controlPoints field) and path editing
|
||||||
|
* (by rendering individual vertex handles when in edit mode).
|
||||||
|
*/
|
||||||
|
const CustomShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||||
|
const shapeRef = useRef<Konva.Line>(null);
|
||||||
|
|
||||||
|
const points = obj.points ?? [];
|
||||||
|
// Need at least 3 vertices (6 values) to render a polygon
|
||||||
|
if (points.length < 6) return null;
|
||||||
|
|
||||||
|
const actualStrokeWidth = obj.strokeWidth ?? 2;
|
||||||
|
const hasStroke = actualStrokeWidth > 0;
|
||||||
|
|
||||||
|
const displayStroke = isSelected
|
||||||
|
? (hasStroke ? obj.stroke : "#3b82f6")
|
||||||
|
: (hasStroke ? obj.stroke : undefined);
|
||||||
|
|
||||||
|
const displayStrokeWidth = isSelected
|
||||||
|
? (hasStroke ? actualStrokeWidth : 2)
|
||||||
|
: actualStrokeWidth;
|
||||||
|
|
||||||
|
// Derive bounding box width/height from points for gradient calculation
|
||||||
|
const xs = points.filter((_, i) => i % 2 === 0);
|
||||||
|
const ys = points.filter((_, i) => i % 2 !== 0);
|
||||||
|
const bboxW = xs.length > 0 ? Math.max(...xs) - Math.min(...xs) : 100;
|
||||||
|
const bboxH = ys.length > 0 ? Math.max(...ys) - Math.min(...ys) : 100;
|
||||||
|
|
||||||
|
const gradientProps = getKonvaGradientProps(
|
||||||
|
obj.fillType,
|
||||||
|
obj.gradient,
|
||||||
|
bboxW,
|
||||||
|
bboxH,
|
||||||
|
"rect",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
ref={shapeRef}
|
||||||
|
id={obj.id}
|
||||||
|
name="canvas-object"
|
||||||
|
x={obj.x}
|
||||||
|
y={obj.y}
|
||||||
|
points={points}
|
||||||
|
closed={obj.closed ?? true}
|
||||||
|
fill={obj.fill ?? "#3b82f6"}
|
||||||
|
{...gradientProps}
|
||||||
|
stroke={displayStroke}
|
||||||
|
strokeWidth={displayStrokeWidth}
|
||||||
|
perfectDrawEnabled={false}
|
||||||
|
rotation={obj.rotation ?? 0}
|
||||||
|
opacity={obj.opacity ?? 1}
|
||||||
|
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 CustomShape;
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export { default as RectangleShape } from "./RectangleShape";
|
export { default as RectangleShape } from "./RectangleShape";
|
||||||
|
export { default as CustomShape } from "./CustomShape";
|
||||||
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";
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export type ToolType =
|
|||||||
| "audio"
|
| "audio"
|
||||||
| "link"
|
| "link"
|
||||||
| "grid"
|
| "grid"
|
||||||
| "group";
|
| "group"
|
||||||
|
| "custom-shape";
|
||||||
|
|
||||||
export type TableCell = {
|
export type TableCell = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -91,6 +92,13 @@ export type EditorObject = {
|
|||||||
entranceDurationMs?: number;
|
entranceDurationMs?: number;
|
||||||
/** تأخیر قبل از شروع انیمیشن ورود به میلیثانیه (پیشفرض ۰) */
|
/** تأخیر قبل از شروع انیمیشن ورود به میلیثانیه (پیشفرض ۰) */
|
||||||
entranceDelayMs?: number;
|
entranceDelayMs?: number;
|
||||||
|
/**
|
||||||
|
* نقاط شکل چندضلعی سفارشی — آرایه تخت [x1,y1, x2,y2, …] نسبت به obj.x / obj.y.
|
||||||
|
* همان فرمت Konva.Line.points؛ برای افزودن Bézier در آینده میتوان controlPoints جداگانه اضافه کرد.
|
||||||
|
*/
|
||||||
|
points?: number[];
|
||||||
|
/** آیا شکل چندضلعی بسته است (پیشفرض true برای custom-shape کاملشده) */
|
||||||
|
closed?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PageGuide = {
|
export type PageGuide = {
|
||||||
|
|||||||
Reference in New Issue
Block a user