updateCurrentPageBackground({ backgroundType: opt.value })}
+ onClick={() =>
+ updateCurrentPageBackground({
+ backgroundType: opt.value,
+ ...(opt.value === 'gradient'
+ ? {
+ backgroundGradient: currentPage?.backgroundGradient ?? {
+ from: '#ffffff',
+ to: '#e2e8f0',
+ angle: 135,
+ },
+ }
+ : {}),
+ })
+ }
className={clx(
'w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors cursor-pointer',
backgroundType === opt.value
@@ -209,6 +229,73 @@ const SettingsPanel = () => {
)}
+ {backgroundType === 'gradient' && (
+
diff --git a/src/pages/editor/components/canvas/ObjectRenderer.tsx b/src/pages/editor/components/canvas/ObjectRenderer.tsx
index 367786a..c3ec4a6 100644
--- a/src/pages/editor/components/canvas/ObjectRenderer.tsx
+++ b/src/pages/editor/components/canvas/ObjectRenderer.tsx
@@ -74,7 +74,7 @@ const ObjectRenderer = ({
if (rafId1) cancelAnimationFrame(rafId1);
groupNode?.clearCache();
};
- }, [shouldApplyMask, isSelected, maskShape?.x, maskShape?.y, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.x, obj.y, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill]);
+ }, [shouldApplyMask, isSelected, maskShape?.x, maskShape?.y, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.x, obj.y, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient]);
// Refresh cache after transformer is attached (when isSelected changes)
useEffect(() => {
diff --git a/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx b/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx
index 613f6a1..8c4e8d7 100644
--- a/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx
+++ b/src/pages/editor/components/sidebar/settings/ShapeSettings.tsx
@@ -11,6 +11,12 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
const baseWidth = selectedObject.width ?? 100;
const baseHeight = selectedObject.height ?? 100;
const baseFill = selectedObject.fill ?? "#3b82f6";
+ const fillType = selectedObject.fillType ?? "solid";
+ const gradient = selectedObject.gradient ?? {
+ from: "#3b82f6",
+ to: "#1d4ed8",
+ angle: 135,
+ };
const baseStroke = selectedObject.stroke ?? "#1e40af";
const baseStrokeWidth = selectedObject.strokeWidth ?? 0;
const baseBorderRadius = selectedObject.borderRadius ?? 0;
@@ -42,12 +48,84 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
onUpdate(selectedObject.id, {
fill: value,
})
}
/>
+
+
+
+
+
+
+
+ {fillType === "gradient" && (
+
+
+ onUpdate(selectedObject.id, {
+ gradient: { ...gradient, from: value },
+ })
+ }
+ />
+
+ onUpdate(selectedObject.id, {
+ gradient: { ...gradient, to: value },
+ })
+ }
+ />
+
+ onUpdate(selectedObject.id, {
+ gradient: {
+ ...gradient,
+ angle: Number(e.target.value) || 0,
+ },
+ })
+ }
+ min={0}
+ max={360}
+ />
+
+ )}
{
const shapeRef = useRef(null);
@@ -30,6 +31,15 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth);
+ const gradientProps = isMask
+ ? {}
+ : getKonvaGradientProps(
+ obj.fillType,
+ obj.gradient,
+ obj.width || 100,
+ obj.height || 100,
+ "centered",
+ );
return (
{
const shapeRef = useRef(null);
@@ -26,6 +27,10 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth);
+ const diameter = obj.width || 100;
+ const gradientProps = isMask
+ ? {}
+ : getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
return (
{
const shapeRef = useRef(null);
@@ -27,6 +28,15 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth);
+ const gradientProps = isMask
+ ? {}
+ : getKonvaGradientProps(
+ obj.fillType,
+ obj.gradient,
+ obj.width || 100,
+ obj.height || 100,
+ "rect",
+ );
return (
{
const shapeRef = useRef(null);
@@ -27,6 +28,15 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth);
+ const gradientProps = isMask
+ ? {}
+ : getKonvaGradientProps(
+ obj.fillType,
+ obj.gradient,
+ obj.width || 100,
+ obj.height || 100,
+ "centered",
+ );
return (
({
guides: [],
backgroundType: "color",
backgroundColor: "#ffffff",
+ backgroundGradient: {
+ from: "#ffffff",
+ to: "#e2e8f0",
+ angle: 135,
+ },
backgroundImageUrl: "",
});
@@ -118,8 +123,8 @@ export const getObjectBounds = (obj: EditorObject) => {
return { minX, minY, maxX, maxY };
}
- let w = obj.width || 0;
- let h = obj.height || 0;
+ const w = obj.width || 0;
+ const h = obj.height || 0;
if (obj.type === "rectangle" && obj.shapeType === "circle") {
const radius = w / 2;
diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts
index 392d24b..dd2c824 100644
--- a/src/pages/editor/store/editorStore.ts
+++ b/src/pages/editor/store/editorStore.ts
@@ -46,7 +46,7 @@ export type {
type PageBackgroundSettings = Pick<
Page,
- "backgroundType" | "backgroundColor" | "backgroundImageUrl"
+ "backgroundType" | "backgroundColor" | "backgroundGradient" | "backgroundImageUrl"
>;
type EditorStoreType = {
@@ -191,6 +191,11 @@ export const useEditorStore = create((set, get) => {
leftToRightFlip: false,
backgroundType: 'color',
backgroundColor: '#ffffff',
+ backgroundGradient: {
+ from: "#ffffff",
+ to: "#e2e8f0",
+ angle: 135,
+ },
backgroundImageUrl: '',
},
updateDocumentSettings: (updates) =>
@@ -836,6 +841,7 @@ export const useEditorStore = create((set, get) => {
guides: [...pageToDuplicate.guides],
backgroundType: pageToDuplicate.backgroundType,
backgroundColor: pageToDuplicate.backgroundColor,
+ backgroundGradient: pageToDuplicate.backgroundGradient,
backgroundImageUrl: pageToDuplicate.backgroundImageUrl,
};
@@ -1009,6 +1015,12 @@ export const useEditorStore = create((set, get) => {
page.backgroundType ?? fallbackSettings.backgroundType ?? "color",
backgroundColor:
page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff",
+ backgroundGradient: page.backgroundGradient ??
+ fallbackSettings.backgroundGradient ?? {
+ from: "#ffffff",
+ to: "#e2e8f0",
+ angle: 135,
+ },
backgroundImageUrl:
page.backgroundImageUrl ?? fallbackSettings.backgroundImageUrl ?? "",
}));
diff --git a/src/pages/editor/store/editorStore.types.ts b/src/pages/editor/store/editorStore.types.ts
index a51fc45..ce306fe 100644
--- a/src/pages/editor/store/editorStore.types.ts
+++ b/src/pages/editor/store/editorStore.types.ts
@@ -1,4 +1,5 @@
import type { ShapeType } from "./shapeStore";
+import type { FillType, LinearGradient } from "../utils/gradient";
export type ToolType =
| "select"
@@ -41,6 +42,8 @@ export type EditorObject = {
width?: number;
height?: number;
fill?: string;
+ fillType?: FillType;
+ gradient?: LinearGradient;
stroke?: string;
strokeWidth?: number;
text?: string;
@@ -82,11 +85,12 @@ export type Page = {
guides: PageGuide[];
backgroundType: BackgroundType;
backgroundColor: string;
+ backgroundGradient?: LinearGradient;
backgroundImageUrl: string;
};
export type DisplayStyle = 'single' | 'double';
-export type BackgroundType = 'color' | 'image';
+export type BackgroundType = 'color' | 'gradient' | 'image';
export type DocumentSettings = {
displayStyle: DisplayStyle;
@@ -95,5 +99,6 @@ export type DocumentSettings = {
leftToRightFlip: boolean;
backgroundType: BackgroundType;
backgroundColor: string;
+ backgroundGradient?: LinearGradient;
backgroundImageUrl: string;
};
diff --git a/src/pages/editor/utils/gradient.ts b/src/pages/editor/utils/gradient.ts
new file mode 100644
index 0000000..fd3e9c8
--- /dev/null
+++ b/src/pages/editor/utils/gradient.ts
@@ -0,0 +1,73 @@
+export type FillType = "solid" | "gradient";
+
+export type LinearGradient = {
+ from: string;
+ to: string;
+ angle: number;
+};
+
+type Point = { x: number; y: number };
+
+type GradientPointMode = "rect" | "centered";
+
+const toRadians = (angle: number) => (angle * Math.PI) / 180;
+
+const normalizeAngle = (angle: number) => {
+ if (!Number.isFinite(angle)) return 0;
+ return ((angle % 360) + 360) % 360;
+};
+
+const getGradientPoints = (
+ width: number,
+ height: number,
+ angle: number,
+ mode: GradientPointMode,
+): { start: Point; end: Point } => {
+ const safeWidth = Math.max(1, width);
+ const safeHeight = Math.max(1, height);
+ const rad = toRadians(normalizeAngle(angle));
+ const dx = Math.cos(rad);
+ const dy = Math.sin(rad);
+ const half = Math.sqrt(safeWidth * safeWidth + safeHeight * safeHeight) / 2;
+
+ if (mode === "centered") {
+ return {
+ start: { x: -dx * half, y: -dy * half },
+ end: { x: dx * half, y: dy * half },
+ };
+ }
+
+ const cx = safeWidth / 2;
+ const cy = safeHeight / 2;
+ return {
+ start: { x: cx - dx * half, y: cy - dy * half },
+ end: { x: cx + dx * half, y: cy + dy * half },
+ };
+};
+
+export const getKonvaGradientProps = (
+ fillType: FillType | undefined,
+ gradient: LinearGradient | undefined,
+ width: number,
+ height: number,
+ mode: GradientPointMode,
+) => {
+ if (fillType !== "gradient" || !gradient) return {};
+ const points = getGradientPoints(width, height, gradient.angle, mode);
+ return {
+ fillPriority: "linear-gradient" as const,
+ fillLinearGradientStartPoint: points.start,
+ fillLinearGradientEndPoint: points.end,
+ fillLinearGradientColorStops: [0, gradient.from, 1, gradient.to] as [
+ number,
+ string,
+ number,
+ string,
+ ],
+ };
+};
+
+export const toCssLinearGradient = (gradient: LinearGradient | undefined) => {
+ if (!gradient) return undefined;
+ return `linear-gradient(${normalizeAngle(gradient.angle)}deg, ${gradient.from} 0%, ${gradient.to} 100%)`;
+};
diff --git a/src/pages/viewer/components/BookPage.tsx b/src/pages/viewer/components/BookPage.tsx
index 98900ce..e1a9b11 100644
--- a/src/pages/viewer/components/BookPage.tsx
+++ b/src/pages/viewer/components/BookPage.tsx
@@ -1,6 +1,7 @@
import { forwardRef } from 'react';
import { type PageData } from '../types';
import type { EditorObject } from '@/pages/editor/store/editorStore';
+import { toCssLinearGradient } from '@/pages/editor/utils/gradient';
type BookPageProps = {
page: PageData;
@@ -8,8 +9,13 @@ type BookPageProps = {
pageWidth?: number;
pageHeight?: number;
onLinkClick?: (linkUrl: string) => void;
- backgroundType?: 'color' | 'image';
+ backgroundType?: 'color' | 'gradient' | 'image';
backgroundColor?: string;
+ backgroundGradient?: {
+ from: string;
+ to: string;
+ angle: number;
+ };
backgroundImageUrl?: string;
};
@@ -21,7 +27,7 @@ type BookPageProps = {
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
*/
const BookPage = forwardRef(
- ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundImageUrl }, ref) => {
+ ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl }, ref) => {
// تابع برای تبدیل opacity به رنگ (مثل editor)
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
@@ -69,6 +75,11 @@ const BookPage = forwardRef(
return null;
}
+ const objectFillStyle: React.CSSProperties =
+ obj.fillType === 'gradient' && obj.gradient
+ ? { backgroundImage: toCssLinearGradient(obj.gradient) }
+ : { backgroundColor: obj.fill || 'transparent' };
+
switch (obj.type) {
case 'text': {
// برای text، opacity در رنگ اعمال میشود، پس باید opacity را از baseStyle حذف کنیم
@@ -234,7 +245,7 @@ const BookPage = forwardRef(
width: `${radius * 2}px`,
height: `${radius * 2}px`,
borderRadius: '50%',
- backgroundColor: obj.fill || 'transparent',
+ ...objectFillStyle,
border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
: 'none',
@@ -280,6 +291,7 @@ const BookPage = forwardRef(
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
const fillColor = obj.fill || '#000000';
const strokeColor = obj.stroke || 'transparent';
+ const gradientId = `triangle-grad-${obj.id}-${index}`;
return (