From 0361e05acb45b51958ffe0f378a920cd01b2e026 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Wed, 8 Jul 2026 16:42:55 +0330 Subject: [PATCH] blur --- src/pages/editor/components/EditorCanvas.tsx | 8 + .../components/canvas/BlurBackdropLayer.tsx | 195 ++++++++++++++++++ .../editor/components/tools/AbstractShape.tsx | 31 +-- .../editor/components/tools/CircleShape.tsx | 21 +- .../components/tools/RectangleShape.tsx | 18 +- .../editor/components/tools/TriangleShape.tsx | 33 +-- 6 files changed, 265 insertions(+), 41 deletions(-) create mode 100644 src/pages/editor/components/canvas/BlurBackdropLayer.tsx diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index 5925bc3..0793631 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -14,6 +14,7 @@ import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing"; import { usePencilDrawing } from "./canvas/usePencilDrawing"; import ObjectsLayer from "./canvas/ObjectsLayer"; import GuidesLayer from "./canvas/GuidesLayer"; +import BlurBackdropLayer from "./canvas/BlurBackdropLayer"; import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer"; import PencilDrawingLayer from "./canvas/PencilDrawingLayer"; import { @@ -627,6 +628,13 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { + diff --git a/src/pages/editor/components/canvas/BlurBackdropLayer.tsx b/src/pages/editor/components/canvas/BlurBackdropLayer.tsx new file mode 100644 index 0000000..0dbdac2 --- /dev/null +++ b/src/pages/editor/components/canvas/BlurBackdropLayer.tsx @@ -0,0 +1,195 @@ +import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import type Konva from "konva"; +import type { EditorObject } from "../../store/editorStore"; +import { getColorWithOpacity } from "../../utils/colorOpacity"; +import { toCssGradientAngle, type LinearGradient } from "../../utils/gradient"; + +type BlurBackdropLayerProps = { + objects: EditorObject[]; + stageWidth: number; + stageHeight: number; + /** Stage scale applied to Konva (editor internal scale + zoom). */ + scale: number; + stageRef: React.RefObject; +}; + +const TRIANGLE_TOP_Y_PERCENT = 6.69873; // (1 - sqrt(3)/2) / 2 +const TRIANGLE_BOTTOM_Y_PERCENT = 93.30127; // 100 - TRIANGLE_TOP_Y_PERCENT + +const BlurBackdropLayer = ({ objects, stageWidth, stageHeight, scale, stageRef }: BlurBackdropLayerProps) => { + const [dragRevision, setDragRevision] = useState(0); + + useEffect(() => { + const stage = stageRef.current; + if (!stage) return; + + let raf = 0; + const bump = () => setDragRevision((v) => v + 1); + + const scheduleBump = () => { + if (raf) return; + raf = window.requestAnimationFrame(() => { + raf = 0; + bump(); + }); + }; + + const onDragMove = () => scheduleBump(); + const onDragEnd = () => scheduleBump(); + + stage.on("dragmove", onDragMove); + stage.on("dragend", onDragEnd); + + return () => { + stage.off("dragmove", onDragMove); + stage.off("dragend", onDragEnd); + if (raf) window.cancelAnimationFrame(raf); + }; + }, [stageRef]); + + const overlays = useMemo(() => { + const stage = stageRef.current; + + return objects + .map((obj, index) => { + if (obj.visible === false) return null; + if (obj.isMask) return null; + if (obj.type !== "rectangle") return null; + + const blur = obj.blur ?? 0; + if (blur <= 0) return null; + + const blurPx = blur * scale; + const rotation = obj.rotation ?? 0; + const shapeType = obj.shapeType ?? "square"; + + // Konva shapes are positioned differently depending on shapeType. + let leftPx = 0; + let topPx = 0; + let widthPx = (obj.width ?? 100) * scale; + let heightPx = (obj.height ?? 100) * scale; + let borderRadiusPx = 0; + let clipPath: string | undefined; + let transformOrigin: CSSProperties["transformOrigin"] = "top left"; + + const node = stage?.findOne(`#${obj.id}`); + const absPos = node && "getAbsolutePosition" in node ? node.getAbsolutePosition() : null; + const nodeX = absPos?.x ?? node?.x?.() ?? obj.x ?? 0; + const nodeY = absPos?.y ?? node?.y?.() ?? obj.y ?? 0; + + if (shapeType === "circle") { + const sizePx = (obj.width ?? 100) * scale; + const radiusPx = sizePx / 2; + // `getAbsolutePosition()` is already in the same pixel space as our overlay container. + // Konva.Circle uses x/y as CENTER coordinates, while our overlay uses top-left. + leftPx = nodeX - radiusPx; + topPx = nodeY - radiusPx; + widthPx = sizePx; + heightPx = sizePx; + borderRadiusPx = radiusPx; + transformOrigin = "center center"; + } else if (shapeType === "triangle") { + const baseWidth = obj.width ?? 100; + const baseHeight = obj.height ?? 100; + const side = Math.min(baseWidth, baseHeight); + const radius = side / 2; + const radiusPx = radius * scale; + // In Konva TriangleShape, x/y are center coordinates. + leftPx = nodeX - radiusPx; + topPx = nodeY - radiusPx; + widthPx = side * scale; + heightPx = side * scale; + clipPath = `polygon(50% ${TRIANGLE_TOP_Y_PERCENT}%, 0% ${TRIANGLE_BOTTOM_Y_PERCENT}%, 100% ${TRIANGLE_BOTTOM_Y_PERCENT}%)`; + transformOrigin = "center center"; + } else if (shapeType === "abstract") { + const rawW = obj.width ?? 100; + const rawH = obj.height ?? 100; + const width = rawW * scale; + const height = rawH * scale; + + const baseRadius = Math.min(width, height) / 2; + const abstractScale = 0.85; + const radius = baseRadius * abstractScale; + + // In Konva AbstractShape, x/y are center coordinates. + const centerX = nodeX; + const centerY = nodeY; + leftPx = centerX - radius; + topPx = centerY - radius; + widthPx = radius * 2; + heightPx = radius * 2; + clipPath = + "polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)"; + transformOrigin = "center center"; + } else { + // rectangle / square (Konva Rect uses x/y as top-left). + leftPx = nodeX; + topPx = nodeY; + widthPx = (obj.width ?? 100) * scale; + heightPx = (obj.height ?? 100) * scale; + borderRadiusPx = Math.max(0, obj.borderRadius ?? 0) * scale; + transformOrigin = "0% 0%"; + } + + const objOpacity = obj.opacity ?? 100; // 0-100 (editor convention) + // Make blur tint translucent so the backdrop blur stays visible. + const BLUR_TINT_OPACITY_FACTOR = 0.25; + const tintOpacity = Math.max(0, Math.min(100, objOpacity * BLUR_TINT_OPACITY_FACTOR)); + const defaultFill = "#3b82f6"; + + const overlayBackground: CSSProperties = + obj.fillType === "gradient" && obj.gradient + ? (() => { + const g = obj.gradient as LinearGradient; + const angle = toCssGradientAngle(g.angle); + const from = getColorWithOpacity(g.from, tintOpacity); + const to = getColorWithOpacity(g.to, tintOpacity); + return { + backgroundImage: `linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`, + backgroundColor: "transparent", + }; + })() + : { + backgroundColor: getColorWithOpacity(obj.fill ?? defaultFill, tintOpacity), + }; + + const style: CSSProperties = { + position: "absolute", + left: `${leftPx}px`, + top: `${topPx}px`, + width: `${widthPx}px`, + height: `${heightPx}px`, + zIndex: index, + pointerEvents: "none", + backdropFilter: `blur(${blurPx}px)`, + WebkitBackdropFilter: `blur(${blurPx}px)`, + ...overlayBackground, + overflow: clipPath ? "hidden" : borderRadiusPx > 0 ? "hidden" : undefined, + borderRadius: borderRadiusPx > 0 ? `${borderRadiusPx}px` : undefined, + clipPath, + transform: rotation ? `rotate(${rotation}deg)` : undefined, + transformOrigin, + }; + + return
; + }) + ; + }, [objects, scale, dragRevision, stageRef]); + + return ( +
+ {overlays} +
+ ); +}; + +export default BlurBackdropLayer; + diff --git a/src/pages/editor/components/tools/AbstractShape.tsx b/src/pages/editor/components/tools/AbstractShape.tsx index 8f0bf6d..33bfd9f 100644 --- a/src/pages/editor/components/tools/AbstractShape.tsx +++ b/src/pages/editor/components/tools/AbstractShape.tsx @@ -3,7 +3,8 @@ 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"; +import { getColorWithOpacity } from "../../utils/colorOpacity"; +import { useShapeBlurCache } from "../../utils/shapeBlur"; const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); @@ -19,9 +20,13 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape const actualStrokeWidth = obj.strokeWidth ?? 0; const hasStroke = actualStrokeWidth > 0; - const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); + const blurRadius = obj.blur ?? 0; + const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined; + // Editor blur is implemented via backdrop-filter (see BlurBackdropLayer). + // Avoid applying Konva blur filters on the element itself. + const blurProps = {}; - useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ + useShapeBlurCache(shapeRef, 0, [ obj.width, obj.height, obj.fill, @@ -29,7 +34,6 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape obj.gradient, obj.stroke, actualStrokeWidth, - obj.blur, ]); // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده @@ -44,14 +48,14 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape const displayStrokeWidth = isSelected ? (hasStroke ? actualStrokeWidth : 3) : (isMask && showGuide ? 2 : actualStrokeWidth); - const gradientProps = isMask - ? {} - : getKonvaGradientProps( - obj.fillType, - obj.gradient, - obj.width || 100, - obj.height || 100, - "centered", + const gradientProps = isMask || blurRadius > 0 + ? {} + : getKonvaGradientProps( + obj.fillType, + obj.gradient, + obj.width || 100, + obj.height || 100, + "centered", ); return ( @@ -64,7 +68,8 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape numPoints={5} innerRadius={innerRadius} outerRadius={radius} - fill={isMask ? "transparent" : obj.fill} + // When blur is enabled, tint is rendered by BlurBackdropLayer. + fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")} {...gradientProps} stroke={displayStroke} strokeWidth={displayStrokeWidth} diff --git a/src/pages/editor/components/tools/CircleShape.tsx b/src/pages/editor/components/tools/CircleShape.tsx index 4b37697..6d4e7f2 100644 --- a/src/pages/editor/components/tools/CircleShape.tsx +++ b/src/pages/editor/components/tools/CircleShape.tsx @@ -3,7 +3,8 @@ 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"; +import { getColorWithOpacity } from "../../utils/colorOpacity"; +import { useShapeBlurCache } from "../../utils/shapeBlur"; const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); @@ -15,16 +16,19 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr const actualStrokeWidth = obj.strokeWidth ?? 0; const hasStroke = actualStrokeWidth > 0; - const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); + const blurRadius = obj.blur ?? 0; + const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined; + // Editor blur is implemented via backdrop-filter (see BlurBackdropLayer). + // Avoid applying Konva blur filters on the element itself. + const blurProps = {}; - useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ + useShapeBlurCache(shapeRef, 0, [ obj.width, obj.fill, obj.fillType, obj.gradient, obj.stroke, actualStrokeWidth, - obj.blur, ]); // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده @@ -40,9 +44,9 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr ? (hasStroke ? actualStrokeWidth : 3) : (isMask && showGuide ? 2 : actualStrokeWidth); const diameter = obj.width || 100; - const gradientProps = isMask - ? {} - : getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered"); + const gradientProps = isMask || blurRadius > 0 + ? {} + : getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered"); return ( 0 ? blurHitFill : (obj.fill ?? "#000000")} {...gradientProps} stroke={displayStroke} strokeWidth={displayStrokeWidth} diff --git a/src/pages/editor/components/tools/RectangleShape.tsx b/src/pages/editor/components/tools/RectangleShape.tsx index 90aa1b5..dbe3abd 100644 --- a/src/pages/editor/components/tools/RectangleShape.tsx +++ b/src/pages/editor/components/tools/RectangleShape.tsx @@ -3,7 +3,8 @@ 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"; +import { getColorWithOpacity } from "../../utils/colorOpacity"; +import { useShapeBlurCache } from "../../utils/shapeBlur"; const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); @@ -16,9 +17,13 @@ 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); + const blurRadius = obj.blur ?? 0; + const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined; + // Editor blur is implemented via backdrop-filter (see BlurBackdropLayer), + // so we intentionally do not apply Konva blur filters here. + const blurProps = {}; - useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ + useShapeBlurCache(shapeRef, 0, [ obj.width, obj.height, cornerRadius, @@ -27,7 +32,6 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap obj.gradient, obj.stroke, actualStrokeWidth, - obj.blur, ]); // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده @@ -42,7 +46,7 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap const displayStrokeWidth = isSelected ? (hasStroke ? actualStrokeWidth : 3) : (isMask && showGuide ? 2 : actualStrokeWidth); - const gradientProps = isMask + const gradientProps = isMask || blurRadius > 0 ? {} : getKonvaGradientProps( obj.fillType, @@ -62,7 +66,9 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap width={obj.width || 100} height={obj.height || 100} cornerRadius={cornerRadius} - fill={isMask ? "transparent" : obj.fill} + // When blur is enabled, visual tint is rendered by BlurBackdropLayer. + // Use a tiny non-zero alpha so Konva hit-detection still works for dragging. + fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")} {...gradientProps} stroke={displayStroke} strokeWidth={displayStrokeWidth} diff --git a/src/pages/editor/components/tools/TriangleShape.tsx b/src/pages/editor/components/tools/TriangleShape.tsx index dd19118..41637b1 100644 --- a/src/pages/editor/components/tools/TriangleShape.tsx +++ b/src/pages/editor/components/tools/TriangleShape.tsx @@ -3,7 +3,8 @@ 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"; +import { getColorWithOpacity } from "../../utils/colorOpacity"; +import { useShapeBlurCache } from "../../utils/shapeBlur"; const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const shapeRef = useRef(null); @@ -16,17 +17,20 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape const actualStrokeWidth = obj.strokeWidth ?? 0; const hasStroke = actualStrokeWidth > 0; - const blurProps = isMask ? {} : getKonvaBlurProps(obj.blur); + const blurRadius = obj.blur ?? 0; + const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined; + // Editor blur is implemented via backdrop-filter (see BlurBackdropLayer). + // Avoid applying Konva blur filters on the element itself. + const blurProps = {}; - useShapeBlurCache(shapeRef, isMask ? 0 : obj.blur, [ + useShapeBlurCache(shapeRef, 0, [ obj.width, - obj.height, obj.fill, obj.fillType, obj.gradient, obj.stroke, actualStrokeWidth, - obj.blur, + obj.height, ]); // برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده @@ -41,14 +45,14 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape const displayStrokeWidth = isSelected ? (hasStroke ? actualStrokeWidth : 3) : (isMask && showGuide ? 2 : actualStrokeWidth); - const gradientProps = isMask - ? {} - : getKonvaGradientProps( - obj.fillType, - obj.gradient, - obj.width || 100, - obj.height || 100, - "centered", + const gradientProps = isMask || blurRadius > 0 + ? {} + : getKonvaGradientProps( + obj.fillType, + obj.gradient, + obj.width || 100, + obj.height || 100, + "centered", ); return ( @@ -60,7 +64,8 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape y={obj.y + (obj.height || 100) / 2} sides={3} radius={radius} - fill={isMask ? "transparent" : obj.fill} + // When blur is enabled, tint is rendered by BlurBackdropLayer. + fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")} {...gradientProps} stroke={displayStroke} strokeWidth={displayStrokeWidth}