Compare commits
5 Commits
4273e899ca
...
677c62e6eb
| Author | SHA1 | Date | |
|---|---|---|---|
| 677c62e6eb | |||
| e0eb29488a | |||
| ae3787241a | |||
| 9e105606a3 | |||
| c6bb275d05 |
@@ -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 Konva from "konva";
|
||||||
import type { EditorObject } from "../../store/editorStore";
|
import type { EditorObject } from "../../store/editorStore";
|
||||||
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
import {
|
||||||
import { toCssGradientAngle, type LinearGradient } from "../../utils/gradient";
|
buildBlurOverlayStyle,
|
||||||
|
buildBlurStackEntries,
|
||||||
|
isBlurBackdropObject,
|
||||||
|
} from "../../utils/blurOverlayLayout";
|
||||||
|
import OcclusionObjectsStage from "./OcclusionObjectsStage";
|
||||||
|
|
||||||
type BlurBackdropLayerProps = {
|
type BlurBackdropLayerProps = {
|
||||||
objects: EditorObject[];
|
objects: EditorObject[];
|
||||||
@@ -13,11 +17,9 @@ type BlurBackdropLayerProps = {
|
|||||||
stageRef: React.RefObject<Konva.Stage | null>;
|
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 BlurBackdropLayer = ({ objects, stageWidth, stageHeight, scale, stageRef }: BlurBackdropLayerProps) => {
|
||||||
const [dragRevision, setDragRevision] = useState(0);
|
const [dragRevision, setDragRevision] = useState(0);
|
||||||
|
const [draggedBlurObjectId, setDraggedBlurObjectId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
@@ -34,147 +36,66 @@ const BlurBackdropLayer = ({ objects, stageWidth, stageHeight, scale, stageRef }
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDragMove = () => scheduleBump();
|
const resolveBlurObjectId = (nodeId: string): string | null => {
|
||||||
const onDragEnd = () => scheduleBump();
|
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("dragmove", onDragMove);
|
||||||
stage.on("dragend", onDragEnd);
|
stage.on("dragend", onDragEnd);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
stage.off("dragstart", onDragStart);
|
||||||
stage.off("dragmove", onDragMove);
|
stage.off("dragmove", onDragMove);
|
||||||
stage.off("dragend", onDragEnd);
|
stage.off("dragend", onDragEnd);
|
||||||
if (raf) window.cancelAnimationFrame(raf);
|
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;
|
const stage = stageRef.current;
|
||||||
|
if (!stage) return new Map<string, { x: number; y: number }>();
|
||||||
|
|
||||||
return objects
|
const node =
|
||||||
.map((obj, index) => {
|
stage.findOne(`#${draggedBlurObjectId}`) ??
|
||||||
if (obj.visible === false) return null;
|
stage.findOne(`#masked-${draggedBlurObjectId}`);
|
||||||
if (obj.isMask) return null;
|
if (node && "getAbsolutePosition" in node) {
|
||||||
if (obj.type !== "rectangle") return null;
|
return new Map([[draggedBlurObjectId, node.getAbsolutePosition()]]);
|
||||||
|
|
||||||
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)
|
return new Map<string, { x: number; y: number }>();
|
||||||
// Make blur tint translucent so the backdrop blur stays visible.
|
}, [draggedBlurObjectId, dragRevision, stageRef]);
|
||||||
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 =
|
if (stackEntries.length === 0) return null;
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -186,10 +107,25 @@ const BlurBackdropLayer = ({ objects, stageWidth, stageHeight, scale, stageRef }
|
|||||||
pointerEvents: "none",
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BlurBackdropLayer;
|
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;
|
isGroupedWithMask: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SELECTION_BLUE = "#93c5fd";
|
||||||
|
const ANCHOR_SIZE = 5;
|
||||||
|
const ROTATE_ANCHOR_OFFSET = 22;
|
||||||
|
|
||||||
const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => {
|
const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => {
|
||||||
if (!selectedObject) return null;
|
if (!selectedObject) return null;
|
||||||
|
|
||||||
@@ -43,12 +47,18 @@ const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask
|
|||||||
}}
|
}}
|
||||||
ignoreStroke={isText}
|
ignoreStroke={isText}
|
||||||
perfectDrawEnabled={false}
|
perfectDrawEnabled={false}
|
||||||
anchorFill="#3b82f6"
|
anchorFill={SELECTION_BLUE}
|
||||||
anchorStroke="#ffffff"
|
anchorStroke="#ffffff"
|
||||||
borderStroke="#3b82f6"
|
borderStroke={SELECTION_BLUE}
|
||||||
borderStrokeWidth={1}
|
borderStrokeWidth={1}
|
||||||
padding={-2}
|
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}
|
rotateEnabled={!isGroup && !isGroupedWithMask}
|
||||||
enabledAnchors={enabledAnchors}
|
enabledAnchors={enabledAnchors}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { EditorObject } from "../../../store/editorStore";
|
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/Input";
|
||||||
|
import type { EditorObject } from "../../../store/editorStore";
|
||||||
|
|
||||||
type TransformSettingsProps = {
|
type TransformSettingsProps = {
|
||||||
selectedObject: EditorObject;
|
selectedObject: EditorObject;
|
||||||
@@ -9,6 +9,7 @@ type TransformSettingsProps = {
|
|||||||
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
|
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="pt-4 flex flex-col gap-4 border-t border-border">
|
<div className="pt-4 flex flex-col gap-4 border-t border-border">
|
||||||
|
<div className="flex gap-4">
|
||||||
<Input
|
<Input
|
||||||
label="موقعیت X"
|
label="موقعیت X"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -29,6 +30,7 @@ const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<Input
|
<Input
|
||||||
label="چرخش (درجه)"
|
label="چرخش (درجه)"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -44,4 +46,3 @@ const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default TransformSettings;
|
export default TransformSettings;
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import Konva from "konva";
|
|||||||
import type { TextShapeProps } from "./types";
|
import type { TextShapeProps } from "./types";
|
||||||
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
|
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
|
||||||
import {
|
import {
|
||||||
|
buildCanvasFont,
|
||||||
|
buildKonvaCanvasFont,
|
||||||
DEFAULT_TEXT_MAX_WIDTH_PX,
|
DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||||
getEffectiveTextWidth,
|
getEffectiveTextWidth,
|
||||||
|
getKonvaFontStyle,
|
||||||
measureTextBlock,
|
measureTextBlock,
|
||||||
resolveTextMaxWidth,
|
resolveTextMaxWidth,
|
||||||
usesWrappedLayout,
|
usesWrappedLayout,
|
||||||
@@ -30,15 +33,6 @@ const getFontWeight = (fontWeight?: string): string => {
|
|||||||
return weightMap[fontWeight] || "normal";
|
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 = ({
|
const TextShape = ({
|
||||||
obj,
|
obj,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -172,11 +166,9 @@ const TextShape = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const specs = [
|
const specs = [
|
||||||
`${fontSize}px ${resolvedFamily}`,
|
buildCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
|
||||||
|
buildKonvaCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
|
||||||
`${konvaStyle} normal ${fontSize}px ${resolvedFamily}`,
|
`${konvaStyle} normal ${fontSize}px ${resolvedFamily}`,
|
||||||
`bold ${fontSize}px ${resolvedFamily}`,
|
|
||||||
`300 ${fontSize}px ${resolvedFamily}`,
|
|
||||||
`200 ${fontSize}px ${resolvedFamily}`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -11,12 +11,18 @@ export const getCssFontWeight = (fontWeight?: string): number => {
|
|||||||
return 400;
|
return 400;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */
|
/**
|
||||||
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => {
|
* Konva.Text `fontStyle` — supports `normal`, `bold`, or numeric strings like `200`.
|
||||||
if (!fontWeight || fontWeight === "normal") return false;
|
* @see https://konvajs.org/api/Konva.Text.html#fontStyle
|
||||||
if (fontWeight === "bold" || fontWeight === "bolder") return true;
|
*/
|
||||||
|
export const getKonvaFontStyle = (fontWeight?: string): string => {
|
||||||
|
if (!fontWeight || fontWeight === "normal") return "normal";
|
||||||
|
if (fontWeight === "bold" || fontWeight === "bolder") return "bold";
|
||||||
|
|
||||||
const n = parseInt(fontWeight, 10);
|
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. */
|
/** Canvas/CSS `font` string shared by editor measurement and viewer layout. */
|
||||||
@@ -30,15 +36,15 @@ export const buildCanvasFont = (
|
|||||||
return `${weight} ${fontSize}px ${family}`;
|
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 = (
|
export const buildKonvaCanvasFont = (
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
fontFamily?: string,
|
fontFamily?: string,
|
||||||
fontWeight?: string,
|
fontWeight?: string,
|
||||||
): string => {
|
): string => {
|
||||||
const family = getFontFamily(fontFamily);
|
const family = getFontFamily(fontFamily);
|
||||||
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal";
|
const style = getKonvaFontStyle(fontWeight);
|
||||||
return `${style} ${fontSize}px ${family}`;
|
return `${style} normal ${fontSize}px ${family}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
|
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
resolveTextMaxWidth,
|
resolveTextMaxWidth,
|
||||||
usesWrappedLayout,
|
usesWrappedLayout,
|
||||||
} from '@/pages/editor/utils/textStyle';
|
} 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 { getCssBorderRadius } from '@/pages/editor/utils/borderRadius';
|
||||||
import '@/pages/viewer/styles/entranceAnimations.css';
|
import '@/pages/viewer/styles/entranceAnimations.css';
|
||||||
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
||||||
@@ -213,11 +217,15 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
return null;
|
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 =
|
const objectFillStyle: React.CSSProperties =
|
||||||
obj.fillType === 'gradient' && obj.gradient
|
obj.fillType === 'gradient' && obj.gradient
|
||||||
? { backgroundImage: toCssLinearGradient(obj.gradient) }
|
? { backgroundImage: toCssLinearGradient(obj.gradient) }
|
||||||
: { backgroundColor: obj.fill || 'transparent' };
|
: { backgroundColor: obj.fill || 'transparent' };
|
||||||
const shapeBlurStyle = getCssBlurStyle(obj.blur, scale);
|
|
||||||
|
|
||||||
switch (obj.type) {
|
switch (obj.type) {
|
||||||
case 'text': {
|
case 'text': {
|
||||||
@@ -537,7 +545,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
height: `${radius * 2}px`,
|
height: `${radius * 2}px`,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
...objectFillStyle,
|
...objectFillStyle,
|
||||||
...shapeBlurStyle,
|
|
||||||
border: hasStroke && obj.stroke
|
border: hasStroke && obj.stroke
|
||||||
? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
|
? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
|
||||||
: 'none',
|
: 'none',
|
||||||
@@ -610,7 +617,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
width: `${size}px`,
|
width: `${size}px`,
|
||||||
height: `${size}px`,
|
height: `${size}px`,
|
||||||
opacity: (obj.opacity ?? 100) / 100,
|
opacity: (obj.opacity ?? 100) / 100,
|
||||||
...shapeBlurStyle,
|
|
||||||
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
|
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
zIndex: index,
|
zIndex: index,
|
||||||
@@ -669,7 +675,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
height: `${radius * 2}px`,
|
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%)',
|
clipPath: 'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)',
|
||||||
...objectFillStyle,
|
...objectFillStyle,
|
||||||
...shapeBlurStyle,
|
|
||||||
border: obj.stroke
|
border: obj.stroke
|
||||||
? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}`
|
? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}`
|
||||||
: 'none',
|
: 'none',
|
||||||
@@ -692,7 +697,6 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
width: `${(obj.width || 100) * scale}px`,
|
width: `${(obj.width || 100) * scale}px`,
|
||||||
height: `${(obj.height || 100) * scale}px`,
|
height: `${(obj.height || 100) * scale}px`,
|
||||||
...objectFillStyle,
|
...objectFillStyle,
|
||||||
...shapeBlurStyle,
|
|
||||||
borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`,
|
borderRadius: `${Math.max(0, (obj.borderRadius || 0) * scale)}px`,
|
||||||
border: hasStroke && obj.stroke
|
border: hasStroke && obj.stroke
|
||||||
? `${actualStrokeWidth * scale}px solid ${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 }}>
|
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||||
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
{showLoadingOverlay && (
|
{showLoadingOverlay && (
|
||||||
|
|||||||
Reference in New Issue
Block a user