Compare commits
33 Commits
b8de25e9d1
...
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 | |||
| 85ba8e4261 | |||
| 39e18c3331 | |||
| ca02a621da | |||
| b13a46ec65 | |||
| e93ab38b05 | |||
| 052b737653 |
@@ -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
+147
-120
@@ -23,6 +23,7 @@
|
|||||||
"moment-jalaali": "^0.10.4",
|
"moment-jalaali": "^0.10.4",
|
||||||
"page-flip": "^2.0.7",
|
"page-flip": "^2.0.7",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
"react-colorful": "^5.7.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-i18next": "^16.3.0",
|
"react-i18next": "^16.3.0",
|
||||||
@@ -54,13 +55,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
|
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-validator-identifier": "^7.27.1",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"js-tokens": "^4.0.0",
|
"js-tokens": "^4.0.0",
|
||||||
"picocolors": "^1.1.1"
|
"picocolors": "^1.1.1"
|
||||||
},
|
},
|
||||||
@@ -69,9 +70,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/compat-data": {
|
"node_modules/@babel/compat-data": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||||
"integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==",
|
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -79,21 +80,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/core": {
|
"node_modules/@babel/core": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-compilation-targets": "^7.27.2",
|
"@babel/helper-compilation-targets": "^7.29.7",
|
||||||
"@babel/helper-module-transforms": "^7.28.3",
|
"@babel/helper-module-transforms": "^7.29.7",
|
||||||
"@babel/helpers": "^7.28.4",
|
"@babel/helpers": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.27.2",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.5",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.28.5",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/remapping": "^2.3.5",
|
"@jridgewell/remapping": "^2.3.5",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"debug": "^4.1.0",
|
"debug": "^4.1.0",
|
||||||
@@ -110,14 +111,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/generator": {
|
"node_modules/@babel/generator": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||||
"integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
|
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.28.5",
|
"@babel/types": "^7.29.7",
|
||||||
"@jridgewell/gen-mapping": "^0.3.12",
|
"@jridgewell/gen-mapping": "^0.3.12",
|
||||||
"@jridgewell/trace-mapping": "^0.3.28",
|
"@jridgewell/trace-mapping": "^0.3.28",
|
||||||
"jsesc": "^3.0.2"
|
"jsesc": "^3.0.2"
|
||||||
@@ -127,14 +128,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-compilation-targets": {
|
"node_modules/@babel/helper-compilation-targets": {
|
||||||
"version": "7.27.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||||
"integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==",
|
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/compat-data": "^7.27.2",
|
"@babel/compat-data": "^7.29.7",
|
||||||
"@babel/helper-validator-option": "^7.27.1",
|
"@babel/helper-validator-option": "^7.29.7",
|
||||||
"browserslist": "^4.24.0",
|
"browserslist": "^4.24.0",
|
||||||
"lru-cache": "^5.1.1",
|
"lru-cache": "^5.1.1",
|
||||||
"semver": "^6.3.1"
|
"semver": "^6.3.1"
|
||||||
@@ -144,9 +145,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-globals": {
|
"node_modules/@babel/helper-globals": {
|
||||||
"version": "7.28.0",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -154,29 +155,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-imports": {
|
"node_modules/@babel/helper-module-imports": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||||
"integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==",
|
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/traverse": "^7.27.1",
|
"@babel/traverse": "^7.29.7",
|
||||||
"@babel/types": "^7.27.1"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-module-transforms": {
|
"node_modules/@babel/helper-module-transforms": {
|
||||||
"version": "7.28.3",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||||
"integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
|
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-module-imports": "^7.27.1",
|
"@babel/helper-module-imports": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.27.1",
|
"@babel/helper-validator-identifier": "^7.29.7",
|
||||||
"@babel/traverse": "^7.28.3"
|
"@babel/traverse": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -196,9 +197,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -206,9 +207,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -216,9 +217,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-validator-option": {
|
"node_modules/@babel/helper-validator-option": {
|
||||||
"version": "7.27.1",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -226,27 +227,27 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helpers": {
|
"node_modules/@babel/helpers": {
|
||||||
"version": "7.28.4",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||||
"integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
|
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/template": "^7.27.2",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.28.4"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
"node_modules/@babel/parser": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.28.5"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"parser": "bin/babel-parser.js"
|
"parser": "bin/babel-parser.js"
|
||||||
@@ -297,33 +298,33 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||||
"integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==",
|
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/parser": "^7.27.2",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/types": "^7.27.1"
|
"@babel/types": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/traverse": {
|
"node_modules/@babel/traverse": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||||
"integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
|
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.29.7",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.29.7",
|
||||||
"@babel/helper-globals": "^7.28.0",
|
"@babel/helper-globals": "^7.29.7",
|
||||||
"@babel/parser": "^7.28.5",
|
"@babel/parser": "^7.29.7",
|
||||||
"@babel/template": "^7.27.2",
|
"@babel/template": "^7.29.7",
|
||||||
"@babel/types": "^7.28.5",
|
"@babel/types": "^7.29.7",
|
||||||
"debug": "^4.3.1"
|
"debug": "^4.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -331,14 +332,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
"node_modules/@babel/types": {
|
||||||
"version": "7.28.5",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/helper-string-parser": "^7.27.1",
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
"@babel/helper-validator-identifier": "^7.28.5"
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -2570,13 +2571,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.25",
|
"version": "2.10.38",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz",
|
||||||
"integrity": "sha512-2NovHVesVF5TXefsGX1yzx1xgr7+m9JQenvz6FQY3qd+YXkKkYiv+vTCc7OriP9mcDZpTC5mAOYN4ocd29+erA==",
|
"integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
@@ -2604,9 +2608,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.0",
|
"version": "4.28.4",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
|
||||||
"integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
|
"integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2624,11 +2628,11 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.25",
|
"baseline-browser-mapping": "^2.10.38",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001799",
|
||||||
"electron-to-chromium": "^1.5.249",
|
"electron-to-chromium": "^1.5.376",
|
||||||
"node-releases": "^2.0.27",
|
"node-releases": "^2.0.48",
|
||||||
"update-browserslist-db": "^1.1.4"
|
"update-browserslist-db": "^1.2.3"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"browserslist": "cli.js"
|
"browserslist": "cli.js"
|
||||||
@@ -2661,9 +2665,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001754",
|
"version": "1.0.30001799",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz",
|
||||||
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
|
"integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -2875,9 +2879,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.250",
|
"version": "1.5.376",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.250.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz",
|
||||||
"integrity": "sha512-/5UMj9IiGDMOFBnN4i7/Ry5onJrAGSbOGo3s9FEKmwobGq6xw832ccET0CE3CkkMBZ8GJSlUIesZofpyurqDXw==",
|
"integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
@@ -3338,16 +3342,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.5",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.2",
|
"hasown": "^2.0.4",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -3537,9 +3541,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -3742,10 +3746,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.1.1",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -4292,11 +4306,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.48",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz",
|
||||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
"integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
@@ -4540,6 +4557,16 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"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": {
|
"node_modules/react-date-object": {
|
||||||
"version": "2.1.9",
|
"version": "2.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
|
||||||
@@ -5137,9 +5164,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.4",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
"integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
|
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -5206,9 +5233,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.3",
|
"version": "7.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.5.tgz",
|
||||||
"integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==",
|
"integrity": "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"moment-jalaali": "^0.10.4",
|
"moment-jalaali": "^0.10.4",
|
||||||
"page-flip": "^2.0.7",
|
"page-flip": "^2.0.7",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
"react-colorful": "^5.7.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-i18next": "^16.3.0",
|
"react-i18next": "^16.3.0",
|
||||||
|
|||||||
+182
-103
@@ -1,127 +1,206 @@
|
|||||||
import { type FC, type InputHTMLAttributes, useState, useRef, useEffect } from 'react'
|
import { type FC, type InputHTMLAttributes, useEffect, useRef, useState } from "react";
|
||||||
import { clx } from '../helpers/utils'
|
import { HexAlphaColorPicker, HexColorInput, HexColorPicker } from "react-colorful";
|
||||||
import Error from './Error'
|
import { clx } from "../helpers/utils";
|
||||||
|
import Error from "./Error";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label?: string
|
label?: string;
|
||||||
className?: string
|
className?: string;
|
||||||
error_text?: string
|
error_text?: string;
|
||||||
isNotRequired?: boolean
|
isNotRequired?: boolean;
|
||||||
value?: string
|
value?: string;
|
||||||
onChange?: (value: string) => void
|
onChange?: (value: string) => void;
|
||||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value'>
|
opacity?: number;
|
||||||
|
onOpacityChange?: (value: number) => void;
|
||||||
|
opacityLabel?: string;
|
||||||
|
} & Omit<InputHTMLAttributes<HTMLInputElement>, "onChange" | "value">;
|
||||||
|
|
||||||
const ColorPicker: FC<Props> = (props: Props) => {
|
const ColorPicker: FC<Props> = (props: Props) => {
|
||||||
const [color, setColor] = useState<string>(props.value || '#000000')
|
const [color, setColor] = useState<string>(props.value || "#000000");
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${props.opacity ?? 100}`);
|
||||||
const pickerOpenGuardRef = useRef(false)
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const showOpacity = props.onOpacityChange !== undefined;
|
||||||
|
|
||||||
const inputClass = clx(
|
const clampOpacity = (rawValue: number): number => Math.min(100, Math.max(0, rawValue));
|
||||||
'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 handleColorChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const normalizeHex = (value: string): string => {
|
||||||
const newColor = event.target.value
|
if (!value) return "#000000";
|
||||||
setColor(newColor)
|
const withHash = value.startsWith("#") ? value : `#${value}`;
|
||||||
props.onChange?.(newColor)
|
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 longMatch = /^#([0-9A-Fa-f]{6})$/.exec(withHash);
|
||||||
const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
if (longMatch) {
|
||||||
const value = event.target.value
|
return `#${longMatch[1]}`.toLowerCase();
|
||||||
if (/^#[0-9A-Fa-f]{0,6}$/.test(value) || value === '') {
|
|
||||||
const finalColor = value || '#000000'
|
|
||||||
setColor(finalColor)
|
|
||||||
props.onChange?.(finalColor)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return "#000000";
|
||||||
|
};
|
||||||
|
|
||||||
const openNativeColorPicker = () => {
|
const toHexAlpha = (hex: string, opacity: number): string => {
|
||||||
if (props.readOnly) return
|
const normalizedHex = normalizeHex(hex);
|
||||||
if (pickerOpenGuardRef.current) return
|
const alpha = Math.round((clampOpacity(opacity) / 100) * 255)
|
||||||
pickerOpenGuardRef.current = true
|
.toString(16)
|
||||||
colorInputRef.current?.click()
|
.padStart(2, "0");
|
||||||
window.setTimeout(() => {
|
return `${normalizedHex}${alpha}`;
|
||||||
pickerOpenGuardRef.current = false
|
};
|
||||||
}, 400)
|
|
||||||
|
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(() => {
|
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");
|
||||||
if (props.value !== undefined) {
|
|
||||||
setColor(props.value)
|
|
||||||
}
|
|
||||||
}, [props.value])
|
|
||||||
|
|
||||||
return (
|
const handleColorChange = (newColor: string) => {
|
||||||
<div className='w-full'>
|
const normalized = normalizeHex(newColor);
|
||||||
<label className='text-xs'>
|
setColor(normalized);
|
||||||
{props.label}
|
props.onChange?.(normalized);
|
||||||
</label>
|
};
|
||||||
|
|
||||||
<div
|
const togglePicker = () => {
|
||||||
className={clx(
|
if (props.readOnly) return;
|
||||||
'w-full relative mt-1 rounded-xl',
|
setIsOpen((prev) => !prev);
|
||||||
!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`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span
|
useEffect(() => {
|
||||||
aria-hidden
|
if (props.value !== undefined) {
|
||||||
className={clx(
|
setColor(normalizeHex(props.value));
|
||||||
'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'
|
}, [props.value]);
|
||||||
)}
|
|
||||||
>
|
useEffect(() => {
|
||||||
{/* <img
|
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}
|
src={ColorfilterIcon}
|
||||||
alt=''
|
alt=''
|
||||||
className='size-7 select-none'
|
className='size-7 select-none'
|
||||||
/> */}
|
/> */}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className={clx(
|
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")}
|
||||||
'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
|
|
||||||
className='block w-5 h-5 rounded-full border border-border shadow-sm'
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<input
|
{isOpen && !props.readOnly && (
|
||||||
ref={colorInputRef}
|
<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">
|
||||||
type='color'
|
<div className="space-y-1">
|
||||||
value={color}
|
{showOpacity ? (
|
||||||
onChange={handleColorChange}
|
<HexAlphaColorPicker color={toHexAlpha(color, Number(opacityDisplay) || 100)} onChange={handleAlphaPickerChange} />
|
||||||
className='absolute opacity-0 pointer-events-none w-0 h-0'
|
) : (
|
||||||
/>
|
<HexColorPicker color={color} onChange={handleColorChange} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{props.error_text && (
|
<HexColorInput color={color} onChange={handleColorChange} prefixed className="h-9 max-w-[115px] flex-1 rounded-lg border border-border px-2 text-xs" />
|
||||||
<Error errorText={props.error_text} />
|
{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 { Group, Rect, Text } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { EditorObject } from "@/pages/editor/store/editorStore";
|
import type { EditorObject } from "@/pages/editor/store/editorStore";
|
||||||
|
import { getColorWithOpacity } from "@/pages/editor/utils/colorOpacity";
|
||||||
|
|
||||||
type EditorTableProps = {
|
type EditorTableProps = {
|
||||||
obj: EditorObject;
|
obj: EditorObject;
|
||||||
@@ -80,7 +81,7 @@ const EditorTable = ({ obj, selectedCellId, onCellClick, onCellDblClick, onDragE
|
|||||||
y={y}
|
y={y}
|
||||||
width={cellWidth}
|
width={cellWidth}
|
||||||
height={cellHeight}
|
height={cellHeight}
|
||||||
fill={cell.background}
|
fill={getColorWithOpacity(cell.background, cell.backgroundOpacity ?? 100)}
|
||||||
stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"}
|
stroke={isCellSelected ? "#3b82f6" : stroke || "#808080"}
|
||||||
strokeWidth={isCellSelected ? 3 : strokeWidth || 1}
|
strokeWidth={isCellSelected ? 3 : strokeWidth || 1}
|
||||||
cornerRadius={isTopLeft ? [8, 0, 0, 0] : isTopRight ? [0, 8, 0, 0] : 0}
|
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 { type FC, useCallback, useEffect, useState } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { clx } from '../helpers/utils';
|
import { clx } from '../helpers/utils';
|
||||||
|
import UploadProgressBar from './UploadProgressBar';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -15,6 +16,8 @@ type Props = {
|
|||||||
/** نمایش وضعیت بارگذاری داخل باکس آپلود */
|
/** نمایش وضعیت بارگذاری داخل باکس آپلود */
|
||||||
isLoading?: boolean,
|
isLoading?: boolean,
|
||||||
loadingLabel?: string,
|
loadingLabel?: string,
|
||||||
|
/** درصد پیشرفت آپلود (۰ تا ۱۰۰) */
|
||||||
|
uploadProgress?: number,
|
||||||
/** بدون پیشنمایش زیر باکس (مثلاً گالری جداگانه دارد) */
|
/** بدون پیشنمایش زیر باکس (مثلاً گالری جداگانه دارد) */
|
||||||
hidePreview?: boolean,
|
hidePreview?: boolean,
|
||||||
}
|
}
|
||||||
@@ -77,8 +80,16 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
|||||||
}, [props.coverUrl])
|
}, [props.coverUrl])
|
||||||
|
|
||||||
|
|
||||||
|
const showProgressOnly = props.isLoading && props.uploadProgress !== undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{showProgressOnly ? (
|
||||||
|
<UploadProgressBar
|
||||||
|
progress={props.uploadProgress!}
|
||||||
|
label={props.loadingLabel ?? `در حال آپلود... ${props.uploadProgress}٪`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={clx(
|
className={clx(
|
||||||
@@ -114,6 +125,7 @@ const UploadBoxDraggble: FC<Props> = (props: Props) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{
|
{
|
||||||
!props.hidePreview && (files.length > 0 || perviews.length > 0) ? (
|
!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 { type FC } from "react";
|
||||||
import { DocumentUpload, VideoSquare } from "iconsax-react";
|
import { DocumentUpload, VideoSquare } from "iconsax-react";
|
||||||
|
import { clx } from "@/helpers/utils";
|
||||||
|
import UploadProgressBar from "@/components/UploadProgressBar";
|
||||||
|
|
||||||
type VideoUploadZoneProps = {
|
type VideoUploadZoneProps = {
|
||||||
getRootProps: () => React.HTMLAttributes<HTMLDivElement>;
|
getRootProps: () => React.HTMLAttributes<HTMLDivElement>;
|
||||||
getInputProps: () => React.InputHTMLAttributes<HTMLInputElement>;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...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()} />
|
<input {...getInputProps()} />
|
||||||
<VideoSquare
|
{isLoading ? (
|
||||||
size={32}
|
<>
|
||||||
color="#8C90A3"
|
<span className="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-black" />
|
||||||
variant="Outline"
|
<div className="text-description text-xs">{loadingLabel}</div>
|
||||||
/>
|
</>
|
||||||
<div className="text-description text-xs">
|
) : (
|
||||||
ویدیو مورد نظر را آپلود کنید
|
<>
|
||||||
</div>
|
<VideoSquare
|
||||||
<div className="flex items-center gap-2">
|
size={32}
|
||||||
<DocumentUpload
|
color="#8C90A3"
|
||||||
size={16}
|
variant="Outline"
|
||||||
color="black"
|
/>
|
||||||
/>
|
<div className="text-description text-xs">
|
||||||
<div className="text-xs">آپلود</div>
|
ویدیو مورد نظر را آپلود کنید
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<DocumentUpload
|
||||||
|
size={16}
|
||||||
|
color="black"
|
||||||
|
/>
|
||||||
|
<div className="text-xs">آپلود</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -271,3 +271,18 @@ html {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* حالت ذرهبین: همهٔ صفحات visible در اسپرد قابل hit-test باشند */
|
||||||
|
.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,
|
backgroundColor: page.backgroundColor,
|
||||||
backgroundGradient: page.backgroundGradient,
|
backgroundGradient: page.backgroundGradient,
|
||||||
backgroundImageUrl: page.backgroundImageUrl,
|
backgroundImageUrl: page.backgroundImageUrl,
|
||||||
|
backgroundVideoUrl: page.backgroundVideoUrl,
|
||||||
objects: page.objects,
|
objects: page.objects,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -90,7 +91,7 @@ const CatalogPreview: FC<Props> = ({ item, page, size, className, selected }) =>
|
|||||||
height: pageHeight,
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -67,6 +67,14 @@ export const useUpdateCatalog = () => {
|
|||||||
mutationFn: api.updateCatalog,
|
mutationFn: api.updateCatalog,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ref to always hold the latest mutation without adding it to useCallback/useEffect deps.
|
||||||
|
// React Query's useMutation returns a new object reference on every render, so
|
||||||
|
// closing over `mutation` directly would cause scheduleUpdate and the cleanup effect
|
||||||
|
// to be recreated/re-run on every render — firing mutation.mutate on each re-render
|
||||||
|
// during drag (potentially 100+ times).
|
||||||
|
const mutationRef = useRef(mutation);
|
||||||
|
mutationRef.current = mutation;
|
||||||
|
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const pendingParamsRef = useRef<
|
const pendingParamsRef = useRef<
|
||||||
Parameters<typeof api.updateCatalog>[0] | null
|
Parameters<typeof api.updateCatalog>[0] | null
|
||||||
@@ -96,11 +104,11 @@ export const useUpdateCatalog = () => {
|
|||||||
pendingParamsRef.current = null;
|
pendingParamsRef.current = null;
|
||||||
setIsScheduledSaving(false);
|
setIsScheduledSaving(false);
|
||||||
if (pendingParams) {
|
if (pendingParams) {
|
||||||
mutation.mutate(pendingParams);
|
mutationRef.current.mutate(pendingParams);
|
||||||
}
|
}
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
},
|
},
|
||||||
[mutation],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -112,12 +120,12 @@ export const useUpdateCatalog = () => {
|
|||||||
// اگر کاربر قبل از پایان debounce از ادیتور خارج شد،
|
// اگر کاربر قبل از پایان debounce از ادیتور خارج شد،
|
||||||
// آخرین تغییرات معلق را همان لحظه ذخیره کن.
|
// آخرین تغییرات معلق را همان لحظه ذخیره کن.
|
||||||
if (pendingParamsRef.current) {
|
if (pendingParamsRef.current) {
|
||||||
mutation.mutate(pendingParamsRef.current);
|
mutationRef.current.mutate(pendingParamsRef.current);
|
||||||
pendingParamsRef.current = null;
|
pendingParamsRef.current = null;
|
||||||
}
|
}
|
||||||
setIsScheduledSaving(false);
|
setIsScheduledSaving(false);
|
||||||
},
|
},
|
||||||
[mutation],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ import { useTransformHandlers } from "./canvas/useTransformHandlers";
|
|||||||
import { useObjectHandlers } from "./canvas/useObjectHandlers";
|
import { useObjectHandlers } from "./canvas/useObjectHandlers";
|
||||||
import { useMaskGroupState } from "./canvas/useMaskGroupState";
|
import { useMaskGroupState } from "./canvas/useMaskGroupState";
|
||||||
import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing";
|
import { useCustomShapeDrawing } from "./canvas/useCustomShapeDrawing";
|
||||||
|
import { usePencilDrawing } from "./canvas/usePencilDrawing";
|
||||||
import ObjectsLayer from "./canvas/ObjectsLayer";
|
import ObjectsLayer from "./canvas/ObjectsLayer";
|
||||||
import GuidesLayer from "./canvas/GuidesLayer";
|
import GuidesLayer from "./canvas/GuidesLayer";
|
||||||
|
import BlurBackdropLayer from "./canvas/BlurBackdropLayer";
|
||||||
import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer";
|
import CustomShapeDrawingLayer from "./canvas/CustomShapeDrawingLayer";
|
||||||
|
import PencilDrawingLayer from "./canvas/PencilDrawingLayer";
|
||||||
import {
|
import {
|
||||||
clearSmartGuides,
|
clearSmartGuides,
|
||||||
drawSmartGuides,
|
drawSmartGuides,
|
||||||
@@ -25,6 +28,7 @@ import ZoomControls from "./ZoomControls";
|
|||||||
import EditorCanvasToolbar from "./EditorCanvasToolbar";
|
import EditorCanvasToolbar from "./EditorCanvasToolbar";
|
||||||
import { createScopedId } from "../store/editorStore.helpers";
|
import { createScopedId } from "../store/editorStore.helpers";
|
||||||
import { getKonvaGradientProps } from "../utils/gradient";
|
import { getKonvaGradientProps } from "../utils/gradient";
|
||||||
|
import { getColorWithOpacity } from "../utils/colorOpacity";
|
||||||
import { STICKER_DRAG_MIME } from "../types/IconTypes";
|
import { STICKER_DRAG_MIME } from "../types/IconTypes";
|
||||||
import { GALLERY_DRAG_MIME } from "../types/GalleryTypes";
|
import { GALLERY_DRAG_MIME } from "../types/GalleryTypes";
|
||||||
import {
|
import {
|
||||||
@@ -70,10 +74,18 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
const currentPage = pages.find((page) => page.id === currentPageId);
|
const currentPage = pages.find((page) => page.id === currentPageId);
|
||||||
|
|
||||||
const bgColor =
|
const bgColor =
|
||||||
currentPage?.backgroundType === 'color' || currentPage?.backgroundType === 'gradient'
|
currentPage?.backgroundType === 'color'
|
||||||
|
? getColorWithOpacity(
|
||||||
|
currentPage.backgroundColor,
|
||||||
|
currentPage.backgroundOpacity ?? 100,
|
||||||
|
)
|
||||||
|
: currentPage?.backgroundType === 'gradient'
|
||||||
? currentPage.backgroundColor
|
? currentPage.backgroundColor
|
||||||
: '#ffffff';
|
: '#ffffff';
|
||||||
const bgGradient = currentPage?.backgroundGradient;
|
const bgGradient = currentPage?.backgroundGradient;
|
||||||
|
const hasVideoBackground =
|
||||||
|
currentPage?.backgroundType === "video" &&
|
||||||
|
Boolean(currentPage.backgroundVideoUrl);
|
||||||
|
|
||||||
const [bgImage] = useImage(
|
const [bgImage] = useImage(
|
||||||
currentPage?.backgroundType === 'image'
|
currentPage?.backgroundType === 'image'
|
||||||
@@ -101,6 +113,12 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
|
const { transformerRef, handleStageMouseDown, handleStageMouseMove, handleStageMouseUp } = useDrawingHandlers();
|
||||||
const { drawingState, handleStageClick: handleCustomShapeClick, handleStageMouseMove: handleCustomShapeMouseMove } =
|
const { drawingState, handleStageClick: handleCustomShapeClick, handleStageMouseMove: handleCustomShapeMouseMove } =
|
||||||
useCustomShapeDrawing();
|
useCustomShapeDrawing();
|
||||||
|
const {
|
||||||
|
drawingState: pencilDrawingState,
|
||||||
|
handleStagePointerDown: handlePencilPointerDown,
|
||||||
|
handleStagePointerMove: handlePencilPointerMove,
|
||||||
|
handleStagePointerUp: handlePencilPointerUp,
|
||||||
|
} = usePencilDrawing(stageRef);
|
||||||
useKeyboardMovement();
|
useKeyboardMovement();
|
||||||
|
|
||||||
const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers();
|
const { handleSelect, handleCellClick, handleCellDblClick } = useSelectionHandlers();
|
||||||
@@ -372,14 +390,32 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
if (e.target?.name() !== "guide-line") {
|
if (e.target?.name() !== "guide-line") {
|
||||||
setSelectedGuideId(null);
|
setSelectedGuideId(null);
|
||||||
}
|
}
|
||||||
|
if (tool === "pencil") return;
|
||||||
handleStageMouseDown(e);
|
handleStageMouseDown(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleStagePointerDownWithGuideReset = (e: Konva.KonvaEventObject<PointerEvent>) => {
|
||||||
|
if (e.target?.name() !== "guide-line") {
|
||||||
|
setSelectedGuideId(null);
|
||||||
|
}
|
||||||
|
if (tool === "pencil") {
|
||||||
|
handlePencilPointerDown(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCombinedMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleCombinedMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
handleStageMouseMove(e);
|
handleStageMouseMove(e);
|
||||||
handleCustomShapeMouseMove(e);
|
handleCustomShapeMouseMove(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCombinedPointerMove = (e: Konva.KonvaEventObject<PointerEvent>) => {
|
||||||
|
handlePencilPointerMove(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCombinedPointerUp = (e: Konva.KonvaEventObject<PointerEvent>) => {
|
||||||
|
handlePencilPointerUp(e);
|
||||||
|
};
|
||||||
|
|
||||||
const scaledW = stageSize.width * finalScale;
|
const scaledW = stageSize.width * finalScale;
|
||||||
const scaledH = stageSize.height * finalScale;
|
const scaledH = stageSize.height * finalScale;
|
||||||
|
|
||||||
@@ -501,35 +537,50 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
<div className="absolute -top-6 -left-6 w-6 h-6 bg-slate-200 border border-slate-300" />
|
<div className="absolute -top-6 -left-6 w-6 h-6 bg-slate-200 border border-slate-300" />
|
||||||
<div
|
<div
|
||||||
ref={stageWrapRef}
|
ref={stageWrapRef}
|
||||||
className="shadow-lg"
|
className="relative shadow-lg"
|
||||||
onDragOver={handleSidebarDragOver}
|
onDragOver={handleSidebarDragOver}
|
||||||
onDrop={handleSidebarDrop}
|
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
|
<Stage
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
width={stageSize.width * finalScale}
|
width={stageSize.width * finalScale}
|
||||||
height={stageSize.height * finalScale}
|
height={stageSize.height * finalScale}
|
||||||
scaleX={finalScale}
|
scaleX={finalScale}
|
||||||
scaleY={finalScale}
|
scaleY={finalScale}
|
||||||
style={{ cursor: tool === "custom-shape" ? "crosshair" : undefined }}
|
style={{ cursor: tool === "custom-shape" || tool === "pencil" ? "crosshair" : undefined }}
|
||||||
onMouseDown={handleStageMouseDownWithGuideReset}
|
onMouseDown={handleStageMouseDownWithGuideReset}
|
||||||
onMouseMove={handleCombinedMouseMove}
|
onMouseMove={handleCombinedMouseMove}
|
||||||
onMouseUp={handleStageMouseUp}
|
onMouseUp={handleStageMouseUp}
|
||||||
|
onPointerDown={handleStagePointerDownWithGuideReset}
|
||||||
|
onPointerMove={handleCombinedPointerMove}
|
||||||
|
onPointerUp={handleCombinedPointerUp}
|
||||||
onClick={handleCustomShapeClick}
|
onClick={handleCustomShapeClick}
|
||||||
onDragMove={handleDragMove}
|
onDragMove={handleDragMove}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<Layer listening={false}>
|
<Layer listening={false}>
|
||||||
<Rect
|
{!hasVideoBackground && (
|
||||||
x={0}
|
<Rect
|
||||||
y={0}
|
x={0}
|
||||||
width={stageSize.width}
|
y={0}
|
||||||
height={stageSize.height}
|
width={stageSize.width}
|
||||||
fill={bgColor}
|
height={stageSize.height}
|
||||||
{...backgroundGradientProps}
|
fill={bgColor}
|
||||||
listening={false}
|
{...backgroundGradientProps}
|
||||||
/>
|
listening={false}
|
||||||
{currentPage?.backgroundType === 'image' && bgImage && (
|
/>
|
||||||
|
)}
|
||||||
|
{!hasVideoBackground && currentPage?.backgroundType === 'image' && bgImage && (
|
||||||
<KonvaImage
|
<KonvaImage
|
||||||
image={bgImage}
|
image={bgImage}
|
||||||
x={0}
|
x={0}
|
||||||
@@ -575,7 +626,15 @@ const EditorCanvas = ({ catalogSize }: EditorCanvasProps) => {
|
|||||||
/>
|
/>
|
||||||
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
|
<Layer ref={smartGuidesLayerRef} listening={false} visible={false} />
|
||||||
<CustomShapeDrawingLayer drawingState={drawingState} />
|
<CustomShapeDrawingLayer drawingState={drawingState} />
|
||||||
|
<PencilDrawingLayer drawingState={pencilDrawingState} />
|
||||||
</Stage>
|
</Stage>
|
||||||
|
<BlurBackdropLayer
|
||||||
|
objects={objects}
|
||||||
|
stageWidth={stageSize.width}
|
||||||
|
stageHeight={stageSize.height}
|
||||||
|
scale={finalScale}
|
||||||
|
stageRef={stageRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
MouseSquare,
|
MouseSquare,
|
||||||
Sticker,
|
Sticker,
|
||||||
Polygon,
|
Polygon,
|
||||||
|
Brush,
|
||||||
} from 'iconsax-react'
|
} from 'iconsax-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useEditorStore } from '../store/editorStore'
|
import { useEditorStore } from '../store/editorStore'
|
||||||
@@ -66,6 +67,8 @@ const LayersList = () => {
|
|||||||
return <Sticker size={20} color="black" />
|
return <Sticker size={20} color="black" />
|
||||||
case 'custom-shape':
|
case 'custom-shape':
|
||||||
return <Polygon size={20} color="black" />
|
return <Polygon size={20} color="black" />
|
||||||
|
case 'pencil':
|
||||||
|
return <Brush size={20} color="black" />
|
||||||
default:
|
default:
|
||||||
return <Shapes size={20} color="black" />
|
return <Shapes size={20} color="black" />
|
||||||
}
|
}
|
||||||
@@ -117,6 +120,8 @@ const LayersList = () => {
|
|||||||
return 'استیکر'
|
return 'استیکر'
|
||||||
case 'custom-shape':
|
case 'custom-shape':
|
||||||
return 'شکل سفارشی'
|
return 'شکل سفارشی'
|
||||||
|
case 'pencil':
|
||||||
|
return 'نقاشی'
|
||||||
default:
|
default:
|
||||||
return 'لایه'
|
return 'لایه'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ function isPageEmptyForPreview(page: Page): boolean {
|
|||||||
if (page.backgroundType === 'image' && page.backgroundImageUrl.trim() !== '') {
|
if (page.backgroundType === 'image' && page.backgroundImageUrl.trim() !== '') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (page.backgroundType === 'video' && page.backgroundVideoUrl.trim() !== '') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (page.backgroundType === 'gradient') return false
|
if (page.backgroundType === 'gradient') return false
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { clx } from '@/helpers/utils'
|
import { clx } from '@/helpers/utils'
|
||||||
import { useEditorStore } from '../store/editorStore'
|
import { useEditorStore } from '../store/editorStore'
|
||||||
import type { DisplayStyle, BackgroundType } from '../store/editorStore'
|
import type { DisplayStyle, BackgroundType } from '../store/editorStore'
|
||||||
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
|
import UploadBoxDraggble from '@/components/UploadBoxDraggble'
|
||||||
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
import { useSingleUpload } from '@/pages/uploader/hooks/useUploaderData'
|
||||||
import ColorsImage from '@/assets/images/colors.png'
|
|
||||||
import ColorPicker from '@/components/ColorPicker'
|
import ColorPicker from '@/components/ColorPicker'
|
||||||
import Select from '@/components/Select'
|
import Select from '@/components/Select'
|
||||||
|
import { toCssLinearGradient } from '../utils/gradient'
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
'#a8edcf',
|
'#a8edcf',
|
||||||
@@ -27,6 +27,7 @@ const BACKGROUND_TYPE_OPTIONS: { value: BackgroundType; label: string }[] = [
|
|||||||
{ value: 'color', label: 'رنگ' },
|
{ value: 'color', label: 'رنگ' },
|
||||||
{ value: 'gradient', label: 'گرادیانت' },
|
{ value: 'gradient', label: 'گرادیانت' },
|
||||||
{ value: 'image', label: 'تصویر' },
|
{ value: 'image', label: 'تصویر' },
|
||||||
|
{ value: 'video', label: 'ویدیو' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const SettingsPanel = () => {
|
const SettingsPanel = () => {
|
||||||
@@ -37,17 +38,18 @@ const SettingsPanel = () => {
|
|||||||
updateDocumentSettings,
|
updateDocumentSettings,
|
||||||
updateCurrentPageBackground,
|
updateCurrentPageBackground,
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
const { mutateAsync: uploadFile, isPending: mediaLoading } = useSingleUpload()
|
||||||
const { mutateAsync: uploadFile, isPending: imageLoading } = useSingleUpload()
|
|
||||||
const currentPage = pages.find((page) => page.id === currentPageId)
|
const currentPage = pages.find((page) => page.id === currentPageId)
|
||||||
const backgroundType = currentPage?.backgroundType ?? 'color'
|
const backgroundType = currentPage?.backgroundType ?? 'color'
|
||||||
const backgroundColor = currentPage?.backgroundColor ?? '#ffffff'
|
const backgroundColor = currentPage?.backgroundColor ?? '#ffffff'
|
||||||
|
const backgroundOpacity = currentPage?.backgroundOpacity ?? 100
|
||||||
const backgroundGradient = currentPage?.backgroundGradient ?? {
|
const backgroundGradient = currentPage?.backgroundGradient ?? {
|
||||||
from: '#ffffff',
|
from: '#ffffff',
|
||||||
to: '#e2e8f0',
|
to: '#e2e8f0',
|
||||||
angle: 135,
|
angle: 135,
|
||||||
}
|
}
|
||||||
const backgroundImageUrl = currentPage?.backgroundImageUrl ?? ''
|
const backgroundImageUrl = currentPage?.backgroundImageUrl ?? ''
|
||||||
|
const backgroundVideoUrl = currentPage?.backgroundVideoUrl ?? ''
|
||||||
const [uploadedPreviews, setUploadedPreviews] = useState<string[]>(
|
const [uploadedPreviews, setUploadedPreviews] = useState<string[]>(
|
||||||
backgroundImageUrl ? [backgroundImageUrl] : []
|
backgroundImageUrl ? [backgroundImageUrl] : []
|
||||||
)
|
)
|
||||||
@@ -66,13 +68,18 @@ const SettingsPanel = () => {
|
|||||||
)
|
)
|
||||||
}, [backgroundImageUrl])
|
}, [backgroundImageUrl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (backgroundType !== 'video') return
|
||||||
|
setUploadedPreviews(backgroundVideoUrl ? [backgroundVideoUrl] : [])
|
||||||
|
}, [backgroundType, backgroundVideoUrl])
|
||||||
|
|
||||||
const handleImageFiles = async (files: File[]) => {
|
const handleImageFiles = async (files: File[]) => {
|
||||||
if (files.length === 0) return
|
if (files.length === 0) return
|
||||||
const file = files[files.length - 1]
|
const file = files[files.length - 1]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await uploadFile(file)
|
const result = await uploadFile({ file })
|
||||||
const imageUrl = result?.data?.url
|
const imageUrl = result?.data?.url
|
||||||
if (!imageUrl) return
|
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[]) => {
|
const handlePreviewChange = (previews: string[]) => {
|
||||||
setUploadedPreviews(previews)
|
setUploadedPreviews(previews)
|
||||||
if (previews.length === 0) {
|
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>
|
<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) => (
|
{BACKGROUND_TYPE_OPTIONS.map((opt) => (
|
||||||
<label key={opt.value} className="flex items-center gap-1.5 cursor-pointer select-none">
|
<label key={opt.value} className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||||
<span className="text-xs text-gray-600">{opt.label}</span>
|
<span className="text-xs text-gray-600">{opt.label}</span>
|
||||||
@@ -166,6 +194,8 @@ const SettingsPanel = () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(opt.value !== 'image' ? { backgroundImageUrl: '' } : {}),
|
||||||
|
...(opt.value !== 'video' ? { backgroundVideoUrl: '' } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={clx(
|
className={clx(
|
||||||
@@ -203,30 +233,29 @@ const SettingsPanel = () => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Color Wheel */}
|
<div className="w-full mt-2">
|
||||||
<button
|
<ColorPicker
|
||||||
onClick={() => colorInputRef.current?.click()}
|
label="رنگ دلخواه"
|
||||||
className="w-9 h-9 rounded-xl border-2 border-transparent hover:scale-105 transition-all overflow-hidden relative"
|
|
||||||
title="رنگ دلخواه"
|
|
||||||
>
|
|
||||||
<img src={ColorsImage} alt="انتخاب رنگ" className="w-full h-full object-cover" />
|
|
||||||
<input
|
|
||||||
ref={colorInputRef}
|
|
||||||
type="color"
|
|
||||||
value={backgroundColor}
|
value={backgroundColor}
|
||||||
onChange={(e) => updateCurrentPageBackground({ backgroundColor: e.target.value })}
|
opacity={backgroundOpacity}
|
||||||
className="absolute inset-0 opacity-0 w-full h-full cursor-pointer"
|
onChange={(value) =>
|
||||||
|
updateCurrentPageBackground({ backgroundColor: value })
|
||||||
|
}
|
||||||
|
onOpacityChange={(value) =>
|
||||||
|
updateCurrentPageBackground({ backgroundOpacity: value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* نمایش رنگ انتخابشده */}
|
{/* نمایش رنگ انتخابشده */}
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="mt-3 flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-lg border border-border"
|
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-500 font-mono">{backgroundColor}</span>
|
||||||
|
<span className="text-xs text-gray-400">{backgroundOpacity}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -292,7 +321,7 @@ const SettingsPanel = () => {
|
|||||||
<div
|
<div
|
||||||
className="h-12 rounded-xl border border-border"
|
className="h-12 rounded-xl border border-border"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(${backgroundGradient.angle}deg, ${backgroundGradient.from} 0%, ${backgroundGradient.to} 100%)`,
|
backgroundImage: toCssLinearGradient(backgroundGradient),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,7 +330,7 @@ const SettingsPanel = () => {
|
|||||||
{/* ─── آپلود تصویر ─── */}
|
{/* ─── آپلود تصویر ─── */}
|
||||||
{backgroundType === 'image' && (
|
{backgroundType === 'image' && (
|
||||||
<div>
|
<div>
|
||||||
{imageLoading ? (
|
{mediaLoading ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
<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="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>
|
<span className="text-xs text-gray-500">در حال آپلود...</span>
|
||||||
@@ -317,6 +346,44 @@ const SettingsPanel = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</div>
|
</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;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { Group, Rect, Circle, RegularPolygon, Star } from "react-konva";
|
import { Group, Rect, Circle, RegularPolygon, Star } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { EditorObject } from "../../store/editorStore";
|
import type { EditorObject } from "../../store/editorStore";
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
VideoShape,
|
VideoShape,
|
||||||
AudioShape,
|
AudioShape,
|
||||||
CustomShape,
|
CustomShape,
|
||||||
|
PencilShape,
|
||||||
} from "../tools";
|
} from "../tools";
|
||||||
import EditorTable from "@/components/EditorTable";
|
import EditorTable from "@/components/EditorTable";
|
||||||
|
|
||||||
@@ -56,21 +57,26 @@ const ObjectRenderer = ({
|
|||||||
const maskShape = obj.maskId ? allObjects.find((m) => m.id === obj.maskId) : null;
|
const maskShape = obj.maskId ? allObjects.find((m) => m.id === obj.maskId) : null;
|
||||||
const shouldApplyMask = maskShape && !obj.isMask;
|
const shouldApplyMask = maskShape && !obj.isMask;
|
||||||
|
|
||||||
// Apply caching for masked objects - REQUIRED for destination-in to work
|
// Whether the object and its mask share a groupId (move together as one unit).
|
||||||
|
const isGroupedWithMask = !!(obj.groupId && maskShape && maskShape.groupId === obj.groupId);
|
||||||
|
|
||||||
|
// For GROUPED masks the relative offset is always constant — no need to track it.
|
||||||
|
// For NON-GROUPED masks we must recache when the user repositions just the mask.
|
||||||
|
// Round to integer to suppress floating-point noise that accumulates when
|
||||||
|
// handleObjectUpdate adds a delta to every group member on each drag pixel:
|
||||||
|
// e.g. 281.6692917998996 vs 281.6692917998995 would otherwise fire the effect.
|
||||||
|
const maskRelXDep = (shouldApplyMask && !isGroupedWithMask) ? Math.round((maskShape?.x || 0) - (obj.x || 0)) : 0;
|
||||||
|
const maskRelYDep = (shouldApplyMask && !isGroupedWithMask) ? Math.round((maskShape?.y || 0) - (obj.y || 0)) : 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const groupNode = groupRef.current;
|
const groupNode = groupRef.current;
|
||||||
if (!groupNode || !shouldApplyMask) return;
|
if (!groupNode || !shouldApplyMask) return;
|
||||||
|
|
||||||
// Use double requestAnimationFrame to ensure all changes (including stroke) are rendered before caching
|
|
||||||
let rafId1: number;
|
let rafId1: number;
|
||||||
const rafId2 = requestAnimationFrame(() => {
|
const rafId2 = requestAnimationFrame(() => {
|
||||||
rafId1 = requestAnimationFrame(() => {
|
rafId1 = requestAnimationFrame(() => {
|
||||||
if (!groupRef.current) return;
|
if (!groupRef.current) return;
|
||||||
|
|
||||||
// Clear cache first
|
|
||||||
groupRef.current.clearCache();
|
groupRef.current.clearCache();
|
||||||
|
|
||||||
// Apply cache - Konva will automatically calculate bounds
|
|
||||||
groupRef.current.cache();
|
groupRef.current.cache();
|
||||||
groupRef.current.getLayer()?.batchDraw();
|
groupRef.current.getLayer()?.batchDraw();
|
||||||
});
|
});
|
||||||
@@ -81,13 +87,13 @@ const ObjectRenderer = ({
|
|||||||
if (rafId1) cancelAnimationFrame(rafId1);
|
if (rafId1) cancelAnimationFrame(rafId1);
|
||||||
groupNode?.clearCache();
|
groupNode?.clearCache();
|
||||||
};
|
};
|
||||||
}, [shouldApplyMask, isSelected, maskShape?.x, maskShape?.y, maskShape?.width, maskShape?.height, maskShape?.rotation, obj.x, obj.y, obj.width, obj.height, obj.rotation, obj.maskInvert, obj.strokeWidth, obj.stroke, obj.fill, obj.fillType, obj.gradient]);
|
// 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, obj.blur, obj.borderRadius]);
|
||||||
|
|
||||||
// Refresh cache after transformer is attached (when isSelected changes)
|
// Refresh cache after transformer is attached (when isSelected changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!groupRef.current || !shouldApplyMask || !isSelected) return;
|
if (!groupRef.current || !shouldApplyMask || !isSelected) return;
|
||||||
|
|
||||||
// Wait for transformer to attach, then refresh cache
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
if (!groupRef.current) return;
|
if (!groupRef.current) return;
|
||||||
groupRef.current.clearCache();
|
groupRef.current.clearCache();
|
||||||
@@ -100,6 +106,17 @@ const ObjectRenderer = ({
|
|||||||
};
|
};
|
||||||
}, [shouldApplyMask, isSelected, transformerRef]);
|
}, [shouldApplyMask, isSelected, transformerRef]);
|
||||||
|
|
||||||
|
// Called by async shapes (images) once their content is fully decoded and painted.
|
||||||
|
const handleImageReady = useCallback(() => {
|
||||||
|
if (!groupRef.current || !shouldApplyMask) return;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
groupRef.current.clearCache();
|
||||||
|
groupRef.current.cache();
|
||||||
|
groupRef.current.getLayer()?.batchDraw();
|
||||||
|
});
|
||||||
|
}, [shouldApplyMask]);
|
||||||
|
|
||||||
if (obj.visible === false) {
|
if (obj.visible === false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -216,7 +233,7 @@ const ObjectRenderer = ({
|
|||||||
shapeElement = <ArrowShape key={obj.id} {...commonProps} />;
|
shapeElement = <ArrowShape key={obj.id} {...commonProps} />;
|
||||||
break;
|
break;
|
||||||
case "image":
|
case "image":
|
||||||
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
shapeElement = <ImageObject key={obj.id} {...commonProps} onImageReady={handleImageReady} />;
|
||||||
break;
|
break;
|
||||||
case "link":
|
case "link":
|
||||||
shapeElement = <LinkShape key={obj.id} {...commonProps} />;
|
shapeElement = <LinkShape key={obj.id} {...commonProps} />;
|
||||||
@@ -234,10 +251,10 @@ const ObjectRenderer = ({
|
|||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "document":
|
case "document":
|
||||||
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
shapeElement = <ImageObject key={obj.id} {...commonProps} onImageReady={handleImageReady} />;
|
||||||
break;
|
break;
|
||||||
case "sticker":
|
case "sticker":
|
||||||
shapeElement = <ImageObject key={obj.id} {...commonProps} />;
|
shapeElement = <ImageObject key={obj.id} {...commonProps} onImageReady={handleImageReady} />;
|
||||||
break;
|
break;
|
||||||
case "grid":
|
case "grid":
|
||||||
shapeElement = (
|
shapeElement = (
|
||||||
@@ -262,6 +279,9 @@ const ObjectRenderer = ({
|
|||||||
case "custom-shape":
|
case "custom-shape":
|
||||||
shapeElement = <CustomShape key={obj.id} {...commonProps} />;
|
shapeElement = <CustomShape key={obj.id} {...commonProps} />;
|
||||||
break;
|
break;
|
||||||
|
case "pencil":
|
||||||
|
shapeElement = <PencilShape key={obj.id} {...commonProps} />;
|
||||||
|
break;
|
||||||
case "group":
|
case "group":
|
||||||
// Render group as a simple selection box
|
// Render group as a simple selection box
|
||||||
// Objects inside group are rendered separately in EditorCanvas
|
// Objects inside group are rendered separately in EditorCanvas
|
||||||
@@ -317,8 +337,8 @@ const ObjectRenderer = ({
|
|||||||
// Apply masking with Group + destination-in or destination-out
|
// Apply masking with Group + destination-in or destination-out
|
||||||
// IMPORTANT: The shape inside keeps its normal event handlers
|
// IMPORTANT: The shape inside keeps its normal event handlers
|
||||||
// The mask layer has listening={false} to not interfere with selection
|
// The mask layer has listening={false} to not interfere with selection
|
||||||
// Default: destination-out (overlap مخفی میشود)
|
// Default: destination-in (نمایش فقط داخل ماسک — جاهایی که ماسک هست نمایش میدهد)
|
||||||
const compositeOp = obj.maskInvert === false ? "destination-in" : "destination-out";
|
const compositeOp = obj.maskInvert === false ? "destination-out" : "destination-in";
|
||||||
|
|
||||||
const handleGroupDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
|
const handleGroupDragEnd = (e: Konva.KonvaEventObject<DragEvent>) => {
|
||||||
const node = e.target;
|
const node = e.target;
|
||||||
@@ -328,18 +348,10 @@ const ObjectRenderer = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleGroupDragMove = (e: Konva.KonvaEventObject<DragEvent>) => {
|
const handleGroupDragMove = (_e: Konva.KonvaEventObject<DragEvent>) => {
|
||||||
const node = e.target;
|
// Intentionally empty — see comment above.
|
||||||
// بهروزرسانی transformer در حین drag
|
|
||||||
if (transformerRef.current && isSelected) {
|
|
||||||
transformerRef.current.nodes([node]);
|
|
||||||
transformerRef.current.getLayer()?.batchDraw();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// بررسی آیا object با mask گروه شده است
|
|
||||||
const isGroupedWithMask = obj.groupId && maskShape && maskShape.groupId === obj.groupId;
|
|
||||||
|
|
||||||
const handleGroupTransformEnd = () => {
|
const handleGroupTransformEnd = () => {
|
||||||
if (!groupRef.current || !maskShape) return;
|
if (!groupRef.current || !maskShape) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from "react";
|
||||||
import { Layer, Group } from "react-konva";
|
import { Layer, Group } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import ObjectRenderer from "./ObjectRenderer";
|
import ObjectRenderer from "./ObjectRenderer";
|
||||||
@@ -57,7 +58,7 @@ const ObjectsLayer = ({
|
|||||||
stageHeight,
|
stageHeight,
|
||||||
}: ObjectsLayerProps) => {
|
}: ObjectsLayerProps) => {
|
||||||
return (
|
return (
|
||||||
<Layer ref={layerRef}>
|
<Layer ref={layerRef} listening={tool !== "pencil"}>
|
||||||
{/* Render group objects first */}
|
{/* Render group objects first */}
|
||||||
{objects
|
{objects
|
||||||
.filter((obj) => obj.type === "group")
|
.filter((obj) => obj.type === "group")
|
||||||
@@ -152,5 +153,11 @@ const ObjectsLayer = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ObjectsLayer;
|
// Memoized to prevent re-renders when EditorCanvas state that is unrelated to
|
||||||
|
// object rendering changes (e.g. activeGuideIds during drag). All function
|
||||||
|
// props are stabilised via useCallback in their respective hooks so this memo
|
||||||
|
// is effective — preventing the cascade: setActiveGuideIds → EditorCanvas
|
||||||
|
// re-render → ObjectsLayer re-render → every ObjectRenderer re-render →
|
||||||
|
// react-konva reconcile + batchDraw for every drag-move pixel.
|
||||||
|
export default memo(ObjectsLayer);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Layer, Line } from "react-konva";
|
||||||
|
import type { PencilDrawingState } from "./usePencilDrawing";
|
||||||
|
|
||||||
|
type PencilDrawingLayerProps = {
|
||||||
|
drawingState: PencilDrawingState;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PencilDrawingLayer = ({ drawingState }: PencilDrawingLayerProps) => {
|
||||||
|
const { isDrawing, points, stroke, strokeWidth } = drawingState;
|
||||||
|
|
||||||
|
if (!isDrawing || points.length < 1) return null;
|
||||||
|
|
||||||
|
const linePoints = points.flatMap((p) => [p.x, p.y]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layer listening={false}>
|
||||||
|
<Line
|
||||||
|
points={linePoints}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
tension={0.5}
|
||||||
|
lineCap="round"
|
||||||
|
lineJoin="round"
|
||||||
|
perfectDrawEnabled={false}
|
||||||
|
/>
|
||||||
|
</Layer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PencilDrawingLayer;
|
||||||
@@ -8,6 +8,10 @@ interface TransformerControlsProps {
|
|||||||
isGroupedWithMask: boolean;
|
isGroupedWithMask: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SELECTION_BLUE = "#93c5fd";
|
||||||
|
const ANCHOR_SIZE = 5;
|
||||||
|
const ROTATE_ANCHOR_OFFSET = 22;
|
||||||
|
|
||||||
const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => {
|
const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask }: TransformerControlsProps) => {
|
||||||
if (!selectedObject) return null;
|
if (!selectedObject) return null;
|
||||||
|
|
||||||
@@ -43,12 +47,18 @@ const TransformerControls = ({ transformerRef, selectedObject, isGroupedWithMask
|
|||||||
}}
|
}}
|
||||||
ignoreStroke={isText}
|
ignoreStroke={isText}
|
||||||
perfectDrawEnabled={false}
|
perfectDrawEnabled={false}
|
||||||
anchorFill="#3b82f6"
|
anchorFill={SELECTION_BLUE}
|
||||||
anchorStroke="#ffffff"
|
anchorStroke="#ffffff"
|
||||||
borderStroke="#3b82f6"
|
borderStroke={SELECTION_BLUE}
|
||||||
borderStrokeWidth={1}
|
borderStrokeWidth={1}
|
||||||
padding={-2}
|
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}
|
rotateEnabled={!isGroup && !isGroupedWithMask}
|
||||||
enabledAnchors={enabledAnchors}
|
enabledAnchors={enabledAnchors}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Konva from "konva";
|
|||||||
import { useEditorStore, type EditorObject } from "@/pages/editor/store/editorStore";
|
import { useEditorStore, type EditorObject } from "@/pages/editor/store/editorStore";
|
||||||
import { useTextDefaultsStore } from "@/pages/editor/store/textDefaultsStore";
|
import { useTextDefaultsStore } from "@/pages/editor/store/textDefaultsStore";
|
||||||
import { createDrawingObject } from "./drawingUtils";
|
import { createDrawingObject } from "./drawingUtils";
|
||||||
|
import { DEFAULT_TEXT_MAX_WIDTH_PX } from "@/pages/editor/utils/textStyle";
|
||||||
|
|
||||||
export const useDrawingHandlers = () => {
|
export const useDrawingHandlers = () => {
|
||||||
const [isDrawing, setIsDrawing] = useState(false);
|
const [isDrawing, setIsDrawing] = useState(false);
|
||||||
@@ -27,8 +28,8 @@ export const useDrawingHandlers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleStageMouseDown = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
// custom-shape drawing is fully managed by useCustomShapeDrawing
|
// custom-shape and pencil drawing are managed by dedicated hooks
|
||||||
if (tool === "custom-shape") return;
|
if (tool === "custom-shape" || tool === "pencil") return;
|
||||||
|
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
@@ -82,6 +83,7 @@ export const useDrawingHandlers = () => {
|
|||||||
letterSpacing: defaults.letterSpacing,
|
letterSpacing: defaults.letterSpacing,
|
||||||
wordSpacing: defaults.wordSpacing,
|
wordSpacing: defaults.wordSpacing,
|
||||||
height: defaults.fontSize || 24,
|
height: defaults.fontSize || 24,
|
||||||
|
textMaxWidth: DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||||
};
|
};
|
||||||
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
||||||
addObject(newText);
|
addObject(newText);
|
||||||
@@ -98,7 +100,7 @@ export const useDrawingHandlers = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStageMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
const handleStageMouseMove = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
if (!isDrawing || tool === "select" || tool === "text" || tool === "custom-shape") return;
|
if (!isDrawing || tool === "select" || tool === "text" || tool === "custom-shape" || tool === "pencil") return;
|
||||||
|
|
||||||
const stage = e.target.getStage();
|
const stage = e.target.getStage();
|
||||||
if (!stage) return;
|
if (!stage) return;
|
||||||
|
|||||||
@@ -1,58 +1,55 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
import { useEditorStore, type EditorObject } from "../../store/editorStore";
|
import { useEditorStore, type EditorObject } from "../../store/editorStore";
|
||||||
|
|
||||||
export const useObjectHandlers = () => {
|
export const useObjectHandlers = () => {
|
||||||
const { updateObject, groupObjects, ungroupObjects, updateCellValue, setEditingCell, editingCell } =
|
// All handlers read live state via getState() and are wrapped in useCallback
|
||||||
useEditorStore();
|
// with empty deps so their references never change between renders.
|
||||||
|
// This prevents ObjectsLayer (and every ObjectRenderer inside it) from
|
||||||
|
// re-rendering whenever unrelated EditorCanvas state (e.g. active guide IDs)
|
||||||
|
// changes during drag — eliminating lag and flickering on masked groups.
|
||||||
|
|
||||||
const handleObjectUpdate = (id: string, updates: Partial<EditorObject>) => {
|
const handleObjectUpdate = useCallback((id: string, updates: Partial<EditorObject>) => {
|
||||||
// همیشه آخرین state را بخوان تا چند dragmove پشتسرهم قبل از رِندر React، دلتا درست بماند
|
const { objects, updateObject } = useEditorStore.getState();
|
||||||
const liveObjects = useEditorStore.getState().objects;
|
const obj = objects.find((o) => o.id === id);
|
||||||
const obj = liveObjects.find((o) => o.id === id);
|
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
|
|
||||||
// اگر group object است و position تغییر کرد، همه objectهای داخل group را جابجا کن
|
|
||||||
if (obj.type === "group" && (updates.x !== undefined || updates.y !== undefined)) {
|
if (obj.type === "group" && (updates.x !== undefined || updates.y !== undefined)) {
|
||||||
const groupMembers = liveObjects.filter((o) => o.groupId === id);
|
const groupMembers = objects.filter((o) => o.groupId === id);
|
||||||
const deltaX = updates.x !== undefined ? updates.x - (obj.x || 0) : 0;
|
const deltaX = updates.x !== undefined ? updates.x - (obj.x || 0) : 0;
|
||||||
const deltaY = updates.y !== undefined ? updates.y - (obj.y || 0) : 0;
|
const deltaY = updates.y !== undefined ? updates.y - (obj.y || 0) : 0;
|
||||||
|
|
||||||
// بهروزرسانی group object
|
|
||||||
updateObject(id, updates);
|
updateObject(id, updates);
|
||||||
|
|
||||||
// بهروزرسانی همه اعضای گروه
|
|
||||||
groupMembers.forEach((member) => {
|
groupMembers.forEach((member) => {
|
||||||
updateObject(member.id, {
|
updateObject(member.id, {
|
||||||
x: (member.x || 0) + deltaX,
|
x: (member.x || 0) + deltaX,
|
||||||
y: (member.y || 0) + deltaY,
|
y: (member.y || 0) + deltaY,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if (obj.groupId && (updates.x !== undefined || updates.y !== undefined)) {
|
|
||||||
// اگر object در یک گروه است و position تغییر کرد، فقط object را بهروزرسانی کن
|
|
||||||
// bounds group فقط موقع transform بهروزرسانی میشود
|
|
||||||
updateObject(id, updates);
|
|
||||||
} else {
|
} else {
|
||||||
updateObject(id, updates);
|
updateObject(id, updates);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleGroupWithMask = (objectId: string, maskId: string) => {
|
const handleGroupWithMask = useCallback((objectId: string, maskId: string) => {
|
||||||
groupObjects([objectId, maskId]);
|
useEditorStore.getState().groupObjects([objectId, maskId]);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleUngroupFromMask = (groupId: string) => {
|
const handleUngroupFromMask = useCallback((groupId: string) => {
|
||||||
ungroupObjects(groupId);
|
useEditorStore.getState().ungroupObjects(groupId);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCellEditorSave = (text: string) => {
|
const handleCellEditorSave = useCallback((text: string) => {
|
||||||
|
const { editingCell, updateCellValue, setEditingCell } = useEditorStore.getState();
|
||||||
if (editingCell) {
|
if (editingCell) {
|
||||||
updateCellValue(editingCell.tableId, editingCell.cellId, text);
|
updateCellValue(editingCell.tableId, editingCell.cellId, text);
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCellEditorCancel = () => {
|
const handleCellEditorCancel = useCallback(() => {
|
||||||
setEditingCell(null);
|
useEditorStore.getState().setEditingCell(null);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleObjectUpdate,
|
handleObjectUpdate,
|
||||||
@@ -62,4 +59,3 @@ export const useObjectHandlers = () => {
|
|||||||
handleCellEditorCancel,
|
handleCellEditorCancel,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,284 @@
|
|||||||
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import Konva from "konva";
|
||||||
|
import { useEditorStore } from "@/pages/editor/store/editorStore";
|
||||||
|
import type { EditorObject } from "@/pages/editor/store/editorStore";
|
||||||
|
import { usePencilDefaultsStore } from "@/pages/editor/store/pencilDefaultsStore";
|
||||||
|
|
||||||
|
const MIN_POINT_DISTANCE = 1.5;
|
||||||
|
const MIN_POINTS = 2;
|
||||||
|
const MIN_DRAW_DISTANCE = 4;
|
||||||
|
const DEFAULT_TENSION = 0.5;
|
||||||
|
|
||||||
|
export type DrawPoint = { x: number; y: number };
|
||||||
|
|
||||||
|
export type PencilDrawingState = {
|
||||||
|
isDrawing: boolean;
|
||||||
|
points: DrawPoint[];
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INITIAL_STATE: PencilDrawingState = {
|
||||||
|
isDrawing: false,
|
||||||
|
points: [],
|
||||||
|
stroke: "#000000",
|
||||||
|
strokeWidth: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCanvasPos = (stage: Konva.Stage): DrawPoint | null => {
|
||||||
|
const pos = stage.getPointerPosition();
|
||||||
|
if (!pos) return null;
|
||||||
|
return {
|
||||||
|
x: pos.x / stage.scaleX(),
|
||||||
|
y: pos.y / stage.scaleY(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStagePosFromClient = (
|
||||||
|
stage: Konva.Stage,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
): DrawPoint | null => {
|
||||||
|
const container = stage.container();
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const scaleX = stage.scaleX();
|
||||||
|
const scaleY = stage.scaleY();
|
||||||
|
return {
|
||||||
|
x: (clientX - rect.left) / scaleX,
|
||||||
|
y: (clientY - rect.top) / scaleY,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFarEnough = (a: DrawPoint, b: DrawPoint, minDistance: number): boolean => {
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = a.y - b.y;
|
||||||
|
return dx * dx + dy * dy >= minDistance * minDistance;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPathLength = (points: DrawPoint[]): number => {
|
||||||
|
let length = 0;
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
length += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
|
||||||
|
}
|
||||||
|
return length;
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const minY = Math.min(...ys);
|
||||||
|
const maxX = Math.max(...xs);
|
||||||
|
const maxY = Math.max(...ys);
|
||||||
|
|
||||||
|
const normalizedPoints = points.flatMap((p) => [p.x - minX, p.y - minY]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `pencil-${Date.now()}`,
|
||||||
|
type: "pencil",
|
||||||
|
x: minX,
|
||||||
|
y: minY,
|
||||||
|
width: Math.max(1, maxX - minX),
|
||||||
|
height: Math.max(1, maxY - minY),
|
||||||
|
points: normalizedPoints,
|
||||||
|
closed: false,
|
||||||
|
stroke,
|
||||||
|
strokeWidth,
|
||||||
|
tension: DEFAULT_TENSION,
|
||||||
|
opacity,
|
||||||
|
rotation: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePencilDrawing = (stageRef: React.RefObject<Konva.Stage | null>) => {
|
||||||
|
const pointsRef = useRef<DrawPoint[]>([]);
|
||||||
|
const isDrawingRef = useRef(false);
|
||||||
|
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);
|
||||||
|
|
||||||
|
const { tool, addObject } = useEditorStore();
|
||||||
|
const { defaults } = usePencilDefaultsStore();
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
pointsRef.current = [];
|
||||||
|
isDrawingRef.current = false;
|
||||||
|
pointerIdRef.current = null;
|
||||||
|
setDrawingState(INITIAL_STATE);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool !== "pencil") {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [tool, reset]);
|
||||||
|
|
||||||
|
const releasePointerCapture = useCallback(() => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
const pointerId = pointerIdRef.current;
|
||||||
|
if (!stage || pointerId === null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stage.container().releasePointerCapture(pointerId);
|
||||||
|
} catch {
|
||||||
|
// pointer may already be released
|
||||||
|
}
|
||||||
|
pointerIdRef.current = null;
|
||||||
|
}, [stageRef]);
|
||||||
|
|
||||||
|
const commitStroke = useCallback(() => {
|
||||||
|
const points = pointsRef.current;
|
||||||
|
releasePointerCapture();
|
||||||
|
|
||||||
|
if (points.length < MIN_POINTS || getPathLength(points) < MIN_DRAW_DISTANCE) {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = buildPencilObject(
|
||||||
|
points,
|
||||||
|
strokeRef.current,
|
||||||
|
strokeWidthRef.current,
|
||||||
|
opacityRef.current,
|
||||||
|
);
|
||||||
|
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
||||||
|
addObject(obj);
|
||||||
|
reset();
|
||||||
|
}, [addObject, releasePointerCapture, reset]);
|
||||||
|
|
||||||
|
const appendPoint = useCallback((pos: DrawPoint) => {
|
||||||
|
const points = pointsRef.current;
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
if (last && !isFarEnough(last, pos, MIN_POINT_DISTANCE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pointsRef.current = [...points, pos];
|
||||||
|
setDrawingState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
points: pointsRef.current,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startDrawing = useCallback(
|
||||||
|
(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];
|
||||||
|
|
||||||
|
setDrawingState({
|
||||||
|
isDrawing: true,
|
||||||
|
points: [pos],
|
||||||
|
stroke: defaults.stroke,
|
||||||
|
strokeWidth: defaults.strokeWidth,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[defaults.stroke, defaults.strokeWidth],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStagePointerDown = useCallback(
|
||||||
|
(e: Konva.KonvaEventObject<PointerEvent>) => {
|
||||||
|
if (tool !== "pencil") return;
|
||||||
|
if (e.evt.button !== 0) return;
|
||||||
|
|
||||||
|
e.evt.preventDefault();
|
||||||
|
e.cancelBubble = true;
|
||||||
|
|
||||||
|
const stage = stageRef.current ?? e.target.getStage();
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const pos = getCanvasPos(stage);
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stage.container().setPointerCapture(e.evt.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore if capture is unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
startDrawing(pos, e.evt.pointerId);
|
||||||
|
},
|
||||||
|
[tool, stageRef, startDrawing],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStagePointerMove = useCallback(
|
||||||
|
(e: Konva.KonvaEventObject<PointerEvent>) => {
|
||||||
|
if (tool !== "pencil" || !isDrawingRef.current) return;
|
||||||
|
if (pointerIdRef.current !== null && e.evt.pointerId !== pointerIdRef.current) return;
|
||||||
|
|
||||||
|
const stage = stageRef.current ?? e.target.getStage();
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const pos = getCanvasPos(stage);
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
appendPoint(pos);
|
||||||
|
},
|
||||||
|
[tool, appendPoint, stageRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStagePointerUp = useCallback(
|
||||||
|
(e: Konva.KonvaEventObject<PointerEvent>) => {
|
||||||
|
if (tool !== "pencil" || !isDrawingRef.current) return;
|
||||||
|
if (pointerIdRef.current !== null && e.evt.pointerId !== pointerIdRef.current) return;
|
||||||
|
|
||||||
|
e.evt.preventDefault();
|
||||||
|
e.cancelBubble = true;
|
||||||
|
commitStroke();
|
||||||
|
},
|
||||||
|
[tool, commitStroke],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep drawing smooth when pointer leaves the canvas while still pressed
|
||||||
|
useEffect(() => {
|
||||||
|
if (tool !== "pencil") return;
|
||||||
|
|
||||||
|
const handleWindowPointerMove = (e: PointerEvent) => {
|
||||||
|
if (!isDrawingRef.current) return;
|
||||||
|
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
|
||||||
|
|
||||||
|
const stage = stageRef.current;
|
||||||
|
if (!stage) return;
|
||||||
|
|
||||||
|
const pos = getStagePosFromClient(stage, e.clientX, e.clientY);
|
||||||
|
if (!pos) return;
|
||||||
|
|
||||||
|
appendPoint(pos);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowPointerUp = (e: PointerEvent) => {
|
||||||
|
if (!isDrawingRef.current) return;
|
||||||
|
if (pointerIdRef.current !== null && e.pointerId !== pointerIdRef.current) return;
|
||||||
|
|
||||||
|
commitStroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handleWindowPointerMove);
|
||||||
|
window.addEventListener("pointerup", handleWindowPointerUp);
|
||||||
|
window.addEventListener("pointercancel", handleWindowPointerUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||||
|
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||||
|
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
||||||
|
};
|
||||||
|
}, [tool, appendPoint, commitStroke, stageRef]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawingState,
|
||||||
|
handleStagePointerDown,
|
||||||
|
handleStagePointerMove,
|
||||||
|
handleStagePointerUp,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import { useEditorStore } from "../../store/editorStore";
|
import { useEditorStore } from "../../store/editorStore";
|
||||||
|
|
||||||
export const useSelectionHandlers = () => {
|
export const useSelectionHandlers = () => {
|
||||||
const {
|
// Read live state via getState() so callbacks never go stale and can have
|
||||||
objects,
|
// empty deps — this prevents ObjectsLayer from re-rendering every time
|
||||||
selectedObjectIds,
|
// unrelated React state (e.g. active guide IDs) changes in EditorCanvas.
|
||||||
setSelectedObjectId,
|
|
||||||
addToSelection,
|
const handleSelect = useCallback((id: string, _node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
removeFromSelection,
|
const {
|
||||||
setSelectedTableId,
|
objects,
|
||||||
setSelectedCellId,
|
selectedObjectIds,
|
||||||
tool,
|
tool,
|
||||||
setTool,
|
setTool,
|
||||||
} = useEditorStore();
|
setSelectedObjectId,
|
||||||
|
addToSelection,
|
||||||
|
removeFromSelection,
|
||||||
|
setSelectedTableId,
|
||||||
|
setSelectedCellId,
|
||||||
|
} = useEditorStore.getState();
|
||||||
|
|
||||||
|
if (tool === "pencil" || tool === "custom-shape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelect = (id: string, _node: Konva.Node, e?: Konva.KonvaEventObject<MouseEvent>) => {
|
|
||||||
if (tool !== "select") {
|
if (tool !== "select") {
|
||||||
setTool("select");
|
setTool("select");
|
||||||
}
|
}
|
||||||
@@ -22,7 +31,6 @@ export const useSelectionHandlers = () => {
|
|||||||
const obj = objects.find((o) => o.id === id);
|
const obj = objects.find((o) => o.id === id);
|
||||||
const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey;
|
const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey;
|
||||||
|
|
||||||
// اگر object در یک group است، group را انتخاب کن نه object
|
|
||||||
if (obj?.groupId && obj.type !== "group") {
|
if (obj?.groupId && obj.type !== "group") {
|
||||||
const groupObject = objects.find((o) => o.id === obj.groupId);
|
const groupObject = objects.find((o) => o.id === obj.groupId);
|
||||||
if (groupObject) {
|
if (groupObject) {
|
||||||
@@ -40,16 +48,12 @@ export const useSelectionHandlers = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isMultiSelect) {
|
if (isMultiSelect) {
|
||||||
// انتخاب چندتایی
|
|
||||||
if (selectedObjectIds.includes(id)) {
|
if (selectedObjectIds.includes(id)) {
|
||||||
// اگر قبلاً انتخاب شده بود، از انتخاب خارج کن
|
|
||||||
removeFromSelection(id);
|
removeFromSelection(id);
|
||||||
} else {
|
} else {
|
||||||
// اگر انتخاب نشده بود، به انتخاب اضافه کن
|
|
||||||
addToSelection(id);
|
addToSelection(id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// انتخاب تکتایی
|
|
||||||
setSelectedObjectId(id);
|
setSelectedObjectId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +66,21 @@ export const useSelectionHandlers = () => {
|
|||||||
if (!isMultiSelect) {
|
if (!isMultiSelect) {
|
||||||
setSelectedCellId(null);
|
setSelectedCellId(null);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const handleCellClick = useCallback((tableId: string, cellId: string, e?: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
|
const {
|
||||||
|
selectedObjectIds,
|
||||||
|
addToSelection,
|
||||||
|
removeFromSelection,
|
||||||
|
setSelectedTableId,
|
||||||
|
setSelectedObjectId,
|
||||||
|
setSelectedCellId,
|
||||||
|
} = useEditorStore.getState();
|
||||||
|
|
||||||
const handleCellClick = (tableId: string, cellId: string, e?: Konva.KonvaEventObject<MouseEvent>) => {
|
|
||||||
const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey;
|
const isMultiSelect = e?.evt?.metaKey || e?.evt?.ctrlKey || e?.evt?.shiftKey;
|
||||||
|
|
||||||
if (isMultiSelect) {
|
if (isMultiSelect) {
|
||||||
// انتخاب چندتایی
|
|
||||||
if (selectedObjectIds.includes(tableId)) {
|
if (selectedObjectIds.includes(tableId)) {
|
||||||
removeFromSelection(tableId);
|
removeFromSelection(tableId);
|
||||||
} else {
|
} else {
|
||||||
@@ -80,20 +92,19 @@ export const useSelectionHandlers = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSelectedCellId(cellId);
|
setSelectedCellId(cellId);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleCellDblClick = (
|
const handleCellDblClick = useCallback((
|
||||||
tableId: string,
|
tableId: string,
|
||||||
cellId: string,
|
cellId: string,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
text: string
|
text: string,
|
||||||
) => {
|
) => {
|
||||||
const { setEditingCell } = useEditorStore.getState();
|
useEditorStore.getState().setEditingCell({ tableId, cellId, x, y, width, height, value: text });
|
||||||
setEditingCell({ tableId, cellId, x, y, width, height, value: text });
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleSelect,
|
handleSelect,
|
||||||
@@ -101,4 +112,3 @@ export const useSelectionHandlers = () => {
|
|||||||
handleCellDblClick,
|
handleCellDblClick,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -495,7 +495,7 @@ export const useTransformHandlers = (
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (selectedObject.type === "custom-shape") {
|
} else if (selectedObject.type === "custom-shape" || selectedObject.type === "pencil") {
|
||||||
// Scale each vertex in-place; no width/height anchor needed
|
// Scale each vertex in-place; no width/height anchor needed
|
||||||
const oldPoints = selectedObject.points ?? [];
|
const oldPoints = selectedObject.points ?? [];
|
||||||
const newPoints = oldPoints.map((val, i) =>
|
const newPoints = oldPoints.map((val, i) =>
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const ObjectSettings = ({ selectedObject, pageWidth, pageHeight, onUpdate, onDel
|
|||||||
|
|
||||||
{roundedSelectedObject.type === "rectangle" && <ShapeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
{roundedSelectedObject.type === "rectangle" && <ShapeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
||||||
|
|
||||||
{(roundedSelectedObject.type === "line" || roundedSelectedObject.type === "arrow") && <LineSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
{(roundedSelectedObject.type === "line" || roundedSelectedObject.type === "arrow" || roundedSelectedObject.type === "pencil") && <LineSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
||||||
|
|
||||||
{roundedSelectedObject.type === "image" && <SizeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
{roundedSelectedObject.type === "image" && <SizeSettings selectedObject={roundedSelectedObject} onUpdate={handleRoundedUpdate} />}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const ToolInstructions = ({ tool }: ToolInstructionsProps) => {
|
|||||||
rectangle: <RectangleInstruction />,
|
rectangle: <RectangleInstruction />,
|
||||||
line: <RectangleInstruction />,
|
line: <RectangleInstruction />,
|
||||||
arrow: <RectangleInstruction />,
|
arrow: <RectangleInstruction />,
|
||||||
|
pencil: <RectangleInstruction />,
|
||||||
"custom-shape": <RectangleInstruction />,
|
"custom-shape": <RectangleInstruction />,
|
||||||
text: <TextInstruction />,
|
text: <TextInstruction />,
|
||||||
sticker: <StickerInstruction />,
|
sticker: <StickerInstruction />,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const ToolsBar = ({ tool, onToolClick }: ToolsBarProps) => {
|
|||||||
{tools.map((item, index) => {
|
{tools.map((item, index) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
item.tool === "rectangle"
|
item.tool === "rectangle"
|
||||||
? tool === "rectangle" || tool === "line" || tool === "arrow" || tool === "custom-shape"
|
? tool === "rectangle" || tool === "line" || tool === "arrow" || tool === "custom-shape" || tool === "pencil"
|
||||||
: tool === item.tool;
|
: tool === item.tool;
|
||||||
const iconColor = "black";
|
const iconColor = "black";
|
||||||
const iconVariant: "Bold" | "Outline" | "Broken" | "Bulk" | "Linear" | "TwoTone" | undefined = isActive ? "Bold" : "Outline";
|
const iconVariant: "Bold" | "Outline" | "Broken" | "Bulk" | "Linear" | "TwoTone" | undefined = isActive ? "Bold" : "Outline";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const AudioInput: FC = () => {
|
|||||||
try {
|
try {
|
||||||
for (const file of acceptedFiles) {
|
for (const file of acceptedFiles) {
|
||||||
try {
|
try {
|
||||||
const result = await uploadFile(file);
|
const result = await uploadFile({ file });
|
||||||
const audioUrl = result?.data?.url;
|
const audioUrl = result?.data?.url;
|
||||||
if (!audioUrl) continue;
|
if (!audioUrl) continue;
|
||||||
const audioId = `audio-${Date.now()}-${Math.random()}`;
|
const audioId = `audio-${Date.now()}-${Math.random()}`;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
const CustomShapeInstruction = () => (
|
const CustomShapeInstruction = () => (
|
||||||
<div className="space-y-2 text-sm text-gray-600">
|
<div className="space-y-2 text-sm text-gray-600">
|
||||||
<p className="font-medium text-gray-700">رسم شکل سفارشی</p>
|
<p className="font-medium text-gray-700">رسم نقطهبهنقطه (Pen)</p>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
<li>• روی کانوس کلیک کنید تا نقاط اضافه شود</li>
|
<li>• روی کانوس کلیک کنید تا نقاط اضافه شود</li>
|
||||||
<li>• روی نقطه اول کلیک کنید تا شکل بسته شود</li>
|
<li>• روی نقطه اول کلیک کنید تا شکل بسته شود</li>
|
||||||
<li>• دوبار کلیک کنید تا شکل تکمیل شود</li>
|
<li>• دوبار کلیک کنید تا شکل تکمیل شود</li>
|
||||||
<li>• Escape را بزنید تا لغو شود</li>
|
<li>• Escape را بزنید تا لغو شود</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<p className="text-xs text-gray-500 pt-1">
|
||||||
|
برای نقاشی آزاد (کلیک و بکش) از ابزار «قلم» استفاده کنید.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,23 @@ const ImageGallery: FC = () => {
|
|||||||
const { gallery, addGalleryItem, removeGalleryItem } = useEditorStore();
|
const { gallery, addGalleryItem, removeGalleryItem } = useEditorStore();
|
||||||
const { mutateAsync: uploadFile } = useSingleUpload();
|
const { mutateAsync: uploadFile } = useSingleUpload();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
||||||
const handleFileChange = async (files: File[]) => {
|
const handleFileChange = async (files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
|
setUploadProgress(0);
|
||||||
try {
|
try {
|
||||||
for (const file of files) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const result = await uploadFile(file);
|
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;
|
const imageUrl = result?.data?.url;
|
||||||
if (!imageUrl) continue;
|
if (!imageUrl) continue;
|
||||||
|
|
||||||
@@ -33,6 +42,7 @@ const ImageGallery: FC = () => {
|
|||||||
// TODO: show error toast
|
// TODO: show error toast
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
|
setUploadProgress(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +64,7 @@ const ImageGallery: FC = () => {
|
|||||||
isMultiple={true}
|
isMultiple={true}
|
||||||
hidePreview
|
hidePreview
|
||||||
isLoading={isUploading}
|
isLoading={isUploading}
|
||||||
|
uploadProgress={isUploading ? uploadProgress : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{gallery.length === 0 && !isUploading && (
|
{gallery.length === 0 && !isUploading && (
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import ColorPicker from "@/components/ColorPicker";
|
||||||
|
import Input from "@/components/Input";
|
||||||
|
import { usePencilDefaultsStore } from "@/pages/editor/store/pencilDefaultsStore";
|
||||||
|
import { Brush } from "iconsax-react";
|
||||||
|
import { type FC } from "react";
|
||||||
|
|
||||||
|
const PencilInstruction: FC = () => {
|
||||||
|
const { defaults, updateDefaults } = usePencilDefaultsStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-9 space-y-4">
|
||||||
|
<div className="flex items-start gap-2 text-sm text-gray-600">
|
||||||
|
<Brush size={18} color="#666" variant="Outline" className="mt-0.5 shrink-0" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>کلیک کرده و بکشید تا آزاد نقاشی کنید (مانند Pencil در Figma).</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
برای رسم نقطهبهنقطه از ابزار «سفارشی» استفاده کنید.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ColorPicker
|
||||||
|
label="رنگ قلم"
|
||||||
|
value={defaults.stroke}
|
||||||
|
opacity={defaults.opacity}
|
||||||
|
onOpacityChange={(value) => updateDefaults({ opacity: value })}
|
||||||
|
onChange={(value) => updateDefaults({ stroke: value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="ضخامت قلم"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={defaults.strokeWidth}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateDefaults({
|
||||||
|
strokeWidth: Math.max(1, parseInt(e.target.value) || 1),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PencilInstruction;
|
||||||
@@ -6,9 +6,10 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { clx } from "@/helpers/utils";
|
import { clx } from "@/helpers/utils";
|
||||||
import { useEditorStore } from "@/pages/editor/store/editorStore";
|
import { useEditorStore } from "@/pages/editor/store/editorStore";
|
||||||
import { useShapeStore, type ShapeType } from "@/pages/editor/store/shapeStore";
|
import { useShapeStore, type ShapeType } from "@/pages/editor/store/shapeStore";
|
||||||
import { ArrowLeft, Minus, PenTool } from "iconsax-react";
|
import { ArrowLeft, Brush, Minus, PenTool } from "iconsax-react";
|
||||||
import { type FC } from "react";
|
import { type FC } from "react";
|
||||||
import CustomShapeInstruction from "./CustomShapeInstruction";
|
import CustomShapeInstruction from "./CustomShapeInstruction";
|
||||||
|
import PencilInstruction from "./PencilInstruction";
|
||||||
|
|
||||||
const shapeOptions: Array<{ id: ShapeType; label: string; icon: string; alt: string }> = [
|
const shapeOptions: Array<{ id: ShapeType; label: string; icon: string; alt: string }> = [
|
||||||
{ id: "square", label: "مربع", icon: SquareIcon, alt: "square" },
|
{ id: "square", label: "مربع", icon: SquareIcon, alt: "square" },
|
||||||
@@ -18,6 +19,7 @@ const shapeOptions: Array<{ id: ShapeType; label: string; icon: string; alt: str
|
|||||||
];
|
];
|
||||||
|
|
||||||
const drawOptions = [
|
const drawOptions = [
|
||||||
|
{ tool: "pencil" as const, label: "قلم", Icon: Brush },
|
||||||
{ tool: "line" as const, label: "خط", Icon: Minus },
|
{ tool: "line" as const, label: "خط", Icon: Minus },
|
||||||
{ tool: "arrow" as const, label: "پیکان", Icon: ArrowLeft },
|
{ tool: "arrow" as const, label: "پیکان", Icon: ArrowLeft },
|
||||||
{ tool: "custom-shape" as const, label: "سفارشی", Icon: PenTool },
|
{ tool: "custom-shape" as const, label: "سفارشی", Icon: PenTool },
|
||||||
@@ -81,6 +83,8 @@ const RectangleInstruction: FC = () => {
|
|||||||
<p className="mt-9 text-sm text-gray-600">{tool === "line" ? "برای رسم خط، روی کانوس کلیک کرده و بکشید" : "برای رسم پیکان، روی کانوس کلیک کرده و بکشید"}</p>
|
<p className="mt-9 text-sm text-gray-600">{tool === "line" ? "برای رسم خط، روی کانوس کلیک کرده و بکشید" : "برای رسم پیکان، روی کانوس کلیک کرده و بکشید"}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{tool === "pencil" && <PencilInstruction />}
|
||||||
|
|
||||||
{tool === "custom-shape" && (
|
{tool === "custom-shape" && (
|
||||||
<div className="mt-9">
|
<div className="mt-9">
|
||||||
<CustomShapeInstruction />
|
<CustomShapeInstruction />
|
||||||
|
|||||||
@@ -106,13 +106,6 @@ const TextInstruction: FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// State محلی برای نمایش شفافیت
|
|
||||||
const [opacityDisplay, setOpacityDisplay] = useState<string>(`${formState.opacity}`);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOpacityDisplay(`${formState.opacity}`);
|
|
||||||
}, [formState.opacity]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -164,41 +157,14 @@ const TextInstruction: FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-col gap-2">
|
<div className="mt-4">
|
||||||
<div className="flex gap-3">
|
<ColorPicker
|
||||||
<ColorPicker
|
label="رنگ"
|
||||||
label="رنگ"
|
value={formState.fill}
|
||||||
className="flex-1"
|
opacity={formState.opacity}
|
||||||
value={formState.fill}
|
onOpacityChange={(value) => handleChange("opacity", value)}
|
||||||
onChange={(value) => handleChange("fill", value)}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* <div className="mt-4 flex gap-2">
|
{/* <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 { useEditorStore, type ToolType } from "@/pages/editor/store/editorStore";
|
||||||
import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData";
|
||||||
import { CloseCircle } from "iconsax-react";
|
import { CloseCircle } from "iconsax-react";
|
||||||
@@ -9,30 +9,50 @@ const VideoInput: FC = () => {
|
|||||||
const { objects, addObject, setSelectedObjectId, setTool, deleteObject } =
|
const { objects, addObject, setSelectedObjectId, setTool, deleteObject } =
|
||||||
useEditorStore();
|
useEditorStore();
|
||||||
const { mutateAsync: uploadFile } = useSingleUpload();
|
const { mutateAsync: uploadFile } = useSingleUpload();
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
async (acceptedFiles: File[]) => {
|
async (acceptedFiles: File[]) => {
|
||||||
for (const file of acceptedFiles) {
|
if (acceptedFiles.length === 0) return;
|
||||||
try {
|
|
||||||
const result = await uploadFile(file);
|
setIsUploading(true);
|
||||||
const videoUrl = result?.data?.url;
|
setUploadProgress(0);
|
||||||
if (!videoUrl) continue;
|
try {
|
||||||
const videoId = `video-${Date.now()}-${Math.random()}`;
|
for (let i = 0; i < acceptedFiles.length; i++) {
|
||||||
const newVideo = {
|
const file = acceptedFiles[i];
|
||||||
id: videoId,
|
try {
|
||||||
type: "video" as ToolType,
|
const result = await uploadFile({
|
||||||
x: 100,
|
file,
|
||||||
y: 100,
|
onProgress: (fileProgress) => {
|
||||||
width: 400,
|
const overall = Math.round(
|
||||||
height: 300,
|
((i + fileProgress / 100) / acceptedFiles.length) * 100
|
||||||
videoUrl,
|
);
|
||||||
};
|
setUploadProgress(overall);
|
||||||
addObject(newVideo);
|
},
|
||||||
setSelectedObjectId(newVideo.id);
|
});
|
||||||
setTool("select");
|
const videoUrl = result?.data?.url;
|
||||||
} catch {
|
if (!videoUrl) continue;
|
||||||
// TODO: show error toast
|
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]
|
[addObject, setSelectedObjectId, setTool, uploadFile]
|
||||||
@@ -43,7 +63,8 @@ const VideoInput: FC = () => {
|
|||||||
accept: {
|
accept: {
|
||||||
'video/*': ['.mp4', '.webm', '.ogg', '.mov', '.avi']
|
'video/*': ['.mp4', '.webm', '.ogg', '.mov', '.avi']
|
||||||
},
|
},
|
||||||
multiple: true
|
multiple: true,
|
||||||
|
disabled: isUploading,
|
||||||
});
|
});
|
||||||
|
|
||||||
const videoObjects = useMemo(() => {
|
const videoObjects = useMemo(() => {
|
||||||
@@ -61,6 +82,8 @@ const VideoInput: FC = () => {
|
|||||||
<VideoUploadZone
|
<VideoUploadZone
|
||||||
getRootProps={getRootProps}
|
getRootProps={getRootProps}
|
||||||
getInputProps={getInputProps}
|
getInputProps={getInputProps}
|
||||||
|
isLoading={isUploading}
|
||||||
|
uploadProgress={isUploading ? uploadProgress : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{videoObjects.length > 0 && (
|
{videoObjects.length > 0 && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
AlignBottom,
|
AlignBottom,
|
||||||
AlignHorizontally,
|
AlignHorizontally,
|
||||||
@@ -23,20 +23,40 @@ type AlignmentSettingsProps = {
|
|||||||
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AlignButtonGroupProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AlignButtonProps = {
|
||||||
|
title: string;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
const alignIconProps = { size: 24, color: "currentColor" as const, variant: "Linear" as const };
|
const AlignButtonGroup = ({ children }: AlignButtonGroupProps) => (
|
||||||
|
<div className="flex flex-1 overflow-hidden rounded-lg border border-[#E5E5E5] bg-[#F2F2F2] divide-x divide-[#E5E5E5]">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AlignButton = ({ title, onClick, children }: AlignButtonProps) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex flex-1 items-center justify-center py-2.5 text-[#333333] transition-colors hover:bg-black/4 active:bg-black/8"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const alignIconProps = { size: 20, color: "currentColor" as const, variant: "Linear" as const };
|
||||||
|
|
||||||
const AlignmentSettings = ({
|
const AlignmentSettings = ({
|
||||||
pageWidth,
|
pageWidth,
|
||||||
pageHeight,
|
pageHeight,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: AlignmentSettingsProps) => {
|
}: AlignmentSettingsProps) => {
|
||||||
// const selectedObjectIds = useEditorStore((s) => s.selectedObjectIds);
|
|
||||||
|
|
||||||
/** دقیقاً دو شیٔ مجزا (هنوز یک گروه نشدهاند) → مرز همان دو شی؛ یک شی یا گروه یا بیش از دو تا → صفحه */
|
|
||||||
// const alignToSelection = selectedObjectIds.length === 2;
|
|
||||||
|
|
||||||
const applyAlign = (kind: AlignKind) => {
|
const applyAlign = (kind: AlignKind) => {
|
||||||
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
useEditorStore.getState().commitObjectHistoryBeforeChange();
|
||||||
const { objects: objs, selectedObjectIds: ids, layerRef } = useEditorStore.getState();
|
const { objects: objs, selectedObjectIds: ids, layerRef } = useEditorStore.getState();
|
||||||
@@ -79,35 +99,34 @@ const AlignmentSettings = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// const hint = alignToSelection ? "نسبت به دو شی انتخابشده" : "نسبت به صفحه";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<h4 className="text-[13px] text-[#7A7A7A]">ترازبندی</h4>
|
||||||
<h4 className="text-sm font-bold text-foreground">ابزار جابهجایی</h4>
|
|
||||||
{/* <p className="mt-1 text-xs text-muted-foreground">{hint}</p> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="flex gap-2">
|
||||||
<p className="text-xs font-medium text-muted-foreground">افقی</p>
|
<AlignButtonGroup>
|
||||||
<div className="flex gap-12 items-center mt-4">
|
<AlignButton title="تراز راست" onClick={() => applyAlign("right")}>
|
||||||
<AlignRight onClick={() => applyAlign("right")} {...alignIconProps} />
|
<AlignRight {...alignIconProps} />
|
||||||
<AlignHorizontally onClick={() => applyAlign("centerH")} {...alignIconProps} />
|
</AlignButton>
|
||||||
<AlignLeft onClick={() => applyAlign("left")} {...alignIconProps} />
|
<AlignButton title="تراز افقی وسط" onClick={() => applyAlign("centerH")}>
|
||||||
</div>
|
<AlignHorizontally {...alignIconProps} />
|
||||||
</div>
|
</AlignButton>
|
||||||
|
<AlignButton title="تراز چپ" onClick={() => applyAlign("left")}>
|
||||||
|
<AlignLeft {...alignIconProps} />
|
||||||
|
</AlignButton>
|
||||||
|
</AlignButtonGroup>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<AlignButtonGroup>
|
||||||
<p className="text-xs font-medium text-muted-foreground mt-4">عمودی</p>
|
<AlignButton title="تراز بالا" onClick={() => applyAlign("top")}>
|
||||||
<div className="flex gap-12 items-center mt-4">
|
<AlignTop {...alignIconProps} />
|
||||||
|
</AlignButton>
|
||||||
<AlignTop onClick={() => applyAlign("top")} {...alignIconProps} />
|
<AlignButton title="تراز عمودی وسط" onClick={() => applyAlign("centerV")}>
|
||||||
|
<AlignVertically {...alignIconProps} />
|
||||||
<AlignVertically onClick={() => applyAlign("centerV")} {...alignIconProps} />
|
</AlignButton>
|
||||||
|
<AlignButton title="تراز پایین" onClick={() => applyAlign("bottom")}>
|
||||||
<AlignBottom onClick={() => applyAlign("bottom")} {...alignIconProps} />
|
<AlignBottom {...alignIconProps} />
|
||||||
|
</AlignButton>
|
||||||
</div>
|
</AlignButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsPr
|
|||||||
};
|
};
|
||||||
const stroke = selectedObject.stroke ?? "#1e40af";
|
const stroke = selectedObject.stroke ?? "#1e40af";
|
||||||
const strokeWidth = selectedObject.strokeWidth ?? 2;
|
const strokeWidth = selectedObject.strokeWidth ?? 2;
|
||||||
|
const opacity = selectedObject.opacity ?? 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -29,6 +30,8 @@ const CustomShapeSettings = ({ selectedObject, onUpdate }: CustomShapeSettingsPr
|
|||||||
label="رنگ پر"
|
label="رنگ پر"
|
||||||
value={fill}
|
value={fill}
|
||||||
readOnly={fillType === "gradient"}
|
readOnly={fillType === "gradient"}
|
||||||
|
opacity={opacity}
|
||||||
|
onOpacityChange={(value) => onUpdate(selectedObject.id, { opacity: value })}
|
||||||
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
|
|||||||
removeRow,
|
removeRow,
|
||||||
removeColumn,
|
removeColumn,
|
||||||
changeCellBackground,
|
changeCellBackground,
|
||||||
|
changeCellBackgroundOpacity,
|
||||||
selectedCellId,
|
selectedCellId,
|
||||||
} = useEditorStore();
|
} = 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;
|
const selectedCell = selectedCellId && tableDataObj ? tableDataObj.cells[selectedCellId] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,6 +118,8 @@ const GridSettings = ({ selectedObject }: GridSettingsProps) => {
|
|||||||
<ColorPicker
|
<ColorPicker
|
||||||
label="رنگ پسزمینه سلول"
|
label="رنگ پسزمینه سلول"
|
||||||
value={selectedCell.background}
|
value={selectedCell.background}
|
||||||
|
opacity={selectedCell.backgroundOpacity ?? 100}
|
||||||
|
onOpacityChange={handleCellBackgroundOpacityChange}
|
||||||
onChange={handleCellBackgroundChange}
|
onChange={handleCellBackgroundChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const LineSettings = ({ selectedObject, onUpdate }: LineSettingsProps) => {
|
|||||||
<ColorPicker
|
<ColorPicker
|
||||||
label="رنگ خط"
|
label="رنگ خط"
|
||||||
value={selectedObject.stroke || "#000000"}
|
value={selectedObject.stroke || "#000000"}
|
||||||
|
opacity={selectedObject.opacity ?? 100}
|
||||||
|
onOpacityChange={(value) =>
|
||||||
|
onUpdate(selectedObject.id, { opacity: value })
|
||||||
|
}
|
||||||
onChange={(value) => onUpdate(selectedObject.id, { stroke: value })}
|
onChange={(value) => onUpdate(selectedObject.id, { stroke: value })}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const LinkSettings = ({ selectedObject, onUpdate }: LinkSettingsProps) => {
|
|||||||
<ColorPicker
|
<ColorPicker
|
||||||
label="رنگ متن"
|
label="رنگ متن"
|
||||||
value={selectedObject.fill || "#0000ff"}
|
value={selectedObject.fill || "#0000ff"}
|
||||||
|
opacity={selectedObject.opacity ?? 100}
|
||||||
|
onOpacityChange={(value) =>
|
||||||
|
onUpdate(selectedObject.id, { opacity: value })
|
||||||
|
}
|
||||||
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -132,7 +132,10 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => {
|
|||||||
{object.maskId && (
|
{object.maskId && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between mt-4 pt-3 border-t">
|
<div className="flex items-center justify-between mt-4 pt-3 border-t">
|
||||||
<span className="text-xs">نمایش فقط همپوشانی</span>
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs font-medium">معکوس کردن ماسک</span>
|
||||||
|
<span className="text-[10px] text-gray-400">نمایش خارج از محدوده ماسک</span>
|
||||||
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={object.maskInvert === false}
|
checked={object.maskInvert === false}
|
||||||
onCheckedChange={(checked) => handleToggleMaskInvert(!checked)}
|
onCheckedChange={(checked) => handleToggleMaskInvert(!checked)}
|
||||||
@@ -153,7 +156,7 @@ const MaskSettings = ({ objectId }: MaskSettingsProps) => {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
✓ ماسک اعمال شد. {object.maskInvert === false ? "فقط قسمتهای همپوشانی نمایش داده میشوند." : "قسمتهای همپوشانی مخفی میشوند."}
|
✓ ماسک اعمال شد. {object.maskInvert === false ? "قسمتهای خارج از ماسک نمایش داده میشوند." : "فقط داخل محدوده ماسک نمایش داده میشود."}
|
||||||
<br />
|
<br />
|
||||||
💡 برای گروهبندی با ماسک، object را انتخاب کنید و دکمه "گروهبندی با ماسک" را بزنید.
|
💡 برای گروهبندی با ماسک، object را انتخاب کنید و دکمه "گروهبندی با ماسک" را بزنید.
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type ShapeSettingsProps = {
|
|||||||
const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
||||||
const baseWidth = selectedObject.width ?? 100;
|
const baseWidth = selectedObject.width ?? 100;
|
||||||
const baseHeight = selectedObject.height ?? 100;
|
const baseHeight = selectedObject.height ?? 100;
|
||||||
|
const baseOpacity = selectedObject.opacity ?? 100;
|
||||||
const baseFill = selectedObject.fill ?? "#3b82f6";
|
const baseFill = selectedObject.fill ?? "#3b82f6";
|
||||||
const fillType = selectedObject.fillType ?? "solid";
|
const fillType = selectedObject.fillType ?? "solid";
|
||||||
const gradient = selectedObject.gradient ?? {
|
const gradient = selectedObject.gradient ?? {
|
||||||
@@ -20,6 +21,7 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
|||||||
const baseStroke = selectedObject.stroke ?? "#1e40af";
|
const baseStroke = selectedObject.stroke ?? "#1e40af";
|
||||||
const baseStrokeWidth = selectedObject.strokeWidth ?? 0;
|
const baseStrokeWidth = selectedObject.strokeWidth ?? 0;
|
||||||
const baseBorderRadius = selectedObject.borderRadius ?? 0;
|
const baseBorderRadius = selectedObject.borderRadius ?? 0;
|
||||||
|
const baseBlur = selectedObject.blur ?? 0;
|
||||||
const isSquareShape =
|
const isSquareShape =
|
||||||
selectedObject.shapeType === "square" || selectedObject.shapeType === undefined;
|
selectedObject.shapeType === "square" || selectedObject.shapeType === undefined;
|
||||||
|
|
||||||
@@ -49,6 +51,12 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
|||||||
label="رنگ پسزمینه"
|
label="رنگ پسزمینه"
|
||||||
value={baseFill}
|
value={baseFill}
|
||||||
readOnly={fillType === "gradient"}
|
readOnly={fillType === "gradient"}
|
||||||
|
opacity={baseOpacity}
|
||||||
|
onOpacityChange={(value) =>
|
||||||
|
onUpdate(selectedObject.id, {
|
||||||
|
opacity: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
onUpdate(selectedObject.id, {
|
onUpdate(selectedObject.id, {
|
||||||
fill: value,
|
fill: value,
|
||||||
@@ -179,6 +187,35 @@ const ShapeSettings = ({ selectedObject, onUpdate }: ShapeSettingsProps) => {
|
|||||||
min={0}
|
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>
|
</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 type { EditorObject } from "../../../store/editorStore";
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/Input";
|
||||||
import ColorPicker from "@/components/ColorPicker";
|
import ColorPicker from "@/components/ColorPicker";
|
||||||
|
import { DEFAULT_TEXT_MAX_WIDTH_PX } from "@/pages/editor/utils/textStyle";
|
||||||
|
|
||||||
type TextSettingsProps = {
|
type TextSettingsProps = {
|
||||||
selectedObject: EditorObject;
|
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">
|
<div className="space-y-2">
|
||||||
<p className="text-sm">چینش متن</p>
|
<p className="text-sm">چینش متن</p>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
@@ -89,6 +102,10 @@ const TextSettings = ({ selectedObject, onUpdate }: TextSettingsProps) => {
|
|||||||
<ColorPicker
|
<ColorPicker
|
||||||
label="رنگ متن"
|
label="رنگ متن"
|
||||||
value={selectedObject.fill || "#000000"}
|
value={selectedObject.fill || "#000000"}
|
||||||
|
opacity={selectedObject.opacity ?? 100}
|
||||||
|
onOpacityChange={(value) =>
|
||||||
|
onUpdate(selectedObject.id, { opacity: value })
|
||||||
|
}
|
||||||
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
onChange={(value) => onUpdate(selectedObject.id, { fill: value })}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,47 +1,48 @@
|
|||||||
import type { EditorObject } from "../../../store/editorStore";
|
|
||||||
import Input from "@/components/Input";
|
import Input from "@/components/Input";
|
||||||
|
import type { EditorObject } from "../../../store/editorStore";
|
||||||
|
|
||||||
type TransformSettingsProps = {
|
type TransformSettingsProps = {
|
||||||
selectedObject: EditorObject;
|
selectedObject: EditorObject;
|
||||||
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
onUpdate: (id: string, updates: Partial<EditorObject>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
|
const TransformSettings = ({ selectedObject, onUpdate }: TransformSettingsProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="pt-4 flex flex-col gap-4 border-t border-border">
|
<div className="pt-4 flex flex-col gap-4 border-t border-border">
|
||||||
<Input
|
<div className="flex gap-4">
|
||||||
label="موقعیت X"
|
<Input
|
||||||
type="number"
|
label="موقعیت X"
|
||||||
value={Math.round(selectedObject.x)}
|
type="number"
|
||||||
onChange={(e) =>
|
value={Math.round(selectedObject.x)}
|
||||||
onUpdate(selectedObject.id, {
|
onChange={(e) =>
|
||||||
x: parseInt(e.target.value) || 0,
|
onUpdate(selectedObject.id, {
|
||||||
})
|
x: parseInt(e.target.value) || 0,
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
<Input
|
/>
|
||||||
label="موقعیت Y"
|
<Input
|
||||||
type="number"
|
label="موقعیت Y"
|
||||||
value={Math.round(selectedObject.y)}
|
type="number"
|
||||||
onChange={(e) =>
|
value={Math.round(selectedObject.y)}
|
||||||
onUpdate(selectedObject.id, {
|
onChange={(e) =>
|
||||||
y: parseInt(e.target.value) || 0,
|
onUpdate(selectedObject.id, {
|
||||||
})
|
y: parseInt(e.target.value) || 0,
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
<Input
|
/>
|
||||||
label="چرخش (درجه)"
|
</div>
|
||||||
type="number"
|
<Input
|
||||||
value={selectedObject.rotation || 0}
|
label="چرخش (درجه)"
|
||||||
onChange={(e) =>
|
type="number"
|
||||||
onUpdate(selectedObject.id, {
|
value={selectedObject.rotation || 0}
|
||||||
rotation: parseInt(e.target.value) || 0,
|
onChange={(e) =>
|
||||||
})
|
onUpdate(selectedObject.id, {
|
||||||
}
|
rotation: parseInt(e.target.value) || 0,
|
||||||
/>
|
})
|
||||||
</div>
|
}
|
||||||
);
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TransformSettings;
|
export default TransformSettings;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Star } from "react-konva";
|
|||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
import { getKonvaGradientProps } from "../../utils/gradient";
|
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||||
|
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
||||||
|
import { useShapeBlurCache } from "../../utils/shapeBlur";
|
||||||
|
|
||||||
const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||||
const shapeRef = useRef<Konva.Star>(null);
|
const shapeRef = useRef<Konva.Star>(null);
|
||||||
@@ -18,6 +20,21 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
|||||||
|
|
||||||
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||||
const hasStroke = actualStrokeWidth > 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 را برای انتخاب نمایش بده
|
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
|
||||||
const displayStroke = isSelected
|
const displayStroke = isSelected
|
||||||
@@ -31,14 +48,14 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
|||||||
const displayStrokeWidth = isSelected
|
const displayStrokeWidth = isSelected
|
||||||
? (hasStroke ? actualStrokeWidth : 3)
|
? (hasStroke ? actualStrokeWidth : 3)
|
||||||
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
||||||
const gradientProps = isMask
|
const gradientProps = isMask || blurRadius > 0
|
||||||
? {}
|
? {}
|
||||||
: getKonvaGradientProps(
|
: getKonvaGradientProps(
|
||||||
obj.fillType,
|
obj.fillType,
|
||||||
obj.gradient,
|
obj.gradient,
|
||||||
obj.width || 100,
|
obj.width || 100,
|
||||||
obj.height || 100,
|
obj.height || 100,
|
||||||
"centered",
|
"centered",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,12 +68,15 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
|||||||
numPoints={5}
|
numPoints={5}
|
||||||
innerRadius={innerRadius}
|
innerRadius={innerRadius}
|
||||||
outerRadius={radius}
|
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}
|
{...gradientProps}
|
||||||
stroke={displayStroke}
|
stroke={displayStroke}
|
||||||
strokeWidth={displayStrokeWidth}
|
strokeWidth={displayStrokeWidth}
|
||||||
dash={isMask && showGuide ? [10, 5] : undefined}
|
dash={isMask && showGuide ? [10, 5] : undefined}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
|
opacity={(obj.opacity ?? 100) / 100}
|
||||||
|
{...blurProps}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (shapeRef.current) {
|
if (shapeRef.current) {
|
||||||
@@ -64,11 +84,7 @@ const AbstractShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragMove={(e) => {
|
onDragMove={(e) => {
|
||||||
const node = e.target;
|
e.target.getLayer()?.batchDraw();
|
||||||
onUpdate(obj.id, {
|
|
||||||
x: node.x() - (obj.width || 100) / 2,
|
|
||||||
y: node.y() - (obj.height || 100) / 2,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
const node = e.target;
|
const node = e.target;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ const ArrowShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePro
|
|||||||
hitStrokeWidth={20}
|
hitStrokeWidth={20}
|
||||||
perfectDrawEnabled={false}
|
perfectDrawEnabled={false}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
|
opacity={(obj.opacity ?? 100) / 100}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (shapeRef.current) {
|
if (shapeRef.current) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from "react";
|
|||||||
import { Rect, Group, Circle, Text as KonvaText } from "react-konva";
|
import { Rect, Group, Circle, Text as KonvaText } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
|
import { getObjectBorderRadius } from "../../utils/borderRadius";
|
||||||
|
|
||||||
const AudioShape = ({
|
const AudioShape = ({
|
||||||
obj,
|
obj,
|
||||||
@@ -15,6 +16,7 @@ const AudioShape = ({
|
|||||||
|
|
||||||
const containerWidth = obj.width || 320;
|
const containerWidth = obj.width || 320;
|
||||||
const containerHeight = obj.height || 56;
|
const containerHeight = obj.height || 56;
|
||||||
|
const cornerRadius = getObjectBorderRadius(obj.borderRadius ?? 8);
|
||||||
const barY = containerHeight * 0.55;
|
const barY = containerHeight * 0.55;
|
||||||
const barHeight = Math.max(4, containerHeight * 0.12);
|
const barHeight = Math.max(4, containerHeight * 0.12);
|
||||||
const playRadius = Math.min(18, containerHeight * 0.35);
|
const playRadius = Math.min(18, containerHeight * 0.35);
|
||||||
@@ -70,7 +72,7 @@ const AudioShape = ({
|
|||||||
width={containerWidth}
|
width={containerWidth}
|
||||||
height={containerHeight}
|
height={containerHeight}
|
||||||
fill="#f3f4f6"
|
fill="#f3f4f6"
|
||||||
cornerRadius={8}
|
cornerRadius={cornerRadius}
|
||||||
stroke={isSelected ? "#3b82f6" : "#d1d5db"}
|
stroke={isSelected ? "#3b82f6" : "#d1d5db"}
|
||||||
strokeWidth={isSelected ? 3 : 1}
|
strokeWidth={isSelected ? 3 : 1}
|
||||||
onClick={handleGroupClick}
|
onClick={handleGroupClick}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Circle } from "react-konva";
|
|||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
import { getKonvaGradientProps } from "../../utils/gradient";
|
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||||
|
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
||||||
|
import { useShapeBlurCache } from "../../utils/shapeBlur";
|
||||||
|
|
||||||
const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||||
const shapeRef = useRef<Konva.Circle>(null);
|
const shapeRef = useRef<Konva.Circle>(null);
|
||||||
@@ -14,6 +16,20 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
|
|||||||
|
|
||||||
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||||
const hasStroke = actualStrokeWidth > 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 را برای انتخاب نمایش بده
|
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
|
||||||
const displayStroke = isSelected
|
const displayStroke = isSelected
|
||||||
@@ -28,9 +44,9 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
|
|||||||
? (hasStroke ? actualStrokeWidth : 3)
|
? (hasStroke ? actualStrokeWidth : 3)
|
||||||
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
||||||
const diameter = obj.width || 100;
|
const diameter = obj.width || 100;
|
||||||
const gradientProps = isMask
|
const gradientProps = isMask || blurRadius > 0
|
||||||
? {}
|
? {}
|
||||||
: getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
|
: getKonvaGradientProps(obj.fillType, obj.gradient, diameter, diameter, "centered");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Circle
|
<Circle
|
||||||
@@ -40,12 +56,15 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
|
|||||||
x={obj.x}
|
x={obj.x}
|
||||||
y={obj.y}
|
y={obj.y}
|
||||||
radius={(obj.width || 50) / 2}
|
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}
|
{...gradientProps}
|
||||||
stroke={displayStroke}
|
stroke={displayStroke}
|
||||||
strokeWidth={displayStrokeWidth}
|
strokeWidth={displayStrokeWidth}
|
||||||
dash={isMask && showGuide ? [10, 5] : undefined}
|
dash={isMask && showGuide ? [10, 5] : undefined}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
|
opacity={(obj.opacity ?? 100) / 100}
|
||||||
|
{...blurProps}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (shapeRef.current) {
|
if (shapeRef.current) {
|
||||||
@@ -53,11 +72,7 @@ const CircleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapePr
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragMove={(e) => {
|
onDragMove={(e) => {
|
||||||
const node = e.target;
|
e.target.getLayer()?.batchDraw();
|
||||||
onUpdate(obj.id, {
|
|
||||||
x: node.x(),
|
|
||||||
y: node.y(),
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
const node = e.target;
|
const node = e.target;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRef } from "react";
|
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 Konva from "konva";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
import { clampPositionToStage } from "../../utils/stageBounds";
|
import { clampPositionToStage } from "../../utils/stageBounds";
|
||||||
|
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
|
||||||
|
|
||||||
const ImageObject = ({
|
const ImageObject = ({
|
||||||
obj,
|
obj,
|
||||||
@@ -13,17 +14,101 @@ const ImageObject = ({
|
|||||||
draggable,
|
draggable,
|
||||||
stageWidth,
|
stageWidth,
|
||||||
stageHeight,
|
stageHeight,
|
||||||
|
onImageReady,
|
||||||
}: ShapeProps) => {
|
}: ShapeProps) => {
|
||||||
const [image] = useImage(obj.imageUrl || "");
|
const [image, status] = useImage(obj.imageUrl || "");
|
||||||
const shapeRef = useRef<Konva.Image>(null);
|
const shapeRef = useRef<Konva.Image>(null);
|
||||||
|
const groupRef = useRef<Konva.Group>(null);
|
||||||
|
|
||||||
// Transformer is managed in EditorCanvas for multi-select support
|
useEffect(() => {
|
||||||
|
if (status === "loaded" && onImageReady) {
|
||||||
|
onImageReady();
|
||||||
|
}
|
||||||
|
}, [status, onImageReady]);
|
||||||
|
|
||||||
if (!image) return null;
|
if (!image) return null;
|
||||||
|
|
||||||
const width = obj.width || image.width;
|
const width = obj.width || image.width;
|
||||||
const height = obj.height || image.height;
|
const height = obj.height || image.height;
|
||||||
const hasStageBounds = stageWidth != null && stageHeight != null;
|
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 (
|
return (
|
||||||
<KonvaImage
|
<KonvaImage
|
||||||
@@ -39,44 +124,11 @@ const ImageObject = ({
|
|||||||
strokeWidth={isSelected ? 3 : 0}
|
strokeWidth={isSelected ? 3 : 0}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
dragBoundFunc={
|
dragBoundFunc={dragBoundFunc}
|
||||||
draggable && hasStageBounds
|
onClick={handleClick}
|
||||||
? (pos) =>
|
onDragEnd={handleDragEnd}
|
||||||
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 });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ImageObject;
|
export default ImageObject;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const LineShape = ({ obj, onSelect, onUpdate, draggable }: ShapeProps) => {
|
|||||||
hitStrokeWidth={20}
|
hitStrokeWidth={20}
|
||||||
perfectDrawEnabled={false}
|
perfectDrawEnabled={false}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
|
opacity={(obj.opacity ?? 100) / 100}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (shapeRef.current) {
|
if (shapeRef.current) {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const LinkShape = ({
|
|||||||
fontStyle={fontWeight}
|
fontStyle={fontWeight}
|
||||||
underline={true}
|
underline={true}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
|
opacity={(obj.opacity ?? 100) / 100}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
// در حالت editor، لینکها قابل کلیک نیستند
|
// در حالت editor، لینکها قابل کلیک نیستند
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { Line } from "react-konva";
|
||||||
|
import Konva from "konva";
|
||||||
|
import type { ShapeProps } from "./types";
|
||||||
|
|
||||||
|
const PencilShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||||
|
const shapeRef = useRef<Konva.Line>(null);
|
||||||
|
|
||||||
|
const points = obj.points ?? [];
|
||||||
|
if (points.length < 4) return null;
|
||||||
|
|
||||||
|
const actualStrokeWidth = obj.strokeWidth ?? 3;
|
||||||
|
const hasStroke = actualStrokeWidth > 0;
|
||||||
|
|
||||||
|
const displayStroke = isSelected
|
||||||
|
? (hasStroke ? obj.stroke : "#3b82f6")
|
||||||
|
: (hasStroke ? obj.stroke : "#000000");
|
||||||
|
|
||||||
|
const displayStrokeWidth = isSelected
|
||||||
|
? (hasStroke ? actualStrokeWidth : 3)
|
||||||
|
: actualStrokeWidth;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
ref={shapeRef}
|
||||||
|
id={obj.id}
|
||||||
|
name="canvas-object"
|
||||||
|
x={obj.x}
|
||||||
|
y={obj.y}
|
||||||
|
points={points}
|
||||||
|
closed={false}
|
||||||
|
stroke={displayStroke}
|
||||||
|
strokeWidth={displayStrokeWidth}
|
||||||
|
tension={obj.tension ?? 0.5}
|
||||||
|
lineCap="round"
|
||||||
|
lineJoin="round"
|
||||||
|
hitStrokeWidth={Math.max(20, displayStrokeWidth + 10)}
|
||||||
|
perfectDrawEnabled={false}
|
||||||
|
rotation={obj.rotation ?? 0}
|
||||||
|
opacity={(obj.opacity ?? 100) / 100}
|
||||||
|
draggable={draggable}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (shapeRef.current) {
|
||||||
|
onSelect(obj.id, shapeRef.current, e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragMove={(e) => {
|
||||||
|
e.target.getLayer()?.batchDraw();
|
||||||
|
}}
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
const node = e.target;
|
||||||
|
onUpdate(obj.id, {
|
||||||
|
x: node.x(),
|
||||||
|
y: node.y(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PencilShape;
|
||||||
@@ -3,6 +3,8 @@ import { Rect } from "react-konva";
|
|||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
import { getKonvaGradientProps } from "../../utils/gradient";
|
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||||
|
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
||||||
|
import { useShapeBlurCache } from "../../utils/shapeBlur";
|
||||||
|
|
||||||
const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||||
const shapeRef = useRef<Konva.Rect>(null);
|
const shapeRef = useRef<Konva.Rect>(null);
|
||||||
@@ -15,6 +17,22 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
|
|||||||
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||||
const hasStroke = actualStrokeWidth > 0;
|
const hasStroke = actualStrokeWidth > 0;
|
||||||
const cornerRadius = Math.max(0, obj.borderRadius ?? 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 را برای انتخاب نمایش بده
|
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
|
||||||
const displayStroke = isSelected
|
const displayStroke = isSelected
|
||||||
@@ -28,7 +46,7 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
|
|||||||
const displayStrokeWidth = isSelected
|
const displayStrokeWidth = isSelected
|
||||||
? (hasStroke ? actualStrokeWidth : 3)
|
? (hasStroke ? actualStrokeWidth : 3)
|
||||||
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
||||||
const gradientProps = isMask
|
const gradientProps = isMask || blurRadius > 0
|
||||||
? {}
|
? {}
|
||||||
: getKonvaGradientProps(
|
: getKonvaGradientProps(
|
||||||
obj.fillType,
|
obj.fillType,
|
||||||
@@ -48,12 +66,16 @@ const RectangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shap
|
|||||||
width={obj.width || 100}
|
width={obj.width || 100}
|
||||||
height={obj.height || 100}
|
height={obj.height || 100}
|
||||||
cornerRadius={cornerRadius}
|
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}
|
{...gradientProps}
|
||||||
stroke={displayStroke}
|
stroke={displayStroke}
|
||||||
strokeWidth={displayStrokeWidth}
|
strokeWidth={displayStrokeWidth}
|
||||||
dash={isMask && showGuide ? [10, 5] : undefined}
|
dash={isMask && showGuide ? [10, 5] : undefined}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
|
opacity={(obj.opacity ?? 100) / 100}
|
||||||
|
{...blurProps}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (shapeRef.current) {
|
if (shapeRef.current) {
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import Konva from "konva";
|
|||||||
import type { TextShapeProps } from "./types";
|
import type { TextShapeProps } from "./types";
|
||||||
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
|
import { getFontFamily } from "@/pages/editor/utils/fontFamily";
|
||||||
import {
|
import {
|
||||||
|
buildCanvasFont,
|
||||||
|
buildKonvaCanvasFont,
|
||||||
|
DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||||
getEffectiveTextWidth,
|
getEffectiveTextWidth,
|
||||||
|
getKonvaFontStyle,
|
||||||
measureTextBlock,
|
measureTextBlock,
|
||||||
|
resolveTextMaxWidth,
|
||||||
usesWrappedLayout,
|
usesWrappedLayout,
|
||||||
} from "@/pages/editor/utils/textStyle";
|
} from "@/pages/editor/utils/textStyle";
|
||||||
|
import { getColorWithOpacity } from "@/pages/editor/utils/colorOpacity";
|
||||||
|
|
||||||
const getFontWeight = (fontWeight?: string): string => {
|
const getFontWeight = (fontWeight?: string): string => {
|
||||||
if (!fontWeight) return "normal";
|
if (!fontWeight) return "normal";
|
||||||
@@ -27,28 +33,6 @@ const getFontWeight = (fontWeight?: string): string => {
|
|||||||
return weightMap[fontWeight] || "normal";
|
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 = ({
|
const TextShape = ({
|
||||||
obj,
|
obj,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -58,6 +42,7 @@ const TextShape = ({
|
|||||||
const shapeRef = useRef<Konva.Text>(null);
|
const shapeRef = useRef<Konva.Text>(null);
|
||||||
const groupRef = useRef<Konva.Group>(null);
|
const groupRef = useRef<Konva.Group>(null);
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [maxTextWidth, setMaxTextWidth] = useState<number>(DEFAULT_TEXT_MAX_WIDTH_PX);
|
||||||
const fontSize = obj.fontSize || 24;
|
const fontSize = obj.fontSize || 24;
|
||||||
const allowWrap = usesWrappedLayout(
|
const allowWrap = usesWrappedLayout(
|
||||||
obj.text,
|
obj.text,
|
||||||
@@ -86,6 +71,7 @@ const TextShape = ({
|
|||||||
const groupNode = groupRef.current;
|
const groupNode = groupRef.current;
|
||||||
if (!textNode || !groupNode) return;
|
if (!textNode || !groupNode) return;
|
||||||
if (groupNode.scaleX() !== 1 || groupNode.scaleY() !== 1) return;
|
if (groupNode.scaleX() !== 1 || groupNode.scaleY() !== 1) return;
|
||||||
|
const stage = textNode.getStage();
|
||||||
|
|
||||||
textNode._setTextData();
|
textNode._setTextData();
|
||||||
|
|
||||||
@@ -110,6 +96,14 @@ const TextShape = ({
|
|||||||
lineHeight: obj.lineHeight ?? 1.2,
|
lineHeight: obj.lineHeight ?? 1.2,
|
||||||
});
|
});
|
||||||
const konvaWidth = Math.max(1, Math.ceil(rw));
|
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(
|
const nextWidth = getEffectiveTextWidth(
|
||||||
konvaWidth,
|
konvaWidth,
|
||||||
obj.text,
|
obj.text,
|
||||||
@@ -122,16 +116,33 @@ const TextShape = ({
|
|||||||
},
|
},
|
||||||
!allowWrap,
|
!allowWrap,
|
||||||
) ?? konvaWidth;
|
) ?? 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(
|
const nextHeight = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.ceil(rh),
|
Math.ceil(measuredHeight),
|
||||||
cssMeasured.height,
|
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;
|
const heightChanged = Math.abs((obj.height || 0) - nextHeight) > 1;
|
||||||
|
|
||||||
if (widthChanged || heightChanged) {
|
if (widthChanged || heightChanged) {
|
||||||
onUpdate(obj.id, { width: nextWidth, height: nextHeight });
|
onUpdate(obj.id, { width: clampedWidth, height: nextHeight });
|
||||||
}
|
}
|
||||||
textNode.getLayer()?.batchDraw();
|
textNode.getLayer()?.batchDraw();
|
||||||
};
|
};
|
||||||
@@ -155,11 +166,9 @@ const TextShape = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const specs = [
|
const specs = [
|
||||||
`${fontSize}px ${resolvedFamily}`,
|
buildCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
|
||||||
|
buildKonvaCanvasFont(fontSize, obj.fontFamily, obj.fontWeight),
|
||||||
`${konvaStyle} normal ${fontSize}px ${resolvedFamily}`,
|
`${konvaStyle} normal ${fontSize}px ${resolvedFamily}`,
|
||||||
`bold ${fontSize}px ${resolvedFamily}`,
|
|
||||||
`300 ${fontSize}px ${resolvedFamily}`,
|
|
||||||
`200 ${fontSize}px ${resolvedFamily}`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
void Promise.all(
|
void Promise.all(
|
||||||
@@ -191,7 +200,10 @@ const TextShape = ({
|
|||||||
obj.lineHeight,
|
obj.lineHeight,
|
||||||
obj.width,
|
obj.width,
|
||||||
obj.height,
|
obj.height,
|
||||||
|
obj.x,
|
||||||
|
obj.textMaxWidth,
|
||||||
allowWrap,
|
allowWrap,
|
||||||
|
maxTextWidth,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -199,6 +211,8 @@ const TextShape = ({
|
|||||||
const konvaFontStyle = useMemo(() => getKonvaFontStyle(obj.fontWeight), [obj.fontWeight]);
|
const konvaFontStyle = useMemo(() => getKonvaFontStyle(obj.fontWeight), [obj.fontWeight]);
|
||||||
const fillColor = useMemo(() => getColorWithOpacity(obj.fill, obj.opacity), [obj.fill, obj.opacity]);
|
const fillColor = useMemo(() => getColorWithOpacity(obj.fill, obj.opacity), [obj.fill, obj.opacity]);
|
||||||
const textAlign = obj.textAlign ?? "right";
|
const textAlign = obj.textAlign ?? "right";
|
||||||
|
const wrapByMaxWidth = (obj.width ?? 0) >= maxTextWidth - 1;
|
||||||
|
const shouldWrap = allowWrap || wrapByMaxWidth;
|
||||||
|
|
||||||
const handleDblClick = () => {
|
const handleDblClick = () => {
|
||||||
if (!shapeRef.current || !groupRef.current) return;
|
if (!shapeRef.current || !groupRef.current) return;
|
||||||
@@ -238,7 +252,8 @@ const TextShape = ({
|
|||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: `${areaPosition.y}px`,
|
top: `${areaPosition.y}px`,
|
||||||
left: `${areaPosition.x}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`,
|
minHeight: `${box.height}px`,
|
||||||
fontSize: `${(obj.fontSize || 24) * stageScale}px`,
|
fontSize: `${(obj.fontSize || 24) * stageScale}px`,
|
||||||
fontFamily: fontFamily,
|
fontFamily: fontFamily,
|
||||||
@@ -358,9 +373,9 @@ const TextShape = ({
|
|||||||
fontStyle={konvaFontStyle}
|
fontStyle={konvaFontStyle}
|
||||||
letterSpacing={obj.letterSpacing ?? 0}
|
letterSpacing={obj.letterSpacing ?? 0}
|
||||||
lineHeight={obj.lineHeight ?? 1.2}
|
lineHeight={obj.lineHeight ?? 1.2}
|
||||||
width={allowWrap ? obj.width || undefined : undefined}
|
width={shouldWrap ? obj.width || maxTextWidth : undefined}
|
||||||
align={textAlign}
|
align={textAlign}
|
||||||
wrap={allowWrap ? "word" : "none"}
|
wrap={shouldWrap ? "word" : "none"}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { RegularPolygon } from "react-konva";
|
|||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
import { getKonvaGradientProps } from "../../utils/gradient";
|
import { getKonvaGradientProps } from "../../utils/gradient";
|
||||||
|
import { getColorWithOpacity } from "../../utils/colorOpacity";
|
||||||
|
import { useShapeBlurCache } from "../../utils/shapeBlur";
|
||||||
|
|
||||||
const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: ShapeProps) => {
|
||||||
const shapeRef = useRef<Konva.RegularPolygon>(null);
|
const shapeRef = useRef<Konva.RegularPolygon>(null);
|
||||||
@@ -15,6 +17,21 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
|||||||
|
|
||||||
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
const actualStrokeWidth = obj.strokeWidth ?? 0;
|
||||||
const hasStroke = actualStrokeWidth > 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 را برای انتخاب نمایش بده
|
// برای نمایش انتخاب: اگر strokeWidth واقعی > 0 است، از آن استفاده کن، وگرنه stroke را برای انتخاب نمایش بده
|
||||||
const displayStroke = isSelected
|
const displayStroke = isSelected
|
||||||
@@ -28,14 +45,14 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
|||||||
const displayStrokeWidth = isSelected
|
const displayStrokeWidth = isSelected
|
||||||
? (hasStroke ? actualStrokeWidth : 3)
|
? (hasStroke ? actualStrokeWidth : 3)
|
||||||
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
: (isMask && showGuide ? 2 : actualStrokeWidth);
|
||||||
const gradientProps = isMask
|
const gradientProps = isMask || blurRadius > 0
|
||||||
? {}
|
? {}
|
||||||
: getKonvaGradientProps(
|
: getKonvaGradientProps(
|
||||||
obj.fillType,
|
obj.fillType,
|
||||||
obj.gradient,
|
obj.gradient,
|
||||||
obj.width || 100,
|
obj.width || 100,
|
||||||
obj.height || 100,
|
obj.height || 100,
|
||||||
"centered",
|
"centered",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,12 +64,15 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
|||||||
y={obj.y + (obj.height || 100) / 2}
|
y={obj.y + (obj.height || 100) / 2}
|
||||||
sides={3}
|
sides={3}
|
||||||
radius={radius}
|
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}
|
{...gradientProps}
|
||||||
stroke={displayStroke}
|
stroke={displayStroke}
|
||||||
strokeWidth={displayStrokeWidth}
|
strokeWidth={displayStrokeWidth}
|
||||||
dash={isMask && showGuide ? [10, 5] : undefined}
|
dash={isMask && showGuide ? [10, 5] : undefined}
|
||||||
rotation={obj.rotation || 0}
|
rotation={obj.rotation || 0}
|
||||||
|
opacity={(obj.opacity ?? 100) / 100}
|
||||||
|
{...blurProps}
|
||||||
draggable={draggable}
|
draggable={draggable}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (shapeRef.current) {
|
if (shapeRef.current) {
|
||||||
@@ -60,11 +80,7 @@ const TriangleShape = ({ obj, isSelected, onSelect, onUpdate, draggable }: Shape
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragMove={(e) => {
|
onDragMove={(e) => {
|
||||||
const node = e.target;
|
e.target.getLayer()?.batchDraw();
|
||||||
onUpdate(obj.id, {
|
|
||||||
x: node.x() - (obj.width || 100) / 2,
|
|
||||||
y: node.y() - (obj.height || 100) / 2,
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
onDragEnd={(e) => {
|
onDragEnd={(e) => {
|
||||||
const node = e.target;
|
const node = e.target;
|
||||||
|
|||||||
@@ -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 { Image as KonvaImage, Rect, Group, Circle, Text as KonvaText } from "react-konva";
|
||||||
import Konva from "konva";
|
import Konva from "konva";
|
||||||
import useImage from "use-image";
|
import useImage from "use-image";
|
||||||
import type { ShapeProps } from "./types";
|
import type { ShapeProps } from "./types";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { createRoundedRectClipFunc, getObjectBorderRadius } from "../../utils/borderRadius";
|
||||||
|
|
||||||
const VideoShape = ({
|
const VideoShape = ({
|
||||||
obj,
|
obj,
|
||||||
@@ -72,6 +73,37 @@ const VideoShape = ({
|
|||||||
|
|
||||||
const containerWidth = obj.width || 400;
|
const containerWidth = obj.width || 400;
|
||||||
const containerHeight = obj.height || 300;
|
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>) => {
|
const handlePlayClick = (e: Konva.KonvaEventObject<MouseEvent>) => {
|
||||||
e.cancelBubble = true;
|
e.cancelBubble = true;
|
||||||
@@ -121,20 +153,32 @@ const VideoShape = ({
|
|||||||
<Rect
|
<Rect
|
||||||
width={containerWidth}
|
width={containerWidth}
|
||||||
height={containerHeight}
|
height={containerHeight}
|
||||||
fill="#000000"
|
fill="transparent"
|
||||||
|
cornerRadius={cornerRadius}
|
||||||
stroke={isSelected ? "#3b82f6" : "#666666"}
|
stroke={isSelected ? "#3b82f6" : "#666666"}
|
||||||
strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)}
|
strokeWidth={isSelected ? 3 : (obj.strokeWidth ?? 0)}
|
||||||
onClick={handleGroupClick}
|
onClick={handleGroupClick}
|
||||||
/>
|
/>
|
||||||
|
<Group clipFunc={clipFunc}>
|
||||||
{image ? (
|
{image ? (
|
||||||
<KonvaImage
|
<>
|
||||||
x={0}
|
<Rect
|
||||||
y={0}
|
x={imageLayout.x}
|
||||||
width={containerWidth}
|
y={imageLayout.y}
|
||||||
height={containerHeight}
|
width={imageLayout.width}
|
||||||
image={image}
|
height={imageLayout.height}
|
||||||
onClick={handleGroupClick}
|
fill="#000000"
|
||||||
/>
|
onClick={handleGroupClick}
|
||||||
|
/>
|
||||||
|
<KonvaImage
|
||||||
|
x={imageLayout.x}
|
||||||
|
y={imageLayout.y}
|
||||||
|
width={imageLayout.width}
|
||||||
|
height={imageLayout.height}
|
||||||
|
image={image}
|
||||||
|
onClick={handleGroupClick}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Rect
|
<Rect
|
||||||
x={0}
|
x={0}
|
||||||
@@ -145,6 +189,7 @@ const VideoShape = ({
|
|||||||
onClick={handleGroupClick}
|
onClick={handleGroupClick}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</Group>
|
||||||
<Group
|
<Group
|
||||||
name="playButton"
|
name="playButton"
|
||||||
onClick={handlePlayClick}
|
onClick={handlePlayClick}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as RectangleShape } from "./RectangleShape";
|
export { default as RectangleShape } from "./RectangleShape";
|
||||||
export { default as CustomShape } from "./CustomShape";
|
export { default as CustomShape } from "./CustomShape";
|
||||||
|
export { default as PencilShape } from "./PencilShape";
|
||||||
export { default as CircleShape } from "./CircleShape";
|
export { default as CircleShape } from "./CircleShape";
|
||||||
export { default as TriangleShape } from "./TriangleShape";
|
export { default as TriangleShape } from "./TriangleShape";
|
||||||
export { default as AbstractShape } from "./AbstractShape";
|
export { default as AbstractShape } from "./AbstractShape";
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ export type ShapeProps = {
|
|||||||
draggable: boolean;
|
draggable: boolean;
|
||||||
stageWidth?: number;
|
stageWidth?: number;
|
||||||
stageHeight?: number;
|
stageHeight?: number;
|
||||||
|
/** Called by async shapes (images) once their content is fully ready to render. */
|
||||||
|
onImageReady?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TextShapeProps = ShapeProps & {
|
export type TextShapeProps = ShapeProps & {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { EditorObject, Page, TableCell } from "./editorStore.types";
|
import type { EditorObject, Page, TableCell } from "./editorStore.types";
|
||||||
import { normalizeCustomShapeObject } from "../utils/customShape";
|
import { normalizeCustomShapeObject, normalizePencilObject } from "../utils/customShape";
|
||||||
|
|
||||||
export const createTableCellId = (row: number, col: number) =>
|
export const createTableCellId = (row: number, col: number) =>
|
||||||
`cell-${row}-${col}`;
|
`cell-${row}-${col}`;
|
||||||
@@ -44,6 +44,7 @@ export const createInitialCells = (
|
|||||||
id: cellId,
|
id: cellId,
|
||||||
text: "",
|
text: "",
|
||||||
background: "#f5f5f5",
|
background: "#f5f5f5",
|
||||||
|
backgroundOpacity: 100,
|
||||||
width: cellWidth,
|
width: cellWidth,
|
||||||
height: cellHeight,
|
height: cellHeight,
|
||||||
};
|
};
|
||||||
@@ -59,12 +60,14 @@ export const createInitialPage = (name: string): Page => ({
|
|||||||
guides: [],
|
guides: [],
|
||||||
backgroundType: "color",
|
backgroundType: "color",
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
|
backgroundOpacity: 100,
|
||||||
backgroundGradient: {
|
backgroundGradient: {
|
||||||
from: "#ffffff",
|
from: "#ffffff",
|
||||||
to: "#e2e8f0",
|
to: "#e2e8f0",
|
||||||
angle: 135,
|
angle: 135,
|
||||||
},
|
},
|
||||||
backgroundImageUrl: "",
|
backgroundImageUrl: "",
|
||||||
|
backgroundVideoUrl: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getDefaultPageName = (index: number): string =>
|
export const getDefaultPageName = (index: number): string =>
|
||||||
@@ -390,4 +393,6 @@ export const ensureMissingGroupObjects = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const normalizePageObjects = (objects: EditorObject[]): EditorObject[] =>
|
export const normalizePageObjects = (objects: EditorObject[]): EditorObject[] =>
|
||||||
ensureMissingGroupObjects(objects).map(normalizeCustomShapeObject);
|
ensureMissingGroupObjects(objects)
|
||||||
|
.map(normalizeCustomShapeObject)
|
||||||
|
.map(normalizePencilObject);
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ type PageBackgroundSettings = Pick<
|
|||||||
Page,
|
Page,
|
||||||
| "backgroundType"
|
| "backgroundType"
|
||||||
| "backgroundColor"
|
| "backgroundColor"
|
||||||
|
| "backgroundOpacity"
|
||||||
| "backgroundGradient"
|
| "backgroundGradient"
|
||||||
| "backgroundImageUrl"
|
| "backgroundImageUrl"
|
||||||
|
| "backgroundVideoUrl"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type EditorStoreType = {
|
type EditorStoreType = {
|
||||||
@@ -126,6 +128,11 @@ type EditorStoreType = {
|
|||||||
cellId: string,
|
cellId: string,
|
||||||
color: string,
|
color: string,
|
||||||
) => void;
|
) => void;
|
||||||
|
changeCellBackgroundOpacity: (
|
||||||
|
tableId: string,
|
||||||
|
cellId: string,
|
||||||
|
opacity: number,
|
||||||
|
) => void;
|
||||||
applyTableResize: (
|
applyTableResize: (
|
||||||
tableId: string,
|
tableId: string,
|
||||||
newWidth: number,
|
newWidth: number,
|
||||||
@@ -185,12 +192,14 @@ const DEFAULT_DOCUMENT_SETTINGS: DocumentSettings = {
|
|||||||
smartGuide: true,
|
smartGuide: true,
|
||||||
backgroundType: "color",
|
backgroundType: "color",
|
||||||
backgroundColor: "#ffffff",
|
backgroundColor: "#ffffff",
|
||||||
|
backgroundOpacity: 100,
|
||||||
backgroundGradient: {
|
backgroundGradient: {
|
||||||
from: "#ffffff",
|
from: "#ffffff",
|
||||||
to: "#e2e8f0",
|
to: "#e2e8f0",
|
||||||
angle: 135,
|
angle: 135,
|
||||||
},
|
},
|
||||||
backgroundImageUrl: "",
|
backgroundImageUrl: "",
|
||||||
|
backgroundVideoUrl: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useEditorStore = create<EditorStoreType>((set, get) => {
|
export const useEditorStore = create<EditorStoreType>((set, get) => {
|
||||||
@@ -638,6 +647,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
id: cellId,
|
id: cellId,
|
||||||
text: "",
|
text: "",
|
||||||
background: "#f5f5f5",
|
background: "#f5f5f5",
|
||||||
|
backgroundOpacity: 100,
|
||||||
width: tableData.cellWidth,
|
width: tableData.cellWidth,
|
||||||
height: tableData.cellHeight,
|
height: tableData.cellHeight,
|
||||||
};
|
};
|
||||||
@@ -668,6 +678,7 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
id: cellId,
|
id: cellId,
|
||||||
text: "",
|
text: "",
|
||||||
background: "#f5f5f5",
|
background: "#f5f5f5",
|
||||||
|
backgroundOpacity: 100,
|
||||||
width: tableData.cellWidth,
|
width: tableData.cellWidth,
|
||||||
height: tableData.cellHeight,
|
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) => {
|
applyTableResize: (tableId, newWidth, newHeight) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
const obj = state.objects.find((o) => o.id === tableId);
|
const obj = state.objects.find((o) => o.id === tableId);
|
||||||
@@ -856,8 +889,10 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
guides: [...pageToDuplicate.guides],
|
guides: [...pageToDuplicate.guides],
|
||||||
backgroundType: pageToDuplicate.backgroundType,
|
backgroundType: pageToDuplicate.backgroundType,
|
||||||
backgroundColor: pageToDuplicate.backgroundColor,
|
backgroundColor: pageToDuplicate.backgroundColor,
|
||||||
|
backgroundOpacity: pageToDuplicate.backgroundOpacity ?? 100,
|
||||||
backgroundGradient: pageToDuplicate.backgroundGradient,
|
backgroundGradient: pageToDuplicate.backgroundGradient,
|
||||||
backgroundImageUrl: pageToDuplicate.backgroundImageUrl,
|
backgroundImageUrl: pageToDuplicate.backgroundImageUrl,
|
||||||
|
backgroundVideoUrl: pageToDuplicate.backgroundVideoUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -1050,6 +1085,8 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
page.backgroundType ?? fallbackSettings.backgroundType ?? "color",
|
page.backgroundType ?? fallbackSettings.backgroundType ?? "color",
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff",
|
page.backgroundColor ?? fallbackSettings.backgroundColor ?? "#ffffff",
|
||||||
|
backgroundOpacity:
|
||||||
|
page.backgroundOpacity ?? fallbackSettings.backgroundOpacity ?? 100,
|
||||||
backgroundGradient: page.backgroundGradient ??
|
backgroundGradient: page.backgroundGradient ??
|
||||||
fallbackSettings.backgroundGradient ?? {
|
fallbackSettings.backgroundGradient ?? {
|
||||||
from: "#ffffff",
|
from: "#ffffff",
|
||||||
@@ -1058,6 +1095,8 @@ export const useEditorStore = create<EditorStoreType>((set, get) => {
|
|||||||
},
|
},
|
||||||
backgroundImageUrl:
|
backgroundImageUrl:
|
||||||
page.backgroundImageUrl ?? fallbackSettings.backgroundImageUrl ?? "",
|
page.backgroundImageUrl ?? fallbackSettings.backgroundImageUrl ?? "",
|
||||||
|
backgroundVideoUrl:
|
||||||
|
page.backgroundVideoUrl ?? fallbackSettings.backgroundVideoUrl ?? "",
|
||||||
}));
|
}));
|
||||||
const firstPage = normalizedPages[0];
|
const firstPage = normalizedPages[0];
|
||||||
const currentSettings = get().documentSettings;
|
const currentSettings = get().documentSettings;
|
||||||
|
|||||||
@@ -26,12 +26,15 @@ export type ToolType =
|
|||||||
| "link"
|
| "link"
|
||||||
| "grid"
|
| "grid"
|
||||||
| "group"
|
| "group"
|
||||||
| "custom-shape";
|
| "custom-shape"
|
||||||
|
| "pencil";
|
||||||
|
|
||||||
export type TableCell = {
|
export type TableCell = {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
background: string;
|
background: string;
|
||||||
|
/** شفافیت پسزمینه سلول (۰–۱۰۰) */
|
||||||
|
backgroundOpacity?: number;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
@@ -60,6 +63,8 @@ export type EditorObject = {
|
|||||||
stroke?: string;
|
stroke?: string;
|
||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
/** حداکثر عرض باکس متن (px) برای wrap پیشفرض متنهای بلند */
|
||||||
|
textMaxWidth?: number;
|
||||||
fontSize?: number;
|
fontSize?: number;
|
||||||
lineHeight?: number;
|
lineHeight?: number;
|
||||||
textAlign?: "left" | "center" | "right";
|
textAlign?: "left" | "center" | "right";
|
||||||
@@ -79,6 +84,8 @@ export type EditorObject = {
|
|||||||
scaleY?: number;
|
scaleY?: number;
|
||||||
shapeType?: ShapeType;
|
shapeType?: ShapeType;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
|
/** میزان بلور شکل به پیکسل (۰ = بدون بلور) */
|
||||||
|
blur?: number;
|
||||||
tableData?: TableData;
|
tableData?: TableData;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
isMask?: boolean;
|
isMask?: boolean;
|
||||||
@@ -99,6 +106,8 @@ export type EditorObject = {
|
|||||||
points?: number[];
|
points?: number[];
|
||||||
/** آیا شکل چندضلعی بسته است (پیشفرض true برای custom-shape کاملشده) */
|
/** آیا شکل چندضلعی بسته است (پیشفرض true برای custom-shape کاملشده) */
|
||||||
closed?: boolean;
|
closed?: boolean;
|
||||||
|
/** نرمی منحنی برای مسیرهای آزاد (pencil) — مقدار Konva Line.tension */
|
||||||
|
tension?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PageGuide = {
|
export type PageGuide = {
|
||||||
@@ -114,12 +123,14 @@ export type Page = {
|
|||||||
guides: PageGuide[];
|
guides: PageGuide[];
|
||||||
backgroundType: BackgroundType;
|
backgroundType: BackgroundType;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
|
backgroundOpacity?: number;
|
||||||
backgroundGradient?: LinearGradient;
|
backgroundGradient?: LinearGradient;
|
||||||
backgroundImageUrl: string;
|
backgroundImageUrl: string;
|
||||||
|
backgroundVideoUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DisplayStyle = "single" | "double";
|
export type DisplayStyle = "single" | "double";
|
||||||
export type BackgroundType = "color" | "gradient" | "image";
|
export type BackgroundType = "color" | "gradient" | "image" | "video";
|
||||||
|
|
||||||
export type DocumentSettings = {
|
export type DocumentSettings = {
|
||||||
displayStyle: DisplayStyle;
|
displayStyle: DisplayStyle;
|
||||||
@@ -129,8 +140,10 @@ export type DocumentSettings = {
|
|||||||
smartGuide: boolean;
|
smartGuide: boolean;
|
||||||
backgroundType: BackgroundType;
|
backgroundType: BackgroundType;
|
||||||
backgroundColor: string;
|
backgroundColor: string;
|
||||||
|
backgroundOpacity?: number;
|
||||||
backgroundGradient?: LinearGradient;
|
backgroundGradient?: LinearGradient;
|
||||||
backgroundImageUrl: string;
|
backgroundImageUrl: string;
|
||||||
|
backgroundVideoUrl: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** تصویر آپلودشده در گالری کاتالوگ (مشترک بین همهٔ صفحات) */
|
/** تصویر آپلودشده در گالری کاتالوگ (مشترک بین همهٔ صفحات) */
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
export type PencilDefaults = {
|
||||||
|
stroke: string;
|
||||||
|
strokeWidth: number;
|
||||||
|
opacity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PencilDefaultsStoreType = {
|
||||||
|
defaults: PencilDefaults;
|
||||||
|
updateDefaults: (updates: Partial<PencilDefaults>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePencilDefaultsStore = create<PencilDefaultsStoreType>((set) => ({
|
||||||
|
defaults: {
|
||||||
|
stroke: "#000000",
|
||||||
|
strokeWidth: 3,
|
||||||
|
opacity: 100,
|
||||||
|
},
|
||||||
|
updateDefaults: (updates) =>
|
||||||
|
set((state) => ({
|
||||||
|
defaults: { ...state.defaults, ...updates },
|
||||||
|
})),
|
||||||
|
}));
|
||||||
@@ -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})`;
|
||||||
|
};
|
||||||
@@ -71,3 +71,44 @@ export const normalizeCustomShapeObject = (
|
|||||||
|
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MIN_PENCIL_POINTS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures pencil stroke points are relative to obj.x/obj.y and bbox matches width/height.
|
||||||
|
*/
|
||||||
|
export const normalizePencilObject = (obj: EditorObject): EditorObject => {
|
||||||
|
if (obj.type !== "pencil" || !obj.points || obj.points.length < MIN_PENCIL_POINTS) {
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
let next: EditorObject = { ...obj, closed: false };
|
||||||
|
|
||||||
|
if (
|
||||||
|
next.opacity !== undefined &&
|
||||||
|
next.opacity > 0 &&
|
||||||
|
next.opacity <= 1
|
||||||
|
) {
|
||||||
|
next = { ...next, opacity: next.opacity * 100 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = next.points!;
|
||||||
|
const { minX, minY, width, height } = getCustomShapePointBounds(points);
|
||||||
|
|
||||||
|
if (minX !== 0 || minY !== 0) {
|
||||||
|
next = {
|
||||||
|
...next,
|
||||||
|
x: (next.x ?? 0) + minX,
|
||||||
|
y: (next.y ?? 0) + minY,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
points: points.map((val, i) =>
|
||||||
|
i % 2 === 0 ? val - minX : val - minY,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
} else if (next.width !== width || next.height !== height) {
|
||||||
|
next = { ...next, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|||||||
@@ -196,8 +196,9 @@ export const exportObjectWithMask = async (
|
|||||||
// Step 1: Draw object
|
// Step 1: Draw object
|
||||||
await drawObject(ctx, { ...object, x: 0, y: 0 });
|
await drawObject(ctx, { ...object, x: 0, y: 0 });
|
||||||
|
|
||||||
// Step 2: Apply mask با destination-in
|
// Step 2: Apply mask — default is destination-in (show inside mask).
|
||||||
ctx.globalCompositeOperation = "destination-in";
|
// maskInvert === false → destination-out (show outside mask, hide inside).
|
||||||
|
ctx.globalCompositeOperation = object.maskInvert === false ? "destination-out" : "destination-in";
|
||||||
|
|
||||||
// Adjust mask position relative to object
|
// Adjust mask position relative to object
|
||||||
const adjustedMaskShape: EditorObject = {
|
const adjustedMaskShape: EditorObject = {
|
||||||
|
|||||||
@@ -25,23 +25,24 @@ const getGradientPoints = (
|
|||||||
): { start: Point; end: Point } => {
|
): { start: Point; end: Point } => {
|
||||||
const safeWidth = Math.max(1, width);
|
const safeWidth = Math.max(1, width);
|
||||||
const safeHeight = Math.max(1, height);
|
const safeHeight = Math.max(1, height);
|
||||||
|
// App/UI angle: 0° = top→bottom, 90° = left→right (clockwise).
|
||||||
const rad = toRadians(normalizeAngle(angle));
|
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 half = Math.sqrt(safeWidth * safeWidth + safeHeight * safeHeight) / 2;
|
||||||
|
const halfX = Math.sin(rad) * half;
|
||||||
|
const halfY = Math.cos(rad) * half;
|
||||||
|
|
||||||
if (mode === "centered") {
|
if (mode === "centered") {
|
||||||
return {
|
return {
|
||||||
start: { x: -dx * half, y: -dy * half },
|
start: { x: -halfX, y: -halfY },
|
||||||
end: { x: dx * half, y: dy * half },
|
end: { x: halfX, y: halfY },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cx = safeWidth / 2;
|
const cx = safeWidth / 2;
|
||||||
const cy = safeHeight / 2;
|
const cy = safeHeight / 2;
|
||||||
return {
|
return {
|
||||||
start: { x: cx - dx * half, y: cy - dy * half },
|
start: { x: cx - halfX, y: cy - halfY },
|
||||||
end: { x: cx + dx * half, y: cy + dy * half },
|
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) => {
|
export const toCssLinearGradient = (gradient: LinearGradient | undefined) => {
|
||||||
if (!gradient) return 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;
|
return 400;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Konva.Text uses `fontStyle: bold` (canvas keyword), not numeric weight. */
|
/**
|
||||||
export const usesKonvaBoldStyle = (fontWeight?: string): boolean => {
|
* Konva.Text `fontStyle` — supports `normal`, `bold`, or numeric strings like `200`.
|
||||||
if (!fontWeight || fontWeight === "normal") return false;
|
* @see https://konvajs.org/api/Konva.Text.html#fontStyle
|
||||||
if (fontWeight === "bold" || fontWeight === "bolder") return true;
|
*/
|
||||||
|
export const getKonvaFontStyle = (fontWeight?: string): string => {
|
||||||
|
if (!fontWeight || fontWeight === "normal") return "normal";
|
||||||
|
if (fontWeight === "bold" || fontWeight === "bolder") return "bold";
|
||||||
|
|
||||||
const n = parseInt(fontWeight, 10);
|
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. */
|
/** Canvas/CSS `font` string shared by editor measurement and viewer layout. */
|
||||||
@@ -30,15 +36,15 @@ export const buildCanvasFont = (
|
|||||||
return `${weight} ${fontSize}px ${family}`;
|
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 = (
|
export const buildKonvaCanvasFont = (
|
||||||
fontSize: number,
|
fontSize: number,
|
||||||
fontFamily?: string,
|
fontFamily?: string,
|
||||||
fontWeight?: string,
|
fontWeight?: string,
|
||||||
): string => {
|
): string => {
|
||||||
const family = getFontFamily(fontFamily);
|
const family = getFontFamily(fontFamily);
|
||||||
const style = usesKonvaBoldStyle(fontWeight) ? "bold" : "normal";
|
const style = getKonvaFontStyle(fontWeight);
|
||||||
return `${style} ${fontSize}px ${family}`;
|
return `${style} normal ${fontSize}px ${family}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LINE_BREAK_RE = /[\r\n\u2028\u2029]/;
|
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. */
|
/** Extra px so HTML layout does not wrap tighter than Konva/canvas metrics. */
|
||||||
export const SINGLE_LINE_WIDTH_SLACK_PX = 12;
|
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 = {
|
export type TextMeasureOptions = {
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import * as api from "../service/UploaderService";
|
import * as api from "../service/UploaderService";
|
||||||
|
|
||||||
|
export type SingleUploadParams = {
|
||||||
|
file: File;
|
||||||
|
onProgress?: (progress: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
export const useSingleUpload = () => {
|
export const useSingleUpload = () => {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: api.singleUpload,
|
mutationFn: ({ file, onProgress }: SingleUploadParams) =>
|
||||||
|
api.singleUpload(file, onProgress),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,21 @@ import type {
|
|||||||
} from "../types/Types";
|
} from "../types/Types";
|
||||||
|
|
||||||
export const singleUpload = async (
|
export const singleUpload = async (
|
||||||
file: File
|
file: File,
|
||||||
|
onProgress?: (progress: number) => void
|
||||||
): Promise<SingleUploadResponse> => {
|
): Promise<SingleUploadResponse> => {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
const { data } = await axios.post<SingleUploadResponse>(
|
const { data } = await axios.post<SingleUploadResponse>(
|
||||||
`/admin/single-file`,
|
`/admin/single-file`,
|
||||||
formData
|
formData,
|
||||||
|
{
|
||||||
|
onUploadProgress: (event) => {
|
||||||
|
if (event.total) {
|
||||||
|
onProgress?.(Math.round((event.loaded / event.total) * 100));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import { forwardRef } from 'react';
|
import { forwardRef, memo, useEffect, useRef } from 'react';
|
||||||
import { type PageData } from '../types';
|
import { type PageData } from '../types';
|
||||||
import type { EditorObject } from '@/pages/editor/store/editorStore';
|
import type { EditorObject } from '@/pages/editor/store/editorStore';
|
||||||
import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
|
import { toCssLinearGradient, getSvgGradientEndpoints } from '@/pages/editor/utils/gradient';
|
||||||
import { getCustomShapePointBounds } from '@/pages/editor/utils/customShape';
|
import { getCustomShapePointBounds } from '@/pages/editor/utils/customShape';
|
||||||
import { getFontFamily } from '@/pages/editor/utils/fontFamily';
|
import { getFontFamily } from '@/pages/editor/utils/fontFamily';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_TEXT_MAX_WIDTH_PX,
|
||||||
getCssFontWeight,
|
getCssFontWeight,
|
||||||
getViewerTextLayout,
|
getViewerTextLayout,
|
||||||
|
resolveTextMaxWidth,
|
||||||
usesWrappedLayout,
|
usesWrappedLayout,
|
||||||
} from '@/pages/editor/utils/textStyle';
|
} 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 '@/pages/viewer/styles/entranceAnimations.css';
|
||||||
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
import { mergeEntranceAnimationStyle } from '@/pages/viewer/utils/entranceAnimationStyle';
|
||||||
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
|
import { getMaskImageStyle, getMaskedLayout } from '@/pages/viewer/utils/maskStyle';
|
||||||
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
|
import type { EntrancePhase } from '@/pages/viewer/hooks/useBookEntranceController';
|
||||||
|
import { usePageMediaReady } from '@/pages/viewer/hooks/usePageMediaReady';
|
||||||
|
|
||||||
const getRasterObjectStyle = (
|
const getRasterObjectStyle = (
|
||||||
obj: EditorObject,
|
obj: EditorObject,
|
||||||
@@ -24,6 +33,7 @@ const getRasterObjectStyle = (
|
|||||||
const scaleY = obj.scaleY ?? 1;
|
const scaleY = obj.scaleY ?? 1;
|
||||||
const width = obj.width != null ? obj.width * scaleX * scale : undefined;
|
const width = obj.width != null ? obj.width * scaleX * scale : undefined;
|
||||||
const height = obj.height != null ? obj.height * scaleY * scale : undefined;
|
const height = obj.height != null ? obj.height * scaleY * scale : undefined;
|
||||||
|
const borderRadius = getCssBorderRadius(obj.borderRadius, scale);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseStyle,
|
...baseStyle,
|
||||||
@@ -33,11 +43,14 @@ const getRasterObjectStyle = (
|
|||||||
height: height != null ? `${height}px` : 'auto',
|
height: height != null ? `${height}px` : 'auto',
|
||||||
objectFit: 'fill',
|
objectFit: 'fill',
|
||||||
zIndex: index,
|
zIndex: index,
|
||||||
|
...(borderRadius ? { borderRadius, overflow: 'hidden' as const } : {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type RenderObjectOptions = {
|
type RenderObjectOptions = {
|
||||||
skipEntrance?: boolean;
|
skipEntrance?: boolean;
|
||||||
|
/** در پیشنمایش (لیست صفحات و کاتالوگ) رسانه تعاملی نباشد */
|
||||||
|
staticMedia?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BookPageProps = {
|
type BookPageProps = {
|
||||||
@@ -46,22 +59,36 @@ type BookPageProps = {
|
|||||||
pageWidth?: number;
|
pageWidth?: number;
|
||||||
pageHeight?: number;
|
pageHeight?: number;
|
||||||
onLinkClick?: (linkUrl: string) => void;
|
onLinkClick?: (linkUrl: string) => void;
|
||||||
backgroundType?: 'color' | 'gradient' | 'image';
|
backgroundType?: 'color' | 'gradient' | 'image' | 'video';
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
backgroundOpacity?: number;
|
||||||
backgroundGradient?: {
|
backgroundGradient?: {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
angle: number;
|
angle: number;
|
||||||
};
|
};
|
||||||
backgroundImageUrl?: string;
|
backgroundImageUrl?: string;
|
||||||
|
backgroundVideoUrl?: string;
|
||||||
/** فاز انیمیشن ورود — از BookViewer کنترل میشود */
|
/** فاز انیمیشن ورود — از BookViewer کنترل میشود */
|
||||||
entrancePhase?: EntrancePhase;
|
entrancePhase?: EntrancePhase;
|
||||||
/** در پیشنمایش کاتالوگ انیمیشن ورود غیرفعال باشد */
|
/** در پیشنمایش کاتالوگ انیمی션 ورود غیرفعال باشد */
|
||||||
disableEntranceAnimations?: boolean;
|
disableEntranceAnimations?: boolean;
|
||||||
|
/** وقتی رسانهٔ صفحه دیرتر از زمانبندی انیمیشن لود شد */
|
||||||
|
onDeferredEntranceReady?: (pageId: number) => void;
|
||||||
|
/** لایهٔ اسپینر لود رسانه را نشان نده (مثلاً ذرهبین) */
|
||||||
|
hideMediaLoadingOverlay?: boolean;
|
||||||
|
/** ویدیو/صوت فقط بهصورت کاور — بدون کنترل و بدون کلیک */
|
||||||
|
staticMedia?: boolean;
|
||||||
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
|
/** سایهٔ لبهٔ کاغذ (گرادیان داخل DOM — سازگار با iOS قدیمی) */
|
||||||
showPaperShadow?: boolean;
|
showPaperShadow?: boolean;
|
||||||
/** ورق سخت بهجای clip-path برای iOS قدیمی */
|
/** ورق سخت بهجای clip-path برای iOS قدیمی */
|
||||||
useHardPage?: boolean;
|
useHardPage?: boolean;
|
||||||
|
/**
|
||||||
|
* پسوند یکتا برای idهای SVG (گرادیان و غیره).
|
||||||
|
* وقتی همان صفحه بیش از یکبار همزمان رندر میشود (مثلاً در Magnifier)
|
||||||
|
* باید idها متفاوت باشند تا با نسخهٔ اصلی تداخل نکنند.
|
||||||
|
*/
|
||||||
|
idSuffix?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,9 +98,42 @@ type BookPageProps = {
|
|||||||
* این کامپوننت به عنوان child برای HTMLFlipBook استفاده میشود
|
* این کامپوننت به عنوان child برای HTMLFlipBook استفاده میشود
|
||||||
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
* نیاز به forwardRef دارد تا react-pageflip بتواند ref را مدیریت کند
|
||||||
*/
|
*/
|
||||||
const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
const BookPage = memo(forwardRef<HTMLDivElement, BookPageProps>(
|
||||||
({ page, scale = 1, pageWidth, pageHeight, onLinkClick, backgroundType, backgroundColor, backgroundGradient, backgroundImageUrl, entrancePhase = 'idle', disableEntranceAnimations = false, showPaperShadow = true, useHardPage = false }, ref) => {
|
({ 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 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)
|
// تابع برای تبدیل opacity به رنگ (مثل editor)
|
||||||
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
// در editor، getColorWithOpacity انتظار opacity 0-100 دارد
|
||||||
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
|
// در dataTransformer، opacity از 0-1 به 0-100 تبدیل شده است
|
||||||
@@ -117,7 +177,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
rotationDeg?: number;
|
rotationDeg?: number;
|
||||||
},
|
},
|
||||||
) =>
|
) =>
|
||||||
mergeEntranceAnimationStyle(style, obj, scale, index, phase, {
|
mergeEntranceAnimationStyle(style, obj, scale, index, effectivePhase, {
|
||||||
...extra,
|
...extra,
|
||||||
flyLayoutPx,
|
flyLayoutPx,
|
||||||
});
|
});
|
||||||
@@ -157,6 +217,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
return null;
|
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 =
|
const objectFillStyle: React.CSSProperties =
|
||||||
obj.fillType === 'gradient' && obj.gradient
|
obj.fillType === 'gradient' && obj.gradient
|
||||||
? { backgroundImage: toCssLinearGradient(obj.gradient) }
|
? { backgroundImage: toCssLinearGradient(obj.gradient) }
|
||||||
@@ -188,14 +253,39 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
measureOpts,
|
measureOpts,
|
||||||
wrapped,
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={obj.id || index}
|
key={obj.id || index}
|
||||||
style={applyStyle(
|
style={applyStyle(
|
||||||
{
|
{
|
||||||
...textBaseStyle,
|
...textBaseStyle,
|
||||||
left: `${textLayout.leftPx}px`,
|
left: `${resolvedLeftPx}px`,
|
||||||
width: textLayout.widthPx !== undefined ? `${textLayout.widthPx}px` : 'max-content',
|
width: resolvedWidthPx !== undefined ? `${resolvedWidthPx}px` : 'max-content',
|
||||||
fontSize: `${fontSize * scale}px`,
|
fontSize: `${fontSize * scale}px`,
|
||||||
fontFamily: getFontFamily(obj.fontFamily),
|
fontFamily: getFontFamily(obj.fontFamily),
|
||||||
fontWeight: getCssFontWeight(obj.fontWeight),
|
fontWeight: getCssFontWeight(obj.fontWeight),
|
||||||
@@ -203,10 +293,11 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
textAlign: textLayout.textAlign,
|
textAlign: textLayout.textAlign,
|
||||||
direction: 'rtl',
|
direction: 'rtl',
|
||||||
color: getColorWithOpacity(obj.fill, obj.opacity),
|
color: getColorWithOpacity(obj.fill, obj.opacity),
|
||||||
whiteSpace: wrapped ? 'pre-wrap' : 'nowrap',
|
whiteSpace: finalWrapped ? 'pre-wrap' : 'nowrap',
|
||||||
overflowWrap: wrapped ? 'break-word' : 'normal',
|
overflowWrap: finalWrapped ? 'break-word' : 'normal',
|
||||||
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
|
letterSpacing: obj.letterSpacing ? `${obj.letterSpacing * scale}px` : undefined,
|
||||||
boxSizing: 'content-box',
|
boxSizing: 'content-box',
|
||||||
|
zIndex: index,
|
||||||
transform: textLayout.transform,
|
transform: textLayout.transform,
|
||||||
transformOrigin: textLayout.transformOrigin,
|
transformOrigin: textLayout.transformOrigin,
|
||||||
},
|
},
|
||||||
@@ -236,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 (
|
return (
|
||||||
<video
|
<video
|
||||||
key={obj.id || index}
|
key={obj.id || index}
|
||||||
@@ -244,11 +370,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
controls
|
controls
|
||||||
style={applyStyle(
|
style={applyStyle(
|
||||||
{
|
{
|
||||||
...baseStyle,
|
...videoStyle,
|
||||||
width: obj.width ? `${obj.width * scale}px` : 'auto',
|
|
||||||
height: obj.height ? `${obj.height * scale}px` : 'auto',
|
|
||||||
objectFit: 'contain',
|
|
||||||
zIndex: index,
|
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
obj,
|
obj,
|
||||||
@@ -265,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 (
|
return (
|
||||||
<audio
|
<audio
|
||||||
key={obj.id || index}
|
key={obj.id || index}
|
||||||
@@ -275,10 +423,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
style={applyStyle(
|
style={applyStyle(
|
||||||
{
|
{
|
||||||
...baseStyle,
|
...audioBaseStyle,
|
||||||
width: obj.width ? `${obj.width * scale}px` : `${320 * scale}px`,
|
|
||||||
height: obj.height ? `${obj.height * scale}px` : `${56 * scale}px`,
|
|
||||||
zIndex: index,
|
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
},
|
},
|
||||||
obj,
|
obj,
|
||||||
@@ -295,6 +440,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case 'link': {
|
case 'link': {
|
||||||
const isInternalLink = obj.linkUrl?.startsWith('page://');
|
const isInternalLink = obj.linkUrl?.startsWith('page://');
|
||||||
@@ -448,7 +594,17 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
||||||
const fillColor = obj.fill || '#000000';
|
const fillColor = obj.fill || '#000000';
|
||||||
const strokeColor = obj.stroke || 'transparent';
|
const strokeColor = obj.stroke || 'transparent';
|
||||||
const gradientId = `triangle-grad-${obj.id}-${index}`;
|
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 (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -472,25 +628,24 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
)}
|
)}
|
||||||
viewBox={`0 0 ${size} ${size}`}
|
viewBox={`0 0 ${size} ${size}`}
|
||||||
>
|
>
|
||||||
{obj.fillType === 'gradient' && obj.gradient ? (
|
{triangleGradientEndpoints ? (
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient
|
<linearGradient
|
||||||
id={gradientId}
|
id={gradientId}
|
||||||
gradientUnits="userSpaceOnUse"
|
gradientUnits="userSpaceOnUse"
|
||||||
x1="0"
|
x1={triangleGradientEndpoints.x1}
|
||||||
y1="0"
|
y1={triangleGradientEndpoints.y1}
|
||||||
x2={size}
|
x2={triangleGradientEndpoints.x2}
|
||||||
y2={size}
|
y2={triangleGradientEndpoints.y2}
|
||||||
gradientTransform={`rotate(${obj.gradient.angle}, ${size / 2}, ${size / 2})`}
|
|
||||||
>
|
>
|
||||||
<stop offset="0%" stopColor={obj.gradient.from} />
|
<stop offset="0%" stopColor={obj.gradient!.from} />
|
||||||
<stop offset="100%" stopColor={obj.gradient.to} />
|
<stop offset="100%" stopColor={obj.gradient!.to} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
) : null}
|
) : null}
|
||||||
<polygon
|
<polygon
|
||||||
points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`}
|
points={`${topX},${topY} ${bottomLeftX},${bottomY} ${bottomRightX},${bottomY}`}
|
||||||
fill={obj.fillType === 'gradient' && obj.gradient ? `url(#${gradientId})` : fillColor}
|
fill={triangleGradientEndpoints ? `url(#${gradientId})` : fillColor}
|
||||||
stroke={strokeColor}
|
stroke={strokeColor}
|
||||||
strokeWidth={strokeWidth}
|
strokeWidth={strokeWidth}
|
||||||
/>
|
/>
|
||||||
@@ -659,7 +814,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
const strokeWidth = hasStroke && obj.stroke ? actualStrokeWidth * scale : 0;
|
||||||
const fillColor = obj.fill || '#3b82f6';
|
const fillColor = obj.fill || '#3b82f6';
|
||||||
const strokeColor = obj.stroke || 'transparent';
|
const strokeColor = obj.stroke || 'transparent';
|
||||||
const gradientId = `custom-shape-grad-${obj.id}-${index}`;
|
const gradientId = `custom-shape-grad-${obj.id}-${index}${idSuffix}`;
|
||||||
const gradientEndpoints =
|
const gradientEndpoints =
|
||||||
obj.fillType === 'gradient' && obj.gradient
|
obj.fillType === 'gradient' && obj.gradient
|
||||||
? getSvgGradientEndpoints(obj.gradient, width, height, 'rect')
|
? getSvgGradientEndpoints(obj.gradient, width, height, 'rect')
|
||||||
@@ -720,6 +875,56 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'pencil': {
|
||||||
|
const rawPoints = obj.points ?? [];
|
||||||
|
if (rawPoints.length < 4) return null;
|
||||||
|
|
||||||
|
const { minX, minY, width: bboxW, height: bboxH } =
|
||||||
|
getCustomShapePointBounds(rawPoints);
|
||||||
|
const width = bboxW * scale;
|
||||||
|
const height = bboxH * scale;
|
||||||
|
const pointsStr = Array.from(
|
||||||
|
{ length: rawPoints.length / 2 },
|
||||||
|
(_, i) =>
|
||||||
|
`${(rawPoints[i * 2] - minX) * scale},${(rawPoints[i * 2 + 1] - minY) * scale}`,
|
||||||
|
).join(' ');
|
||||||
|
|
||||||
|
const strokeWidth = actualStrokeWidth * scale;
|
||||||
|
const strokeColor = obj.stroke || '#000000';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
key={obj.id || index}
|
||||||
|
style={applyStyle(
|
||||||
|
{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${((obj.x || 0) + minX) * scale}px`,
|
||||||
|
top: `${((obj.y || 0) + minY) * scale}px`,
|
||||||
|
width: `${width}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
opacity: (obj.opacity ?? 100) / 100,
|
||||||
|
transform: obj.rotation ? `rotate(${obj.rotation}deg)` : undefined,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
zIndex: index,
|
||||||
|
overflow: 'visible',
|
||||||
|
},
|
||||||
|
obj,
|
||||||
|
index,
|
||||||
|
)}
|
||||||
|
viewBox={`0 0 ${width} ${height}`}
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points={pointsStr}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case 'grid': {
|
case 'grid': {
|
||||||
if (!obj.tableData) return null;
|
if (!obj.tableData) return null;
|
||||||
|
|
||||||
@@ -751,7 +956,10 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
key={cellId}
|
key={cellId}
|
||||||
style={{
|
style={{
|
||||||
border: stroke ? `${(strokeWidth || 1) * scale}px solid ${stroke}` : `${1 * scale}px solid #ccc`,
|
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',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -781,7 +989,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!maskShape) {
|
if (!maskShape) {
|
||||||
return renderObjectContent(obj, index);
|
return renderObjectContent(obj, index, { staticMedia });
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = getMaskedLayout(obj, maskShape, scale);
|
const layout = getMaskedLayout(obj, maskShape, scale);
|
||||||
@@ -791,7 +999,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const content = renderObjectContent(
|
const content = renderObjectContent(
|
||||||
{ ...obj, x: (obj.x || 0) - unionLeft, y: (obj.y || 0) - unionTop },
|
{ ...obj, x: (obj.x || 0) - unionLeft, y: (obj.y || 0) - unionTop },
|
||||||
index,
|
index,
|
||||||
{ skipEntrance: true },
|
{ skipEntrance: true, staticMedia },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
@@ -820,11 +1028,6 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
const width = pageWidth ?? 794 * scale;
|
const width = pageWidth ?? 794 * scale;
|
||||||
const height = pageHeight ?? 1123 * 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 =
|
const bgStyle: React.CSSProperties =
|
||||||
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
|
effectiveBackgroundType === 'image' && effectiveBackgroundImageUrl
|
||||||
? {
|
? {
|
||||||
@@ -837,13 +1040,16 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
? {
|
? {
|
||||||
backgroundImage: toCssLinearGradient(effectiveBackgroundGradient),
|
backgroundImage: toCssLinearGradient(effectiveBackgroundGradient),
|
||||||
}
|
}
|
||||||
: { backgroundColor: effectiveBackgroundColor };
|
: { backgroundColor: getColorWithOpacity(effectiveBackgroundColor, effectiveBackgroundOpacity) };
|
||||||
|
|
||||||
|
const showLoadingOverlay = !hideMediaLoadingOverlay && !isMediaReady;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className="page"
|
className="page"
|
||||||
|
data-page-id={page.id}
|
||||||
data-density={useHardPage ? 'hard' : 'soft'}
|
data-density={useHardPage ? 'hard' : 'soft'}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -852,30 +1058,105 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
|
|||||||
width: `${width}px`,
|
width: `${width}px`,
|
||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
...(useHardPage
|
...(useHardPage && !showLoadingOverlay
|
||||||
? { backgroundColor: effectiveBackgroundColor, ...bgStyle }
|
? { 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
|
<div
|
||||||
aria-hidden
|
className="flex items-center justify-center bg-gray-100"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
...bgStyle,
|
zIndex: 2,
|
||||||
pointerEvents: 'none',
|
|
||||||
zIndex: 0,
|
|
||||||
}}
|
}}
|
||||||
/>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
BookPage.displayName = 'BookPage';
|
BookPage.displayName = 'BookPage';
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
|||||||
|
.root {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root,
|
||||||
|
.root * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lens {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: var(--magnifier-size);
|
||||||
|
height: var(--magnifier-size);
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
visibility: hidden;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 32px rgba(0, 0, 0, 0.28),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.16),
|
||||||
|
inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||||
|
background-color: #fff;
|
||||||
|
will-change: transform;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* پنجرهٔ دید ثابت (بهاندازهٔ لنز)؛ محتوای بزرگنماییشده داخلش clip میشود */
|
||||||
|
.content {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* لایهای که BookPage با مقیاس بزرگ داخلش رندر میشود و فقط translate میگیرد */
|
||||||
|
.mirror {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { memo, useCallback, useMemo, useRef, useState, type CSSProperties, type RefObject } from "react";
|
||||||
|
import BookPage from "@/pages/viewer/components/BookPage";
|
||||||
|
import type { PageData } from "@/pages/viewer/types";
|
||||||
|
import { resolvePageBackground, type PageBackgroundDefaults } from "@/pages/viewer/utils/pageBackground";
|
||||||
|
import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants";
|
||||||
|
import styles from "./Magnifier.module.css";
|
||||||
|
import { useMagnifier } from "./useMagnifier";
|
||||||
|
|
||||||
|
const noopLinkClick = () => {};
|
||||||
|
|
||||||
|
export type MagnifierProps = {
|
||||||
|
enabled: boolean;
|
||||||
|
/** مرجع مختصات برای موقعیتدهی لنز (معمولاً ناحیهٔ اطراف کتاب) */
|
||||||
|
containerRef: RefObject<HTMLElement | null>;
|
||||||
|
/** ناحیهای که رویدادهای موس روی آن گوش داده میشود (wrapper کتاب) */
|
||||||
|
interactionRef: RefObject<HTMLElement | null>;
|
||||||
|
/** صفحاتی که هماکنون در DOM رندر شدهاند (برای نگاشت pageId به داده) */
|
||||||
|
pages: PageData[];
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
contentScale: number;
|
||||||
|
pageDefaults: PageBackgroundDefaults;
|
||||||
|
useHardPage?: boolean;
|
||||||
|
/** در حین انیمیشن ورقخوردن true است */
|
||||||
|
isFlippingRef: RefObject<boolean>;
|
||||||
|
/** در حالت تکصفحه (موبایل) همیشه صفحهٔ فعلی بزرگنمایی میشود */
|
||||||
|
lockedPageId?: number | null;
|
||||||
|
blockFlipTouch?: boolean;
|
||||||
|
/** با تغییر آن (ورق خوردن) ذرهبین ریست میشود */
|
||||||
|
pageIndex?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Magnifier = memo(
|
||||||
|
({
|
||||||
|
enabled,
|
||||||
|
containerRef,
|
||||||
|
interactionRef,
|
||||||
|
pages,
|
||||||
|
pageWidth,
|
||||||
|
pageHeight,
|
||||||
|
contentScale,
|
||||||
|
pageDefaults,
|
||||||
|
useHardPage = false,
|
||||||
|
isFlippingRef,
|
||||||
|
lockedPageId = null,
|
||||||
|
blockFlipTouch = false,
|
||||||
|
pageIndex,
|
||||||
|
}: MagnifierProps) => {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const lensRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mirrorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [activePageId, setActivePageId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const handleActivePageChange = useCallback((pageId: number | null) => {
|
||||||
|
setActivePageId(pageId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useMagnifier({
|
||||||
|
enabled,
|
||||||
|
containerRef,
|
||||||
|
interactionRef,
|
||||||
|
overlayRef,
|
||||||
|
lensRef,
|
||||||
|
mirrorRef,
|
||||||
|
isFlippingRef,
|
||||||
|
lockedPageId,
|
||||||
|
blockFlipTouch,
|
||||||
|
pageIndex,
|
||||||
|
onActivePageChange: handleActivePageChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
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],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
className={styles.root}
|
||||||
|
aria-hidden
|
||||||
|
data-magnifier-overlay
|
||||||
|
style={{ display: enabled ? "block" : "none" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={lensRef}
|
||||||
|
className={styles.lens}
|
||||||
|
style={{ "--magnifier-size": `${MAGNIFIER_SIZE}px` } as CSSProperties}
|
||||||
|
>
|
||||||
|
<div className={styles.content}>
|
||||||
|
{activePage && background && (
|
||||||
|
<div ref={mirrorRef} className={styles.mirror}>
|
||||||
|
<BookPage
|
||||||
|
page={activePage}
|
||||||
|
scale={contentScale * ZOOM_SCALE}
|
||||||
|
pageWidth={pageWidth * ZOOM_SCALE}
|
||||||
|
pageHeight={pageHeight * ZOOM_SCALE}
|
||||||
|
onLinkClick={noopLinkClick}
|
||||||
|
backgroundType={background.backgroundType}
|
||||||
|
backgroundColor={background.backgroundColor}
|
||||||
|
backgroundGradient={background.backgroundGradient}
|
||||||
|
backgroundImageUrl={background.backgroundImageUrl}
|
||||||
|
backgroundVideoUrl={background.backgroundVideoUrl}
|
||||||
|
useHardPage={useHardPage}
|
||||||
|
disableEntranceAnimations
|
||||||
|
hideMediaLoadingOverlay
|
||||||
|
showPaperShadow={false}
|
||||||
|
idSuffix="-magnifier"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Magnifier.displayName = "Magnifier";
|
||||||
|
|
||||||
|
export default Magnifier;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const MAGNIFIER_SIZE = 220;
|
||||||
|
export const ZOOM_SCALE = 2.5;
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/** پیدا کردن المان `.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,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
ignoreRoot?: HTMLElement | null,
|
||||||
|
): HTMLElement | null {
|
||||||
|
const hitElements = document.elementsFromPoint(clientX, clientY);
|
||||||
|
for (const el of hitElements) {
|
||||||
|
if (!(el instanceof HTMLElement)) continue;
|
||||||
|
if (ignoreRoot?.contains(el)) continue;
|
||||||
|
if (!root.contains(el)) continue;
|
||||||
|
|
||||||
|
const page = el.closest(".page");
|
||||||
|
if (page instanceof HTMLElement && root.contains(page) && isPageVisible(page)) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallback برای زمانی که pointer-events روی صفحات محدود شده (مثلاً هنگام ورقخوردن)
|
||||||
|
let match: HTMLElement | null = null;
|
||||||
|
let topZ = -Infinity;
|
||||||
|
|
||||||
|
root.querySelectorAll<HTMLElement>(".page").forEach((page) => {
|
||||||
|
if (!isPageVisible(page)) return;
|
||||||
|
|
||||||
|
const rect = page.getBoundingClientRect();
|
||||||
|
if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const z = getPageStackZIndex(page);
|
||||||
|
if (z >= topZ) {
|
||||||
|
topZ = z;
|
||||||
|
match = page;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPageVisible(page: HTMLElement): boolean {
|
||||||
|
const rect = page.getBoundingClientRect();
|
||||||
|
if (rect.width < 1 || rect.height < 1) return false;
|
||||||
|
|
||||||
|
const style = window.getComputedStyle(page);
|
||||||
|
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||||
|
|
||||||
|
const opacity = Number.parseFloat(style.opacity);
|
||||||
|
if (Number.isFinite(opacity) && opacity < 0.05) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageStackZIndex(page: HTMLElement): number {
|
||||||
|
let el: HTMLElement | null = page;
|
||||||
|
let maxZ = 0;
|
||||||
|
|
||||||
|
while (el) {
|
||||||
|
const z = Number.parseInt(window.getComputedStyle(el).zIndex, 10);
|
||||||
|
if (Number.isFinite(z)) {
|
||||||
|
maxZ = Math.max(maxZ, z);
|
||||||
|
}
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxZ;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MagnifierGeometry = {
|
||||||
|
/** فاصلهٔ گوشهٔ بالا-چپ صفحه از containerRef (برای موقعیتدهی خود لنز) */
|
||||||
|
pageOffsetX: number;
|
||||||
|
pageOffsetY: number;
|
||||||
|
/** مختصات موس نسبت به گوشهٔ بالا-چپ صفحه (بدون کلمپ — برای محتوای داخل لنز) */
|
||||||
|
localX: number;
|
||||||
|
localY: number;
|
||||||
|
/** مختصات کلمپشده در محدودهٔ صفحه (برای موقعیت خود لنز، تا از صفحه بیرون نزند) */
|
||||||
|
clampedX: number;
|
||||||
|
clampedY: number;
|
||||||
|
pageWidth: number;
|
||||||
|
pageHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function computeMagnifierGeometry(
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
page: HTMLElement,
|
||||||
|
container: HTMLElement,
|
||||||
|
lensSize: number,
|
||||||
|
): MagnifierGeometry | null {
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const pageRect = page.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (pageRect.width <= 0 || pageRect.height <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localX = clientX - pageRect.left;
|
||||||
|
const localY = clientY - pageRect.top;
|
||||||
|
|
||||||
|
if (localX < 0 || localY < 0 || localX > pageRect.width || localY > pageRect.height) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const half = lensSize / 2;
|
||||||
|
const clampedX =
|
||||||
|
pageRect.width <= lensSize ? pageRect.width / 2 : Math.max(half, Math.min(pageRect.width - half, localX));
|
||||||
|
const clampedY =
|
||||||
|
pageRect.height <= lensSize ? pageRect.height / 2 : Math.max(half, Math.min(pageRect.height - half, localY));
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageOffsetX: pageRect.left - containerRect.left,
|
||||||
|
pageOffsetY: pageRect.top - containerRect.top,
|
||||||
|
localX,
|
||||||
|
localY,
|
||||||
|
clampedX,
|
||||||
|
clampedY,
|
||||||
|
pageWidth: pageRect.width,
|
||||||
|
pageHeight: pageRect.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
||||||
|
import { MAGNIFIER_SIZE, ZOOM_SCALE } from "./constants";
|
||||||
|
import { computeMagnifierGeometry, findPageAtPoint, findPageById } from "./magnifierUtils";
|
||||||
|
|
||||||
|
type UseMagnifierOptions = {
|
||||||
|
enabled: boolean;
|
||||||
|
/** مرجع مختصات برای موقعیتدهی لنز (معمولاً ناحیهٔ اطراف کتاب) */
|
||||||
|
containerRef: RefObject<HTMLElement | null>;
|
||||||
|
/** ناحیهٔ تعامل و جستجوی صفحات (wrapper کتاب) */
|
||||||
|
interactionRef: RefObject<HTMLElement | null>;
|
||||||
|
overlayRef: RefObject<HTMLElement | null>;
|
||||||
|
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;
|
||||||
|
/** وقتی صفحهٔ زیر ماوس عوض شود صدا زده میشود تا محتوای مناسب رندر شود */
|
||||||
|
onActivePageChange: (pageId: number | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useMagnifier({
|
||||||
|
enabled,
|
||||||
|
containerRef,
|
||||||
|
interactionRef,
|
||||||
|
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;
|
||||||
|
if (lens) {
|
||||||
|
lens.style.visibility = "hidden";
|
||||||
|
}
|
||||||
|
}, [lensRef]);
|
||||||
|
|
||||||
|
const resetActivePage = useCallback(() => {
|
||||||
|
if (lastPageIdRef.current !== null) {
|
||||||
|
lastPageIdRef.current = null;
|
||||||
|
onActivePageChange(null);
|
||||||
|
}
|
||||||
|
}, [onActivePageChange]);
|
||||||
|
|
||||||
|
const applyGeometryRef = useRef<(clientX: number, clientY: number) => void>(() => {});
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
applyGeometryRef.current = (clientX: number, clientY: number) => {
|
||||||
|
if (isFlippingRef.current) {
|
||||||
|
hideLens();
|
||||||
|
resetActivePage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const interaction = interactionRef.current;
|
||||||
|
const lens = lensRef.current;
|
||||||
|
if (!container || !interaction || !lens) return;
|
||||||
|
|
||||||
|
const lockedId = lockedPageIdRef.current;
|
||||||
|
const page =
|
||||||
|
lockedId != null
|
||||||
|
? findPageById(interaction, lockedId)
|
||||||
|
: findPageAtPoint(interaction, clientX, clientY, overlayRef.current);
|
||||||
|
|
||||||
|
if (!page) {
|
||||||
|
hideLens();
|
||||||
|
resetActivePage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = computeMagnifierGeometry(clientX, clientY, page, container, MAGNIFIER_SIZE);
|
||||||
|
if (!geometry) {
|
||||||
|
hideLens();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageIdAttr = page.dataset.pageId;
|
||||||
|
const pageId = pageIdAttr != null ? Number(pageIdAttr) : null;
|
||||||
|
if (pageId !== lastPageIdRef.current) {
|
||||||
|
lastPageIdRef.current = pageId;
|
||||||
|
onActivePageChange(pageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const half = MAGNIFIER_SIZE / 2;
|
||||||
|
const { pageOffsetX, pageOffsetY, localX, localY, clampedX, clampedY } = geometry;
|
||||||
|
|
||||||
|
lens.style.visibility = "visible";
|
||||||
|
lens.style.transform = `translate3d(${pageOffsetX + clampedX - half}px, ${pageOffsetY + clampedY - half}px, 0)`;
|
||||||
|
|
||||||
|
const mirror = mirrorRef.current;
|
||||||
|
if (mirror) {
|
||||||
|
mirror.style.transform = `translate3d(${half - localX * ZOOM_SCALE}px, ${half - localY * ZOOM_SCALE}px, 0)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// با ورق خوردن، وضعیت ذرهبین کاملاً ریست میشود
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
pendingPointRef.current = null;
|
||||||
|
touchActiveRef.current = false;
|
||||||
|
hideLens();
|
||||||
|
resetActivePage();
|
||||||
|
lastPageIdRef.current = null;
|
||||||
|
}, [pageIndex, enabled, hideLens, resetActivePage]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!enabled) {
|
||||||
|
pendingPointRef.current = null;
|
||||||
|
touchActiveRef.current = false;
|
||||||
|
if (rafIdRef.current != null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current);
|
||||||
|
rafIdRef.current = null;
|
||||||
|
}
|
||||||
|
hideLens();
|
||||||
|
resetActivePage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
const interaction = interactionRef.current;
|
||||||
|
if (!container || !interaction) return;
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { clx } from "@/helpers/utils";
|
||||||
|
import { SearchNormal1 } from "iconsax-react";
|
||||||
|
import { type FC } from "react";
|
||||||
|
|
||||||
|
export type ZoomButtonProps = {
|
||||||
|
isActive: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ZoomButton: FC<ZoomButtonProps> = ({ isActive, onToggle }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-label={isActive ? "غیرفعال کردن ذرهبین" : "فعال کردن ذرهبین"}
|
||||||
|
aria-pressed={isActive}
|
||||||
|
title={isActive ? "غیرفعال کردن ذرهبین" : "فعال کردن ذرهبین"}
|
||||||
|
className={clx(
|
||||||
|
"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 transition-all",
|
||||||
|
isActive
|
||||||
|
? "bg-gray-200 ring-2 ring-gray-400 hover:bg-gray-200"
|
||||||
|
: "hover:bg-gray-100 active:bg-gray-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SearchNormal1
|
||||||
|
color="black"
|
||||||
|
size={20}
|
||||||
|
variant={isActive ? "Bold" : "Outline"}
|
||||||
|
className="text-gray-700 md:w-6 md:h-6"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ZoomButton;
|
||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
ENTRANCE_ANIMATION_BASE_DELAY_MS,
|
ENTRANCE_ANIMATION_BASE_DELAY_MS,
|
||||||
MAX_ENTRANCE_DELAY_MS,
|
MAX_ENTRANCE_DELAY_MS,
|
||||||
} from "@/shared/entranceAnimation";
|
} from "@/shared/entranceAnimation";
|
||||||
import { getVisiblePageIndices } from "@/pages/viewer/utils/visiblePageIndices";
|
|
||||||
|
|
||||||
/** حداکثر زمان پخش انیمیشن ورود (برای برداشتن حالت play) */
|
/** حداکثر زمان پخش انیمیشن ورود (برای برداشتن حالت play) */
|
||||||
const MAX_ENTRANCE_PLAY_MS =
|
const MAX_ENTRANCE_PLAY_MS =
|
||||||
@@ -18,15 +17,18 @@ type PageRef = { id: number };
|
|||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
pages: PageRef[];
|
pages: PageRef[];
|
||||||
portrait: boolean;
|
|
||||||
showCover: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* کنترل یکبار پخش انیمیشن ورود هر صفحه — state در والد نگهداری میشود
|
* کنترل یکبار پخش انیمیشن ورود هر صفحه — state در والد نگهداری میشود
|
||||||
* تا با remount شدن BookPage هنگام ورقخوردن دوباره اجرا نشود.
|
* تا با 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 playedPageIdsRef = useRef(new Set<number>());
|
||||||
const [playingPageIds, setPlayingPageIds] = useState<ReadonlySet<number>>(() => new Set());
|
const [playingPageIds, setPlayingPageIds] = useState<ReadonlySet<number>>(() => new Set());
|
||||||
const playTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
const playTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||||
@@ -49,20 +51,17 @@ export function useBookEntranceController({ pages, portrait, showCover }: Option
|
|||||||
});
|
});
|
||||||
}, [clearPlayTimer]);
|
}, [clearPlayTimer]);
|
||||||
|
|
||||||
const scheduleEntranceForSpread = useCallback(
|
const scheduleEntranceForIndices = useCallback(
|
||||||
(currentIndex: number) => {
|
(visibleLogicalIndices: number[]) => {
|
||||||
const indices = getVisiblePageIndices(currentIndex, pages.length, {
|
|
||||||
portrait,
|
|
||||||
showCover,
|
|
||||||
});
|
|
||||||
|
|
||||||
const visibleIds = new Set(
|
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[] = [];
|
const newlyPlaying: number[] = [];
|
||||||
|
|
||||||
for (const i of indices) {
|
for (const i of visibleLogicalIndices) {
|
||||||
const page = pages[i];
|
const page = pages[i];
|
||||||
if (!page) continue;
|
if (!page) continue;
|
||||||
if (playedPageIdsRef.current.has(page.id)) 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(
|
const getEntrancePhase = useCallback(
|
||||||
@@ -116,9 +115,31 @@ export function useBookEntranceController({ pages, portrait, showCover }: Option
|
|||||||
setPlayingPageIds(new Set());
|
setPlayingPageIds(new Set());
|
||||||
}, [clearPlayTimer]);
|
}, [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 {
|
return {
|
||||||
scheduleEntranceForSpread,
|
scheduleEntranceForIndices,
|
||||||
getEntrancePhase,
|
getEntrancePhase,
|
||||||
|
replayEntranceForPage,
|
||||||
reset,
|
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;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
elements: EditorObject[];
|
elements: EditorObject[];
|
||||||
backgroundType?: "color" | "gradient" | "image";
|
backgroundType?: "color" | "gradient" | "image" | "video";
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
backgroundOpacity?: number;
|
||||||
backgroundGradient?: {
|
backgroundGradient?: {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
angle: number;
|
angle: number;
|
||||||
};
|
};
|
||||||
backgroundImageUrl?: string;
|
backgroundImageUrl?: string;
|
||||||
|
backgroundVideoUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import type { EditorObject, TableData } from "@/pages/editor/store/editorStore";
|
import type { EditorObject, TableData } from "@/pages/editor/store/editorStore";
|
||||||
import { normalizeCustomShapeObject } from "@/pages/editor/utils/customShape";
|
import { normalizeCustomShapeObject, normalizePencilObject } from "@/pages/editor/utils/customShape";
|
||||||
import type { PageData } from "../types";
|
import type { PageData } from "../types";
|
||||||
|
|
||||||
type ViewerDataPage = {
|
type ViewerDataPage = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
backgroundType?: "color" | "gradient" | "image";
|
backgroundType?: "color" | "gradient" | "image" | "video";
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
backgroundOpacity?: number;
|
||||||
backgroundGradient?: {
|
backgroundGradient?: {
|
||||||
from: string;
|
from: string;
|
||||||
to: string;
|
to: string;
|
||||||
angle: number;
|
angle: number;
|
||||||
};
|
};
|
||||||
backgroundImageUrl?: string;
|
backgroundImageUrl?: string;
|
||||||
|
backgroundVideoUrl?: string;
|
||||||
objects: Array<{
|
objects: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -37,6 +39,7 @@ type ViewerDataPage = {
|
|||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
shapeType?: string;
|
shapeType?: string;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
|
blur?: number;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
audioUrl?: string;
|
audioUrl?: string;
|
||||||
@@ -53,6 +56,7 @@ type ViewerDataPage = {
|
|||||||
maskInvert?: boolean;
|
maskInvert?: boolean;
|
||||||
points?: number[];
|
points?: number[];
|
||||||
closed?: boolean;
|
closed?: boolean;
|
||||||
|
tension?: number;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,9 +128,12 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
|||||||
if (obj.type === "rectangle" && obj.shapeType) {
|
if (obj.type === "rectangle" && obj.shapeType) {
|
||||||
baseObject.shapeType = obj.shapeType as EditorObject["shapeType"];
|
baseObject.shapeType = obj.shapeType as EditorObject["shapeType"];
|
||||||
}
|
}
|
||||||
if (obj.type === "rectangle" && obj.borderRadius !== undefined) {
|
if (obj.borderRadius !== undefined) {
|
||||||
baseObject.borderRadius = obj.borderRadius;
|
baseObject.borderRadius = obj.borderRadius;
|
||||||
}
|
}
|
||||||
|
if (obj.type === "rectangle" && obj.blur !== undefined) {
|
||||||
|
baseObject.blur = obj.blur;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(obj.type === "image" || obj.type === "sticker" || obj.type === "document") &&
|
(obj.type === "image" || obj.type === "sticker" || obj.type === "document") &&
|
||||||
@@ -176,7 +183,18 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
|||||||
if (obj.closed !== undefined) baseObject.closed = obj.closed;
|
if (obj.closed !== undefined) baseObject.closed = obj.closed;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizeCustomShapeObject(baseObject);
|
if (obj.type === "pencil") {
|
||||||
|
if (obj.points !== undefined) baseObject.points = obj.points;
|
||||||
|
if (obj.closed !== undefined) baseObject.closed = obj.closed;
|
||||||
|
if (obj.tension !== undefined) baseObject.tension = obj.tension;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized =
|
||||||
|
obj.type === "pencil"
|
||||||
|
? normalizePencilObject(baseObject)
|
||||||
|
: normalizeCustomShapeObject(baseObject);
|
||||||
|
|
||||||
|
return normalized;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -186,8 +204,10 @@ export function transformViewerDataToPages(data: ViewerData): PageData[] {
|
|||||||
elements: objects,
|
elements: objects,
|
||||||
backgroundType: page.backgroundType,
|
backgroundType: page.backgroundType,
|
||||||
backgroundColor: page.backgroundColor,
|
backgroundColor: page.backgroundColor,
|
||||||
|
backgroundOpacity: page.backgroundOpacity,
|
||||||
backgroundGradient: page.backgroundGradient,
|
backgroundGradient: page.backgroundGradient,
|
||||||
backgroundImageUrl: page.backgroundImageUrl,
|
backgroundImageUrl: page.backgroundImageUrl,
|
||||||
|
backgroundVideoUrl: page.backgroundVideoUrl,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ const getMaskBounds = (mask: EditorObject): Bounds => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the wrapper bounding box for a masked object.
|
||||||
|
*
|
||||||
|
* Default (maskInvert !== false) → destination-in → show inside mask:
|
||||||
|
* Uses intersection bounds — only the overlap area needs to be rendered,
|
||||||
|
* which is smaller and faster than the union.
|
||||||
|
*
|
||||||
|
* maskInvert === false → destination-out → show outside mask (hide inside):
|
||||||
|
* Uses object bounds — the entire object is visible except where the mask sits.
|
||||||
|
*/
|
||||||
export const getMaskedLayout = (
|
export const getMaskedLayout = (
|
||||||
obj: EditorObject,
|
obj: EditorObject,
|
||||||
mask: EditorObject,
|
mask: EditorObject,
|
||||||
@@ -113,33 +123,42 @@ export const getMaskedLayout = (
|
|||||||
const objBounds = getObjectBounds(obj);
|
const objBounds = getObjectBounds(obj);
|
||||||
const maskBounds = getMaskBounds(mask);
|
const maskBounds = getMaskBounds(mask);
|
||||||
|
|
||||||
const unionLeft = Math.min(objBounds.left, maskBounds.left);
|
const isDestinationIn = obj.maskInvert !== false;
|
||||||
const unionTop = Math.min(objBounds.top, maskBounds.top);
|
|
||||||
const unionRight = Math.max(objBounds.right, maskBounds.right);
|
|
||||||
const unionBottom = Math.max(objBounds.bottom, maskBounds.bottom);
|
|
||||||
|
|
||||||
let maskRelX: number;
|
let left: number;
|
||||||
let maskRelY: number;
|
let top: number;
|
||||||
|
let right: number;
|
||||||
|
let bottom: number;
|
||||||
|
|
||||||
if (mask.type === 'rectangle' && mask.shapeType === 'circle') {
|
if (isDestinationIn) {
|
||||||
maskRelX = (mask.x || 0) - unionLeft;
|
// Show inside mask: only the intersection area will be visible.
|
||||||
maskRelY = (mask.y || 0) - unionTop;
|
// Use intersection bounds to minimise the wrapper size.
|
||||||
} else if (
|
left = Math.max(objBounds.left, maskBounds.left);
|
||||||
mask.type === 'rectangle' &&
|
top = Math.max(objBounds.top, maskBounds.top);
|
||||||
(mask.shapeType === 'triangle' || mask.shapeType === 'abstract')
|
right = Math.min(objBounds.right, maskBounds.right);
|
||||||
) {
|
bottom = Math.min(objBounds.bottom, maskBounds.bottom);
|
||||||
maskRelX = (mask.x || 0) - unionLeft;
|
|
||||||
maskRelY = (mask.y || 0) - unionTop;
|
if (right <= left || bottom <= top) {
|
||||||
|
// No overlap — nothing to render.
|
||||||
|
return { left: left * scale, top: top * scale, width: 0, height: 0, maskRelX: 0, maskRelY: 0 };
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
maskRelX = (mask.x || 0) - unionLeft;
|
// Show outside mask: the full object area is the canvas.
|
||||||
maskRelY = (mask.y || 0) - unionTop;
|
// The mask shape (drawn black) will punch a hole inside it.
|
||||||
|
left = objBounds.left;
|
||||||
|
top = objBounds.top;
|
||||||
|
right = objBounds.right;
|
||||||
|
bottom = objBounds.bottom;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maskRelX = (mask.x || 0) - left;
|
||||||
|
const maskRelY = (mask.y || 0) - top;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: unionLeft * scale,
|
left: left * scale,
|
||||||
top: unionTop * scale,
|
top: top * scale,
|
||||||
width: Math.max(1, (unionRight - unionLeft) * scale),
|
width: Math.max(1, (right - left) * scale),
|
||||||
height: Math.max(1, (unionBottom - unionTop) * scale),
|
height: Math.max(1, (bottom - top) * scale),
|
||||||
maskRelX: maskRelX * scale,
|
maskRelX: maskRelX * scale,
|
||||||
maskRelY: maskRelY * scale,
|
maskRelY: maskRelY * scale,
|
||||||
};
|
};
|
||||||
@@ -199,8 +218,12 @@ const renderMaskShapeSvg = (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds CSS mask properties matching editor Konva destination-in / destination-out.
|
* Builds CSS mask properties matching editor Konva destination-in / destination-out.
|
||||||
* maskInvert === false → destination-in (show overlap only)
|
*
|
||||||
* otherwise → destination-out (hide overlap)
|
* Default (maskInvert !== false) → destination-in → show inside mask:
|
||||||
|
* bg = black (hide everything), shape = white (reveal mask area)
|
||||||
|
*
|
||||||
|
* maskInvert === false → destination-out → show outside mask (hide inside):
|
||||||
|
* bg = white (show everything), shape = black (hide mask area)
|
||||||
*/
|
*/
|
||||||
export const getMaskImageStyle = (
|
export const getMaskImageStyle = (
|
||||||
obj: EditorObject,
|
obj: EditorObject,
|
||||||
@@ -211,9 +234,10 @@ export const getMaskImageStyle = (
|
|||||||
const canvasW = layout.width;
|
const canvasW = layout.width;
|
||||||
const canvasH = layout.height;
|
const canvasH = layout.height;
|
||||||
|
|
||||||
const showOverlapOnly = obj.maskInvert === false;
|
// Default behaviour: show only inside the mask (destination-in)
|
||||||
const bgFill = showOverlapOnly ? 'black' : 'white';
|
const showInsideMask = obj.maskInvert !== false;
|
||||||
const shapeFill = showOverlapOnly ? 'white' : 'black';
|
const bgFill = showInsideMask ? 'black' : 'white';
|
||||||
|
const shapeFill = showInsideMask ? 'white' : 'black';
|
||||||
|
|
||||||
const maskShapeX = layout.maskRelX;
|
const maskShapeX = layout.maskRelX;
|
||||||
const maskShapeY = layout.maskRelY;
|
const maskShapeY = layout.maskRelY;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { PageData } from "@/pages/viewer/types";
|
||||||
|
|
||||||
|
export type PageBackgroundDefaults = {
|
||||||
|
backgroundType?: "color" | "gradient" | "image" | "video";
|
||||||
|
backgroundColor?: string;
|
||||||
|
backgroundOpacity?: number;
|
||||||
|
backgroundGradient?: { from: string; to: string; angle: number };
|
||||||
|
backgroundImageUrl?: string;
|
||||||
|
backgroundVideoUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** ترکیب پسزمینهٔ اختصاصی صفحه با مقادیر پیشفرض سند (همان قاعدهٔ استفادهشده در BookViewer) */
|
||||||
|
export function resolvePageBackground(page: PageData, defaults: PageBackgroundDefaults): Required<PageBackgroundDefaults> {
|
||||||
|
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) {
|
for (const page of pages) {
|
||||||
if (page.backgroundType === 'image') {
|
if (page.backgroundType === 'image') {
|
||||||
addImageUrl(assets, page.backgroundImageUrl);
|
addImageUrl(assets, page.backgroundImageUrl);
|
||||||
|
} else if (page.backgroundType === 'video') {
|
||||||
|
addVideoUrl(assets, page.backgroundVideoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const element of page.elements) {
|
for (const element of page.elements) {
|
||||||
if (element.visible === false) continue;
|
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);
|
addImageUrl(assets, element.imageUrl);
|
||||||
} else if (element.type === 'video') {
|
} else if (element.type === 'video') {
|
||||||
addVideoUrl(assets, element.videoUrl);
|
addVideoUrl(assets, element.videoUrl);
|
||||||
@@ -51,6 +57,8 @@ export function extractPageMediaAssets(
|
|||||||
|
|
||||||
if (documentSettings?.backgroundType === 'image') {
|
if (documentSettings?.backgroundType === 'image') {
|
||||||
addImageUrl(assets, documentSettings.backgroundImageUrl);
|
addImageUrl(assets, documentSettings.backgroundImageUrl);
|
||||||
|
} else if (documentSettings?.backgroundType === 'video') {
|
||||||
|
addVideoUrl(assets, documentSettings.backgroundVideoUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(assets.values());
|
return Array.from(assets.values());
|
||||||
|
|||||||
Reference in New Issue
Block a user