grediant in a shape

This commit is contained in:
hamid zarghami
2026-05-09 09:50:13 +03:30
parent 0229a1355d
commit b513ecb33f
16 changed files with 378 additions and 15 deletions
+11 -1
View File
@@ -17,6 +17,7 @@ import CellEditor from "@/components/CellEditor";
import ZoomControls from "./ZoomControls"; import ZoomControls from "./ZoomControls";
import EditorCanvasToolbar from "./EditorCanvasToolbar"; import EditorCanvasToolbar from "./EditorCanvasToolbar";
import { createScopedId } from "../store/editorStore.helpers"; import { createScopedId } from "../store/editorStore.helpers";
import { getKonvaGradientProps } from "../utils/gradient";
type EditorCanvasProps = { type EditorCanvasProps = {
catalogSize?: string; catalogSize?: string;
@@ -46,9 +47,10 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const currentPage = pages.find((page) => page.id === currentPageId); const currentPage = pages.find((page) => page.id === currentPageId);
const bgColor = const bgColor =
currentPage?.backgroundType === 'color' currentPage?.backgroundType === 'color' || currentPage?.backgroundType === 'gradient'
? currentPage.backgroundColor ? currentPage.backgroundColor
: '#ffffff'; : '#ffffff';
const bgGradient = currentPage?.backgroundGradient;
const [bgImage] = useImage( const [bgImage] = useImage(
currentPage?.backgroundType === 'image' currentPage?.backgroundType === 'image'
@@ -331,6 +333,13 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
const scaledW = stageSize.width * finalScale; const scaledW = stageSize.width * finalScale;
const scaledH = stageSize.height * finalScale; const scaledH = stageSize.height * finalScale;
const backgroundGradientProps = getKonvaGradientProps(
currentPage?.backgroundType === "gradient" ? "gradient" : "solid",
bgGradient,
stageSize.width,
stageSize.height,
"rect",
);
return ( return (
<div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-auto pb-8 xl:pl-6"> <div className="relative z-10 flex min-h-0 flex-1 flex-col overflow-auto pb-8 xl:pl-6">
@@ -394,6 +403,7 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
width={stageSize.width} width={stageSize.width}
height={stageSize.height} height={stageSize.height}
fill={bgColor} fill={bgColor}
{...backgroundGradientProps}
listening={false} listening={false}
/> />
{currentPage?.backgroundType === 'image' && bgImage && ( {currentPage?.backgroundType === 'image' && bgImage && (
+88 -1
View File
@@ -5,6 +5,7 @@ import type { DisplayStyle, BackgroundType } from '../store/editorStore'
import UploadBoxDraggble from '@/components/UploadBoxDraggble' import UploadBoxDraggble from '@/components/UploadBoxDraggble'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData' import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import ColorsImage from '@/assets/images/colors.png' import ColorsImage from '@/assets/images/colors.png'
import ColorPicker from '@/components/ColorPicker'
const PRESET_COLORS = [ const PRESET_COLORS = [
'#a8edcf', '#a8edcf',
@@ -23,6 +24,7 @@ const DISPLAY_STYLE_OPTIONS: { value: DisplayStyle; label: string }[] = [
const BACKGROUND_TYPE_OPTIONS: { value: BackgroundType; label: string }[] = [ const BACKGROUND_TYPE_OPTIONS: { value: BackgroundType; label: string }[] = [
{ value: 'color', label: 'رنگ' }, { value: 'color', label: 'رنگ' },
{ value: 'gradient', label: 'گرادیانت' },
{ value: 'image', label: 'تصویر' }, { value: 'image', label: 'تصویر' },
] ]
@@ -39,6 +41,11 @@ const SettingsPanel = () => {
const currentPage = pages.find((page) => page.id === currentPageId) const currentPage = pages.find((page) => page.id === currentPageId)
const backgroundType = currentPage?.backgroundType ?? 'color' const backgroundType = currentPage?.backgroundType ?? 'color'
const backgroundColor = currentPage?.backgroundColor ?? '#ffffff' const backgroundColor = currentPage?.backgroundColor ?? '#ffffff'
const backgroundGradient = currentPage?.backgroundGradient ?? {
from: '#ffffff',
to: '#e2e8f0',
angle: 135,
}
const backgroundImageUrl = currentPage?.backgroundImageUrl ?? '' const backgroundImageUrl = currentPage?.backgroundImageUrl ?? ''
const [uploadedPreviews, setUploadedPreviews] = useState<string[]>( const [uploadedPreviews, setUploadedPreviews] = useState<string[]>(
backgroundImageUrl ? [backgroundImageUrl] : [] backgroundImageUrl ? [backgroundImageUrl] : []
@@ -145,7 +152,20 @@ const SettingsPanel = () => {
<label key={opt.value} className="flex items-center gap-1.5 cursor-pointer select-none"> <label key={opt.value} className="flex items-center gap-1.5 cursor-pointer select-none">
<span className="text-xs text-gray-600">{opt.label}</span> <span className="text-xs text-gray-600">{opt.label}</span>
<div <div
onClick={() => updateCurrentPageBackground({ backgroundType: opt.value })} onClick={() =>
updateCurrentPageBackground({
backgroundType: opt.value,
...(opt.value === 'gradient'
? {
backgroundGradient: currentPage?.backgroundGradient ?? {
from: '#ffffff',
to: '#e2e8f0',
angle: 135,
},
}
: {}),
})
}
className={clx( className={clx(
'w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors cursor-pointer', 'w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors cursor-pointer',
backgroundType === opt.value backgroundType === opt.value
@@ -209,6 +229,73 @@ const SettingsPanel = () => {
</div> </div>
)} )}
{backgroundType === 'gradient' && (
<div className="space-y-3">
<p className="text-xs text-gray-500">گرادیانت خطی (سبک Figma)</p>
<ColorPicker
label="رنگ شروع"
value={backgroundGradient.from}
onChange={(value) =>
updateCurrentPageBackground({
backgroundGradient: { ...backgroundGradient, from: value },
})
}
/>
<ColorPicker
label="رنگ پایان"
value={backgroundGradient.to}
onChange={(value) =>
updateCurrentPageBackground({
backgroundGradient: { ...backgroundGradient, to: value },
})
}
/>
<div className="space-y-1">
<label className="text-xs text-gray-500">زاویه</label>
<div className="flex items-center gap-2">
<input
type="range"
min={0}
max={360}
value={backgroundGradient.angle}
onChange={(e) =>
updateCurrentPageBackground({
backgroundGradient: {
...backgroundGradient,
angle: Number(e.target.value),
},
})
}
className="w-full"
/>
<input
type="number"
min={0}
max={360}
value={backgroundGradient.angle}
onChange={(e) =>
updateCurrentPageBackground({
backgroundGradient: {
...backgroundGradient,
angle: Number(e.target.value) || 0,
},
})
}
className="w-16 h-9 rounded-lg border border-border px-2 text-xs"
/>
</div>
</div>
<div
className="h-12 rounded-xl border border-border"
style={{
backgroundImage: `linear-gradient(${backgroundGradient.angle}deg, ${backgroundGradient.from} 0%, ${backgroundGradient.to} 100%)`,
}}
/>
</div>
)}
{/* ─── آپلود تصویر ─── */} {/* ─── آپلود تصویر ─── */}
{backgroundType === 'image' && ( {backgroundType === 'image' && (
<div> <div>
@@ -74,7 +74,7 @@ const ObjectRenderer = ({
if (rafId1) cancelAnimationFrame(rafId1); if (rafId1) cancelAnimationFrame(rafId1);
groupNode?.clearCache(); 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) // Refresh cache after transformer is attached (when isSelected changes)
useEffect(() => { useEffect(() => {
@@ -11,6 +11,12 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
const baseWidth = selectedObject.width ?? 100; const baseWidth = selectedObject.width ?? 100;
const baseHeight = selectedObject.height ?? 100; const baseHeight = selectedObject.height ?? 100;
const baseFill = selectedObject.fill ?? "#3b82f6"; 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 baseStroke = selectedObject.stroke ?? "#1e40af";
const baseStrokeWidth = selectedObject.strokeWidth ?? 0; const baseStrokeWidth = selectedObject.strokeWidth ?? 0;
const baseBorderRadius = selectedObject.borderRadius ?? 0; const baseBorderRadius = selectedObject.borderRadius ?? 0;
@@ -42,12 +48,84 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
<ColorPicker <ColorPicker
label="رنگ پس‌زمینه" label="رنگ پس‌زمینه"
value={baseFill} value={baseFill}
readOnly={fillType === "gradient"}
onChange={(value) => onChange={(value) =>
onUpdate(selectedObject.id, { onUpdate(selectedObject.id, {
fill: value, fill: value,
}) })
} }
/> />
<div className="space-y-2">
<label className="text-sm">نوع Fill</label>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
onUpdate(selectedObject.id, {
fillType: "solid",
})
}
className={`px-3 py-1.5 rounded-lg border text-xs ${fillType === "solid" ? "border-gray-800 text-gray-900" : "border-border text-gray-500"
}`}
>
رنگ ساده
</button>
<button
type="button"
onClick={() =>
onUpdate(selectedObject.id, {
fillType: "gradient",
gradient: selectedObject.gradient ?? {
from: "#3b82f6",
to: "#1d4ed8",
angle: 135,
},
})
}
className={`px-3 py-1.5 rounded-lg border text-xs ${fillType === "gradient" ? "border-gray-800 text-gray-900" : "border-border text-gray-500"
}`}
>
گرادیانت
</button>
</div>
</div>
{fillType === "gradient" && (
<div className="space-y-3 border border-border rounded-xl p-3">
<ColorPicker
label="رنگ شروع"
value={gradient.from}
onChange={(value) =>
onUpdate(selectedObject.id, {
gradient: { ...gradient, from: value },
})
}
/>
<ColorPicker
label="رنگ پایان"
value={gradient.to}
onChange={(value) =>
onUpdate(selectedObject.id, {
gradient: { ...gradient, to: value },
})
}
/>
<Input
label="زاویه گرادیانت"
type="number"
value={gradient.angle}
onChange={(e) =>
onUpdate(selectedObject.id, {
gradient: {
...gradient,
angle: Number(e.target.value) || 0,
},
})
}
min={0}
max={360}
/>
</div>
)}
<ColorPicker <ColorPicker
label="رنگ خط" label="رنگ خط"
value={baseStroke} value={baseStroke}
@@ -2,6 +2,7 @@ import { useRef } from "react";
import { Star } from "react-konva"; import { Star } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient";
const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Star>(null); const shapeRef = useRef<Konva.Star>(null);
@@ -30,6 +31,15 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const displayStrokeWidth = isSelected const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3) ? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth); : (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask
? {}
: getKonvaGradientProps(
obj.fillType,
obj.gradient,
obj.width || 100,
obj.height || 100,
"centered",
);
return ( return (
<Star <Star
@@ -42,6 +52,7 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
innerRadius={innerRadius} innerRadius={innerRadius}
outerRadius={radius} outerRadius={radius}
fill={isMask ? "transparent" : obj.fill} fill={isMask ? "transparent" : obj.fill}
{...gradientProps}
stroke={displayStroke} stroke={displayStroke}
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
@@ -2,6 +2,7 @@ import { useRef } from "react";
import { Circle } from "react-konva"; import { Circle } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient";
const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Circle>(null); const shapeRef = useRef<Konva.Circle>(null);
@@ -26,6 +27,10 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
const displayStrokeWidth = isSelected const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3) ? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth); : (isMask && showGuide ? 2 : actualStrokeWidth);
const diameter = obj.width || 100;
const gradientProps = isMask
? {}
: getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
return ( return (
<Circle <Circle
@@ -36,6 +41,7 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
y={obj.y} y={obj.y}
radius={(obj.width || 50) / 2} radius={(obj.width || 50) / 2}
fill={isMask ? "transparent" : obj.fill} fill={isMask ? "transparent" : obj.fill}
{...gradientProps}
stroke={displayStroke} stroke={displayStroke}
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
@@ -2,6 +2,7 @@ import { useRef } from "react";
import { Rect } from "react-konva"; import { Rect } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient";
const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.Rect>(null); const shapeRef = useRef<Konva.Rect>(null);
@@ -27,6 +28,15 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
const displayStrokeWidth = isSelected const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3) ? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth); : (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask
? {}
: getKonvaGradientProps(
obj.fillType,
obj.gradient,
obj.width || 100,
obj.height || 100,
"rect",
);
return ( return (
<Rect <Rect
@@ -39,6 +49,7 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
height={obj.height || 100} height={obj.height || 100}
cornerRadius={cornerRadius} cornerRadius={cornerRadius}
fill={isMask ? "transparent" : obj.fill} fill={isMask ? "transparent" : obj.fill}
{...gradientProps}
stroke={displayStroke} stroke={displayStroke}
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
@@ -2,6 +2,7 @@ import { useRef } from "react";
import { RegularPolygon } from "react-konva"; import { RegularPolygon } from "react-konva";
import Konva from "konva"; import Konva from "konva";
import type { ShapeProps } from "./types"; import type { ShapeProps } from "./types";
import { getKonvaGradientProps } from "../../utils/gradient";
const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => { const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
const shapeRef = useRef<Konva.RegularPolygon>(null); const shapeRef = useRef<Konva.RegularPolygon>(null);
@@ -27,6 +28,15 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
const displayStrokeWidth = isSelected const displayStrokeWidth = isSelected
? (hasStroke ? actualStrokeWidth : 3) ? (hasStroke ? actualStrokeWidth : 3)
: (isMask && showGuide ? 2 : actualStrokeWidth); : (isMask && showGuide ? 2 : actualStrokeWidth);
const gradientProps = isMask
? {}
: getKonvaGradientProps(
obj.fillType,
obj.gradient,
obj.width || 100,
obj.height || 100,
"centered",
);
return ( return (
<RegularPolygon <RegularPolygon
@@ -38,6 +48,7 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
sides={3} sides={3}
radius={radius} radius={radius}
fill={isMask ? "transparent" : obj.fill} fill={isMask ? "transparent" : obj.fill}
{...gradientProps}
stroke={displayStroke} stroke={displayStroke}
strokeWidth={displayStrokeWidth} strokeWidth={displayStrokeWidth}
dash={isMask && showGuide ? [10, 5] : undefined} dash={isMask && showGuide ? [10, 5] : undefined}
@@ -55,6 +55,11 @@ export const createInitialPage = (name: string): Page => ({
guides: [], guides: [],
backgroundType: "color", backgroundType: "color",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
backgroundGradient: {
from: "#ffffff",
to: "#e2e8f0",
angle: 135,
},
backgroundImageUrl: "", backgroundImageUrl: "",
}); });
@@ -118,8 +123,8 @@ export const getObjectBounds = (obj: EditorObject) => {
return { minX, minY, maxX, maxY }; return { minX, minY, maxX, maxY };
} }
let w = obj.width || 0; const w = obj.width || 0;
let h = obj.height || 0; const h = obj.height || 0;
if (obj.type === "rectangle" && obj.shapeType === "circle") { if (obj.type === "rectangle" && obj.shapeType === "circle") {
const radius = w / 2; const radius = w / 2;
+13 -1
View File
@@ -46,7 +46,7 @@ export type {
type PageBackgroundSettings = Pick< type PageBackgroundSettings = Pick<
Page, Page,
"backgroundType" | "backgroundColor" | "backgroundImageUrl" "backgroundType" | "backgroundColor" | "backgroundGradient" | "backgroundImageUrl"
>; >;
type EditorStoreType = { type EditorStoreType = {
@@ -191,6 +191,11 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
leftToRightFlip: false, leftToRightFlip: false,
backgroundType: 'color', backgroundType: 'color',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
backgroundGradient: {
from: "#ffffff",
to: "#e2e8f0",
angle: 135,
},
backgroundImageUrl: '', backgroundImageUrl: '',
}, },
updateDocumentSettings: (updates) => updateDocumentSettings: (updates) =>
@@ -836,6 +841,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
guides: [...pageToDuplicate.guides], guides: [...pageToDuplicate.guides],
backgroundType: pageToDuplicate.backgroundType, backgroundType: pageToDuplicate.backgroundType,
backgroundColor: pageToDuplicate.backgroundColor, backgroundColor: pageToDuplicate.backgroundColor,
backgroundGradient: pageToDuplicate.backgroundGradient,
backgroundImageUrl: pageToDuplicate.backgroundImageUrl, backgroundImageUrl: pageToDuplicate.backgroundImageUrl,
}; };
@@ -1009,6 +1015,12 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
page.backgroundType ?? fallbackSettings.backgroundType ?? "color", page.backgroundType ?? fallbackSettings.backgroundType ?? "color",
backgroundColor: backgroundColor:
page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff", page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff",
backgroundGradient: page.backgroundGradient ??
fallbackSettings.backgroundGradient ?? {
from: "#ffffff",
to: "#e2e8f0",
angle: 135,
},
backgroundImageUrl: backgroundImageUrl:
page.backgroundImageUrl ?? fallbackSettings.backgroundImageUrl ?? "", page.backgroundImageUrl ?? fallbackSettings.backgroundImageUrl ?? "",
})); }));
+6 -1
View File
@@ -1,4 +1,5 @@
import type { ShapeType } from "./shapeStore"; import type { ShapeType } from "./shapeStore";
import type { FillType, LinearGradient } from "../utils/gradient";
export type ToolType = export type ToolType =
| "select" | "select"
@@ -41,6 +42,8 @@ export type EditorObject = {
width?: number; width?: number;
height?: number; height?: number;
fill?: string; fill?: string;
fillType?: FillType;
gradient?: LinearGradient;
stroke?: string; stroke?: string;
strokeWidth?: number; strokeWidth?: number;
text?: string; text?: string;
@@ -82,11 +85,12 @@ export type Page = {
guides: PageGuide[]; guides: PageGuide[];
backgroundType: BackgroundType; backgroundType: BackgroundType;
backgroundColor: string; backgroundColor: string;
backgroundGradient?: LinearGradient;
backgroundImageUrl: string; backgroundImageUrl: string;
}; };
export type DisplayStyle = 'single' | 'double'; export type DisplayStyle = 'single' | 'double';
export type BackgroundType = 'color' | 'image'; export type BackgroundType = 'color' | 'gradient' | 'image';
export type DocumentSettings = { export type DocumentSettings = {
displayStyle: DisplayStyle; displayStyle: DisplayStyle;
@@ -95,5 +99,6 @@ export type DocumentSettings = {
leftToRightFlip: boolean; leftToRightFlip: boolean;
backgroundType: BackgroundType; backgroundType: BackgroundType;
backgroundColor: string; backgroundColor: string;
backgroundGradient?: LinearGradient;
backgroundImageUrl: string; backgroundImageUrl: string;
}; };
+73
View File
@@ -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%)`;
};
+39 -6
View File
@@ -1,6 +1,7 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { type PageData } from '../types'; import { type PageData } from '../types';
import type { EditorObject } from '@/pages/editor/store/editorStore'; import type { EditorObject } from '@/pages/editor/store/editorStore';
import { toCssLinearGradient } from '@/pages/editor/utils/gradient';
type BookPageProps = { type BookPageProps = {
page: PageData; page: PageData;
@@ -8,8 +9,13 @@ type BookPageProps = {
pageWidth?: number; pageWidth?: number;
pageHeight?: number; pageHeight?: number;
onLinkClick?: (linkUrl: string) => void; onLinkClick?: (linkUrl: string) => void;
backgroundType?: 'color' | 'image'; backgroundType?: 'color' | 'gradient' | 'image';
backgroundColor?: string; backgroundColor?: string;
backgroundGradient?: {
from: string;
to: string;
angle: number;
};
backgroundImageUrl?: string; backgroundImageUrl?: string;
}; };
@@ -21,7 +27,7 @@ type BookPageProps = {
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند * نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
*/ */
const BookPage = forwardRef<HTMLDivElement, BookPageProps>( const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundImageUrl }, ref) => { ({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl }, ref) => {
// تابع برای تبدیل opacity به رنگ (مثل editor) // تابع برای تبدیل opacity به رنگ (مثل editor)
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد // در editor، getColorWithOpacity انتظار opacity 0-100 دارد
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است // در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
@@ -69,6 +75,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
return null; return null;
} }
const objectFillStyle: React.CSSProperties =
obj.fillType === 'gradient' && obj.gradient
? { backgroundImage: toCssLinearGradient(obj.gradient) }
: { backgroundColor: obj.fill || 'transparent' };
switch (obj.type) { switch (obj.type) {
case 'text': { case 'text': {
// برای text، opacity در رنگ اعمال می‌شود، پس باید opacity را از baseStyle حذف کنیم // برای text، opacity در رنگ اعمال می‌شود، پس باید opacity را از baseStyle حذف کنیم
@@ -234,7 +245,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
width: `${radius * 2}px`, width: `${radius * 2}px`,
height: `${radius * 2}px`, height: `${radius * 2}px`,
borderRadius: '50%', borderRadius: '50%',
backgroundColor: obj.fill || 'transparent', ...objectFillStyle,
border: hasStroke && obj.stroke border: hasStroke && obj.stroke
? `${actualStrokeWidth * scale}px solid ${obj.stroke}` ? `${actualStrokeWidth * scale}px solid ${obj.stroke}`
: 'none', : 'none',
@@ -280,6 +291,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0; const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
const fillColor = obj.fill || '#000000'; const fillColor = obj.fill || '#000000';
const strokeColor = obj.stroke || 'transparent'; const strokeColor = obj.stroke || 'transparent';
const gradientId = `triangle-grad-${obj.id}-${index}`;
return ( return (
<svg <svg
@@ -298,9 +310,25 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
}} }}
viewBox={`0 0 ${size} ${size}`} viewBox={`0 0 ${size} ${size}`}
> >
{obj.fillType === 'gradient' && obj.gradient ? (
<defs>
<linearGradient
id={gradientId}
gradientUnits="userSpaceOnUse"
x1="0"
y1="0"
x2={size}
y2={size}
gradientTransform={`rotate(${obj.gradient.angle}, ${size / 2}, ${size / 2})`}
>
<stop offset="0%" stopColor={obj.gradient.from} />
<stop offset="100%" stopColor={obj.gradient.to} />
</linearGradient>
</defs>
) : null}
<polygon <polygon
points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`} points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`}
fill={fillColor} fill={obj.fillType === 'gradient' && obj.gradient ? `url(#${gradientId})` : fillColor}
stroke={strokeColor} stroke={strokeColor}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
/> />
@@ -328,7 +356,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
width: `${radius * 2}px`, width: `${radius * 2}px`,
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%)',
backgroundColor: obj.fill || 'transparent', ...objectFillStyle,
border: obj.stroke border: obj.stroke
? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}` ? `${(obj.strokeWidth || 1) * scale}px solid ${obj.stroke}`
: 'none', : 'none',
@@ -346,7 +374,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
...baseStyle, ...baseStyle,
width: `${(obj.width || 100) * scale}px`, width: `${(obj.width || 100) * scale}px`,
height: `${(obj.height || 100) * scale}px`, height: `${(obj.height || 100) * scale}px`,
backgroundColor: obj.fill || 'transparent', ...objectFillStyle,
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}`
@@ -489,6 +517,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color'; const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff'; const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? ''; const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
const bgStyle: React.CSSProperties = const bgStyle: React.CSSProperties =
@@ -499,6 +528,10 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
} }
: effectiveBackgroundType === 'gradient' && effectiveBackgroundGradient
? {
backgroundImage: toCssLinearGradient(effectiveBackgroundGradient),
}
: { backgroundColor: effectiveBackgroundColor }; : { backgroundColor: effectiveBackgroundColor };
return ( return (
@@ -117,6 +117,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
const displayStyle = documentSettings?.displayStyle ?? 'double'; const displayStyle = documentSettings?.displayStyle ?? 'double';
const backgroundType = documentSettings?.backgroundType ?? 'color'; const backgroundType = documentSettings?.backgroundType ?? 'color';
const backgroundColor = documentSettings?.backgroundColor ?? '#ffffff'; const backgroundColor = documentSettings?.backgroundColor ?? '#ffffff';
const backgroundGradient = documentSettings?.backgroundGradient;
const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? ''; const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? '';
useEffect(() => { useEffect(() => {
@@ -486,6 +487,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
onLinkClick={handleLinkClick} onLinkClick={handleLinkClick}
backgroundType={page.backgroundType ?? backgroundType} backgroundType={page.backgroundType ?? backgroundType}
backgroundColor={page.backgroundColor ?? backgroundColor} backgroundColor={page.backgroundColor ?? backgroundColor}
backgroundGradient={page.backgroundGradient ?? backgroundGradient}
backgroundImageUrl={page.backgroundImageUrl ?? backgroundImageUrl} backgroundImageUrl={page.backgroundImageUrl ?? backgroundImageUrl}
/> />
))} ))}
+6 -1
View File
@@ -5,8 +5,13 @@ export type PageData = {
width: number; width: number;
height: number; height: number;
elements: EditorObject[]; elements: EditorObject[];
backgroundType?: "color" | "image"; backgroundType?: "color" | "gradient" | "image";
backgroundColor?: string; backgroundColor?: string;
backgroundGradient?: {
from: string;
to: string;
angle: number;
};
backgroundImageUrl?: string; backgroundImageUrl?: string;
}; };
+15 -1
View File
@@ -4,8 +4,13 @@ import type { PageData } from "../types";
type ViewerDataPage = { type ViewerDataPage = {
id: string; id: string;
name: string; name: string;
backgroundType?: "color" | "image"; backgroundType?: "color" | "gradient" | "image";
backgroundColor?: string; backgroundColor?: string;
backgroundGradient?: {
from: string;
to: string;
angle: number;
};
backgroundImageUrl?: string; backgroundImageUrl?: string;
objects: Array<{ objects: Array<{
id: string; id: string;
@@ -21,6 +26,12 @@ type ViewerDataPage = {
lineHeight?: number; lineHeight?: number;
textAlign?: "left" | "center" | "right"; textAlign?: "left" | "center" | "right";
fill?: string; fill?: string;
fillType?: "solid" | "gradient";
gradient?: {
from: string;
to: string;
angle: number;
};
stroke?: string; stroke?: string;
strokeWidth?: number; strokeWidth?: number;
shapeType?: string; shapeType?: string;
@@ -82,6 +93,8 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
// baseObject.y = obj.y + (obj.height || 100) / 2; // baseObject.y = obj.y + (obj.height || 100) / 2;
// اما فعلاً JSON به صورت مرکز است، پس تبدیل نمی‌کنیم // اما فعلاً JSON به صورت مرکز است، پس تبدیل نمی‌کنیم
if (obj.fill !== undefined) baseObject.fill = obj.fill; if (obj.fill !== undefined) baseObject.fill = obj.fill;
if (obj.fillType !== undefined) baseObject.fillType = obj.fillType;
if (obj.gradient !== undefined) baseObject.gradient = obj.gradient;
if (obj.stroke !== undefined) baseObject.stroke = obj.stroke; if (obj.stroke !== undefined) baseObject.stroke = obj.stroke;
if (obj.strokeWidth !== undefined) if (obj.strokeWidth !== undefined)
baseObject.strokeWidth = obj.strokeWidth; baseObject.strokeWidth = obj.strokeWidth;
@@ -131,6 +144,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
elements: objects, elements: objects,
backgroundType: page.backgroundType, backgroundType: page.backgroundType,
backgroundColor: page.backgroundColor, backgroundColor: page.backgroundColor,
backgroundGradient: page.backgroundGradient,
backgroundImageUrl: page.backgroundImageUrl, backgroundImageUrl: page.backgroundImageUrl,
}; };
}); });