fix order in blur layers + fix aligment

This commit is contained in:
hamid zarghami
2026-07-11 09:05:13 +03:30
parent 4273e899ca
commit c6bb275d05
3 changed files with 317 additions and 135 deletions
@@ -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<Konva.Stage | null>;
};
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<string | null>(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<DragEvent>) => {
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<string, { x: number; y: number }>();
const overlays = useMemo(() => {
const stage = stageRef.current;
if (!stage) return new Map<string, { x: number; y: number }>();
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 node =
stage.findOne(`#${draggedBlurObjectId}`) ??
stage.findOne(`#masked-${draggedBlurObjectId}`);
if (node && "getAbsolutePosition" in node) {
return new Map([[draggedBlurObjectId, node.getAbsolutePosition()]]);
}
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";
return new Map<string, { x: number; y: number }>();
}, [draggedBlurObjectId, dragRevision, stageRef]);
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 <div key={`blur-backdrop-${obj.id}`} style={style} />;
})
;
}, [objects, scale, dragRevision, stageRef]);
if (stackEntries.length === 0) return null;
return (
<div
@@ -186,10 +107,25 @@ const BlurBackdropLayer = ({ objects, stageWidth, stageHeight, scale, stageRef }
pointerEvents: "none",
}}
>
{overlays}
{stackEntries.map((entry) => {
const livePosition = livePositions.get(entry.blurObject.id) ?? null;
const style = buildBlurOverlayStyle(entry.blurObject, scale, livePosition);
return (
<div key={entry.key} style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
<div style={style} />
<OcclusionObjectsStage
objects={entry.occlusionObjects}
allObjects={objects}
stageWidth={stageWidth}
stageHeight={stageHeight}
scale={scale}
/>
</div>
);
})}
</div>
);
};
export default BlurBackdropLayer;
@@ -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) => (
<ObjectRenderer
key={`occlusion-${obj.id}`}
obj={obj}
isSelected={false}
transformerRef={{ current: null }}
layerRef={{ current: null }}
onSelect={() => {}}
onUpdate={() => {}}
draggable={false}
allObjects={allObjects}
stageWidth={stageWidth}
stageHeight={stageHeight}
/>
);
return (
<Stage
width={stageWidth * scale}
height={stageHeight * scale}
scaleX={scale}
scaleY={scale}
listening={false}
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
}}
>
<Layer listening={false}>
{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 (
<Group key={`occlusion-group-member-${obj.id}`} listening={false}>
{renderObject(obj)}
</Group>
);
})}
</Layer>
</Stage>
);
};
export default OcclusionObjectsStage;
+170
View File
@@ -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;
}