Compare commits
10 Commits
731353a4a8
...
83b2fd809c
| Author | SHA1 | Date | |
|---|---|---|---|
| 83b2fd809c | |||
| cbdc7edbf5 | |||
| 612deba297 | |||
| 517c77af77 | |||
| ebb6c1c9c6 | |||
| b6bff9956a | |||
| 4390937b19 | |||
| 642155661c | |||
| 5bbd677f41 | |||
| 7589a26706 |
@@ -1,4 +1,4 @@
|
|||||||
VITE_API_URL = 'http://188.121.103.8:4141'
|
VITE_API_URL = 'http://89.32.249.26:4010'
|
||||||
# VITE_API_URL = 'http://188.121.103.8:4141'
|
# VITE_API_URL = 'http://192.168.99.131:4000'
|
||||||
VITE_TOKEN_NAME = 'dpage-editor-t'
|
VITE_TOKEN_NAME = 'dpage-editor-t'
|
||||||
VITE_REFRESH_TOKEN_NAME = 'dpage-editor-refresh-t'
|
VITE_REFRESH_TOKEN_NAME = 'dpage-editor-refresh-t'
|
||||||
+22
-63
@@ -1,81 +1,40 @@
|
|||||||
import { useEffect, useState, type FC } from 'react'
|
import FaJson from "@/langs/fa.json";
|
||||||
import './assets/fonts/irancell/style.css'
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import MainRouter from './router/MainRouter'
|
import i18next from "i18next";
|
||||||
import i18next from 'i18next'
|
import { type FC } from "react";
|
||||||
import FaJson from '@/langs/fa.json'
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { I18nextProvider } from 'react-i18next'
|
import "react-multi-date-picker/styles/layouts/mobile.css";
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import ToastContainer from './components/Toast'
|
import "./assets/fonts/irancell/style.css";
|
||||||
import { getToken, setRefreshToken, setToken } from './config/func'
|
import ToastContainer from "./components/Toast";
|
||||||
import "react-multi-date-picker/styles/layouts/mobile.css"
|
import MainRouter from "./router/MainRouter";
|
||||||
|
|
||||||
|
|
||||||
i18next.init({
|
i18next.init({
|
||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
lng: 'fa',
|
lng: "fa",
|
||||||
resources: {
|
resources: {
|
||||||
fa: {
|
fa: {
|
||||||
global: FaJson
|
global: FaJson,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
const App: FC = () => {
|
const App: FC = () => {
|
||||||
|
|
||||||
const [isLogin, setIsLogin] = useState<'checking' | 'isLogin' | 'isNotLogin'>('checking')
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// استخراج توکنها از URL در صورت وجود
|
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
|
||||||
const tokenFromUrl = urlParams.get('token')
|
|
||||||
const refreshTokenFromUrl = urlParams.get('refreshToken')
|
|
||||||
|
|
||||||
if (tokenFromUrl && refreshTokenFromUrl) {
|
|
||||||
// ذخیره توکنها (جایگزین کردن توکنهای قبلی اگر وجود داشته باشند)
|
|
||||||
setToken(tokenFromUrl)
|
|
||||||
setRefreshToken(refreshTokenFromUrl)
|
|
||||||
|
|
||||||
// حذف پارامترها از URL
|
|
||||||
const newUrl = window.location.pathname
|
|
||||||
window.history.replaceState({}, '', newUrl)
|
|
||||||
|
|
||||||
// تنظیم وضعیت لاگین و هدایت به صفحه اصلی
|
|
||||||
setIsLogin('isLogin')
|
|
||||||
// if (window.location.pathname === Paths.auth.login) {
|
|
||||||
// window.location.href = Paths.home
|
|
||||||
// }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = getToken()
|
|
||||||
if (token) {
|
|
||||||
setIsLogin('isLogin')
|
|
||||||
} else {
|
|
||||||
setIsLogin('isNotLogin')
|
|
||||||
if (window.location.href.split('auth').length === 1) {
|
|
||||||
// window.location.href = Paths.auth.login
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
console.log('isLogin', isLogin);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
<I18nextProvider i18n={i18next}>
|
<I18nextProvider i18n={i18next}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MainRouter />
|
<MainRouter />
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ type Props = {
|
|||||||
preview?: string[],
|
preview?: string[],
|
||||||
onChangePreview?: (preview: string[]) => void,
|
onChangePreview?: (preview: string[]) => void,
|
||||||
getCover?: (url: string) => void,
|
getCover?: (url: string) => void,
|
||||||
coverUrl?: string
|
coverUrl?: string,
|
||||||
|
/** نمایش وضعیت بارگذاری داخل باکس آپلود */
|
||||||
|
isLoading?: boolean,
|
||||||
|
loadingLabel?: string,
|
||||||
|
/** بدون پیشنمایش زیر باکس (مثلاً گالری جداگانه دارد) */
|
||||||
|
hidePreview?: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||||
@@ -21,6 +26,10 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
|||||||
const [cover, setCover] = useState<string>(props.coverUrl ?? "");
|
const [cover, setCover] = useState<string>(props.coverUrl ?? "");
|
||||||
|
|
||||||
const onDrop = useCallback((acceptedFiles: File[]) => {
|
const onDrop = useCallback((acceptedFiles: File[]) => {
|
||||||
|
if (props.hidePreview) {
|
||||||
|
props.onChange(acceptedFiles);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// وقتی parent با preview و onChangePreview مدیریت میکند، فقط onChange بزن تا فقط یک ردیف (perviews) نمایش داده شود
|
// وقتی parent با preview و onChangePreview مدیریت میکند، فقط onChange بزن تا فقط یک ردیف (perviews) نمایش داده شود
|
||||||
if (props.preview !== undefined && props.onChangePreview) {
|
if (props.preview !== undefined && props.onChangePreview) {
|
||||||
props.onChange(acceptedFiles);
|
props.onChange(acceptedFiles);
|
||||||
@@ -36,7 +45,10 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [files]);
|
}, [files]);
|
||||||
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
const { getRootProps, getInputProps } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
disabled: props.isLoading,
|
||||||
|
})
|
||||||
|
|
||||||
const handleDelete = (index: number) => {
|
const handleDelete = (index: number) => {
|
||||||
const array = [...files]
|
const array = [...files]
|
||||||
@@ -67,8 +79,23 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div {...getRootProps()} className='w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl'>
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={clx(
|
||||||
|
'relative w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl',
|
||||||
|
props.isLoading && 'pointer-events-none opacity-80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
|
{props.isLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-black" />
|
||||||
|
<div className="text-description text-xs">
|
||||||
|
{props.loadingLabel ?? 'در حال آپلود...'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<Gallery
|
<Gallery
|
||||||
className='size-8'
|
className='size-8'
|
||||||
color='#8C90A3'
|
color='#8C90A3'
|
||||||
@@ -84,10 +111,12 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
|||||||
/>
|
/>
|
||||||
<div className='text-xs'>آپلود</div>
|
<div className='text-xs'>آپلود</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
files.length > 0 || perviews.length > 0 ? (
|
!props.hidePreview && (files.length > 0 || perviews.length > 0) ? (
|
||||||
<div className='mt-4 flex gap-4 items-center'>
|
<div className='mt-4 flex gap-4 items-center'>
|
||||||
{
|
{
|
||||||
perviews && perviews.map((item, index) => {
|
perviews && perviews.map((item, index) => {
|
||||||
|
|||||||
@@ -9,3 +9,14 @@ export const Paths = {
|
|||||||
request: "/designer/request",
|
request: "/designer/request",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const viewerPathPrefix = `${Paths.viewer}/`;
|
||||||
|
|
||||||
|
/** Routes that do not require login (must stay in sync with PrivateRoute requireAuth={false}) */
|
||||||
|
export const isPublicPath = (pathname: string): boolean => {
|
||||||
|
return (
|
||||||
|
pathname === Paths.home ||
|
||||||
|
(pathname.startsWith(viewerPathPrefix) &&
|
||||||
|
pathname.length > viewerPathPrefix.length)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { getToken, setRefreshToken, setToken } from '@/config/func'
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type FC,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
export type AuthStatus = 'checking' | 'authenticated' | 'unauthenticated'
|
||||||
|
|
||||||
|
type AuthContextValue = {
|
||||||
|
status: AuthStatus
|
||||||
|
isAuthenticated: boolean
|
||||||
|
isChecking: boolean
|
||||||
|
refreshAuth: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
type AuthProviderProps = {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthProvider: FC<AuthProviderProps> = ({ children }) => {
|
||||||
|
const [status, setStatus] = useState<AuthStatus>('checking')
|
||||||
|
|
||||||
|
const refreshAuth = useCallback(() => {
|
||||||
|
setStatus(getToken() ? 'authenticated' : 'unauthenticated')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const tokenFromUrl = urlParams.get('token')
|
||||||
|
const refreshTokenFromUrl = urlParams.get('refreshToken')
|
||||||
|
|
||||||
|
if (tokenFromUrl && refreshTokenFromUrl) {
|
||||||
|
setToken(tokenFromUrl)
|
||||||
|
setRefreshToken(refreshTokenFromUrl)
|
||||||
|
|
||||||
|
const newUrl = window.location.pathname
|
||||||
|
window.history.replaceState({}, '', newUrl)
|
||||||
|
|
||||||
|
setStatus('authenticated')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshAuth()
|
||||||
|
}, [refreshAuth])
|
||||||
|
|
||||||
|
const value = useMemo<AuthContextValue>(
|
||||||
|
() => ({
|
||||||
|
status,
|
||||||
|
isAuthenticated: status === 'authenticated',
|
||||||
|
isChecking: status === 'checking',
|
||||||
|
refreshAuth,
|
||||||
|
}),
|
||||||
|
[status, refreshAuth],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = (): AuthContextValue => {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { transformViewerDataToPages } from '@/pages/viewer/utils/dataTransformer
|
|||||||
import { getPaperDimensions } from '@/config/paperSizes'
|
import { getPaperDimensions } from '@/config/paperSizes'
|
||||||
import type { CatalogItemType } from '../types/Types'
|
import type { CatalogItemType } from '../types/Types'
|
||||||
import type { PageData } from '@/pages/viewer/types'
|
import type { PageData } from '@/pages/viewer/types'
|
||||||
|
import type { Page } from '@/pages/editor/store/editorStore.types'
|
||||||
|
import { clx } from '@/helpers/utils'
|
||||||
|
|
||||||
const PREVIEW_WIDTH = 64
|
const PREVIEW_WIDTH = 64
|
||||||
const PREVIEW_HEIGHT = 89
|
const PREVIEW_HEIGHT = 89
|
||||||
@@ -20,30 +22,57 @@ function getFirstPageFromContent(content: string | undefined): PageData | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
function getPageDataFromEditorPage(page: Page): PageData | null {
|
||||||
item: CatalogItemType
|
const transformed = transformViewerDataToPages({
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
id: page.id,
|
||||||
|
name: page.name,
|
||||||
|
backgroundType: page.backgroundType,
|
||||||
|
backgroundColor: page.backgroundColor,
|
||||||
|
backgroundGradient: page.backgroundGradient,
|
||||||
|
backgroundImageUrl: page.backgroundImageUrl,
|
||||||
|
objects: page.objects,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return transformed[0] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const CatalogPreview: FC<Props> = ({ item }) => {
|
type Props = {
|
||||||
const firstPage = useMemo(
|
item?: CatalogItemType
|
||||||
() => getFirstPageFromContent(item.content),
|
page?: Page
|
||||||
[item.content]
|
size?: string
|
||||||
)
|
className?: string
|
||||||
|
selected?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const CatalogPreview: FC<Props> = ({ item, page, size, className, selected }) => {
|
||||||
|
const catalogSize = item?.size ?? size ?? 'a4'
|
||||||
|
|
||||||
|
const firstPage = useMemo(() => {
|
||||||
|
if (page) return getPageDataFromEditorPage(page)
|
||||||
|
return getFirstPageFromContent(item?.content)
|
||||||
|
}, [item?.content, page])
|
||||||
|
|
||||||
const { pageWidth, pageHeight, previewScale } = useMemo(() => {
|
const { pageWidth, pageHeight, previewScale } = useMemo(() => {
|
||||||
const { width, height } = getPaperDimensions(item.size ?? 'a4')
|
const { width, height } = getPaperDimensions(catalogSize)
|
||||||
const scale = Math.min(PREVIEW_WIDTH / width, PREVIEW_HEIGHT / height)
|
const scale = Math.min(PREVIEW_WIDTH / width, PREVIEW_HEIGHT / height)
|
||||||
return {
|
return {
|
||||||
pageWidth: width,
|
pageWidth: width,
|
||||||
pageHeight: height,
|
pageHeight: height,
|
||||||
previewScale: scale,
|
previewScale: scale,
|
||||||
}
|
}
|
||||||
}, [item.size])
|
}, [catalogSize])
|
||||||
|
|
||||||
if (!firstPage) {
|
if (!firstPage) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='bg-gray-200 rounded-lg flex items-center justify-center shrink-0'
|
className={clx(
|
||||||
|
'bg-gray-200 rounded-lg flex items-center justify-center shrink-0',
|
||||||
|
selected && 'border border-black',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
style={{ width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }}
|
style={{ width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }}
|
||||||
>
|
>
|
||||||
<span className='text-[10px] text-gray-400'>بدون پیشنمایش</span>
|
<span className='text-[10px] text-gray-400'>بدون پیشنمایش</span>
|
||||||
@@ -53,7 +82,11 @@ const CatalogPreview: FC<Props> = ({ item }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='rounded-lg overflow-hidden shrink-0 bg-white border border-gray-200 relative'
|
className={clx(
|
||||||
|
'rounded-lg overflow-hidden shrink-0 bg-white border border-gray-200 relative',
|
||||||
|
selected && 'border-black',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
style={{ width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }}
|
style={{ width: PREVIEW_WIDTH, height: PREVIEW_HEIGHT }}
|
||||||
dir='rtl'
|
dir='rtl'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import TrashWithConfrim from '@/components/TrashWithConfrim'
|
|||||||
import { useDleteCatalog } from '@/pages/home/hooks/useHomeData'
|
import { useDleteCatalog } from '@/pages/home/hooks/useHomeData'
|
||||||
import { toast } from '@/components/Toast'
|
import { toast } from '@/components/Toast'
|
||||||
import { extractErrorMessage } from '@/helpers/utils'
|
import { extractErrorMessage } from '@/helpers/utils'
|
||||||
|
import { requestViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
item: CatalogItemType,
|
item: CatalogItemType,
|
||||||
@@ -69,7 +70,7 @@ const CatalogueItem: FC<Props> = ({ item, refetch }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-end gap-1 items-center mt-1'>
|
<div className='flex justify-end gap-1 items-center mt-1'>
|
||||||
<Link to={Paths.viewer + `/${item.id}`}>
|
<Link to={Paths.viewer + `/${item.id}`} onClick={requestViewerFullscreen}>
|
||||||
<div className='size-6 bg-[#EAECF4] rounded-md flex justify-center items-center'>
|
<div className='size-6 bg-[#EAECF4] rounded-md flex justify-center items-center'>
|
||||||
<Eye size={18} color='#0047FF' />
|
<Eye size={18} color='#0047FF' />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+41
-12
@@ -1,4 +1,4 @@
|
|||||||
import { type FC, useEffect, useState } from 'react'
|
import { type FC, useEffect, useRef, useState } from 'react'
|
||||||
import Konva from 'konva'
|
import Konva from 'konva'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
import EditorSidebar from './components/EditorSidebar'
|
import EditorSidebar from './components/EditorSidebar'
|
||||||
@@ -23,33 +23,59 @@ const Editor: FC = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
const [isLayersPanelOpen, setIsLayersPanelOpen] = useState(false)
|
const [isLayersPanelOpen, setIsLayersPanelOpen] = useState(false)
|
||||||
const [isInitialContentReady, setIsInitialContentReady] = useState(false)
|
const [isInitialContentReady, setIsInitialContentReady] = useState(false)
|
||||||
const { loadPages, pages, documentSettings, objects, currentPageId } = useEditorStore()
|
const loadedCatalogIdRef = useRef<string | null>(null)
|
||||||
|
const skipNextAutosaveRef = useRef(true)
|
||||||
|
const { loadPages, resetEditor, pages, documentSettings, gallery, objects, currentPageId } = useEditorStore()
|
||||||
const { data, isFetched } = useGetCatalogById(id!)
|
const { data, isFetched } = useGetCatalogById(id!)
|
||||||
const { scheduleUpdate, isSaving } = useUpdateCatalog()
|
const { scheduleUpdate, cancelPendingUpdate, isSaving } = useUpdateCatalog()
|
||||||
|
|
||||||
|
// هنگام تعویض کاتالوگ: لغو ذخیرهٔ معلق، پاکسازی store، و منتظر بارگذاری مجدد بمان
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFetched) return
|
cancelPendingUpdate()
|
||||||
|
loadedCatalogIdRef.current = null
|
||||||
|
skipNextAutosaveRef.current = true
|
||||||
|
setIsInitialContentReady(false)
|
||||||
|
resetEditor()
|
||||||
|
}, [id, cancelPendingUpdate, resetEditor])
|
||||||
|
|
||||||
if (data?.data?.content) {
|
// بارگذاری محتوا فقط یکبار برای هر id (نه در هر refetch)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id || !isFetched) return
|
||||||
|
if (loadedCatalogIdRef.current === id) return
|
||||||
|
|
||||||
|
const rawContent = data?.data?.content
|
||||||
|
if (rawContent) {
|
||||||
try {
|
try {
|
||||||
const json = JSON.parse(data.data.content)
|
const json = JSON.parse(rawContent)
|
||||||
if (Array.isArray(json)) {
|
if (Array.isArray(json)) {
|
||||||
loadPages(json)
|
loadPages(json)
|
||||||
} else if (json?.pages) {
|
} else if (json?.pages) {
|
||||||
loadPages(json.pages, json.documentSettings)
|
loadPages(json.pages, json.documentSettings, json.gallery)
|
||||||
|
} else {
|
||||||
|
console.error('Invalid catalog content JSON: missing pages')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Invalid catalog content JSON:', error)
|
console.error('Invalid catalog content JSON:', error)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
resetEditor()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadedCatalogIdRef.current = id
|
||||||
|
skipNextAutosaveRef.current = true
|
||||||
setIsInitialContentReady(true)
|
setIsInitialContentReady(true)
|
||||||
}, [data?.data?.content, isFetched, loadPages])
|
}, [id, isFetched, data?.data?.content, loadPages, resetEditor])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
if (!isInitialContentReady) return
|
if (!isInitialContentReady) return
|
||||||
if (!pages || pages.length === 0) return
|
if (!pages || pages.length === 0) return
|
||||||
|
if (skipNextAutosaveRef.current) {
|
||||||
|
skipNextAutosaveRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const pagesToSave = currentPageId
|
const pagesToSave = currentPageId
|
||||||
? pages.map((p) =>
|
? pages.map((p) =>
|
||||||
@@ -59,11 +85,11 @@ const Editor: FC = () => {
|
|||||||
|
|
||||||
scheduleUpdate({
|
scheduleUpdate({
|
||||||
id,
|
id,
|
||||||
content: JSON.stringify({ pages: pagesToSave, documentSettings }),
|
content: JSON.stringify({ pages: pagesToSave, documentSettings, gallery }),
|
||||||
})
|
})
|
||||||
// عمداً scheduleUpdate را در وابستگیها نیاوردیم تا از لوپ ذخیرهسازی جلوگیری شود
|
// عمداً scheduleUpdate را در وابستگیها نیاوردیم تا از لوپ ذخیرهسازی جلوگیری شود
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [id, isInitialContentReady, pages, documentSettings, objects, currentPageId])
|
}, [id, isInitialContentReady, pages, documentSettings, gallery, objects, currentPageId])
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,11 +101,14 @@ const Editor: FC = () => {
|
|||||||
<div className="fixed bottom-10 left-10 z-50 rounded-full shadow-sm">
|
<div className="fixed bottom-10 left-10 z-50 rounded-full shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-700">
|
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-black" />
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-black" />
|
||||||
{/* <span>در حال ذخیره ...</span> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<LayersPanel isOpen={isLayersPanelOpen} setIsOpen={setIsLayersPanelOpen} />
|
<LayersPanel
|
||||||
|
isOpen={isLayersPanelOpen}
|
||||||
|
setIsOpen={setIsLayersPanelOpen}
|
||||||
|
catalogSize={data?.data?.size}
|
||||||
|
/>
|
||||||
<EditorCanvas catalogSize={data?.data?.size} />
|
<EditorCanvas catalogSize={data?.data?.size} />
|
||||||
<EditorSidebar catalogSize={data?.data?.size} />
|
<EditorSidebar catalogSize={data?.data?.size} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ 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";
|
import { getKonvaGradientProps } from "../utils/gradient";
|
||||||
|
import { STICKER_DRAG_MIME } from "../types/IconTypes";
|
||||||
|
import { GALLERY_DRAG_MIME } from "../types/GalleryTypes";
|
||||||
|
import {
|
||||||
|
createStickerObject,
|
||||||
|
scaleStickerDimensions,
|
||||||
|
} from "../utils/stickerUtils";
|
||||||
|
import {
|
||||||
|
createImageObject,
|
||||||
|
scaleImageDimensions,
|
||||||
|
} from "../utils/imageUtils";
|
||||||
|
|
||||||
type EditorCanvasProps = {
|
type EditorCanvasProps = {
|
||||||
catalogSize?: string;
|
catalogSize?: string;
|
||||||
@@ -50,6 +60,9 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
setLayerRef,
|
setLayerRef,
|
||||||
setGuides,
|
setGuides,
|
||||||
zoom,
|
zoom,
|
||||||
|
addObject,
|
||||||
|
setSelectedObjectId,
|
||||||
|
setTool,
|
||||||
} = useEditorStore();
|
} = useEditorStore();
|
||||||
const isSmartGuideEnabled = documentSettings.smartGuide;
|
const isSmartGuideEnabled = documentSettings.smartGuide;
|
||||||
const currentPage = pages.find((page) => page.id === currentPageId);
|
const currentPage = pages.find((page) => page.id === currentPageId);
|
||||||
@@ -360,6 +373,73 @@ 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 SIDEBAR_DRAG_MIMES = [STICKER_DRAG_MIME, GALLERY_DRAG_MIME];
|
||||||
|
|
||||||
|
const handleSidebarDragOver = (e: React.DragEvent) => {
|
||||||
|
if (!SIDEBAR_DRAG_MIMES.some((mime) => e.dataTransfer.types.includes(mime))) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeObjectAt = (
|
||||||
|
imageUrl: string,
|
||||||
|
stageX: number,
|
||||||
|
stageY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
type: "sticker" | "image",
|
||||||
|
) => {
|
||||||
|
const object =
|
||||||
|
type === "sticker"
|
||||||
|
? createStickerObject(
|
||||||
|
imageUrl,
|
||||||
|
stageX,
|
||||||
|
stageY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stageSize.width,
|
||||||
|
stageSize.height,
|
||||||
|
)
|
||||||
|
: createImageObject(
|
||||||
|
imageUrl,
|
||||||
|
stageX,
|
||||||
|
stageY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stageSize.width,
|
||||||
|
stageSize.height,
|
||||||
|
);
|
||||||
|
addObject(object);
|
||||||
|
setSelectedObjectId(object.id);
|
||||||
|
setTool("select");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSidebarDrop = (e: React.DragEvent) => {
|
||||||
|
const stickerUrl = e.dataTransfer.getData(STICKER_DRAG_MIME);
|
||||||
|
const galleryUrl = e.dataTransfer.getData(GALLERY_DRAG_MIME);
|
||||||
|
const imageUrl = stickerUrl || galleryUrl;
|
||||||
|
const dropType = stickerUrl ? "sticker" : galleryUrl ? "image" : null;
|
||||||
|
if (!imageUrl || !dropType || !stageWrapRef.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const rect = stageWrapRef.current.getBoundingClientRect();
|
||||||
|
const stageX = (e.clientX - rect.left) / finalScale;
|
||||||
|
const stageY = (e.clientY - rect.top) / finalScale;
|
||||||
|
|
||||||
|
const scaleFn =
|
||||||
|
dropType === "sticker" ? scaleStickerDimensions : scaleImageDimensions;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const { width, height } = scaleFn(img.naturalWidth, img.naturalHeight);
|
||||||
|
placeObjectAt(imageUrl, stageX, stageY, width, height, dropType);
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
placeObjectAt(imageUrl, stageX, stageY, 100, 100, dropType);
|
||||||
|
};
|
||||||
|
img.src = imageUrl;
|
||||||
|
};
|
||||||
const backgroundGradientProps = getKonvaGradientProps(
|
const backgroundGradientProps = getKonvaGradientProps(
|
||||||
currentPage?.backgroundType === "gradient" ? "gradient" : "solid",
|
currentPage?.backgroundType === "gradient" ? "gradient" : "solid",
|
||||||
bgGradient,
|
bgGradient,
|
||||||
@@ -410,7 +490,12 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
title="Drag to create horizontal guide"
|
title="Drag to create horizontal guide"
|
||||||
/>
|
/>
|
||||||
<div className="absolute -top-6 -left-6 w-6 h-6 bg-slate-200 border border-slate-300" />
|
<div className="absolute -top-6 -left-6 w-6 h-6 bg-slate-200 border border-slate-300" />
|
||||||
<div ref={stageWrapRef} className="shadow-lg">
|
<div
|
||||||
|
ref={stageWrapRef}
|
||||||
|
className="shadow-lg"
|
||||||
|
onDragOver={handleSidebarDragOver}
|
||||||
|
onDrop={handleSidebarDrop}
|
||||||
|
>
|
||||||
<Stage
|
<Stage
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
width={stageSize.width * finalScale}
|
width={stageSize.width * finalScale}
|
||||||
@@ -464,6 +549,8 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
onCellDblClick={handleCellDblClick}
|
onCellDblClick={handleCellDblClick}
|
||||||
onGroup={handleGroupWithMask}
|
onGroup={handleGroupWithMask}
|
||||||
onUngroup={handleUngroupFromMask}
|
onUngroup={handleUngroupFromMask}
|
||||||
|
stageWidth={stageSize.width}
|
||||||
|
stageHeight={stageSize.height}
|
||||||
/>
|
/>
|
||||||
<GuidesLayer
|
<GuidesLayer
|
||||||
guides={guides}
|
guides={guides}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type TabType = 'pages' | 'layers' | 'settings'
|
|||||||
type LayersPanelProps = {
|
type LayersPanelProps = {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
setIsOpen: (isOpen: boolean) => void
|
setIsOpen: (isOpen: boolean) => void
|
||||||
|
catalogSize?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_LABELS: Record<TabType, string> = {
|
const TAB_LABELS: Record<TabType, string> = {
|
||||||
@@ -18,7 +19,7 @@ const TAB_LABELS: Record<TabType, string> = {
|
|||||||
settings: 'تنظیمات',
|
settings: 'تنظیمات',
|
||||||
}
|
}
|
||||||
|
|
||||||
const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => {
|
const LayersPanel = ({ isOpen, setIsOpen, catalogSize }: LayersPanelProps) => {
|
||||||
const [activeTab, setActiveTab] = useState<TabType>('pages')
|
const [activeTab, setActiveTab] = useState<TabType>('pages')
|
||||||
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -98,7 +99,7 @@ const LayersPanel = ({ isOpen, setIsOpen }: LayersPanelProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeTab === 'pages' && <PagesPanel />}
|
{activeTab === 'pages' && <PagesPanel catalogSize={catalogSize} />}
|
||||||
{activeTab === 'layers' && <LayersList />}
|
{activeTab === 'layers' && <LayersList />}
|
||||||
{activeTab === 'settings' && <SettingsPanel />}
|
{activeTab === 'settings' && <SettingsPanel />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
import { DocumentText, Copy, Trash, AddSquare } from 'iconsax-react'
|
import { Copy, Trash, AddSquare, DocumentText } from 'iconsax-react'
|
||||||
import { useEditorStore } from '../store/editorStore'
|
import { useEditorStore } from '../store/editorStore'
|
||||||
|
import type { Page } from '../store/editorStore.types'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
|
import CatalogPreview from '@/pages/catalogue/components/CatalogPreview'
|
||||||
|
|
||||||
const PagesPanel = () => {
|
function isPageEmptyForPreview(page: Page): boolean {
|
||||||
|
if (page.objects.length > 0) return false
|
||||||
|
if (page.backgroundType === 'image' && page.backgroundImageUrl.trim() !== '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (page.backgroundType === 'gradient') return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
type PagesPanelProps = {
|
||||||
|
catalogSize?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PagesPanel = ({ catalogSize }: PagesPanelProps) => {
|
||||||
const {
|
const {
|
||||||
pages,
|
pages,
|
||||||
currentPageId,
|
currentPageId,
|
||||||
@@ -37,6 +52,7 @@ const PagesPanel = () => {
|
|||||||
) : (
|
) : (
|
||||||
pages.map((page) => {
|
pages.map((page) => {
|
||||||
const isSelected = currentPageId === page.id
|
const isSelected = currentPageId === page.id
|
||||||
|
const isEmpty = isPageEmptyForPreview(page)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={page.id}
|
key={page.id}
|
||||||
@@ -46,6 +62,7 @@ const PagesPanel = () => {
|
|||||||
isSelected && ' rounded-lg p-0.5'
|
isSelected && ' rounded-lg p-0.5'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{isEmpty ? (
|
||||||
<div
|
<div
|
||||||
className={clx(
|
className={clx(
|
||||||
'w-[72px] h-[94px] bg-[#EEF0F7] rounded-lg flex items-center justify-center',
|
'w-[72px] h-[94px] bg-[#EEF0F7] rounded-lg flex items-center justify-center',
|
||||||
@@ -54,6 +71,13 @@ const PagesPanel = () => {
|
|||||||
>
|
>
|
||||||
<DocumentText size={20} color="#000" variant="Outline" />
|
<DocumentText size={20} color="#000" variant="Outline" />
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<CatalogPreview
|
||||||
|
page={page}
|
||||||
|
size={catalogSize}
|
||||||
|
selected={isSelected}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="text-xs font-medium text-center text-black">
|
<div className="text-xs font-medium text-center text-black">
|
||||||
{page.name}
|
{page.name}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type ObjectRendererProps = {
|
|||||||
onCellDblClick?: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void;
|
onCellDblClick?: (tableId: string, cellId: string, x: number, y: number, width: number, height: number, text: string) => void;
|
||||||
selectedCellId?: string | null;
|
selectedCellId?: string | null;
|
||||||
allObjects?: EditorObject[];
|
allObjects?: EditorObject[];
|
||||||
|
stageWidth?: number;
|
||||||
|
stageHeight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ObjectRenderer = ({
|
const ObjectRenderer = ({
|
||||||
@@ -42,6 +44,8 @@ const ObjectRenderer = ({
|
|||||||
onCellDblClick,
|
onCellDblClick,
|
||||||
selectedCellId,
|
selectedCellId,
|
||||||
allObjects = [],
|
allObjects = [],
|
||||||
|
stageWidth,
|
||||||
|
stageHeight,
|
||||||
}: ObjectRendererProps) => {
|
}: ObjectRendererProps) => {
|
||||||
const groupRef = useRef<Konva.Group>(null);
|
const groupRef = useRef<Konva.Group>(null);
|
||||||
|
|
||||||
@@ -110,6 +114,8 @@ const ObjectRenderer = ({
|
|||||||
onUpdate,
|
onUpdate,
|
||||||
// اگر mask اعمال شده، shape را غیر draggable کن (Group drag میشود)
|
// اگر mask اعمال شده، shape را غیر draggable کن (Group drag میشود)
|
||||||
draggable: shouldApplyMask ? false : draggable,
|
draggable: shouldApplyMask ? false : draggable,
|
||||||
|
stageWidth,
|
||||||
|
stageHeight,
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMaskShape = (mask: EditorObject) => {
|
const renderMaskShape = (mask: EditorObject) => {
|
||||||
@@ -218,6 +224,9 @@ const ObjectRenderer = ({
|
|||||||
case "document":
|
case "document":
|
||||||
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
||||||
break;
|
break;
|
||||||
|
case "sticker":
|
||||||
|
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
||||||
|
break;
|
||||||
case "grid":
|
case "grid":
|
||||||
shapeElement = (
|
shapeElement = (
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ interface ObjectsLayerProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onGroup: (objectId: string, maskId: string) => void;
|
onGroup: (objectId: string, maskId: string) => void;
|
||||||
onUngroup: (groupId: string) => void;
|
onUngroup: (groupId: string) => void;
|
||||||
|
stageWidth: number;
|
||||||
|
stageHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ObjectsLayer = ({
|
const ObjectsLayer = ({
|
||||||
@@ -51,6 +53,8 @@ const ObjectsLayer = ({
|
|||||||
onCellDblClick,
|
onCellDblClick,
|
||||||
onGroup,
|
onGroup,
|
||||||
onUngroup,
|
onUngroup,
|
||||||
|
stageWidth,
|
||||||
|
stageHeight,
|
||||||
}: ObjectsLayerProps) => {
|
}: ObjectsLayerProps) => {
|
||||||
return (
|
return (
|
||||||
<Layer ref={layerRef}>
|
<Layer ref={layerRef}>
|
||||||
@@ -71,6 +75,8 @@ const ObjectsLayer = ({
|
|||||||
onCellDblClick={onCellDblClick}
|
onCellDblClick={onCellDblClick}
|
||||||
selectedCellId={selectedCellId}
|
selectedCellId={selectedCellId}
|
||||||
allObjects={objects}
|
allObjects={objects}
|
||||||
|
stageWidth={stageWidth}
|
||||||
|
stageHeight={stageHeight}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Render non-grouped objects */}
|
{/* Render non-grouped objects */}
|
||||||
@@ -90,6 +96,8 @@ const ObjectsLayer = ({
|
|||||||
onCellDblClick={onCellDblClick}
|
onCellDblClick={onCellDblClick}
|
||||||
selectedCellId={selectedCellId}
|
selectedCellId={selectedCellId}
|
||||||
allObjects={objects}
|
allObjects={objects}
|
||||||
|
stageWidth={stageWidth}
|
||||||
|
stageHeight={stageHeight}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{/* Render objects inside groups - they should not be interactive */}
|
{/* Render objects inside groups - they should not be interactive */}
|
||||||
@@ -115,6 +123,8 @@ const ObjectsLayer = ({
|
|||||||
onCellDblClick={onCellDblClick}
|
onCellDblClick={onCellDblClick}
|
||||||
selectedCellId={selectedCellId}
|
selectedCellId={selectedCellId}
|
||||||
allObjects={objects}
|
allObjects={objects}
|
||||||
|
stageWidth={stageWidth}
|
||||||
|
stageHeight={stageHeight}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ export const useSelectionHandlers = () => {
|
|||||||
removeFromSelection,
|
removeFromSelection,
|
||||||
setSelectedTableId,
|
setSelectedTableId,
|
||||||
setSelectedCellId,
|
setSelectedCellId,
|
||||||
|
tool,
|
||||||
|
setTool,
|
||||||
} = useEditorStore();
|
} = useEditorStore();
|
||||||
|
|
||||||
const handleSelect = (id: string, _node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleSelect = (id: string, _node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
if (tool !== "select") {
|
||||||
|
setTool("select");
|
||||||
|
}
|
||||||
|
|
||||||
const obj = objects.find((o) => o.id === id);
|
const obj = objects.find((o) => o.id === id);
|
||||||
const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey;
|
const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEditorStore, type ToolType } from "@/pages/editor/store/editorStore";
|
import { type ToolType } from "@/pages/editor/store/editorStore";
|
||||||
import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
|
||||||
import {
|
import {
|
||||||
ImageUpload,
|
ImageGallery,
|
||||||
VideoInput,
|
VideoInput,
|
||||||
LinkInput,
|
LinkInput,
|
||||||
SelectInstruction,
|
SelectInstruction,
|
||||||
@@ -18,39 +17,8 @@ type ToolInstructionsProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
|
const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
|
||||||
const { addObject, setSelectedObjectId, setTool } = useEditorStore();
|
|
||||||
const { mutateAsync: uploadFile } = useSingleUpload();
|
|
||||||
|
|
||||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
try {
|
|
||||||
const result = await uploadFile(file);
|
|
||||||
const imageUrl = result?.data?.url;
|
|
||||||
if (!imageUrl) return;
|
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
const newImage = {
|
|
||||||
id: `image-${Date.now()}`,
|
|
||||||
type: "image" as ToolType,
|
|
||||||
x: 100,
|
|
||||||
y: 100,
|
|
||||||
width: img.width,
|
|
||||||
height: img.height,
|
|
||||||
imageUrl,
|
|
||||||
};
|
|
||||||
addObject(newImage);
|
|
||||||
setSelectedObjectId(newImage.id);
|
|
||||||
setTool("select");
|
|
||||||
};
|
|
||||||
img.src = imageUrl;
|
|
||||||
} catch {
|
|
||||||
// TODO: show error toast
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tool === "image") {
|
if (tool === "image") {
|
||||||
return <ImageUpload onImageUpload={handleImageUpload} />;
|
return <ImageGallery />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tool === "video") {
|
if (tool === "video") {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const tools: Array<{ icon: (color: string, variant?: "Bold" | "Outline" | "Broke
|
|||||||
{ icon: (color, variant) => <Shapes size={20} color={color} variant={variant} />, tool: "rectangle", label: "مستطیل" },
|
{ icon: (color, variant) => <Shapes size={20} color={color} variant={variant} />, tool: "rectangle", label: "مستطیل" },
|
||||||
{ icon: (color, variant) => <Text size={20} color={color} variant={variant} />, tool: "text", label: "متن" },
|
{ icon: (color, variant) => <Text size={20} color={color} variant={variant} />, tool: "text", label: "متن" },
|
||||||
{ icon: (color, variant) => <Grid8 size={20} color={color} variant={variant} />, tool: "grid", label: "گرید" },
|
{ icon: (color, variant) => <Grid8 size={20} color={color} variant={variant} />, tool: "grid", label: "گرید" },
|
||||||
{ icon: (color, variant) => <Gallery size={20} color={color} variant={variant} />, tool: "image", label: "تصویر" },
|
{ icon: (color, variant) => <Gallery size={20} color={color} variant={variant} />, tool: "image", label: "گالری" },
|
||||||
{ icon: (color, variant) => <Link2 size={20} color={color} variant={variant} />, tool: "link", label: "لینک" },
|
{ icon: (color, variant) => <Link2 size={20} color={color} variant={variant} />, tool: "link", label: "لینک" },
|
||||||
{ icon: (color, variant) => <VideoSquare size={20} color={color} variant={variant} />, tool: "video", label: "ویدیو" },
|
{ icon: (color, variant) => <VideoSquare size={20} color={color} variant={variant} />, tool: "video", label: "ویدیو" },
|
||||||
{ icon: (color, variant) => <ArrowLeft size={20} color={color} variant={variant} />, tool: "arrow", label: "پیکان" },
|
{ icon: (color, variant) => <ArrowLeft size={20} color={color} variant={variant} />, tool: "arrow", label: "پیکان" },
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { type FC, useState } from "react";
|
||||||
|
import { Trash } from "iconsax-react";
|
||||||
|
import UploadBoxDraggble from "@/components/UploadBoxDraggble";
|
||||||
|
import { useEditorStore } from "@/pages/editor/store/editorStore";
|
||||||
|
import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
||||||
|
import { createScopedId } from "@/pages/editor/store/editorStore.helpers";
|
||||||
|
import { GALLERY_DRAG_MIME } from "@/pages/editor/types/GalleryTypes";
|
||||||
|
|
||||||
|
const ImageGallery: FC = () => {
|
||||||
|
const { gallery, addGalleryItem, removeGalleryItem } = useEditorStore();
|
||||||
|
const { mutateAsync: uploadFile } = useSingleUpload();
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
const handleFileChange = async (files: File[]) => {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
const result = await uploadFile(file);
|
||||||
|
const imageUrl = result?.data?.url;
|
||||||
|
if (!imageUrl) continue;
|
||||||
|
|
||||||
|
const dimensions = await loadImageDimensions(imageUrl);
|
||||||
|
addGalleryItem({
|
||||||
|
id: createScopedId("gallery"),
|
||||||
|
url: imageUrl,
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// TODO: show error toast
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent<HTMLImageElement>, url: string) => {
|
||||||
|
e.dataTransfer.setData(GALLERY_DRAG_MIME, url);
|
||||||
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-base font-bold">گالری</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
تصویر را آپلود کنید، سپس از گالری بکشید و روی صفحه رها کنید
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<UploadBoxDraggble
|
||||||
|
label="تصویر مورد نظر را آپلود کنید"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
isMultiple={true}
|
||||||
|
hidePreview
|
||||||
|
isLoading={isUploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{gallery.length === 0 && !isUploading && (
|
||||||
|
<p className="text-sm text-gray-500">گالری خالی است</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{gallery.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 max-h-[320px] overflow-y-auto">
|
||||||
|
{gallery.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="group relative flex aspect-square items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-1 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={item.url}
|
||||||
|
alt=""
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, item.url)}
|
||||||
|
className="max-h-full max-w-full object-contain cursor-grab active:cursor-grabbing"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeGalleryItem(item.id)}
|
||||||
|
className="absolute -left-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-white shadow group-hover:flex"
|
||||||
|
title="حذف از گالری"
|
||||||
|
>
|
||||||
|
<Trash size={12} color="#ef4444" variant="Bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadImageDimensions = (url: string): Promise<{ width: number; height: number }> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
resolve({ width: 100, height: 100 });
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ImageGallery;
|
||||||
@@ -1,67 +1 @@
|
|||||||
import { type FC, useState, useRef } from "react";
|
export { default } from "./ImageGallery";
|
||||||
import UploadBoxDraggble from "@/components/UploadBoxDraggble";
|
|
||||||
|
|
||||||
type ImageUploadProps = {
|
|
||||||
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ImageUpload: FC<ImageUploadProps> = ({ onImageUpload }) => {
|
|
||||||
const [uploadedImages, setUploadedImages] = useState<string[]>([]);
|
|
||||||
const previousFilesCountRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const handleFileChange = (files: File[]) => {
|
|
||||||
// Update ref to track current files count
|
|
||||||
if (files.length > previousFilesCountRef.current) {
|
|
||||||
// Process only new files
|
|
||||||
const newFiles = files.slice(previousFilesCountRef.current);
|
|
||||||
|
|
||||||
newFiles.forEach((file) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (event) => {
|
|
||||||
const imageUrl = event.target?.result as string;
|
|
||||||
setUploadedImages((prev) => [...prev, imageUrl]);
|
|
||||||
|
|
||||||
// Create a synthetic event for onImageUpload
|
|
||||||
const syntheticEvent = {
|
|
||||||
target: {
|
|
||||||
files: [file],
|
|
||||||
},
|
|
||||||
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
|
||||||
|
|
||||||
onImageUpload(syntheticEvent);
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always update ref to track current files count
|
|
||||||
previousFilesCountRef.current = files.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviewChange = (previews: string[]) => {
|
|
||||||
setUploadedImages(previews);
|
|
||||||
// Update ref when previews change (e.g., when files are deleted)
|
|
||||||
previousFilesCountRef.current = previews.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-bold mb-4">تصویر</div>
|
|
||||||
<UploadBoxDraggble
|
|
||||||
label="تصویر مورد نظر را آپلود کنید"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
isMultiple={true}
|
|
||||||
preview={uploadedImages}
|
|
||||||
onChangePreview={handlePreviewChange}
|
|
||||||
/>
|
|
||||||
{uploadedImages.length > 0 && (
|
|
||||||
<div className="mt-4 text-description text-xs">
|
|
||||||
تصویر های آپلود شده
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ImageUpload;
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,59 @@
|
|||||||
const StickerInstruction = () => (
|
import { useMemo } from "react";
|
||||||
<div className="text-sm text-gray-600">
|
import { useGetIconGroups } from "@/pages/editor/hooks/useIconData";
|
||||||
<p>برای افزودن استیکر، روی کانوس کلیک کنید</p>
|
import { STICKER_DRAG_MIME } from "@/pages/editor/types/IconTypes";
|
||||||
|
|
||||||
|
const StickerInstruction = () => {
|
||||||
|
const { data, isLoading, isError } = useGetIconGroups();
|
||||||
|
|
||||||
|
const stickers = useMemo(() => {
|
||||||
|
const groups = data?.data ?? [];
|
||||||
|
return groups.flatMap((group) => group.icons);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const handleDragStart = (e: React.DragEvent<HTMLImageElement>, url: string) => {
|
||||||
|
e.dataTransfer.setData(STICKER_DRAG_MIME, url);
|
||||||
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="text-base font-bold">استیکر</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
استیکر را بکشید و روی صفحه رها کنید
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<p className="text-sm text-gray-500">در حال بارگذاری استیکرها...</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<p className="text-sm text-red-500">خطا در بارگذاری استیکرها</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && stickers.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500">استیکری یافت نشد</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stickers.length > 0 && (
|
||||||
|
<div className="grid grid-cols-3 gap-2 max-h-[320px] overflow-y-auto">
|
||||||
|
{stickers.map((icon) => (
|
||||||
|
<div
|
||||||
|
key={icon.id}
|
||||||
|
className="flex aspect-square items-center justify-center rounded-lg border border-gray-200 bg-gray-50 p-1 hover:border-blue-300 hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={icon.url}
|
||||||
|
alt=""
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, icon.url)}
|
||||||
|
className="max-h-full max-w-full object-contain cursor-grab active:cursor-grabbing"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default StickerInstruction;
|
export default StickerInstruction;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { default as ImageGallery } from "./ImageGallery";
|
||||||
export { default as ImageUpload } from "./ImageUpload";
|
export { default as ImageUpload } from "./ImageUpload";
|
||||||
export { default as DocumentUpload } from "./DocumentUpload";
|
export { default as DocumentUpload } from "./DocumentUpload";
|
||||||
export { default as VideoInput } from "./VideoInput";
|
export { default as VideoInput } from "./VideoInput";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Image as KonvaImage } from "react-konva";
|
|||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
|
import { clampPositionToStage } from "../../utils/stageBounds";
|
||||||
|
|
||||||
const ImageObject = ({
|
const ImageObject = ({
|
||||||
obj,
|
obj,
|
||||||
@@ -10,6 +11,8 @@ const ImageObject = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
draggable,
|
draggable,
|
||||||
|
stageWidth,
|
||||||
|
stageHeight,
|
||||||
}: ShapeProps) => {
|
}: ShapeProps) => {
|
||||||
const [image] = useImage(obj.imageUrl || "");
|
const [image] = useImage(obj.imageUrl || "");
|
||||||
const shapeRef = useRef<Konva.Image>(null);
|
const shapeRef = useRef<Konva.Image>(null);
|
||||||
@@ -18,32 +21,58 @@ const ImageObject = ({
|
|||||||
|
|
||||||
if (!image) return null;
|
if (!image) return null;
|
||||||
|
|
||||||
|
const width = obj.width || image.width;
|
||||||
|
const height = obj.height || image.height;
|
||||||
|
const hasStageBounds = stageWidth != null && stageHeight != null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<KonvaImage
|
<KonvaImage
|
||||||
ref={shapeRef}
|
ref={shapeRef}
|
||||||
id={obj.id}
|
id={obj.id}
|
||||||
|
name="canvas-object"
|
||||||
x={obj.x}
|
x={obj.x}
|
||||||
y={obj.y}
|
y={obj.y}
|
||||||
image={image}
|
image={image}
|
||||||
width={obj.width || image.width}
|
width={width}
|
||||||
height={obj.height || image.height}
|
height={height}
|
||||||
// اگر draggable=false باشد، یعنی mask اعمال شده و نباید stroke نمایش داده شود (Group stroke دارد)
|
stroke={isSelected ? "#3b82f6" : undefined}
|
||||||
stroke={isSelected && draggable ? "#3b82f6" : undefined}
|
strokeWidth={isSelected ? 3 : 0}
|
||||||
strokeWidth={isSelected && draggable ? 3 : 0}
|
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onClick={draggable ? (e) => {
|
dragBoundFunc={
|
||||||
// فقط وقتی mask اعمال نشده onClick handler را فعال کن
|
draggable && hasStageBounds
|
||||||
|
? (pos) =>
|
||||||
|
clampPositionToStage(
|
||||||
|
pos.x,
|
||||||
|
pos.y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stageWidth!,
|
||||||
|
stageHeight!,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={(e) => {
|
||||||
if (shapeRef.current) {
|
if (shapeRef.current) {
|
||||||
onSelect(obj.id, shapeRef.current, e);
|
onSelect(obj.id, shapeRef.current, e);
|
||||||
}
|
}
|
||||||
} : undefined}
|
}}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
const node = e.target;
|
const node = e.target;
|
||||||
onUpdate(obj.id, {
|
let x = node.x();
|
||||||
x: node.x(),
|
let y = node.y();
|
||||||
y: node.y(),
|
if (hasStageBounds) {
|
||||||
});
|
({ x, y } = clampPositionToStage(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
stageWidth!,
|
||||||
|
stageHeight!,
|
||||||
|
));
|
||||||
|
node.position({ x, y });
|
||||||
|
}
|
||||||
|
onUpdate(obj.id, { x, y });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export type ShapeProps = {
|
|||||||
onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||||
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||||
draggable: boolean;
|
draggable: boolean;
|
||||||
|
stageWidth?: number;
|
||||||
|
stageHeight?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TextShapeProps = ShapeProps & {
|
export type TextShapeProps = ShapeProps & {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import * as api from "../service/IconService";
|
||||||
|
|
||||||
|
export const useGetIconGroups = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["icon-groups"],
|
||||||
|
queryFn: api.getIconGroups,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import axios from "@/config/axios";
|
||||||
|
import type { IconGroupsResponse } from "../types/IconTypes";
|
||||||
|
|
||||||
|
export const getIconGroups = async () => {
|
||||||
|
const { data } = await axios.get<IconGroupsResponse>("/admin/groups/icons");
|
||||||
|
return data;
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ import type {
|
|||||||
TableData,
|
TableData,
|
||||||
ToolType,
|
ToolType,
|
||||||
DocumentSettings,
|
DocumentSettings,
|
||||||
|
GalleryItem,
|
||||||
} from "./editorStore.types";
|
} from "./editorStore.types";
|
||||||
|
|
||||||
const MAX_OBJECT_HISTORY = 50;
|
const MAX_OBJECT_HISTORY = 50;
|
||||||
@@ -56,6 +57,7 @@ export type {
|
|||||||
TableData,
|
TableData,
|
||||||
ToolType,
|
ToolType,
|
||||||
DocumentSettings,
|
DocumentSettings,
|
||||||
|
GalleryItem,
|
||||||
DisplayStyle,
|
DisplayStyle,
|
||||||
BackgroundType,
|
BackgroundType,
|
||||||
EntranceAnimationType,
|
EntranceAnimationType,
|
||||||
@@ -149,7 +151,9 @@ type EditorStoreType = {
|
|||||||
loadPages: (
|
loadPages: (
|
||||||
pages: Page[],
|
pages: Page[],
|
||||||
documentSettings?: Partial<DocumentSettings>,
|
documentSettings?: Partial<DocumentSettings>,
|
||||||
|
gallery?: GalleryItem[],
|
||||||
) => void;
|
) => void;
|
||||||
|
resetEditor: () => void;
|
||||||
clipboardObjects: EditorObject[];
|
clipboardObjects: EditorObject[];
|
||||||
objectHistoryPast: EditorObject[][];
|
objectHistoryPast: EditorObject[][];
|
||||||
objectHistoryFuture: EditorObject[][];
|
objectHistoryFuture: EditorObject[][];
|
||||||
@@ -165,6 +169,26 @@ type EditorStoreType = {
|
|||||||
updateCurrentPageBackground: (
|
updateCurrentPageBackground: (
|
||||||
updates: Partial<PageBackgroundSettings>,
|
updates: Partial<PageBackgroundSettings>,
|
||||||
) => void;
|
) => void;
|
||||||
|
gallery: GalleryItem[];
|
||||||
|
addGalleryItem: (item: GalleryItem) => void;
|
||||||
|
removeGalleryItem: (id: string) => void;
|
||||||
|
setGallery: (items: GalleryItem[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
|
||||||
|
displayStyle: "double",
|
||||||
|
autoPlay: false,
|
||||||
|
pageFlipSound: true,
|
||||||
|
leftToRightFlip: false,
|
||||||
|
smartGuide: true,
|
||||||
|
backgroundType: "color",
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
backgroundGradient: {
|
||||||
|
from: "#ffffff",
|
||||||
|
to: "#e2e8f0",
|
||||||
|
angle: 135,
|
||||||
|
},
|
||||||
|
backgroundImageUrl: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useEditorStore = create<EditorStoreType>((set, get) => {
|
export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||||
@@ -209,21 +233,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documentSettings: {
|
documentSettings: { ...DEFAULT_DOCUMENT_SETTINGS },
|
||||||
displayStyle: "double",
|
|
||||||
autoPlay: false,
|
|
||||||
pageFlipSound: true,
|
|
||||||
leftToRightFlip: false,
|
|
||||||
smartGuide: true,
|
|
||||||
backgroundType: "color",
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
backgroundGradient: {
|
|
||||||
from: "#ffffff",
|
|
||||||
to: "#e2e8f0",
|
|
||||||
angle: 135,
|
|
||||||
},
|
|
||||||
backgroundImageUrl: "",
|
|
||||||
},
|
|
||||||
updateDocumentSettings: (updates) =>
|
updateDocumentSettings: (updates) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
documentSettings: { ...state.documentSettings, ...updates },
|
documentSettings: { ...state.documentSettings, ...updates },
|
||||||
@@ -242,6 +252,14 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
gallery: [],
|
||||||
|
addGalleryItem: (item) =>
|
||||||
|
set((state) => ({ gallery: [...state.gallery, item] })),
|
||||||
|
removeGalleryItem: (id) =>
|
||||||
|
set((state) => ({
|
||||||
|
gallery: state.gallery.filter((item) => item.id !== id),
|
||||||
|
})),
|
||||||
|
setGallery: (items) => set({ gallery: items }),
|
||||||
tool: "select",
|
tool: "select",
|
||||||
setTool: (tool) =>
|
setTool: (tool) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -984,7 +1002,22 @@ 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, documentSettings) => {
|
resetEditor: () => {
|
||||||
|
const page = createInitialPage("جلد");
|
||||||
|
set({
|
||||||
|
pages: [page],
|
||||||
|
currentPageId: page.id,
|
||||||
|
objects: page.objects,
|
||||||
|
guides: [],
|
||||||
|
selectedObjectId: null,
|
||||||
|
selectedObjectIds: [],
|
||||||
|
objectHistoryPast: [],
|
||||||
|
objectHistoryFuture: [],
|
||||||
|
documentSettings: { ...DEFAULT_DOCUMENT_SETTINGS },
|
||||||
|
gallery: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
loadPages: (pages, documentSettings, gallery) => {
|
||||||
if (pages.length === 0) return;
|
if (pages.length === 0) return;
|
||||||
const fallbackSettings = documentSettings ?? {};
|
const fallbackSettings = documentSettings ?? {};
|
||||||
const normalizedPages = pages.map((page) => ({
|
const normalizedPages = pages.map((page) => ({
|
||||||
@@ -1019,6 +1052,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
...(documentSettings && {
|
...(documentSettings && {
|
||||||
documentSettings: { ...currentSettings, ...documentSettings },
|
documentSettings: { ...currentSettings, ...documentSettings },
|
||||||
}),
|
}),
|
||||||
|
...(gallery !== undefined && { gallery }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -120,3 +120,11 @@ export type DocumentSettings = {
|
|||||||
backgroundGradient?: LinearGradient;
|
backgroundGradient?: LinearGradient;
|
||||||
backgroundImageUrl: string;
|
backgroundImageUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** تصویر آپلودشده در گالری کاتالوگ (مشترک بین همهٔ صفحات) */
|
||||||
|
export type GalleryItem = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const GALLERY_DRAG_MIME = "application/x-dpage-gallery";
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { BaseResponse } from "@/shared/types/SharedTypes";
|
||||||
|
|
||||||
|
export type IconItem = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
url: string;
|
||||||
|
group: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IconGroup = {
|
||||||
|
id: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
name: string;
|
||||||
|
icons: IconItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IconGroupsResponse = BaseResponse<IconGroup[]>;
|
||||||
|
|
||||||
|
export const STICKER_DRAG_MIME = "application/x-dpage-sticker";
|
||||||
@@ -117,7 +117,7 @@ const drawObject = async (
|
|||||||
ctx.scale(object.scaleX || 1, object.scaleY || 1);
|
ctx.scale(object.scaleX || 1, object.scaleY || 1);
|
||||||
|
|
||||||
// برای image objects
|
// برای image objects
|
||||||
if (object.type === "image" || object.type === "document") {
|
if (object.type === "image" || object.type === "document" || object.type === "sticker") {
|
||||||
if (object.imageUrl) {
|
if (object.imageUrl) {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = "anonymous";
|
img.crossOrigin = "anonymous";
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ToolType } from "../store/editorStore.types";
|
||||||
|
import { clampPositionToStage, fitSizeToStage } from "./stageBounds";
|
||||||
|
|
||||||
|
const IMAGE_MAX_SIZE = 400;
|
||||||
|
|
||||||
|
export const scaleImageDimensions = (
|
||||||
|
naturalWidth: number,
|
||||||
|
naturalHeight: number,
|
||||||
|
maxSize = IMAGE_MAX_SIZE,
|
||||||
|
) => {
|
||||||
|
let width = naturalWidth;
|
||||||
|
let height = naturalHeight;
|
||||||
|
|
||||||
|
if (width > maxSize || height > maxSize) {
|
||||||
|
const ratio = Math.min(maxSize / width, maxSize / height);
|
||||||
|
width = Math.round(width * ratio);
|
||||||
|
height = Math.round(height * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createImageObject = (
|
||||||
|
imageUrl: string,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
stageWidth?: number,
|
||||||
|
stageHeight?: number,
|
||||||
|
) => {
|
||||||
|
let w = width;
|
||||||
|
let h = height;
|
||||||
|
if (stageWidth && stageHeight) {
|
||||||
|
({ width: w, height: h } = fitSizeToStage(w, h, stageWidth, stageHeight));
|
||||||
|
}
|
||||||
|
let x = Math.round(centerX - w / 2);
|
||||||
|
let y = Math.round(centerY - h / 2);
|
||||||
|
if (stageWidth && stageHeight) {
|
||||||
|
({ x, y } = clampPositionToStage(x, y, w, h, stageWidth, stageHeight));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `image-${Date.now()}`,
|
||||||
|
type: "image" as ToolType,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
imageUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const fitSizeToStage = (
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
stageWidth: number,
|
||||||
|
stageHeight: number,
|
||||||
|
) => {
|
||||||
|
if (width <= stageWidth && height <= stageHeight) {
|
||||||
|
return { width, height };
|
||||||
|
}
|
||||||
|
const ratio = Math.min(stageWidth / width, stageHeight / height);
|
||||||
|
return {
|
||||||
|
width: Math.round(width * ratio),
|
||||||
|
height: Math.round(height * ratio),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clampPositionToStage = (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
stageWidth: number,
|
||||||
|
stageHeight: number,
|
||||||
|
) => ({
|
||||||
|
x: Math.max(0, Math.min(stageWidth - width, Math.round(x))),
|
||||||
|
y: Math.max(0, Math.min(stageHeight - height, Math.round(y))),
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { ToolType } from "../store/editorStore.types";
|
||||||
|
import { clampPositionToStage, fitSizeToStage } from "./stageBounds";
|
||||||
|
|
||||||
|
const STICKER_MAX_SIZE = 120;
|
||||||
|
|
||||||
|
export const scaleStickerDimensions = (
|
||||||
|
naturalWidth: number,
|
||||||
|
naturalHeight: number,
|
||||||
|
maxSize = STICKER_MAX_SIZE,
|
||||||
|
) => {
|
||||||
|
let width = naturalWidth;
|
||||||
|
let height = naturalHeight;
|
||||||
|
|
||||||
|
if (width > maxSize || height > maxSize) {
|
||||||
|
const ratio = Math.min(maxSize / width, maxSize / height);
|
||||||
|
width = Math.round(width * ratio);
|
||||||
|
height = Math.round(height * ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { width, height };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createStickerObject = (
|
||||||
|
imageUrl: string,
|
||||||
|
centerX: number,
|
||||||
|
centerY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
stageWidth?: number,
|
||||||
|
stageHeight?: number,
|
||||||
|
) => {
|
||||||
|
let w = width;
|
||||||
|
let h = height;
|
||||||
|
if (stageWidth && stageHeight) {
|
||||||
|
({ width: w, height: h } = fitSizeToStage(w, h, stageWidth, stageHeight));
|
||||||
|
}
|
||||||
|
let x = Math.round(centerX - w / 2);
|
||||||
|
let y = Math.round(centerY - h / 2);
|
||||||
|
if (stageWidth && stageHeight) {
|
||||||
|
({ x, y } = clampPositionToStage(x, y, w, h, stageWidth, stageHeight));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: `sticker-${Date.now()}`,
|
||||||
|
type: "sticker" as ToolType,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
imageUrl,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -144,7 +144,7 @@ export const getEffectiveTextWidth = (
|
|||||||
return Math.max(1, base + SINGLE_LINE_WIDTH_SLACK_PX);
|
return Math.max(1, base + SINGLE_LINE_WIDTH_SLACK_PX);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Scaled width for viewer text (wrapped / multi-line layout only). */
|
/** Scaled width for viewer text boxes (includes slack to avoid tighter CSS wrapping than Konva). */
|
||||||
export const getScaledTextWidth = (
|
export const getScaledTextWidth = (
|
||||||
width: number | undefined,
|
width: number | undefined,
|
||||||
scale: number,
|
scale: number,
|
||||||
@@ -152,3 +152,68 @@ export const getScaledTextWidth = (
|
|||||||
if (!width) return undefined;
|
if (!width) return undefined;
|
||||||
return Math.ceil(width * scale) + Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale);
|
return Math.ceil(width * scale) + Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TextAlignValue = "left" | "center" | "right";
|
||||||
|
|
||||||
|
export type ViewerTextLayout = {
|
||||||
|
leftPx: number;
|
||||||
|
widthPx: number | undefined;
|
||||||
|
textAlign: TextAlignValue;
|
||||||
|
transform: string | undefined;
|
||||||
|
transformOrigin: "top right";
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Viewer HTML layout matching Konva TextShape: (x, y) is the top-right anchor of the text box.
|
||||||
|
* Text alignment is applied inside a fixed-width box that extends left from the anchor.
|
||||||
|
*/
|
||||||
|
export function getViewerTextLayout(args: {
|
||||||
|
x: number;
|
||||||
|
width?: number;
|
||||||
|
text?: string;
|
||||||
|
textAlign?: TextAlignValue;
|
||||||
|
rotation?: number;
|
||||||
|
scale: number;
|
||||||
|
measureOpts: TextMeasureOptions;
|
||||||
|
wrapped: boolean;
|
||||||
|
}): ViewerTextLayout {
|
||||||
|
const {
|
||||||
|
x,
|
||||||
|
width: storedWidth,
|
||||||
|
text,
|
||||||
|
textAlign = "right",
|
||||||
|
rotation = 0,
|
||||||
|
scale,
|
||||||
|
measureOpts,
|
||||||
|
wrapped,
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
const layoutWidth = getEffectiveTextWidth(
|
||||||
|
storedWidth,
|
||||||
|
text,
|
||||||
|
measureOpts,
|
||||||
|
!wrapped,
|
||||||
|
);
|
||||||
|
const widthPx = getScaledTextWidth(layoutWidth, scale);
|
||||||
|
const slackPx =
|
||||||
|
widthPx !== undefined ? Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale) : 0;
|
||||||
|
const rightPx = x * scale;
|
||||||
|
const boxWidthUnscaled = layoutWidth ?? storedWidth ?? 0;
|
||||||
|
|
||||||
|
let leftPx: number;
|
||||||
|
if (widthPx !== undefined && boxWidthUnscaled > 0) {
|
||||||
|
leftPx = Math.round(rightPx - boxWidthUnscaled * scale) - slackPx;
|
||||||
|
} else {
|
||||||
|
leftPx = Math.round(rightPx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotationTransform = rotation ? `rotate(${rotation}deg)` : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
leftPx,
|
||||||
|
widthPx,
|
||||||
|
textAlign,
|
||||||
|
transform: rotationTransform,
|
||||||
|
transformOrigin: "top right",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+44
-52
@@ -9,20 +9,29 @@ import { useCreateCatalog } from './hooks/useHomeData'
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Paths } from '@/config/Paths'
|
import { Paths } from '@/config/Paths'
|
||||||
import DirectLogin from '../auth/components/DirectLogin'
|
import DirectLogin from '../auth/components/DirectLogin'
|
||||||
import { extractErrorMessage } from '@/helpers/utils'
|
import { clx, extractErrorMessage } from '@/helpers/utils'
|
||||||
|
|
||||||
|
const CATALOG_SIZES = [
|
||||||
|
{ id: 'a4', label: 'A4' },
|
||||||
|
{ id: 'a3', label: 'A3' },
|
||||||
|
{ id: 'a5', label: 'A5' },
|
||||||
|
] as const
|
||||||
|
|
||||||
const Home: FC = () => {
|
const Home: FC = () => {
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [active, setActive] = useState<string>('a4')
|
const [active, setActive] = useState<string>('a4')
|
||||||
const [name, setName] = useState<string>('')
|
const [name, setName] = useState<string>('')
|
||||||
|
const [slug, setSlug] = useState<string>('')
|
||||||
const { mutate: createCatalog, isPending } = useCreateCatalog()
|
const { mutate: createCatalog, isPending } = useCreateCatalog()
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!name) {
|
if (!name) {
|
||||||
toast('نام کاتالوگ را وارد کنید', 'error')
|
toast('نام کاتالوگ را وارد کنید', 'error')
|
||||||
|
} else if (!slug) {
|
||||||
|
toast('اسلاگ کاتالوگ را وارد کنید', 'error')
|
||||||
} else {
|
} else {
|
||||||
createCatalog({ name: name, size: active, content: '' }, {
|
createCatalog({ name, slug, size: active, content: '' }, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
toast('کاتالوگ با موفقیت ساخته شد', 'success')
|
toast('کاتالوگ با موفقیت ساخته شد', 'success')
|
||||||
navigate(Paths.editor + `/${data?.data?.id}`)
|
navigate(Paths.editor + `/${data?.data?.id}`)
|
||||||
@@ -36,75 +45,58 @@ const Home: FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-4 w-full'>
|
<div className='mt-4 w-full'>
|
||||||
<div className='flex justify-between'>
|
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
<h1 className='text-lg font-light'>
|
<h1 className='text-base font-light sm:text-lg'>
|
||||||
ساخت کاتالوگ ب ویرایشگر داناک
|
ساخت کاتالوگ ب ویرایشگر داناک
|
||||||
</h1>
|
</h1>
|
||||||
<DirectLogin />
|
<DirectLogin />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-20 bg-white rounded-4xl p-10'>
|
<div className='mt-8 rounded-2xl bg-white p-4 sm:mt-12 sm:rounded-3xl sm:p-6 lg:mt-20 lg:rounded-4xl lg:p-10'>
|
||||||
<div className='text-center'>
|
<div className='mx-auto max-w-xl px-1 text-center text-sm leading-relaxed sm:text-base'>
|
||||||
سایز و نام مورد نظر را انتخاب کنبد و یک کاتالوگ زیبا با ویرایشگر داناک بسازید
|
سایز و نام مورد نظر را انتخاب کنبد و یک کاتالوگ زیبا با ویرایشگر داناک بسازید
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className='mt-8 flex justify-center gap-8'>
|
<div className='mt-6 flex flex-wrap justify-center gap-4 sm:mt-8 sm:gap-6 lg:gap-8'>
|
||||||
<div onClick={() => setActive('a4')} className={`
|
{CATALOG_SIZES.map(({ id, label }) => (
|
||||||
w-[160px] h-[166px] rounded-[20px] border border-border flex items-center justify-center
|
<button
|
||||||
${active === 'a4' && 'border-black!'}
|
key={id}
|
||||||
`}>
|
type='button'
|
||||||
|
onClick={() => setActive(id)}
|
||||||
|
className={clx(
|
||||||
|
'flex w-full h-[126px] sm:w-[120px] cursor-pointer items-center justify-center rounded-[20px] border border-border sm:h-[146px] sm:w-[140px] lg:h-[166px] lg:w-[160px]',
|
||||||
|
active === id && 'border-black!',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className='size-[94px] bg-[#EEF0F7] rounded flex justify-center items-center'>
|
<div className='flex size-[72px] items-center justify-center rounded bg-[#EEF0F7] sm:size-[84px] lg:size-[94px]'>
|
||||||
<DocumentText size={24} color='black' />
|
<DocumentText size={24} color='black' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-3 text-center text-sm'>
|
<div className='mt-3 text-center text-sm'>
|
||||||
A4
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
<div onClick={() => setActive('a3')} className={`
|
))}
|
||||||
w-[160px] h-[166px] rounded-[20px] border border-border flex items-center justify-center
|
|
||||||
${active === 'a3' && 'border-black!'}
|
|
||||||
`}>
|
|
||||||
<div>
|
|
||||||
<div className='size-[94px] bg-[#EEF0F7] rounded flex justify-center items-center'>
|
|
||||||
<DocumentText size={24} color='black' />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-3 text-center text-sm'>
|
<div className='mx-auto mt-5 flex w-full max-w-md flex-col gap-4 px-1 sm:px-0'>
|
||||||
A3
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div onClick={() => setActive('a5')} className={`
|
|
||||||
w-[160px] h-[166px] rounded-[20px] border border-border flex items-center justify-center
|
|
||||||
${active === 'a5' && 'border-black!'}
|
|
||||||
`}>
|
|
||||||
<div>
|
|
||||||
<div className='size-[94px] bg-[#EEF0F7] rounded flex justify-center items-center'>
|
|
||||||
<DocumentText size={24} color='black' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-3 text-center text-sm'>
|
|
||||||
A5
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='mt-5'>
|
|
||||||
<Input
|
<Input
|
||||||
label='نام کاتالوگ'
|
label='نام کاتالوگ'
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
label='اسلاگ'
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => setSlug(e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex justify-center'>
|
<div className='flex justify-center'>
|
||||||
<Button disabled={isPending} onClick={handleSubmit} className='mt-8 rounded-xl'>
|
<Button disabled={isPending} onClick={handleSubmit} className='mt-6 w-full max-w-md rounded-xl sm:mt-8 sm:w-auto'>
|
||||||
<div className='flex gap-2 items-center'>
|
<div className='flex items-center justify-center gap-2'>
|
||||||
ادامه
|
ادامه
|
||||||
<ArrowLeft size={16} color='white' />
|
<ArrowLeft size={16} color='white' />
|
||||||
</div>
|
</div>
|
||||||
@@ -112,16 +104,16 @@ const Home: FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider label='یا' className='mt-10' />
|
<Divider label='یا' className='mt-8 sm:mt-10' />
|
||||||
|
|
||||||
<div className='mt-20 flex justify-center'>
|
<div className='mt-12 flex justify-center sm:mt-16 lg:mt-20'>
|
||||||
<div className='w-[183px] h-[193px] rounded-[18px] border flex flex-col items-center justify-center'>
|
<div className='flex h-[160px] w-[150px] flex-col items-center justify-center rounded-[18px] border sm:h-[193px] sm:w-[183px]'>
|
||||||
<img src={PdfIcon} alt='upload' className='w-12' />
|
<img src={PdfIcon} alt='upload' className='w-10 sm:w-12' />
|
||||||
<div className='text-sm mt-3'>
|
<div className='mt-3 text-sm'>
|
||||||
آپلود PDF
|
آپلود PDF
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='h-5 px-5 rounded-full flex items-center bg-[#D4EDDE] text-[#01973D] text-[10px] mt-3'>
|
<div className='mt-3 flex h-5 items-center rounded-full bg-[#D4EDDE] px-5 text-[10px] text-[#01973D]'>
|
||||||
رایگان
|
رایگان
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export const useGetCatalogById = (id: string) => {
|
|||||||
queryKey: ["catalog", id],
|
queryKey: ["catalog", id],
|
||||||
queryFn: () => api.getCatalogById(id),
|
queryFn: () => api.getCatalogById(id),
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
|
// جلوگیری از بازنویسی state محلی با refetch هنگام فوکوس پنجره (مثلاً دو کاربر همزمان)
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -38,6 +41,15 @@ export const useUpdateCatalog = () => {
|
|||||||
const pendingParamsRef = useRef<Parameters<typeof api.updateCatalog>[0] | null>(null);
|
const pendingParamsRef = useRef<Parameters<typeof api.updateCatalog>[0] | null>(null);
|
||||||
const [isScheduledSaving, setIsScheduledSaving] = useState(false);
|
const [isScheduledSaving, setIsScheduledSaving] = useState(false);
|
||||||
|
|
||||||
|
const cancelPendingUpdate = useCallback(() => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
pendingParamsRef.current = null;
|
||||||
|
setIsScheduledSaving(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const scheduleUpdate = useCallback(
|
const scheduleUpdate = useCallback(
|
||||||
(params: Parameters<typeof api.updateCatalog>[0], delayMs = 3000) => {
|
(params: Parameters<typeof api.updateCatalog>[0], delayMs = 3000) => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
@@ -79,6 +91,7 @@ export const useUpdateCatalog = () => {
|
|||||||
return {
|
return {
|
||||||
...mutation,
|
...mutation,
|
||||||
scheduleUpdate,
|
scheduleUpdate,
|
||||||
|
cancelPendingUpdate,
|
||||||
isSaving: isScheduledSaving || mutation.isPending,
|
isSaving: isScheduledSaving || mutation.isPending,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import axios from "@/config/axios";
|
import axios from "@/config/axios";
|
||||||
import type {
|
|
||||||
CreateCatalogParamsType,
|
|
||||||
UpdateCatalogParamsType,
|
|
||||||
} from "../types/Types";
|
|
||||||
import type {
|
import type {
|
||||||
CatalogByIdResponseType,
|
CatalogByIdResponseType,
|
||||||
CatalogResponseType,
|
CatalogResponseType,
|
||||||
} from "@/pages/catalogue/types/Types";
|
} from "@/pages/catalogue/types/Types";
|
||||||
|
import type {
|
||||||
|
CreateCatalogParamsType,
|
||||||
|
UpdateCatalogParamsType,
|
||||||
|
} from "../types/Types";
|
||||||
|
|
||||||
export const createCatalog = async (params: CreateCatalogParamsType) => {
|
export const createCatalog = async (params: CreateCatalogParamsType) => {
|
||||||
const { data } = await axios.post("/admin/catalogue", params);
|
const { data } = await axios.post("/admin/catalogue", params);
|
||||||
@@ -20,7 +20,7 @@ export const getCatalogs = async () => {
|
|||||||
|
|
||||||
export const getCatalogById = async (id: string) => {
|
export const getCatalogById = async (id: string) => {
|
||||||
const { data } = await axios.get<CatalogByIdResponseType>(
|
const { data } = await axios.get<CatalogByIdResponseType>(
|
||||||
`/admin/catalogue/${id}`,
|
`/public/catalogue/${id}`,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export type CreateCatalogParamsType = {
|
export type CreateCatalogParamsType = {
|
||||||
name: string;
|
name: string;
|
||||||
|
slug: string;
|
||||||
size: string;
|
size: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { PageData } from './types';
|
|||||||
import type { DocumentSettings } from '@/pages/editor/store/editorStore';
|
import type { DocumentSettings } from '@/pages/editor/store/editorStore';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useGetCatalogById } from '@/pages/home/hooks/useHomeData';
|
import { useGetCatalogById } from '@/pages/home/hooks/useHomeData';
|
||||||
|
import { useBrowserFullscreen } from './hooks/useBrowserFullscreen';
|
||||||
|
|
||||||
const Viewer: FC = () => {
|
const Viewer: FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
@@ -30,6 +31,8 @@ const Viewer: FC = () => {
|
|||||||
}
|
}
|
||||||
}, [data?.data?.content, id]);
|
}, [data?.data?.content, id]);
|
||||||
|
|
||||||
|
useBrowserFullscreen({ enabled: Boolean(id) });
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
@@ -63,7 +66,7 @@ const Viewer: FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full min-h-0 flex flex-col">
|
||||||
<BookViewer pages={pages} catalogSize={data?.data?.size} documentSettings={documentSettings} />
|
<BookViewer pages={pages} catalogSize={data?.data?.size} documentSettings={documentSettings} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { toCssLinearGradient } from '@/pages/editor/utils/gradient';
|
|||||||
import { getFontFamily } from '@/pages/editor/utils/fontFamily';
|
import { getFontFamily } from '@/pages/editor/utils/fontFamily';
|
||||||
import {
|
import {
|
||||||
getCssFontWeight,
|
getCssFontWeight,
|
||||||
getEffectiveTextWidth,
|
getViewerTextLayout,
|
||||||
getScaledTextWidth,
|
|
||||||
usesWrappedLayout,
|
usesWrappedLayout,
|
||||||
SINGLE_LINE_WIDTH_SLACK_PX,
|
|
||||||
} from '@/pages/editor/utils/textStyle';
|
} from '@/pages/editor/utils/textStyle';
|
||||||
import '@/pages/viewer/styles/entranceAnimations.css';
|
import '@/pages/viewer/styles/entranceAnimations.css';
|
||||||
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
||||||
@@ -131,51 +129,41 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
letterSpacing: obj.letterSpacing ?? 0,
|
letterSpacing: obj.letterSpacing ?? 0,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
};
|
};
|
||||||
const layoutWidth = wrapped
|
const textLayout = getViewerTextLayout({
|
||||||
? getEffectiveTextWidth(obj.width, obj.text, measureOpts, false)
|
x: obj.x || 0,
|
||||||
: undefined;
|
width: obj.width,
|
||||||
const textWidth = wrapped ? getScaledTextWidth(layoutWidth, scale) : undefined;
|
text: obj.text,
|
||||||
// getScaledTextWidth adds SINGLE_LINE_WIDTH_SLACK_PX to prevent unwanted CSS wrapping.
|
textAlign: obj.textAlign,
|
||||||
// Compensate by shifting textLeft left by the same amount so the right edge stays at obj.x * scale.
|
rotation,
|
||||||
const textSlackPx = textWidth !== undefined ? Math.ceil(SINGLE_LINE_WIDTH_SLACK_PX * scale) : 0;
|
scale,
|
||||||
const textLeft = wrapped
|
measureOpts,
|
||||||
? Math.round(((obj.x || 0) - (layoutWidth || obj.width || 0)) * scale) - textSlackPx
|
wrapped,
|
||||||
: Math.round((obj.x || 0) * scale);
|
});
|
||||||
const textTransform = wrapped
|
|
||||||
? rotation
|
|
||||||
? `rotate(${rotation}deg)`
|
|
||||||
: undefined
|
|
||||||
: rotation
|
|
||||||
? `rotate(${rotation}deg) translateX(-100%)`
|
|
||||||
: 'translateX(-100%)';
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={obj.id || index}
|
key={obj.id || index}
|
||||||
style={withEntrance(
|
style={withEntrance(
|
||||||
{
|
{
|
||||||
...textBaseStyle,
|
...textBaseStyle,
|
||||||
left: `${textLeft}px`,
|
left: `${textLayout.leftPx}px`,
|
||||||
width: wrapped ? (textWidth ? `${textWidth}px` : undefined) : 'max-content',
|
width: textLayout.widthPx !== undefined ? `${textLayout.widthPx}px` : 'max-content',
|
||||||
maxWidth: wrapped ? undefined : 'none',
|
|
||||||
fontSize: `${fontSize * scale}px`,
|
fontSize: `${fontSize * scale}px`,
|
||||||
fontFamily: getFontFamily(obj.fontFamily),
|
fontFamily: getFontFamily(obj.fontFamily),
|
||||||
fontWeight: getCssFontWeight(obj.fontWeight),
|
fontWeight: getCssFontWeight(obj.fontWeight),
|
||||||
lineHeight,
|
lineHeight,
|
||||||
textAlign: obj.textAlign ?? 'right',
|
textAlign: textLayout.textAlign,
|
||||||
direction: 'rtl',
|
direction: 'rtl',
|
||||||
color: getColorWithOpacity(obj.fill, obj.opacity),
|
color: getColorWithOpacity(obj.fill, obj.opacity),
|
||||||
whiteSpace: wrapped ? 'pre-wrap' : 'nowrap',
|
whiteSpace: wrapped ? 'pre-wrap' : 'nowrap',
|
||||||
overflowWrap: wrapped ? 'break-word' : 'normal',
|
overflowWrap: wrapped ? 'break-word' : 'normal',
|
||||||
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
|
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
|
||||||
boxSizing: 'content-box',
|
boxSizing: 'content-box',
|
||||||
transform: textTransform,
|
transform: textLayout.transform,
|
||||||
transformOrigin: wrapped ? 'top left' : 'top right',
|
transformOrigin: textLayout.transformOrigin,
|
||||||
},
|
},
|
||||||
obj,
|
obj,
|
||||||
index,
|
index,
|
||||||
wrapped
|
{ rotationDeg: rotation, transformOrigin: textLayout.transformOrigin },
|
||||||
? { rotationDeg: rotation, transformOrigin: 'top left' }
|
|
||||||
: { rotationDeg: rotation, transformOrigin: 'top right' },
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{obj.text || ''}
|
{obj.text || ''}
|
||||||
@@ -184,6 +172,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'image':
|
case 'image':
|
||||||
|
case 'sticker':
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
key={obj.id || index}
|
key={obj.id || index}
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
import { type FC, useRef, useCallback, useState, useEffect, useLayoutEffect, useMemo, useSyncExternalStore } from 'react';
|
import { type FC, useRef, useCallback, useState, useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||||
import HTMLFlipBook from 'react-pageflip';
|
import HTMLFlipBook from 'react-pageflip';
|
||||||
import { type PageData } from '../types';
|
import { type PageData } from '../types';
|
||||||
import BookPage from './BookPage';
|
import BookPage from './BookPage';
|
||||||
import { ArrowLeft2, ArrowRight2, Pause, Play } from 'iconsax-react';
|
import { ArrowLeft2, ArrowRight2, Maximize4, Pause, Play, RowVertical } from 'iconsax-react';
|
||||||
import { getPaperDimensions } from '@/config/paperSizes';
|
import { getPaperDimensions } from '@/config/paperSizes';
|
||||||
import type { DocumentSettings } from '@/pages/editor/store/editorStore';
|
import type { DocumentSettings } from '@/pages/editor/store/editorStore';
|
||||||
import { useBookEntranceController } from '@/pages/viewer/hooks/useBookEntranceController';
|
import { useBookEntranceController } from '@/pages/viewer/hooks/useBookEntranceController';
|
||||||
|
import { useViewerViewport } from '@/pages/viewer/hooks/useViewerViewport';
|
||||||
|
import { useIsBrowserFullscreen } from '@/pages/viewer/hooks/useIsBrowserFullscreen';
|
||||||
|
import { toggleViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||||
|
|
||||||
/** فاصله پیشفرض بین هر ورق در پخش خودکار (میلیثانیه) */
|
/** فاصله پیشفرض بین هر ورق در پخش خودکار (میلیثانیه) */
|
||||||
const AUTO_PLAY_INTERVAL_MS = 3000;
|
const AUTO_PLAY_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
const VIEWER_SCALE = 0.5;
|
const VIEWER_SCALE = 0.5;
|
||||||
|
/** فاصلهٔ کتاب از بالای صفحه (پیکسل) */
|
||||||
|
const BOOK_TOP_GAP_DESKTOP = 48;
|
||||||
|
const BOOK_TOP_GAP_MOBILE = 32;
|
||||||
/** نسخهٔ فعلی `page-flip.mp3` دو ضربهٔ ورق پشتهم دارد؛ فقط تا این سهم از طول پخش میشود */
|
/** نسخهٔ فعلی `page-flip.mp3` دو ضربهٔ ورق پشتهم دارد؛ فقط تا این سهم از طول پخش میشود */
|
||||||
const PAGE_FLIP_SOUND_PLAY_FRACTION = 0.5;
|
const PAGE_FLIP_SOUND_PLAY_FRACTION = 0.5;
|
||||||
/** همراستا با `md` تیلویند (768px) — موبایل: زیر این عرض */
|
/** همراستا با `md` تیلویند (768px) — موبایل: زیر این عرض */
|
||||||
@@ -111,6 +117,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
const [isAutoPlayActive, setIsAutoPlayActive] = useState(false);
|
const [isAutoPlayActive, setIsAutoPlayActive] = useState(false);
|
||||||
const [startPage, setStartPage] = useState(0);
|
const [startPage, setStartPage] = useState(0);
|
||||||
const isDesktop = useIsDesktop();
|
const isDesktop = useIsDesktop();
|
||||||
|
const isBrowserFullscreen = useIsBrowserFullscreen();
|
||||||
|
|
||||||
const autoPlay = documentSettings?.autoPlay ?? false;
|
const autoPlay = documentSettings?.autoPlay ?? false;
|
||||||
const pageFlipSound = documentSettings?.pageFlipSound ?? true;
|
const pageFlipSound = documentSettings?.pageFlipSound ?? true;
|
||||||
@@ -130,54 +137,34 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
() => getPaperDimensions(catalogSize ?? 'a4'),
|
() => getPaperDimensions(catalogSize ?? 'a4'),
|
||||||
[catalogSize]
|
[catalogSize]
|
||||||
);
|
);
|
||||||
const displayWidth = Math.round(bookWidth * VIEWER_SCALE);
|
|
||||||
const displayHeight = Math.round(bookHeight * VIEWER_SCALE);
|
|
||||||
const hasMultiplePages = pages.length > 1;
|
const hasMultiplePages = pages.length > 1;
|
||||||
|
const viewport = useViewerViewport();
|
||||||
|
const usePortrait = !isDesktop || displayStyle === 'single';
|
||||||
|
|
||||||
// موبایل: از ابعاد viewport برای جا دادن کتاب در صفحه
|
const viewportFit = useMemo(() => {
|
||||||
const [viewport, setViewport] = useState(() =>
|
const padX = isDesktop ? 48 : 20;
|
||||||
typeof window !== 'undefined'
|
const edgeBleed = isDesktop ? 0 : 6;
|
||||||
? { w: window.innerWidth, h: window.innerHeight }
|
const reservedNav = isDesktop ? 96 : 120;
|
||||||
: { w: 1200, h: 800 }
|
const reservedTop = isDesktop ? BOOK_TOP_GAP_DESKTOP : BOOK_TOP_GAP_MOBILE;
|
||||||
);
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight });
|
|
||||||
onResize();
|
|
||||||
window.addEventListener('resize', onResize);
|
|
||||||
window.addEventListener('orientationchange', onResize);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', onResize);
|
|
||||||
window.removeEventListener('orientationchange', onResize);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const mobileFit = useMemo(() => {
|
|
||||||
if (isDesktop) return null;
|
|
||||||
const padX = 20;
|
|
||||||
const edgeBleed = 6;
|
|
||||||
const reservedNav = 120;
|
|
||||||
const maxW = Math.max(1, viewport.w - padX - edgeBleed * 2);
|
const maxW = Math.max(1, viewport.w - padX - edgeBleed * 2);
|
||||||
const maxH = Math.max(1, viewport.h - reservedNav - edgeBleed);
|
const maxH = Math.max(1, viewport.h - reservedNav - reservedTop - edgeBleed);
|
||||||
const baseW = bookWidth * VIEWER_SCALE;
|
const spreadCount = usePortrait ? 1 : 2;
|
||||||
const baseH = bookHeight * VIEWER_SCALE;
|
const basePageW = bookWidth * VIEWER_SCALE;
|
||||||
const scale = Math.min(1, maxW / baseW, maxH / baseH);
|
const basePageH = bookHeight * VIEWER_SCALE;
|
||||||
|
const baseSpreadW = basePageW * spreadCount;
|
||||||
|
const scale = Math.min(maxW / baseSpreadW, maxH / basePageH);
|
||||||
return {
|
return {
|
||||||
w: Math.max(1, Math.round(baseW * scale)),
|
w: Math.max(1, Math.round(basePageW * scale)),
|
||||||
h: Math.max(1, Math.round(baseH * scale)),
|
h: Math.max(1, Math.round(basePageH * scale)),
|
||||||
contentScale: VIEWER_SCALE * scale,
|
contentScale: VIEWER_SCALE * scale,
|
||||||
};
|
};
|
||||||
}, [isDesktop, bookWidth, bookHeight, viewport.w, viewport.h]);
|
}, [isDesktop, bookWidth, bookHeight, viewport.w, viewport.h, usePortrait]);
|
||||||
|
|
||||||
// دسکتاپ: دقیقاً مثل قبل — موبایل: مقیاسشده
|
const pagePixelWidth = viewportFit.w;
|
||||||
const pagePixelWidth = isDesktop ? displayWidth : (mobileFit?.w ?? displayWidth);
|
const pagePixelHeight = viewportFit.h;
|
||||||
const pagePixelHeight = isDesktop ? displayHeight : (mobileFit?.h ?? displayHeight);
|
const contentScale = viewportFit.contentScale;
|
||||||
const contentScale = isDesktop ? VIEWER_SCALE : (mobileFit?.contentScale ?? VIEWER_SCALE);
|
|
||||||
|
|
||||||
const flipKey = isDesktop
|
const flipKey = `${usePortrait ? 'portrait' : 'spread'}-${pagePixelWidth}-${pagePixelHeight}`;
|
||||||
? 'desktop'
|
|
||||||
: `mobile-${pagePixelWidth}-${pagePixelHeight}`;
|
|
||||||
|
|
||||||
const usePortrait = !isDesktop || displayStyle === 'single';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
scheduleEntranceForSpread,
|
scheduleEntranceForSpread,
|
||||||
@@ -455,9 +442,10 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
dir="rtl"
|
dir="rtl"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="relative w-full flex justify-center flex-1 max-md:min-h-0 items-center"
|
className="relative w-full flex flex-col flex-1 max-md:min-h-0 pt-8 md:pt-12 min-h-0"
|
||||||
style={{ overflow: isDesktop ? 'hidden' : 'visible' }}
|
style={{ overflow: isDesktop ? 'hidden' : 'visible' }}
|
||||||
>
|
>
|
||||||
|
<div className="relative flex flex-1 min-h-0 w-full items-center justify-center">
|
||||||
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحهای دسکتاپ) */}
|
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحهای دسکتاپ) */}
|
||||||
{!usePortrait && (
|
{!usePortrait && (
|
||||||
<div
|
<div
|
||||||
@@ -524,6 +512,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
</HTMLFlipBook>
|
</HTMLFlipBook>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 max-md:min-h-12 flex items-center gap-3 md:gap-6 bg-white rounded-full px-4 md:px-6 py-2 md:py-3 shadow-lg">
|
<div className="shrink-0 max-md:min-h-12 flex items-center gap-3 md:gap-6 bg-white rounded-full px-4 md:px-6 py-2 md:py-3 shadow-lg">
|
||||||
{hasMultiplePages && (
|
{hasMultiplePages && (
|
||||||
@@ -582,6 +571,20 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
|||||||
{hasMultiplePages && autoPlay && (
|
{hasMultiplePages && autoPlay && (
|
||||||
<AutoPlayToggleButton isActive={isAutoPlayActive} onToggle={toggleAutoPlay} />
|
<AutoPlayToggleButton isActive={isAutoPlayActive} onToggle={toggleAutoPlay} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleViewerFullscreen}
|
||||||
|
className="p-2 md:p-2 min-h-11 min-w-11 md:min-h-0 md:min-w-0 inline-flex items-center justify-center rounded-full hover:bg-gray-100 active:bg-gray-200 transition-all"
|
||||||
|
aria-label={isBrowserFullscreen ? 'خروج از تمامصفحه' : 'تمامصفحه مرورگر'}
|
||||||
|
title={isBrowserFullscreen ? 'خروج از تمامصفحه' : 'تمامصفحه مرورگر'}
|
||||||
|
>
|
||||||
|
{isBrowserFullscreen ? (
|
||||||
|
<RowVertical color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
|
||||||
|
) : (
|
||||||
|
<Maximize4 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
DEFAULT_ENTRANCE_DURATION_MS,
|
|
||||||
ENTRANCE_ANIMATION_BASE_DELAY_MS,
|
ENTRANCE_ANIMATION_BASE_DELAY_MS,
|
||||||
MAX_ENTRANCE_DELAY_MS,
|
MAX_ENTRANCE_DELAY_MS,
|
||||||
} from "@/shared/entranceAnimation";
|
} from "@/shared/entranceAnimation";
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { exitViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Leaves browser fullscreen when the viewer route unmounts. */
|
||||||
|
export function useBrowserFullscreen({ enabled }: Options) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
return () => {
|
||||||
|
exitViewerFullscreen();
|
||||||
|
};
|
||||||
|
}, [enabled]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { isViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen';
|
||||||
|
|
||||||
|
function subscribeFullscreen(callback: () => void) {
|
||||||
|
document.addEventListener('fullscreenchange', callback);
|
||||||
|
return () => document.removeEventListener('fullscreenchange', callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIsBrowserFullscreen() {
|
||||||
|
return useSyncExternalStore(subscribeFullscreen, isViewerFullscreen, () => false);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useLayoutEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type ViewerViewport = { w: number; h: number };
|
||||||
|
|
||||||
|
function readViewport(): ViewerViewport {
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
return {
|
||||||
|
w: Math.round(vv?.width ?? window.innerWidth),
|
||||||
|
h: Math.round(vv?.height ?? window.innerHeight),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tracks layout size; updates on resize, orientation, fullscreen, and visualViewport changes. */
|
||||||
|
export function useViewerViewport(): ViewerViewport {
|
||||||
|
const [viewport, setViewport] = useState<ViewerViewport>(() =>
|
||||||
|
typeof window !== 'undefined' ? readViewport() : { w: 1200, h: 800 },
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const update = () => setViewport(readViewport());
|
||||||
|
update();
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
window.addEventListener('orientationchange', update);
|
||||||
|
document.addEventListener('fullscreenchange', update);
|
||||||
|
window.visualViewport?.addEventListener('resize', update);
|
||||||
|
window.visualViewport?.addEventListener('scroll', update);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
window.removeEventListener('orientationchange', update);
|
||||||
|
document.removeEventListener('fullscreenchange', update);
|
||||||
|
window.visualViewport?.removeEventListener('resize', update);
|
||||||
|
window.visualViewport?.removeEventListener('scroll', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return viewport;
|
||||||
|
}
|
||||||
@@ -121,7 +121,7 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
|||||||
baseObject.borderRadius = obj.borderRadius;
|
baseObject.borderRadius = obj.borderRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (obj.type === "image" && obj.imageUrl) {
|
if ((obj.type === "image" || obj.type === "sticker") && obj.imageUrl) {
|
||||||
baseObject.imageUrl = obj.imageUrl;
|
baseObject.imageUrl = obj.imageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** Enter browser fullscreen (must run inside a user-gesture handler, e.g. link click). */
|
||||||
|
export function requestViewerFullscreen(): void {
|
||||||
|
if (document.fullscreenElement) return;
|
||||||
|
void document.documentElement.requestFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exitViewerFullscreen(): void {
|
||||||
|
if (document.fullscreenElement !== document.documentElement) return;
|
||||||
|
void document.exitFullscreen().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleViewerFullscreen(): void {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
exitViewerFullscreen();
|
||||||
|
} else {
|
||||||
|
requestViewerFullscreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isViewerFullscreen(): boolean {
|
||||||
|
return document.fullscreenElement === document.documentElement;
|
||||||
|
}
|
||||||
+65
-15
@@ -2,6 +2,7 @@ import SideBar from '@/shared/SideBar'
|
|||||||
import Header from '@/shared/Header'
|
import Header from '@/shared/Header'
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom'
|
import { Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import { Paths } from '@/config/Paths'
|
import { Paths } from '@/config/Paths'
|
||||||
|
import PrivateRoute from '@/router/PrivateRoute'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
import { useSharedStore } from '@/shared/store/sharedStore'
|
import { useSharedStore } from '@/shared/store/sharedStore'
|
||||||
import Home from '@/pages/home/Home'
|
import Home from '@/pages/home/Home'
|
||||||
@@ -13,30 +14,79 @@ import DesignerRequest from '@/pages/designer/Request'
|
|||||||
const MainRouter = () => {
|
const MainRouter = () => {
|
||||||
const { hasSubMenu } = useSharedStore()
|
const { hasSubMenu } = useSharedStore()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const viewerPathPrefix = `${Paths.viewer}/`
|
||||||
|
const isViewerRoute =
|
||||||
|
location.pathname.startsWith(viewerPathPrefix) &&
|
||||||
|
location.pathname.length > viewerPathPrefix.length
|
||||||
const hiddenSideBarRoutes = [Paths.editor]
|
const hiddenSideBarRoutes = [Paths.editor]
|
||||||
const shouldRenderSideBar = !hiddenSideBarRoutes.includes(location.pathname)
|
const shouldRenderSideBar =
|
||||||
|
!hiddenSideBarRoutes.includes(location.pathname) && !isViewerRoute
|
||||||
const routeHasLocalSidebar = location.pathname.includes('editor')
|
const routeHasLocalSidebar = location.pathname.includes('editor')
|
||||||
const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar
|
const hasSidebarSpace = shouldRenderSideBar || routeHasLocalSidebar
|
||||||
const headerSidebarSize = routeHasLocalSidebar ? 'wide' : 'default'
|
const headerSidebarSize = routeHasLocalSidebar ? 'wide' : 'default'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex min-h-full flex-col overflow-hidden p-4'>
|
|
||||||
{shouldRenderSideBar ? <SideBar /> : null}
|
|
||||||
<Header hasMainSidebar={hasSidebarSpace} sidebarSize={headerSidebarSize} />
|
|
||||||
<div className={clx(
|
<div className={clx(
|
||||||
'mt-[68px] flex flex-1 flex-col xl:mt-[81px]',
|
'flex min-h-full flex-col overflow-hidden',
|
||||||
shouldRenderSideBar && 'xl:ms-[269px]',
|
isViewerRoute ? 'fixed inset-0 z-50 bg-neutral-100' : 'p-4',
|
||||||
shouldRenderSideBar && hasSubMenu && 'xl:ms-[305px]',
|
|
||||||
routeHasLocalSidebar && 'xl:mr-[374px]',
|
|
||||||
)}>
|
)}>
|
||||||
<div className='flex-1 flex flex-col overflow-auto w-full max-h-[calc(100vh-113px)]'>
|
{!isViewerRoute && shouldRenderSideBar ? <SideBar /> : null}
|
||||||
<div className='flex-1 h-full flex'>
|
{!isViewerRoute ? (
|
||||||
|
<Header hasMainSidebar={hasSidebarSpace} sidebarSize={headerSidebarSize} />
|
||||||
|
) : null}
|
||||||
|
<div className={clx(
|
||||||
|
'flex flex-1 flex-col min-h-0',
|
||||||
|
!isViewerRoute && 'mt-[68px] xl:mt-[81px]',
|
||||||
|
!isViewerRoute && shouldRenderSideBar && 'xl:ms-[269px]',
|
||||||
|
!isViewerRoute && shouldRenderSideBar && hasSubMenu && 'xl:ms-[305px]',
|
||||||
|
!isViewerRoute && routeHasLocalSidebar && 'xl:mr-[374px]',
|
||||||
|
)}>
|
||||||
|
<div className={clx(
|
||||||
|
'flex-1 flex flex-col w-full min-h-0',
|
||||||
|
isViewerRoute ? 'h-full overflow-hidden' : 'overflow-auto max-h-[calc(100vh-113px)]',
|
||||||
|
)}>
|
||||||
|
<div className='flex-1 h-full flex min-h-0'>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path={Paths.home} element={<Home />} />
|
<Route
|
||||||
<Route path={Paths.editor + '/:id'} element={<Editor />} />
|
path={Paths.home}
|
||||||
<Route path={Paths.viewer + '/:id'} element={<Viewer />} />
|
element={
|
||||||
<Route path={Paths.catalog.list} element={<CatalogueList />} />
|
<PrivateRoute requireAuth={false}>
|
||||||
<Route path={Paths.designer.request} element={<DesignerRequest />} />
|
<Home />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Paths.editor + '/:id'}
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Editor />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Paths.viewer + '/:id'}
|
||||||
|
element={
|
||||||
|
<PrivateRoute requireAuth={false}>
|
||||||
|
<Viewer />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Paths.catalog.list}
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<CatalogueList />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path={Paths.designer.request}
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<DesignerRequest />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { Paths } from '@/config/Paths'
|
||||||
|
import { type FC, type ReactNode } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
type PrivateRouteProps = {
|
||||||
|
children: ReactNode
|
||||||
|
/** When true (default), user must be logged in to access the route */
|
||||||
|
requireAuth?: boolean
|
||||||
|
/** Redirect target when auth is required but user is not logged in */
|
||||||
|
redirectTo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrivateRoute: FC<PrivateRouteProps> = ({
|
||||||
|
children,
|
||||||
|
requireAuth = true,
|
||||||
|
redirectTo = Paths.home,
|
||||||
|
}) => {
|
||||||
|
const { isAuthenticated, isChecking } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (!requireAuth) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isChecking) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="text-gray-600">در حال بارگذاری...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to={redirectTo} replace state={{ from: location }} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PrivateRoute
|
||||||
@@ -8,6 +8,7 @@ import { Paths } from '@/config/Paths'
|
|||||||
import { useSharedStore } from '@/shared/store/sharedStore'
|
import { useSharedStore } from '@/shared/store/sharedStore'
|
||||||
import { Eye, HambergerMenu } from 'iconsax-react'
|
import { Eye, HambergerMenu } from 'iconsax-react'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
|
import { requestViewerFullscreen } from '@/pages/viewer/utils/viewerFullscreen'
|
||||||
|
|
||||||
type SidebarSize = 'default' | 'wide'
|
type SidebarSize = 'default' | 'wide'
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ const Header: FC<HeaderProps> = ({ hasMainSidebar = true, sidebarSize = 'default
|
|||||||
className='flex items-center'
|
className='flex items-center'
|
||||||
aria-label={t('header.open_viewer')}
|
aria-label={t('header.open_viewer')}
|
||||||
title={t('header.open_viewer')}
|
title={t('header.open_viewer')}
|
||||||
|
onClick={requestViewerFullscreen}
|
||||||
>
|
>
|
||||||
<Eye size={20} color='black' />
|
<Eye size={20} color='black' />
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
Reference in New Issue
Block a user