add setting

This commit is contained in:
hamid zarghami
2026-05-06 12:33:23 +03:30
parent e15dc76468
commit 192036e519
7 changed files with 384 additions and 30 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+7 -5
View File
@@ -22,17 +22,19 @@ const Editor: FC = () => {
} }
}, []) }, [])
const [isLayersPanelOpen, setIsLayersPanelOpen] = useState(false) const [isLayersPanelOpen, setIsLayersPanelOpen] = useState(false)
const { loadPages, pages } = useEditorStore() const { loadPages, pages, documentSettings } = useEditorStore()
const { data } = useGetCatalogById(id!) const { data } = useGetCatalogById(id!)
const { scheduleUpdate, isSaving } = useUpdateCatalog() const { scheduleUpdate, isSaving } = useUpdateCatalog()
useEffect(() => { useEffect(() => {
if (data?.data && data?.data?.content) { if (data?.data && data?.data?.content) {
const json = JSON.parse(data?.data?.content) const json = JSON.parse(data?.data?.content)
console.log(json); if (!json) return
if (json) { if (Array.isArray(json)) {
loadPages(json) loadPages(json)
} else if (json.pages) {
loadPages(json.pages, json.documentSettings)
} }
} }
@@ -45,11 +47,11 @@ const Editor: FC = () => {
scheduleUpdate({ scheduleUpdate({
id, id,
content: JSON.stringify(pages), content: JSON.stringify({ pages, documentSettings }),
}) })
// عمداً scheduleUpdate را در وابستگی‌ها نیاوردیم تا از لوپ ذخیره‌سازی جلوگیری شود // عمداً scheduleUpdate را در وابستگی‌ها نیاوردیم تا از لوپ ذخیره‌سازی جلوگیری شود
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, pages]) }, [id, pages, documentSettings])
return ( return (
+43 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Stage, Layer, Rect } from "react-konva"; import { Stage, Layer, Rect, Image as KonvaImage } from "react-konva";
import useImage from "use-image";
import Konva from "konva"; import Konva from "konva";
import { useEditorStore } from "../store/editorStore"; import { useEditorStore } from "../store/editorStore";
import { useDrawingHandlers } from "./canvas/useDrawingHandlers"; import { useDrawingHandlers } from "./canvas/useDrawingHandlers";
@@ -39,10 +40,38 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
setLayerRef, setLayerRef,
setGuides, setGuides,
zoom, zoom,
documentSettings,
} = useEditorStore(); } = useEditorStore();
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers(); const bgColor =
documentSettings.backgroundType === 'color'
? documentSettings.backgroundColor
: '#ffffff';
const [bgImage] = useImage(
documentSettings.backgroundType === 'image'
? documentSettings.backgroundImageUrl
: '',
);
const stageSize = useStageSize(catalogSize); const stageSize = useStageSize(catalogSize);
const bgImageCrop = (() => {
if (!bgImage) return null;
const containerRatio = stageSize.width / stageSize.height;
const imageRatio = bgImage.width / bgImage.height;
if (imageRatio > containerRatio) {
const cropWidth = bgImage.height * containerRatio;
const cropX = (bgImage.width - cropWidth) / 2;
return { x: cropX, y: 0, width: cropWidth, height: bgImage.height };
}
const cropHeight = bgImage.width / containerRatio;
const cropY = (bgImage.height - cropHeight) / 2;
return { x: 0, y: cropY, width: bgImage.width, height: cropHeight };
})();
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
useKeyboardMovement(); useKeyboardMovement();
const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers(); const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers();
@@ -362,9 +391,20 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
y={0} y={0}
width={stageSize.width} width={stageSize.width}
height={stageSize.height} height={stageSize.height}
fill="#ffffff" fill={bgColor}
listening={false} listening={false}
/> />
{documentSettings.backgroundType === 'image' && bgImage && (
<KonvaImage
image={bgImage}
x={0}
y={0}
width={stageSize.width}
height={stageSize.height}
crop={bgImageCrop ?? undefined}
listening={false}
/>
)}
</Layer> </Layer>
<ObjectsLayer <ObjectsLayer
+35 -20
View File
@@ -1,16 +1,23 @@
import { Layer, Book1, ArrowLeft2 } from 'iconsax-react' import { Layer, Book1, ArrowLeft2, Setting2 } from 'iconsax-react'
import { clx } from '@/helpers/utils' import { clx } from '@/helpers/utils'
import { useState } from 'react' import { useState } from 'react'
import PagesPanel from './PagesPanel' import PagesPanel from './PagesPanel'
import LayersList from './LayersList' import LayersList from './LayersList'
import SettingsPanel from './SettingsPanel'
type TabType = 'pages' | 'layers' type TabType = 'pages' | 'layers' | 'settings'
type LayersPanelProps = { type LayersPanelProps = {
isOpen: boolean isOpen: boolean
setIsOpen: (isOpen: boolean) => void setIsOpen: (isOpen: boolean) => void
} }
const TAB_LABELS: Record<TabType, string> = {
pages: 'صفحات',
layers: 'لایه ها',
settings: 'تنظیمات',
}
const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => { const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => {
const [activeTab, setActiveTab] = useState<TabType>('pages') const [activeTab, setActiveTab] = useState<TabType>('pages')
@@ -19,23 +26,23 @@ const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => {
<div className="fixed left-4 top-[100px] bottom-4 z-30 flex flex-col"> <div className="fixed left-4 top-[100px] bottom-4 z-30 flex flex-col">
<div className="bg-white rounded-t-[32px] rounded-b-[32px] w-16 h-full flex flex-col items-center gap-4 p-4"> <div className="bg-white rounded-t-[32px] rounded-b-[32px] w-16 h-full flex flex-col items-center gap-4 p-4">
<button <button
onClick={() => { onClick={() => { setActiveTab('pages'); setIsOpen(true) }}
setActiveTab('pages')
setIsOpen(true)
}}
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-200 transition-colors" className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-200 transition-colors"
> >
<Book1 size={24} color="black" /> <Book1 size={24} color="black" />
</button> </button>
<button <button
onClick={() => { onClick={() => { setActiveTab('layers'); setIsOpen(true) }}
setActiveTab('layers')
setIsOpen(true)
}}
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-200 transition-colors" className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-200 transition-colors"
> >
<Layer size={24} color="black" /> <Layer size={24} color="black" />
</button> </button>
<button
onClick={() => { setActiveTab('settings'); setIsOpen(true) }}
className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-200 transition-colors"
>
<Setting2 size={24} color="black" />
</button>
</div> </div>
</div> </div>
) )
@@ -45,28 +52,34 @@ const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => {
<div className="fixed left-4 top-[100px] bottom-4 z-30 flex flex-col"> <div className="fixed left-4 top-[100px] bottom-4 z-30 flex flex-col">
<div className="bg-white rounded-l-4xl w-16 h-full flex flex-col items-center gap-4 p-4"> <div className="bg-white rounded-l-4xl w-16 h-full flex flex-col items-center gap-4 p-4">
<button <button
onClick={() => { onClick={() => setActiveTab('pages')}
setActiveTab('pages')
}}
className={clx( className={clx(
'w-10 h-10 flex items-center justify-center rounded-lg transition-colors', 'w-10 h-10 flex items-center justify-center rounded-lg transition-colors',
activeTab === 'pages' ? 'bg-white' : 'hover:bg-gray-200' activeTab === 'pages' ? 'bg-gray-100' : 'hover:bg-gray-200'
)} )}
> >
<Book1 variant={activeTab === 'pages' ? 'Bold' : 'Outline'} size={24} color="black" /> <Book1 variant={activeTab === 'pages' ? 'Bold' : 'Outline'} size={24} color="black" />
</button> </button>
<button <button
onClick={() => { onClick={() => setActiveTab('layers')}
setActiveTab('layers')
}}
className={clx( className={clx(
'w-10 h-10 flex items-center justify-center rounded-lg transition-colors', 'w-10 h-10 flex items-center justify-center rounded-lg transition-colors',
activeTab === 'layers' ? 'bg-white' : 'hover:bg-gray-200' activeTab === 'layers' ? 'bg-gray-100' : 'hover:bg-gray-200'
)} )}
> >
<Layer variant={activeTab === 'layers' ? 'Bold' : 'Outline'} size={24} color="black" /> <Layer variant={activeTab === 'layers' ? 'Bold' : 'Outline'} size={24} color="black" />
</button> </button>
<button
onClick={() => setActiveTab('settings')}
className={clx(
'w-10 h-10 flex items-center justify-center rounded-lg transition-colors',
activeTab === 'settings' ? 'bg-gray-100' : 'hover:bg-gray-200'
)}
>
<Setting2 variant={activeTab === 'settings' ? 'Bold' : 'Outline'} size={24} color="black" />
</button>
</div> </div>
<div className="fixed border-l border-border left-20 top-[100px] bottom-4 z-30 flex flex-col w-[232px]"> <div className="fixed border-l border-border left-20 top-[100px] bottom-4 z-30 flex flex-col w-[232px]">
@@ -80,12 +93,14 @@ const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => {
<ArrowLeft2 size={20} color="black" /> <ArrowLeft2 size={20} color="black" />
</button> </button>
<h2 className="text-lg font-medium"> <h2 className="text-lg font-medium">
{activeTab === 'pages' ? 'صفحات' : 'لایه ها'} {TAB_LABELS[activeTab]}
</h2> </h2>
</div> </div>
</div> </div>
{activeTab === 'pages' ? <PagesPanel /> : <LayersList />} {activeTab === 'pages' && <PagesPanel />}
{activeTab === 'layers' && <LayersList />}
{activeTab === 'settings' && <SettingsPanel />}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,261 @@
import { useEffect, useRef, useState } from 'react'
import { clx } from '@/helpers/utils'
import { useEditorStore } from '../store/editorStore'
import type { DisplayStyle, BackgroundType } from '../store/editorStore'
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
import ColorsImage from '@/assets/images/colors.png'
const PRESET_COLORS = [
'#a8edcf',
'#e91e8c',
'#ff9800',
'#6d8b8b',
'#111111',
'#e53935',
'#e0e0e0',
]
const DISPLAY_STYLE_OPTIONS: { value: DisplayStyle; label: string }[] = [
{ value: 'single', label: 'تک صفحه ای' },
{ value: 'double', label: 'دو صفحه ای' },
]
const BACKGROUND_TYPE_OPTIONS: { value: BackgroundType; label: string }[] = [
{ value: 'color', label: 'رنگ' },
{ value: 'image', label: 'تصویر' },
]
const SettingsPanel = () => {
const { documentSettings, updateDocumentSettings } = useEditorStore()
const colorInputRef = useRef<HTMLInputElement>(null)
const { mutateAsync: uploadFile, isPending: imageLoading } = useSingleUpload()
const [uploadedPreviews, setUploadedPreviews] = useState<string[]>(
documentSettings.backgroundImageUrl ? [documentSettings.backgroundImageUrl] : []
)
const {
displayStyle,
autoPlay,
pageFlipSound,
leftToRightFlip,
backgroundType,
backgroundColor,
} = documentSettings
useEffect(() => {
setUploadedPreviews(
documentSettings.backgroundImageUrl ? [documentSettings.backgroundImageUrl] : []
)
}, [documentSettings.backgroundImageUrl])
const handleImageFiles = async (files: File[]) => {
if (files.length === 0) return
const file = files[files.length - 1]
if (!file) return
try {
const result = await uploadFile(file)
const imageUrl = result?.data?.url
if (!imageUrl) return
updateDocumentSettings({ backgroundImageUrl: imageUrl })
setUploadedPreviews([imageUrl])
} catch {
// TODO: show error toast
}
}
const handlePreviewChange = (previews: string[]) => {
setUploadedPreviews(previews)
if (previews.length === 0) {
updateDocumentSettings({ backgroundImageUrl: '' })
}
}
return (
<div className="flex flex-col gap-0 overflow-y-auto h-full text-right" dir="rtl">
{/* ─── تنظیمات نمایش ─── */}
<section className="p-4 border-b border-border">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-sm font-semibold text-gray-800">تنظیمات نمایش</h3>
</div>
{/* استایل نمایش */}
<div className="mb-4">
<label className="block text-xs text-gray-500 mb-2">استایل نمایش</label>
<div className="relative">
<select
value={displayStyle}
onChange={(e) =>
updateDocumentSettings({ displayStyle: e.target.value as DisplayStyle })
}
className="w-full appearance-none border border-border rounded-xl px-3 py-2.5 text-sm bg-white text-right pr-3 pl-8 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-200"
>
{DISPLAY_STYLE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<span className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none text-gray-400">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
{/* چک‌باکس‌ها */}
<div className="flex flex-col gap-3">
<CheckboxRow
label="پخش خودکار"
checked={autoPlay}
onChange={(v) => updateDocumentSettings({ autoPlay: v })}
/>
<CheckboxRow
label="صدای برگ خوردن"
checked={pageFlipSound}
onChange={(v) => updateDocumentSettings({ pageFlipSound: v })}
/>
<CheckboxRow
label="چپ به راست ورق خوردن"
checked={leftToRightFlip}
onChange={(v) => updateDocumentSettings({ leftToRightFlip: v })}
/>
</div>
</section>
{/* ─── پس زمینه ─── */}
<section className="p-4">
<h3 className="text-sm font-semibold text-gray-800 mb-4">پس زمینه</h3>
{/* نوع پس‌زمینه */}
<div className="flex items-center gap-5 mb-4">
{BACKGROUND_TYPE_OPTIONS.map((opt) => (
<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>
<div
onClick={() => updateDocumentSettings({ backgroundType: opt.value })}
className={clx(
'w-4 h-4 rounded-full border-2 flex items-center justify-center transition-colors cursor-pointer',
backgroundType === opt.value
? 'border-gray-800 bg-gray-800'
: 'border-gray-300 bg-white'
)}
>
{backgroundType === opt.value && (
<div className="w-1.5 h-1.5 rounded-full bg-white" />
)}
</div>
</label>
))}
</div>
{/* ─── انتخاب رنگ ─── */}
{backgroundType === 'color' && (
<div>
<p className="text-xs text-gray-500 mb-3">انتخاب رنگ</p>
<div className="flex items-center gap-2 flex-wrap">
{PRESET_COLORS.map((color) => (
<button
key={color}
onClick={() => updateDocumentSettings({ backgroundColor: color })}
className={clx(
'w-9 h-9 rounded-xl border-2 transition-all',
backgroundColor === color
? 'border-gray-800 scale-110'
: 'border-transparent hover:scale-105'
)}
style={{ backgroundColor: color }}
title={color}
/>
))}
{/* Color Wheel */}
<button
onClick={() => colorInputRef.current?.click()}
className="w-9 h-9 rounded-xl border-2 border-transparent hover:scale-105 transition-all overflow-hidden relative"
title="رنگ دلخواه"
>
<img src={ColorsImage} alt="انتخاب رنگ" className="w-full h-full object-cover" />
<input
ref={colorInputRef}
type="color"
value={backgroundColor}
onChange={(e) => updateDocumentSettings({ backgroundColor: e.target.value })}
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
/>
</button>
</div>
{/* نمایش رنگ انتخاب‌شده */}
<div className="mt-3 flex items-center gap-2">
<div
className="w-6 h-6 rounded-lg border border-border"
style={{ backgroundColor }}
/>
<span className="text-xs text-gray-500 font-mono">{backgroundColor}</span>
</div>
</div>
)}
{/* ─── آپلود تصویر ─── */}
{backgroundType === 'image' && (
<div>
{imageLoading ? (
<div className="flex flex-col items-center justify-center gap-3 py-8">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-800" />
<span className="text-xs text-gray-500">در حال آپلود...</span>
</div>
) : (
<UploadBoxDraggble
label="تصویر پس‌زمینه را آپلود کنید"
onChange={handleImageFiles}
isMultiple={false}
preview={uploadedPreviews}
onChangePreview={handlePreviewChange}
/>
)}
</div>
)}
</section>
</div>
)
}
type CheckboxRowProps = {
label: string
checked: boolean
onChange: (value: boolean) => void
}
const CheckboxRow = ({ label, checked, onChange }: CheckboxRowProps) => (
<label className="flex items-center justify-between cursor-pointer select-none group">
<span className="text-sm text-gray-700">{label}</span>
<div
onClick={() => onChange(!checked)}
className={clx(
'w-5 h-5 rounded-md border-2 flex items-center justify-center transition-all',
checked
? 'border-gray-800 bg-gray-800'
: 'border-gray-300 bg-white group-hover:border-gray-400'
)}
>
{checked && (
<svg width="10" height="8" viewBox="0 0 10 8" fill="none">
<path
d="M1 4l3 3 5-6"
stroke="white"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</label>
)
export default SettingsPanel
+25 -2
View File
@@ -18,6 +18,7 @@ import type {
TableCell, TableCell,
TableData, TableData,
ToolType, ToolType,
DocumentSettings,
} from "./editorStore.types"; } from "./editorStore.types";
const MAX_OBJECT_HISTORY = 50; const MAX_OBJECT_HISTORY = 50;
@@ -38,6 +39,9 @@ export type {
TableCell, TableCell,
TableData, TableData,
ToolType, ToolType,
DocumentSettings,
DisplayStyle,
BackgroundType,
} from "./editorStore.types"; } from "./editorStore.types";
type EditorStoreType = { type EditorStoreType = {
@@ -117,7 +121,7 @@ type EditorStoreType = {
toggleObjectVisibility: (id: string) => void; toggleObjectVisibility: (id: string) => void;
groupObjects: (objectIds: string[]) => void; groupObjects: (objectIds: string[]) => void;
ungroupObjects: (groupId: string) => void; ungroupObjects: (groupId: string) => void;
loadPages: (pages: Page[]) => void; loadPages: (pages: Page[], documentSettings?: Partial<DocumentSettings>) => void;
clipboardObjects: EditorObject[]; clipboardObjects: EditorObject[];
objectHistoryPast: EditorObject[][]; objectHistoryPast: EditorObject[][];
objectHistoryFuture: EditorObject[][]; objectHistoryFuture: EditorObject[][];
@@ -128,6 +132,8 @@ type EditorStoreType = {
redoObjects: () => void; redoObjects: () => void;
bringObjectForward: (id: string) => void; bringObjectForward: (id: string) => void;
sendObjectBackward: (id: string) => void; sendObjectBackward: (id: string) => void;
documentSettings: DocumentSettings;
updateDocumentSettings: (updates: Partial<DocumentSettings>) => void;
}; };
export const useEditorStore = create<EditorStoreType>((set, get) => { export const useEditorStore = create<EditorStoreType>((set, get) => {
@@ -172,6 +178,19 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
}; };
return { return {
documentSettings: {
displayStyle: 'double',
autoPlay: false,
pageFlipSound: true,
leftToRightFlip: false,
backgroundType: 'color',
backgroundColor: '#ffffff',
backgroundImageUrl: '',
},
updateDocumentSettings: (updates) =>
set((state) => ({
documentSettings: { ...state.documentSettings, ...updates },
})),
tool: "select", tool: "select",
setTool: (tool) => setTool: (tool) =>
set((state) => ({ set((state) => ({
@@ -957,7 +976,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
const state = get(); const state = get();
return state.objects.filter((obj) => obj.groupId === groupId); return state.objects.filter((obj) => obj.groupId === groupId);
}, },
loadPages: (pages) => { loadPages: (pages, documentSettings) => {
if (pages.length === 0) return; if (pages.length === 0) return;
const normalizedPages = pages.map((page) => ({ const normalizedPages = pages.map((page) => ({
...page, ...page,
@@ -966,6 +985,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
objects: ensureMissingGroupObjects(page.objects || []), objects: ensureMissingGroupObjects(page.objects || []),
})); }));
const firstPage = normalizedPages[0]; const firstPage = normalizedPages[0];
const currentSettings = get().documentSettings;
set({ set({
pages: normalizedPages, pages: normalizedPages,
currentPageId: firstPage.id, currentPageId: firstPage.id,
@@ -975,6 +995,9 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
selectedObjectIds: [], selectedObjectIds: [],
objectHistoryPast: [], objectHistoryPast: [],
objectHistoryFuture: [], objectHistoryFuture: [],
...(documentSettings && {
documentSettings: { ...currentSettings, ...documentSettings },
}),
}); });
}, },
}; };
@@ -81,3 +81,16 @@ export type Page = {
objects: EditorObject[]; objects: EditorObject[];
guides: PageGuide[]; guides: PageGuide[];
}; };
export type DisplayStyle = 'single' | 'double';
export type BackgroundType = 'color' | 'image';
export type DocumentSettings = {
displayStyle: DisplayStyle;
autoPlay: boolean;
pageFlipSound: boolean;
leftToRightFlip: boolean;
backgroundType: BackgroundType;
backgroundColor: string;
backgroundImageUrl: string;
};