diff --git a/src/pages/editor/components/canvas/BlurBackdropLayer.tsx b/src/pages/editor/components/canvas/BlurBackdropLayer.tsx index 0dbdac2..c3f8a5a 100644 --- a/src/pages/editor/components/canvas/BlurBackdropLayer.tsx +++ b/src/pages/editor/components/canvas/BlurBackdropLayer.tsx @@ -1,8 +1,12 @@ -import { useEffect, useMemo, useState, type CSSProperties } from "react"; +import { useEffect, useMemo, useState } 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"; +import { + buildBlurOverlayStyle, + buildBlurStackEntries, + isBlurBackdropObject, +} from "../../utils/blurOverlayLayout"; +import OcclusionObjectsStage from "./OcclusionObjectsStage"; type BlurBackdropLayerProps = { objects: EditorObject[]; @@ -13,11 +17,9 @@ type BlurBackdropLayerProps = { 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); + const [draggedBlurObjectId, setDraggedBlurObjectId] = useState(null); useEffect(() => { const stage = stageRef.current; @@ -34,147 +36,66 @@ const BlurBackdropLayer = ({ objects, stageWidth, stageHeight, scale, stageRef } }); }; - const onDragMove = () => scheduleBump(); - const onDragEnd = () => scheduleBump(); + const resolveBlurObjectId = (nodeId: string): string | null => { + if (!nodeId) return null; + const direct = objects.find((obj) => obj.id === nodeId && isBlurBackdropObject(obj)); + if (direct) return direct.id; + if (nodeId.startsWith("masked-")) { + const maskedId = nodeId.slice("masked-".length); + const masked = objects.find((obj) => obj.id === maskedId && isBlurBackdropObject(obj)); + if (masked) return masked.id; + } + return null; + }; + const onDragStart = (event: Konva.KonvaEventObject) => { + const target = event.target; + const id = typeof target.id === "function" ? target.id() : ""; + const blurObjectId = resolveBlurObjectId(id); + if (blurObjectId) { + setDraggedBlurObjectId(blurObjectId); + } + scheduleBump(); + }; + + const onDragMove = () => scheduleBump(); + + const onDragEnd = () => { + setDraggedBlurObjectId(null); + scheduleBump(); + }; + + stage.on("dragstart", onDragStart); stage.on("dragmove", onDragMove); stage.on("dragend", onDragEnd); return () => { + stage.off("dragstart", onDragStart); stage.off("dragmove", onDragMove); stage.off("dragend", onDragEnd); if (raf) window.cancelAnimationFrame(raf); }; - }, [stageRef]); + }, [objects, stageRef]); + + const stackEntries = useMemo(() => buildBlurStackEntries(objects), [objects]); + + const livePositions = useMemo(() => { + if (!draggedBlurObjectId) return new Map(); - const overlays = useMemo(() => { const stage = stageRef.current; + if (!stage) return new Map(); - return objects - .map((obj, index) => { - if (obj.visible === false) return null; - if (obj.isMask) return null; - if (obj.type !== "rectangle") return null; + const node = + stage.findOne(`#${draggedBlurObjectId}`) ?? + stage.findOne(`#masked-${draggedBlurObjectId}`); + if (node && "getAbsolutePosition" in node) { + return new Map([[draggedBlurObjectId, node.getAbsolutePosition()]]); + } - const blur = obj.blur ?? 0; - if (blur <= 0) return null; + return new Map(); + }, [draggedBlurObjectId, dragRevision, stageRef]); - 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]); + if (stackEntries.length === 0) return null; return (
- {overlays} + {stackEntries.map((entry) => { + const livePosition = livePositions.get(entry.blurObject.id) ?? null; + const style = buildBlurOverlayStyle(entry.blurObject, scale, livePosition); + + return ( +
+
+ +
+ ); + })}
); }; export default BlurBackdropLayer; - diff --git a/src/pages/editor/components/canvas/OcclusionObjectsStage.tsx b/src/pages/editor/components/canvas/OcclusionObjectsStage.tsx new file mode 100644 index 0000000..4a72bba --- /dev/null +++ b/src/pages/editor/components/canvas/OcclusionObjectsStage.tsx @@ -0,0 +1,76 @@ +import { Stage, Layer, Group } from "react-konva"; +import ObjectRenderer from "./ObjectRenderer"; +import type { EditorObject } from "../../store/editorStore"; + +type OcclusionObjectsStageProps = { + objects: EditorObject[]; + allObjects: EditorObject[]; + stageWidth: number; + stageHeight: number; + scale: number; +}; + +/** Re-renders objects above a blur overlay so they stay crisp (display-only). */ +const OcclusionObjectsStage = ({ + objects, + allObjects, + stageWidth, + stageHeight, + scale, +}: OcclusionObjectsStageProps) => { + if (objects.length === 0) return null; + + const renderObject = (obj: EditorObject) => ( + {}} + onUpdate={() => {}} + draggable={false} + allObjects={allObjects} + stageWidth={stageWidth} + stageHeight={stageHeight} + /> + ); + + return ( + + + {objects + .filter((obj) => obj.type === "group") + .map(renderObject)} + {objects + .filter((obj) => !obj.groupId && obj.type !== "group") + .map(renderObject)} + {objects + .filter((obj) => obj.groupId && obj.type !== "group") + .map((obj) => { + const groupObject = objects.find((o) => o.id === obj.groupId); + if (!groupObject) return null; + + return ( + + {renderObject(obj)} + + ); + })} + + + ); +}; + +export default OcclusionObjectsStage; diff --git a/src/pages/editor/utils/blurOverlayLayout.ts b/src/pages/editor/utils/blurOverlayLayout.ts new file mode 100644 index 0000000..bbc5d48 --- /dev/null +++ b/src/pages/editor/utils/blurOverlayLayout.ts @@ -0,0 +1,170 @@ +import type { CSSProperties } from "react"; +import type { EditorObject } from "../store/editorStore"; +import { getColorWithOpacity } from "./colorOpacity"; +import { toCssGradientAngle, type LinearGradient } from "./gradient"; + +const TRIANGLE_TOP_Y_PERCENT = 6.69873; +const TRIANGLE_BOTTOM_Y_PERCENT = 93.30127; +const BLUR_TINT_OPACITY_FACTOR = 0.25; + +export type BlurOverlayGeometry = { + leftPx: number; + topPx: number; + widthPx: number; + heightPx: number; + borderRadiusPx: number; + clipPath?: string; + transformOrigin: CSSProperties["transformOrigin"]; + rotation: number; +}; + +export function isBlurBackdropObject(obj: EditorObject): boolean { + if (obj.visible === false) return false; + if (obj.isMask) return false; + if (obj.type !== "rectangle") return false; + return (obj.blur ?? 0) > 0; +} + +/** Logical stage coords → overlay pixel space (matches Konva getAbsolutePosition after stage scale). */ +export function computeBlurOverlayGeometry( + obj: EditorObject, + scale: number, + livePosition?: { x: number; y: number } | null, +): BlurOverlayGeometry { + const rotation = obj.rotation ?? 0; + const shapeType = obj.shapeType ?? "square"; + const anchorX = livePosition?.x ?? (obj.x ?? 0) * scale; + const anchorY = livePosition?.y ?? (obj.y ?? 0) * scale; + + if (shapeType === "circle") { + const sizePx = (obj.width ?? 100) * scale; + const radiusPx = sizePx / 2; + return { + leftPx: anchorX - radiusPx, + topPx: anchorY - radiusPx, + widthPx: sizePx, + heightPx: sizePx, + borderRadiusPx: radiusPx, + transformOrigin: "center center", + rotation, + }; + } + + if (shapeType === "triangle") { + const side = Math.min(obj.width ?? 100, obj.height ?? 100); + const radiusPx = (side / 2) * scale; + return { + leftPx: anchorX - radiusPx, + topPx: anchorY - radiusPx, + widthPx: side * scale, + heightPx: side * scale, + borderRadiusPx: 0, + clipPath: `polygon(50% ${TRIANGLE_TOP_Y_PERCENT}%, 0% ${TRIANGLE_BOTTOM_Y_PERCENT}%, 100% ${TRIANGLE_BOTTOM_Y_PERCENT}%)`, + transformOrigin: "center center", + rotation, + }; + } + + if (shapeType === "abstract") { + const rawW = obj.width ?? 100; + const rawH = obj.height ?? 100; + const baseRadius = (Math.min(rawW, rawH) / 2) * scale; + const radius = baseRadius * 0.85; + return { + leftPx: anchorX - radius, + topPx: anchorY - radius, + widthPx: radius * 2, + heightPx: radius * 2, + borderRadiusPx: 0, + 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", + rotation, + }; + } + + return { + leftPx: anchorX, + topPx: anchorY, + widthPx: (obj.width ?? 100) * scale, + heightPx: (obj.height ?? 100) * scale, + borderRadiusPx: Math.max(0, obj.borderRadius ?? 0) * scale, + transformOrigin: "0% 0%", + rotation, + }; +} + +export function buildBlurOverlayStyle( + obj: EditorObject, + scale: number, + livePosition?: { x: number; y: number } | null, +): CSSProperties { + const blur = obj.blur ?? 0; + const blurPx = blur * scale; + const geometry = computeBlurOverlayGeometry(obj, scale, livePosition); + const objOpacity = obj.opacity ?? 100; + 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), + }; + + return { + position: "absolute", + left: `${geometry.leftPx}px`, + top: `${geometry.topPx}px`, + width: `${geometry.widthPx}px`, + height: `${geometry.heightPx}px`, + pointerEvents: "none", + backdropFilter: `blur(${blurPx}px)`, + WebkitBackdropFilter: `blur(${blurPx}px)`, + ...overlayBackground, + overflow: + geometry.clipPath + ? "hidden" + : geometry.borderRadiusPx > 0 + ? "hidden" + : undefined, + borderRadius: geometry.borderRadiusPx > 0 ? `${geometry.borderRadiusPx}px` : undefined, + clipPath: geometry.clipPath, + transform: geometry.rotation ? `rotate(${geometry.rotation}deg)` : undefined, + transformOrigin: geometry.transformOrigin, + }; +} + +export type BlurStackEntry = { + key: string; + blurObject: EditorObject; + /** Objects rendered above this blur so they stay crisp. */ + occlusionObjects: EditorObject[]; +}; + +export function buildBlurStackEntries(objects: EditorObject[]): BlurStackEntry[] { + const entries: BlurStackEntry[] = []; + + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + if (!isBlurBackdropObject(obj)) continue; + + entries.push({ + key: obj.id, + blurObject: obj, + occlusionObjects: objects.slice(i + 1), + }); + } + + return entries; +}