costum shape
This commit is contained in:
@@ -10,8 +10,10 @@ import { useSelectionHandlers } from "./canvas/useSelectionHandlers";
|
||||
import { useTransformHandlers } from "./canvas/useTransformHandlers";
|
||||
import { useObjectHandlers } from "./canvas/useObjectHandlers";
|
||||
import { useMaskGroupState } from "./canvas/useMaskGroupState";
|
||||
import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing";
|
||||
import ObjectsLayer from "./canvas/ObjectsLayer";
|
||||
import GuidesLayer from "./canvas/GuidesLayer";
|
||||
import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer";
|
||||
import {
|
||||
clearSmartGuides,
|
||||
drawSmartGuides,
|
||||
@@ -97,6 +99,8 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
})();
|
||||
|
||||
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
|
||||
const { drawingState, handleStageClick: handleCustomShapeClick, handleStageMouseMove: handleCustomShapeMouseMove } =
|
||||
useCustomShapeDrawing();
|
||||
useKeyboardMovement();
|
||||
|
||||
const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers();
|
||||
@@ -371,6 +375,11 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
handleStageMouseDown(e);
|
||||
};
|
||||
|
||||
const handleCombinedMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
handleStageMouseMove(e);
|
||||
handleCustomShapeMouseMove(e);
|
||||
};
|
||||
|
||||
const scaledW = stageSize.width * finalScale;
|
||||
const scaledH = stageSize.height * finalScale;
|
||||
|
||||
@@ -502,9 +511,11 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
height={stageSize.height * finalScale}
|
||||
scaleX={finalScale}
|
||||
scaleY={finalScale}
|
||||
style={{ cursor: tool === "custom-shape" ? "crosshair" : undefined }}
|
||||
onMouseDown={handleStageMouseDownWithGuideReset}
|
||||
onMouseMove={handleStageMouseMove}
|
||||
onMouseMove={handleCombinedMouseMove}
|
||||
onMouseUp={handleStageMouseUp}
|
||||
onClick={handleCustomShapeClick}
|
||||
onDragMove={handleDragMove}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
@@ -563,6 +574,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
onGuideDragEnd={handleGuideDragEnd}
|
||||
/>
|
||||
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
|
||||
<CustomShapeDrawingLayer drawingState={drawingState} />
|
||||
</Stage>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Layer,
|
||||
MouseSquare,
|
||||
Sticker,
|
||||
Polygon,
|
||||
} from 'iconsax-react'
|
||||
import { useState } from 'react'
|
||||
import { useEditorStore } from '../store/editorStore'
|
||||
@@ -63,6 +64,8 @@ const LayersList = () => {
|
||||
return <MouseSquare size={20} color="black" />
|
||||
case 'sticker':
|
||||
return <Sticker size={20} color="black" />
|
||||
case 'custom-shape':
|
||||
return <Polygon size={20} color="black" />
|
||||
default:
|
||||
return <Shapes size={20} color="black" />
|
||||
}
|
||||
@@ -112,6 +115,8 @@ const LayersList = () => {
|
||||
return 'انتخاب'
|
||||
case 'sticker':
|
||||
return 'استیکر'
|
||||
case 'custom-shape':
|
||||
return 'شکل سفارشی'
|
||||
default:
|
||||
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,
|
||||
VideoShape,
|
||||
AudioShape,
|
||||
CustomShape,
|
||||
} from "../tools";
|
||||
import EditorTable from "@/components/EditorTable";
|
||||
|
||||
@@ -257,6 +258,9 @@ const ObjectRenderer = ({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "custom-shape":
|
||||
shapeElement = <CustomShape key={obj.id} {...commonProps} />;
|
||||
break;
|
||||
case "group":
|
||||
// Render group as a simple selection box
|
||||
// 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>) => {
|
||||
// custom-shape drawing is fully managed by useCustomShapeDrawing
|
||||
if (tool === "custom-shape") return;
|
||||
|
||||
const stage = e.target.getStage();
|
||||
if (!stage) return;
|
||||
|
||||
@@ -95,7 +98,7 @@ export const useDrawingHandlers = () => {
|
||||
};
|
||||
|
||||
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();
|
||||
if (!stage) return;
|
||||
|
||||
@@ -481,6 +481,29 @@ export const useTransformHandlers = (
|
||||
: 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 {
|
||||
// Default for other objects (image, etc.)
|
||||
updateObject(selectedObject.id, {
|
||||
|
||||
@@ -4,6 +4,7 @@ import TextInstruction from "./instructions/TextInstruction";
|
||||
import {
|
||||
AlignmentSettings,
|
||||
AudioSettings,
|
||||
CustomShapeSettings,
|
||||
EntranceAnimationSettings,
|
||||
GridSettings,
|
||||
LineSettings,
|
||||
@@ -97,6 +98,10 @@ const ObjectSettings = ({ selectedObject, pageWidth, pageHeight, onUpdate, onDel
|
||||
|
||||
{roundedSelectedObject.type === "grid" && <GridSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
||||
|
||||
{roundedSelectedObject.type === "custom-shape" && (
|
||||
<CustomShapeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||
)}
|
||||
|
||||
<EntranceAnimationSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||
|
||||
<TransformSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ArrowInstruction,
|
||||
StickerInstruction,
|
||||
GridInstruction,
|
||||
CustomShapeInstruction,
|
||||
} from "./instructions";
|
||||
|
||||
type ToolInstructionsProps = {
|
||||
@@ -42,6 +43,7 @@ const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
|
||||
arrow: <ArrowInstruction />,
|
||||
sticker: <StickerInstruction />,
|
||||
grid: <GridInstruction />,
|
||||
"custom-shape": <CustomShapeInstruction />,
|
||||
};
|
||||
|
||||
return simpleInstructions[tool] ?? null;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Music,
|
||||
Minus,
|
||||
Link2,
|
||||
Magicpen,
|
||||
} from "iconsax-react";
|
||||
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) => <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) => <Magicpen size={20} color={color} variant={variant} />, tool: "custom-shape", label: "شکل سفارشی" },
|
||||
];
|
||||
|
||||
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 StickerInstruction } from "./StickerInstruction";
|
||||
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 CustomShapeSettings } from "./CustomShapeSettings";
|
||||
export { default as ShapeSettings } from "./ShapeSettings";
|
||||
export { default as LineSettings } from "./LineSettings";
|
||||
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 CustomShape } from "./CustomShape";
|
||||
export { default as CircleShape } from "./CircleShape";
|
||||
export { default as TriangleShape } from "./TriangleShape";
|
||||
export { default as AbstractShape } from "./AbstractShape";
|
||||
|
||||
@@ -25,7 +25,8 @@ export type ToolType =
|
||||
| "audio"
|
||||
| "link"
|
||||
| "grid"
|
||||
| "group";
|
||||
| "group"
|
||||
| "custom-shape";
|
||||
|
||||
export type TableCell = {
|
||||
id: string;
|
||||
@@ -91,6 +92,13 @@ export type EditorObject = {
|
||||
entranceDurationMs?: 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 = {
|
||||
|
||||
Reference in New Issue
Block a user