Compare commits

...

5 Commits

Author SHA1 Message Date
hamid zarghami 677c62e6eb fix extra light weight
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-11 09:39:03 +03:30
hamid zarghami e0eb29488a position x and y in line 2026-07-11 09:31:40 +03:30
hamid zarghami ae3787241a object box smaller 2026-07-11 09:21:13 +03:30
hamid zarghami 9e105606a3 fix blur in viewer 2026-07-11 09:10:41 +03:30
hamid zarghami c6bb275d05 fix order in blur layers + fix aligment 2026-07-11 09:05:13 +03:30
8 changed files with 434 additions and 203 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 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;
@@ -8,6 +8,10 @@ interface TransformerControlsProps {
isGroupedWithMask: boolean;
}
const SELECTION_BLUE = "#93c5fd";
const ANCHOR_SIZE = 5;
const ROTATE_ANCHOR_OFFSET = 22;
const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => {
if (!selectedObject) return null;
@@ -43,12 +47,18 @@ const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask
}}
ignoreStroke={isText}
perfectDrawEnabled={false}
anchorFill="#3b82f6"
anchorFill={SELECTION_BLUE}
anchorStroke="#ffffff"
borderStroke="#3b82f6"
borderStroke={SELECTION_BLUE}
borderStrokeWidth={1}
padding={-2}
anchorSize={8}
anchorSize={ANCHOR_SIZE}
rotateAnchorOffset={ROTATE_ANCHOR_OFFSET}
anchorStyleFunc={(anchor) => {
if (anchor.hasName("rotater")) {
anchor.cornerRadius(ANCHOR_SIZE / 2);
}
}}
rotateEnabled={!isGroup && !isGroupedWithMask}
enabledAnchors={enabledAnchors}
/>
@@ -1,47 +1,48 @@
import type { EditorObject } from "../../../store/editorStore";
import Input from "@/components/Input";
import type { EditorObject } from "../../../store/editorStore";
type TransformSettingsProps = {
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
selectedObject: EditorObject;
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
};
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
return (
<div className="pt-4 flex flex-col gap-4 border-t border-border">
<Input
label="موقعیت X"
type="number"
value={Math.round(selectedObject.x)}
onChange={(e) =>
onUpdate(selectedObject.id, {
x: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="موقعیت Y"
type="number"
value={Math.round(selectedObject.y)}
onChange={(e) =>
onUpdate(selectedObject.id, {
y: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="چرخش (درجه)"
type="number"
value={selectedObject.rotation || 0}
onChange={(e) =>
onUpdate(selectedObject.id, {
rotation: parseInt(e.target.value) || 0,
})
}
/>
</div>
);
return (
<div className="pt-4 flex flex-col gap-4 border-t border-border">
<div className="flex gap-4">
<Input
label="موقعیت X"
type="number"
value={Math.round(selectedObject.x)}
onChange={(e) =>
onUpdate(selectedObject.id, {
x: parseInt(e.target.value) || 0,
})
}
/>
<Input
label="موقعیت Y"
type="number"
value={Math.round(selectedObject.y)}
onChange={(e) =>
onUpdate(selectedObject.id, {
y: parseInt(e.target.value) || 0,
})
}
/>
</div>
<Input
label="چرخش (درجه)"
type="number"
value={selectedObject.rotation || 0}
onChange={(e) =>
onUpdate(selectedObject.id, {
rotation: parseInt(e.target.value) || 0,
})
}
/>
</div>
);
};
export default TransformSettings;
@@ -4,8 +4,11 @@ import Konva from "konva";
import type { TextShapeProps } from "./types";
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
import {
buildCanvasFont,
buildKonvaCanvasFont,
DEFAULT_TEXT_MAX_WIDTH_PX,
getEffectiveTextWidth,
getKonvaFontStyle,
measureTextBlock,
resolveTextMaxWidth,
usesWrappedLayout,
@@ -30,15 +33,6 @@ const getFontWeight = (fontWeight?: string): string => {
return weightMap[fontWeight] || "normal";
};
/** Konva.Text فقط `fontStyle` دارد (نه fontWeight جداگانه)؛ مقدار باید normal|bold|italic باشد. */
const getKonvaFontStyle = (fontWeight?: string): string => {
const w = getFontWeight(fontWeight);
if (w === "bold" || w === "bolder") return "bold";
const n = parseInt(w, 10);
if (!Number.isNaN(n) && n >= 600) return "bold";
return "normal";
};
const TextShape = ({
obj,
onSelect,
@@ -172,11 +166,9 @@ const TextShape = ({
}
const specs = [
`${fontSize}px ${resolvedFamily}`,
buildCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
buildKonvaCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
`${konvaStyle} normal ${fontSize}px ${resolvedFamily}`,
`bold ${fontSize}px ${resolvedFamily}`,
`300 ${fontSize}px ${resolvedFamily}`,
`200 ${fontSize}px ${resolvedFamily}`,
];
void Promise.all(
+178
View File
@@ -0,0 +1,178 @@
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 ??
(shapeType === "triangle" || shapeType === "abstract"
? ((obj.x ?? 0) + (obj.width ?? 100) / 2) * scale
: (obj.x ?? 0) * scale);
const anchorY =
livePosition?.y ??
(shapeType === "triangle" || shapeType === "abstract"
? ((obj.y ?? 0) + (obj.height ?? 100) / 2) * scale
: (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;
}
+14 -8
View File
@@ -11,12 +11,18 @@ export const getCssFontWeight = (fontWeight?: string): number => {
return 400;
};
/** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => {
if (!fontWeight || fontWeight === "normal") return false;
if (fontWeight === "bold" || fontWeight === "bolder") return true;
/**
* Konva.Text `fontStyle` — supports `normal`, `bold`, or numeric strings like `200`.
* @see https://konvajs.org/api/Konva.Text.html#fontStyle
*/
export const getKonvaFontStyle = (fontWeight?: string): string => {
if (!fontWeight || fontWeight === "normal") return "normal";
if (fontWeight === "bold" || fontWeight === "bolder") return "bold";
const n = parseInt(fontWeight, 10);
return !Number.isNaN(n) && n >= 600;
if (!Number.isNaN(n)) return n.toString();
return "normal";
};
/** Canvas/CSS `font` string shared by editor measurement and viewer layout. */
@@ -30,15 +36,15 @@ export const buildCanvasFont = (
return `${weight} ${fontSize}px ${family}`;
};
/** Canvas font string that matches Konva.Text (`fontStyle: bold`). */
/** Canvas font string that matches Konva.Text `_getContextFont()`. */
export const buildKonvaCanvasFont = (
fontSize: number,
fontFamily?: string,
fontWeight?: string,
): string => {
const family = getFontFamily(fontFamily);
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal";
return `${style} ${fontSize}px ${family}`;
const style = getKonvaFontStyle(fontWeight);
return `${style} normal ${fontSize}px ${family}`;
};
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
+38 -6
View File
@@ -11,7 +11,11 @@ import {
resolveTextMaxWidth,
usesWrappedLayout,
} from '@/pages/editor/utils/textStyle';
import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur';
import {
buildBlurOverlayStyle,
buildBlurStackEntries,
isBlurBackdropObject,
} from '@/pages/editor/utils/blurOverlayLayout';
import { getCssBorderRadius } from '@/pages/editor/utils/borderRadius';
import '@/pages/viewer/styles/entranceAnimations.css';
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
@@ -213,11 +217,15 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
return null;
}
// Blur shapes use backdrop-filter overlays (see blur stack below), not CSS filter on the shape.
if (isBlurBackdropObject(obj)) {
return null;
}
const objectFillStyle: React.CSSProperties =
obj.fillType === 'gradient' && obj.gradient
? { backgroundImage: toCssLinearGradient(obj.gradient) }
: { backgroundColor: obj.fill || 'transparent' };
const shapeBlurStyle = getCssBlurStyle(obj.blur, scale);
switch (obj.type) {
case 'text': {
@@ -537,7 +545,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
height: `${radius * 2}px`,
borderRadius: '50%',
...objectFillStyle,
...shapeBlurStyle,
border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
: 'none',
@@ -610,7 +617,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
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,
@@ -669,7 +675,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
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',
@@ -692,7 +697,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
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}`
@@ -1102,6 +1106,34 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
)}
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
{buildBlurStackEntries(page.elements).map((entry) => {
const blurIndex = page.elements.findIndex((el) => el.id === entry.blurObject.id);
const blurOverlayStyle = buildBlurOverlayStyle(entry.blurObject, scale);
return (
<div
key={`blur-stack-${entry.key}`}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
>
<div
style={withEntrance(
blurOverlayStyle,
entry.blurObject,
blurIndex >= 0 ? blurIndex : 0,
{
transformOrigin: blurOverlayStyle.transformOrigin,
rotationDeg: entry.blurObject.rotation ?? 0,
},
)}
/>
{entry.occlusionObjects.map((obj) => {
const index = page.elements.findIndex((el) => el.id === obj.id);
if (index < 0) return null;
return renderObject(obj, index, page.elements);
})}
</div>
);
})}
</div>
</div>
{showLoadingOverlay && (