diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx index d66f143..06ef848 100644 --- a/src/pages/editor/components/canvas/ObjectRenderer.tsx +++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx @@ -88,7 +88,7 @@ const ObjectRenderer = ({ groupNode?.clearCache(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient]); + }, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur]); // Refresh cache after transformer is attached (when isSelected changes) useEffect(() => { diff --git a/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx b/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx index fc39210..c623bec 100644 --- a/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx +++ b/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx @@ -20,6 +20,7 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => { const baseStroke = selectedObject.stroke ?? "#1e40af"; const baseStrokeWidth = selectedObject.strokeWidth ?? 0; const baseBorderRadius = selectedObject.borderRadius ?? 0; + const baseBlur = selectedObject.blur ?? 0; const isSquareShape = selectedObject.shapeType === "square" || selectedObject.shapeType === undefined; @@ -179,6 +180,35 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => { min={0} /> )} +
+ +
+ + onUpdate(selectedObject.id, { + blur: Math.max(0, Math.min(50, Number(e.target.value))), + }) + } + className="w-full" + /> + + onUpdate(selectedObject.id, { + blur: Math.max(0, Math.min(50, Number(e.target.value) || 0)), + }) + } + className="w-16 h-9 rounded-lg border border-border px-2 text-xs" + /> +
+
); }; diff --git a/src/pages/editor/components/tools/AbstractShape.tsx b/src/pages/editor/components/tools/AbstractShape.tsx index b20f085..bfc3598 100644 --- a/src/pages/editor/components/tools/AbstractShape.tsx +++ b/src/pages/editor/components/tools/AbstractShape.tsx @@ -3,6 +3,7 @@ import { Star } from "react-konva"; import Konva from "konva"; import type { ShapeProps } from "./types"; import { getKonvaGradientProps } from "../../utils/gradient"; +import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur"; const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); @@ -18,6 +19,18 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape const actualStrokeWidth = obj.strokeWidth ?? 0; const hasStroke = actualStrokeWidth > 0; + const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); + + useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ + obj.width, + obj.height, + obj.fill, + obj.fillType, + obj.gradient, + obj.stroke, + actualStrokeWidth, + obj.blur, + ]); // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده const displayStroke = isSelected @@ -57,6 +70,7 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape strokeWidth={displayStrokeWidth} dash={isMask && showGuide ? [10, 5] : undefined} rotation={obj.rotation || 0} + {...blurProps} draggable={draggable} onClick={(e) => { if (shapeRef.current) { diff --git a/src/pages/editor/components/tools/CircleShape.tsx b/src/pages/editor/components/tools/CircleShape.tsx index 7a6a1cb..cca3292 100644 --- a/src/pages/editor/components/tools/CircleShape.tsx +++ b/src/pages/editor/components/tools/CircleShape.tsx @@ -3,6 +3,7 @@ import { Circle } from "react-konva"; import Konva from "konva"; import type { ShapeProps } from "./types"; import { getKonvaGradientProps } from "../../utils/gradient"; +import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur"; const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); @@ -14,6 +15,17 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr const actualStrokeWidth = obj.strokeWidth ?? 0; const hasStroke = actualStrokeWidth > 0; + const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); + + useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ + obj.width, + obj.fill, + obj.fillType, + obj.gradient, + obj.stroke, + actualStrokeWidth, + obj.blur, + ]); // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده const displayStroke = isSelected @@ -46,6 +58,7 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr strokeWidth={displayStrokeWidth} dash={isMask && showGuide ? [10, 5] : undefined} rotation={obj.rotation || 0} + {...blurProps} draggable={draggable} onClick={(e) => { if (shapeRef.current) { diff --git a/src/pages/editor/components/tools/RectangleShape.tsx b/src/pages/editor/components/tools/RectangleShape.tsx index eee5bd4..6894414 100644 --- a/src/pages/editor/components/tools/RectangleShape.tsx +++ b/src/pages/editor/components/tools/RectangleShape.tsx @@ -3,6 +3,7 @@ import { Rect } from "react-konva"; import Konva from "konva"; import type { ShapeProps } from "./types"; import { getKonvaGradientProps } from "../../utils/gradient"; +import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur"; const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); @@ -15,6 +16,19 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap const actualStrokeWidth = obj.strokeWidth ?? 0; const hasStroke = actualStrokeWidth > 0; const cornerRadius = Math.max(0, obj.borderRadius ?? 0); + const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); + + useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ + obj.width, + obj.height, + cornerRadius, + obj.fill, + obj.fillType, + obj.gradient, + obj.stroke, + actualStrokeWidth, + obj.blur, + ]); // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده const displayStroke = isSelected @@ -54,6 +68,7 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap strokeWidth={displayStrokeWidth} dash={isMask && showGuide ? [10, 5] : undefined} rotation={obj.rotation || 0} + {...blurProps} draggable={draggable} onClick={(e) => { if (shapeRef.current) { diff --git a/src/pages/editor/components/tools/TriangleShape.tsx b/src/pages/editor/components/tools/TriangleShape.tsx index 6986e2e..6ac629c 100644 --- a/src/pages/editor/components/tools/TriangleShape.tsx +++ b/src/pages/editor/components/tools/TriangleShape.tsx @@ -3,6 +3,7 @@ import { RegularPolygon } from "react-konva"; import Konva from "konva"; import type { ShapeProps } from "./types"; import { getKonvaGradientProps } from "../../utils/gradient"; +import { getKonvaBlurProps, useShapeBlurCache } from "../../utils/shapeBlur"; const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); @@ -15,6 +16,18 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape const actualStrokeWidth = obj.strokeWidth ?? 0; const hasStroke = actualStrokeWidth > 0; + const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); + + useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ + obj.width, + obj.height, + obj.fill, + obj.fillType, + obj.gradient, + obj.stroke, + actualStrokeWidth, + obj.blur, + ]); // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده const displayStroke = isSelected @@ -53,6 +66,7 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape strokeWidth={displayStrokeWidth} dash={isMask && showGuide ? [10, 5] : undefined} rotation={obj.rotation || 0} + {...blurProps} draggable={draggable} onClick={(e) => { if (shapeRef.current) { diff --git a/src/pages/editor/store/editorStore.types.ts b/src/pages/editor/store/editorStore.types.ts index 19bf24b..ea9a041 100644 --- a/src/pages/editor/store/editorStore.types.ts +++ b/src/pages/editor/store/editorStore.types.ts @@ -82,6 +82,8 @@ export type EditorObject = { scaleY?: number; shapeType?: ShapeType; borderRadius?: number; + /** میزان بلور شکل به پیکسل (۰ = بدون بلور) */ + blur?: number; tableData?: TableData; visible?: boolean; isMask?: boolean; diff --git a/src/pages/editor/utils/shapeBlur.ts b/src/pages/editor/utils/shapeBlur.ts new file mode 100644 index 0000000..db6c2aa --- /dev/null +++ b/src/pages/editor/utils/shapeBlur.ts @@ -0,0 +1,42 @@ +import { useLayoutEffect, type CSSProperties } from "react"; +import type Konva from "konva"; +import KonvaLib from "konva"; + +export function getKonvaBlurProps(blur?: number) { + const blurRadius = blur ?? 0; + if (blurRadius <= 0) return {}; + return { + filters: [KonvaLib.Filters.Blur], + blurRadius, + }; +} + +export function getCssBlurStyle(blur?: number, scale = 1): CSSProperties { + const blurPx = blur ?? 0; + if (blurPx <= 0) return {}; + return { filter: `blur(${blurPx * scale}px)` }; +} + +export function useShapeBlurCache( + shapeRef: React.RefObject, + blur: number | undefined, + cacheKey: unknown, +) { + useLayoutEffect(() => { + const node = shapeRef.current; + if (!node) return; + + const blurRadius = blur ?? 0; + if (blurRadius > 0) { + node.clearCache(); + node.cache({ + offset: blurRadius, + drawBorder: false, + }); + } else { + node.clearCache(); + } + + node.getLayer()?.batchDraw(); + }, [blur, cacheKey, shapeRef]); +} diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx index b41823c..b3d9aaf 100644 --- a/src/pages/viewer/components/BookPage.tsx +++ b/src/pages/viewer/components/BookPage.tsx @@ -11,6 +11,7 @@ import { resolveTextMaxWidth, usesWrappedLayout, } from '@/pages/editor/utils/textStyle'; +import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur'; import '@/pages/viewer/styles/entranceAnimations.css'; import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle'; import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle'; @@ -174,6 +175,7 @@ const BookPage = memo(forwardRef( obj.fillType === 'gradient' && obj.gradient ? { backgroundImage: toCssLinearGradient(obj.gradient) } : { backgroundColor: obj.fill || 'transparent' }; + const shapeBlurStyle = getCssBlurStyle(obj.blur, scale); switch (obj.type) { case 'text': { @@ -487,6 +489,7 @@ const BookPage = memo(forwardRef( height: `${radius * 2}px`, borderRadius: '50%', ...objectFillStyle, + ...shapeBlurStyle, border: hasStroke && obj.stroke ? `${actualStrokeWidth * scale}px solid ${obj.stroke}` : 'none', @@ -549,6 +552,7 @@ const BookPage = memo(forwardRef( width: `${size}px`, height: `${size}px`, opacity: (obj.opacity ?? 100) / 100, + ...shapeBlurStyle, transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined, transformOrigin: 'center center', zIndex: index, @@ -608,6 +612,7 @@ const BookPage = memo(forwardRef( height: `${radius * 2}px`, clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)', ...objectFillStyle, + ...shapeBlurStyle, border: obj.stroke ? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}` : 'none', @@ -630,6 +635,7 @@ const BookPage = memo(forwardRef( width: `${(obj.width || 100) * scale}px`, height: `${(obj.height || 100) * scale}px`, ...objectFillStyle, + ...shapeBlurStyle, borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`, border: hasStroke && obj.stroke ? `${actualStrokeWidth * scale}px solid ${obj.stroke}` diff --git a/src/pages/viewer/utils/dataTransformer.ts b/src/pages/viewer/utils/dataTransformer.ts index 7d932ca..d869901 100644 --- a/src/pages/viewer/utils/dataTransformer.ts +++ b/src/pages/viewer/utils/dataTransformer.ts @@ -38,6 +38,7 @@ type ViewerDataPage = { strokeWidth?: number; shapeType?: string; borderRadius?: number; + blur?: number; imageUrl?: string; videoUrl?: string; audioUrl?: string; @@ -129,6 +130,9 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] { if (obj.type === "rectangle" && obj.borderRadius !== undefined) { baseObject.borderRadius = obj.borderRadius; } + if (obj.type === "rectangle" && obj.blur !== undefined) { + baseObject.blur = obj.blur; + } if ( (obj.type === "image" || obj.type === "sticker" || obj.type === "document") &&