fix order in blur layers + fix aligment
This commit is contained in:
@@ -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 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<string, { x: number; y: number }>();
|
||||
}, [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 <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;
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user