diff --git a/src/assets/images/colors.png b/src/assets/images/colors.png new file mode 100644 index 0000000..f6bc357 Binary files /dev/null and b/src/assets/images/colors.png differ diff --git a/src/pages/editor/Editor.tsx b/src/pages/editor/Editor.tsx index 5e364f6..ce18c58 100644 --- a/src/pages/editor/Editor.tsx +++ b/src/pages/editor/Editor.tsx @@ -22,17 +22,19 @@ const Editor: FC = () => { } }, []) const [isLayersPanelOpen, setIsLayersPanelOpen] = useState(false) - const { loadPages, pages } = useEditorStore() + const { loadPages, pages, documentSettings } = useEditorStore() const { data } = useGetCatalogById(id!) const { scheduleUpdate, isSaving } = useUpdateCatalog() useEffect(() => { if (data?.data && data?.data?.content) { const json = JSON.parse(data?.data?.content) - console.log(json); + if (!json) return - if (json) { + if (Array.isArray(json)) { loadPages(json) + } else if (json.pages) { + loadPages(json.pages, json.documentSettings) } } @@ -45,11 +47,11 @@ const Editor: FC = () => { scheduleUpdate({ id, - content: JSON.stringify(pages), + content: JSON.stringify({ pages, documentSettings }), }) // عمداً scheduleUpdate را در وابستگیها نیاوردیم تا از لوپ ذخیرهسازی جلوگیری شود // eslint-disable-next-line react-hooks/exhaustive-deps - }, [id, pages]) + }, [id, pages, documentSettings]) return ( diff --git a/src/pages/editor/components/EditorCanvas.tsx b/src/pages/editor/components/EditorCanvas.tsx index c13d5be..9765f51 100644 --- a/src/pages/editor/components/EditorCanvas.tsx +++ b/src/pages/editor/components/EditorCanvas.tsx @@ -1,5 +1,6 @@ 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 { useEditorStore } from "../store/editorStore"; import { useDrawingHandlers } from "./canvas/useDrawingHandlers"; @@ -39,10 +40,38 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { setLayerRef, setGuides, zoom, + documentSettings, } = 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 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(); const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers(); @@ -362,9 +391,20 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => { y={0} width={stageSize.width} height={stageSize.height} - fill="#ffffff" + fill={bgColor} listening={false} /> + {documentSettings.backgroundType === 'image' && bgImage && ( + + )} void } +const TAB_LABELS: Record = { + pages: 'صفحات', + layers: 'لایه ها', + settings: 'تنظیمات', +} + const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => { const [activeTab, setActiveTab] = useState('pages') @@ -19,23 +26,23 @@ const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => { { - setActiveTab('pages') - setIsOpen(true) - }} + onClick={() => { setActiveTab('pages'); setIsOpen(true) }} className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-200 transition-colors" > { - setActiveTab('layers') - setIsOpen(true) - }} + onClick={() => { setActiveTab('layers'); setIsOpen(true) }} className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-200 transition-colors" > + { setActiveTab('settings'); setIsOpen(true) }} + className="w-10 h-10 flex items-center justify-center rounded-lg hover:bg-gray-200 transition-colors" + > + + ) @@ -45,28 +52,34 @@ const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => { { - setActiveTab('pages') - }} + onClick={() => setActiveTab('pages')} className={clx( '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' )} > { - setActiveTab('layers') - }} + onClick={() => setActiveTab('layers')} className={clx( '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' )} > + + 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' + )} + > + + @@ -80,12 +93,14 @@ const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => { - {activeTab === 'pages' ? 'صفحات' : 'لایه ها'} + {TAB_LABELS[activeTab]} - {activeTab === 'pages' ? : } + {activeTab === 'pages' && } + {activeTab === 'layers' && } + {activeTab === 'settings' && } diff --git a/src/pages/editor/components/SettingsPanel.tsx b/src/pages/editor/components/SettingsPanel.tsx new file mode 100644 index 0000000..48074b6 --- /dev/null +++ b/src/pages/editor/components/SettingsPanel.tsx @@ -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(null) + const { mutateAsync: uploadFile, isPending: imageLoading } = useSingleUpload() + const [uploadedPreviews, setUploadedPreviews] = useState( + 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 ( + + + {/* ─── تنظیمات نمایش ─── */} + + + تنظیمات نمایش + + + {/* استایل نمایش */} + + استایل نمایش + + + 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) => ( + + {opt.label} + + ))} + + + + + + + + + + {/* چکباکسها */} + + updateDocumentSettings({ autoPlay: v })} + /> + updateDocumentSettings({ pageFlipSound: v })} + /> + updateDocumentSettings({ leftToRightFlip: v })} + /> + + + + {/* ─── پس زمینه ─── */} + + پس زمینه + + {/* نوع پسزمینه */} + + {BACKGROUND_TYPE_OPTIONS.map((opt) => ( + + {opt.label} + 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 && ( + + )} + + + ))} + + + {/* ─── انتخاب رنگ ─── */} + {backgroundType === 'color' && ( + + انتخاب رنگ + + {PRESET_COLORS.map((color) => ( + 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 */} + colorInputRef.current?.click()} + className="w-9 h-9 rounded-xl border-2 border-transparent hover:scale-105 transition-all overflow-hidden relative" + title="رنگ دلخواه" + > + + updateDocumentSettings({ backgroundColor: e.target.value })} + className="absolute inset-0 opacity-0 w-full h-full cursor-pointer" + /> + + + + {/* نمایش رنگ انتخابشده */} + + + {backgroundColor} + + + )} + + {/* ─── آپلود تصویر ─── */} + {backgroundType === 'image' && ( + + {imageLoading ? ( + + + در حال آپلود... + + ) : ( + + )} + + )} + + + ) +} + +type CheckboxRowProps = { + label: string + checked: boolean + onChange: (value: boolean) => void +} + +const CheckboxRow = ({ label, checked, onChange }: CheckboxRowProps) => ( + + {label} + 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 && ( + + + + )} + + +) + +export default SettingsPanel diff --git a/src/pages/editor/store/editorStore.ts b/src/pages/editor/store/editorStore.ts index 013891f..1884a95 100644 --- a/src/pages/editor/store/editorStore.ts +++ b/src/pages/editor/store/editorStore.ts @@ -18,6 +18,7 @@ import type { TableCell, TableData, ToolType, + DocumentSettings, } from "./editorStore.types"; const MAX_OBJECT_HISTORY = 50; @@ -38,6 +39,9 @@ export type { TableCell, TableData, ToolType, + DocumentSettings, + DisplayStyle, + BackgroundType, } from "./editorStore.types"; type EditorStoreType = { @@ -117,7 +121,7 @@ type EditorStoreType = { toggleObjectVisibility: (id: string) => void; groupObjects: (objectIds: string[]) => void; ungroupObjects: (groupId: string) => void; - loadPages: (pages: Page[]) => void; + loadPages: (pages: Page[], documentSettings?: Partial) => void; clipboardObjects: EditorObject[]; objectHistoryPast: EditorObject[][]; objectHistoryFuture: EditorObject[][]; @@ -128,6 +132,8 @@ type EditorStoreType = { redoObjects: () => void; bringObjectForward: (id: string) => void; sendObjectBackward: (id: string) => void; + documentSettings: DocumentSettings; + updateDocumentSettings: (updates: Partial) => void; }; export const useEditorStore = create((set, get) => { @@ -172,6 +178,19 @@ export const useEditorStore = create((set, get) => { }; 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", setTool: (tool) => set((state) => ({ @@ -957,7 +976,7 @@ export const useEditorStore = create((set, get) => { const state = get(); return state.objects.filter((obj) => obj.groupId === groupId); }, - loadPages: (pages) => { + loadPages: (pages, documentSettings) => { if (pages.length === 0) return; const normalizedPages = pages.map((page) => ({ ...page, @@ -966,6 +985,7 @@ export const useEditorStore = create((set, get) => { objects: ensureMissingGroupObjects(page.objects || []), })); const firstPage = normalizedPages[0]; + const currentSettings = get().documentSettings; set({ pages: normalizedPages, currentPageId: firstPage.id, @@ -975,6 +995,9 @@ export const useEditorStore = create((set, get) => { selectedObjectIds: [], objectHistoryPast: [], objectHistoryFuture: [], + ...(documentSettings && { + documentSettings: { ...currentSettings, ...documentSettings }, + }), }); }, }; diff --git a/src/pages/editor/store/editorStore.types.ts b/src/pages/editor/store/editorStore.types.ts index 001f8bc..5e7c0c6 100644 --- a/src/pages/editor/store/editorStore.types.ts +++ b/src/pages/editor/store/editorStore.types.ts @@ -81,3 +81,16 @@ export type Page = { objects: EditorObject[]; 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; +};
انتخاب رنگ