Compare commits

..

35 Commits

Author SHA1 Message Date
hamid zarghami 677c62e6eb fix extra light weight
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-11 09:39:03 +03:30
hamid zarghami e0eb29488a position x and y in line 2026-07-11 09:31:40 +03:30
hamid zarghami ae3787241a object box smaller 2026-07-11 09:21:13 +03:30
hamid zarghami 9e105606a3 fix blur in viewer 2026-07-11 09:10:41 +03:30
hamid zarghami c6bb275d05 fix order in blur layers + fix aligment 2026-07-11 09:05:13 +03:30
hamid zarghami 4273e899ca cursor ignore
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-08 16:44:46 +03:30
hamid zarghami 0361e05acb blur
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-08 16:42:55 +03:30
hamid zarghami 33cce93064 bg opcity
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-08 15:50:21 +03:30
hamid zarghami a2a591420c color opacity
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-08 09:21:41 +03:30
hamid zarghami 3c764a1652 spinner to loading page compeletely
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-05 16:26:37 +03:30
hamid zarghami 954ad48486 ankle gredient 2026-07-05 16:18:57 +03:30
hamid zarghami 368cace143 border radius for all shape 2026-07-05 15:52:39 +03:30
hamid zarghami 46926c66e7 blue
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-04 14:51:24 +03:30
hamid zarghami a448ff10de rtl in mobile 2026-07-04 14:45:44 +03:30
hamid zarghami 80a92ea4c4 fix in mobile 2026-07-04 14:43:51 +03:30
hamid zarghami 63a774c4af fix repeat animation 2026-07-04 14:21:27 +03:30
hamid zarghami 1ed198ca69 add max width in text
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-02 12:23:58 +03:30
hamid zarghami d94bf77522 background video 2026-07-02 12:08:29 +03:30
hamid zarghami 5e314bb581 add skill
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-01 16:54:06 +03:30
hamid zarghami 0391513928 next and prev
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-01 16:30:23 +03:30
hamid zarghami 22d15f9a13 progressbar uploading
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-01 16:27:21 +03:30
hamid zarghami bbb797cc62 border z-index than paper 2026-07-01 16:21:43 +03:30
hamid zarghami 4af7181384 fix animation 2026-07-01 16:17:03 +03:30
hamid zarghami cda1b5b5bd fix viewer in even pages
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-01 15:11:47 +03:30
hamid zarghami 2b4fd31ea2 fix diffrent size in editor and viewer
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-01 11:53:30 +03:30
hamid zarghami 168af3bf95 pages video fix 2026-07-01 11:23:11 +03:30
hamid zarghami 0bef54c51c pages list in editor 2026-07-01 11:20:55 +03:30
hamid zarghami 85ba8e4261 magnifier
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-01 11:17:08 +03:30
hamid zarghami 39e18c3331 RTL Trick
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-22 16:44:00 +03:30
hamid zarghami ca02a621da paint
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-20 15:32:02 +03:30
hamid zarghami b13a46ec65 auto save fix
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-20 12:59:39 +03:30
hamid zarghami e93ab38b05 mask 2026-06-20 12:19:23 +03:30
hamid zarghami 052b737653 change design alignmentsetting 2026-06-20 11:35:37 +03:30
hamid zarghami b8de25e9d1 fix big size image
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-15 16:40:08 +03:30
hamid zarghami 8b194fd7cd change icon
deploy to danak / build_and_deploy (push) Has been cancelled
2026-06-15 14:15:57 +03:30
91 changed files with 4554 additions and 1342 deletions
+116
View File
@@ -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`
+35
View File
@@ -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
+147 -120
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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}
+12
View File
@@ -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) ? (
+26
View File
@@ -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;
+51 -17
View File
@@ -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>
); );
}; };
+15
View File
@@ -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>
); );
+12 -4
View File
@@ -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 {
+72 -13
View File
@@ -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
} }
+90 -23
View File
@@ -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,7 +1,8 @@
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";
import { getNodeTransformDimensions } from "../../utils/transformCommit";
import { import {
RectangleShape, RectangleShape,
CircleShape, CircleShape,
@@ -15,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";
@@ -55,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();
}); });
@@ -80,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();
@@ -99,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;
} }
@@ -215,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} />;
@@ -233,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 = (
@@ -261,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
@@ -316,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;
@@ -327,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;
@@ -404,9 +417,10 @@ const ObjectRenderer = ({
newWidth = Math.max(5, (obj.width || 100) * scaleX); newWidth = Math.max(5, (obj.width || 100) * scaleX);
newHeight = Math.max(5, (obj.height || 100) * scaleY); newHeight = Math.max(5, (obj.height || 100) * scaleY);
} else { } else {
// Default for other objects (image, etc.) const { width: baseWidth, height: baseHeight } =
newWidth = Math.max(5, (obj.width || 100) * scaleX); getNodeTransformDimensions(node);
newHeight = Math.max(5, (obj.height || 100) * scaleY); newWidth = Math.max(5, baseWidth * scaleX);
newHeight = Math.max(5, baseHeight * scaleY);
} }
// به‌روزرسانی object // به‌روزرسانی object
@@ -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,
}; };
}; };
@@ -1,6 +1,11 @@
import { useEffect, type RefObject } from "react"; import { useEffect, useRef, type RefObject } from "react";
import Konva from "konva"; import Konva from "konva";
import { useEditorStore } from "../../store/editorStore"; import { useEditorStore } from "../../store/editorStore";
import {
getNodeTransformDimensions,
nodeHasPendingTransform,
resolveObjectFromNodeId,
} from "../../utils/transformCommit";
export const useTransformHandlers = ( export const useTransformHandlers = (
transformerRef: RefObject<Konva.Transformer | null>, transformerRef: RefObject<Konva.Transformer | null>,
@@ -12,6 +17,7 @@ export const useTransformHandlers = (
updateObject, updateObject,
applyTableResize, applyTableResize,
} = useEditorStore(); } = useEditorStore();
const commitTransformRef = useRef<(() => void) | null>(null);
// Attach transformer to selected objects when they change // Attach transformer to selected objects when they change
useEffect(() => { useEffect(() => {
@@ -60,7 +66,15 @@ export const useTransformHandlers = (
} }
}, 100); }, 100);
return () => clearTimeout(timeoutId); return () => {
clearTimeout(timeoutId);
const transformer = transformerRef.current;
if (!transformer) return;
const nodes = transformer.nodes();
if (nodes.some(nodeHasPendingTransform)) {
commitTransformRef.current?.();
}
};
}, [selectedObjectIds, objects, transformerRef, layerRef]); }, [selectedObjectIds, objects, transformerRef, layerRef]);
useEffect(() => { useEffect(() => {
@@ -148,7 +162,7 @@ export const useTransformHandlers = (
nodes.forEach((node) => { nodes.forEach((node) => {
const nodeId = node.id(); const nodeId = node.id();
const selectedObject = objects.find((obj) => obj.id === nodeId); const selectedObject = resolveObjectFromNodeId(nodeId, objects);
if (!selectedObject) return; if (!selectedObject) return;
// Handle group objects // Handle group objects
@@ -481,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) =>
@@ -505,23 +519,28 @@ export const useTransformHandlers = (
height: Math.max(1, newHeight), height: Math.max(1, newHeight),
}); });
} else { } else {
// Default for other objects (image, etc.) // Default for raster objects (image, sticker, document, video, …)
const { width: baseWidth, height: baseHeight } =
getNodeTransformDimensions(node);
updateObject(selectedObject.id, { updateObject(selectedObject.id, {
x: node.x(), x: node.x(),
y: node.y(), y: node.y(),
rotation: node.rotation(), rotation: node.rotation(),
width: Math.max(5, (selectedObject.width || 100) * scaleX), width: Math.max(5, baseWidth * scaleX),
height: Math.max(5, (selectedObject.height || 100) * scaleY), height: Math.max(5, baseHeight * scaleY),
}); });
} }
} }
}); });
}; };
commitTransformRef.current = handleTransformEnd;
transformer.on("transform", handleTransform); transformer.on("transform", handleTransform);
transformer.on("transformend", handleTransformEnd); transformer.on("transformend", handleTransformEnd);
return () => { return () => {
commitTransformRef.current = null;
transformer.off("transform", handleTransform); transformer.off("transform", handleTransform);
transformer.off("transformend", handleTransformEnd); transformer.off("transformend", handleTransformEnd);
}; };
@@ -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;
@@ -1,101 +1,97 @@
import { type FC } from "react";
import { ArrowLeft, Magicpen, Minus } from "iconsax-react";
import CustomShapeInstruction from "./CustomShapeInstruction";
import SquareIcon from "@/assets/icons/square.svg";
import CircleIcon from "@/assets/icons/circle.svg"; import CircleIcon from "@/assets/icons/circle.svg";
import TriangleIcon from "@/assets/icons/triangle.svg"; import SquareIcon from "@/assets/icons/square.svg";
import StarIcon from "@/assets/icons/Star.svg"; import StarIcon from "@/assets/icons/Star.svg";
import TriangleIcon from "@/assets/icons/triangle.svg";
import { Switch } from "@/components/ui/switch"; 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, Brush, Minus, PenTool } from "iconsax-react";
import { type FC } from "react";
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" },
{ id: "circle", label: "دایره", icon: CircleIcon, alt: "circle" }, { id: "circle", label: "دایره", icon: CircleIcon, alt: "circle" },
{ id: "triangle", label: "مثلث", icon: TriangleIcon, alt: "triangle" }, { id: "triangle", label: "مثلث", icon: TriangleIcon, alt: "triangle" },
{ id: "abstract", label: "انتزاعی", icon: StarIcon, alt: "abstract" }, { id: "abstract", label: "انتزاعی", icon: StarIcon, alt: "abstract" },
]; ];
const drawOptions = [ const drawOptions = [
{ tool: "line" as const, label: "خط", Icon: Minus }, { tool: "pencil" as const, label: "قلم", Icon: Brush },
{ tool: "arrow" as const, label: "پیکان", Icon: ArrowLeft }, { tool: "line" as const, label: "خط", Icon: Minus },
{ tool: "custom-shape" as const, label: "سفارشی", Icon: Magicpen }, { tool: "arrow" as const, label: "پیکان", Icon: ArrowLeft },
{ tool: "custom-shape" as const, label: "سفارشی", Icon: PenTool },
]; ];
const RectangleInstruction: FC = () => { const RectangleInstruction: FC = () => {
const tool = useEditorStore((state) => state.tool); const tool = useEditorStore((state) => state.tool);
const setTool = useEditorStore((state) => state.setTool); const setTool = useEditorStore((state) => state.setTool);
const { activeShape, setActiveShape, useAsMask, setUseAsMask } = useShapeStore(); const { activeShape, setActiveShape, useAsMask, setUseAsMask } = useShapeStore();
const handleShapeClick = (shapeId: ShapeType) => { const handleShapeClick = (shapeId: ShapeType) => {
setActiveShape(shapeId); setActiveShape(shapeId);
setTool("rectangle"); setTool("rectangle");
}; };
return ( return (
<div> <div>
<h2 className="font-bold">اشکال</h2> <h2 className="font-bold">اشکال</h2>
<div className="mt-7 flex flex-wrap gap-3 text-[#B2B2B2]"> <div className="mt-7 flex flex-wrap gap-3 text-[#B2B2B2]">
{shapeOptions.map((shape) => { {shapeOptions.map((shape) => {
const isActive = tool === "rectangle" && activeShape === shape.id; const isActive = tool === "rectangle" && activeShape === shape.id;
return ( return (
<button <button
type="button" type="button"
key={shape.id} key={shape.id}
onClick={() => handleShapeClick(shape.id)} onClick={() => handleShapeClick(shape.id)}
className={clx( className={clx("size-[60px] flex flex-col gap-1 justify-center items-center rounded-xl text-[13px] transition-colors", isActive ? "" : "border-transparent")}
"size-[60px] flex flex-col gap-1 justify-center items-center rounded-xl text-[13px] transition-colors", aria-pressed={isActive}
isActive ? "" : "border-transparent", >
)} <img src={shape.icon} alt={shape.alt} className={clx("w-6", isActive && "filterBlack")} />
aria-pressed={isActive} <div className={clx(isActive ? "text-black" : "text-[#7A7A7A]")}>{shape.label}</div>
> </button>
<img src={shape.icon} alt={shape.alt} className={clx("w-6", isActive && "filterBlack")} /> );
<div className={clx(isActive ? "text-black" : "text-[#7A7A7A]")}>{shape.label}</div> })}
</button> {drawOptions.map(({ tool: drawTool, label, Icon }) => {
); const isActive = tool === drawTool;
})} return (
{drawOptions.map(({ tool: drawTool, label, Icon }) => { <button
const isActive = tool === drawTool; type="button"
return ( key={drawTool}
<button onClick={() => setTool(drawTool)}
type="button" className={clx("size-[60px] flex flex-col gap-1 justify-center items-center rounded-xl text-[13px] transition-colors", isActive ? "" : "border-transparent")}
key={drawTool} aria-pressed={isActive}
onClick={() => setTool(drawTool)} >
className={clx( <Icon size={24} color={isActive ? "black" : "#7A7A7A"} variant={isActive ? "Bold" : "Outline"} />
"size-[60px] flex flex-col gap-1 justify-center items-center rounded-xl text-[13px] transition-colors", <div className={clx(isActive ? "text-black" : "text-[#7A7A7A]")}>{label}</div>
isActive ? "" : "border-transparent", </button>
)} );
aria-pressed={isActive} })}
> </div>
<Icon size={24} color={isActive ? "black" : "#7A7A7A"} variant={isActive ? "Bold" : "Outline"} />
<div className={clx(isActive ? "text-black" : "text-[#7A7A7A]")}>{label}</div>
</button>
);
})}
</div>
{tool === "rectangle" && ( {tool === "rectangle" && (
<div className="mt-9 flex items-center justify-between"> <div className="mt-9 flex items-center justify-between">
<div className="text-[13px]">استفاده برای ماسک</div> <div className="text-[13px]">استفاده برای ماسک</div>
<Switch checked={useAsMask} onCheckedChange={setUseAsMask} /> <Switch checked={useAsMask} onCheckedChange={setUseAsMask} />
</div>
)}
{(tool === "line" || tool === "arrow") && (
<p className="mt-9 text-sm text-gray-600">
{tool === "line" ? "برای رسم خط، روی کانوس کلیک کرده و بکشید" : "برای رسم پیکان، روی کانوس کلیک کرده و بکشید"}
</p>
)}
{tool === "custom-shape" && (
<div className="mt-9">
<CustomShapeInstruction />
</div>
)}
</div> </div>
); )}
{(tool === "line" || tool === "arrow") && (
<p className="mt-9 text-sm text-gray-600">{tool === "line" ? "برای رسم خط، روی کانوس کلیک کرده و بکشید" : "برای رسم پیکان، روی کانوس کلیک کرده و بکشید"}</p>
)}
{tool === "pencil" && <PencilInstruction />}
{tool === "custom-shape" && (
<div className="mt-9">
<CustomShapeInstruction />
</div>
)}
</div>
);
}; };
export default RectangleInstruction; export default RectangleInstruction;
@@ -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) {
+47 -32
View File
@@ -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);
+39
View File
@@ -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;
+15 -2
View File
@@ -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 },
})),
}));
+178
View File
@@ -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;
}
+33
View File
@@ -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;
};
+28
View File
@@ -0,0 +1,28 @@
/** Converts a hex color + 0100 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})`;
};
+41
View File
@@ -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;
};
+3 -2
View File
@@ -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 = {
+12 -7
View File
@@ -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%)`;
}; };
+42
View File
@@ -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]);
}
+47 -8
View File
@@ -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;
+47
View File
@@ -0,0 +1,47 @@
import Konva from "konva";
import type { EditorObject } from "../store/editorStore";
/** Resolve editor object from a Konva node id (supports masked-* groups). */
export const resolveObjectFromNodeId = (
nodeId: string,
objects: EditorObject[],
): EditorObject | undefined => {
if (nodeId.startsWith("masked-")) {
const objectId = nodeId.slice("masked-".length);
return objects.find((obj) => obj.id === objectId);
}
return objects.find((obj) => obj.id === nodeId);
};
/**
* Base width/height used for transform baking.
* For images, Konva may use natural image size while store width is still unset.
*/
export const getNodeTransformDimensions = (
node: Konva.Node,
): { width: number; height: number } => {
const nodeWidth = node.width();
const nodeHeight = node.height();
if (nodeWidth > 0 && nodeHeight > 0) {
return { width: nodeWidth, height: nodeHeight };
}
const imageNode =
"findOne" in node
? (node as Konva.Container).findOne<Konva.Image>("Image")
: undefined;
if (imageNode && imageNode.width() > 0 && imageNode.height() > 0) {
return { width: imageNode.width(), height: imageNode.height() };
}
const rect = node.getClientRect({ skipTransform: true });
if (rect.width > 0 && rect.height > 0) {
return { width: rect.width, height: rect.height };
}
return { width: 100, height: 100 };
};
export const nodeHasPendingTransform = (node: Konva.Node): boolean =>
Math.abs(node.scaleX() - 1) > 0.0001 ||
Math.abs(node.scaleY() - 1) > 0.0001;
+7 -1
View File
@@ -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),
}); });
}; };
+10 -2
View File
@@ -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;
}; };
+358 -60
View File
@@ -1,21 +1,56 @@
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 = (
obj: EditorObject,
scale: number,
baseStyle: React.CSSProperties,
index: number,
): React.CSSProperties => {
const scaleX = obj.scaleX ?? 1;
const scaleY = obj.scaleY ?? 1;
const width = obj.width != null ? obj.width * scaleX * scale : undefined;
const height = obj.height != null ? obj.height * scaleY * scale : undefined;
const borderRadius = getCssBorderRadius(obj.borderRadius, scale);
return {
...baseStyle,
display: 'block',
maxWidth: 'none',
width: width != null ? `${width}px` : 'auto',
height: height != null ? `${height}px` : 'auto',
objectFit: 'fill',
zIndex: index,
...(borderRadius ? { borderRadius, overflow: 'hidden' as const } : {}),
};
};
type RenderObjectOptions = { type RenderObjectOptions = {
skipEntrance?: boolean; skipEntrance?: boolean;
/** در پیش‌نمایش (لیست صفحات و کاتالوگ) رسانه تعاملی نباشد */
staticMedia?: boolean;
}; };
type BookPageProps = { type BookPageProps = {
@@ -24,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;
}; };
/** /**
@@ -49,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 تبدیل شده است
@@ -95,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,
}); });
@@ -135,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) }
@@ -166,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),
@@ -181,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,
}, },
@@ -200,26 +313,56 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
case 'image': case 'image':
case 'sticker': case 'sticker':
case 'document':
return ( return (
<img <img
key={obj.id || index} key={obj.id || index}
src={obj.imageUrl || ''} src={obj.imageUrl || ''}
alt="" alt=""
style={applyStyle( style={applyStyle(
{ getRasterObjectStyle(obj, scale, baseStyle, index),
...baseStyle,
width: obj.width ? `${obj.width * scale}px` : 'auto',
height: obj.height ? `${obj.height * scale}px` : 'auto',
objectFit: 'fill', // مشابه Konva که image را با width و height مشخص شده رندر می‌کند
zIndex: index, // برای حفظ ترتیب رندر
},
obj, obj,
index, index,
)} )}
/> />
); );
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}
@@ -227,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,
@@ -248,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}
@@ -258,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,
@@ -278,6 +440,7 @@ const BookPage = forwardRef<HTMLDivElement, BookPageProps>(
}} }}
/> />
); );
}
case 'link': { case 'link': {
const isInternalLink = obj.linkUrl?.startsWith('page://'); const isInternalLink = obj.linkUrl?.startsWith('page://');
@@ -431,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
@@ -455,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}
/> />
@@ -642,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')
@@ -703,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;
@@ -734,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',
@@ -764,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);
@@ -774,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;
@@ -803,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
? { ? {
@@ -820,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',
@@ -835,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;
}
+3 -1
View File
@@ -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;
}; };
+28 -5
View File
@@ -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,11 +128,17 @@ 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 ((obj.type === "image" || obj.type === "sticker") && obj.imageUrl) { if (
(obj.type === "image" || obj.type === "sticker" || obj.type === "document") &&
obj.imageUrl
) {
baseObject.imageUrl = obj.imageUrl; baseObject.imageUrl = obj.imageUrl;
} }
@@ -173,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 {
@@ -183,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,
}; };
}); });
} }
+50 -26
View File
@@ -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;
+22
View File
@@ -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 ?? "",
};
}
+9 -1
View File
@@ -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());