From 368cace1432100ac5384b3c7cd9d848c4bfe7408 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 5 Jul 2026 15:52:39 +0330 Subject: [PATCH] border radius for all shape --- .../components/canvas/ObjectRenderer.tsx | 2 +- .../sidebar/settings/SizeSettings.tsx | 11 ++ .../editor/components/tools/AudioShape.tsx | 4 +- .../editor/components/tools/ImageObject.tsx | 123 ++++++++++++------ .../editor/components/tools/VideoShape.tsx | 6 + src/pages/editor/utils/borderRadius.ts | 33 +++++ src/pages/viewer/components/BookPage.tsx | 51 +++++--- src/pages/viewer/utils/dataTransformer.ts | 2 +- 8 files changed, 169 insertions(+), 63 deletions(-) create mode 100644 src/pages/editor/utils/borderRadius.ts diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx index 06ef848..bf19384 100644 --- a/src/pages/editor/components/canvas/ObjectRenderer.tsx +++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx @@ -88,7 +88,7 @@ const ObjectRenderer = ({ groupNode?.clearCache(); }; // 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) useEffect(() => { diff --git a/src/pages/editor/components/sidebar/settings/SizeSettings.tsx b/src/pages/editor/components/sidebar/settings/SizeSettings.tsx index adbb636..d5b8abd 100644 --- a/src/pages/editor/components/sidebar/settings/SizeSettings.tsx +++ b/src/pages/editor/components/sidebar/settings/SizeSettings.tsx @@ -31,6 +31,17 @@ const SizeSettings = ({ selectedObject, onUpdate, defaultWidth = 100, defaultHei }) } /> + + onUpdate(selectedObject.id, { + borderRadius: Math.max(0, parseInt(e.target.value) || 0), + }) + } + min={0} + /> ); }; diff --git a/src/pages/editor/components/tools/AudioShape.tsx b/src/pages/editor/components/tools/AudioShape.tsx index 0d3bba5..cd8ba55 100644 --- a/src/pages/editor/components/tools/AudioShape.tsx +++ b/src/pages/editor/components/tools/AudioShape.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react"; import { Rect, Group, Circle, Text as KonvaText } from "react-konva"; import Konva from "konva"; import type { ShapeProps } from "./types"; +import { getObjectBorderRadius } from "../../utils/borderRadius"; const AudioShape = ({ obj, @@ -15,6 +16,7 @@ const AudioShape = ({ const containerWidth = obj.width || 320; const containerHeight = obj.height || 56; + const cornerRadius = getObjectBorderRadius(obj.borderRadius ?? 8); const barY = containerHeight * 0.55; const barHeight = Math.max(4, containerHeight * 0.12); const playRadius = Math.min(18, containerHeight * 0.35); @@ -70,7 +72,7 @@ const AudioShape = ({ width={containerWidth} height={containerHeight} fill="#f3f4f6" - cornerRadius={8} + cornerRadius={cornerRadius} stroke={isSelected ? "#3b82f6" : "#d1d5db"} strokeWidth={isSelected ? 3 : 1} onClick={handleGroupClick} diff --git a/src/pages/editor/components/tools/ImageObject.tsx b/src/pages/editor/components/tools/ImageObject.tsx index cb1b629..7d47a28 100644 --- a/src/pages/editor/components/tools/ImageObject.tsx +++ b/src/pages/editor/components/tools/ImageObject.tsx @@ -1,9 +1,10 @@ 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 useImage from "use-image"; import type { ShapeProps } from "./types"; import { clampPositionToStage } from "../../utils/stageBounds"; +import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius"; const ImageObject = ({ obj, @@ -17,9 +18,8 @@ const ImageObject = ({ }: ShapeProps) => { const [image, status] = useImage(obj.imageUrl || ""); const shapeRef = useRef(null); + const groupRef = useRef(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(() => { if (status === "loaded" && onImageReady) { onImageReady(); @@ -31,6 +31,84 @@ const ImageObject = ({ const width = obj.width || image.width; const height = obj.height || image.height; 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) => { + 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) => { + const node = cornerRadius > 0 ? groupRef.current : shapeRef.current; + if (node) { + onSelect(obj.id, node, e); + } + }; + + if (cornerRadius > 0) { + return ( + + + {isSelected && ( + + )} + + ); + } return ( - 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 }); - }} + dragBoundFunc={dragBoundFunc} + onClick={handleClick} + onDragEnd={handleDragEnd} /> ); }; export default ImageObject; - diff --git a/src/pages/editor/components/tools/VideoShape.tsx b/src/pages/editor/components/tools/VideoShape.tsx index 57e55f4..1e5dd1e 100644 --- a/src/pages/editor/components/tools/VideoShape.tsx +++ b/src/pages/editor/components/tools/VideoShape.tsx @@ -4,6 +4,7 @@ import Konva from "konva"; import useImage from "use-image"; import type { ShapeProps } from "./types"; import { createPortal } from "react-dom"; +import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius"; const VideoShape = ({ obj, @@ -72,6 +73,8 @@ const VideoShape = ({ const containerWidth = obj.width || 400; const containerHeight = obj.height || 300; + const cornerRadius = getObjectBorderRadius(obj.borderRadius); + const clipFunc = createRoundedRectClipFunc(containerWidth, containerHeight, cornerRadius); // هم‌تراز با viewer که object-fit: contain دارد const imageLayout = useMemo(() => { @@ -151,10 +154,12 @@ const VideoShape = ({ width={containerWidth} height={containerHeight} fill="transparent" + cornerRadius={cornerRadius} stroke={isSelected ? "#3b82f6" : "#666666"} strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)} onClick={handleGroupClick} /> + {image ? ( <> )} + + 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; +}; diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx index b3d9aaf..5331dc4 100644 --- a/src/pages/viewer/components/BookPage.tsx +++ b/src/pages/viewer/components/BookPage.tsx @@ -12,6 +12,7 @@ import { usesWrappedLayout, } from '@/pages/editor/utils/textStyle'; import { getCssBlurStyle } from '@/pages/editor/utils/shapeBlur'; +import { getCssBorderRadius } from '@/pages/editor/utils/borderRadius'; import '@/pages/viewer/styles/entranceAnimations.css'; import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle'; import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle'; @@ -27,6 +28,7 @@ const getRasterObjectStyle = ( const scaleY = obj.scaleY ?? 1; const width = obj.width != null ? obj.width * scaleX * scale : undefined; const height = obj.height != null ? obj.height * scaleY * scale : undefined; + const borderRadius = getCssBorderRadius(obj.borderRadius, scale); return { ...baseStyle, @@ -36,6 +38,7 @@ const getRasterObjectStyle = ( height: height != null ? `${height}px` : 'auto', objectFit: 'fill', zIndex: index, + ...(borderRadius ? { borderRadius, overflow: 'hidden' as const } : {}), }; }; @@ -277,7 +280,17 @@ const BookPage = memo(forwardRef( /> ); - 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) { return (