Compare commits
27 Commits
85ba8e4261
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 677c62e6eb | |||
| e0eb29488a | |||
| ae3787241a | |||
| 9e105606a3 | |||
| c6bb275d05 | |||
| 4273e899ca | |||
| 0361e05acb | |||
| 33cce93064 | |||
| a2a591420c | |||
| 3c764a1652 | |||
| 954ad48486 | |||
| 368cace143 | |||
| 46926c66e7 | |||
| a448ff10de | |||
| 80a92ea4c4 | |||
| 63a774c4af | |||
| 1ed198ca69 | |||
| d94bf77522 | |||
| 5e314bb581 | |||
| 0391513928 | |||
| 22d15f9a13 | |||
| bbb797cc62 | |||
| 4af7181384 | |||
| cda1b5b5bd | |||
| 2b4fd31ea2 | |||
| 168af3bf95 | |||
| 0bef54c51c |
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: dpage-editor
|
||||
description: Guides development in the dpage-editor React/Konva catalogue editor and HTML flip-book viewer. Use when editing editor tools, viewer rendering, Zustand store, catalogue autosave, entrance animations, masks, or any code under src/pages/editor or src/pages/viewer.
|
||||
---
|
||||
|
||||
# dpage-editor
|
||||
|
||||
## Stack
|
||||
|
||||
- React 19 + TypeScript + Vite
|
||||
- Canvas editor: **Konva** / **react-konva**
|
||||
- Viewer: **react-pageflip** (HTML/CSS, not Konva)
|
||||
- State: **Zustand** (`editorStore`)
|
||||
- Data: **TanStack Query** + axios services
|
||||
- Styling: **Tailwind CSS 4**, `clx()` from `@/helpers/utils`
|
||||
- i18n: react-i18next (Persian UI)
|
||||
|
||||
## Architecture
|
||||
|
||||
| Area | Path | Role |
|
||||
|------|------|------|
|
||||
| Editor page | `src/pages/editor/Editor.tsx` | Loads catalogue, autosave, Konva lifecycle |
|
||||
| Editor store | `src/pages/editor/store/editorStore.ts` | Pages, objects, undo, selection |
|
||||
| Object types | `src/pages/editor/store/editorStore.types.ts` | `EditorObject`, `ToolType`, animations |
|
||||
| Canvas | `src/pages/editor/components/canvas/` | Layers, drawing, transform, snap |
|
||||
| Tools (Konva) | `src/pages/editor/components/tools/` | One component per shape/media type |
|
||||
| Sidebar | `src/pages/editor/components/sidebar/` | ToolsBar, settings, instructions |
|
||||
| Viewer | `src/pages/viewer/` | Flip-book, entrance animations, magnifier |
|
||||
| Book render | `src/pages/viewer/components/BookPage.tsx` | HTML mirror of `EditorObject` |
|
||||
| Catalogue API | `src/pages/catalogue/hooks/useCatalogueData.ts` | Load/save `content` JSON |
|
||||
|
||||
**Source of truth:** `EditorObject[]` per page, serialized into catalogue `content`. Editor and viewer must stay in sync.
|
||||
|
||||
## Critical conventions
|
||||
|
||||
### Konva text rendering
|
||||
|
||||
In `Editor.tsx`, `Konva.legacyTextRendering = true` is required. Konva 10's new text baseline breaks transformer bounds for Persian/Latin text. Do not remove without fixing text measurement.
|
||||
|
||||
### Store sync
|
||||
|
||||
`objects` and `pages[currentPageId].objects` must stay synced. Use existing helpers (`withSyncedCurrentPageObjects`, store actions) — never update one without the other.
|
||||
|
||||
### IDs and cloning
|
||||
|
||||
- Use `createScopedId` / helpers in `editorStore.helpers.ts` for new IDs.
|
||||
- Undo history uses `structuredClone` on object lists; new fields on `EditorObject` must be JSON-serializable.
|
||||
|
||||
### Path alias
|
||||
|
||||
Use `@/` imports (e.g. `@/pages/editor/store/editorStore`).
|
||||
|
||||
## Adding or changing a tool type
|
||||
|
||||
Checklist — touch all relevant layers:
|
||||
|
||||
1. **`ToolType`** in `editorStore.types.ts` — add union member + fields on `EditorObject` if needed.
|
||||
2. **Konva shape** — `src/pages/editor/components/tools/<Name>Shape.tsx`, export from `tools/index.ts`.
|
||||
3. **`ObjectRenderer.tsx`** — `switch (obj.type)` case + props wiring.
|
||||
4. **Sidebar** — `ToolsBar.tsx`, `ToolInstructions.tsx`, optional `instructions/` + `settings/`.
|
||||
5. **Creation defaults** — store action or drawing handler that sets initial `x`, `y`, `width`, `height`.
|
||||
6. **Viewer** — `BookPage.tsx` `renderObject` branch; reuse utils from `src/pages/editor/utils/` (gradient, fontFamily, textStyle, customShape).
|
||||
7. **Entrance animation** (if applicable) — `entranceAnimations.css`, `entranceAnimationStyle.ts`, `useBookEntranceController.ts`.
|
||||
|
||||
Default tool prop pattern:
|
||||
|
||||
```typescript
|
||||
type ShapeProps = {
|
||||
obj: EditorObject;
|
||||
isSelected: boolean;
|
||||
transformerRef: React.RefObject<Konva.Transformer | null>;
|
||||
onSelect: (id: string, node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => void;
|
||||
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||
draggable: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
## Editor vs viewer
|
||||
|
||||
| Concern | Editor | Viewer |
|
||||
|---------|--------|--------|
|
||||
| Rendering | Konva nodes | HTML/CSS (`BookPage`) |
|
||||
| Interaction | Select, transform, draw | Flip pages, links, autoplay |
|
||||
| Scale | Stage zoom | `VIEWER_SCALE` + viewport hooks |
|
||||
| Media | Full controls in sidebar | `staticMedia` option for thumbnails |
|
||||
| RTL | Stage coordinates | `RTL_COVER_PAD_PAGE_ID` for odd page counts |
|
||||
|
||||
Viewer-specific logic belongs in `src/pages/viewer/hooks/` and `utils/`, not in editor components.
|
||||
|
||||
## Autosave (Editor.tsx)
|
||||
|
||||
- `useUpdateCatalog` debounces saves.
|
||||
- On catalogue `id` change: `cancelPendingUpdate`, `resetEditor`, reload once per id (`loadedCatalogIdRef`).
|
||||
- Skip first autosave after load (`skipNextAutosaveRef`).
|
||||
|
||||
## Code style for this repo
|
||||
|
||||
- Prefer extending existing components over new abstractions.
|
||||
- Keep comments for non-obvious Konva/RTL/safari workarounds (see `BookViewer.tsx`, `Editor.tsx`).
|
||||
- Match Persian UI strings in surrounding files.
|
||||
- Minimize diff scope; do not refactor unrelated editor/viewer code.
|
||||
- Run `npm run build` after substantive changes (strict TypeScript).
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Adding an `EditorObject` field but forgetting viewer render → object invisible in preview.
|
||||
- Transform commits: use `transformCommit.ts` patterns so scale/rotation persist correctly.
|
||||
- Mask/group: check `groupId`, `maskId`, `isMask` in both editor layers and `maskStyle.ts`.
|
||||
- Page background: editor `documentSettings` ↔ viewer `resolvePageBackground`.
|
||||
- Fullscreen/viewer chrome: `isViewerPath` hides app header/sidebar in `MainRouter.tsx`.
|
||||
|
||||
## Key routes
|
||||
|
||||
- Editor: `/editor/:id`
|
||||
- Viewer: `/viewer/:id` or `/viewer/:businessSlug/:slug`
|
||||
- Catalogue list: see `src/config/Paths.ts`
|
||||
@@ -0,0 +1,35 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
out/
|
||||
|
||||
# Caches
|
||||
.cache/
|
||||
.vite/
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Environment / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor / IDE
|
||||
.vscode/
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Generated
+11
@@ -23,6 +23,7 @@
|
||||
"moment-jalaali": "^0.10.4",
|
||||
"page-flip": "^2.0.7",
|
||||
"react": "^19.2.0",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-i18next": "^16.3.0",
|
||||
@@ -4556,6 +4557,16 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-colorful": {
|
||||
"version": "5.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.7.0.tgz",
|
||||
"integrity": "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-date-object": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"moment-jalaali": "^0.10.4",
|
||||
"page-flip": "^2.0.7",
|
||||
"react": "^19.2.0",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-i18next": "^16.3.0",
|
||||
|
||||
+182
-103
@@ -1,127 +1,206 @@
|
||||
import { type FC, type InputHTMLAttributes, useState, useRef, useEffect } from 'react'
|
||||
import { clx } from '../helpers/utils'
|
||||
import Error from './Error'
|
||||
import { type FC, type InputHTMLAttributes, useEffect, useRef, useState } from "react";
|
||||
import { HexAlphaColorPicker, HexColorInput, HexColorPicker } from "react-colorful";
|
||||
import { clx } from "../helpers/utils";
|
||||
import Error from "./Error";
|
||||
|
||||
type Props = {
|
||||
label?: string
|
||||
className?: string
|
||||
error_text?: string
|
||||
isNotRequired?: boolean
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'>
|
||||
label?: string;
|
||||
className?: string;
|
||||
error_text?: string;
|
||||
isNotRequired?: boolean;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
opacity?: number;
|
||||
onOpacityChange?: (value: number) => void;
|
||||
opacityLabel?: string;
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "value">;
|
||||
|
||||
const ColorPicker: FC<Props> = (props: Props) => {
|
||||
const [color, setColor] = useState<string>(props.value || '#000000')
|
||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||
const pickerOpenGuardRef = useRef(false)
|
||||
const [color, setColor] = useState<string>(props.value || "#000000");
|
||||
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${props.opacity ?? 100}`);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const showOpacity = props.onOpacityChange !== undefined;
|
||||
|
||||
const inputClass = clx(
|
||||
'w-full bg-white h-[34px] text-black block pl-10 pr-10 text-xs rounded-xl border border-border',
|
||||
props.readOnly && 'bg-gray-100 border-0 text-description',
|
||||
props.className
|
||||
)
|
||||
const clampOpacity = (rawValue: number): number => Math.min(100, Math.max(0, rawValue));
|
||||
|
||||
const handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newColor = event.target.value
|
||||
setColor(newColor)
|
||||
props.onChange?.(newColor)
|
||||
const normalizeHex = (value: string): string => {
|
||||
if (!value) return "#000000";
|
||||
const withHash = value.startsWith("#") ? value : `#${value}`;
|
||||
const shortMatch = /^#([0-9A-Fa-f]{3})$/.exec(withHash);
|
||||
if (shortMatch) {
|
||||
const [r, g, b] = shortMatch[1]!.split("");
|
||||
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase();
|
||||
}
|
||||
|
||||
const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value
|
||||
if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') {
|
||||
const finalColor = value || '#000000'
|
||||
setColor(finalColor)
|
||||
props.onChange?.(finalColor)
|
||||
}
|
||||
const longMatch = /^#([0-9A-Fa-f]{6})$/.exec(withHash);
|
||||
if (longMatch) {
|
||||
return `#${longMatch[1]}`.toLowerCase();
|
||||
}
|
||||
return "#000000";
|
||||
};
|
||||
|
||||
const openNativeColorPicker = () => {
|
||||
if (props.readOnly) return
|
||||
if (pickerOpenGuardRef.current) return
|
||||
pickerOpenGuardRef.current = true
|
||||
colorInputRef.current?.click()
|
||||
window.setTimeout(() => {
|
||||
pickerOpenGuardRef.current = false
|
||||
}, 400)
|
||||
const toHexAlpha = (hex: string, opacity: number): string => {
|
||||
const normalizedHex = normalizeHex(hex);
|
||||
const alpha = Math.round((clampOpacity(opacity) / 100) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
return `${normalizedHex}${alpha}`;
|
||||
};
|
||||
|
||||
const fromHexAlpha = (hexAlpha: string): { hex: string; opacity: number } => {
|
||||
const match = /^#([0-9A-Fa-f]{8})$/.exec(hexAlpha);
|
||||
if (!match) {
|
||||
return {
|
||||
hex: normalizeHex(hexAlpha),
|
||||
opacity: 100,
|
||||
};
|
||||
}
|
||||
const rgbaHex = match[1]!.toLowerCase();
|
||||
const rgb = `#${rgbaHex.slice(0, 6)}`;
|
||||
const alphaHex = rgbaHex.slice(6, 8);
|
||||
const opacity = Math.round((parseInt(alphaHex, 16) / 255) * 100);
|
||||
return { hex: rgb, opacity: clampOpacity(opacity) };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setColor(props.value)
|
||||
}
|
||||
}, [props.value])
|
||||
const inputClass = clx("w-full bg-white h-[34px] text-black block pl-10 pr-10 text-xs rounded-xl border border-border", props.readOnly && "bg-gray-100 border-0 text-description");
|
||||
|
||||
return (
|
||||
<div className='w-full'>
|
||||
<label className='text-xs'>
|
||||
{props.label}
|
||||
</label>
|
||||
const handleColorChange = (newColor: string) => {
|
||||
const normalized = normalizeHex(newColor);
|
||||
setColor(normalized);
|
||||
props.onChange?.(normalized);
|
||||
};
|
||||
|
||||
<div
|
||||
className={clx(
|
||||
'w-full relative mt-1 rounded-xl',
|
||||
!props.readOnly && 'cursor-pointer'
|
||||
)}
|
||||
onClick={!props.readOnly ? openNativeColorPicker : undefined}
|
||||
>
|
||||
<input
|
||||
type='text'
|
||||
value={color}
|
||||
onChange={handleTextChange}
|
||||
placeholder='#000000'
|
||||
readOnly={props.readOnly}
|
||||
title={
|
||||
!props.readOnly
|
||||
? 'کلیک: پالت رنگ — با Tab وارد فیلد شوید تا کد را تایپ کنید'
|
||||
: undefined
|
||||
}
|
||||
className={`${inputClass} cursor-pointer`}
|
||||
/>
|
||||
const togglePicker = () => {
|
||||
if (props.readOnly) return;
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={clx(
|
||||
'absolute top-0 bottom-0 my-auto left-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none',
|
||||
props.readOnly && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* <img
|
||||
useEffect(() => {
|
||||
if (props.value !== undefined) {
|
||||
setColor(normalizeHex(props.value));
|
||||
}
|
||||
}, [props.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.opacity !== undefined) {
|
||||
setOpacityDisplay(`${props.opacity}`);
|
||||
}
|
||||
}, [props.opacity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePointerDownOutside = (event: MouseEvent) => {
|
||||
if (!wrapperRef.current?.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousedown", handlePointerDownOutside);
|
||||
return () => window.removeEventListener("mousedown", handlePointerDownOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOpacityChange = (rawValue: string) => {
|
||||
setOpacityDisplay(rawValue);
|
||||
const numValue = parseInt(rawValue, 10);
|
||||
if (!Number.isNaN(numValue)) {
|
||||
props.onOpacityChange?.(clampOpacity(numValue));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityBlur = (rawValue: string) => {
|
||||
const numValue = parseInt(rawValue, 10) || 0;
|
||||
const clampedValue = clampOpacity(numValue);
|
||||
setOpacityDisplay(`${clampedValue}`);
|
||||
props.onOpacityChange?.(clampedValue);
|
||||
};
|
||||
|
||||
const handleAlphaPickerChange = (hexAlpha: string) => {
|
||||
const { hex, opacity } = fromHexAlpha(hexAlpha);
|
||||
setColor(hex);
|
||||
setOpacityDisplay(`${opacity}`);
|
||||
props.onChange?.(hex);
|
||||
props.onOpacityChange?.(opacity);
|
||||
};
|
||||
|
||||
const colorField = (
|
||||
<div className={clx("w-full", showOpacity && "flex-1")}>
|
||||
<label className="text-xs">{props.label}</label>
|
||||
|
||||
<div className={clx("w-full relative mt-1 rounded-xl", !props.readOnly && "cursor-pointer")}>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(event) => handleColorChange(event.target.value)}
|
||||
placeholder="#000000"
|
||||
readOnly={props.readOnly}
|
||||
title={!props.readOnly ? "کلیک برای باز کردن انتخابگر رنگ" : undefined}
|
||||
onClick={togglePicker}
|
||||
className={`${inputClass} ${props.readOnly ? "" : "cursor-pointer"}`}
|
||||
/>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={clx("absolute top-0 bottom-0 my-auto left-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none", props.readOnly && "opacity-50")}
|
||||
>
|
||||
{/* <img
|
||||
src={ColorfilterIcon}
|
||||
alt=''
|
||||
className='size-7 select-none'
|
||||
/> */}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className={clx(
|
||||
'absolute top-0 bottom-0 my-auto right-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none',
|
||||
props.readOnly && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className='block w-5 h-5 rounded-full border border-border shadow-sm'
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clx("absolute top-0 bottom-0 my-auto right-1 flex items-center justify-center min-h-[34px] min-w-[34px] rounded-lg pointer-events-none", props.readOnly && "opacity-50")}
|
||||
>
|
||||
<span className="block w-5 h-5 rounded-full border border-border shadow-sm" style={{ backgroundColor: color }} />
|
||||
</span>
|
||||
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type='color'
|
||||
value={color}
|
||||
onChange={handleColorChange}
|
||||
className='absolute opacity-0 pointer-events-none w-0 h-0'
|
||||
/>
|
||||
{isOpen && !props.readOnly && (
|
||||
<div className="absolute right-0 z-50 mt-2 w-full rounded-xl border border-border bg-white p-3 shadow-lg space-y-3">
|
||||
<div className="space-y-1">
|
||||
{showOpacity ? (
|
||||
<HexAlphaColorPicker color={toHexAlpha(color, Number(opacityDisplay) || 100)} onChange={handleAlphaPickerChange} />
|
||||
) : (
|
||||
<HexColorPicker color={color} onChange={handleColorChange} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{props.error_text && (
|
||||
<Error errorText={props.error_text} />
|
||||
<div className="flex items-center gap-2">
|
||||
<HexColorInput color={color} onChange={handleColorChange} prefixed className="h-9 max-w-[115px] flex-1 rounded-lg border border-border px-2 text-xs" />
|
||||
{showOpacity && (
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={opacityDisplay}
|
||||
onChange={(e) => handleOpacityChange(e.target.value)}
|
||||
onBlur={(e) => handleOpacityBlur(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-border px-2 text-xs"
|
||||
title={props.opacityLabel ?? "شفافیت"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showOpacity && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{props.opacityLabel ?? "شفافیت"}: {opacityDisplay}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ColorPicker
|
||||
return (
|
||||
<div ref={wrapperRef} className={clx("w-full", props.className)}>
|
||||
{colorField}
|
||||
|
||||
{props.error_text && <Error errorText={props.error_text} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorPicker;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Group, Rect, Text } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { EditorObject } from "@/pages/editor/store/editorStore";
|
||||
import { getColorWithOpacity } from "@/pages/editor/utils/colorOpacity";
|
||||
|
||||
type EditorTableProps = {
|
||||
obj: EditorObject;
|
||||
@@ -80,7 +81,7 @@ const EditorTable = ({ obj, selectedCellId, onCellClick, onCellDblClick, onDragE
|
||||
y={y}
|
||||
width={cellWidth}
|
||||
height={cellHeight}
|
||||
fill={cell.background}
|
||||
fill={getColorWithOpacity(cell.background, cell.backgroundOpacity ?? 100)}
|
||||
stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"}
|
||||
strokeWidth={isCellSelected ? 3 : strokeWidth || 1}
|
||||
cornerRadius={isTopLeft ? [8, 0, 0, 0] : isTopRight ? [0, 8, 0, 0] : 0}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CloseCircle, DocumentUpload, Gallery } from 'iconsax-react'
|
||||
import { type FC, useCallback, useEffect, useState } from 'react'
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { clx } from '../helpers/utils';
|
||||
import UploadProgressBar from './UploadProgressBar';
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
@@ -15,6 +16,8 @@ type Props = {
|
||||
/** نمایش وضعیت بارگذاری داخل باکس آپلود */
|
||||
isLoading?: boolean,
|
||||
loadingLabel?: string,
|
||||
/** درصد پیشرفت آپلود (۰ تا ۱۰۰) */
|
||||
uploadProgress?: number,
|
||||
/** بدون پیشنمایش زیر باکس (مثلاً گالری جداگانه دارد) */
|
||||
hidePreview?: boolean,
|
||||
}
|
||||
@@ -77,8 +80,16 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||
}, [props.coverUrl])
|
||||
|
||||
|
||||
const showProgressOnly = props.isLoading && props.uploadProgress !== undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{showProgressOnly ? (
|
||||
<UploadProgressBar
|
||||
progress={props.uploadProgress!}
|
||||
label={props.loadingLabel ?? `در حال آپلود... ${props.uploadProgress}٪`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={clx(
|
||||
@@ -114,6 +125,7 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
!props.hidePreview && (files.length > 0 || perviews.length > 0) ? (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type FC } from "react";
|
||||
|
||||
type UploadProgressBarProps = {
|
||||
progress: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const UploadProgressBar: FC<UploadProgressBarProps> = ({ progress, label }) => {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress));
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col items-center gap-2 py-2">
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-full rounded-full bg-black transition-all duration-200"
|
||||
style={{ width: `${clampedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-description text-xs">
|
||||
{label ?? `${clampedProgress}٪`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadProgressBar;
|
||||
@@ -1,33 +1,67 @@
|
||||
import { type FC } from "react";
|
||||
import { DocumentUpload, VideoSquare } from "iconsax-react";
|
||||
import { clx } from "@/helpers/utils";
|
||||
import UploadProgressBar from "@/components/UploadProgressBar";
|
||||
|
||||
type VideoUploadZoneProps = {
|
||||
getRootProps: () => React.HTMLAttributes<HTMLDivElement>;
|
||||
getInputProps: () => React.InputHTMLAttributes<HTMLInputElement>;
|
||||
isLoading?: boolean;
|
||||
loadingLabel?: string;
|
||||
uploadProgress?: number;
|
||||
};
|
||||
|
||||
const VideoUploadZone: FC<VideoUploadZoneProps> = ({ getRootProps, getInputProps }) => {
|
||||
const VideoUploadZone: FC<VideoUploadZoneProps> = ({
|
||||
getRootProps,
|
||||
getInputProps,
|
||||
isLoading = false,
|
||||
loadingLabel = "در حال آپلود...",
|
||||
uploadProgress,
|
||||
}) => {
|
||||
if (isLoading && uploadProgress !== undefined) {
|
||||
return (
|
||||
<UploadProgressBar
|
||||
progress={uploadProgress}
|
||||
label={`${loadingLabel} ${uploadProgress}٪`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
className={clx(
|
||||
"w-full py-8 border border-dashed border-description flex flex-col justify-center items-center gap-4 rounded-2xl transition-colors",
|
||||
isLoading
|
||||
? "pointer-events-none opacity-80"
|
||||
: "cursor-pointer hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<VideoSquare
|
||||
size={32}
|
||||
color="#8C90A3"
|
||||
variant="Outline"
|
||||
/>
|
||||
<div className="text-description text-xs">
|
||||
ویدیو مورد نظر را آپلود کنید
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentUpload
|
||||
size={16}
|
||||
color="black"
|
||||
/>
|
||||
<div className="text-xs">آپلود</div>
|
||||
</div>
|
||||
{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">{loadingLabel}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<VideoSquare
|
||||
size={32}
|
||||
color="#8C90A3"
|
||||
variant="Outline"
|
||||
/>
|
||||
<div className="text-description text-xs">
|
||||
ویدیو مورد نظر را آپلود کنید
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentUpload
|
||||
size={16}
|
||||
color="black"
|
||||
/>
|
||||
<div className="text-xs">آپلود</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -276,3 +276,13 @@ html {
|
||||
.flipbook-container.flipbook-magnifier-active .stf__item {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* جلوگیری از اسکرول/ورقخوردن پیشفرض مرورگر هنگام لمس با ذرهبین فعال */
|
||||
.flipbook-container.flipbook-magnifier-active {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* در حین انیمیشن ورق، hit-test ذرهبین روی صفحات در حال حرکت انجام نشود */
|
||||
.flipbook-flipping .flipbook-container.flipbook-magnifier-active .stf__item {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ function getPageDataFromEditorPage(page: Page): PageData | null {
|
||||
backgroundColor: page.backgroundColor,
|
||||
backgroundGradient: page.backgroundGradient,
|
||||
backgroundImageUrl: page.backgroundImageUrl,
|
||||
backgroundVideoUrl: page.backgroundVideoUrl,
|
||||
objects: page.objects,
|
||||
},
|
||||
],
|
||||
@@ -90,7 +91,7 @@ const CatalogPreview: FC<Props> = ({ item, page, size, className, selected }) =>
|
||||
height: pageHeight,
|
||||
}}
|
||||
>
|
||||
<BookPage page={firstPage} scale={1} pageWidth={pageWidth} pageHeight={pageHeight} disableEntranceAnimations showPaperShadow={false} />
|
||||
<BookPage page={firstPage} scale={1} pageWidth={pageWidth} pageHeight={pageHeight} disableEntranceAnimations staticMedia showPaperShadow={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing";
|
||||
import { usePencilDrawing } from "./canvas/usePencilDrawing";
|
||||
import ObjectsLayer from "./canvas/ObjectsLayer";
|
||||
import GuidesLayer from "./canvas/GuidesLayer";
|
||||
import BlurBackdropLayer from "./canvas/BlurBackdropLayer";
|
||||
import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer";
|
||||
import PencilDrawingLayer from "./canvas/PencilDrawingLayer";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ import ZoomControls from "./ZoomControls";
|
||||
import EditorCanvasToolbar from "./EditorCanvasToolbar";
|
||||
import { createScopedId } from "../store/editorStore.helpers";
|
||||
import { getKonvaGradientProps } from "../utils/gradient";
|
||||
import { getColorWithOpacity } from "../utils/colorOpacity";
|
||||
import { STICKER_DRAG_MIME } from "../types/IconTypes";
|
||||
import { GALLERY_DRAG_MIME } from "../types/GalleryTypes";
|
||||
import {
|
||||
@@ -72,10 +74,18 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
const currentPage = pages.find((page) => page.id === currentPageId);
|
||||
|
||||
const bgColor =
|
||||
currentPage?.backgroundType === 'color' || currentPage?.backgroundType === 'gradient'
|
||||
currentPage?.backgroundType === 'color'
|
||||
? getColorWithOpacity(
|
||||
currentPage.backgroundColor,
|
||||
currentPage.backgroundOpacity ?? 100,
|
||||
)
|
||||
: currentPage?.backgroundType === 'gradient'
|
||||
? currentPage.backgroundColor
|
||||
: '#ffffff';
|
||||
const bgGradient = currentPage?.backgroundGradient;
|
||||
const hasVideoBackground =
|
||||
currentPage?.backgroundType === "video" &&
|
||||
Boolean(currentPage.backgroundVideoUrl);
|
||||
|
||||
const [bgImage] = useImage(
|
||||
currentPage?.backgroundType === 'image'
|
||||
@@ -527,10 +537,20 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
<div className="absolute -top-6 -left-6 w-6 h-6 bg-slate-200 border border-slate-300" />
|
||||
<div
|
||||
ref={stageWrapRef}
|
||||
className="shadow-lg"
|
||||
className="relative shadow-lg"
|
||||
onDragOver={handleSidebarDragOver}
|
||||
onDrop={handleSidebarDrop}
|
||||
>
|
||||
{hasVideoBackground && (
|
||||
<video
|
||||
src={currentPage?.backgroundVideoUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className="absolute inset-0 w-full h-full object-cover pointer-events-none"
|
||||
/>
|
||||
)}
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageSize.width * finalScale}
|
||||
@@ -549,16 +569,18 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<Layer listening={false}>
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
fill={bgColor}
|
||||
{...backgroundGradientProps}
|
||||
listening={false}
|
||||
/>
|
||||
{currentPage?.backgroundType === 'image' && bgImage && (
|
||||
{!hasVideoBackground && (
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
fill={bgColor}
|
||||
{...backgroundGradientProps}
|
||||
listening={false}
|
||||
/>
|
||||
)}
|
||||
{!hasVideoBackground && currentPage?.backgroundType === 'image' && bgImage && (
|
||||
<KonvaImage
|
||||
image={bgImage}
|
||||
x={0}
|
||||
@@ -606,6 +628,13 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
||||
<CustomShapeDrawingLayer drawingState={drawingState} />
|
||||
<PencilDrawingLayer drawingState={pencilDrawingState} />
|
||||
</Stage>
|
||||
<BlurBackdropLayer
|
||||
objects={objects}
|
||||
stageWidth={stageSize.width}
|
||||
stageHeight={stageSize.height}
|
||||
scale={finalScale}
|
||||
stageRef={stageRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,9 @@ function isPageEmptyForPreview(page: Page): boolean {
|
||||
if (page.backgroundType === 'image' && page.backgroundImageUrl.trim() !== '') {
|
||||
return false
|
||||
}
|
||||
if (page.backgroundType === 'video' && page.backgroundVideoUrl.trim() !== '') {
|
||||
return false
|
||||
}
|
||||
if (page.backgroundType === 'gradient') return false
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { clx } from '@/helpers/utils'
|
||||
import { useEditorStore } from '../store/editorStore'
|
||||
import type { DisplayStyle, BackgroundType } from '../store/editorStore'
|
||||
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
|
||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
||||
import ColorsImage from '@/assets/images/colors.png'
|
||||
import ColorPicker from '@/components/ColorPicker'
|
||||
import Select from '@/components/Select'
|
||||
import { toCssLinearGradient } from '../utils/gradient'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#a8edcf',
|
||||
@@ -27,6 +27,7 @@ const BACKGROUND_TYPE_OPTIONS: { value: BackgroundType; label: string }[] = [
|
||||
{ value: 'color', label: 'رنگ' },
|
||||
{ value: 'gradient', label: 'گرادیانت' },
|
||||
{ value: 'image', label: 'تصویر' },
|
||||
{ value: 'video', label: 'ویدیو' },
|
||||
]
|
||||
|
||||
const SettingsPanel = () => {
|
||||
@@ -37,17 +38,18 @@ const SettingsPanel = () => {
|
||||
updateDocumentSettings,
|
||||
updateCurrentPageBackground,
|
||||
} = useEditorStore()
|
||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||
const { mutateAsync: uploadFile, isPending: imageLoading } = useSingleUpload()
|
||||
const { mutateAsync: uploadFile, isPending: mediaLoading } = useSingleUpload()
|
||||
const currentPage = pages.find((page) => page.id === currentPageId)
|
||||
const backgroundType = currentPage?.backgroundType ?? 'color'
|
||||
const backgroundColor = currentPage?.backgroundColor ?? '#ffffff'
|
||||
const backgroundOpacity = currentPage?.backgroundOpacity ?? 100
|
||||
const backgroundGradient = currentPage?.backgroundGradient ?? {
|
||||
from: '#ffffff',
|
||||
to: '#e2e8f0',
|
||||
angle: 135,
|
||||
}
|
||||
const backgroundImageUrl = currentPage?.backgroundImageUrl ?? ''
|
||||
const backgroundVideoUrl = currentPage?.backgroundVideoUrl ?? ''
|
||||
const [uploadedPreviews, setUploadedPreviews] = useState<string[]>(
|
||||
backgroundImageUrl ? [backgroundImageUrl] : []
|
||||
)
|
||||
@@ -66,13 +68,18 @@ const SettingsPanel = () => {
|
||||
)
|
||||
}, [backgroundImageUrl])
|
||||
|
||||
useEffect(() => {
|
||||
if (backgroundType !== 'video') return
|
||||
setUploadedPreviews(backgroundVideoUrl ? [backgroundVideoUrl] : [])
|
||||
}, [backgroundType, backgroundVideoUrl])
|
||||
|
||||
const handleImageFiles = async (files: File[]) => {
|
||||
if (files.length === 0) return
|
||||
const file = files[files.length - 1]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const result = await uploadFile(file)
|
||||
const result = await uploadFile({ file })
|
||||
const imageUrl = result?.data?.url
|
||||
if (!imageUrl) return
|
||||
|
||||
@@ -83,10 +90,31 @@ const SettingsPanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleVideoFiles = async (files: File[]) => {
|
||||
if (files.length === 0) return
|
||||
const file = files[files.length - 1]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const result = await uploadFile({ file })
|
||||
const videoUrl = result?.data?.url
|
||||
if (!videoUrl) return
|
||||
|
||||
updateCurrentPageBackground({ backgroundVideoUrl: videoUrl })
|
||||
setUploadedPreviews([videoUrl])
|
||||
} catch {
|
||||
// TODO: show error toast
|
||||
}
|
||||
}
|
||||
|
||||
const handlePreviewChange = (previews: string[]) => {
|
||||
setUploadedPreviews(previews)
|
||||
if (previews.length === 0) {
|
||||
updateCurrentPageBackground({ backgroundImageUrl: '' })
|
||||
if (backgroundType === 'image') {
|
||||
updateCurrentPageBackground({ backgroundImageUrl: '' })
|
||||
} else if (backgroundType === 'video') {
|
||||
updateCurrentPageBackground({ backgroundVideoUrl: '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +177,7 @@ const SettingsPanel = () => {
|
||||
<h3 className="text-sm mb-4">پس زمینه</h3>
|
||||
|
||||
{/* نوع پسزمینه */}
|
||||
<div className="flex items-center gap-5 mb-4">
|
||||
<div className="flex flex-wrap items-center gap-5 mb-4">
|
||||
{BACKGROUND_TYPE_OPTIONS.map((opt) => (
|
||||
<label key={opt.value} className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<span className="text-xs text-gray-600">{opt.label}</span>
|
||||
@@ -166,6 +194,8 @@ const SettingsPanel = () => {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(opt.value !== 'image' ? { backgroundImageUrl: '' } : {}),
|
||||
...(opt.value !== 'video' ? { backgroundVideoUrl: '' } : {}),
|
||||
})
|
||||
}
|
||||
className={clx(
|
||||
@@ -203,30 +233,29 @@ const SettingsPanel = () => {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Color Wheel */}
|
||||
<button
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className="w-9 h-9 rounded-xl border-2 border-transparent hover:scale-105 transition-all overflow-hidden relative"
|
||||
title="رنگ دلخواه"
|
||||
>
|
||||
<img src={ColorsImage} alt="انتخاب رنگ" className="w-full h-full object-cover" />
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
<div className="w-full mt-2">
|
||||
<ColorPicker
|
||||
label="رنگ دلخواه"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => updateCurrentPageBackground({ backgroundColor: e.target.value })}
|
||||
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
|
||||
opacity={backgroundOpacity}
|
||||
onChange={(value) =>
|
||||
updateCurrentPageBackground({ backgroundColor: value })
|
||||
}
|
||||
onOpacityChange={(value) =>
|
||||
updateCurrentPageBackground({ backgroundOpacity: value })
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* نمایش رنگ انتخابشده */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded-lg border border-border"
|
||||
style={{ backgroundColor }}
|
||||
style={{ backgroundColor, opacity: backgroundOpacity / 100 }}
|
||||
/>
|
||||
<span className="text-xs text-gray-500 font-mono">{backgroundColor}</span>
|
||||
<span className="text-xs text-gray-400">{backgroundOpacity}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -292,7 +321,7 @@ const SettingsPanel = () => {
|
||||
<div
|
||||
className="h-12 rounded-xl border border-border"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(${backgroundGradient.angle}deg, ${backgroundGradient.from} 0%, ${backgroundGradient.to} 100%)`,
|
||||
backgroundImage: toCssLinearGradient(backgroundGradient),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -301,7 +330,7 @@ const SettingsPanel = () => {
|
||||
{/* ─── آپلود تصویر ─── */}
|
||||
{backgroundType === 'image' && (
|
||||
<div>
|
||||
{imageLoading ? (
|
||||
{mediaLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-800" />
|
||||
<span className="text-xs text-gray-500">در حال آپلود...</span>
|
||||
@@ -317,6 +346,44 @@ const SettingsPanel = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{backgroundType === 'video' && (
|
||||
<div>
|
||||
{mediaLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<span className="h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-gray-800" />
|
||||
<span className="text-xs text-gray-500">در حال آپلود...</span>
|
||||
</div>
|
||||
) : (
|
||||
<UploadBoxDraggble
|
||||
label="ویدیوی پسزمینه را آپلود کنید"
|
||||
onChange={handleVideoFiles}
|
||||
isMultiple={false}
|
||||
hidePreview
|
||||
/>
|
||||
)}
|
||||
{backgroundVideoUrl && (
|
||||
<div className="mt-3 space-y-2">
|
||||
<video
|
||||
src={backgroundVideoUrl}
|
||||
controls
|
||||
muted
|
||||
className="w-full h-36 rounded-lg border border-border bg-black object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateCurrentPageBackground({ backgroundVideoUrl: '' })
|
||||
setUploadedPreviews([])
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-600"
|
||||
>
|
||||
حذف ویدیو
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type Konva from "konva";
|
||||
import type { EditorObject } from "../../store/editorStore";
|
||||
import {
|
||||
buildBlurOverlayStyle,
|
||||
buildBlurStackEntries,
|
||||
isBlurBackdropObject,
|
||||
} from "../../utils/blurOverlayLayout";
|
||||
import OcclusionObjectsStage from "./OcclusionObjectsStage";
|
||||
|
||||
type BlurBackdropLayerProps = {
|
||||
objects: EditorObject[];
|
||||
stageWidth: number;
|
||||
stageHeight: number;
|
||||
/** Stage scale applied to Konva (editor internal scale + zoom). */
|
||||
scale: number;
|
||||
stageRef: React.RefObject<Konva.Stage | null>;
|
||||
};
|
||||
|
||||
const BlurBackdropLayer = ({ objects, stageWidth, stageHeight, scale, stageRef }: BlurBackdropLayerProps) => {
|
||||
const [dragRevision, setDragRevision] = useState(0);
|
||||
const [draggedBlurObjectId, setDraggedBlurObjectId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
|
||||
let raf = 0;
|
||||
const bump = () => setDragRevision((v) => v + 1);
|
||||
|
||||
const scheduleBump = () => {
|
||||
if (raf) return;
|
||||
raf = window.requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
bump();
|
||||
});
|
||||
};
|
||||
|
||||
const resolveBlurObjectId = (nodeId: string): string | null => {
|
||||
if (!nodeId) return null;
|
||||
const direct = objects.find((obj) => obj.id === nodeId && isBlurBackdropObject(obj));
|
||||
if (direct) return direct.id;
|
||||
if (nodeId.startsWith("masked-")) {
|
||||
const maskedId = nodeId.slice("masked-".length);
|
||||
const masked = objects.find((obj) => obj.id === maskedId && isBlurBackdropObject(obj));
|
||||
if (masked) return masked.id;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const onDragStart = (event: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const target = event.target;
|
||||
const id = typeof target.id === "function" ? target.id() : "";
|
||||
const blurObjectId = resolveBlurObjectId(id);
|
||||
if (blurObjectId) {
|
||||
setDraggedBlurObjectId(blurObjectId);
|
||||
}
|
||||
scheduleBump();
|
||||
};
|
||||
|
||||
const onDragMove = () => scheduleBump();
|
||||
|
||||
const onDragEnd = () => {
|
||||
setDraggedBlurObjectId(null);
|
||||
scheduleBump();
|
||||
};
|
||||
|
||||
stage.on("dragstart", onDragStart);
|
||||
stage.on("dragmove", onDragMove);
|
||||
stage.on("dragend", onDragEnd);
|
||||
|
||||
return () => {
|
||||
stage.off("dragstart", onDragStart);
|
||||
stage.off("dragmove", onDragMove);
|
||||
stage.off("dragend", onDragEnd);
|
||||
if (raf) window.cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [objects, stageRef]);
|
||||
|
||||
const stackEntries = useMemo(() => buildBlurStackEntries(objects), [objects]);
|
||||
|
||||
const livePositions = useMemo(() => {
|
||||
if (!draggedBlurObjectId) return new Map<string, { x: number; y: number }>();
|
||||
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return new Map<string, { x: number; y: number }>();
|
||||
|
||||
const node =
|
||||
stage.findOne(`#${draggedBlurObjectId}`) ??
|
||||
stage.findOne(`#masked-${draggedBlurObjectId}`);
|
||||
if (node && "getAbsolutePosition" in node) {
|
||||
return new Map([[draggedBlurObjectId, node.getAbsolutePosition()]]);
|
||||
}
|
||||
|
||||
return new Map<string, { x: number; y: number }>();
|
||||
}, [draggedBlurObjectId, dragRevision, stageRef]);
|
||||
|
||||
if (stackEntries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: stageWidth * scale,
|
||||
height: stageHeight * scale,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{stackEntries.map((entry) => {
|
||||
const livePosition = livePositions.get(entry.blurObject.id) ?? null;
|
||||
const style = buildBlurOverlayStyle(entry.blurObject, scale, livePosition);
|
||||
|
||||
return (
|
||||
<div key={entry.key} style={{ position: "absolute", inset: 0, pointerEvents: "none" }}>
|
||||
<div style={style} />
|
||||
<OcclusionObjectsStage
|
||||
objects={entry.occlusionObjects}
|
||||
allObjects={objects}
|
||||
stageWidth={stageWidth}
|
||||
stageHeight={stageHeight}
|
||||
scale={scale}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlurBackdropLayer;
|
||||
@@ -88,7 +88,7 @@ const ObjectRenderer = ({
|
||||
groupNode?.clearCache();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient]);
|
||||
}, [shouldApplyMask, isSelected, maskRelXDep, maskRelYDep, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient, obj.blur, obj.borderRadius]);
|
||||
|
||||
// Refresh cache after transformer is attached (when isSelected changes)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Stage, Layer, Group } from "react-konva";
|
||||
import ObjectRenderer from "./ObjectRenderer";
|
||||
import type { EditorObject } from "../../store/editorStore";
|
||||
|
||||
type OcclusionObjectsStageProps = {
|
||||
objects: EditorObject[];
|
||||
allObjects: EditorObject[];
|
||||
stageWidth: number;
|
||||
stageHeight: number;
|
||||
scale: number;
|
||||
};
|
||||
|
||||
/** Re-renders objects above a blur overlay so they stay crisp (display-only). */
|
||||
const OcclusionObjectsStage = ({
|
||||
objects,
|
||||
allObjects,
|
||||
stageWidth,
|
||||
stageHeight,
|
||||
scale,
|
||||
}: OcclusionObjectsStageProps) => {
|
||||
if (objects.length === 0) return null;
|
||||
|
||||
const renderObject = (obj: EditorObject) => (
|
||||
<ObjectRenderer
|
||||
key={`occlusion-${obj.id}`}
|
||||
obj={obj}
|
||||
isSelected={false}
|
||||
transformerRef={{ current: null }}
|
||||
layerRef={{ current: null }}
|
||||
onSelect={() => {}}
|
||||
onUpdate={() => {}}
|
||||
draggable={false}
|
||||
allObjects={allObjects}
|
||||
stageWidth={stageWidth}
|
||||
stageHeight={stageHeight}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stage
|
||||
width={stageWidth * scale}
|
||||
height={stageHeight * scale}
|
||||
scaleX={scale}
|
||||
scaleY={scale}
|
||||
listening={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
<Layer listening={false}>
|
||||
{objects
|
||||
.filter((obj) => obj.type === "group")
|
||||
.map(renderObject)}
|
||||
{objects
|
||||
.filter((obj) => !obj.groupId && obj.type !== "group")
|
||||
.map(renderObject)}
|
||||
{objects
|
||||
.filter((obj) => obj.groupId && obj.type !== "group")
|
||||
.map((obj) => {
|
||||
const groupObject = objects.find((o) => o.id === obj.groupId);
|
||||
if (!groupObject) return null;
|
||||
|
||||
return (
|
||||
<Group key={`occlusion-group-member-${obj.id}`} listening={false}>
|
||||
{renderObject(obj)}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Layer>
|
||||
</Stage>
|
||||
);
|
||||
};
|
||||
|
||||
export default OcclusionObjectsStage;
|
||||
@@ -8,6 +8,10 @@ interface TransformerControlsProps {
|
||||
isGroupedWithMask: boolean;
|
||||
}
|
||||
|
||||
const SELECTION_BLUE = "#93c5fd";
|
||||
const ANCHOR_SIZE = 5;
|
||||
const ROTATE_ANCHOR_OFFSET = 22;
|
||||
|
||||
const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => {
|
||||
if (!selectedObject) return null;
|
||||
|
||||
@@ -43,12 +47,18 @@ const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask
|
||||
}}
|
||||
ignoreStroke={isText}
|
||||
perfectDrawEnabled={false}
|
||||
anchorFill="#3b82f6"
|
||||
anchorFill={SELECTION_BLUE}
|
||||
anchorStroke="#ffffff"
|
||||
borderStroke="#3b82f6"
|
||||
borderStroke={SELECTION_BLUE}
|
||||
borderStrokeWidth={1}
|
||||
padding={-2}
|
||||
anchorSize={8}
|
||||
anchorSize={ANCHOR_SIZE}
|
||||
rotateAnchorOffset={ROTATE_ANCHOR_OFFSET}
|
||||
anchorStyleFunc={(anchor) => {
|
||||
if (anchor.hasName("rotater")) {
|
||||
anchor.cornerRadius(ANCHOR_SIZE / 2);
|
||||
}
|
||||
}}
|
||||
rotateEnabled={!isGroup && !isGroupedWithMask}
|
||||
enabledAnchors={enabledAnchors}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import Konva from "konva";
|
||||
import { useEditorStore, type EditorObject } from "@/pages/editor/store/editorStore";
|
||||
import { useTextDefaultsStore } from "@/pages/editor/store/textDefaultsStore";
|
||||
import { createDrawingObject } from "./drawingUtils";
|
||||
import { DEFAULT_TEXT_MAX_WIDTH_PX } from "@/pages/editor/utils/textStyle";
|
||||
|
||||
export const useDrawingHandlers = () => {
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
@@ -82,6 +83,7 @@ export const useDrawingHandlers = () => {
|
||||
letterSpacing: defaults.letterSpacing,
|
||||
wordSpacing: defaults.wordSpacing,
|
||||
height: defaults.fontSize || 24,
|
||||
textMaxWidth: DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||
};
|
||||
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
||||
addObject(newText);
|
||||
|
||||
@@ -67,6 +67,7 @@ const buildPencilObject = (
|
||||
points: DrawPoint[],
|
||||
stroke: string,
|
||||
strokeWidth: number,
|
||||
opacity: number,
|
||||
): EditorObject => {
|
||||
const xs = points.map((p) => p.x);
|
||||
const ys = points.map((p) => p.y);
|
||||
@@ -89,7 +90,7 @@ const buildPencilObject = (
|
||||
stroke,
|
||||
strokeWidth,
|
||||
tension: DEFAULT_TENSION,
|
||||
opacity: 100,
|
||||
opacity,
|
||||
rotation: 0,
|
||||
};
|
||||
};
|
||||
@@ -100,6 +101,7 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
|
||||
const pointerIdRef = useRef<number | null>(null);
|
||||
const strokeRef = useRef("#000000");
|
||||
const strokeWidthRef = useRef(3);
|
||||
const opacityRef = useRef(100);
|
||||
|
||||
const [drawingState, setDrawingState] = useState<PencilDrawingState>(INITIAL_STATE);
|
||||
|
||||
@@ -141,7 +143,12 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
|
||||
return;
|
||||
}
|
||||
|
||||
const obj = buildPencilObject(points, strokeRef.current, strokeWidthRef.current);
|
||||
const obj = buildPencilObject(
|
||||
points,
|
||||
strokeRef.current,
|
||||
strokeWidthRef.current,
|
||||
opacityRef.current,
|
||||
);
|
||||
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
||||
addObject(obj);
|
||||
reset();
|
||||
@@ -165,6 +172,7 @@ export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>)
|
||||
(pos: DrawPoint, pointerId: number) => {
|
||||
strokeRef.current = defaults.stroke;
|
||||
strokeWidthRef.current = defaults.strokeWidth;
|
||||
opacityRef.current = defaults.opacity;
|
||||
isDrawingRef.current = true;
|
||||
pointerIdRef.current = pointerId;
|
||||
pointsRef.current = [pos];
|
||||
|
||||
@@ -28,7 +28,7 @@ const AudioInput: FC = () => {
|
||||
try {
|
||||
for (const file of acceptedFiles) {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const result = await uploadFile({ file });
|
||||
const audioUrl = result?.data?.url;
|
||||
if (!audioUrl) continue;
|
||||
const audioId = `audio-${Date.now()}-${Math.random()}`;
|
||||
|
||||
@@ -10,14 +10,23 @@ const ImageGallery: FC = () => {
|
||||
const { gallery, addGalleryItem, removeGalleryItem } = useEditorStore();
|
||||
const { mutateAsync: uploadFile } = useSingleUpload();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const handleFileChange = async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
for (const file of files) {
|
||||
const result = await uploadFile(file);
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const result = await uploadFile({
|
||||
file,
|
||||
onProgress: (fileProgress) => {
|
||||
const overall = Math.round(((i + fileProgress / 100) / files.length) * 100);
|
||||
setUploadProgress(overall);
|
||||
},
|
||||
});
|
||||
const imageUrl = result?.data?.url;
|
||||
if (!imageUrl) continue;
|
||||
|
||||
@@ -33,6 +42,7 @@ const ImageGallery: FC = () => {
|
||||
// TODO: show error toast
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,6 +64,7 @@ const ImageGallery: FC = () => {
|
||||
isMultiple={true}
|
||||
hidePreview
|
||||
isLoading={isUploading}
|
||||
uploadProgress={isUploading ? uploadProgress : undefined}
|
||||
/>
|
||||
|
||||
{gallery.length === 0 && !isUploading && (
|
||||
|
||||
@@ -22,6 +22,8 @@ const PencilInstruction: FC = () => {
|
||||
<ColorPicker
|
||||
label="رنگ قلم"
|
||||
value={defaults.stroke}
|
||||
opacity={defaults.opacity}
|
||||
onOpacityChange={(value) => updateDefaults({ opacity: value })}
|
||||
onChange={(value) => updateDefaults({ stroke: value })}
|
||||
/>
|
||||
<Input
|
||||
|
||||
@@ -106,13 +106,6 @@ const TextInstruction: FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// State محلی برای نمایش شفافیت
|
||||
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${formState.opacity}`);
|
||||
|
||||
useEffect(() => {
|
||||
setOpacityDisplay(`${formState.opacity}`);
|
||||
}, [formState.opacity]);
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -164,41 +157,14 @@ const TextInstruction: FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<div className="flex gap-3">
|
||||
<ColorPicker
|
||||
label="رنگ"
|
||||
className="flex-1"
|
||||
value={formState.fill}
|
||||
onChange={(value) => handleChange("fill", value)}
|
||||
/>
|
||||
<div className="w-28">
|
||||
<Input
|
||||
type="number"
|
||||
label="شفافیت"
|
||||
className="px-3"
|
||||
value={opacityDisplay}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setOpacityDisplay(value);
|
||||
|
||||
// اعمال تغییرات به صورت لایو اگر مقدار معتبر است
|
||||
const numValue = parseInt(value);
|
||||
if (!isNaN(numValue) && numValue >= 0 && numValue <= 100) {
|
||||
handleChange("opacity", numValue);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
const numValue = parseInt(e.target.value) || 0;
|
||||
const clampedValue = Math.min(100, Math.max(0, numValue));
|
||||
setOpacityDisplay(`${clampedValue}`);
|
||||
handleChange("opacity", clampedValue);
|
||||
}}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ColorPicker
|
||||
label="رنگ"
|
||||
value={formState.fill}
|
||||
opacity={formState.opacity}
|
||||
onOpacityChange={(value) => handleChange("opacity", value)}
|
||||
onChange={(value) => handleChange("fill", value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* <div className="mt-4 flex gap-2">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type FC, useCallback, useMemo } from "react";
|
||||
import { type FC, useCallback, useMemo, useState } from "react";
|
||||
import { useEditorStore, type ToolType } from "@/pages/editor/store/editorStore";
|
||||
import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
||||
import { CloseCircle } from "iconsax-react";
|
||||
@@ -9,30 +9,50 @@ const VideoInput: FC = () => {
|
||||
const { objects, addObject, setSelectedObjectId, setTool, deleteObject } =
|
||||
useEditorStore();
|
||||
const { mutateAsync: uploadFile } = useSingleUpload();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
for (const file of acceptedFiles) {
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
const videoUrl = result?.data?.url;
|
||||
if (!videoUrl) continue;
|
||||
const videoId = `video-${Date.now()}-${Math.random()}`;
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
type: "video" as ToolType,
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 300,
|
||||
videoUrl,
|
||||
};
|
||||
addObject(newVideo);
|
||||
setSelectedObjectId(newVideo.id);
|
||||
setTool("select");
|
||||
} catch {
|
||||
// TODO: show error toast
|
||||
if (acceptedFiles.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
for (let i = 0; i < acceptedFiles.length; i++) {
|
||||
const file = acceptedFiles[i];
|
||||
try {
|
||||
const result = await uploadFile({
|
||||
file,
|
||||
onProgress: (fileProgress) => {
|
||||
const overall = Math.round(
|
||||
((i + fileProgress / 100) / acceptedFiles.length) * 100
|
||||
);
|
||||
setUploadProgress(overall);
|
||||
},
|
||||
});
|
||||
const videoUrl = result?.data?.url;
|
||||
if (!videoUrl) continue;
|
||||
const videoId = `video-${Date.now()}-${Math.random()}`;
|
||||
const newVideo = {
|
||||
id: videoId,
|
||||
type: "video" as ToolType,
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 400,
|
||||
height: 300,
|
||||
videoUrl,
|
||||
};
|
||||
addObject(newVideo);
|
||||
setSelectedObjectId(newVideo.id);
|
||||
setTool("select");
|
||||
} catch {
|
||||
// TODO: show error toast
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
},
|
||||
[addObject, setSelectedObjectId, setTool, uploadFile]
|
||||
@@ -43,7 +63,8 @@ const VideoInput: FC = () => {
|
||||
accept: {
|
||||
'video/*': ['.mp4', '.webm', '.ogg', '.mov', '.avi']
|
||||
},
|
||||
multiple: true
|
||||
multiple: true,
|
||||
disabled: isUploading,
|
||||
});
|
||||
|
||||
const videoObjects = useMemo(() => {
|
||||
@@ -61,6 +82,8 @@ const VideoInput: FC = () => {
|
||||
<VideoUploadZone
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
isLoading={isUploading}
|
||||
uploadProgress={isUploading ? uploadProgress : undefined}
|
||||
/>
|
||||
|
||||
{videoObjects.length > 0 && (
|
||||
|
||||
@@ -22,6 +22,7 @@ const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsPr
|
||||
};
|
||||
const stroke = selectedObject.stroke ?? "#1e40af";
|
||||
const strokeWidth = selectedObject.strokeWidth ?? 2;
|
||||
const opacity = selectedObject.opacity ?? 100;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -29,6 +30,8 @@ const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsPr
|
||||
label="رنگ پر"
|
||||
value={fill}
|
||||
readOnly={fillType === "gradient"}
|
||||
opacity={opacity}
|
||||
onOpacityChange={(value) => onUpdate(selectedObject.id, { opacity: value })}
|
||||
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
||||
/>
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
|
||||
removeRow,
|
||||
removeColumn,
|
||||
changeCellBackground,
|
||||
changeCellBackgroundOpacity,
|
||||
selectedCellId,
|
||||
} = useEditorStore();
|
||||
|
||||
@@ -50,6 +51,12 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCellBackgroundOpacityChange = (opacity: number) => {
|
||||
if (selectedCellId && tableDataObj) {
|
||||
changeCellBackgroundOpacity(selectedObject.id, selectedCellId, opacity);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedCell = selectedCellId && tableDataObj ? tableDataObj.cells[selectedCellId] : null;
|
||||
|
||||
return (
|
||||
@@ -111,6 +118,8 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
|
||||
<ColorPicker
|
||||
label="رنگ پسزمینه سلول"
|
||||
value={selectedCell.background}
|
||||
opacity={selectedCell.backgroundOpacity ?? 100}
|
||||
onOpacityChange={handleCellBackgroundOpacityChange}
|
||||
onChange={handleCellBackgroundChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,10 @@ const LineSettings = ({ selectedObject, onUpdate }: LineSettingsProps) => {
|
||||
<ColorPicker
|
||||
label="رنگ خط"
|
||||
value={selectedObject.stroke || "#000000"}
|
||||
opacity={selectedObject.opacity ?? 100}
|
||||
onOpacityChange={(value) =>
|
||||
onUpdate(selectedObject.id, { opacity: value })
|
||||
}
|
||||
onChange={(value) => onUpdate(selectedObject.id, { stroke: value })}
|
||||
/>
|
||||
<Input
|
||||
|
||||
@@ -51,6 +51,10 @@ const LinkSettings = ({ selectedObject, onUpdate }: LinkSettingsProps) => {
|
||||
<ColorPicker
|
||||
label="رنگ متن"
|
||||
value={selectedObject.fill || "#0000ff"}
|
||||
opacity={selectedObject.opacity ?? 100}
|
||||
onOpacityChange={(value) =>
|
||||
onUpdate(selectedObject.id, { opacity: value })
|
||||
}
|
||||
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -10,6 +10,7 @@ type ShapeSettingsProps = {
|
||||
const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
||||
const baseWidth = selectedObject.width ?? 100;
|
||||
const baseHeight = selectedObject.height ?? 100;
|
||||
const baseOpacity = selectedObject.opacity ?? 100;
|
||||
const baseFill = selectedObject.fill ?? "#3b82f6";
|
||||
const fillType = selectedObject.fillType ?? "solid";
|
||||
const gradient = selectedObject.gradient ?? {
|
||||
@@ -20,6 +21,7 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
||||
const baseStroke = selectedObject.stroke ?? "#1e40af";
|
||||
const baseStrokeWidth = selectedObject.strokeWidth ?? 0;
|
||||
const baseBorderRadius = selectedObject.borderRadius ?? 0;
|
||||
const baseBlur = selectedObject.blur ?? 0;
|
||||
const isSquareShape =
|
||||
selectedObject.shapeType === "square" || selectedObject.shapeType === undefined;
|
||||
|
||||
@@ -49,6 +51,12 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
||||
label="رنگ پسزمینه"
|
||||
value={baseFill}
|
||||
readOnly={fillType === "gradient"}
|
||||
opacity={baseOpacity}
|
||||
onOpacityChange={(value) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
opacity: value,
|
||||
})
|
||||
}
|
||||
onChange={(value) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
fill: value,
|
||||
@@ -179,6 +187,35 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
||||
min={0}
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm">بلور</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={50}
|
||||
value={baseBlur}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
blur: Math.max(0, Math.min(50, Number(e.target.value))),
|
||||
})
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={50}
|
||||
value={baseBlur}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
blur: Math.max(0, Math.min(50, Number(e.target.value) || 0)),
|
||||
})
|
||||
}
|
||||
className="w-16 h-9 rounded-lg border border-border px-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,17 @@ const SizeSettings = ({ selectedObject, onUpdate, defaultWidth = 100, defaultHei
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="گردی گوشه"
|
||||
type="number"
|
||||
value={selectedObject.borderRadius ?? 0}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
borderRadius: Math.max(0, parseInt(e.target.value) || 0),
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { EditorObject } from "../../../store/editorStore";
|
||||
import Input from "@/components/Input";
|
||||
import ColorPicker from "@/components/ColorPicker";
|
||||
import { DEFAULT_TEXT_MAX_WIDTH_PX } from "@/pages/editor/utils/textStyle";
|
||||
|
||||
type TextSettingsProps = {
|
||||
selectedObject: EditorObject;
|
||||
@@ -60,6 +61,18 @@ const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => {
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="حداکثر عرض متن"
|
||||
type="number"
|
||||
value={selectedObject.textMaxWidth ?? DEFAULT_TEXT_MAX_WIDTH_PX}
|
||||
step="1"
|
||||
min="80"
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
textMaxWidth: Math.max(80, parseNumber(e.target.value) || DEFAULT_TEXT_MAX_WIDTH_PX),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">چینش متن</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
@@ -89,6 +102,10 @@ const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => {
|
||||
<ColorPicker
|
||||
label="رنگ متن"
|
||||
value={selectedObject.fill || "#000000"}
|
||||
opacity={selectedObject.opacity ?? 100}
|
||||
onOpacityChange={(value) =>
|
||||
onUpdate(selectedObject.id, { opacity: value })
|
||||
}
|
||||
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import type { EditorObject } from "../../../store/editorStore";
|
||||
import Input from "@/components/Input";
|
||||
import type { EditorObject } from "../../../store/editorStore";
|
||||
|
||||
type TransformSettingsProps = {
|
||||
selectedObject: EditorObject;
|
||||
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||
selectedObject: EditorObject;
|
||||
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||
};
|
||||
|
||||
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
|
||||
return (
|
||||
<div className="pt-4 flex flex-col gap-4 border-t border-border">
|
||||
<Input
|
||||
label="موقعیت X"
|
||||
type="number"
|
||||
value={Math.round(selectedObject.x)}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
x: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="موقعیت Y"
|
||||
type="number"
|
||||
value={Math.round(selectedObject.y)}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
y: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="چرخش (درجه)"
|
||||
type="number"
|
||||
value={selectedObject.rotation || 0}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
rotation: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="pt-4 flex flex-col gap-4 border-t border-border">
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
label="موقعیت X"
|
||||
type="number"
|
||||
value={Math.round(selectedObject.x)}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
x: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label="موقعیت Y"
|
||||
type="number"
|
||||
value={Math.round(selectedObject.y)}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
y: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="چرخش (درجه)"
|
||||
type="number"
|
||||
value={selectedObject.rotation || 0}
|
||||
onChange={(e) =>
|
||||
onUpdate(selectedObject.id, {
|
||||
rotation: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransformSettings;
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Star } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { ShapeProps } from "./types";
|
||||
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
||||
import { useShapeBlurCache } from "../../utils/shapeBlur";
|
||||
|
||||
const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||
const shapeRef = useRef<Konva.Star>(null);
|
||||
@@ -18,6 +20,21 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
||||
|
||||
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||
const hasStroke = actualStrokeWidth > 0;
|
||||
const blurRadius = obj.blur ?? 0;
|
||||
const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined;
|
||||
// Editor blur is implemented via backdrop-filter (see BlurBackdropLayer).
|
||||
// Avoid applying Konva blur filters on the element itself.
|
||||
const blurProps = {};
|
||||
|
||||
useShapeBlurCache(shapeRef, 0, [
|
||||
obj.width,
|
||||
obj.height,
|
||||
obj.fill,
|
||||
obj.fillType,
|
||||
obj.gradient,
|
||||
obj.stroke,
|
||||
actualStrokeWidth,
|
||||
]);
|
||||
|
||||
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
|
||||
const displayStroke = isSelected
|
||||
@@ -31,14 +48,14 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
||||
const displayStrokeWidth = isSelected
|
||||
? (hasStroke ? actualStrokeWidth : 3)
|
||||
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
||||
const gradientProps = isMask
|
||||
? {}
|
||||
: getKonvaGradientProps(
|
||||
obj.fillType,
|
||||
obj.gradient,
|
||||
obj.width || 100,
|
||||
obj.height || 100,
|
||||
"centered",
|
||||
const gradientProps = isMask || blurRadius > 0
|
||||
? {}
|
||||
: getKonvaGradientProps(
|
||||
obj.fillType,
|
||||
obj.gradient,
|
||||
obj.width || 100,
|
||||
obj.height || 100,
|
||||
"centered",
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -51,12 +68,15 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
||||
numPoints={5}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={radius}
|
||||
fill={isMask ? "transparent" : obj.fill}
|
||||
// When blur is enabled, tint is rendered by BlurBackdropLayer.
|
||||
fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")}
|
||||
{...gradientProps}
|
||||
stroke={displayStroke}
|
||||
strokeWidth={displayStrokeWidth}
|
||||
dash={isMask && showGuide ? [10, 5] : undefined}
|
||||
rotation={obj.rotation || 0}
|
||||
opacity={(obj.opacity ?? 100) / 100}
|
||||
{...blurProps}
|
||||
draggable={draggable}
|
||||
onClick={(e) => {
|
||||
if (shapeRef.current) {
|
||||
|
||||
@@ -42,6 +42,7 @@ const ArrowShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePro
|
||||
hitStrokeWidth={20}
|
||||
perfectDrawEnabled={false}
|
||||
rotation={obj.rotation || 0}
|
||||
opacity={(obj.opacity ?? 100) / 100}
|
||||
draggable={draggable}
|
||||
onClick={(e) => {
|
||||
if (shapeRef.current) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import { Rect, Group, Circle, Text as KonvaText } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { ShapeProps } from "./types";
|
||||
import { getObjectBorderRadius } from "../../utils/borderRadius";
|
||||
|
||||
const AudioShape = ({
|
||||
obj,
|
||||
@@ -15,6 +16,7 @@ const AudioShape = ({
|
||||
|
||||
const containerWidth = obj.width || 320;
|
||||
const containerHeight = obj.height || 56;
|
||||
const cornerRadius = getObjectBorderRadius(obj.borderRadius ?? 8);
|
||||
const barY = containerHeight * 0.55;
|
||||
const barHeight = Math.max(4, containerHeight * 0.12);
|
||||
const playRadius = Math.min(18, containerHeight * 0.35);
|
||||
@@ -70,7 +72,7 @@ const AudioShape = ({
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
fill="#f3f4f6"
|
||||
cornerRadius={8}
|
||||
cornerRadius={cornerRadius}
|
||||
stroke={isSelected ? "#3b82f6" : "#d1d5db"}
|
||||
strokeWidth={isSelected ? 3 : 1}
|
||||
onClick={handleGroupClick}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Circle } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { ShapeProps } from "./types";
|
||||
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
||||
import { useShapeBlurCache } from "../../utils/shapeBlur";
|
||||
|
||||
const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||
const shapeRef = useRef<Konva.Circle>(null);
|
||||
@@ -14,6 +16,20 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
|
||||
|
||||
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||
const hasStroke = actualStrokeWidth > 0;
|
||||
const blurRadius = obj.blur ?? 0;
|
||||
const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined;
|
||||
// Editor blur is implemented via backdrop-filter (see BlurBackdropLayer).
|
||||
// Avoid applying Konva blur filters on the element itself.
|
||||
const blurProps = {};
|
||||
|
||||
useShapeBlurCache(shapeRef, 0, [
|
||||
obj.width,
|
||||
obj.fill,
|
||||
obj.fillType,
|
||||
obj.gradient,
|
||||
obj.stroke,
|
||||
actualStrokeWidth,
|
||||
]);
|
||||
|
||||
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
|
||||
const displayStroke = isSelected
|
||||
@@ -28,9 +44,9 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
|
||||
? (hasStroke ? actualStrokeWidth : 3)
|
||||
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
||||
const diameter = obj.width || 100;
|
||||
const gradientProps = isMask
|
||||
? {}
|
||||
: getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
|
||||
const gradientProps = isMask || blurRadius > 0
|
||||
? {}
|
||||
: getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
|
||||
|
||||
return (
|
||||
<Circle
|
||||
@@ -40,12 +56,15 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
|
||||
x={obj.x}
|
||||
y={obj.y}
|
||||
radius={(obj.width || 50) / 2}
|
||||
fill={isMask ? "transparent" : obj.fill}
|
||||
// When blur is enabled, tint is rendered by BlurBackdropLayer.
|
||||
fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")}
|
||||
{...gradientProps}
|
||||
stroke={displayStroke}
|
||||
strokeWidth={displayStrokeWidth}
|
||||
dash={isMask && showGuide ? [10, 5] : undefined}
|
||||
rotation={obj.rotation || 0}
|
||||
opacity={(obj.opacity ?? 100) / 100}
|
||||
{...blurProps}
|
||||
draggable={draggable}
|
||||
onClick={(e) => {
|
||||
if (shapeRef.current) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Image as KonvaImage } from "react-konva";
|
||||
import { Image as KonvaImage, Group, Rect } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import useImage from "use-image";
|
||||
import type { ShapeProps } from "./types";
|
||||
import { clampPositionToStage } from "../../utils/stageBounds";
|
||||
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
|
||||
|
||||
const ImageObject = ({
|
||||
obj,
|
||||
@@ -17,9 +18,8 @@ const ImageObject = ({
|
||||
}: ShapeProps) => {
|
||||
const [image, status] = useImage(obj.imageUrl || "");
|
||||
const shapeRef = useRef<Konva.Image>(null);
|
||||
const groupRef = useRef<Konva.Group>(null);
|
||||
|
||||
// Notify parent (ObjectRenderer) as soon as the image is decoded and ready.
|
||||
// This lets the masked group re-cache itself after the image content appears.
|
||||
useEffect(() => {
|
||||
if (status === "loaded" && onImageReady) {
|
||||
onImageReady();
|
||||
@@ -31,6 +31,84 @@ const ImageObject = ({
|
||||
const width = obj.width || image.width;
|
||||
const height = obj.height || image.height;
|
||||
const hasStageBounds = stageWidth != null && stageHeight != null;
|
||||
const cornerRadius = getObjectBorderRadius(obj.borderRadius);
|
||||
const clipFunc = createRoundedRectClipFunc(width, height, cornerRadius);
|
||||
|
||||
const dragBoundFunc =
|
||||
draggable && hasStageBounds
|
||||
? (pos: { x: number; y: number }) =>
|
||||
clampPositionToStage(
|
||||
pos.x,
|
||||
pos.y,
|
||||
width,
|
||||
height,
|
||||
stageWidth!,
|
||||
stageHeight!,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const handleDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
|
||||
const node = e.target;
|
||||
let x = node.x();
|
||||
let y = node.y();
|
||||
if (hasStageBounds) {
|
||||
({ x, y } = clampPositionToStage(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
stageWidth!,
|
||||
stageHeight!,
|
||||
));
|
||||
node.position({ x, y });
|
||||
}
|
||||
onUpdate(obj.id, { x, y });
|
||||
};
|
||||
|
||||
const handleClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
const node = cornerRadius > 0 ? groupRef.current : shapeRef.current;
|
||||
if (node) {
|
||||
onSelect(obj.id, node, e);
|
||||
}
|
||||
};
|
||||
|
||||
if (cornerRadius > 0) {
|
||||
return (
|
||||
<Group
|
||||
ref={groupRef}
|
||||
id={obj.id}
|
||||
name="canvas-object"
|
||||
x={obj.x}
|
||||
y={obj.y}
|
||||
rotation={obj.rotation || 0}
|
||||
draggable={draggable}
|
||||
clipFunc={clipFunc}
|
||||
dragBoundFunc={dragBoundFunc}
|
||||
onClick={handleClick}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<KonvaImage
|
||||
x={0}
|
||||
y={0}
|
||||
image={image}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
{isSelected && (
|
||||
<Rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={width}
|
||||
height={height}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={3}
|
||||
cornerRadius={cornerRadius}
|
||||
listening={false}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KonvaImage
|
||||
@@ -46,44 +124,11 @@ const ImageObject = ({
|
||||
strokeWidth={isSelected ? 3 : 0}
|
||||
rotation={obj.rotation || 0}
|
||||
draggable={draggable}
|
||||
dragBoundFunc={
|
||||
draggable && hasStageBounds
|
||||
? (pos) =>
|
||||
clampPositionToStage(
|
||||
pos.x,
|
||||
pos.y,
|
||||
width,
|
||||
height,
|
||||
stageWidth!,
|
||||
stageHeight!,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (shapeRef.current) {
|
||||
onSelect(obj.id, shapeRef.current, e);
|
||||
}
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
const node = e.target;
|
||||
let x = node.x();
|
||||
let y = node.y();
|
||||
if (hasStageBounds) {
|
||||
({ x, y } = clampPositionToStage(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
stageWidth!,
|
||||
stageHeight!,
|
||||
));
|
||||
node.position({ x, y });
|
||||
}
|
||||
onUpdate(obj.id, { x, y });
|
||||
}}
|
||||
dragBoundFunc={dragBoundFunc}
|
||||
onClick={handleClick}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageObject;
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const LineShape = ({ obj, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||
hitStrokeWidth={20}
|
||||
perfectDrawEnabled={false}
|
||||
rotation={obj.rotation || 0}
|
||||
opacity={(obj.opacity ?? 100) / 100}
|
||||
draggable={draggable}
|
||||
onClick={(e) => {
|
||||
if (shapeRef.current) {
|
||||
|
||||
@@ -35,6 +35,7 @@ const LinkShape = ({
|
||||
fontStyle={fontWeight}
|
||||
underline={true}
|
||||
rotation={obj.rotation || 0}
|
||||
opacity={(obj.opacity ?? 100) / 100}
|
||||
draggable={draggable}
|
||||
onClick={(e) => {
|
||||
// در حالت editor، لینکها قابل کلیک نیستند
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Rect } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { ShapeProps } from "./types";
|
||||
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
||||
import { useShapeBlurCache } from "../../utils/shapeBlur";
|
||||
|
||||
const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||
const shapeRef = useRef<Konva.Rect>(null);
|
||||
@@ -15,6 +17,22 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
|
||||
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||
const hasStroke = actualStrokeWidth > 0;
|
||||
const cornerRadius = Math.max(0, obj.borderRadius ?? 0);
|
||||
const blurRadius = obj.blur ?? 0;
|
||||
const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined;
|
||||
// Editor blur is implemented via backdrop-filter (see BlurBackdropLayer),
|
||||
// so we intentionally do not apply Konva blur filters here.
|
||||
const blurProps = {};
|
||||
|
||||
useShapeBlurCache(shapeRef, 0, [
|
||||
obj.width,
|
||||
obj.height,
|
||||
cornerRadius,
|
||||
obj.fill,
|
||||
obj.fillType,
|
||||
obj.gradient,
|
||||
obj.stroke,
|
||||
actualStrokeWidth,
|
||||
]);
|
||||
|
||||
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
|
||||
const displayStroke = isSelected
|
||||
@@ -28,7 +46,7 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
|
||||
const displayStrokeWidth = isSelected
|
||||
? (hasStroke ? actualStrokeWidth : 3)
|
||||
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
||||
const gradientProps = isMask
|
||||
const gradientProps = isMask || blurRadius > 0
|
||||
? {}
|
||||
: getKonvaGradientProps(
|
||||
obj.fillType,
|
||||
@@ -48,12 +66,16 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
|
||||
width={obj.width || 100}
|
||||
height={obj.height || 100}
|
||||
cornerRadius={cornerRadius}
|
||||
fill={isMask ? "transparent" : obj.fill}
|
||||
// When blur is enabled, visual tint is rendered by BlurBackdropLayer.
|
||||
// Use a tiny non-zero alpha so Konva hit-detection still works for dragging.
|
||||
fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")}
|
||||
{...gradientProps}
|
||||
stroke={displayStroke}
|
||||
strokeWidth={displayStrokeWidth}
|
||||
dash={isMask && showGuide ? [10, 5] : undefined}
|
||||
rotation={obj.rotation || 0}
|
||||
opacity={(obj.opacity ?? 100) / 100}
|
||||
{...blurProps}
|
||||
draggable={draggable}
|
||||
onClick={(e) => {
|
||||
if (shapeRef.current) {
|
||||
|
||||
@@ -4,10 +4,16 @@ import Konva from "konva";
|
||||
import type { TextShapeProps } from "./types";
|
||||
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
|
||||
import {
|
||||
buildCanvasFont,
|
||||
buildKonvaCanvasFont,
|
||||
DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||
getEffectiveTextWidth,
|
||||
getKonvaFontStyle,
|
||||
measureTextBlock,
|
||||
resolveTextMaxWidth,
|
||||
usesWrappedLayout,
|
||||
} from "@/pages/editor/utils/textStyle";
|
||||
import { getColorWithOpacity } from "@/pages/editor/utils/colorOpacity";
|
||||
|
||||
const getFontWeight = (fontWeight?: string): string => {
|
||||
if (!fontWeight) return "normal";
|
||||
@@ -27,28 +33,6 @@ const getFontWeight = (fontWeight?: string): string => {
|
||||
return weightMap[fontWeight] || "normal";
|
||||
};
|
||||
|
||||
/** Konva.Text فقط `fontStyle` دارد (نه fontWeight جداگانه)؛ مقدار باید normal|bold|italic باشد. */
|
||||
const getKonvaFontStyle = (fontWeight?: string): string => {
|
||||
const w = getFontWeight(fontWeight);
|
||||
if (w === "bold" || w === "bolder") return "bold";
|
||||
const n = parseInt(w, 10);
|
||||
if (!Number.isNaN(n) && n >= 600) return "bold";
|
||||
return "normal";
|
||||
};
|
||||
|
||||
const getColorWithOpacity = (fill?: string, opacity?: number): string => {
|
||||
if (!fill) return "#000000";
|
||||
if (opacity === undefined || opacity === 100) return fill;
|
||||
|
||||
const hex = fill.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
const alpha = opacity / 100;
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
const TextShape = ({
|
||||
obj,
|
||||
onSelect,
|
||||
@@ -58,6 +42,7 @@ const TextShape = ({
|
||||
const shapeRef = useRef<Konva.Text>(null);
|
||||
const groupRef = useRef<Konva.Group>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [maxTextWidth, setMaxTextWidth] = useState<number>(DEFAULT_TEXT_MAX_WIDTH_PX);
|
||||
const fontSize = obj.fontSize || 24;
|
||||
const allowWrap = usesWrappedLayout(
|
||||
obj.text,
|
||||
@@ -86,6 +71,7 @@ const TextShape = ({
|
||||
const groupNode = groupRef.current;
|
||||
if (!textNode || !groupNode) return;
|
||||
if (groupNode.scaleX() !== 1 || groupNode.scaleY() !== 1) return;
|
||||
const stage = textNode.getStage();
|
||||
|
||||
textNode._setTextData();
|
||||
|
||||
@@ -110,6 +96,14 @@ const TextShape = ({
|
||||
lineHeight: obj.lineHeight ?? 1.2,
|
||||
});
|
||||
const konvaWidth = Math.max(1, Math.ceil(rw));
|
||||
const resolvedMaxWidth = resolveTextMaxWidth({
|
||||
anchorX: obj.x || 0,
|
||||
pageWidth: stage?.width(),
|
||||
defaultMaxWidth: obj.textMaxWidth ?? DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||
});
|
||||
if (Math.abs(resolvedMaxWidth - maxTextWidth) > 1) {
|
||||
setMaxTextWidth(resolvedMaxWidth);
|
||||
}
|
||||
const nextWidth = getEffectiveTextWidth(
|
||||
konvaWidth,
|
||||
obj.text,
|
||||
@@ -122,16 +116,33 @@ const TextShape = ({
|
||||
},
|
||||
!allowWrap,
|
||||
) ?? konvaWidth;
|
||||
const clampedWidth = Math.min(nextWidth, resolvedMaxWidth);
|
||||
const forcedWrapByWidth = clampedWidth < nextWidth - 1;
|
||||
let measuredHeight = rh;
|
||||
if (forcedWrapByWidth) {
|
||||
const wrappedMeasureNode = textNode.clone({
|
||||
width: clampedWidth,
|
||||
wrap: "word",
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
wrappedMeasureNode._setTextData();
|
||||
const { height: wrappedHeight } = wrappedMeasureNode.getClientRect({
|
||||
skipTransform: true,
|
||||
});
|
||||
wrappedMeasureNode.destroy();
|
||||
measuredHeight = Math.max(measuredHeight, wrappedHeight);
|
||||
}
|
||||
const nextHeight = Math.max(
|
||||
1,
|
||||
Math.ceil(rh),
|
||||
Math.ceil(measuredHeight),
|
||||
cssMeasured.height,
|
||||
);
|
||||
const widthChanged = Math.abs((obj.width || 0) - nextWidth) > 1;
|
||||
const widthChanged = Math.abs((obj.width || 0) - clampedWidth) > 1;
|
||||
const heightChanged = Math.abs((obj.height || 0) - nextHeight) > 1;
|
||||
|
||||
if (widthChanged || heightChanged) {
|
||||
onUpdate(obj.id, { width: nextWidth, height: nextHeight });
|
||||
onUpdate(obj.id, { width: clampedWidth, height: nextHeight });
|
||||
}
|
||||
textNode.getLayer()?.batchDraw();
|
||||
};
|
||||
@@ -155,11 +166,9 @@ const TextShape = ({
|
||||
}
|
||||
|
||||
const specs = [
|
||||
`${fontSize}px ${resolvedFamily}`,
|
||||
buildCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
|
||||
buildKonvaCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
|
||||
`${konvaStyle} normal ${fontSize}px ${resolvedFamily}`,
|
||||
`bold ${fontSize}px ${resolvedFamily}`,
|
||||
`300 ${fontSize}px ${resolvedFamily}`,
|
||||
`200 ${fontSize}px ${resolvedFamily}`,
|
||||
];
|
||||
|
||||
void Promise.all(
|
||||
@@ -191,7 +200,10 @@ const TextShape = ({
|
||||
obj.lineHeight,
|
||||
obj.width,
|
||||
obj.height,
|
||||
obj.x,
|
||||
obj.textMaxWidth,
|
||||
allowWrap,
|
||||
maxTextWidth,
|
||||
onUpdate,
|
||||
]);
|
||||
|
||||
@@ -199,6 +211,8 @@ const TextShape = ({
|
||||
const konvaFontStyle = useMemo(() => getKonvaFontStyle(obj.fontWeight), [obj.fontWeight]);
|
||||
const fillColor = useMemo(() => getColorWithOpacity(obj.fill, obj.opacity), [obj.fill, obj.opacity]);
|
||||
const textAlign = obj.textAlign ?? "right";
|
||||
const wrapByMaxWidth = (obj.width ?? 0) >= maxTextWidth - 1;
|
||||
const shouldWrap = allowWrap || wrapByMaxWidth;
|
||||
|
||||
const handleDblClick = () => {
|
||||
if (!shapeRef.current || !groupRef.current) return;
|
||||
@@ -238,7 +252,8 @@ const TextShape = ({
|
||||
position: "absolute",
|
||||
top: `${areaPosition.y}px`,
|
||||
left: `${areaPosition.x}px`,
|
||||
width: `${box.width}px`,
|
||||
width: `${Math.min(box.width, maxTextWidth * stageScale)}px`,
|
||||
maxWidth: `${maxTextWidth * stageScale}px`,
|
||||
minHeight: `${box.height}px`,
|
||||
fontSize: `${(obj.fontSize || 24) * stageScale}px`,
|
||||
fontFamily: fontFamily,
|
||||
@@ -358,9 +373,9 @@ const TextShape = ({
|
||||
fontStyle={konvaFontStyle}
|
||||
letterSpacing={obj.letterSpacing ?? 0}
|
||||
lineHeight={obj.lineHeight ?? 1.2}
|
||||
width={allowWrap ? obj.width || undefined : undefined}
|
||||
width={shouldWrap ? obj.width || maxTextWidth : undefined}
|
||||
align={textAlign}
|
||||
wrap={allowWrap ? "word" : "none"}
|
||||
wrap={shouldWrap ? "word" : "none"}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { RegularPolygon } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import type { ShapeProps } from "./types";
|
||||
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
||||
import { useShapeBlurCache } from "../../utils/shapeBlur";
|
||||
|
||||
const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||
const shapeRef = useRef<Konva.RegularPolygon>(null);
|
||||
@@ -15,6 +17,21 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
||||
|
||||
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||
const hasStroke = actualStrokeWidth > 0;
|
||||
const blurRadius = obj.blur ?? 0;
|
||||
const blurHitFill = blurRadius > 0 ? getColorWithOpacity(obj.fill ?? "#000000", 0.5) : undefined;
|
||||
// Editor blur is implemented via backdrop-filter (see BlurBackdropLayer).
|
||||
// Avoid applying Konva blur filters on the element itself.
|
||||
const blurProps = {};
|
||||
|
||||
useShapeBlurCache(shapeRef, 0, [
|
||||
obj.width,
|
||||
obj.fill,
|
||||
obj.fillType,
|
||||
obj.gradient,
|
||||
obj.stroke,
|
||||
actualStrokeWidth,
|
||||
obj.height,
|
||||
]);
|
||||
|
||||
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
|
||||
const displayStroke = isSelected
|
||||
@@ -28,14 +45,14 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
||||
const displayStrokeWidth = isSelected
|
||||
? (hasStroke ? actualStrokeWidth : 3)
|
||||
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
||||
const gradientProps = isMask
|
||||
? {}
|
||||
: getKonvaGradientProps(
|
||||
obj.fillType,
|
||||
obj.gradient,
|
||||
obj.width || 100,
|
||||
obj.height || 100,
|
||||
"centered",
|
||||
const gradientProps = isMask || blurRadius > 0
|
||||
? {}
|
||||
: getKonvaGradientProps(
|
||||
obj.fillType,
|
||||
obj.gradient,
|
||||
obj.width || 100,
|
||||
obj.height || 100,
|
||||
"centered",
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -47,12 +64,15 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
||||
y={obj.y + (obj.height || 100) / 2}
|
||||
sides={3}
|
||||
radius={radius}
|
||||
fill={isMask ? "transparent" : obj.fill}
|
||||
// When blur is enabled, tint is rendered by BlurBackdropLayer.
|
||||
fill={isMask ? "transparent" : blurRadius > 0 ? blurHitFill : (obj.fill ?? "#000000")}
|
||||
{...gradientProps}
|
||||
stroke={displayStroke}
|
||||
strokeWidth={displayStrokeWidth}
|
||||
dash={isMask && showGuide ? [10, 5] : undefined}
|
||||
rotation={obj.rotation || 0}
|
||||
opacity={(obj.opacity ?? 100) / 100}
|
||||
{...blurProps}
|
||||
draggable={draggable}
|
||||
onClick={(e) => {
|
||||
if (shapeRef.current) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Image as KonvaImage, Rect, Group, Circle, Text as KonvaText } from "react-konva";
|
||||
import Konva from "konva";
|
||||
import useImage from "use-image";
|
||||
import type { ShapeProps } from "./types";
|
||||
import { createPortal } from "react-dom";
|
||||
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
|
||||
|
||||
const VideoShape = ({
|
||||
obj,
|
||||
@@ -72,6 +73,37 @@ const VideoShape = ({
|
||||
|
||||
const containerWidth = obj.width || 400;
|
||||
const containerHeight = obj.height || 300;
|
||||
const cornerRadius = getObjectBorderRadius(obj.borderRadius);
|
||||
const clipFunc = createRoundedRectClipFunc(containerWidth, containerHeight, cornerRadius);
|
||||
|
||||
// همتراز با viewer که object-fit: contain دارد
|
||||
const imageLayout = useMemo(() => {
|
||||
if (!image) {
|
||||
return { x: 0, y: 0, width: containerWidth, height: containerHeight };
|
||||
}
|
||||
|
||||
const naturalWidth = image.width || 1;
|
||||
const naturalHeight = image.height || 1;
|
||||
const containerRatio = containerWidth / containerHeight;
|
||||
const imageRatio = naturalWidth / naturalHeight;
|
||||
|
||||
let width: number;
|
||||
let height: number;
|
||||
if (imageRatio > containerRatio) {
|
||||
width = containerWidth;
|
||||
height = containerWidth / imageRatio;
|
||||
} else {
|
||||
height = containerHeight;
|
||||
width = containerHeight * imageRatio;
|
||||
}
|
||||
|
||||
return {
|
||||
x: (containerWidth - width) / 2,
|
||||
y: (containerHeight - height) / 2,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
}, [image, containerWidth, containerHeight]);
|
||||
|
||||
const handlePlayClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||
e.cancelBubble = true;
|
||||
@@ -121,20 +153,32 @@ const VideoShape = ({
|
||||
<Rect
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
fill="#000000"
|
||||
fill="transparent"
|
||||
cornerRadius={cornerRadius}
|
||||
stroke={isSelected ? "#3b82f6" : "#666666"}
|
||||
strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)}
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
<Group clipFunc={clipFunc}>
|
||||
{image ? (
|
||||
<KonvaImage
|
||||
x={0}
|
||||
y={0}
|
||||
width={containerWidth}
|
||||
height={containerHeight}
|
||||
image={image}
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
<>
|
||||
<Rect
|
||||
x={imageLayout.x}
|
||||
y={imageLayout.y}
|
||||
width={imageLayout.width}
|
||||
height={imageLayout.height}
|
||||
fill="#000000"
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
<KonvaImage
|
||||
x={imageLayout.x}
|
||||
y={imageLayout.y}
|
||||
width={imageLayout.width}
|
||||
height={imageLayout.height}
|
||||
image={image}
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Rect
|
||||
x={0}
|
||||
@@ -145,6 +189,7 @@ const VideoShape = ({
|
||||
onClick={handleGroupClick}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Group
|
||||
name="playButton"
|
||||
onClick={handlePlayClick}
|
||||
|
||||
@@ -44,6 +44,7 @@ export const createInitialCells = (
|
||||
id: cellId,
|
||||
text: "",
|
||||
background: "#f5f5f5",
|
||||
backgroundOpacity: 100,
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
};
|
||||
@@ -59,12 +60,14 @@ export const createInitialPage = (name: string): Page => ({
|
||||
guides: [],
|
||||
backgroundType: "color",
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundOpacity: 100,
|
||||
backgroundGradient: {
|
||||
from: "#ffffff",
|
||||
to: "#e2e8f0",
|
||||
angle: 135,
|
||||
},
|
||||
backgroundImageUrl: "",
|
||||
backgroundVideoUrl: "",
|
||||
});
|
||||
|
||||
export const getDefaultPageName = (index: number): string =>
|
||||
|
||||
@@ -68,8 +68,10 @@ type PageBackgroundSettings = Pick<
|
||||
Page,
|
||||
| "backgroundType"
|
||||
| "backgroundColor"
|
||||
| "backgroundOpacity"
|
||||
| "backgroundGradient"
|
||||
| "backgroundImageUrl"
|
||||
| "backgroundVideoUrl"
|
||||
>;
|
||||
|
||||
type EditorStoreType = {
|
||||
@@ -126,6 +128,11 @@ type EditorStoreType = {
|
||||
cellId: string,
|
||||
color: string,
|
||||
) => void;
|
||||
changeCellBackgroundOpacity: (
|
||||
tableId: string,
|
||||
cellId: string,
|
||||
opacity: number,
|
||||
) => void;
|
||||
applyTableResize: (
|
||||
tableId: string,
|
||||
newWidth: number,
|
||||
@@ -185,12 +192,14 @@ const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
|
||||
smartGuide: true,
|
||||
backgroundType: "color",
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundOpacity: 100,
|
||||
backgroundGradient: {
|
||||
from: "#ffffff",
|
||||
to: "#e2e8f0",
|
||||
angle: 135,
|
||||
},
|
||||
backgroundImageUrl: "",
|
||||
backgroundVideoUrl: "",
|
||||
};
|
||||
|
||||
export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
@@ -638,6 +647,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
id: cellId,
|
||||
text: "",
|
||||
background: "#f5f5f5",
|
||||
backgroundOpacity: 100,
|
||||
width: tableData.cellWidth,
|
||||
height: tableData.cellHeight,
|
||||
};
|
||||
@@ -668,6 +678,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
id: cellId,
|
||||
text: "",
|
||||
background: "#f5f5f5",
|
||||
backgroundOpacity: 100,
|
||||
width: tableData.cellWidth,
|
||||
height: tableData.cellHeight,
|
||||
};
|
||||
@@ -774,6 +785,28 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
},
|
||||
});
|
||||
},
|
||||
changeCellBackgroundOpacity: (tableId, cellId, opacity) => {
|
||||
get().commitObjectHistoryBeforeChange();
|
||||
const state = get();
|
||||
const obj = state.objects.find((o) => o.id === tableId);
|
||||
if (!obj || !obj.tableData) return;
|
||||
|
||||
const { tableData } = obj;
|
||||
const updatedCells = {
|
||||
...tableData.cells,
|
||||
[cellId]: {
|
||||
...tableData.cells[cellId],
|
||||
backgroundOpacity: Math.min(100, Math.max(0, opacity)),
|
||||
},
|
||||
};
|
||||
|
||||
state.updateObject(tableId, {
|
||||
tableData: {
|
||||
...tableData,
|
||||
cells: updatedCells,
|
||||
},
|
||||
});
|
||||
},
|
||||
applyTableResize: (tableId, newWidth, newHeight) => {
|
||||
const state = get();
|
||||
const obj = state.objects.find((o) => o.id === tableId);
|
||||
@@ -856,8 +889,10 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
guides: [...pageToDuplicate.guides],
|
||||
backgroundType: pageToDuplicate.backgroundType,
|
||||
backgroundColor: pageToDuplicate.backgroundColor,
|
||||
backgroundOpacity: pageToDuplicate.backgroundOpacity ?? 100,
|
||||
backgroundGradient: pageToDuplicate.backgroundGradient,
|
||||
backgroundImageUrl: pageToDuplicate.backgroundImageUrl,
|
||||
backgroundVideoUrl: pageToDuplicate.backgroundVideoUrl,
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
@@ -1050,6 +1085,8 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
page.backgroundType ?? fallbackSettings.backgroundType ?? "color",
|
||||
backgroundColor:
|
||||
page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff",
|
||||
backgroundOpacity:
|
||||
page.backgroundOpacity ?? fallbackSettings.backgroundOpacity ?? 100,
|
||||
backgroundGradient: page.backgroundGradient ??
|
||||
fallbackSettings.backgroundGradient ?? {
|
||||
from: "#ffffff",
|
||||
@@ -1058,6 +1095,8 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||
},
|
||||
backgroundImageUrl:
|
||||
page.backgroundImageUrl ?? fallbackSettings.backgroundImageUrl ?? "",
|
||||
backgroundVideoUrl:
|
||||
page.backgroundVideoUrl ?? fallbackSettings.backgroundVideoUrl ?? "",
|
||||
}));
|
||||
const firstPage = normalizedPages[0];
|
||||
const currentSettings = get().documentSettings;
|
||||
|
||||
@@ -33,6 +33,8 @@ export type TableCell = {
|
||||
id: string;
|
||||
text: string;
|
||||
background: string;
|
||||
/** شفافیت پسزمینه سلول (۰–۱۰۰) */
|
||||
backgroundOpacity?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
@@ -61,6 +63,8 @@ export type EditorObject = {
|
||||
stroke?: string;
|
||||
strokeWidth?: number;
|
||||
text?: string;
|
||||
/** حداکثر عرض باکس متن (px) برای wrap پیشفرض متنهای بلند */
|
||||
textMaxWidth?: number;
|
||||
fontSize?: number;
|
||||
lineHeight?: number;
|
||||
textAlign?: "left" | "center" | "right";
|
||||
@@ -80,6 +84,8 @@ export type EditorObject = {
|
||||
scaleY?: number;
|
||||
shapeType?: ShapeType;
|
||||
borderRadius?: number;
|
||||
/** میزان بلور شکل به پیکسل (۰ = بدون بلور) */
|
||||
blur?: number;
|
||||
tableData?: TableData;
|
||||
visible?: boolean;
|
||||
isMask?: boolean;
|
||||
@@ -117,12 +123,14 @@ export type Page = {
|
||||
guides: PageGuide[];
|
||||
backgroundType: BackgroundType;
|
||||
backgroundColor: string;
|
||||
backgroundOpacity?: number;
|
||||
backgroundGradient?: LinearGradient;
|
||||
backgroundImageUrl: string;
|
||||
backgroundVideoUrl: string;
|
||||
};
|
||||
|
||||
export type DisplayStyle = "single" | "double";
|
||||
export type BackgroundType = "color" | "gradient" | "image";
|
||||
export type BackgroundType = "color" | "gradient" | "image" | "video";
|
||||
|
||||
export type DocumentSettings = {
|
||||
displayStyle: DisplayStyle;
|
||||
@@ -132,8 +140,10 @@ export type DocumentSettings = {
|
||||
smartGuide: boolean;
|
||||
backgroundType: BackgroundType;
|
||||
backgroundColor: string;
|
||||
backgroundOpacity?: number;
|
||||
backgroundGradient?: LinearGradient;
|
||||
backgroundImageUrl: string;
|
||||
backgroundVideoUrl: string;
|
||||
};
|
||||
|
||||
/** تصویر آپلودشده در گالری کاتالوگ (مشترک بین همهٔ صفحات) */
|
||||
|
||||
@@ -3,6 +3,7 @@ import { create } from "zustand";
|
||||
export type PencilDefaults = {
|
||||
stroke: string;
|
||||
strokeWidth: number;
|
||||
opacity: number;
|
||||
};
|
||||
|
||||
type PencilDefaultsStoreType = {
|
||||
@@ -14,6 +15,7 @@ export const usePencilDefaultsStore = create<PencilDefaultsStoreType>((set) => (
|
||||
defaults: {
|
||||
stroke: "#000000",
|
||||
strokeWidth: 3,
|
||||
opacity: 100,
|
||||
},
|
||||
updateDefaults: (updates) =>
|
||||
set((state) => ({
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import type { EditorObject } from "../store/editorStore";
|
||||
import { getColorWithOpacity } from "./colorOpacity";
|
||||
import { toCssGradientAngle, type LinearGradient } from "./gradient";
|
||||
|
||||
const TRIANGLE_TOP_Y_PERCENT = 6.69873;
|
||||
const TRIANGLE_BOTTOM_Y_PERCENT = 93.30127;
|
||||
const BLUR_TINT_OPACITY_FACTOR = 0.25;
|
||||
|
||||
export type BlurOverlayGeometry = {
|
||||
leftPx: number;
|
||||
topPx: number;
|
||||
widthPx: number;
|
||||
heightPx: number;
|
||||
borderRadiusPx: number;
|
||||
clipPath?: string;
|
||||
transformOrigin: CSSProperties["transformOrigin"];
|
||||
rotation: number;
|
||||
};
|
||||
|
||||
export function isBlurBackdropObject(obj: EditorObject): boolean {
|
||||
if (obj.visible === false) return false;
|
||||
if (obj.isMask) return false;
|
||||
if (obj.type !== "rectangle") return false;
|
||||
return (obj.blur ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Logical stage coords → overlay pixel space (matches Konva getAbsolutePosition after stage scale). */
|
||||
export function computeBlurOverlayGeometry(
|
||||
obj: EditorObject,
|
||||
scale: number,
|
||||
livePosition?: { x: number; y: number } | null,
|
||||
): BlurOverlayGeometry {
|
||||
const rotation = obj.rotation ?? 0;
|
||||
const shapeType = obj.shapeType ?? "square";
|
||||
const anchorX =
|
||||
livePosition?.x ??
|
||||
(shapeType === "triangle" || shapeType === "abstract"
|
||||
? ((obj.x ?? 0) + (obj.width ?? 100) / 2) * scale
|
||||
: (obj.x ?? 0) * scale);
|
||||
const anchorY =
|
||||
livePosition?.y ??
|
||||
(shapeType === "triangle" || shapeType === "abstract"
|
||||
? ((obj.y ?? 0) + (obj.height ?? 100) / 2) * scale
|
||||
: (obj.y ?? 0) * scale);
|
||||
|
||||
if (shapeType === "circle") {
|
||||
const sizePx = (obj.width ?? 100) * scale;
|
||||
const radiusPx = sizePx / 2;
|
||||
return {
|
||||
leftPx: anchorX - radiusPx,
|
||||
topPx: anchorY - radiusPx,
|
||||
widthPx: sizePx,
|
||||
heightPx: sizePx,
|
||||
borderRadiusPx: radiusPx,
|
||||
transformOrigin: "center center",
|
||||
rotation,
|
||||
};
|
||||
}
|
||||
|
||||
if (shapeType === "triangle") {
|
||||
const side = Math.min(obj.width ?? 100, obj.height ?? 100);
|
||||
const radiusPx = (side / 2) * scale;
|
||||
return {
|
||||
leftPx: anchorX - radiusPx,
|
||||
topPx: anchorY - radiusPx,
|
||||
widthPx: side * scale,
|
||||
heightPx: side * scale,
|
||||
borderRadiusPx: 0,
|
||||
clipPath: `polygon(50% ${TRIANGLE_TOP_Y_PERCENT}%, 0% ${TRIANGLE_BOTTOM_Y_PERCENT}%, 100% ${TRIANGLE_BOTTOM_Y_PERCENT}%)`,
|
||||
transformOrigin: "center center",
|
||||
rotation,
|
||||
};
|
||||
}
|
||||
|
||||
if (shapeType === "abstract") {
|
||||
const rawW = obj.width ?? 100;
|
||||
const rawH = obj.height ?? 100;
|
||||
const baseRadius = (Math.min(rawW, rawH) / 2) * scale;
|
||||
const radius = baseRadius * 0.85;
|
||||
return {
|
||||
leftPx: anchorX - radius,
|
||||
topPx: anchorY - radius,
|
||||
widthPx: radius * 2,
|
||||
heightPx: radius * 2,
|
||||
borderRadiusPx: 0,
|
||||
clipPath:
|
||||
"polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)",
|
||||
transformOrigin: "center center",
|
||||
rotation,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
leftPx: anchorX,
|
||||
topPx: anchorY,
|
||||
widthPx: (obj.width ?? 100) * scale,
|
||||
heightPx: (obj.height ?? 100) * scale,
|
||||
borderRadiusPx: Math.max(0, obj.borderRadius ?? 0) * scale,
|
||||
transformOrigin: "0% 0%",
|
||||
rotation,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildBlurOverlayStyle(
|
||||
obj: EditorObject,
|
||||
scale: number,
|
||||
livePosition?: { x: number; y: number } | null,
|
||||
): CSSProperties {
|
||||
const blur = obj.blur ?? 0;
|
||||
const blurPx = blur * scale;
|
||||
const geometry = computeBlurOverlayGeometry(obj, scale, livePosition);
|
||||
const objOpacity = obj.opacity ?? 100;
|
||||
const tintOpacity = Math.max(0, Math.min(100, objOpacity * BLUR_TINT_OPACITY_FACTOR));
|
||||
const defaultFill = "#3b82f6";
|
||||
|
||||
const overlayBackground: CSSProperties =
|
||||
obj.fillType === "gradient" && obj.gradient
|
||||
? (() => {
|
||||
const g = obj.gradient as LinearGradient;
|
||||
const angle = toCssGradientAngle(g.angle);
|
||||
const from = getColorWithOpacity(g.from, tintOpacity);
|
||||
const to = getColorWithOpacity(g.to, tintOpacity);
|
||||
return {
|
||||
backgroundImage: `linear-gradient(${angle}deg, ${from} 0%, ${to} 100%)`,
|
||||
backgroundColor: "transparent",
|
||||
};
|
||||
})()
|
||||
: {
|
||||
backgroundColor: getColorWithOpacity(obj.fill ?? defaultFill, tintOpacity),
|
||||
};
|
||||
|
||||
return {
|
||||
position: "absolute",
|
||||
left: `${geometry.leftPx}px`,
|
||||
top: `${geometry.topPx}px`,
|
||||
width: `${geometry.widthPx}px`,
|
||||
height: `${geometry.heightPx}px`,
|
||||
pointerEvents: "none",
|
||||
backdropFilter: `blur(${blurPx}px)`,
|
||||
WebkitBackdropFilter: `blur(${blurPx}px)`,
|
||||
...overlayBackground,
|
||||
overflow:
|
||||
geometry.clipPath
|
||||
? "hidden"
|
||||
: geometry.borderRadiusPx > 0
|
||||
? "hidden"
|
||||
: undefined,
|
||||
borderRadius: geometry.borderRadiusPx > 0 ? `${geometry.borderRadiusPx}px` : undefined,
|
||||
clipPath: geometry.clipPath,
|
||||
transform: geometry.rotation ? `rotate(${geometry.rotation}deg)` : undefined,
|
||||
transformOrigin: geometry.transformOrigin,
|
||||
};
|
||||
}
|
||||
|
||||
export type BlurStackEntry = {
|
||||
key: string;
|
||||
blurObject: EditorObject;
|
||||
/** Objects rendered above this blur so they stay crisp. */
|
||||
occlusionObjects: EditorObject[];
|
||||
};
|
||||
|
||||
export function buildBlurStackEntries(objects: EditorObject[]): BlurStackEntry[] {
|
||||
const entries: BlurStackEntry[] = [];
|
||||
|
||||
for (let i = 0; i < objects.length; i++) {
|
||||
const obj = objects[i];
|
||||
if (!isBlurBackdropObject(obj)) continue;
|
||||
|
||||
entries.push({
|
||||
key: obj.id,
|
||||
blurObject: obj,
|
||||
occlusionObjects: objects.slice(i + 1),
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type Konva from "konva";
|
||||
|
||||
export const getObjectBorderRadius = (borderRadius?: number): number =>
|
||||
Math.max(0, borderRadius ?? 0);
|
||||
|
||||
export const clampBorderRadius = (
|
||||
radius: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): number => Math.max(0, Math.min(radius, width / 2, height / 2));
|
||||
|
||||
export const createRoundedRectClipFunc = (
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
): Konva.ContainerConfig["clipFunc"] => {
|
||||
const r = clampBorderRadius(radius, width, height);
|
||||
if (r <= 0) return undefined;
|
||||
|
||||
return (ctx) => {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(0, 0, width, height, r);
|
||||
ctx.closePath();
|
||||
};
|
||||
};
|
||||
|
||||
export const getCssBorderRadius = (
|
||||
borderRadius: number | undefined,
|
||||
scale: number,
|
||||
): string | undefined => {
|
||||
const r = getObjectBorderRadius(borderRadius) * scale;
|
||||
return r > 0 ? `${r}px` : undefined;
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/** Converts a hex color + 0–100 opacity to rgba (editor/viewer convention). */
|
||||
export const getColorWithOpacity = (fill?: string, opacity?: number): string => {
|
||||
if (!fill) return "#000000";
|
||||
if (opacity === undefined || opacity >= 100) return fill;
|
||||
|
||||
const hex = fill.replace("#", "");
|
||||
if (!/^[0-9A-Fa-f]{3,8}$/.test(hex)) return fill;
|
||||
|
||||
const paddedHex =
|
||||
hex.length === 3
|
||||
? hex
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
: hex.length === 4
|
||||
? hex
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
: hex.padEnd(6, "0").slice(0, 6);
|
||||
|
||||
const r = parseInt(paddedHex.substring(0, 2), 16);
|
||||
const g = parseInt(paddedHex.substring(2, 4), 16);
|
||||
const b = parseInt(paddedHex.substring(4, 6), 16);
|
||||
const alpha = opacity / 100;
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
@@ -25,23 +25,24 @@ const getGradientPoints = (
|
||||
): { start: Point; end: Point } => {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const safeHeight = Math.max(1, height);
|
||||
// App/UI angle: 0° = top→bottom, 90° = left→right (clockwise).
|
||||
const rad = toRadians(normalizeAngle(angle));
|
||||
const dx = Math.cos(rad);
|
||||
const dy = Math.sin(rad);
|
||||
const half = Math.sqrt(safeWidth * safeWidth + safeHeight * safeHeight) / 2;
|
||||
const halfX = Math.sin(rad) * half;
|
||||
const halfY = Math.cos(rad) * half;
|
||||
|
||||
if (mode === "centered") {
|
||||
return {
|
||||
start: { x: -dx * half, y: -dy * half },
|
||||
end: { x: dx * half, y: dy * half },
|
||||
start: { x: -halfX, y: -halfY },
|
||||
end: { x: halfX, y: halfY },
|
||||
};
|
||||
}
|
||||
|
||||
const cx = safeWidth / 2;
|
||||
const cy = safeHeight / 2;
|
||||
return {
|
||||
start: { x: cx - dx * half, y: cy - dy * half },
|
||||
end: { x: cx + dx * half, y: cy + dy * half },
|
||||
start: { x: cx - halfX, y: cy - halfY },
|
||||
end: { x: cx + halfX, y: cy + halfY },
|
||||
};
|
||||
};
|
||||
|
||||
@@ -91,7 +92,11 @@ export const getSvgGradientEndpoints = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Convert app/UI angle to CSS linear-gradient degrees (0deg = upward in CSS). */
|
||||
export const toCssGradientAngle = (angle: number) =>
|
||||
normalizeAngle(180 - normalizeAngle(angle));
|
||||
|
||||
export const toCssLinearGradient = (gradient: LinearGradient | undefined) => {
|
||||
if (!gradient) return undefined;
|
||||
return `linear-gradient(${normalizeAngle(gradient.angle)}deg, ${gradient.from} 0%, ${gradient.to} 100%)`;
|
||||
return `linear-gradient(${toCssGradientAngle(gradient.angle)}deg, ${gradient.from} 0%, ${gradient.to} 100%)`;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useLayoutEffect, type CSSProperties } from "react";
|
||||
import type Konva from "konva";
|
||||
import KonvaLib from "konva";
|
||||
|
||||
export function getKonvaBlurProps(blur?: number) {
|
||||
const blurRadius = blur ?? 0;
|
||||
if (blurRadius <= 0) return {};
|
||||
return {
|
||||
filters: [KonvaLib.Filters.Blur],
|
||||
blurRadius,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCssBlurStyle(blur?: number, scale = 1): CSSProperties {
|
||||
const blurPx = blur ?? 0;
|
||||
if (blurPx <= 0) return {};
|
||||
return { filter: `blur(${blurPx * scale}px)` };
|
||||
}
|
||||
|
||||
export function useShapeBlurCache(
|
||||
shapeRef: React.RefObject<Konva.Shape | null>,
|
||||
blur: number | undefined,
|
||||
cacheKey: unknown,
|
||||
) {
|
||||
useLayoutEffect(() => {
|
||||
const node = shapeRef.current;
|
||||
if (!node) return;
|
||||
|
||||
const blurRadius = blur ?? 0;
|
||||
if (blurRadius > 0) {
|
||||
node.clearCache();
|
||||
node.cache({
|
||||
offset: blurRadius,
|
||||
drawBorder: false,
|
||||
});
|
||||
} else {
|
||||
node.clearCache();
|
||||
}
|
||||
|
||||
node.getLayer()?.batchDraw();
|
||||
}, [blur, cacheKey, shapeRef]);
|
||||
}
|
||||
@@ -11,12 +11,18 @@ export const getCssFontWeight = (fontWeight?: string): number => {
|
||||
return 400;
|
||||
};
|
||||
|
||||
/** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */
|
||||
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => {
|
||||
if (!fontWeight || fontWeight === "normal") return false;
|
||||
if (fontWeight === "bold" || fontWeight === "bolder") return true;
|
||||
/**
|
||||
* Konva.Text `fontStyle` — supports `normal`, `bold`, or numeric strings like `200`.
|
||||
* @see https://konvajs.org/api/Konva.Text.html#fontStyle
|
||||
*/
|
||||
export const getKonvaFontStyle = (fontWeight?: string): string => {
|
||||
if (!fontWeight || fontWeight === "normal") return "normal";
|
||||
if (fontWeight === "bold" || fontWeight === "bolder") return "bold";
|
||||
|
||||
const n = parseInt(fontWeight, 10);
|
||||
return !Number.isNaN(n) && n >= 600;
|
||||
if (!Number.isNaN(n)) return n.toString();
|
||||
|
||||
return "normal";
|
||||
};
|
||||
|
||||
/** Canvas/CSS `font` string shared by editor measurement and viewer layout. */
|
||||
@@ -30,15 +36,15 @@ export const buildCanvasFont = (
|
||||
return `${weight} ${fontSize}px ${family}`;
|
||||
};
|
||||
|
||||
/** Canvas font string that matches Konva.Text (`fontStyle: bold`). */
|
||||
/** Canvas font string that matches Konva.Text `_getContextFont()`. */
|
||||
export const buildKonvaCanvasFont = (
|
||||
fontSize: number,
|
||||
fontFamily?: string,
|
||||
fontWeight?: string,
|
||||
): string => {
|
||||
const family = getFontFamily(fontFamily);
|
||||
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal";
|
||||
return `${style} ${fontSize}px ${family}`;
|
||||
const style = getKonvaFontStyle(fontWeight);
|
||||
return `${style} normal ${fontSize}px ${family}`;
|
||||
};
|
||||
|
||||
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
|
||||
@@ -63,6 +69,39 @@ export const usesWrappedLayout = (
|
||||
|
||||
/** Extra px so HTML layout does not wrap tighter than Konva/canvas metrics. */
|
||||
export const SINGLE_LINE_WIDTH_SLACK_PX = 12;
|
||||
export const DEFAULT_TEXT_MAX_WIDTH_PX = 520;
|
||||
const MIN_TEXT_MAX_WIDTH_PX = 80;
|
||||
|
||||
/**
|
||||
* Maximum width for text boxes.
|
||||
* - never larger than page/view bounds
|
||||
* - has a sane default for long single-line text
|
||||
*/
|
||||
export const resolveTextMaxWidth = (args: {
|
||||
anchorX: number;
|
||||
pageWidth?: number;
|
||||
defaultMaxWidth?: number;
|
||||
edgePadding?: number;
|
||||
}): number => {
|
||||
const {
|
||||
anchorX,
|
||||
pageWidth,
|
||||
defaultMaxWidth = DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||
edgePadding = 8,
|
||||
} = args;
|
||||
|
||||
const fromAnchor = Math.floor(anchorX - edgePadding);
|
||||
const fromPage =
|
||||
pageWidth !== undefined
|
||||
? Math.floor(Math.min(anchorX - edgePadding, pageWidth - edgePadding))
|
||||
: fromAnchor;
|
||||
|
||||
const hardLimit = Math.max(MIN_TEXT_MAX_WIDTH_PX, fromPage);
|
||||
return Math.max(
|
||||
MIN_TEXT_MAX_WIDTH_PX,
|
||||
Math.min(defaultMaxWidth, hardLimit),
|
||||
);
|
||||
};
|
||||
|
||||
export type TextMeasureOptions = {
|
||||
fontSize: number;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as api from "../service/UploaderService";
|
||||
|
||||
export type SingleUploadParams = {
|
||||
file: File;
|
||||
onProgress?: (progress: number) => void;
|
||||
};
|
||||
|
||||
export const useSingleUpload = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.singleUpload,
|
||||
mutationFn: ({ file, onProgress }: SingleUploadParams) =>
|
||||
api.singleUpload(file, onProgress),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -5,13 +5,21 @@ import type {
|
||||
} from "../types/Types";
|
||||
|
||||
export const singleUpload = async (
|
||||
file: File
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<SingleUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const { data } = await axios.post<SingleUploadResponse>(
|
||||
`/admin/single-file`,
|
||||
formData
|
||||
formData,
|
||||
{
|
||||
onUploadProgress: (event) => {
|
||||
if (event.total) {
|
||||
onProgress?.(Math.round((event.loaded / event.total) * 100));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { forwardRef, memo, useEffect, useRef } from 'react';
|
||||
import { type PageData } from '../types';
|
||||
import type { EditorObject } from '@/pages/editor/store/editorStore';
|
||||
import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
|
||||
import { getCustomShapePointBounds } from '@/pages/editor/utils/customShape';
|
||||
import { getFontFamily } from '@/pages/editor/utils/fontFamily';
|
||||
import {
|
||||
DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||
getCssFontWeight,
|
||||
getViewerTextLayout,
|
||||
resolveTextMaxWidth,
|
||||
usesWrappedLayout,
|
||||
} from '@/pages/editor/utils/textStyle';
|
||||
import {
|
||||
buildBlurOverlayStyle,
|
||||
buildBlurStackEntries,
|
||||
isBlurBackdropObject,
|
||||
} from '@/pages/editor/utils/blurOverlayLayout';
|
||||
import { getCssBorderRadius } from '@/pages/editor/utils/borderRadius';
|
||||
import '@/pages/viewer/styles/entranceAnimations.css';
|
||||
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
||||
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
|
||||
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
|
||||
import { usePageMediaReady } from '@/pages/viewer/hooks/usePageMediaReady';
|
||||
|
||||
const getRasterObjectStyle = (
|
||||
obj: EditorObject,
|
||||
@@ -24,6 +33,7 @@ const getRasterObjectStyle = (
|
||||
const scaleY = obj.scaleY ?? 1;
|
||||
const width = obj.width != null ? obj.width * scaleX * scale : undefined;
|
||||
const height = obj.height != null ? obj.height * scaleY * scale : undefined;
|
||||
const borderRadius = getCssBorderRadius(obj.borderRadius, scale);
|
||||
|
||||
return {
|
||||
...baseStyle,
|
||||
@@ -33,11 +43,14 @@ const getRasterObjectStyle = (
|
||||
height: height != null ? `${height}px` : 'auto',
|
||||
objectFit: 'fill',
|
||||
zIndex: index,
|
||||
...(borderRadius ? { borderRadius, overflow: 'hidden' as const } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
type RenderObjectOptions = {
|
||||
skipEntrance?: boolean;
|
||||
/** در پیشنمایش (لیست صفحات و کاتالوگ) رسانه تعاملی نباشد */
|
||||
staticMedia?: boolean;
|
||||
};
|
||||
|
||||
type BookPageProps = {
|
||||
@@ -46,18 +59,26 @@ type BookPageProps = {
|
||||
pageWidth?: number;
|
||||
pageHeight?: number;
|
||||
onLinkClick?: (linkUrl: string) => void;
|
||||
backgroundType?: 'color' | 'gradient' | 'image';
|
||||
backgroundType?: 'color' | 'gradient' | 'image' | 'video';
|
||||
backgroundColor?: string;
|
||||
backgroundOpacity?: number;
|
||||
backgroundGradient?: {
|
||||
from: string;
|
||||
to: string;
|
||||
angle: number;
|
||||
};
|
||||
backgroundImageUrl?: string;
|
||||
backgroundVideoUrl?: string;
|
||||
/** فاز انیمیشن ورود — از BookViewer کنترل میشود */
|
||||
entrancePhase?: EntrancePhase;
|
||||
/** در پیشنمایش کاتالوگ انیمیشن ورود غیرفعال باشد */
|
||||
/** در پیشنمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
|
||||
disableEntranceAnimations?: boolean;
|
||||
/** وقتی رسانهٔ صفحه دیرتر از زمانبندی انیمیشن لود شد */
|
||||
onDeferredEntranceReady?: (pageId: number) => void;
|
||||
/** لایهٔ اسپینر لود رسانه را نشان نده (مثلاً ذرهبین) */
|
||||
hideMediaLoadingOverlay?: boolean;
|
||||
/** ویدیو/صوت فقط بهصورت کاور — بدون کنترل و بدون کلیک */
|
||||
staticMedia?: boolean;
|
||||
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
|
||||
showPaperShadow?: boolean;
|
||||
/** ورق سخت بهجای clip-path برای iOS قدیمی */
|
||||
@@ -77,9 +98,42 @@ type BookPageProps = {
|
||||
* این کامپوننت به عنوان child برای HTMLFlipBook استفاده میشود
|
||||
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
||||
*/
|
||||
const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
|
||||
const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
||||
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl, entrancePhase = 'idle', disableEntranceAnimations = false, onDeferredEntranceReady, hideMediaLoadingOverlay = false, staticMedia = false, showPaperShadow = true, useHardPage = false, idSuffix = '' }, ref) => {
|
||||
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
|
||||
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
|
||||
const effectiveBackgroundOpacity = backgroundOpacity ?? page.backgroundOpacity ?? 100;
|
||||
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
|
||||
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
|
||||
const effectiveBackgroundVideoUrl = backgroundVideoUrl ?? page.backgroundVideoUrl ?? '';
|
||||
|
||||
const isMediaReady = usePageMediaReady(
|
||||
page,
|
||||
{
|
||||
backgroundType: effectiveBackgroundType,
|
||||
backgroundImageUrl: effectiveBackgroundImageUrl,
|
||||
backgroundVideoUrl: effectiveBackgroundVideoUrl,
|
||||
},
|
||||
!hideMediaLoadingOverlay,
|
||||
);
|
||||
const deferredEntranceRef = useRef(false);
|
||||
|
||||
const phase: EntrancePhase = disableEntranceAnimations ? 'settled' : entrancePhase;
|
||||
const effectivePhase: EntrancePhase =
|
||||
hideMediaLoadingOverlay || isMediaReady ? phase : 'idle';
|
||||
|
||||
useEffect(() => {
|
||||
if (disableEntranceAnimations || hideMediaLoadingOverlay) return;
|
||||
if (phase === 'play' && !isMediaReady) {
|
||||
deferredEntranceRef.current = true;
|
||||
}
|
||||
}, [phase, isMediaReady, disableEntranceAnimations, hideMediaLoadingOverlay]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMediaReady || !deferredEntranceRef.current) return;
|
||||
deferredEntranceRef.current = false;
|
||||
onDeferredEntranceReady?.(page.id);
|
||||
}, [isMediaReady, onDeferredEntranceReady, page.id]);
|
||||
// تابع برای تبدیل opacity به رنگ (مثل editor)
|
||||
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
||||
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
|
||||
@@ -123,7 +177,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
rotationDeg?: number;
|
||||
},
|
||||
) =>
|
||||
mergeEntranceAnimationStyle(style, obj, scale, index, phase, {
|
||||
mergeEntranceAnimationStyle(style, obj, scale, index, effectivePhase, {
|
||||
...extra,
|
||||
flyLayoutPx,
|
||||
});
|
||||
@@ -163,6 +217,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Blur shapes use backdrop-filter overlays (see blur stack below), not CSS filter on the shape.
|
||||
if (isBlurBackdropObject(obj)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectFillStyle: React.CSSProperties =
|
||||
obj.fillType === 'gradient' && obj.gradient
|
||||
? { backgroundImage: toCssLinearGradient(obj.gradient) }
|
||||
@@ -194,14 +253,39 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
measureOpts,
|
||||
wrapped,
|
||||
});
|
||||
const maxWidthPx = Math.ceil(
|
||||
resolveTextMaxWidth({
|
||||
anchorX: (obj.x || 0) * scale,
|
||||
pageWidth: pageWidth ?? 794 * scale,
|
||||
defaultMaxWidth: (obj.textMaxWidth ?? DEFAULT_TEXT_MAX_WIDTH_PX) * scale,
|
||||
}),
|
||||
);
|
||||
const wrappedWidthPx =
|
||||
obj.width !== undefined ? Math.ceil(obj.width * scale) : undefined;
|
||||
const wrapByMaxWidth =
|
||||
(wrapped ? wrappedWidthPx : textLayout.widthPx) !== undefined &&
|
||||
(wrapped ? wrappedWidthPx : textLayout.widthPx)! > maxWidthPx;
|
||||
const finalWrapped = wrapped || wrapByMaxWidth;
|
||||
const resolvedWidthPx = finalWrapped
|
||||
? (wrappedWidthPx !== undefined
|
||||
? Math.min(wrappedWidthPx, maxWidthPx)
|
||||
: (textLayout.widthPx !== undefined
|
||||
? Math.min(textLayout.widthPx, maxWidthPx)
|
||||
: undefined))
|
||||
: textLayout.widthPx;
|
||||
const anchorRightPx = Math.round((obj.x || 0) * scale);
|
||||
const resolvedLeftPx =
|
||||
resolvedWidthPx !== undefined
|
||||
? anchorRightPx - resolvedWidthPx
|
||||
: textLayout.leftPx;
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={applyStyle(
|
||||
{
|
||||
...textBaseStyle,
|
||||
left: `${textLayout.leftPx}px`,
|
||||
width: textLayout.widthPx !== undefined ? `${textLayout.widthPx}px` : 'max-content',
|
||||
left: `${resolvedLeftPx}px`,
|
||||
width: resolvedWidthPx !== undefined ? `${resolvedWidthPx}px` : 'max-content',
|
||||
fontSize: `${fontSize * scale}px`,
|
||||
fontFamily: getFontFamily(obj.fontFamily),
|
||||
fontWeight: getCssFontWeight(obj.fontWeight),
|
||||
@@ -209,10 +293,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
textAlign: textLayout.textAlign,
|
||||
direction: 'rtl',
|
||||
color: getColorWithOpacity(obj.fill, obj.opacity),
|
||||
whiteSpace: wrapped ? 'pre-wrap' : 'nowrap',
|
||||
overflowWrap: wrapped ? 'break-word' : 'normal',
|
||||
whiteSpace: finalWrapped ? 'pre-wrap' : 'nowrap',
|
||||
overflowWrap: finalWrapped ? 'break-word' : 'normal',
|
||||
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
|
||||
boxSizing: 'content-box',
|
||||
zIndex: index,
|
||||
transform: textLayout.transform,
|
||||
transformOrigin: textLayout.transformOrigin,
|
||||
},
|
||||
@@ -242,7 +327,42 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
/>
|
||||
);
|
||||
|
||||
case 'video':
|
||||
case 'video': {
|
||||
const videoBorderRadius = getCssBorderRadius(obj.borderRadius, scale);
|
||||
const videoStyle: React.CSSProperties = {
|
||||
...baseStyle,
|
||||
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
||||
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
||||
objectFit: 'contain',
|
||||
zIndex: index,
|
||||
...(videoBorderRadius ? { borderRadius: videoBorderRadius, overflow: 'hidden' } : {}),
|
||||
};
|
||||
|
||||
if (options.staticMedia) {
|
||||
return (
|
||||
<video
|
||||
key={obj.id || index}
|
||||
src={obj.videoUrl || ''}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={applyStyle(
|
||||
{
|
||||
...videoStyle,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
)}
|
||||
onLoadedMetadata={(e) => {
|
||||
const video = e.currentTarget;
|
||||
if (video.currentTime === 0) {
|
||||
video.currentTime = 0.1;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<video
|
||||
key={obj.id || index}
|
||||
@@ -250,11 +370,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
controls
|
||||
style={applyStyle(
|
||||
{
|
||||
...baseStyle,
|
||||
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
||||
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
||||
objectFit: 'contain',
|
||||
zIndex: index,
|
||||
...videoStyle,
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
obj,
|
||||
@@ -271,8 +387,34 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'audio':
|
||||
case 'audio': {
|
||||
const audioBorderRadius = getCssBorderRadius(obj.borderRadius ?? 8, scale);
|
||||
const audioBaseStyle: React.CSSProperties = {
|
||||
...baseStyle,
|
||||
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
|
||||
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
|
||||
zIndex: index,
|
||||
...(audioBorderRadius ? { borderRadius: audioBorderRadius, overflow: 'hidden' } : {}),
|
||||
};
|
||||
|
||||
if (options.staticMedia) {
|
||||
return (
|
||||
<div
|
||||
key={obj.id || index}
|
||||
style={applyStyle(
|
||||
{
|
||||
...audioBaseStyle,
|
||||
backgroundColor: '#f3f4f6',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
obj,
|
||||
index,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<audio
|
||||
key={obj.id || index}
|
||||
@@ -281,10 +423,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
preload="metadata"
|
||||
style={applyStyle(
|
||||
{
|
||||
...baseStyle,
|
||||
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
|
||||
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
|
||||
zIndex: index,
|
||||
...audioBaseStyle,
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
obj,
|
||||
@@ -301,6 +440,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
const isInternalLink = obj.linkUrl?.startsWith('page://');
|
||||
@@ -455,6 +595,16 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
const fillColor = obj.fill || '#000000';
|
||||
const strokeColor = obj.stroke || 'transparent';
|
||||
const gradientId = `triangle-grad-${obj.id}-${index}${idSuffix}`;
|
||||
const triangleGradientEndpoints =
|
||||
obj.fillType === 'gradient' && obj.gradient
|
||||
? getSvgGradientEndpoints(
|
||||
obj.gradient,
|
||||
baseWidth * scale,
|
||||
baseHeight * scale,
|
||||
'centered',
|
||||
{ x: halfSize, y: halfSize },
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -478,25 +628,24 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
)}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
>
|
||||
{obj.fillType === 'gradient' && obj.gradient ? (
|
||||
{triangleGradientEndpoints ? (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2={size}
|
||||
y2={size}
|
||||
gradientTransform={`rotate(${obj.gradient.angle}, ${size / 2}, ${size / 2})`}
|
||||
x1={triangleGradientEndpoints.x1}
|
||||
y1={triangleGradientEndpoints.y1}
|
||||
x2={triangleGradientEndpoints.x2}
|
||||
y2={triangleGradientEndpoints.y2}
|
||||
>
|
||||
<stop offset="0%" stopColor={obj.gradient.from} />
|
||||
<stop offset="100%" stopColor={obj.gradient.to} />
|
||||
<stop offset="0%" stopColor={obj.gradient!.from} />
|
||||
<stop offset="100%" stopColor={obj.gradient!.to} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
) : null}
|
||||
<polygon
|
||||
points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`}
|
||||
fill={obj.fillType === 'gradient' && obj.gradient ? `url(#${gradientId})` : fillColor}
|
||||
fill={triangleGradientEndpoints ? `url(#${gradientId})` : fillColor}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
@@ -807,7 +956,10 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
key={cellId}
|
||||
style={{
|
||||
border: stroke ? `${(strokeWidth || 1) * scale}px solid ${stroke}` : `${1 * scale}px solid #ccc`,
|
||||
backgroundColor: cell?.background || '#ffffff',
|
||||
backgroundColor: getColorWithOpacity(
|
||||
cell?.background || '#ffffff',
|
||||
cell?.backgroundOpacity,
|
||||
),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -837,7 +989,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
: null;
|
||||
|
||||
if (!maskShape) {
|
||||
return renderObjectContent(obj, index);
|
||||
return renderObjectContent(obj, index, { staticMedia });
|
||||
}
|
||||
|
||||
const layout = getMaskedLayout(obj, maskShape, scale);
|
||||
@@ -847,7 +999,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
const content = renderObjectContent(
|
||||
{ ...obj, x: (obj.x || 0) - unionLeft, y: (obj.y || 0) - unionTop },
|
||||
index,
|
||||
{ skipEntrance: true },
|
||||
{ skipEntrance: true, staticMedia },
|
||||
);
|
||||
|
||||
if (!content) return null;
|
||||
@@ -876,11 +1028,6 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
const width = pageWidth ?? 794 * scale;
|
||||
const height = pageHeight ?? 1123 * scale;
|
||||
|
||||
const effectiveBackgroundType = backgroundType ?? page.backgroundType ?? 'color';
|
||||
const effectiveBackgroundColor = backgroundColor ?? page.backgroundColor ?? '#ffffff';
|
||||
const effectiveBackgroundGradient = backgroundGradient ?? page.backgroundGradient;
|
||||
const effectiveBackgroundImageUrl = backgroundImageUrl ?? page.backgroundImageUrl ?? '';
|
||||
|
||||
const bgStyle: React.CSSProperties =
|
||||
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
|
||||
? {
|
||||
@@ -893,7 +1040,9 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
? {
|
||||
backgroundImage: toCssLinearGradient(effectiveBackgroundGradient),
|
||||
}
|
||||
: { backgroundColor: effectiveBackgroundColor };
|
||||
: { backgroundColor: getColorWithOpacity(effectiveBackgroundColor, effectiveBackgroundOpacity) };
|
||||
|
||||
const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -909,30 +1058,105 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
zIndex: 1,
|
||||
...(useHardPage
|
||||
...(useHardPage && !showLoadingOverlay
|
||||
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{!useHardPage && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
visibility: showLoadingOverlay ? 'hidden' : 'visible',
|
||||
}}
|
||||
>
|
||||
{!useHardPage && (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
...bgStyle,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
{effectiveBackgroundType === 'video' && effectiveBackgroundVideoUrl && (
|
||||
<video
|
||||
aria-hidden
|
||||
src={effectiveBackgroundVideoUrl}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
|
||||
{buildBlurStackEntries(page.elements).map((entry) => {
|
||||
const blurIndex = page.elements.findIndex((el) => el.id === entry.blurObject.id);
|
||||
const blurOverlayStyle = buildBlurOverlayStyle(entry.blurObject, scale);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`blur-stack-${entry.key}`}
|
||||
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<div
|
||||
style={withEntrance(
|
||||
blurOverlayStyle,
|
||||
entry.blurObject,
|
||||
blurIndex >= 0 ? blurIndex : 0,
|
||||
{
|
||||
transformOrigin: blurOverlayStyle.transformOrigin,
|
||||
rotationDeg: entry.blurObject.rotation ?? 0,
|
||||
},
|
||||
)}
|
||||
/>
|
||||
{entry.occlusionObjects.map((obj) => {
|
||||
const index = page.elements.findIndex((el) => el.id === obj.id);
|
||||
if (index < 0) return null;
|
||||
return renderObject(obj, index, page.elements);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{showLoadingOverlay && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex items-center justify-center bg-gray-100"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
...bgStyle,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
zIndex: 2,
|
||||
}}
|
||||
/>
|
||||
role="status"
|
||||
aria-label="در حال بارگذاری صفحه"
|
||||
>
|
||||
<div className="size-8 rounded-full border-2 border-gray-200 border-t-gray-600 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{useHardPage && showPaperShadow && !showLoadingOverlay && (
|
||||
<div aria-hidden className="page-paper-shadow" />
|
||||
)}
|
||||
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
|
||||
{page.elements.map((element, index) => renderObject(element, index, page.elements))}
|
||||
</div>
|
||||
{useHardPage && showPaperShadow && <div aria-hidden className="page-paper-shadow" />}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
BookPage.displayName = 'BookPage';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useIsBrowserFullscreen } from "@/pages/viewer/hooks/useIsBrowserFullscr
|
||||
import { useViewerViewport } from "@/pages/viewer/hooks/useViewerViewport";
|
||||
import { isLegacyIOSSafari } from "@/pages/viewer/utils/isLegacyIOSSafari";
|
||||
import { resolvePageBackground } from "@/pages/viewer/utils/pageBackground";
|
||||
import { getVisiblePageIndices } from "@/pages/viewer/utils/visiblePageIndices";
|
||||
import { toggleViewerFullscreen } from "@/pages/viewer/utils/viewerFullscreen";
|
||||
import { ArrowLeft2, ArrowRight2, Maximize4, Pause, Play, RowVertical } from "iconsax-react";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
@@ -26,6 +27,8 @@ const BOOK_TOP_GAP_MOBILE = 32;
|
||||
const PAGE_FLIP_SOUND_PLAY_FRACTION = 0.5;
|
||||
/** همراستا با `md` تیلویند (768px) — موبایل: زیر این عرض */
|
||||
const MOBILE_QUERY = "(min-width: 768px)";
|
||||
/** شناسهٔ صفحهٔ خالیِ کمکی برای اصلاح جفتسازی جلد در کتاب RTL با تعداد صفحات فرد (هرگز به کاربر نمایش داده نمیشود) */
|
||||
const RTL_COVER_PAD_PAGE_ID = -1;
|
||||
|
||||
function subscribeDesktopMql(callback: () => void) {
|
||||
const mq = window.matchMedia(MOBILE_QUERY);
|
||||
@@ -123,11 +126,23 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
const bookRef = useRef<FlipBookInstance | null>(null);
|
||||
const bookAreaRef = useRef<HTMLDivElement | null>(null);
|
||||
const flipbookWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const spineOverlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const pageFlipHandlerRef = useRef<(e: FlipEvent) => void>(() => {});
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [isAutoPlayActive, setIsAutoPlayActive] = useState(false);
|
||||
const [startPage, setStartPage] = useState(0);
|
||||
const [magnifierEnabled, setMagnifierEnabled] = useState(false);
|
||||
/** ref نه state — تغییر z-index هنگام hover ورق نباید BookPage را remount/re-render کند (انیمیشن ورود دوباره اجرا میشد) */
|
||||
const isBookFlippingRef = useRef(false);
|
||||
const setBookFlippingVisual = useCallback((flipping: boolean) => {
|
||||
if (isBookFlippingRef.current === flipping) return;
|
||||
isBookFlippingRef.current = flipping;
|
||||
flipbookWrapperRef.current?.classList.toggle("relative", flipping);
|
||||
flipbookWrapperRef.current?.classList.toggle("z-10", flipping);
|
||||
flipbookWrapperRef.current?.classList.toggle("flipbook-flipping", flipping);
|
||||
spineOverlayRef.current?.classList.toggle("z-50", !flipping);
|
||||
spineOverlayRef.current?.classList.toggle("z-0", flipping);
|
||||
}, []);
|
||||
const isDesktop = useIsDesktop();
|
||||
const legacyIOS = useLegacyIOSSafari();
|
||||
const isBrowserFullscreen = useIsBrowserFullscreen();
|
||||
@@ -135,19 +150,56 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
const autoPlay = documentSettings?.autoPlay ?? false;
|
||||
const pageFlipSound = documentSettings?.pageFlipSound ?? true;
|
||||
const leftToRightFlip = documentSettings?.leftToRightFlip ?? false;
|
||||
/** react-pageflip از RTL پشتیبانی نمیکند؛ ترتیب صفحات برعکس + شروع از آخر (فقط دسکتاپ) */
|
||||
const isRtlBook = !leftToRightFlip && isDesktop;
|
||||
const displayPages = useMemo(() => (isRtlBook ? [...pages].reverse() : pages), [pages, isRtlBook]);
|
||||
const toLogicalIndex = useCallback((flipIndex: number) => (isRtlBook ? pages.length - 1 - flipIndex : flipIndex), [isRtlBook, pages.length]);
|
||||
const toFlipIndex = useCallback((logicalIndex: number) => (isRtlBook ? pages.length - 1 - logicalIndex : logicalIndex), [isRtlBook, pages.length]);
|
||||
const displayStyle = documentSettings?.displayStyle ?? "double";
|
||||
const usePortrait = !isDesktop || displayStyle === "single";
|
||||
/** react-pageflip از RTL پشتیبانی نمیکند؛ ترتیب صفحات برعکس + شروع از آخر */
|
||||
const isRtlBook = !leftToRightFlip;
|
||||
/**
|
||||
* page-flip فقط صفحهٔ با ایندکس 0 را بهعنوان جلد تکی در نظر میگیرد. بعد از برعکسکردن
|
||||
* آرایه برای RTL، جلد واقعی (صفحهٔ منطقی 0) در انتهای آرایه قرار میگیرد و اگر تعداد کل
|
||||
* صفحات فرد باشد، جفتسازی داخلی کتابخانه آن را با صفحهٔ بعدی هماسپرد میکند (بهجای تکی).
|
||||
* با افزودن یک صفحهٔ خالی نامرئی به ابتدای آرایهٔ برعکسشده، توازن جفتسازی طوری اصلاح میشود
|
||||
* که جلد واقعی همیشه تکی نمایش داده شود.
|
||||
*/
|
||||
const needsRtlCoverPad = isRtlBook && !usePortrait && pages.length > 1 && pages.length % 2 === 1;
|
||||
const displayPages = useMemo(() => {
|
||||
if (!isRtlBook) return pages;
|
||||
const reversed = [...pages].reverse();
|
||||
if (!needsRtlCoverPad) return reversed;
|
||||
const fillerPage: PageData = {
|
||||
id: RTL_COVER_PAD_PAGE_ID,
|
||||
width: pages[0]?.width ?? 794,
|
||||
height: pages[0]?.height ?? 1123,
|
||||
elements: [],
|
||||
backgroundType: "color",
|
||||
backgroundColor: "#ffffff",
|
||||
};
|
||||
return [fillerPage, ...reversed];
|
||||
}, [pages, isRtlBook, needsRtlCoverPad]);
|
||||
const flipIndexCount = displayPages.length;
|
||||
const toLogicalIndex = useCallback(
|
||||
(flipIndex: number) => {
|
||||
if (!isRtlBook) return flipIndex;
|
||||
return needsRtlCoverPad ? pages.length - flipIndex : pages.length - 1 - flipIndex;
|
||||
},
|
||||
[isRtlBook, needsRtlCoverPad, pages.length],
|
||||
);
|
||||
const toFlipIndex = useCallback(
|
||||
(logicalIndex: number) => {
|
||||
if (!isRtlBook) return logicalIndex;
|
||||
return needsRtlCoverPad ? pages.length - logicalIndex : pages.length - 1 - logicalIndex;
|
||||
},
|
||||
[isRtlBook, needsRtlCoverPad, pages.length],
|
||||
);
|
||||
const backgroundType = documentSettings?.backgroundType ?? "color";
|
||||
const backgroundColor = documentSettings?.backgroundColor ?? "#ffffff";
|
||||
const backgroundOpacity = documentSettings?.backgroundOpacity ?? 100;
|
||||
const backgroundGradient = documentSettings?.backgroundGradient;
|
||||
const backgroundImageUrl = documentSettings?.backgroundImageUrl ?? "";
|
||||
const backgroundVideoUrl = documentSettings?.backgroundVideoUrl ?? "";
|
||||
const pageBackgroundDefaults = useMemo(
|
||||
() => ({ backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl }),
|
||||
[backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl],
|
||||
() => ({ backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl }),
|
||||
[backgroundType, backgroundColor, backgroundOpacity, backgroundGradient, backgroundImageUrl, backgroundVideoUrl],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,7 +210,6 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
const { width: bookWidth, height: bookHeight } = useMemo(() => getPaperDimensions(catalogSize ?? "a4"), [catalogSize]);
|
||||
const hasMultiplePages = pages.length > 1;
|
||||
const viewport = useViewerViewport();
|
||||
const usePortrait = !isDesktop || displayStyle === "single";
|
||||
|
||||
const viewportFit = useMemo(() => {
|
||||
const padX = isDesktop ? 48 : 20;
|
||||
@@ -182,26 +233,51 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
const pagePixelWidth = viewportFit.w;
|
||||
const pagePixelHeight = viewportFit.h;
|
||||
const contentScale = viewportFit.contentScale;
|
||||
const flipDisabledOnMobile = !isDesktop && magnifierEnabled;
|
||||
const magnifierLockedPageId = usePortrait ? (pages[currentPage]?.id ?? null) : null;
|
||||
|
||||
const flipKey = `${usePortrait ? "portrait" : "spread"}-${pagePixelWidth}-${pagePixelHeight}`;
|
||||
const flipKey = `${usePortrait ? "portrait" : "spread"}-${pagePixelWidth}-${pagePixelHeight}-${flipIndexCount}`;
|
||||
|
||||
const {
|
||||
scheduleEntranceForSpread,
|
||||
scheduleEntranceForIndices,
|
||||
getEntrancePhase,
|
||||
replayEntranceForPage,
|
||||
reset: resetEntrance,
|
||||
} = useBookEntranceController({
|
||||
pages,
|
||||
portrait: usePortrait,
|
||||
showCover: hasMultiplePages,
|
||||
});
|
||||
} = useBookEntranceController({ pages });
|
||||
|
||||
/**
|
||||
* ایندکسهای منطقیِ صفحات قابلمشاهده در اسپرد جاری را برمیگرداند.
|
||||
* جفتسازی (کدام دو صفحه با هم دیده میشوند) باید در فضای نمایشیِ کتاب
|
||||
* (flip index) انجام شود، نه فضای منطقی: در کتاب RTL این دو فضا برعکس
|
||||
* هماند، پس مثلاً «ایندکس منطقی + ۱» لزوماً همصفحهی واقعی نیست.
|
||||
*/
|
||||
const getVisibleLogicalIndices = useCallback(
|
||||
(flipIdx: number): number[] => {
|
||||
const displayIndices = getVisiblePageIndices(flipIdx, flipIndexCount, {
|
||||
portrait: usePortrait,
|
||||
showCover: hasMultiplePages,
|
||||
});
|
||||
const seen = new Set<number>();
|
||||
const result: number[] = [];
|
||||
for (const di of displayIndices) {
|
||||
const logical = toLogicalIndex(di);
|
||||
if (logical >= 0 && logical < pages.length && !seen.has(logical)) {
|
||||
seen.add(logical);
|
||||
result.push(logical);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[flipIndexCount, usePortrait, hasMultiplePages, toLogicalIndex, pages.length],
|
||||
);
|
||||
|
||||
const commitEntranceAtCurrentSpread = useCallback(() => {
|
||||
const api = bookRef.current?.pageFlip();
|
||||
const idx = api?.getCurrentPageIndex?.();
|
||||
if (typeof idx !== "number" || pages.length <= 0) return;
|
||||
const flipIdx = Math.max(0, Math.min(idx, pages.length - 1));
|
||||
scheduleEntranceForSpread(toLogicalIndex(flipIdx));
|
||||
}, [pages.length, scheduleEntranceForSpread, toLogicalIndex]);
|
||||
if (typeof idx !== "number" || flipIndexCount <= 0) return;
|
||||
const flipIdx = Math.max(0, Math.min(idx, flipIndexCount - 1));
|
||||
scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx));
|
||||
}, [flipIndexCount, scheduleEntranceForIndices, getVisibleLogicalIndices]);
|
||||
|
||||
useEffect(() => {
|
||||
resetEntrance();
|
||||
@@ -284,8 +360,8 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
// اگر flipIndex منفی است یا بزرگتر از تعداد صفحات، آن را محدود میکنیم
|
||||
if (flipIndex < 0) {
|
||||
flipIndex = 0;
|
||||
} else if (flipIndex >= pages.length) {
|
||||
flipIndex = pages.length - 1;
|
||||
} else if (flipIndex >= flipIndexCount) {
|
||||
flipIndex = flipIndexCount - 1;
|
||||
}
|
||||
|
||||
const pageNum = toLogicalIndex(flipIndex);
|
||||
@@ -297,7 +373,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
|
||||
setCurrentPage(pageNum);
|
||||
},
|
||||
[pages.length, pageFlipSound, playPageFlipSound, toLogicalIndex],
|
||||
[flipIndexCount, pageFlipSound, playPageFlipSound, toLogicalIndex],
|
||||
);
|
||||
|
||||
pageFlipHandlerRef.current = handlePageFlip;
|
||||
@@ -309,54 +385,66 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
const syncPageIndexFromBook = useCallback(() => {
|
||||
const api = bookRef.current?.pageFlip();
|
||||
const idx = api?.getCurrentPageIndex?.();
|
||||
if (typeof idx !== "number" || pages.length <= 0) return;
|
||||
const flipIdx = Math.max(0, Math.min(idx, pages.length - 1));
|
||||
if (typeof idx !== "number" || flipIndexCount <= 0) return;
|
||||
const flipIdx = Math.max(0, Math.min(idx, flipIndexCount - 1));
|
||||
setCurrentPage(toLogicalIndex(flipIdx));
|
||||
}, [pages.length, toLogicalIndex]);
|
||||
}, [flipIndexCount, toLogicalIndex]);
|
||||
|
||||
const handleBookInit = useCallback(
|
||||
(e: PageFlipInitEvent) => {
|
||||
const p = e.data?.page;
|
||||
if (typeof p !== "number" || pages.length <= 0) return;
|
||||
const flipIdx = Math.max(0, Math.min(p, pages.length - 1));
|
||||
if (typeof p !== "number" || flipIndexCount <= 0) return;
|
||||
const flipIdx = Math.max(0, Math.min(p, flipIndexCount - 1));
|
||||
const logicalIdx = toLogicalIndex(flipIdx);
|
||||
setCurrentPage(logicalIdx);
|
||||
lastFlipPageRef.current = logicalIdx;
|
||||
// اولین اسپرد قبل از onChangeState('read') هم باید انیمیشن ورود بگیرد
|
||||
scheduleEntranceForSpread(logicalIdx);
|
||||
scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx));
|
||||
},
|
||||
[pages.length, scheduleEntranceForSpread, toLogicalIndex],
|
||||
[flipIndexCount, scheduleEntranceForIndices, getVisibleLogicalIndices, toLogicalIndex],
|
||||
);
|
||||
|
||||
const beginPageNavigation = useCallback(() => {
|
||||
if (flipDisabledOnMobile) return false;
|
||||
setBookFlippingVisual(true);
|
||||
return true;
|
||||
}, [flipDisabledOnMobile, setBookFlippingVisual]);
|
||||
|
||||
/** بعد از اتمام انیمیشن ورق، ایندکس واقعی کتاب را میگیریم و انیمیشن ورود را یکبار شروع میکنیم */
|
||||
const handleChangeState = useCallback(
|
||||
(e: PageFlipStateEvent) => {
|
||||
if (e.data !== "read") return;
|
||||
syncPageIndexFromBook();
|
||||
commitEntranceAtCurrentSpread();
|
||||
if (e.data === "read") {
|
||||
setBookFlippingVisual(false);
|
||||
syncPageIndexFromBook();
|
||||
commitEntranceAtCurrentSpread();
|
||||
return;
|
||||
}
|
||||
setBookFlippingVisual(true);
|
||||
},
|
||||
[syncPageIndexFromBook, commitEntranceAtCurrentSpread],
|
||||
[syncPageIndexFromBook, commitEntranceAtCurrentSpread, setBookFlippingVisual],
|
||||
);
|
||||
|
||||
const goToNextPage = useCallback(() => {
|
||||
if (currentPage >= pages.length - 1) return;
|
||||
if (!beginPageNavigation()) return;
|
||||
const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined;
|
||||
if (isRtlBook) {
|
||||
triggerFlipPrev(api);
|
||||
} else {
|
||||
api?.flipNext();
|
||||
}
|
||||
}, [currentPage, pages.length, isRtlBook]);
|
||||
}, [currentPage, pages.length, isRtlBook, beginPageNavigation]);
|
||||
|
||||
const goToPrevPage = useCallback(() => {
|
||||
if (currentPage <= 0) return;
|
||||
if (!beginPageNavigation()) return;
|
||||
const api = bookRef.current?.pageFlip() as PageFlipWithFlipController | undefined;
|
||||
if (isRtlBook) {
|
||||
api?.flipNext();
|
||||
} else {
|
||||
triggerFlipPrev(api);
|
||||
}
|
||||
}, [currentPage, isRtlBook]);
|
||||
}, [currentPage, isRtlBook, beginPageNavigation]);
|
||||
|
||||
const stopAutoPlayByUser = useCallback(() => {
|
||||
if (isAutoPlayActive) {
|
||||
@@ -384,18 +472,25 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [magnifierEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (flipDisabledOnMobile && isAutoPlayActive) {
|
||||
setIsAutoPlayActive(false);
|
||||
}
|
||||
}, [flipDisabledOnMobile, isAutoPlayActive]);
|
||||
|
||||
const navigateToPageIndex = useCallback(
|
||||
(targetIndex: number) => {
|
||||
const pageFlipAPI = bookRef.current?.pageFlip();
|
||||
if (!pageFlipAPI) return;
|
||||
if (!beginPageNavigation()) return;
|
||||
|
||||
const logicalIdx = Math.max(0, Math.min(targetIndex, pages.length - 1));
|
||||
const flipIdx = toFlipIndex(logicalIdx);
|
||||
pageFlipAPI.turnToPage(flipIdx);
|
||||
lastFlipPageRef.current = logicalIdx;
|
||||
scheduleEntranceForSpread(logicalIdx);
|
||||
scheduleEntranceForIndices(getVisibleLogicalIndices(flipIdx));
|
||||
},
|
||||
[pages.length, scheduleEntranceForSpread, toFlipIndex],
|
||||
[pages.length, scheduleEntranceForIndices, getVisibleLogicalIndices, toFlipIndex, beginPageNavigation],
|
||||
);
|
||||
|
||||
const handleLinkClick = useCallback(
|
||||
@@ -404,6 +499,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
stopAutoPlayByUser();
|
||||
|
||||
if (!linkUrl.startsWith("page://")) return;
|
||||
if (flipDisabledOnMobile) return;
|
||||
|
||||
const action = linkUrl.replace("page://", "");
|
||||
|
||||
@@ -422,7 +518,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
}
|
||||
}
|
||||
},
|
||||
[pages.length, goToNextPage, goToPrevPage, navigateToPageIndex, stopAutoPlayByUser],
|
||||
[pages.length, goToNextPage, goToPrevPage, navigateToPageIndex, stopAutoPlayByUser, flipDisabledOnMobile],
|
||||
);
|
||||
|
||||
const prevFlipKeyRef = useRef(flipKey);
|
||||
@@ -436,19 +532,17 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
|
||||
useEffect(() => {
|
||||
if (pages.length <= 0) return;
|
||||
const initialFlipPage = isRtlBook ? pages.length - 1 : 0;
|
||||
setStartPage(initialFlipPage);
|
||||
setStartPage(toFlipIndex(0));
|
||||
setCurrentPage(0);
|
||||
lastFlipPageRef.current = 0;
|
||||
}, [pages.length, pages[0]?.id, isRtlBook]);
|
||||
}, [pages.length, pages[0]?.id, toFlipIndex]);
|
||||
|
||||
// پخش خودکار
|
||||
useEffect(() => {
|
||||
if (!isAutoPlayActive || pages.length <= 1) return;
|
||||
if (!isAutoPlayActive || pages.length <= 1 || flipDisabledOnMobile) return;
|
||||
const timer = setInterval(() => {
|
||||
if (currentPage >= pages.length - 1) {
|
||||
const firstFlipPage = isRtlBook ? pages.length - 1 : 0;
|
||||
bookRef.current?.pageFlip()?.turnToPage?.(firstFlipPage);
|
||||
bookRef.current?.pageFlip()?.turnToPage?.(toFlipIndex(0));
|
||||
setCurrentPage(0);
|
||||
lastFlipPageRef.current = 0;
|
||||
return;
|
||||
@@ -461,7 +555,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
}
|
||||
}, AUTO_PLAY_INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [isAutoPlayActive, currentPage, pages.length, isRtlBook]);
|
||||
}, [isAutoPlayActive, currentPage, pages.length, isRtlBook, toFlipIndex, flipDisabledOnMobile]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 md:gap-6 w-full h-full max-md:min-h-0 max-md:max-w-full max-md:overflow-x-hidden max-md:pb-[env(safe-area-inset-bottom,0px)]" dir="rtl">
|
||||
@@ -470,7 +564,8 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
{/* Border و Shadow عمودی در وسط (فقط حالت دوصفحهای دسکتاپ) */}
|
||||
{!usePortrait && (
|
||||
<div
|
||||
className="hidden md:block absolute inset-y-0 left-1/2 -translate-x-1/2 w-px border-r border-gray-300 pointer-events-none z-50"
|
||||
ref={spineOverlayRef}
|
||||
className="hidden md:block absolute inset-y-0 left-1/2 -translate-x-1/2 w-px border-r border-gray-300 pointer-events-none transition-none z-50"
|
||||
style={{
|
||||
boxShadow: "-5px 0 15px rgba(0, 0, 0, 0.15), 5px 0 15px rgba(0, 0, 0, 0.15)",
|
||||
}}
|
||||
@@ -486,6 +581,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
className="max-w-full flex justify-center shrink-0 rounded-sm"
|
||||
style={{
|
||||
cursor: magnifierEnabled ? "zoom-in" : undefined,
|
||||
touchAction: magnifierEnabled ? "none" : undefined,
|
||||
overflow: "hidden",
|
||||
width: usePortrait ? pagePixelWidth : pagePixelWidth * 2,
|
||||
height: pagePixelHeight,
|
||||
@@ -509,12 +605,12 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
autoSize={false}
|
||||
maxShadowOpacity={legacyIOS ? 0 : isDesktop ? 0.55 : 0.32}
|
||||
showCover={hasMultiplePages}
|
||||
mobileScrollSupport={!usePortrait}
|
||||
mobileScrollSupport={!usePortrait && !magnifierEnabled}
|
||||
clickEventForward={true}
|
||||
useMouseEvents={hasMultiplePages && !magnifierEnabled}
|
||||
swipeDistance={hasMultiplePages ? (usePortrait ? 48 : 30) : 9999}
|
||||
swipeDistance={flipDisabledOnMobile || !hasMultiplePages ? 9999 : usePortrait ? 48 : 30}
|
||||
startZIndex={1}
|
||||
showPageCorners={hasMultiplePages}
|
||||
showPageCorners={hasMultiplePages && !flipDisabledOnMobile}
|
||||
disableFlipByClick={true}
|
||||
className={clx(
|
||||
"flipbook-container mx-auto max-w-full",
|
||||
@@ -533,6 +629,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
key={page.id}
|
||||
page={page}
|
||||
entrancePhase={getEntrancePhase(page.id)}
|
||||
onDeferredEntranceReady={replayEntranceForPage}
|
||||
scale={contentScale}
|
||||
pageWidth={pagePixelWidth}
|
||||
pageHeight={pagePixelHeight}
|
||||
@@ -541,6 +638,7 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
backgroundColor={background.backgroundColor}
|
||||
backgroundGradient={background.backgroundGradient}
|
||||
backgroundImageUrl={background.backgroundImageUrl}
|
||||
backgroundVideoUrl={background.backgroundVideoUrl}
|
||||
useHardPage={legacyIOS}
|
||||
/>
|
||||
);
|
||||
@@ -551,6 +649,9 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
enabled={magnifierEnabled}
|
||||
containerRef={bookAreaRef}
|
||||
interactionRef={flipbookWrapperRef}
|
||||
isFlippingRef={isBookFlippingRef}
|
||||
lockedPageId={magnifierLockedPageId}
|
||||
blockFlipTouch={flipDisabledOnMobile}
|
||||
pages={displayPages}
|
||||
pageWidth={pagePixelWidth}
|
||||
pageHeight={pagePixelHeight}
|
||||
@@ -565,20 +666,24 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
<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 && (
|
||||
<>
|
||||
{/* دکمه راست: فارسی = صفحه بعد | لاتین = صفحه قبل */}
|
||||
{/* دکمه راست: فارسی = صفحه قبل | لاتین = صفحه بعد */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stopAutoPlayByUser();
|
||||
if (leftToRightFlip) {
|
||||
goToPrevPage();
|
||||
} else {
|
||||
goToNextPage();
|
||||
} else {
|
||||
goToPrevPage();
|
||||
}
|
||||
}}
|
||||
disabled={leftToRightFlip ? currentPage <= 0 : currentPage >= pages.length - 1}
|
||||
disabled={
|
||||
flipDisabledOnMobile ||
|
||||
(leftToRightFlip ? currentPage >= pages.length - 1 : currentPage <= 0)
|
||||
}
|
||||
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 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
||||
aria-label={leftToRightFlip ? "صفحه قبل" : "صفحه بعد"}
|
||||
aria-label={leftToRightFlip ? "صفحه بعد" : "صفحه قبل"}
|
||||
title={flipDisabledOnMobile ? "برای ورق زدن ابتدا ذرهبین را خاموش کنید" : undefined}
|
||||
>
|
||||
<ArrowRight2 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
|
||||
</button>
|
||||
@@ -594,20 +699,24 @@ const BookViewer: FC<BookViewerProps> = ({ pages, catalogSize, documentSettings
|
||||
|
||||
{hasMultiplePages && (
|
||||
<>
|
||||
{/* دکمه چپ: فارسی = صفحه قبل | لاتین = صفحه بعد */}
|
||||
{/* دکمه چپ: فارسی = صفحه بعد | لاتین = صفحه قبل */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
stopAutoPlayByUser();
|
||||
if (leftToRightFlip) {
|
||||
goToNextPage();
|
||||
} else {
|
||||
goToPrevPage();
|
||||
} else {
|
||||
goToNextPage();
|
||||
}
|
||||
}}
|
||||
disabled={leftToRightFlip ? currentPage >= pages.length - 1 : currentPage <= 0}
|
||||
disabled={
|
||||
flipDisabledOnMobile ||
|
||||
(leftToRightFlip ? currentPage <= 0 : currentPage >= pages.length - 1)
|
||||
}
|
||||
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 disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
||||
aria-label={leftToRightFlip ? "صفحه بعد" : "صفحه قبل"}
|
||||
aria-label={leftToRightFlip ? "صفحه قبل" : "صفحه بعد"}
|
||||
title={flipDisabledOnMobile ? "برای ورق زدن ابتدا ذرهبین را خاموش کنید" : undefined}
|
||||
>
|
||||
<ArrowLeft2 color="black" size={20} className="text-gray-700 md:w-6 md:h-6" />
|
||||
</button>
|
||||
|
||||
@@ -21,6 +21,11 @@ export type MagnifierProps = {
|
||||
contentScale: number;
|
||||
pageDefaults: PageBackgroundDefaults;
|
||||
useHardPage?: boolean;
|
||||
/** در حین انیمیشن ورقخوردن true است */
|
||||
isFlippingRef: RefObject<boolean>;
|
||||
/** در حالت تکصفحه (موبایل) همیشه صفحهٔ فعلی بزرگنمایی میشود */
|
||||
lockedPageId?: number | null;
|
||||
blockFlipTouch?: boolean;
|
||||
/** با تغییر آن (ورق خوردن) ذرهبین ریست میشود */
|
||||
pageIndex?: number;
|
||||
};
|
||||
@@ -36,6 +41,9 @@ const Magnifier = memo(
|
||||
contentScale,
|
||||
pageDefaults,
|
||||
useHardPage = false,
|
||||
isFlippingRef,
|
||||
lockedPageId = null,
|
||||
blockFlipTouch = false,
|
||||
pageIndex,
|
||||
}: MagnifierProps) => {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
@@ -54,15 +62,23 @@ const Magnifier = memo(
|
||||
overlayRef,
|
||||
lensRef,
|
||||
mirrorRef,
|
||||
isFlippingRef,
|
||||
lockedPageId,
|
||||
blockFlipTouch,
|
||||
pageIndex,
|
||||
onActivePageChange: handleActivePageChange,
|
||||
});
|
||||
|
||||
const activePage = useMemo(
|
||||
() => (activePageId != null ? pages.find((p) => p.id === activePageId) : undefined),
|
||||
[pages, activePageId],
|
||||
const lockedPage = useMemo(
|
||||
() => (lockedPageId != null ? pages.find((p) => p.id === lockedPageId) : undefined),
|
||||
[lockedPageId, pages],
|
||||
);
|
||||
|
||||
const activePage = useMemo(() => {
|
||||
if (lockedPage) return lockedPage;
|
||||
return activePageId != null ? pages.find((p) => p.id === activePageId) : undefined;
|
||||
}, [lockedPage, pages, activePageId]);
|
||||
|
||||
const background = useMemo(
|
||||
() => (activePage ? resolvePageBackground(activePage, pageDefaults) : null),
|
||||
[activePage, pageDefaults],
|
||||
@@ -94,8 +110,10 @@ const Magnifier = memo(
|
||||
backgroundColor={background.backgroundColor}
|
||||
backgroundGradient={background.backgroundGradient}
|
||||
backgroundImageUrl={background.backgroundImageUrl}
|
||||
backgroundVideoUrl={background.backgroundVideoUrl}
|
||||
useHardPage={useHardPage}
|
||||
disableEntranceAnimations
|
||||
hideMediaLoadingOverlay
|
||||
showPaperShadow={false}
|
||||
idSuffix="-magnifier"
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
/** پیدا کردن المان `.page` با شناسهٔ مشخص (حالت تکصفحهای موبایل) */
|
||||
export function findPageById(root: HTMLElement, pageId: number): HTMLElement | null {
|
||||
const page = root.querySelector<HTMLElement>(`.page[data-page-id="${pageId}"]`);
|
||||
if (!(page instanceof HTMLElement) || !root.contains(page) || !isPageVisible(page)) {
|
||||
return null;
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/** پیدا کردن المان `.page` زیر مختصات مشخص، با اولویت بالاترین z-index در حالت اسپرد */
|
||||
export function findPageAtPoint(
|
||||
root: HTMLElement,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||
import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants";
|
||||
import { computeMagnifierGeometry, findPageAtPoint } from "./magnifierUtils";
|
||||
import { computeMagnifierGeometry, findPageAtPoint, findPageById } from "./magnifierUtils";
|
||||
|
||||
type UseMagnifierOptions = {
|
||||
enabled: boolean;
|
||||
/** ناحیهٔ موقعیتدهی لنز (مرجع مختصات نسبی) */
|
||||
/** مرجع مختصات برای موقعیتدهی لنز (معمولاً ناحیهٔ اطراف کتاب) */
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
/** ناحیهٔ تعامل و جستجوی صفحات (wrapper کتاب) */
|
||||
interactionRef: RefObject<HTMLElement | null>;
|
||||
@@ -12,6 +12,12 @@ type UseMagnifierOptions = {
|
||||
lensRef: RefObject<HTMLElement | null>;
|
||||
/** لایهٔ داخل لنز که محتوای صفحه (BookPage با مقیاس بزرگ) داخلش رندر میشود */
|
||||
mirrorRef: RefObject<HTMLElement | null>;
|
||||
/** در حین انیمیشن ورقخوردن true است — hit-test و بهروزرسانی لنز متوقف میشود */
|
||||
isFlippingRef: RefObject<boolean>;
|
||||
/** در حالت تکصفحه (موبایل) همیشه همین صفحه بزرگنمایی میشود، نه نتیجهٔ hit-test */
|
||||
lockedPageId?: number | null;
|
||||
/** جلوگیری از رسیدن touch به page-flip (کتابخانه تنظیمات را بعد از mount بهروز نمیکند) */
|
||||
blockFlipTouch?: boolean;
|
||||
/** با تغییر آن (ورق خوردن) وضعیت فعلی ریست میشود */
|
||||
pageIndex?: number;
|
||||
/** وقتی صفحهٔ زیر ماوس عوض شود صدا زده میشود تا محتوای مناسب رندر شود */
|
||||
@@ -25,12 +31,19 @@ export function useMagnifier({
|
||||
overlayRef,
|
||||
lensRef,
|
||||
mirrorRef,
|
||||
isFlippingRef,
|
||||
lockedPageId = null,
|
||||
blockFlipTouch = false,
|
||||
pageIndex,
|
||||
onActivePageChange,
|
||||
}: UseMagnifierOptions) {
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const pendingPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const lastPageIdRef = useRef<number | null>(null);
|
||||
const touchActiveRef = useRef(false);
|
||||
const lockedPageIdRef = useRef(lockedPageId);
|
||||
|
||||
lockedPageIdRef.current = lockedPageId;
|
||||
|
||||
const hideLens = useCallback(() => {
|
||||
const lens = lensRef.current;
|
||||
@@ -46,37 +59,27 @@ export function useMagnifier({
|
||||
}
|
||||
}, [onActivePageChange]);
|
||||
|
||||
// با تغییر صفحه (ورق خوردن) باید دوباره از صفر hit-test انجام شود
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
pendingPointRef.current = null;
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
}, [pageIndex, enabled, hideLens, resetActivePage]);
|
||||
const applyGeometryRef = useRef<(clientX: number, clientY: number) => void>(() => {});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
pendingPointRef.current = null;
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
applyGeometryRef.current = (clientX: number, clientY: number) => {
|
||||
if (isFlippingRef.current) {
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
return;
|
||||
}
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const interaction = interactionRef.current;
|
||||
if (!container || !interaction) return;
|
||||
|
||||
const half = MAGNIFIER_SIZE / 2;
|
||||
|
||||
const applyGeometry = (clientX: number, clientY: number) => {
|
||||
const container = containerRef.current;
|
||||
const interaction = interactionRef.current;
|
||||
const lens = lensRef.current;
|
||||
if (!lens) return;
|
||||
if (!container || !interaction || !lens) return;
|
||||
|
||||
const lockedId = lockedPageIdRef.current;
|
||||
const page =
|
||||
lockedId != null
|
||||
? findPageById(interaction, lockedId)
|
||||
: findPageAtPoint(interaction, clientX, clientY, overlayRef.current);
|
||||
|
||||
const page = findPageAtPoint(interaction, clientX, clientY, overlayRef.current);
|
||||
if (!page) {
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
@@ -96,6 +99,7 @@ export function useMagnifier({
|
||||
onActivePageChange(pageId);
|
||||
}
|
||||
|
||||
const half = MAGNIFIER_SIZE / 2;
|
||||
const { pageOffsetX, pageOffsetY, localX, localY, clampedX, clampedY } = geometry;
|
||||
|
||||
lens.style.visibility = "visible";
|
||||
@@ -103,66 +107,146 @@ export function useMagnifier({
|
||||
|
||||
const mirror = mirrorRef.current;
|
||||
if (mirror) {
|
||||
const tx = half - localX * ZOOM_SCALE;
|
||||
const ty = half - localY * ZOOM_SCALE;
|
||||
mirror.style.transform = `translate3d(${tx}px, ${ty}px, 0)`;
|
||||
mirror.style.transform = `translate3d(${half - localX * ZOOM_SCALE}px, ${half - localY * ZOOM_SCALE}px, 0)`;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const flushPending = () => {
|
||||
rafIdRef.current = null;
|
||||
const point = pendingPointRef.current;
|
||||
if (!point) return;
|
||||
applyGeometry(point.x, point.y);
|
||||
};
|
||||
// با ورق خوردن، وضعیت ذرهبین کاملاً ریست میشود
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
pendingPointRef.current = null;
|
||||
touchActiveRef.current = false;
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
lastPageIdRef.current = null;
|
||||
}, [pageIndex, enabled, hideLens, resetActivePage]);
|
||||
|
||||
const scheduleUpdate = (clientX: number, clientY: number) => {
|
||||
pendingPointRef.current = { x: clientX, y: clientY };
|
||||
if (rafIdRef.current == null) {
|
||||
rafIdRef.current = requestAnimationFrame(flushPending);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
scheduleUpdate(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) {
|
||||
pendingPointRef.current = null;
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
};
|
||||
|
||||
interaction.addEventListener("mousemove", handleMouseMove);
|
||||
interaction.addEventListener("mouseleave", handleMouseLeave);
|
||||
|
||||
return () => {
|
||||
interaction.removeEventListener("mousemove", handleMouseMove);
|
||||
interaction.removeEventListener("mouseleave", handleMouseLeave);
|
||||
touchActiveRef.current = false;
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
};
|
||||
}, [enabled, containerRef, interactionRef, overlayRef, lensRef, mirrorRef, hideLens, resetActivePage, onActivePageChange]);
|
||||
return;
|
||||
}
|
||||
|
||||
// بلافاصله بعد از سوییچ صفحه (رندر مجدد mirror) آخرین موقعیت شناختهشده را دوباره اعمال میکند
|
||||
// تا محتوای تازهرندرشده یک فریم با موقعیت اشتباه دیده نشود.
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled) return;
|
||||
const mirror = mirrorRef.current;
|
||||
const point = pendingPointRef.current;
|
||||
if (!mirror || !point) return;
|
||||
const half = MAGNIFIER_SIZE / 2;
|
||||
const container = containerRef.current;
|
||||
const interaction = interactionRef.current;
|
||||
if (!container || !interaction) return;
|
||||
const page = findPageAtPoint(interaction, point.x, point.y, overlayRef.current);
|
||||
if (!page) return;
|
||||
const geometry = computeMagnifierGeometry(point.x, point.y, page, container, MAGNIFIER_SIZE);
|
||||
if (!geometry) return;
|
||||
mirror.style.transform = `translate3d(${half - geometry.localX * ZOOM_SCALE}px, ${half - geometry.localY * ZOOM_SCALE}px, 0)`;
|
||||
});
|
||||
|
||||
const flushPending = () => {
|
||||
rafIdRef.current = null;
|
||||
const point = pendingPointRef.current;
|
||||
if (!point) return;
|
||||
applyGeometryRef.current(point.x, point.y);
|
||||
};
|
||||
|
||||
const scheduleUpdate = (clientX: number, clientY: number) => {
|
||||
if (isFlippingRef.current) {
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingPointRef.current = { x: clientX, y: clientY };
|
||||
if (rafIdRef.current == null) {
|
||||
rafIdRef.current = requestAnimationFrame(flushPending);
|
||||
}
|
||||
};
|
||||
|
||||
const isTouchLike = (event: PointerEvent) =>
|
||||
event.pointerType === "touch" || event.pointerType === "pen";
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (isTouchLike(event)) {
|
||||
if (!touchActiveRef.current) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
scheduleUpdate(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!isTouchLike(event)) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
touchActiveRef.current = true;
|
||||
scheduleUpdate(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const handlePointerUpOrCancel = (event: PointerEvent) => {
|
||||
if (!isTouchLike(event)) return;
|
||||
event.stopPropagation();
|
||||
touchActiveRef.current = false;
|
||||
pendingPointRef.current = null;
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
};
|
||||
|
||||
const handlePointerLeave = (event: PointerEvent) => {
|
||||
if (isTouchLike(event)) return;
|
||||
pendingPointRef.current = null;
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
};
|
||||
|
||||
const listenerOptions = { capture: true, passive: false } as const;
|
||||
|
||||
interaction.addEventListener("pointermove", handlePointerMove, listenerOptions);
|
||||
interaction.addEventListener("pointerdown", handlePointerDown, listenerOptions);
|
||||
interaction.addEventListener("pointerup", handlePointerUpOrCancel, listenerOptions);
|
||||
interaction.addEventListener("pointercancel", handlePointerUpOrCancel, listenerOptions);
|
||||
interaction.addEventListener("pointerleave", handlePointerLeave);
|
||||
|
||||
const blockTouchEvent = (event: TouchEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
const touchBlockOptions = { capture: true, passive: false } as const;
|
||||
|
||||
if (blockFlipTouch) {
|
||||
interaction.addEventListener("touchstart", blockTouchEvent, touchBlockOptions);
|
||||
interaction.addEventListener("touchmove", blockTouchEvent, touchBlockOptions);
|
||||
interaction.addEventListener("touchend", blockTouchEvent, touchBlockOptions);
|
||||
interaction.addEventListener("touchcancel", blockTouchEvent, touchBlockOptions);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (blockFlipTouch) {
|
||||
interaction.removeEventListener("touchstart", blockTouchEvent, touchBlockOptions);
|
||||
interaction.removeEventListener("touchmove", blockTouchEvent, touchBlockOptions);
|
||||
interaction.removeEventListener("touchend", blockTouchEvent, touchBlockOptions);
|
||||
interaction.removeEventListener("touchcancel", blockTouchEvent, touchBlockOptions);
|
||||
}
|
||||
interaction.removeEventListener("pointermove", handlePointerMove, listenerOptions);
|
||||
interaction.removeEventListener("pointerdown", handlePointerDown, listenerOptions);
|
||||
interaction.removeEventListener("pointerup", handlePointerUpOrCancel, listenerOptions);
|
||||
interaction.removeEventListener("pointercancel", handlePointerUpOrCancel, listenerOptions);
|
||||
interaction.removeEventListener("pointerleave", handlePointerLeave);
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
touchActiveRef.current = false;
|
||||
hideLens();
|
||||
resetActivePage();
|
||||
};
|
||||
}, [
|
||||
enabled,
|
||||
containerRef,
|
||||
interactionRef,
|
||||
overlayRef,
|
||||
lensRef,
|
||||
mirrorRef,
|
||||
isFlippingRef,
|
||||
hideLens,
|
||||
resetActivePage,
|
||||
onActivePageChange,
|
||||
blockFlipTouch,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ENTRANCE_ANIMATION_BASE_DELAY_MS,
|
||||
MAX_ENTRANCE_DELAY_MS,
|
||||
} from "@/shared/entranceAnimation";
|
||||
import { getVisiblePageIndices } from "@/pages/viewer/utils/visiblePageIndices";
|
||||
|
||||
/** حداکثر زمان پخش انیمیشن ورود (برای برداشتن حالت play) */
|
||||
const MAX_ENTRANCE_PLAY_MS =
|
||||
@@ -18,15 +17,18 @@ type PageRef = { id: number };
|
||||
|
||||
type Options = {
|
||||
pages: PageRef[];
|
||||
portrait: boolean;
|
||||
showCover: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* کنترل یکبار پخش انیمیشن ورود هر صفحه — state در والد نگهداری میشود
|
||||
* تا با remount شدن BookPage هنگام ورقخوردن دوباره اجرا نشود.
|
||||
*
|
||||
* محاسبهٔ اینکه «کدام صفحات منطقی در اسپرد جاری قابلمشاهدهاند» بر عهدهٔ صدازننده
|
||||
* (BookViewer) است: در کتاب RTL، ایندکس نمایشی (flip index) و ایندکس منطقی صفحه
|
||||
* برعکس هم میشوند، پس جفتسازی باید در فضای نمایشی انجام و سپس به منطقی تبدیل شود.
|
||||
* اینجا فقط لیست ایندکسهای منطقیِ از قبلمحاسبهشده را میگیرد.
|
||||
*/
|
||||
export function useBookEntranceController({ pages, portrait, showCover }: Options) {
|
||||
export function useBookEntranceController({ pages }: Options) {
|
||||
const playedPageIdsRef = useRef(new Set<number>());
|
||||
const [playingPageIds, setPlayingPageIds] = useState<ReadonlySet<number>>(() => new Set());
|
||||
const playTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
@@ -49,20 +51,17 @@ export function useBookEntranceController({ pages, portrait, showCover }: Option
|
||||
});
|
||||
}, [clearPlayTimer]);
|
||||
|
||||
const scheduleEntranceForSpread = useCallback(
|
||||
(currentIndex: number) => {
|
||||
const indices = getVisiblePageIndices(currentIndex, pages.length, {
|
||||
portrait,
|
||||
showCover,
|
||||
});
|
||||
|
||||
const scheduleEntranceForIndices = useCallback(
|
||||
(visibleLogicalIndices: number[]) => {
|
||||
const visibleIds = new Set(
|
||||
indices.map((i) => pages[i]?.id).filter((id): id is number => id != null),
|
||||
visibleLogicalIndices
|
||||
.map((i) => pages[i]?.id)
|
||||
.filter((id): id is number => id != null),
|
||||
);
|
||||
|
||||
const newlyPlaying: number[] = [];
|
||||
|
||||
for (const i of indices) {
|
||||
for (const i of visibleLogicalIndices) {
|
||||
const page = pages[i];
|
||||
if (!page) continue;
|
||||
if (playedPageIdsRef.current.has(page.id)) continue;
|
||||
@@ -96,7 +95,7 @@ export function useBookEntranceController({ pages, portrait, showCover }: Option
|
||||
);
|
||||
}
|
||||
},
|
||||
[pages, portrait, showCover, clearPlayTimer, finishPlaying],
|
||||
[pages, clearPlayTimer, finishPlaying],
|
||||
);
|
||||
|
||||
const getEntrancePhase = useCallback(
|
||||
@@ -116,9 +115,31 @@ export function useBookEntranceController({ pages, portrait, showCover }: Option
|
||||
setPlayingPageIds(new Set());
|
||||
}, [clearPlayTimer]);
|
||||
|
||||
/** وقتی رسانهٔ صفحه دیرتر از زمانبندی انیمیشن لود شد، پخش را دوباره شروع میکند */
|
||||
const replayEntranceForPage = useCallback(
|
||||
(pageId: number) => {
|
||||
if (!playedPageIdsRef.current.has(pageId)) return;
|
||||
|
||||
setPlayingPageIds((prev) => {
|
||||
if (prev.has(pageId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(pageId);
|
||||
return next;
|
||||
});
|
||||
|
||||
clearPlayTimer(pageId);
|
||||
playTimersRef.current.set(
|
||||
pageId,
|
||||
setTimeout(() => finishPlaying(pageId), MAX_ENTRANCE_PLAY_MS),
|
||||
);
|
||||
},
|
||||
[clearPlayTimer, finishPlaying],
|
||||
);
|
||||
|
||||
return {
|
||||
scheduleEntranceForSpread,
|
||||
scheduleEntranceForIndices,
|
||||
getEntrancePhase,
|
||||
replayEntranceForPage,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { PageData } from '../types';
|
||||
import {
|
||||
extractPageMediaAssets,
|
||||
preloadPagesMedia,
|
||||
} from '../utils/pageMediaUrls';
|
||||
|
||||
type PageBackground = Pick<
|
||||
PageData,
|
||||
'backgroundType' | 'backgroundImageUrl' | 'backgroundVideoUrl'
|
||||
>;
|
||||
|
||||
function mergePageBackground(
|
||||
page: PageData,
|
||||
background?: Partial<PageBackground>,
|
||||
): PageData {
|
||||
if (!background) return page;
|
||||
return {
|
||||
...page,
|
||||
backgroundType: background.backgroundType ?? page.backgroundType,
|
||||
backgroundImageUrl:
|
||||
background.backgroundImageUrl ?? page.backgroundImageUrl,
|
||||
backgroundVideoUrl:
|
||||
background.backgroundVideoUrl ?? page.backgroundVideoUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function usePageMediaReady(
|
||||
page: PageData,
|
||||
background?: Partial<PageBackground>,
|
||||
enabled = true,
|
||||
) {
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
const mediaKey = enabled
|
||||
? extractPageMediaAssets([mergePageBackground(page, background)])
|
||||
.map((asset) => `${asset.kind}:${asset.url}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedPage = mergePageBackground(page, background);
|
||||
const assets = extractPageMediaAssets([mergedPage]);
|
||||
|
||||
if (assets.length === 0) {
|
||||
setIsReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsReady(false);
|
||||
|
||||
void preloadPagesMedia([mergedPage]).then(() => {
|
||||
if (!cancelled) setIsReady(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, page.id, mediaKey, background?.backgroundType, background?.backgroundImageUrl, background?.backgroundVideoUrl]);
|
||||
|
||||
return isReady;
|
||||
}
|
||||
@@ -5,13 +5,15 @@ export type PageData = {
|
||||
width: number;
|
||||
height: number;
|
||||
elements: EditorObject[];
|
||||
backgroundType?: "color" | "gradient" | "image";
|
||||
backgroundType?: "color" | "gradient" | "image" | "video";
|
||||
backgroundColor?: string;
|
||||
backgroundOpacity?: number;
|
||||
backgroundGradient?: {
|
||||
from: string;
|
||||
to: string;
|
||||
angle: number;
|
||||
};
|
||||
backgroundImageUrl?: string;
|
||||
backgroundVideoUrl?: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -5,14 +5,16 @@ import type { PageData } from "../types";
|
||||
type ViewerDataPage = {
|
||||
id: string;
|
||||
name: string;
|
||||
backgroundType?: "color" | "gradient" | "image";
|
||||
backgroundType?: "color" | "gradient" | "image" | "video";
|
||||
backgroundColor?: string;
|
||||
backgroundOpacity?: number;
|
||||
backgroundGradient?: {
|
||||
from: string;
|
||||
to: string;
|
||||
angle: number;
|
||||
};
|
||||
backgroundImageUrl?: string;
|
||||
backgroundVideoUrl?: string;
|
||||
objects: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
@@ -37,6 +39,7 @@ type ViewerDataPage = {
|
||||
strokeWidth?: number;
|
||||
shapeType?: string;
|
||||
borderRadius?: number;
|
||||
blur?: number;
|
||||
imageUrl?: string;
|
||||
videoUrl?: string;
|
||||
audioUrl?: string;
|
||||
@@ -125,9 +128,12 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
||||
if (obj.type === "rectangle" && obj.shapeType) {
|
||||
baseObject.shapeType = obj.shapeType as EditorObject["shapeType"];
|
||||
}
|
||||
if (obj.type === "rectangle" && obj.borderRadius !== undefined) {
|
||||
if (obj.borderRadius !== undefined) {
|
||||
baseObject.borderRadius = obj.borderRadius;
|
||||
}
|
||||
if (obj.type === "rectangle" && obj.blur !== undefined) {
|
||||
baseObject.blur = obj.blur;
|
||||
}
|
||||
|
||||
if (
|
||||
(obj.type === "image" || obj.type === "sticker" || obj.type === "document") &&
|
||||
@@ -198,8 +204,10 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
||||
elements: objects,
|
||||
backgroundType: page.backgroundType,
|
||||
backgroundColor: page.backgroundColor,
|
||||
backgroundOpacity: page.backgroundOpacity,
|
||||
backgroundGradient: page.backgroundGradient,
|
||||
backgroundImageUrl: page.backgroundImageUrl,
|
||||
backgroundVideoUrl: page.backgroundVideoUrl,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { PageData } from "@/pages/viewer/types";
|
||||
|
||||
export type PageBackgroundDefaults = {
|
||||
backgroundType?: "color" | "gradient" | "image";
|
||||
backgroundType?: "color" | "gradient" | "image" | "video";
|
||||
backgroundColor?: string;
|
||||
backgroundOpacity?: number;
|
||||
backgroundGradient?: { from: string; to: string; angle: number };
|
||||
backgroundImageUrl?: string;
|
||||
backgroundVideoUrl?: string;
|
||||
};
|
||||
|
||||
/** ترکیب پسزمینهٔ اختصاصی صفحه با مقادیر پیشفرض سند (همان قاعدهٔ استفادهشده در BookViewer) */
|
||||
@@ -12,7 +14,9 @@ export function resolvePageBackground(page: PageData, defaults: PageBackgroundDe
|
||||
return {
|
||||
backgroundType: page.backgroundType ?? defaults.backgroundType ?? "color",
|
||||
backgroundColor: page.backgroundColor ?? defaults.backgroundColor ?? "#ffffff",
|
||||
backgroundOpacity: page.backgroundOpacity ?? defaults.backgroundOpacity ?? 100,
|
||||
backgroundGradient: page.backgroundGradient ?? defaults.backgroundGradient ?? { from: "#ffffff", to: "#ffffff", angle: 0 },
|
||||
backgroundImageUrl: page.backgroundImageUrl ?? defaults.backgroundImageUrl ?? "",
|
||||
backgroundVideoUrl: page.backgroundVideoUrl ?? defaults.backgroundVideoUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,12 +34,18 @@ export function extractPageMediaAssets(
|
||||
for (const page of pages) {
|
||||
if (page.backgroundType === 'image') {
|
||||
addImageUrl(assets, page.backgroundImageUrl);
|
||||
} else if (page.backgroundType === 'video') {
|
||||
addVideoUrl(assets, page.backgroundVideoUrl);
|
||||
}
|
||||
|
||||
for (const element of page.elements) {
|
||||
if (element.visible === false) continue;
|
||||
|
||||
if (element.type === 'image' || element.type === 'sticker') {
|
||||
if (
|
||||
element.type === 'image' ||
|
||||
element.type === 'sticker' ||
|
||||
element.type === 'document'
|
||||
) {
|
||||
addImageUrl(assets, element.imageUrl);
|
||||
} else if (element.type === 'video') {
|
||||
addVideoUrl(assets, element.videoUrl);
|
||||
@@ -51,6 +57,8 @@ export function extractPageMediaAssets(
|
||||
|
||||
if (documentSettings?.backgroundType === 'image') {
|
||||
addImageUrl(assets, documentSettings.backgroundImageUrl);
|
||||
} else if (documentSettings?.backgroundType === 'video') {
|
||||
addVideoUrl(assets, documentSettings.backgroundVideoUrl);
|
||||
}
|
||||
|
||||
return Array.from(assets.values());
|
||||
|
||||
Reference in New Issue
Block a user