border radius for all shape

This commit is contained in:
hamid zarghami
2026-07-05 15:52:39 +03:30
parent 46926c66e7
commit 368cace143
8 changed files with 169 additions and 63 deletions
@@ -88,7 +88,7 @@ const ObjectRenderer = ({
groupNode?.clearCache(); groupNode?.clearCache();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur]); }, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur, obj.borderRadius]);
// Refresh cache after transformer is attached (when isSelected changes) // Refresh cache after transformer is attached (when isSelected changes)
useEffect(() => { useEffect(() => {
@@ -31,6 +31,17 @@ const SizeSettings = ({ selectedObject, onUpdate, defaultWidth = 100, defaultHei
}) })
} }
/> />
<Input
label="گردی گوشه"
type="number"
value={selectedObject.borderRadius ?? 0}
onChange={(e) =>
onUpdate(selectedObject.id, {
borderRadius: Math.max(0, parseInt(e.target.value) || 0),
})
}
min={0}
/>
</> </>
); );
}; };
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
import { Rect, Group, Circle, Text as KonvaText } from "react-konva"; import { Rect, Group, Circle, Text as KonvaText } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getObjectBorderRadius } from "../../utils/borderRadius";
const AudioShape = ({ const AudioShape = ({
obj, obj,
@@ -15,6 +16,7 @@ const AudioShape = ({
const containerWidth = obj.width || 320; const containerWidth = obj.width || 320;
const containerHeight = obj.height || 56; const containerHeight = obj.height || 56;
const cornerRadius = getObjectBorderRadius(obj.borderRadius ?? 8);
const barY = containerHeight * 0.55; const barY = containerHeight * 0.55;
const barHeight = Math.max(4, containerHeight * 0.12); const barHeight = Math.max(4, containerHeight * 0.12);
const playRadius = Math.min(18, containerHeight * 0.35); const playRadius = Math.min(18, containerHeight * 0.35);
@@ -70,7 +72,7 @@ const AudioShape = ({
width={containerWidth} width={containerWidth}
height={containerHeight} height={containerHeight}
fill="#f3f4f6" fill="#f3f4f6"
cornerRadius={8} cornerRadius={cornerRadius}
stroke={isSelected ? "#3b82f6" : "#d1d5db"} stroke={isSelected ? "#3b82f6" : "#d1d5db"}
strokeWidth={isSelected ? 3 : 1} strokeWidth={isSelected ? 3 : 1}
onClick={handleGroupClick} onClick={handleGroupClick}
@@ -1,9 +1,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Image as KonvaImage } from "react-konva"; import { Image as KonvaImage, Group, Rect } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import useImage from "use-image"; import useImage from "use-image";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { clampPositionToStage } from "../../utils/stageBounds"; import { clampPositionToStage } from "../../utils/stageBounds";
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
const ImageObject = ({ const ImageObject = ({
obj, obj,
@@ -17,9 +18,8 @@ const ImageObject = ({
}: ShapeProps) => { }: ShapeProps) => {
const [image, status] = useImage(obj.imageUrl || ""); const [image, status] = useImage(obj.imageUrl || "");
const shapeRef = useRef<Konva.Image>(null); const shapeRef = useRef<Konva.Image>(null);
const groupRef = useRef<Konva.Group>(null);
// Notify parent (ObjectRenderer) as soon as the image is decoded and ready.
// This lets the masked group re-cache itself after the image content appears.
useEffect(() => { useEffect(() => {
if (status === "loaded" && onImageReady) { if (status === "loaded" && onImageReady) {
onImageReady(); onImageReady();
@@ -31,6 +31,84 @@ const ImageObject = ({
const width = obj.width || image.width; const width = obj.width || image.width;
const height = obj.height || image.height; const height = obj.height || image.height;
const hasStageBounds = stageWidth != null && stageHeight != null; const hasStageBounds = stageWidth != null && stageHeight != null;
const cornerRadius = getObjectBorderRadius(obj.borderRadius);
const clipFunc = createRoundedRectClipFunc(width, height, cornerRadius);
const dragBoundFunc =
draggable && hasStageBounds
? (pos: { x: number; y: number }) =>
clampPositionToStage(
pos.x,
pos.y,
width,
height,
stageWidth!,
stageHeight!,
)
: undefined;
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
const node = e.target;
let x = node.x();
let y = node.y();
if (hasStageBounds) {
({ x, y } = clampPositionToStage(
x,
y,
width,
height,
stageWidth!,
stageHeight!,
));
node.position({ x, y });
}
onUpdate(obj.id, { x, y });
};
const handleClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
const node = cornerRadius > 0 ? groupRef.current : shapeRef.current;
if (node) {
onSelect(obj.id, node, e);
}
};
if (cornerRadius > 0) {
return (
<Group
ref={groupRef}
id={obj.id}
name="canvas-object"
x={obj.x}
y={obj.y}
rotation={obj.rotation || 0}
draggable={draggable}
clipFunc={clipFunc}
dragBoundFunc={dragBoundFunc}
onClick={handleClick}
onDragEnd={handleDragEnd}
>
<KonvaImage
x={0}
y={0}
image={image}
width={width}
height={height}
/>
{isSelected && (
<Rect
x={0}
y={0}
width={width}
height={height}
stroke="#3b82f6"
strokeWidth={3}
cornerRadius={cornerRadius}
listening={false}
/>
)}
</Group>
);
}
return ( return (
<KonvaImage <KonvaImage
@@ -46,44 +124,11 @@ const ImageObject = ({
strokeWidth={isSelected ? 3 : 0} strokeWidth={isSelected ? 3 : 0}
rotation={obj.rotation || 0} rotation={obj.rotation || 0}
draggable={draggable} draggable={draggable}
dragBoundFunc={ dragBoundFunc={dragBoundFunc}
draggable && hasStageBounds onClick={handleClick}
? (pos) => onDragEnd={handleDragEnd}
clampPositionToStage(
pos.x,
pos.y,
width,
height,
stageWidth!,
stageHeight!,
)
: undefined
}
onClick={(e) => {
if (shapeRef.current) {
onSelect(obj.id, shapeRef.current, e);
}
}}
onDragEnd={(e) => {
const node = e.target;
let x = node.x();
let y = node.y();
if (hasStageBounds) {
({ x, y } = clampPositionToStage(
x,
y,
width,
height,
stageWidth!,
stageHeight!,
));
node.position({ x, y });
}
onUpdate(obj.id, { x, y });
}}
/> />
); );
}; };
export default ImageObject; export default ImageObject;
@@ -4,6 +4,7 @@ import Konva from "konva";
import useImage from "use-image"; import useImage from "use-image";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
const VideoShape = ({ const VideoShape = ({
obj, obj,
@@ -72,6 +73,8 @@ const VideoShape = ({
const containerWidth = obj.width || 400; const containerWidth = obj.width || 400;
const containerHeight = obj.height || 300; const containerHeight = obj.height || 300;
const cornerRadius = getObjectBorderRadius(obj.borderRadius);
const clipFunc = createRoundedRectClipFunc(containerWidth, containerHeight, cornerRadius);
// هم‌تراز با viewer که object-fit: contain دارد // هم‌تراز با viewer که object-fit: contain دارد
const imageLayout = useMemo(() => { const imageLayout = useMemo(() => {
@@ -151,10 +154,12 @@ const VideoShape = ({
width={containerWidth} width={containerWidth}
height={containerHeight} height={containerHeight}
fill="transparent" fill="transparent"
cornerRadius={cornerRadius}
stroke={isSelected ? "#3b82f6" : "#666666"} stroke={isSelected ? "#3b82f6" : "#666666"}
strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)} strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)}
onClick={handleGroupClick} onClick={handleGroupClick}
/> />
<Group clipFunc={clipFunc}>
{image ? ( {image ? (
<> <>
<Rect <Rect
@@ -184,6 +189,7 @@ const VideoShape = ({
onClick={handleGroupClick} onClick={handleGroupClick}
/> />
)} )}
</Group>
<Group <Group
name="playButton" name="playButton"
onClick={handlePlayClick} onClick={handlePlayClick}
+33
View File
@@ -0,0 +1,33 @@
import type Konva from "konva";
export const getObjectBorderRadius = (borderRadius?: number): number =>
Math.max(0, borderRadius ?? 0);
export const clampBorderRadius = (
radius: number,
width: number,
height: number,
): number => Math.max(0, Math.min(radius, width / 2, height / 2));
export const createRoundedRectClipFunc = (
width: number,
height: number,
radius: number,
): Konva.ContainerConfig["clipFunc"] => {
const r = clampBorderRadius(radius, width, height);
if (r <= 0) return undefined;
return (ctx) => {
ctx.beginPath();
ctx.roundRect(0, 0, width, height, r);
ctx.closePath();
};
};
export const getCssBorderRadius = (
borderRadius: number | undefined,
scale: number,
): string | undefined => {
const r = getObjectBorderRadius(borderRadius) * scale;
return r > 0 ? `${r}px` : undefined;
};
+30 -21
View File
@@ -12,6 +12,7 @@ import {
usesWrappedLayout, usesWrappedLayout,
} from '@/pages/editor/utils/textStyle'; } from '@/pages/editor/utils/textStyle';
import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur'; import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur';
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';
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle'; import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
@@ -27,6 +28,7 @@ const getRasterObjectStyle = (
const scaleY = obj.scaleY ?? 1; const scaleY = obj.scaleY ?? 1;
const width = obj.width != null ? obj.width * scaleX * scale : undefined; const width = obj.width != null ? obj.width * scaleX * scale : undefined;
const height = obj.height != null ? obj.height * scaleY * scale : undefined; const height = obj.height != null ? obj.height * scaleY * scale : undefined;
const borderRadius = getCssBorderRadius(obj.borderRadius, scale);
return { return {
...baseStyle, ...baseStyle,
@@ -36,6 +38,7 @@ const getRasterObjectStyle = (
height: height != null ? `${height}px` : 'auto', height: height != null ? `${height}px` : 'auto',
objectFit: 'fill', objectFit: 'fill',
zIndex: index, zIndex: index,
...(borderRadius ? { borderRadius, overflow: 'hidden' as const } : {}),
}; };
}; };
@@ -277,7 +280,17 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
/> />
); );
case 'video': case 'video': {
const videoBorderRadius = getCssBorderRadius(obj.borderRadius, scale);
const videoStyle: React.CSSProperties = {
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
height: obj.height ? `${obj.height * scale}px` : 'auto',
objectFit: 'contain',
zIndex: index,
...(videoBorderRadius ? { borderRadius: videoBorderRadius, overflow: 'hidden' } : {}),
};
if (options.staticMedia) { if (options.staticMedia) {
return ( return (
<video <video
@@ -288,11 +301,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
preload="metadata" preload="metadata"
style={applyStyle( style={applyStyle(
{ {
...baseStyle, ...videoStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
height: obj.height ? `${obj.height * scale}px` : 'auto',
objectFit: 'contain',
zIndex: index,
pointerEvents: 'none', pointerEvents: 'none',
}, },
obj, obj,
@@ -314,11 +323,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
controls controls
style={applyStyle( style={applyStyle(
{ {
...baseStyle, ...videoStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
height: obj.height ? `${obj.height * scale}px` : 'auto',
objectFit: 'contain',
zIndex: index,
pointerEvents: 'auto', pointerEvents: 'auto',
}, },
obj, obj,
@@ -335,20 +340,26 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
}} }}
/> />
); );
}
case 'audio': {
const audioBorderRadius = getCssBorderRadius(obj.borderRadius ?? 8, scale);
const audioBaseStyle: React.CSSProperties = {
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
zIndex: index,
...(audioBorderRadius ? { borderRadius: audioBorderRadius, overflow: 'hidden' } : {}),
};
case 'audio':
if (options.staticMedia) { if (options.staticMedia) {
return ( return (
<div <div
key={obj.id || index} key={obj.id || index}
style={applyStyle( style={applyStyle(
{ {
...baseStyle, ...audioBaseStyle,
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
backgroundColor: '#f3f4f6', backgroundColor: '#f3f4f6',
borderRadius: `${4 * scale}px`,
zIndex: index,
pointerEvents: 'none', pointerEvents: 'none',
}, },
obj, obj,
@@ -365,10 +376,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
preload="metadata" preload="metadata"
style={applyStyle( style={applyStyle(
{ {
...baseStyle, ...audioBaseStyle,
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
zIndex: index,
pointerEvents: 'auto', pointerEvents: 'auto',
}, },
obj, obj,
@@ -385,6 +393,7 @@ const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
}} }}
/> />
); );
}
case 'link': { case 'link': {
const isInternalLink = obj.linkUrl?.startsWith('page://'); const isInternalLink = obj.linkUrl?.startsWith('page://');
+1 -1
View File
@@ -127,7 +127,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
if (obj.type === "rectangle" && obj.shapeType) { if (obj.type === "rectangle" && obj.shapeType) {
baseObject.shapeType = obj.shapeType as EditorObject["shapeType"]; baseObject.shapeType = obj.shapeType as EditorObject["shapeType"];
} }
if (obj.type === "rectangle" && obj.borderRadius !== undefined) { if (obj.borderRadius !== undefined) {
baseObject.borderRadius = obj.borderRadius; baseObject.borderRadius = obj.borderRadius;
} }
if (obj.type === "rectangle" && obj.blur !== undefined) { if (obj.type === "rectangle" && obj.blur !== undefined) {