├── .browserslistrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .hintrc ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc ├── .prettierignore ├── .prettierrc ├── .stylelintignore ├── .stylelintrc ├── README.md ├── babel.config.js ├── commitlint.config.cjs ├── index.html ├── lib.d.ts ├── package-lock.json ├── package.json ├── packages ├── EditorTools │ ├── EditorBox │ │ ├── EditorBox.less │ │ ├── EditorBox.tsx │ │ ├── index.ts │ │ └── props.ts │ ├── MagneticLine │ │ ├── MagneticLine.less │ │ ├── MagneticLine.tsx │ │ ├── index.ts │ │ └── props.ts │ ├── constants │ │ ├── AxleDirection.ts │ │ ├── EditorBox.ts │ │ ├── Magnetic.ts │ │ └── Points.ts │ ├── core │ │ ├── MagneticLineHandler.ts │ │ ├── MaskContainScaleHandler.ts │ │ ├── MaskCoverScaleHandler.ts │ │ ├── RotateHandler.ts │ │ ├── ScaleHandler.ts │ │ └── dragAction.ts │ ├── enum │ │ └── point-type.ts │ ├── helper │ │ ├── magneticLine.ts │ │ ├── math.ts │ │ └── utils.ts │ ├── index.ts │ └── types │ │ ├── Editor.ts │ │ └── MagneticLine.ts └── Screenshot │ ├── CloneNode.ts │ ├── CopyCssStyles.ts │ ├── CreateContext.ts │ ├── Document.ts │ ├── Fetch.ts │ ├── File.ts │ ├── ImageToBlob.ts │ ├── Screenshot.ts │ ├── Svg.ts │ └── index.ts ├── public └── magic.svg ├── scripts └── prepare.js ├── src ├── App.tsx ├── assets │ └── styles │ │ ├── common.less │ │ ├── index.less │ │ ├── reset.less │ │ └── setting.less ├── components │ ├── ContextMenu │ │ ├── ContextMenu.module.less │ │ ├── ContextMenu.tsx │ │ ├── ContextMenuContent.tsx │ │ ├── MenuItemComponent.tsx │ │ ├── SubMenu.tsx │ │ ├── index.ts │ │ └── props.ts │ ├── Crop │ │ ├── Crop.module.less │ │ ├── Crop.tsx │ │ └── index.ts │ ├── Editor │ │ ├── Editor.module.less │ │ ├── Editor.tsx │ │ ├── EditorControl │ │ │ ├── EditorControl.module.less │ │ │ ├── EditorControl.tsx │ │ │ └── index.ts │ │ ├── Hover │ │ │ ├── Hover.module.less │ │ │ ├── Hover.tsx │ │ │ └── index.ts │ │ ├── RichText │ │ │ ├── RichText.module.less │ │ │ ├── RichText.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── LayerBaseSetting │ │ ├── Flip │ │ │ ├── Flip.module.less │ │ │ ├── Flip.tsx │ │ │ └── index.ts │ │ ├── LayerBaseSetting.module.less │ │ ├── LayerBaseSetting.tsx │ │ ├── LayerPosition │ │ │ ├── LayerPosition.module.less │ │ │ ├── LayerPosition.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── MenuPopover │ │ ├── MenuPopover.module.less │ │ ├── MenuPopover.tsx │ │ └── index.ts │ ├── Renderer │ │ ├── Layer │ │ │ ├── Back │ │ │ │ ├── Back.module.less │ │ │ │ ├── Back.tsx │ │ │ │ └── index.ts │ │ │ ├── Group │ │ │ │ ├── Group.tsx │ │ │ │ └── index.ts │ │ │ ├── Image │ │ │ │ ├── Image.module.less │ │ │ │ ├── Image.tsx │ │ │ │ └── index.ts │ │ │ ├── Layer.module.less │ │ │ ├── Layer.tsx │ │ │ ├── Shape │ │ │ │ ├── Shape.tsx │ │ │ │ └── index.ts │ │ │ ├── Text │ │ │ │ ├── Text.module.less │ │ │ │ ├── Text.tsx │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── Renderer.module.less │ │ ├── Renderer.tsx │ │ └── index.ts │ ├── SettingContainer │ │ ├── SettingContainer.module.less │ │ ├── SettingContainer.tsx │ │ └── index.ts │ ├── SliderNumberInput │ │ ├── SliderNumberInput.module.less │ │ ├── SliderNumberInput.tsx │ │ └── index.ts │ └── Upload │ │ ├── Upload.tsx │ │ └── index.ts ├── config │ ├── Cmd.ts │ ├── ColorList.ts │ ├── DefaultValues.ts │ ├── Fonts.ts │ ├── HotKeys.ts │ ├── Mocks.ts │ ├── Shape.ts │ └── StageContextMenu.tsx ├── constants │ ├── CacheKeys.ts │ ├── CmdEnum.ts │ ├── Device.ts │ ├── Font.ts │ ├── FontSize.ts │ ├── HotKeyScope.ts │ ├── KeyCode.ts │ ├── LayerRatio.ts │ ├── LayerTypeEnum.ts │ ├── MaterialEnum.ts │ ├── MimeTypes.ts │ ├── NodeNamePlate.ts │ ├── PointList.ts │ ├── Refs.ts │ ├── TemplateSize.ts │ └── ZoomLevel.ts ├── core │ ├── Decorator │ │ └── History.ts │ ├── FormatData │ │ ├── Layer.ts │ │ └── Scene.ts │ ├── Manager │ │ ├── Clipboard.ts │ │ ├── Cmd.ts │ │ ├── ContextMenuManager.ts │ │ ├── History.ts │ │ ├── Keyboard.ts │ │ └── LocalCache.ts │ └── Tools │ │ └── CropMove.ts ├── helpers │ ├── Chain.ts │ ├── Crop.ts │ ├── HotKey.ts │ ├── Node.ts │ ├── Obb.ts │ └── Styles.ts ├── hooks │ ├── index.ts │ ├── useEscapeClose.ts │ ├── useGlobalClick.ts │ └── useResizeObserver.ts ├── layout │ ├── Header │ │ ├── Header.module.less │ │ ├── Header.tsx │ │ └── index.ts │ ├── Layout.module.less │ ├── Layout.tsx │ ├── Material │ │ ├── Content │ │ │ ├── Back │ │ │ │ ├── Back.module.less │ │ │ │ ├── Back.tsx │ │ │ │ └── index.ts │ │ │ ├── Image │ │ │ │ ├── Image.tsx │ │ │ │ └── index.ts │ │ │ ├── Shape │ │ │ │ ├── Shape.module.less │ │ │ │ ├── Shape.tsx │ │ │ │ └── index.ts │ │ │ └── Text │ │ │ │ ├── Text.module.less │ │ │ │ ├── Text.tsx │ │ │ │ └── index.ts │ │ ├── Material.module.less │ │ ├── Material.tsx │ │ ├── SidebarMenu │ │ │ ├── SidebarMenu.module.less │ │ │ ├── SidebarMenu.tsx │ │ │ └── index.ts │ │ ├── constants.ts │ │ └── index.ts │ ├── Setting │ │ ├── Canvas │ │ │ ├── Canvas.module.less │ │ │ ├── Canvas.tsx │ │ │ └── index.ts │ │ ├── Group │ │ │ ├── Group.tsx │ │ │ └── index.ts │ │ ├── Image │ │ │ ├── Image.module.less │ │ │ ├── Image.tsx │ │ │ └── index.ts │ │ ├── Setting.module.less │ │ ├── Setting.tsx │ │ ├── Shape │ │ │ ├── Shape.tsx │ │ │ └── index.ts │ │ ├── Text │ │ │ ├── Text.module.less │ │ │ ├── Text.tsx │ │ │ ├── TextAlign.tsx │ │ │ ├── TextColor.tsx │ │ │ ├── TextFamilyWithSize.tsx │ │ │ ├── TextStyle.tsx │ │ │ └── index.ts │ │ └── index.ts │ ├── Stage │ │ ├── Canvas │ │ │ ├── Canvas.module.less │ │ │ ├── Canvas.tsx │ │ │ └── index.ts │ │ ├── Scenes │ │ │ ├── Scene │ │ │ │ ├── Scene.module.less │ │ │ │ ├── Scene.tsx │ │ │ │ └── index.ts │ │ │ ├── Scenes.module.less │ │ │ ├── Scenes.tsx │ │ │ └── index.ts │ │ ├── Stage.module.less │ │ ├── Stage.tsx │ │ └── index.ts │ └── index.ts ├── main.tsx ├── models │ ├── FactoryStruc │ │ ├── LayerFactory.ts │ │ └── SceneFactory.ts │ ├── LayerStruc │ │ ├── BackgroundStruc.ts │ │ ├── GroupStruc.ts │ │ ├── ImageStruc.ts │ │ ├── LayerStruc.ts │ │ ├── ShapeStruc.ts │ │ ├── TextStruc.ts │ │ └── index.ts │ ├── MagicStruc │ │ ├── MagicStruc.ts │ │ └── index.ts │ └── SceneStruc │ │ ├── SceneStruc.ts │ │ └── index.ts ├── store │ ├── Font.ts │ ├── History.ts │ ├── Magic.ts │ ├── Material.ts │ ├── OS.ts │ └── index.ts ├── types │ ├── canvas.ts │ ├── componentProps.ts │ ├── history.ts │ ├── magic.d.ts │ ├── material.ts │ ├── model.ts │ └── updateOptions.ts └── utils │ ├── charAttrs.ts │ ├── collision.ts │ ├── copyText.ts │ ├── download.ts │ ├── equals.ts │ ├── file.ts │ ├── filterData.ts │ ├── font.ts │ ├── getPreviewSizePosition.ts │ ├── getRectData.ts │ ├── image.ts │ ├── layers.ts │ ├── logo.ts │ ├── mergeData.ts │ ├── move.ts │ ├── penetration.ts │ ├── portalRender.ts │ └── random.ts ├── tsconfig.image.json ├── tsconfig.json ├── tsconfig.node.json ├── vite-env.d.ts └── vite.config.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | Chrome >= 45 2 | ie >= 10 3 | > 1% 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = true 10 | max_line_length = 80 11 | 12 | [*.md] 13 | max_line_length = 0 14 | trim_trailing_whitespace = false 15 | 16 | [COMMIT_EDITMSG] 17 | max_line_length = 0 18 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintcache 2 | .stylelintcache 3 | .vscode 4 | dist 5 | node_modules 6 | package-lock.json 7 | scripts 8 | src/utils/*.js -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: [push] 3 | jobs: 4 | build-and-deploy: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 🛎️ 8 | uses: actions/checkout@v2 9 | 10 | - name: Install and Build 🔧 11 | run: | 12 | npm install --force 13 | npm run build 14 | 15 | - name: Deploy 🚀 16 | uses: JamesIves/github-pages-deploy-action@releases/v3 17 | with: 18 | ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }} 19 | BRANCH: gh-pages 20 | FOLDER: dist 21 | # 整个流程在master分支发生push事件时触发。 22 | # 只有一个job,运行在虚拟机环境ubuntu-latest。 23 | # 第一步是获取源码,使用的 action 是actions/checkout@v2。 24 | # 第二步是安装依赖和打包 25 | # 第三步发布,需要三个环境变量,分别为 GitHub 密钥、发布分支、构建成果所在目录。其中,只有 GitHub 密钥是秘密变量,需要写在双括号里面,其他三个都可以直接写在文件里。 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .eslintcache 26 | .stylelintcache -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["development"], 3 | "hints": { 4 | "no-inline-styles": "off", 5 | "compat-api/html": "off", 6 | "axe/text-alternatives": "off", 7 | "compat-api/css": "off" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run commitlint 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{css,less}": "stylelint --fix --aei", 3 | "*.{jsx,ts,tsx}": "eslint --fix", 4 | "*.{css,less,jsx,ts,tsx,json,yml,yaml,md}": "prettier --write" 5 | } -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .eslintcache 4 | .stylelintcache 5 | .vscode 6 | .history 7 | dist 8 | node_modules 9 | package-lock.json 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "jsxSingleQuote": false, 8 | "bracketSpacing": true, 9 | "trailingComma": "es5", 10 | "arrowParens": "avoid", 11 | "endOfLine": "lf" 12 | } -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .eslintcache 4 | .stylelintcache 5 | .vscode 6 | .history 7 | dist 8 | node_modules 9 | package-lock.json 10 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-rational-order", 5 | "stylelint-config-prettier" 6 | ], 7 | "plugins": [ 8 | "stylelint-order", 9 | "stylelint-config-rational-order/plugin" 10 | ], 11 | "rules": { 12 | "no-empty-source": null, 13 | "order/properties-order": null, 14 | "no-descending-specificity": null, 15 | "color-function-notation": null, 16 | "alpha-value-notation": null, 17 | "plugin/rational-order": [ 18 | true, 19 | { 20 | "border-in-box-model": false, 21 | "empty-line-between-groups": false 22 | } 23 | ], 24 | "selector-pseudo-class-no-unknown": [ 25 | true, 26 | { 27 | "ignorePseudoClasses": [ 28 | "host", 29 | "global" 30 | ] 31 | } 32 | ], 33 | "selector-class-pattern": null, 34 | "at-rule-no-unknown": null, 35 | "font-family-no-missing-generic-family-keyword": null 36 | }, 37 | "customSyntax": "postcss-less", 38 | "ignoreFiles": [ 39 | "**/*.js", 40 | "**/*.jsx", 41 | "**/*.ts", 42 | "**/*.tsx" 43 | ] 44 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magic 2 | 3 | ## 介绍 4 | 5 | 一个简易版的在线图片编辑器,其功能设计和样式参考了 “稿定设计” 和 “canva”,主要用于个人的学习与技术沉淀 6 | **在线体验地址:** https://qiangqiang-id.github.io/magic/ 7 | 8 | ### 技术栈 9 | 10 | - React 11 | - Typescript 12 | - Mobx 13 | - Vite 14 | - Ant-Design 15 | 16 | ## 功能列表 17 | 18 | **基础功能** 19 | 20 | - [x] 拉伸 21 | - [x] 旋转 22 | - [x] 辅助线 23 | - [ ] 历史记录(撤销、重做) 24 | - [x] 快捷键 25 | 26 | **场景页** 27 | 28 | - [x] 拖拽排序 29 | - [x] 删除 30 | - [x] 新增 31 | - [x] 复制 32 | 33 | **画布** 34 | 35 | - [x] 背景设置 36 | - [ ] 画布大小设置 37 | - [x] 右键菜单 38 | - [ ] 设置缩放比 39 | - [x] 选择图层,透明图片点击穿透 40 | 41 | **图层基础功能** 42 | 43 | - [x] 图层位置(层级) 44 | - [x] 删除 45 | - [x] 复制 46 | - [x] 翻转 47 | - [x] 锁定 48 | - [x] 不透明度 49 | 50 | **图片** 51 | 52 | - [x] 蒙层裁剪 53 | - [ ] 滤镜 54 | - [x] 替换图片 55 | - [ ] 自定义美化 56 | 57 | **文字** 58 | 59 | - [x] 字体设置 60 | - [x] 字体大小 61 | - [ ] 字体风格(加粗、斜体、删除线、下划线,支持局部设置) 62 | - [x] 对齐方式 63 | - [ ] 字间距 64 | - [ ] 颜色(支持局部设置,支持渐变设置) 65 | - [ ] 描边 66 | - [ ] 背景(支持局部设置,支持渐变设置) 67 | 68 | **形状** 69 | 70 | - [ ] 填充 71 | - [ ] 描边(粗细、颜色、描边类型) 72 | - [ ] 圆角 73 | 74 | **进阶** 75 | 76 | - [ ] 多选 77 | - [ ] 打组 78 | - [ ] 生成 Gif 图 79 | - [ ] psd 解析 80 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | ignoreBrowserslistConfig: true, 10 | }, 11 | ], 12 | '@babel/preset-typescript', 13 | '@babel/preset-react', 14 | ], 15 | plugins: [ 16 | ['@babel/plugin-proposal-decorators', { legacy: true }], 17 | ['@babel/plugin-proposal-nullish-coalescing-operator'], 18 | ['@babel/plugin-proposal-optional-chaining'], 19 | ['@babel/plugin-proposal-class-properties'], 20 | ['@babel/plugin-proposal-private-methods'], 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | magic编辑器 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /lib.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css' { 2 | const style: Record; 3 | export default style; 4 | } 5 | 6 | declare module '*.less' { 7 | const style: Record; 8 | export default style; 9 | } 10 | 11 | declare module '*.svg' { 12 | const content: string; 13 | export default content; 14 | } 15 | 16 | declare module '*.png' { 17 | const image: string; 18 | export default image; 19 | } 20 | 21 | declare module '*.jpg' { 22 | const image: string; 23 | export default image; 24 | } 25 | 26 | declare module '*.jpeg' { 27 | const image: string; 28 | export default image; 29 | } 30 | 31 | declare module '*.gif' { 32 | const image: string; 33 | export default image; 34 | } 35 | 36 | declare interface Size { 37 | width: number; 38 | height: number; 39 | } 40 | 41 | declare interface Point { 42 | x: number; 43 | y: number; 44 | } 45 | 46 | declare interface Rect { 47 | width: number; 48 | height: number; 49 | x: number; 50 | y: number; 51 | } 52 | 53 | declare interface Element { 54 | webkitRequestFullscreen(options?: FullscreenOptions): Promise; 55 | mozRequestFullScreen(options?: FullscreenOptions): Promise; 56 | msRequestFullScreen(options?: FullscreenOptions): Promise; 57 | } 58 | 59 | declare interface Document { 60 | webkitFullscreenElement: Element | null; 61 | mozFullScreenElement: Element | null; 62 | msFullscreenElement: Element | null; 63 | webkitExitFullscreen(): Promise; 64 | webkitCancelFullScreen(): Promise; 65 | mozCancelFullScreen(): Promise; 66 | msExitFullscreen(): Promise; 67 | } 68 | -------------------------------------------------------------------------------- /packages/EditorTools/EditorBox/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './EditorBox'; 2 | -------------------------------------------------------------------------------- /packages/EditorTools/EditorBox/props.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { RectData } from '../types/Editor'; 3 | import { ScaleHandlerOptions } from '../core/ScaleHandler'; 4 | import { POINT_TYPE } from '../enum/point-type'; 5 | 6 | /** 7 | * 鼠标事件属性 8 | */ 9 | export interface MouseEventProps { 10 | /** 鼠标点击时 */ 11 | onClick?: (e: React.MouseEvent) => void; 12 | /** 鼠标双击时 */ 13 | onDoubleClick?: (e: React.MouseEvent) => void; 14 | /** 鼠标右击时 */ 15 | onContextMenu?: (e: React.MouseEvent) => void; 16 | /** 鼠标按下时 */ 17 | onMouseDown?: (e: React.MouseEvent) => void; 18 | /** 鼠标弹起时 */ 19 | onMouseUp?: (e: React.MouseEvent) => void; 20 | /** 鼠标到达时 */ 21 | onMouseEnter?: (e: React.MouseEvent) => void; 22 | /** 鼠标移出时 */ 23 | onMouseLeave?: (e: React.MouseEvent) => void; 24 | /** 鼠标移动时 */ 25 | onMouseMove?: (e: React.MouseEvent) => void; 26 | /** 鼠标移入时 */ 27 | onMouseOver?: (e: React.MouseEvent) => void; 28 | /** 鼠标移开时 */ 29 | onMouseOut?: (e: React.MouseEvent) => void; 30 | } 31 | 32 | export interface EditorBoxProps 33 | extends Partial, 34 | MouseEventProps { 35 | className?: string; 36 | editorPanelStyle?: React.CSSProperties; 37 | style?: React.CSSProperties; 38 | /** 是否显示旋转点 */ 39 | isShowRotate?: boolean; 40 | /** 矩形信息 */ 41 | rectInfo: RectData; 42 | /** 拉伸点集合,默认所有点 */ 43 | points?: POINT_TYPE[]; 44 | /** 是否显示拉伸点以及旋转点 */ 45 | isShowPoint?: boolean; 46 | /** 拉伸类型 default 默认拉伸; mask-cover铺满; mask-contain 平铺; 47 | * mask-cover 和 mask-contain 只有rectInfo 存在mask才生效*/ 48 | scaleType?: 'default' | 'mask-cover' | 'mask-contain'; 49 | /** 缩放倍数 */ 50 | zoomLevel?: number; 51 | /** 自定义元素 */ 52 | extra?: React.ReactNode | ((rectData: RectData) => React.ReactNode); 53 | /** 开始拉伸 54 | * @param point 当前拉伸的点 55 | * @param e 事件对象 56 | */ 57 | onStartScale?: ( 58 | point: POINT_TYPE, 59 | e: MouseEvent 60 | ) => ScaleHandlerOptions | void; 61 | /** 拉伸中 62 | * @param point 当前拉伸的点 63 | * @param result 拉伸计算的结果 64 | * @param e 事件对象 65 | */ 66 | onScale?: (result: RectData, point: POINT_TYPE, e: MouseEvent) => void; 67 | /** 结束拉伸 */ 68 | onEndScale?: (point: POINT_TYPE, e: MouseEvent) => void; 69 | /** 开始旋转 */ 70 | onRotateStart?: (e: MouseEvent) => void; 71 | /** 旋转中 */ 72 | onRotate?: (rotate: number, e: MouseEvent) => void; 73 | /** 旋转结束 */ 74 | onRotateEnd?: (e: MouseEvent) => void; 75 | } 76 | -------------------------------------------------------------------------------- /packages/EditorTools/MagneticLine/MagneticLine.less: -------------------------------------------------------------------------------- 1 | .magic-magnetic-line { 2 | position: absolute; 3 | z-index: 100; 4 | 5 | &-line { 6 | width: 0; 7 | height: 0; 8 | border: 0 solid #0f0; 9 | } 10 | 11 | &-horizontal { 12 | border-top-width: 1px; 13 | transform: translateY(-0.5px); 14 | } 15 | 16 | &-vertical { 17 | border-left-width: 1px; 18 | transform: translateX(-0.5px); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/EditorTools/MagneticLine/MagneticLine.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import cls from 'classnames'; 3 | 4 | import { MagneticLineProps } from './props'; 5 | import { AxleDirection } from '../constants/AxleDirection'; 6 | import './MagneticLine.less'; 7 | 8 | export const prefixCls = 'magic-magnetic-line'; 9 | 10 | const getLineStyle = (length: number, direction: 'x' | 'y') => { 11 | if (direction === AxleDirection.x) { 12 | return { 13 | width: length, 14 | }; 15 | } 16 | return { 17 | height: length, 18 | }; 19 | }; 20 | 21 | export default function MagneticLine(props: MagneticLineProps) { 22 | const { lines } = props; 23 | 24 | if (!lines) return null; 25 | 26 | return ( 27 | <> 28 | {lines.map((line, index) => { 29 | const { axis, length, direction } = line; 30 | 31 | return ( 32 |
39 |
46 |
47 | ); 48 | })} 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /packages/EditorTools/MagneticLine/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MagneticLine'; 2 | -------------------------------------------------------------------------------- /packages/EditorTools/MagneticLine/props.ts: -------------------------------------------------------------------------------- 1 | import { MagneticLineType } from '../types/MagneticLine'; 2 | 3 | export interface MagneticLineProps { 4 | lines?: MagneticLineType[] | null; 5 | } 6 | -------------------------------------------------------------------------------- /packages/EditorTools/constants/AxleDirection.ts: -------------------------------------------------------------------------------- 1 | export enum AxleDirection { 2 | x = 'x', 3 | y = 'y', 4 | } 5 | -------------------------------------------------------------------------------- /packages/EditorTools/constants/EditorBox.ts: -------------------------------------------------------------------------------- 1 | import { POINT_TYPE } from '../enum/point-type'; 2 | 3 | /** 4 | * 锚点鼠标样式 5 | */ 6 | export const POINT_CURSORS = ['nwse', 'ns', 'nesw', 'ew']; 7 | 8 | /** 拉伸点集合 */ 9 | export const POINT_LIST = [ 10 | { 11 | point: POINT_TYPE.LEFT_TOP, 12 | className: 'left-top', 13 | }, 14 | { 15 | point: POINT_TYPE.LEFT_CENTER, 16 | className: 'left-center', 17 | }, 18 | { 19 | point: POINT_TYPE.LEFT_BOTTOM, 20 | className: 'left-bottom', 21 | }, 22 | { 23 | point: POINT_TYPE.RIGHT_TOP, 24 | className: 'right-top', 25 | }, 26 | { 27 | point: POINT_TYPE.RIGHT_CENTER, 28 | className: 'right-center', 29 | }, 30 | { 31 | point: POINT_TYPE.RIGHT_BOTTOM, 32 | className: 'right-bottom', 33 | }, 34 | { 35 | point: POINT_TYPE.TOP_CENTER, 36 | className: 'top-center', 37 | }, 38 | { 39 | point: POINT_TYPE.BOTTOM_CENTER, 40 | className: 'bottom-center', 41 | }, 42 | ]; 43 | 44 | /** 中间点最大的间隔 */ 45 | export const MIN_SPACING = 24; 46 | /** 中间点的宽度 */ 47 | export const VERTICAL_AXIS_WIDTH = 8; 48 | export const HORIZONTAL_AXIS_WIDTH = 18; 49 | 50 | export const prefixCls = 'magic-editor-box'; 51 | 52 | /** 53 | * 拉伸类型 54 | */ 55 | export const ScaleTypeMap = { 56 | default: 'default', 57 | maskCover: 'mask-cover', 58 | maskContain: 'mask-contain', 59 | } as const; 60 | -------------------------------------------------------------------------------- /packages/EditorTools/constants/Magnetic.ts: -------------------------------------------------------------------------------- 1 | export const DISTANCE = 3; 2 | 3 | export const ZOOM_LEVEL = 1; 4 | -------------------------------------------------------------------------------- /packages/EditorTools/constants/Points.ts: -------------------------------------------------------------------------------- 1 | import { POINT_TYPE } from '../enum/point-type'; 2 | 3 | /** 4 | * 所有的锚点 5 | */ 6 | export const ALL_POINT: POINT_TYPE[] = [ 7 | POINT_TYPE.LEFT_TOP, 8 | POINT_TYPE.TOP_CENTER, 9 | POINT_TYPE.RIGHT_TOP, 10 | POINT_TYPE.RIGHT_CENTER, 11 | POINT_TYPE.RIGHT_BOTTOM, 12 | POINT_TYPE.BOTTOM_CENTER, 13 | POINT_TYPE.LEFT_BOTTOM, 14 | POINT_TYPE.LEFT_CENTER, 15 | ]; 16 | 17 | /** 18 | * 中心的锚点 19 | */ 20 | export const CENTER_POINT = [ 21 | POINT_TYPE.TOP_CENTER, 22 | POINT_TYPE.LEFT_CENTER, 23 | POINT_TYPE.RIGHT_CENTER, 24 | POINT_TYPE.BOTTOM_CENTER, 25 | ]; 26 | -------------------------------------------------------------------------------- /packages/EditorTools/core/MaskContainScaleHandler.ts: -------------------------------------------------------------------------------- 1 | import { RectData, Coordinate } from '../types/Editor'; 2 | import { getMaskInCanvasRectData } from '../helper/math'; 3 | import ScaleHandler from './ScaleHandler'; 4 | import type { ScaleHandlerOptions } from './ScaleHandler'; 5 | import { POINT_TYPE } from '../enum/point-type'; 6 | 7 | export default class MaskContainScaleHandler { 8 | /** 矩形数据 */ 9 | private readonly rectData: RectData; 10 | 11 | /** 拉伸函数实例 */ 12 | private scaleHandler: ScaleHandler; 13 | 14 | constructor( 15 | rectData: RectData, 16 | pointType: POINT_TYPE, 17 | options: ScaleHandlerOptions 18 | ) { 19 | this.rectData = rectData; 20 | this.scaleHandler = new ScaleHandler( 21 | getMaskInCanvasRectData(this.rectData), 22 | pointType, 23 | options 24 | ); 25 | } 26 | 27 | /** 28 | * 拉伸 29 | * */ 30 | public onScale(mousePosition: Coordinate): RectData { 31 | const newMaskDataInEditArea = this.scaleHandler.onScale(mousePosition); 32 | 33 | const { width, height } = this.rectData; 34 | const rateW = newMaskDataInEditArea.width / width; 35 | const rateH = newMaskDataInEditArea.height / height; 36 | 37 | const rate = Math.min(rateW, rateH); 38 | /** 新宽高 */ 39 | const newWidth = width * rate; 40 | const newHeight = height * rate; 41 | 42 | /** 让图片居中显示 */ 43 | const offsetX = (newWidth - newMaskDataInEditArea.width) / 2; 44 | const offsetY = (newHeight - newMaskDataInEditArea.height) / 2; 45 | /** 新坐标 */ 46 | const newX = newMaskDataInEditArea.x + -offsetX; 47 | const newY = newMaskDataInEditArea.y + -offsetY; 48 | 49 | return { 50 | x: newX, 51 | y: newY, 52 | width: newWidth, 53 | height: newHeight, 54 | mask: { 55 | width: newMaskDataInEditArea.width, 56 | height: newMaskDataInEditArea.height, 57 | x: offsetX, 58 | y: offsetY, 59 | }, 60 | anchor: { x: 0.5, y: 0.5 }, 61 | }; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/EditorTools/core/RotateHandler.ts: -------------------------------------------------------------------------------- 1 | import { RectData, Coordinate } from '../types/Editor'; 2 | import { 3 | toAngle, 4 | getRectCenter, 5 | getMaskInCanvasRectData, 6 | } from '../helper/math'; 7 | 8 | export default class RotateHandler { 9 | /** 旋转角度 */ 10 | private readonly startRotate: number; 11 | 12 | /** 矩形数据 */ 13 | private readonly rectCenterData: Coordinate; 14 | 15 | /** 鼠标在画布中开始坐标 */ 16 | private readonly mouseStartData: Coordinate; 17 | 18 | constructor(rectData: RectData, mouseStartData: Coordinate, angle: number) { 19 | /** 计算可视区域到画布的矩形信息,也就是mask到基于画布的信息 */ 20 | rectData = getMaskInCanvasRectData(rectData); 21 | this.rectCenterData = getRectCenter(rectData); 22 | this.mouseStartData = mouseStartData; 23 | this.startRotate = angle; 24 | } 25 | 26 | /** 27 | * 旋转 28 | * */ 29 | public onRotate(mousePosition: Coordinate): number { 30 | const { x: centerX, y: centerY } = this.rectCenterData; 31 | const { x: startX, y: startY } = this.mouseStartData; 32 | /** 旋转前的角度 */ 33 | const rotateDegreeBefore = toAngle( 34 | Math.atan2(startY - centerY, startX - centerX) 35 | ); 36 | /** 旋转后的角度 */ 37 | const rotateDegreeAfter = toAngle( 38 | Math.atan2(mousePosition.y - centerY, mousePosition.x - centerX) 39 | ); 40 | 41 | /** 获取旋转的角度值,需要加上开始的角度*/ 42 | return rotateDegreeAfter - rotateDegreeBefore + this.startRotate; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/EditorTools/core/dragAction.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 传输参数 3 | * */ 4 | export interface DragExecutor { 5 | /** 鼠标按下触发事件 */ 6 | init?: (event: MouseEvent) => void; 7 | /** 鼠标移动触发事件 */ 8 | move?: (moveEvent: MouseEvent) => void; 9 | /** 鼠标抬起触发事件 */ 10 | end?: (endEvent: MouseEvent) => void; 11 | } 12 | 13 | /** 14 | * drag事件 15 | **/ 16 | const dragAction = (event: MouseEvent, executors: DragExecutor) => { 17 | executors.init?.(event); 18 | const startMove = (moveEvent: MouseEvent) => { 19 | executors.move?.(moveEvent); 20 | }; 21 | 22 | const endMove = (endEvent: MouseEvent) => { 23 | executors.end?.(endEvent); 24 | document.removeEventListener('mousemove', startMove); 25 | document.removeEventListener('mouseup', endMove); 26 | }; 27 | 28 | document.addEventListener('mousemove', startMove); 29 | document.addEventListener('mouseup', endMove); 30 | }; 31 | 32 | export default dragAction; 33 | -------------------------------------------------------------------------------- /packages/EditorTools/enum/point-type.ts: -------------------------------------------------------------------------------- 1 | /** 枚举位置 */ 2 | export enum POINT_TYPE { 3 | LEFT_TOP = 'leftTop', 4 | RIGHT_TOP = 'rightTop', 5 | LEFT_BOTTOM = 'leftBottom', 6 | RIGHT_BOTTOM = 'rightBottom', 7 | TOP_CENTER = 'topCenter', 8 | RIGHT_CENTER = 'rightCenter', 9 | BOTTOM_CENTER = 'bottomCenter', 10 | LEFT_CENTER = 'leftCenter', 11 | ROTATE = 'rotate', 12 | } 13 | -------------------------------------------------------------------------------- /packages/EditorTools/helper/magneticLine.ts: -------------------------------------------------------------------------------- 1 | import { Coordinate, Size } from '../types/Editor'; 2 | import { Range, LineData } from '../types/MagneticLine'; 3 | 4 | /** 5 | * 将一组对齐吸附线进行去重:同位置的的多条对齐吸附线仅留下一条,取该位置所有对齐吸附线的最大值和最小值为新的范围 6 | * @param lines 一组对齐吸附线信息 7 | */ 8 | export function uniqAlignLines(lines: LineData[]) { 9 | const map: Record = {}; 10 | const uniqLines = lines.reduce((pre: LineData[], cur: LineData) => { 11 | if (!Reflect.has(map, cur.value)) { 12 | pre.push(cur); 13 | map[cur.value] = pre.length - 1; 14 | return pre; 15 | } 16 | 17 | const index = map[cur.value]; 18 | const preLine = pre[index]; 19 | const rangeMin = Math.min(preLine.range[0], cur.range[0]); 20 | const rangeMax = Math.max(preLine.range[1], cur.range[1]); 21 | const range: Range = [rangeMin, rangeMax]; 22 | const line: LineData = { value: cur.value, range }; 23 | pre[index] = line; 24 | return pre; 25 | }, []); 26 | return uniqLines; 27 | } 28 | 29 | /** 30 | * 获取矩形的自身的六条磁力线 31 | * @param leftTop 左上角坐标 32 | * @param rightBottom 右上角坐标 33 | * @param width 宽 34 | * @param height 高 35 | * @returns horizontal 横轴线 vertical 纵轴线 36 | */ 37 | export function getRectMagneticLines( 38 | leftTop: Coordinate, 39 | rightBottom: Coordinate, 40 | rectSize: Size 41 | ): { horizontal: LineData[]; vertical: LineData[] } { 42 | const center = { 43 | x: leftTop.x + rectSize.width / 2, 44 | y: leftTop.y + rectSize.height / 2, 45 | }; 46 | 47 | const topLine: LineData = { 48 | value: leftTop.y, 49 | range: [leftTop.x, rightBottom.x], 50 | }; 51 | 52 | const bottomLine: LineData = { 53 | value: rightBottom.y, 54 | range: [leftTop.x, rightBottom.x], 55 | }; 56 | 57 | const horizontalCenterLine: LineData = { 58 | value: center.y, 59 | range: [leftTop.x, rightBottom.x], 60 | }; 61 | 62 | const leftLine: LineData = { 63 | value: leftTop.x, 64 | range: [leftTop.y, rightBottom.y], 65 | }; 66 | 67 | const rightLine: LineData = { 68 | value: rightBottom.x, 69 | range: [leftTop.y, rightBottom.y], 70 | }; 71 | 72 | const verticalCenterLine: LineData = { 73 | value: center.x, 74 | range: [leftTop.y, rightBottom.y], 75 | }; 76 | 77 | return { 78 | horizontal: [topLine, bottomLine, horizontalCenterLine], 79 | vertical: [leftLine, rightLine, verticalCenterLine], 80 | }; 81 | } 82 | -------------------------------------------------------------------------------- /packages/EditorTools/helper/utils.ts: -------------------------------------------------------------------------------- 1 | import { POINT_TYPE } from '../enum/point-type'; 2 | import { Coordinate, RectData } from '../types/Editor'; 3 | import { CENTER_POINT } from '../constants/Points'; 4 | import { 5 | valuesToDivide, 6 | valuesToMultiply, 7 | pointToAnchor, 8 | pointToTopLeft, 9 | } from './math'; 10 | 11 | /** 12 | * 判断当前的拉动点是否是中心点 13 | * @param {POINT_TYPE} pointType 14 | * @return {Boolean} 否是中心点 15 | */ 16 | export const isCenterPoint = (pointType: POINT_TYPE): boolean => 17 | CENTER_POINT.includes(pointType); 18 | 19 | /** 20 | * 检测 p0 是否在 p1 与 p2 建立的矩形内 21 | * @param {Coordinate} p0 被检测的坐标 22 | * @param {Coordinate} p1 点1坐标 23 | * @param {Coordinate} p2 点2坐标 24 | * @return {Boolean} 检测结果 25 | */ 26 | export const pointInRect = ( 27 | p0: Coordinate, 28 | p1: Coordinate, 29 | p2: Coordinate 30 | ): boolean => { 31 | if (p1.x > p2.x) { 32 | if (p0.x < p2.x) { 33 | return false; 34 | } 35 | } else if (p0.x > p2.x) { 36 | return false; 37 | } 38 | 39 | if (p1.y > p2.y) { 40 | if (p0.y < p2.y) { 41 | return false; 42 | } 43 | } else if (p0.y > p2.y) { 44 | return false; 45 | } 46 | 47 | return true; 48 | }; 49 | 50 | /** 51 | * 保留小数 52 | * @param num 浮点数 53 | * @param unit 保留小数的位数 54 | * @returns 55 | */ 56 | export function keepDecimal(num: number, unit: number) { 57 | return Math.floor(num * 10 ** unit) / 10 ** unit; 58 | } 59 | 60 | /** 61 | * 处理成可编辑的数据,转换缩放值,和移动锚点位置 62 | * @param data 63 | * @param zoomLevel 64 | * @returns 65 | */ 66 | export function processToEditableData( 67 | data: RectData, 68 | zoomLevel: number 69 | ): RectData { 70 | let result = { ...data, ...pointToTopLeft(data) }; 71 | 72 | const { x, y, width, height } = result; 73 | result = { 74 | ...result, 75 | ...valuesToMultiply({ x, y, width, height }, zoomLevel), 76 | }; 77 | 78 | if (data.mask) { 79 | result.mask = valuesToMultiply(data.mask, zoomLevel); 80 | } 81 | return result; 82 | } 83 | 84 | /** 85 | * 处理成原始数据 86 | * @param data 87 | * @param zoomLevel 88 | * @returns 89 | */ 90 | export function processToRawData(data: RectData, zoomLevel: number): RectData { 91 | let result = { ...data, ...pointToAnchor(data) }; 92 | const { x, y, width, height } = result; 93 | 94 | result = { ...result, ...valuesToDivide({ x, y, width, height }, zoomLevel) }; 95 | 96 | if (data.mask) { 97 | result.mask = valuesToDivide(data.mask, zoomLevel); 98 | } 99 | return result; 100 | } 101 | -------------------------------------------------------------------------------- /packages/EditorTools/index.ts: -------------------------------------------------------------------------------- 1 | // ========= core ========== 2 | export { default as dragAction } from './core/dragAction'; 3 | export { default as ScaleHandler } from './core/ScaleHandler'; 4 | export { default as RotateHandler } from './core/RotateHandler'; 5 | export { default as MaskCoverScaleHandler } from './core/MaskCoverScaleHandler'; 6 | export { default as MaskContainScaleHandler } from './core/MaskContainScaleHandler'; 7 | export { default as MagneticLineHandler } from './core/MagneticLineHandler'; 8 | 9 | // ======== enum ========== 10 | export { POINT_TYPE } from './enum/point-type'; 11 | 12 | // ======== helper ======== 13 | export { 14 | calcRotatedPoint, 15 | toRadian, 16 | toAngle, 17 | getMiddlePoint, 18 | getRectCenter, 19 | getMaskInCanvasRectData, 20 | valuesToDivide, 21 | valuesToMultiply, 22 | pointToAnchor, 23 | pointToTopLeft, 24 | getRectRotatedRange, 25 | } from './helper/math'; 26 | export { 27 | pointInRect, 28 | isCenterPoint, 29 | keepDecimal, 30 | processToEditableData, 31 | processToRawData, 32 | } from './helper/utils'; 33 | 34 | // =========== types ================ 35 | export type { RectData, Coordinate, BaseRectData, Size } from './types/Editor'; 36 | export type { EditorBoxProps } from './EditorBox/props'; 37 | export type { ScaleHandlerOptions } from './core/ScaleHandler'; 38 | export type { MagneticLineType, LineData, Range } from './types/MagneticLine'; 39 | 40 | // =========== components ============= 41 | export { default as EditorBox } from './EditorBox'; 42 | export { default as MagneticLine } from './MagneticLine'; 43 | -------------------------------------------------------------------------------- /packages/EditorTools/types/Editor.ts: -------------------------------------------------------------------------------- 1 | export interface Coordinate { 2 | /** x轴坐标 */ 3 | x: number; 4 | /** y轴坐标 */ 5 | y: number; 6 | } 7 | 8 | export interface BaseRectData extends Coordinate { 9 | /** 宽度 */ 10 | width: number; 11 | /** 高度 */ 12 | height: number; 13 | } 14 | 15 | export interface RectData extends BaseRectData { 16 | /** 蒙层数据 */ 17 | mask?: BaseRectData; 18 | /** 锚点数据 */ 19 | anchor?: Coordinate; 20 | /** 翻转数据 */ 21 | scale?: Coordinate; 22 | /** 旋转角度 */ 23 | rotate?: number; 24 | } 25 | 26 | export type Size = Omit; 27 | -------------------------------------------------------------------------------- /packages/EditorTools/types/MagneticLine.ts: -------------------------------------------------------------------------------- 1 | /** 范围 */ 2 | export type Range = [number, number]; 3 | 4 | /** 5 | * 磁力线数据格式 6 | */ 7 | export interface LineData { 8 | value: number; 9 | range: Range; 10 | } 11 | 12 | export interface MagneticLineType { 13 | direction: 'x' | 'y'; 14 | axis: { x: number; y: number }; 15 | length: number; 16 | } 17 | -------------------------------------------------------------------------------- /packages/Screenshot/CreateContext.ts: -------------------------------------------------------------------------------- 1 | import { isElementNode } from './Document'; 2 | import { createSvgRootStyleElement } from './Svg'; 3 | import type { Options } from './Screenshot'; 4 | 5 | export interface Context extends Options { 6 | /** 异步任务 */ 7 | tasks: Promise[]; 8 | /** svg 根节点 */ 9 | svgRootStyleElement: HTMLStyleElement; 10 | /** 缓存加载过的字体key */ 11 | catchFontKey: string[]; 12 | } 13 | 14 | /** 15 | * 创建上下文 16 | * @param node 17 | * @param options 18 | * @returns Context 19 | */ 20 | export function createContext( 21 | node: T, 22 | options: Options 23 | ): Context { 24 | const context = { 25 | tasks: [], 26 | catchFontKey: [], 27 | svgRootStyleElement: createSvgRootStyleElement(node), 28 | ...options, 29 | }; 30 | 31 | const { width, height } = getSize(node, context); 32 | context.width = width; 33 | context.height = height; 34 | 35 | return context; 36 | } 37 | 38 | /** 39 | * 获取截图宽高 40 | * @param node 41 | * @param context 42 | * @returns size 43 | */ 44 | function getSize(node: Node, context: Context) { 45 | let { width, height } = context; 46 | 47 | if (isElementNode(node) && (!width || !height)) { 48 | const box = node.getBoundingClientRect(); 49 | 50 | width = width || box.width || Number(node.getAttribute('width')) || 0; 51 | 52 | height = height || box.height || Number(node.getAttribute('height')) || 0; 53 | } 54 | 55 | return { width, height }; 56 | } 57 | -------------------------------------------------------------------------------- /packages/Screenshot/Document.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 是否为元素节点 3 | * @param node 4 | * @returns boolean 5 | */ 6 | export const isElementNode = (node: Node): node is Element => 7 | node.nodeType === 1; // Node.ELEMENT_NODE 8 | 9 | /** 10 | * 是否为svg元素节点 11 | * @param node 12 | * @returns boolean 13 | */ 14 | export const isSVGElementNode = (node: Element): node is SVGElement => 15 | typeof (node as SVGElement).className === 'object'; 16 | 17 | /** 18 | * 是否为HTML元素节点 19 | * @param node 20 | * @returns boolean 21 | */ 22 | export const isHTMLElementNode = (node: Node): node is HTMLElement => 23 | isElementNode(node) && 24 | typeof (node as HTMLElement).style !== 'undefined' && 25 | !isSVGElementNode(node); 26 | 27 | /** 28 | * 是否为图片元素 29 | * @param node 30 | * @returns boolean 31 | */ 32 | export const isImageElement = (node: Element): node is HTMLImageElement => 33 | node.tagName === 'IMG'; 34 | 35 | /** 36 | * 是否为dataURl 37 | * @param url 38 | * @returns boolean 39 | */ 40 | export const isDataUrl = (url: string) => url.startsWith('data:'); 41 | 42 | /** 43 | * 获取文档 44 | * @param target 45 | * @returns boolean 46 | */ 47 | export function getDocument(target?: T | null): Document { 48 | return ((target && isElementNode(target as any) 49 | ? target?.ownerDocument 50 | : target) ?? window.document) as any; 51 | } 52 | -------------------------------------------------------------------------------- /packages/Screenshot/Fetch.ts: -------------------------------------------------------------------------------- 1 | import { blobToDataUrl } from './File'; 2 | 3 | /** 4 | * 加载blob 5 | * @param {string} url 6 | * @return {*} {Promise} 7 | */ 8 | export function fetchBlob(url: string): Promise { 9 | return fetch(url).then(res => res.blob().then(blob => blob)); 10 | } 11 | 12 | /** 13 | * 加载dataUrl 14 | * @param {string} url 15 | * @return {*} {Promise} 16 | */ 17 | export function fetchDataUrl(url: string): Promise { 18 | return fetchBlob(url).then(blob => blobToDataUrl(blob)); 19 | } 20 | 21 | /** 22 | * 加载 ArrayBuffer 23 | * @param {string} url 24 | * @return {*} {Promise} 25 | */ 26 | export function fetchArrayBuffer(url: string): Promise { 27 | return fetch(url).then(res => res.arrayBuffer()); 28 | } 29 | -------------------------------------------------------------------------------- /packages/Screenshot/File.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * blob 转 dataUrl 3 | * @param {Blob} blob 4 | * @return {*} {Promise} 5 | */ 6 | export function blobToDataUrl(blob: Blob): Promise { 7 | return new Promise((resolve, reject) => { 8 | const reader = new FileReader(); 9 | reader.onloadend = () => { 10 | const content = (reader.result as string).split(/,/)[1]; 11 | if (content) resolve(reader.result as string); 12 | else reject(new Error('DataUrl 为空')); 13 | }; 14 | reader.onerror = reject; 15 | reader.readAsDataURL(blob); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /packages/Screenshot/ImageToBlob.ts: -------------------------------------------------------------------------------- 1 | import type { Context } from './CreateContext'; 2 | 3 | /** 4 | * 图片元素 转 blob 5 | * @param context 6 | * @param image 7 | * @returns Promise 8 | */ 9 | export function imageToBlob( 10 | context: Context, 11 | image: HTMLImageElement 12 | ): Promise { 13 | return new Promise((resolve, reject) => { 14 | const { width = 0, height = 0, type } = context; 15 | const canvas = image.ownerDocument.createElement('canvas'); 16 | canvas.width = width; 17 | canvas.height = height; 18 | 19 | const ctx = canvas.getContext('2d'); 20 | ctx?.drawImage(image, 0, 0, width, height); 21 | // canvas 默认格式为 image/png 22 | canvas.toBlob(blob => { 23 | if (blob) resolve(blob); 24 | else reject(new Error('imageToBlob fail')); 25 | }, type); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /packages/Screenshot/Screenshot.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from './CreateContext'; 2 | import { cloneNode } from './CloneNode'; 3 | import { removeDefaultStyleSandbox } from './CopyCssStyles'; 4 | import { createForeignObjectSvg, svgToDataUrl } from './Svg'; 5 | 6 | import { makeImage } from '@/utils/image'; 7 | import { imageToBlob } from './ImageToBlob'; 8 | 9 | export interface Options { 10 | // key 为 字体的fontFamily ,value 为 url 11 | fontMap?: Record; 12 | width?: number; 13 | height?: number; 14 | backgroundColor?: string; 15 | style?: Partial | null; 16 | type?: 'image/png' | 'image/jpeg'; 17 | debug?: boolean; 18 | } 19 | 20 | export default async function screenshot( 21 | node: T, 22 | options: Options 23 | ): Promise { 24 | const { debug } = options; 25 | const context = createContext(node, options); 26 | debug && console.time('cloneNode'); 27 | const clone = cloneNode(node, context); 28 | debug && console.timeEnd('cloneNode'); 29 | 30 | removeDefaultStyleSandbox(); 31 | debug && console.time('runTask'); 32 | const runTask = async () => { 33 | while (true) { 34 | const task = context.tasks.pop(); 35 | if (!task) break; 36 | try { 37 | // eslint-disable-next-line no-await-in-loop 38 | await task; 39 | } catch (error) { 40 | console.warn('任务失败', error); 41 | } 42 | } 43 | }; 44 | 45 | // todo: 替换并发限制逻辑 46 | await Promise.all([...Array(4)].map(runTask)); 47 | debug && console.timeEnd('runTask'); 48 | 49 | const svg = createForeignObjectSvg(clone, context); 50 | svg.insertBefore(context.svgRootStyleElement, svg.children[0]); 51 | const dataUrl = svgToDataUrl(svg); 52 | const image = await makeImage(dataUrl, context.width, context.height); 53 | return imageToBlob(context, image); 54 | } 55 | -------------------------------------------------------------------------------- /packages/Screenshot/Svg.ts: -------------------------------------------------------------------------------- 1 | import { getDocument } from './Document'; 2 | import { Context } from './CreateContext'; 3 | 4 | /** 5 | * 创建Svg根样式元素 6 | * @param node 7 | * @returns style 8 | */ 9 | export function createSvgRootStyleElement(node: Node) { 10 | const style = getDocument(node).createElement('style'); 11 | const cssText = style.ownerDocument.createTextNode(` 12 | .______background-clip--text { 13 | background-clip: text; 14 | -webkit-background-clip: text; 15 | } 16 | `); 17 | style.appendChild(cssText); 18 | return style; 19 | } 20 | 21 | /** 22 | * 创建 ForeignObject Svg 23 | * @param clone 24 | * @param context 25 | * @returns svg 26 | */ 27 | export function createForeignObjectSvg( 28 | clone: Node, 29 | context: Context 30 | ): SVGSVGElement { 31 | const svg = getDocument(clone.ownerDocument).createElementNS( 32 | 'http://www.w3.org/2000/svg', 33 | 'svg' 34 | ); 35 | svg.setAttribute('viewBox', `0 0 ${context.width} ${context.height}`); 36 | 37 | const foreignObject = svg.ownerDocument.createElementNS( 38 | svg.namespaceURI, 39 | 'foreignObject' 40 | ); 41 | foreignObject.setAttributeNS(null, 'x', '0%'); 42 | foreignObject.setAttributeNS(null, 'y', '0%'); 43 | foreignObject.setAttributeNS(null, 'width', '100%'); 44 | foreignObject.setAttributeNS(null, 'height', '100%'); 45 | foreignObject.setAttributeNS(null, 'externalResourcesRequired', 'true'); 46 | foreignObject.append(clone); 47 | svg.appendChild(foreignObject); 48 | return svg; 49 | } 50 | 51 | /** 52 | * svg 转 dataUrl 53 | * @param svg 54 | * @returns dataUrl 55 | */ 56 | export function svgToDataUrl(svg: SVGElement) { 57 | const xhtml = new XMLSerializer().serializeToString(svg); 58 | return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xhtml)}`; 59 | } 60 | -------------------------------------------------------------------------------- /packages/Screenshot/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Screenshot'; 2 | -------------------------------------------------------------------------------- /scripts/prepare.js: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; 2 | 3 | spawnSync('npx husky install'); 4 | 5 | spawnSync('git config core.hooksPath .husky'); 6 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import Layout from './layout'; 3 | import KeyboardManager from './core/Manager/Keyboard'; 4 | import HistoryManager from './core/Manager/History'; 5 | import ContextMenuManager from './core/Manager/ContextMenuManager'; 6 | import { 7 | registerAppActions, 8 | registerOSSActions, 9 | registerHistoryActions, 10 | } from './core/Manager/Cmd'; 11 | import ClipboardManager from './core/Manager/Clipboard'; 12 | 13 | import { useStores } from '@/store'; 14 | import './utils/logo'; 15 | 16 | function App() { 17 | const { OS, magic, history } = useStores(); 18 | 19 | const registerInfo = () => { 20 | HistoryManager.register(history); 21 | ClipboardManager.register(magic); 22 | 23 | registerAppActions(magic); 24 | registerOSSActions(OS); 25 | registerHistoryActions(); 26 | }; 27 | 28 | useEffect(() => { 29 | registerInfo(); 30 | }, []); 31 | return ( 32 | <> 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /src/assets/styles/common.less: -------------------------------------------------------------------------------- 1 | /* 单行文本省略,配合 width 或 max-width 使用 */ 2 | .single-line-omit { 3 | overflow: hidden; 4 | white-space: nowrap; 5 | text-overflow: ellipsis; 6 | } 7 | 8 | /* 多行文本省略,配合 width 或 max-width 9 | * 加上 css 变量 --line 使用,默认是 2 行。 10 | * --line 使用方式:
...
11 | */ 12 | .multiple-line-omit { 13 | display: box !important; 14 | overflow: hidden; 15 | text-overflow: ellipsis; 16 | -webkit-line-clamp: var(--line, 2); 17 | -webkit-box-orient: vertical; 18 | } 19 | 20 | /** 马赛克背景 */ 21 | .mosaic-background { 22 | background-image: linear-gradient( 23 | 45deg, 24 | #ecf0f5 25%, 25 | transparent 25%, 26 | transparent 75%, 27 | #ecf0f5 75%, 28 | #ecf0f5 100% 29 | ), 30 | linear-gradient( 31 | 45deg, 32 | #ecf0f5 25%, 33 | white 25%, 34 | white 75%, 35 | #ecf0f5 75%, 36 | #ecf0f5 100% 37 | ); 38 | background-position: 0 0, 8px 8px; 39 | background-size: 16px 16px; 40 | } 41 | -------------------------------------------------------------------------------- /src/assets/styles/index.less: -------------------------------------------------------------------------------- 1 | @import './common.less'; 2 | 3 | @import './reset.less'; 4 | 5 | @import './setting.less'; 6 | 7 | * { 8 | box-sizing: border-box; 9 | } 10 | -------------------------------------------------------------------------------- /src/assets/styles/reset.less: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | padding: 0; 89 | font: inherit; 90 | font-size: 100%; 91 | vertical-align: baseline; 92 | border: 0; 93 | } 94 | 95 | /* HTML5 display-role reset for older browsers */ 96 | article, 97 | aside, 98 | details, 99 | figcaption, 100 | figure, 101 | footer, 102 | header, 103 | hgroup, 104 | menu, 105 | nav, 106 | section { 107 | display: block; 108 | } 109 | 110 | body { 111 | line-height: 1; 112 | } 113 | 114 | ol, 115 | ul { 116 | list-style: none; 117 | } 118 | 119 | blockquote, 120 | q { 121 | quotes: none; 122 | } 123 | 124 | blockquote::before, 125 | blockquote::after, 126 | q::before, 127 | q::after { 128 | content: ''; 129 | content: none; 130 | } 131 | 132 | table { 133 | border-collapse: collapse; 134 | border-spacing: 0; 135 | } 136 | -------------------------------------------------------------------------------- /src/assets/styles/setting.less: -------------------------------------------------------------------------------- 1 | .setting-row { 2 | height: 36px; 3 | } 4 | 5 | .title-text { 6 | color: #222529; 7 | font-weight: 700; 8 | font-size: 12px; 9 | } 10 | 11 | .locked { 12 | cursor: not-allowed; 13 | opacity: 0.5; 14 | } 15 | 16 | .attribute-row { 17 | display: flex; 18 | align-items: center; 19 | justify-content: space-between; 20 | width: 100%; 21 | margin-bottom: 10px; 22 | padding: 0 10px; 23 | background-color: #f3f4f6; 24 | border-radius: 4px; 25 | 26 | .icon-item { 27 | display: inline-block; 28 | width: 30px; 29 | height: 30px; 30 | margin-right: 5px; 31 | line-height: 30px; 32 | text-align: center; 33 | border-radius: 4px; 34 | cursor: pointer; 35 | 36 | &:hover { 37 | color: #000; 38 | background-color: #e5e7eb; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/ContextMenu/ContextMenu.module.less: -------------------------------------------------------------------------------- 1 | .menu { 2 | position: fixed; 3 | z-index: 50; 4 | z-index: 1000; 5 | min-width: 200px; 6 | padding: 4px; 7 | background-color: white; 8 | border: 1px solid #e5e7eb; 9 | border-radius: 4px; 10 | box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); 11 | } 12 | 13 | .sub-menu { 14 | position: absolute; 15 | z-index: 1001; 16 | padding: 4px; 17 | background-color: white; 18 | border-radius: 4px; 19 | box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1); 20 | } 21 | 22 | .menu-item { 23 | position: relative; 24 | display: flex; 25 | align-items: center; 26 | justify-content: space-between; 27 | min-width: 200px; 28 | max-width: 80%; 29 | height: 40px; 30 | padding: 0 8px; 31 | border-radius: 4px; 32 | cursor: pointer; 33 | user-select: none; 34 | 35 | &:not(.menu-item-disabled):hover { 36 | background-color: #eff6ff; 37 | } 38 | } 39 | 40 | .menu-item-info { 41 | display: flex; 42 | gap: 4px; 43 | align-items: center; 44 | justify-content: left; 45 | } 46 | 47 | .menu-item-label { 48 | max-width: 160px; 49 | overflow: hidden; 50 | font-size: 14px; 51 | white-space: nowrap; 52 | text-overflow: ellipsis; 53 | } 54 | 55 | .menu-item-shortcut { 56 | margin-left: 8px; 57 | color: #9ca3af; 58 | font-size: 12px; 59 | } 60 | 61 | .menu-item-active { 62 | background-color: #eff6ff; 63 | } 64 | 65 | .menu-item-disabled { 66 | cursor: not-allowed; 67 | opacity: 0.5; 68 | } 69 | 70 | .divider { 71 | height: 1px; 72 | margin: 4px 0; 73 | background-color: #e5e7eb; 74 | } 75 | -------------------------------------------------------------------------------- /src/components/ContextMenu/ContextMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { 3 | createContainerById, 4 | removeContainerById, 5 | render, 6 | } from '@/utils/portalRender'; 7 | import { useEscapeClose, useGlobalClick } from '@/hooks'; 8 | import ContextMenuContent from './ContextMenuContent'; 9 | import Style from './ContextMenu.module.less'; 10 | import { MenuItem } from './props'; 11 | 12 | export interface ContextMenuProps { 13 | items: MenuItem[]; 14 | x: number; 15 | y: number; 16 | } 17 | 18 | export default function ContextMenu(props: ContextMenuProps) { 19 | const { items, x, y } = props; 20 | 21 | const [position, setPosition] = useState({ x, y }); 22 | const menuRef = useRef(null); 23 | 24 | const handleClose = () => { 25 | ContextMenu.hide(); 26 | }; 27 | 28 | useEscapeClose(handleClose, true, true); 29 | useGlobalClick(handleClose, true, menuRef); 30 | 31 | const getPosition = () => { 32 | if (menuRef.current) { 33 | /** 边界处理 */ 34 | const rect = menuRef.current.getBoundingClientRect(); 35 | const newPosition = { x, y }; 36 | 37 | if (rect.right > window.innerWidth) { 38 | newPosition.x = window.innerWidth - rect.width; 39 | } 40 | if (rect.bottom > window.innerHeight) { 41 | newPosition.y = window.innerHeight - rect.height; 42 | } 43 | newPosition.x = Math.max(0, newPosition.x); 44 | newPosition.y = Math.max(0, newPosition.y); 45 | setPosition(newPosition); 46 | } 47 | }; 48 | 49 | useEffect(() => { 50 | getPosition(); 51 | }, [x, y]); 52 | 53 | return ( 54 |
61 |
62 | 63 |
64 |
65 | ); 66 | } 67 | 68 | const CONTEXT_MENU_ID = 'magic-context-menu'; 69 | 70 | ContextMenu.show = function show(props: ContextMenuProps) { 71 | const container = createContainerById(CONTEXT_MENU_ID); 72 | const content = ; 73 | render(content, container); 74 | }; 75 | 76 | ContextMenu.hide = function hide() { 77 | removeContainerById(CONTEXT_MENU_ID); 78 | }; 79 | -------------------------------------------------------------------------------- /src/components/ContextMenu/ContextMenuContent.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | import MenuItemComponent from './MenuItemComponent'; 3 | import { MenuItem } from './props'; 4 | import Style from './ContextMenu.module.less'; 5 | 6 | interface ContextMenuContentProps { 7 | items: MenuItem[]; 8 | onClose: () => void; 9 | } 10 | 11 | export default function ContextMenuContent(props: ContextMenuContentProps) { 12 | const { items, onClose } = props; 13 | 14 | return ( 15 | <> 16 | {items.map((item, index) => ( 17 | 18 | {item.label === '-' ? ( 19 |
20 | ) : ( 21 | 22 | )} 23 | 24 | ))} 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ContextMenu/MenuItemComponent.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react'; 2 | import { ChevronRight } from 'lucide-react'; 3 | import cls from 'classnames'; 4 | import SubMenu from './SubMenu'; 5 | import { MenuItem } from './props'; 6 | import Style from './ContextMenu.module.less'; 7 | 8 | interface MenuItemComponentProps { 9 | item: MenuItem; 10 | onClose: () => void; 11 | } 12 | 13 | export default function MenuItemComponent(props: MenuItemComponentProps) { 14 | const { item, onClose } = props; 15 | 16 | const [isSubMenuOpen, setIsSubMenuOpen] = useState(false); 17 | const itemRef = useRef(null); 18 | 19 | const handleMouseEnter = useCallback(() => { 20 | if (item.children) { 21 | setIsSubMenuOpen(true); 22 | } 23 | }, [item.children]); 24 | 25 | const handleMouseLeave = useCallback(() => { 26 | if (item.children) { 27 | setIsSubMenuOpen(false); 28 | } 29 | }, [item.children]); 30 | 31 | const handleClick = () => { 32 | if (item.disabled || item.children) return; 33 | item.onClick?.(); 34 | onClose?.(); 35 | }; 36 | 37 | return ( 38 |
48 |
49 | {item.icon} 50 | {item.label} 51 |
52 | 53 | {item.shortcut && ( 54 | 55 | {item.shortcut} 56 | 57 | )} 58 | {item.children && ( 59 | <> 60 | 61 | 62 | 63 | {isSubMenuOpen && ( 64 | 69 | )} 70 | 71 | )} 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/components/ContextMenu/SubMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState, RefObject } from 'react'; 2 | import ContextMenuContent from './ContextMenuContent'; 3 | import { MenuItem } from './props'; 4 | import Style from './ContextMenu.module.less'; 5 | 6 | interface SubMenuProps { 7 | items: MenuItem[]; 8 | parentRef: RefObject; 9 | onClose: () => void; 10 | } 11 | 12 | export default function SubMenu(props: SubMenuProps) { 13 | const { items, onClose, parentRef } = props; 14 | 15 | const subMenuRef = useRef(null); 16 | const [position, setPosition] = useState({ left: '100%', top: '0' }); 17 | 18 | const getPosition = () => { 19 | if (subMenuRef.current && parentRef.current) { 20 | const subMenuRect = subMenuRef.current.getBoundingClientRect(); 21 | const parentRect = parentRef.current.getBoundingClientRect(); 22 | 23 | let left = '100%'; 24 | let top = '0'; 25 | if (parentRect.right + subMenuRect.width > window.innerWidth) { 26 | left = `-${subMenuRect.width}px`; 27 | } 28 | if (parentRect.bottom + subMenuRect.height > window.innerHeight) { 29 | top = `${parentRect.height - subMenuRect.height}px`; 30 | } 31 | setPosition({ left, top }); 32 | } 33 | }; 34 | 35 | useEffect(() => { 36 | getPosition(); 37 | }, []); 38 | 39 | return ( 40 |
47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/components/ContextMenu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ContextMenu'; 2 | export type { ContextMenuProps } from './ContextMenu'; 3 | -------------------------------------------------------------------------------- /src/components/ContextMenu/props.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export interface MenuItem { 4 | label: string; 5 | onClick?: () => void; 6 | shortcut?: string; 7 | icon?: ReactNode; 8 | disabled?: boolean; 9 | children?: MenuItem[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Crop/Crop.module.less: -------------------------------------------------------------------------------- 1 | .crop { 2 | position: relative; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | width: 100%; 7 | height: 100%; 8 | background: rgba(0, 0, 0, 0.1); 9 | } 10 | 11 | .editor_area { 12 | position: relative; 13 | } 14 | 15 | .grid_box, 16 | .img_box { 17 | position: absolute; 18 | top: 0; 19 | } 20 | 21 | .img { 22 | width: 100%; 23 | height: 100%; 24 | object-fit: contain; 25 | user-select: none; 26 | -webkit-user-drag: none; 27 | } 28 | 29 | .grid_vertical_line { 30 | position: absolute; 31 | top: 0; 32 | width: 1px; 33 | height: 100%; 34 | background-image: linear-gradient( 35 | to top, 36 | rgba(255, 255, 255, 1) 0%, 37 | rgba(255, 255, 255, 1) 50%, 38 | transparent 50% 39 | ); 40 | background-repeat: repeat-y; 41 | background-size: 1px 8px; 42 | } 43 | 44 | .grid_horizontal_line { 45 | position: absolute; 46 | left: 0; 47 | width: 100%; 48 | height: 1px; 49 | background-image: linear-gradient( 50 | to right, 51 | rgba(255, 255, 255, 1) 0%, 52 | rgba(255, 255, 255, 1) 50%, 53 | transparent 50% 54 | ); 55 | background-repeat: repeat-x; 56 | background-size: 8px 1px; 57 | } 58 | 59 | .editor_box { 60 | cursor: move; 61 | } 62 | 63 | .btn_box { 64 | position: absolute; 65 | right: 16px; 66 | bottom: 16px; 67 | 68 | .confirm { 69 | margin-left: 10px; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/Crop/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Crop'; 2 | -------------------------------------------------------------------------------- /src/components/Editor/Editor.module.less: -------------------------------------------------------------------------------- 1 | .editor { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | width: 100%; 6 | } 7 | 8 | .hidden { 9 | display: none; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/Editor/Editor.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import cls from 'classnames'; 3 | import { MagneticLine, MagneticLineType } from '@p/EditorTools'; 4 | import EditorControl from './EditorControl'; 5 | import { LayerStrucType } from '@/types/model'; 6 | import Hover from './Hover'; 7 | import RichText from './RichText'; 8 | import { magic } from '@/store'; 9 | import Style from './Editor.module.less'; 10 | 11 | interface EditorProps { 12 | isMultiple?: boolean; 13 | zoomLevel?: number; 14 | activedLayers: LayerStrucType[]; 15 | magneticLines?: MagneticLineType[] | null; 16 | } 17 | 18 | function Editor(props: EditorProps) { 19 | const { 20 | isMultiple = false, 21 | activedLayers, 22 | zoomLevel = 1, 23 | magneticLines, 24 | } = props; 25 | 26 | const model = activedLayers[0]; 27 | 28 | const getEditorControl = () => { 29 | if (activedLayers.length === 0) return null; 30 | 31 | if (isMultiple) { 32 | return
多选框
; 33 | } 34 | 35 | return ; 36 | }; 37 | 38 | return ( 39 |
44 | {/* 编辑框 */} 45 | {getEditorControl()} 46 | 47 | {/* hover 框 */} 48 | 49 | 50 | {/* 磁力线 */} 51 | 52 | 53 | {/* 富文本 */} 54 | {model?.isText() && ( 55 | 56 | )} 57 |
58 | ); 59 | } 60 | 61 | export default observer(Editor); 62 | -------------------------------------------------------------------------------- /src/components/Editor/EditorControl/EditorControl.module.less: -------------------------------------------------------------------------------- 1 | .editor_control { 2 | cursor: move; 3 | } 4 | 5 | .pointer_events_none { 6 | pointer-events: none; 7 | } 8 | 9 | .preview_size { 10 | position: absolute; 11 | z-index: 10; 12 | padding: 2px 4px; 13 | color: #fff; 14 | font-size: 14px; 15 | white-space: nowrap; 16 | background: #191919; 17 | border-radius: 3px; 18 | user-select: none; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Editor/EditorControl/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './EditorControl'; 2 | -------------------------------------------------------------------------------- /src/components/Editor/Hover/Hover.module.less: -------------------------------------------------------------------------------- 1 | .hover_wrap { 2 | position: absolute; 3 | border: 2px solid #0f0; 4 | pointer-events: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Editor/Hover/Hover.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import { useStores } from '@/store'; 3 | import { getLayerOuterStyles } from '@/helpers/Styles'; 4 | 5 | import style from './Hover.module.less'; 6 | 7 | function Hover() { 8 | const { magic, OS } = useStores(); 9 | const { activedLayers, hoveredLayer } = magic; 10 | const zoomLevel = OS.zoomLevel; 11 | 12 | const isActived = activedLayers.some(cmp => cmp.id === hoveredLayer?.id); 13 | 14 | if (!hoveredLayer || isActived) { 15 | return null; 16 | } 17 | const hoverStyle = getLayerOuterStyles(hoveredLayer, zoomLevel); 18 | 19 | return
; 20 | } 21 | 22 | export default observer(Hover); 23 | -------------------------------------------------------------------------------- /src/components/Editor/Hover/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Hover'; 2 | -------------------------------------------------------------------------------- /src/components/Editor/RichText/RichText.module.less: -------------------------------------------------------------------------------- 1 | .rich_text_contariner { 2 | position: absolute; 3 | visibility: hidden; 4 | } 5 | 6 | .rich_text_main { 7 | outline: none; 8 | 9 | :global { 10 | .ql-editor { 11 | padding: 0; 12 | overflow: visible; 13 | white-space: break-spaces; 14 | outline: none; 15 | cursor: text; 16 | // user-select: none; 17 | line-break: anywhere; 18 | } 19 | 20 | .ql-clipboard { 21 | position: absolute; 22 | top: 50%; 23 | left: -100000px; 24 | height: 1px; 25 | overflow-y: hidden; 26 | } 27 | } 28 | } 29 | 30 | .show { 31 | visibility: visible; 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Editor/RichText/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './RichText'; 2 | -------------------------------------------------------------------------------- /src/components/Editor/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Editor'; 2 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/Flip/Flip.module.less: -------------------------------------------------------------------------------- 1 | .popover_flip { 2 | :global { 3 | .ant-popover-arrow { 4 | display: none; 5 | } 6 | 7 | .ant-popover-inner { 8 | padding: 8px 0; 9 | } 10 | 11 | .ant-popover-title { 12 | margin: 0; 13 | } 14 | } 15 | } 16 | 17 | .flip_option_item { 18 | padding: 10px 12px; 19 | font-size: 12px; 20 | border-radius: 8px; 21 | cursor: pointer; 22 | 23 | &:hover { 24 | background-color: #e5e7eb; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/Flip/Flip.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Popover } from 'antd'; 3 | import cls from 'classnames'; 4 | import { LayerStrucType } from '@/types/model'; 5 | 6 | import Style from './Flip.module.less'; 7 | 8 | interface FlipProps { 9 | model: LayerStrucType; 10 | className: string; 11 | } 12 | 13 | export default function Flip(props: FlipProps) { 14 | const { model, className } = props; 15 | 16 | const [flipPopoverOpen, setFlipPopoverOpen] = useState(false); 17 | 18 | const onFlipPopoverOpenChange = (open: boolean) => { 19 | const value = model.isLock ? false : open; 20 | setFlipPopoverOpen(value); 21 | }; 22 | 23 | const handleFlipX = () => { 24 | !model.isLock && model.flipX(); 25 | setFlipPopoverOpen(false); 26 | }; 27 | 28 | const handleFlipY = () => { 29 | !model.isLock && model.flipY(); 30 | setFlipPopoverOpen(false); 31 | }; 32 | 33 | const flipOptions = ( 34 | <> 35 |
36 | 水平翻转 37 |
38 |
39 | 垂直翻转 40 |
41 | 42 | ); 43 | 44 | return ( 45 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/Flip/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Flip'; 2 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/LayerBaseSetting.module.less: -------------------------------------------------------------------------------- 1 | .layer_base_setting { 2 | position: relative; 3 | } 4 | 5 | .locked { 6 | cursor: not-allowed; 7 | opacity: 0.5; 8 | } 9 | 10 | .locked_icon { 11 | color: red; 12 | 13 | &:hover { 14 | color: red; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/LayerPosition/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './LayerPosition'; 2 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './LayerBaseSetting'; 2 | -------------------------------------------------------------------------------- /src/components/MenuPopover/MenuPopover.module.less: -------------------------------------------------------------------------------- 1 | .menu { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-around; 5 | width: 24px; 6 | height: 14px; 7 | padding: 0 4px; 8 | background-color: rgba(0, 0, 0, 0.5); 9 | border-radius: 9px; 10 | cursor: default; 11 | 12 | .point { 13 | width: 2.5px; 14 | height: 2.5px; 15 | background-color: #d4d4d4; 16 | border-radius: 100%; 17 | } 18 | 19 | &:hover { 20 | background-color: #fd6b11; 21 | } 22 | } 23 | 24 | .popover_content { 25 | padding: 8px 0; 26 | 27 | .popover_item { 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | width: 102px; 32 | height: 48px; 33 | text-align: center; 34 | background: #fff; 35 | cursor: pointer; 36 | 37 | &:hover { 38 | background: #fafafa; 39 | } 40 | } 41 | 42 | .popover_item_icon { 43 | display: flex; 44 | align-items: center; 45 | margin-right: 10px; 46 | } 47 | 48 | .disable { 49 | cursor: no-drop; 50 | opacity: 0.5; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/MenuPopover/MenuPopover.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useState, useEffect } from 'react'; 2 | import { Popover, PopoverProps } from 'antd'; 3 | import cls from 'classnames'; 4 | 5 | import Style from './MenuPopover.module.less'; 6 | 7 | interface Action { 8 | status?: string | boolean; 9 | name: string; 10 | handle?: (data?: T) => void; 11 | icon?: ReactNode; 12 | disable?: boolean; 13 | } 14 | 15 | interface MenuPopoverProps 16 | extends Omit { 17 | className?: string; 18 | children?: ReactNode; 19 | actionList: Action[]; 20 | itemClassName?: string; 21 | } 22 | 23 | export default function MenuPopover(props: MenuPopoverProps) { 24 | const { 25 | className, 26 | children, 27 | open = false, 28 | actionList, 29 | onOpenChange, 30 | overlayInnerStyle, 31 | itemClassName, 32 | ...otherProps 33 | } = props; 34 | 35 | const [visible, setVisible] = useState(open); 36 | 37 | useEffect(() => { 38 | setVisible(open); 39 | }, [open]); 40 | 41 | const handleClickMenu = (action: Action) => { 42 | if (action.disable) return; 43 | action.handle?.(); 44 | setVisible(false); 45 | onOpenChange?.(false); 46 | }; 47 | 48 | const openChange = (open: boolean) => { 49 | setVisible(open); 50 | onOpenChange?.(open); 51 | }; 52 | 53 | /** popover 框内容渲染 */ 54 | const renderContent = ( 55 |
56 | {actionList.map(action => ( 57 |
{ 63 | handleClickMenu(action); 64 | }} 65 | > 66 | {action.icon && ( 67 |
{action.icon}
68 | )} 69 | {action.name} 70 |
71 | ))} 72 |
73 | ); 74 | 75 | /** popover 的元素渲染 */ 76 | const renderChildren = children || ( 77 |
78 | {Array.from({ length: 3 }, (_, index) => ( 79 |
80 | ))} 81 |
82 | ); 83 | 84 | return ( 85 | 97 | {renderChildren} 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/components/MenuPopover/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MenuPopover'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Back/Back.module.less: -------------------------------------------------------------------------------- 1 | .back_container { 2 | position: relative; 3 | height: 100%; 4 | } 5 | 6 | .image_background { 7 | width: 100%; 8 | height: 100%; 9 | object-fit: cover; 10 | } 11 | 12 | .color_background { 13 | height: 100%; 14 | background: color; 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Back/Back.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import { BackgroundStruc } from '@/models/LayerStruc'; 3 | import { LayerProps } from '../Layer'; 4 | import Style from './Back.module.less'; 5 | 6 | interface BackProps extends LayerProps {} 7 | 8 | function Back(props: BackProps) { 9 | const { model } = props; 10 | const { url, color, isColorFill, isImageFill } = model; 11 | 12 | return ( 13 |
14 | {isImageFill && ( 15 | 16 | )} 17 | {isColorFill && ( 18 |
19 | )} 20 |
21 | ); 22 | } 23 | 24 | export default observer(Back); 25 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Back/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Back'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Group/Group.tsx: -------------------------------------------------------------------------------- 1 | export default function Group() { 2 | return
Group
; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Group/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Group'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Image/Image.module.less: -------------------------------------------------------------------------------- 1 | .image_container { 2 | position: absolute; 3 | top: 0; 4 | left: 0; 5 | } 6 | 7 | .image { 8 | width: 100%; 9 | height: 100%; 10 | object-fit: contain; 11 | user-select: none; 12 | -webkit-user-drag: none; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import { ImageStruc } from '@/models/LayerStruc'; 3 | import { LayerProps } from '../Layer'; 4 | import Style from './Image.module.less'; 5 | 6 | interface ImageProps extends LayerProps {} 7 | 8 | function Image(props: ImageProps) { 9 | const { model, style } = props; 10 | const { url } = model; 11 | return ( 12 |
13 | 14 |
15 | ); 16 | } 17 | 18 | export default observer(Image); 19 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Image/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Image'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Layer.module.less: -------------------------------------------------------------------------------- 1 | .layer { 2 | position: absolute; 3 | overflow: hidden; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Layer.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, CSSProperties } from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import { LayerTypeEnum } from '@/constants/LayerTypeEnum'; 4 | import { LayerStrucType } from '@/types/model'; 5 | import Image from './Image'; 6 | import Text from './Text'; 7 | import Group from './Group'; 8 | import Back from './Back'; 9 | import Shape from './Shape'; 10 | import Style from './Layer.module.less'; 11 | import { useStores } from '@/store'; 12 | import { getLayerOuterStyles, getLayerInnerStyles } from '@/helpers/Styles'; 13 | import { moveHandle } from '@/utils/move'; 14 | import { NodeNameplate } from '@/constants/NodeNamePlate'; 15 | 16 | const LayerCmpMap = { 17 | [LayerTypeEnum.BACKGROUND]: Back, 18 | [LayerTypeEnum.GROUP]: Group, 19 | [LayerTypeEnum.TEXT]: Text, 20 | [LayerTypeEnum.IMAGE]: Image, 21 | [LayerTypeEnum.SHAPE]: Shape, 22 | }; 23 | 24 | export interface LayerProps { 25 | model: M; 26 | /** 27 | * 画布缩放级别 28 | */ 29 | zoomLevel?: number; 30 | 31 | style?: CSSProperties; 32 | } 33 | 34 | function Layer( 35 | props: LayerProps 36 | ) { 37 | const { model, zoomLevel = 1 } = props; 38 | const { magic, OS } = useStores(); 39 | const LayerCmp = LayerCmpMap[model.type] as ComponentType< 40 | LayerProps 41 | >; 42 | 43 | if (!LayerCmp || !model.visible) return null; 44 | const outerStyle = getLayerOuterStyles(model); 45 | const innerStyle = getLayerInnerStyles(model); 46 | 47 | /** 48 | * 鼠标按下时触发 49 | */ 50 | const handleMouseDown = (e: React.MouseEvent) => { 51 | // 0 左键 2 右键 52 | if (![0, 2].includes(e.button) || model.actived) return; 53 | 54 | /** 如果是右键,并且已经存在活动组件,不做选择行为 */ 55 | if (e.button === 2 && magic.activedLayers.length) { 56 | return; 57 | } 58 | 59 | magic.activeLayer(model, e.shiftKey); 60 | 61 | if (model.isLock) return; 62 | moveHandle(e.nativeEvent, model, zoomLevel); 63 | }; 64 | 65 | /** 66 | * 鼠标进入时触发 67 | */ 68 | const handleMouseEnter = () => { 69 | if (OS.isEditing || model.isBack()) return; 70 | magic.hoverLayer(model); 71 | }; 72 | 73 | /** 74 | * 鼠标离开时触发 75 | */ 76 | const handleMouseLeave = () => { 77 | if (OS.isEditing || model.isBack()) return; 78 | magic.hoverLayer(null); 79 | }; 80 | 81 | return ( 82 |
94 | 95 |
96 | ); 97 | } 98 | 99 | export default observer(Layer); 100 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Shape/Shape.tsx: -------------------------------------------------------------------------------- 1 | import { ShapeStruc } from '@/models/LayerStruc'; 2 | import { LayerProps } from '../Layer'; 3 | 4 | interface ShapeProps 5 | extends LayerProps> {} 6 | 7 | export default function Shape(props: ShapeProps) { 8 | const { model } = props; 9 | const { width, height, rx, ry } = model; 10 | 11 | return ( 12 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Shape/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Shape'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Text/Text.module.less: -------------------------------------------------------------------------------- 1 | .text-container { 2 | position: relative; 3 | // height: 100%; 4 | 5 | :global { 6 | .ql-editor { 7 | padding: 0; 8 | overflow: visible; 9 | font-family: inherit; 10 | line-height: inherit; 11 | white-space: break-spaces; 12 | text-align: inherit; 13 | outline: none; 14 | user-select: none; 15 | line-break: anywhere; 16 | } 17 | 18 | .ql-clipboard { 19 | position: absolute; 20 | top: 50%; 21 | left: -100000px; 22 | height: 1px; 23 | overflow-y: hidden; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useState } from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import Quill from 'quill'; 4 | import Delta from 'quill-delta'; 5 | import { TextStruc } from '@/models/LayerStruc'; 6 | import { LayerProps } from '../Layer'; 7 | import Style from './Text.module.less'; 8 | 9 | interface TextProps extends LayerProps {} 10 | 11 | function Text(props: TextProps) { 12 | const { model, style } = props; 13 | 14 | const { content, charAttrs, isEditing } = model; 15 | 16 | const [textValue, setTextValue] = useState(''); 17 | 18 | const textMainRef = useRef(null); 19 | const quillRef = useRef(null); 20 | /** 21 | * 实例化quill 22 | * */ 23 | const initEditor = () => { 24 | if (!textMainRef.current) return; 25 | quillRef.current = new Quill(textMainRef.current, {}); 26 | quillRef.current?.enable(false); 27 | quillRef.current?.blur(); 28 | }; 29 | 30 | const formatTitleText = (val: string): Delta => { 31 | quillRef.current?.setText(val); 32 | if (charAttrs?.length) { 33 | charAttrs.forEach((i: Delta) => { 34 | const { bgColor, color, start, endPos } = i; 35 | if (bgColor) { 36 | quillRef.current?.formatText(start, endPos - start, { 37 | background: bgColor, 38 | }); 39 | } 40 | if (color) { 41 | quillRef.current?.formatText(start, endPos - start, { 42 | color, 43 | }); 44 | } 45 | }); 46 | } 47 | return quillRef.current?.getContents(); 48 | }; 49 | 50 | /** 51 | * 初始化富文本内容 52 | * */ 53 | const initTextContent = (val: string) => { 54 | const delta = formatTitleText(val); 55 | quillRef.current?.setContents(delta); 56 | }; 57 | 58 | /** 获取文字 */ 59 | const getTextValue = () => content || '双击编辑文字'; 60 | 61 | /** 初始化文字内容 */ 62 | useEffect(() => { 63 | setTextValue(getTextValue()); 64 | }, [content]); 65 | 66 | /** 初始化富文本 */ 67 | useEffect(() => { 68 | initEditor(); 69 | }, []); 70 | 71 | useEffect(() => { 72 | initTextContent(textValue); 73 | }, [textValue, charAttrs]); 74 | 75 | return ( 76 |
77 | {/* 文字主体 */} 78 |
86 |
87 | ); 88 | } 89 | 90 | export default observer(Text); 91 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Text/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Text'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Layer'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Renderer.module.less: -------------------------------------------------------------------------------- 1 | .renderer { 2 | position: relative; 3 | width: auto; 4 | height: 100%; 5 | overflow: hidden; 6 | transform-origin: top left; 7 | pointer-events: none; 8 | } 9 | 10 | .editable { 11 | pointer-events: auto; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Renderer/Renderer.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react'; 2 | import cls from 'classnames'; 3 | import { observer } from 'mobx-react'; 4 | import SceneStruc from '@/models/SceneStruc'; 5 | import Layer from './Layer'; 6 | import Style from './Renderer.module.less'; 7 | 8 | interface RendererProps { 9 | zoomLevel?: number; 10 | 11 | scene: SceneStruc; 12 | 13 | editable?: boolean; 14 | 15 | style?: CSSProperties; 16 | 17 | className?: string; 18 | } 19 | 20 | function Renderer(props: RendererProps) { 21 | const { scene, zoomLevel = 1, editable, style, className } = props; 22 | const { layers } = scene; 23 | 24 | return ( 25 |
31 | {layers?.map(layer => ( 32 | 33 | ))} 34 |
35 | ); 36 | } 37 | 38 | export default observer(Renderer); 39 | -------------------------------------------------------------------------------- /src/components/Renderer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Renderer'; 2 | -------------------------------------------------------------------------------- /src/components/SettingContainer/SettingContainer.module.less: -------------------------------------------------------------------------------- 1 | .setting_container { 2 | height: 100%; 3 | } 4 | 5 | .title { 6 | height: 48px; 7 | padding: 0 24px; 8 | font-weight: 700; 9 | font-size: 14px; 10 | line-height: 48px; 11 | border-bottom: 1px solid rgb(229, 231, 235); 12 | } 13 | 14 | .content { 15 | height: calc(100% - 48px); 16 | padding: 16px; 17 | overflow-y: auto; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/SettingContainer/SettingContainer.tsx: -------------------------------------------------------------------------------- 1 | import Style from './SettingContainer.module.less'; 2 | 3 | interface SettingContainerProps { 4 | title: string; 5 | children?: React.ReactElement[] | React.ReactElement; 6 | } 7 | 8 | export default function SettingContainer(props: SettingContainerProps) { 9 | const { title, children } = props; 10 | return ( 11 |
12 |
{title}
13 |
{children}
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/components/SettingContainer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './SettingContainer'; 2 | -------------------------------------------------------------------------------- /src/components/SliderNumberInput/SliderNumberInput.module.less: -------------------------------------------------------------------------------- 1 | .slider_number_input { 2 | display: flex; 3 | gap: 10px; 4 | align-items: center; 5 | width: 100%; 6 | } 7 | 8 | .slider { 9 | flex: 1; 10 | } 11 | 12 | .input_number { 13 | width: 50px; 14 | height: 26px; 15 | 16 | :global { 17 | .ant-input-number-handler-wrap { 18 | width: 15px; 19 | } 20 | 21 | .ant-input-number-input { 22 | padding: 0 6px; 23 | } 24 | 25 | .ant-input-number-input-wrap, 26 | .ant-input-number-input { 27 | height: 100%; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/SliderNumberInput/SliderNumberInput.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { Slider, InputNumber } from 'antd'; 3 | import Style from './SliderNumberInput.module.less'; 4 | 5 | interface SliderNumberInputProps { 6 | prefixIcon?: ReactNode; 7 | min?: number; 8 | max?: number; 9 | value?: number; 10 | onChange?: (val: number) => void; 11 | } 12 | 13 | export default function SliderNumberInput(props: SliderNumberInputProps) { 14 | const { prefixIcon, value, min = 0, max = 100, onChange } = props; 15 | 16 | const inputChange = (value: number | null) => { 17 | onChange?.(Number(value)); 18 | }; 19 | 20 | return ( 21 |
22 | {prefixIcon} 23 | 24 | 31 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/SliderNumberInput/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './SliderNumberInput'; 2 | -------------------------------------------------------------------------------- /src/components/Upload/Upload.tsx: -------------------------------------------------------------------------------- 1 | import { ReactElement, cloneElement, useRef } from 'react'; 2 | 3 | interface UploadProps { 4 | children: ReactElement; 5 | accept?: string[]; 6 | multiple?: boolean; 7 | onChange?: (files: File[]) => void; 8 | } 9 | 10 | export default function Upload(props: UploadProps) { 11 | const { children, multiple, accept, onChange } = props; 12 | 13 | const inputRef = useRef(null); 14 | 15 | const handleClick = () => { 16 | inputRef.current?.click(); 17 | children.props.onClick?.(); 18 | }; 19 | 20 | const handleChange = (e: React.ChangeEvent) => { 21 | const files = Array.from(e.target.files || []); 22 | e.target.value = ''; 23 | if (!files.length) return; 24 | onChange?.(files); 25 | }; 26 | 27 | return ( 28 | <> 29 | {cloneElement(children, { onClick: handleClick })} 30 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/Upload/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Upload'; 2 | -------------------------------------------------------------------------------- /src/config/Cmd.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 执行终端 3 | */ 4 | 5 | import CmdEnum from '@/constants/CmdEnum'; 6 | 7 | export type CmdHandler = (data?: any) => void; 8 | 9 | export interface CmdItem { 10 | /** 命令名称 */ 11 | name: CmdEnum; 12 | 13 | /** 命令标签 */ 14 | label: string; 15 | 16 | /** 逆向操作命令 */ 17 | reverseLabel?: string; 18 | 19 | /** 命令简介 */ 20 | desc?: string; 21 | } 22 | 23 | const cmdMaps: Record = { 24 | [CmdEnum.COPY]: { 25 | name: CmdEnum.COPY, 26 | label: '复制', 27 | }, 28 | [CmdEnum.CUT]: { 29 | name: CmdEnum.CUT, 30 | label: '剪切', 31 | }, 32 | [CmdEnum.PASTE]: { 33 | name: CmdEnum.PASTE, 34 | label: '粘贴', 35 | }, 36 | [CmdEnum.UNDO]: { 37 | name: CmdEnum.UNDO, 38 | label: '撤销', 39 | }, 40 | [CmdEnum.REDO]: { 41 | name: CmdEnum.REDO, 42 | label: '恢复', 43 | }, 44 | [CmdEnum.DELETE]: { 45 | name: CmdEnum.DELETE, 46 | label: '删除', 47 | }, 48 | [CmdEnum.ESC]: { 49 | name: CmdEnum.ESC, 50 | label: '取消', 51 | }, 52 | [CmdEnum.SAVE]: { 53 | name: CmdEnum.SAVE, 54 | label: '保存', 55 | }, 56 | [CmdEnum.LOCK]: { 57 | name: CmdEnum.LOCK, 58 | label: '锁定', 59 | }, 60 | [CmdEnum.UNLOCK]: { 61 | name: CmdEnum.UNLOCK, 62 | label: '解锁', 63 | }, 64 | [CmdEnum.PREVIEW]: { 65 | name: CmdEnum.PREVIEW, 66 | label: '预览', 67 | }, 68 | [CmdEnum['TO UP']]: { 69 | name: CmdEnum['TO UP'], 70 | label: '上移', 71 | }, 72 | [CmdEnum['TO BUTTOM']]: { 73 | name: CmdEnum['TO BUTTOM'], 74 | label: '下移', 75 | }, 76 | [CmdEnum['TO LEFT']]: { 77 | name: CmdEnum['TO LEFT'], 78 | label: '左移', 79 | }, 80 | [CmdEnum['TO RIGHT']]: { 81 | name: CmdEnum['TO RIGHT'], 82 | label: '右移', 83 | }, 84 | [CmdEnum['TO UP 10PX']]: { 85 | name: CmdEnum['TO UP 10PX'], 86 | label: '上移10px', 87 | }, 88 | [CmdEnum['TO BUTTOM 10PX']]: { 89 | name: CmdEnum['TO BUTTOM 10PX'], 90 | label: '下移10px', 91 | }, 92 | [CmdEnum['TO LEFT 10PX']]: { 93 | name: CmdEnum['TO LEFT 10PX'], 94 | label: '左移10px', 95 | }, 96 | [CmdEnum['TO RIGHT 10PX']]: { 97 | name: CmdEnum['TO RIGHT 10PX'], 98 | label: '右移10px', 99 | }, 100 | [CmdEnum['SELECT ALL']]: { 101 | name: CmdEnum['SELECT ALL'], 102 | label: '全选', 103 | }, 104 | [CmdEnum['SELECT MULTI']]: { 105 | name: CmdEnum['SELECT MULTI'], 106 | label: '多选', 107 | }, 108 | [CmdEnum['ZOOM IN']]: { 109 | name: CmdEnum['ZOOM IN'], 110 | label: '放大', 111 | }, 112 | [CmdEnum['ZOOM OUT']]: { 113 | name: CmdEnum['ZOOM OUT'], 114 | label: '缩小', 115 | }, 116 | [CmdEnum.GROUP]: { 117 | name: CmdEnum.GROUP, 118 | label: '组合', 119 | }, 120 | [CmdEnum['BREAK GROUP']]: { 121 | name: CmdEnum['BREAK GROUP'], 122 | label: '打散', 123 | }, 124 | }; 125 | 126 | export default cmdMaps; 127 | -------------------------------------------------------------------------------- /src/config/ColorList.ts: -------------------------------------------------------------------------------- 1 | /** 背景预设颜色 */ 2 | export const BackColorList = [ 3 | 'rgb(232,221,203)', 4 | 'rgb(205,179,128)', 5 | 'rgb(3,101,100)', 6 | 'rgb(3,54,73)', 7 | 'rgb(3,22,52)', 8 | 'rgb(255,67,101)', 9 | 'rgb(252,157,153)', 10 | 'rgb(249,204,173)', 11 | 'rgb(201,200,170)', 12 | 'rgb(132,175,155)', 13 | 'rgb(17,63,61)', 14 | 'rgb(60,79,57)', 15 | 'rgb(95,92,51)', 16 | 'rgb(179,214,110)', 17 | 'rgb(248,147,29)', 18 | 'rgb(227,160,92)', 19 | 'rgb(178,190,126)', 20 | 'rgb(114,111,128)', 21 | 'rgb(57,13,49)', 22 | 'rgb(90,61,66)', 23 | ]; 24 | -------------------------------------------------------------------------------- /src/config/Fonts.ts: -------------------------------------------------------------------------------- 1 | /** 系统字体 */ 2 | export const SYS_FONTS = [ 3 | { label: 'Arial', value: 'Arial' }, 4 | { label: '微软雅黑', value: 'Microsoft Yahei' }, 5 | { label: '宋体', value: 'SimSun' }, 6 | { label: '黑体', value: 'SimHei' }, 7 | { label: '楷体', value: 'KaiTi' }, 8 | { label: '新宋体', value: 'NSimSun' }, 9 | { label: '仿宋', value: 'FangSong' }, 10 | { label: '苹方', value: 'PingFang SC' }, 11 | { label: '华文黑体', value: 'STHeiti' }, 12 | { label: '华文楷体', value: 'STKaiti' }, 13 | { label: '华文宋体', value: 'STSong' }, 14 | { label: '华文仿宋', value: 'STFangSong' }, 15 | { label: '华文中宋', value: 'STZhongSong' }, 16 | { label: '华文琥珀', value: 'STHupo' }, 17 | { label: '华文新魏', value: 'STXinwei' }, 18 | { label: '华文隶书', value: 'STLiti' }, 19 | { label: '华文行楷', value: 'STXingkai' }, 20 | { label: '冬青黑体', value: 'Hiragino Sans GB' }, 21 | { label: '兰亭黑', value: 'Lantinghei SC' }, 22 | { label: '偏偏体', value: 'Hanzipen SC' }, 23 | { label: '手札体', value: 'Hannotate SC' }, 24 | { label: '宋体', value: 'Songti SC' }, 25 | { label: '娃娃体', value: 'Wawati SC' }, 26 | { label: '行楷', value: 'Xingkai SC' }, 27 | { label: '圆体', value: 'Yuanti SC' }, 28 | { label: '华文细黑', value: 'STXihei' }, 29 | { label: '幼圆', value: 'YouYuan' }, 30 | { label: '隶书', value: 'LiSu' }, 31 | ]; 32 | -------------------------------------------------------------------------------- /src/config/Mocks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getBackDefaultValues, 3 | getImageDefaultValues, 4 | getTextDefaultValues, 5 | } from './DefaultValues'; 6 | import { LayerTypeEnum } from '@/constants/LayerTypeEnum'; 7 | import { MIME_TYPES } from '@/constants/MimeTypes'; 8 | import { TEMPLATE_HEIGHT, TEMPLATE_WIDTH } from '@/constants/TemplateSize'; 9 | import { randomString } from '@/utils/random'; 10 | 11 | const backLayer: LayerModel.Background = { 12 | ...getBackDefaultValues(), 13 | name: '第一张图片', 14 | color: '#09152A', 15 | width: TEMPLATE_WIDTH, 16 | height: TEMPLATE_HEIGHT, 17 | isLock: true, 18 | }; 19 | 20 | const layer1: LayerModel.Text = { 21 | ...getTextDefaultValues(), 22 | content: 'Magic', 23 | color: '#ffffff', 24 | width: 285, 25 | x: 110, 26 | y: 189, 27 | fontWeight: 'bold', 28 | rotate: 0, 29 | }; 30 | 31 | const layer2: LayerModel.Text = { 32 | ...getTextDefaultValues(), 33 | width: 700, 34 | content: 'React + TypeScript + Vite', 35 | color: '#ffffff', 36 | fontSize: 54, 37 | x: 110, 38 | y: 329, 39 | }; 40 | 41 | const layer3: LayerModel.Image = { 42 | ...getImageDefaultValues(), 43 | name: '第二张图片', 44 | type: LayerTypeEnum.IMAGE, 45 | width: 864, 46 | height: 889, 47 | x: 216, 48 | y: 794, 49 | rotate: 0, 50 | url: 'https://img.shanjian.tv/video/document/2025/05/21/17/1196x1280/3b76534d8cb09c15eacc42494c3cfd68.png', 51 | mimeType: MIME_TYPES.png, 52 | mask: { 53 | x: 0, 54 | y: 0, 55 | width: 864, 56 | height: 889, 57 | }, 58 | }; 59 | 60 | const scene1: SceneModel = { 61 | id: randomString(), 62 | name: '第一个页面', 63 | layers: [backLayer, layer1, layer2, layer3], 64 | width: TEMPLATE_WIDTH, 65 | height: TEMPLATE_HEIGHT, 66 | }; 67 | 68 | const backLayer2: LayerModel.Background = { 69 | ...backLayer, 70 | color: '#F9CCAD', 71 | }; 72 | const scene2: SceneModel = { 73 | id: randomString(), 74 | name: '第一个页面', 75 | layers: [backLayer2, layer1, layer2, layer3], 76 | width: TEMPLATE_WIDTH, 77 | height: TEMPLATE_HEIGHT, 78 | }; 79 | 80 | export const product1: MagicModel = { 81 | id: randomString(), 82 | name: '第一个作品', 83 | scenes: [scene1, scene2], 84 | }; 85 | -------------------------------------------------------------------------------- /src/config/Shape.ts: -------------------------------------------------------------------------------- 1 | export const ShapeList: Partial[] = [ 2 | { 3 | shapeType: 'rect', 4 | fill: 'rgba(0, 0, 0, 1)', 5 | width: 100, 6 | height: 100, 7 | x: 0, 8 | y: 0, 9 | rx: 20, 10 | ry: 20, 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /src/constants/CacheKeys.ts: -------------------------------------------------------------------------------- 1 | /** 前缀 */ 2 | export const MAGIC_PREFIX = 'MAGIC'; 3 | 4 | /** 画布缩放级别 */ 5 | export const CANVAS_ZOOM_LEVEL = `${MAGIC_PREFIX}:CANVAS:ZOOM-LEVEL`; 6 | -------------------------------------------------------------------------------- /src/constants/CmdEnum.ts: -------------------------------------------------------------------------------- 1 | enum CmdEnum { 2 | /** 复制 */ 3 | 'COPY', 4 | 5 | /** 剪切 */ 6 | 'CUT', 7 | 8 | /** 粘贴 */ 9 | 'PASTE', 10 | 11 | /** 撤销 */ 12 | 'UNDO', 13 | 14 | /** 恢复 */ 15 | 'REDO', 16 | 17 | /** 删除 */ 18 | 'DELETE', 19 | 20 | /** 取消 */ 21 | 'ESC', 22 | 23 | /** 向上 */ 24 | 'TO UP', 25 | 26 | /** 向下 */ 27 | 'TO BUTTOM', 28 | 29 | /** 向左 */ 30 | 'TO LEFT', 31 | 32 | /** 向右 */ 33 | 'TO RIGHT', 34 | 35 | /** 向上 10px */ 36 | 'TO UP 10PX', 37 | 38 | /** 向下 10px */ 39 | 'TO BUTTOM 10PX', 40 | 41 | /** 向左 10px */ 42 | 'TO LEFT 10PX', 43 | 44 | /** 向右 10px */ 45 | 'TO RIGHT 10PX', 46 | 47 | /** 保存 */ 48 | 'SAVE', 49 | 50 | /** 全选 */ 51 | 'SELECT ALL', 52 | 53 | /** 多选 */ 54 | 'SELECT MULTI', 55 | 56 | /** 放大画布 */ 57 | 'ZOOM IN', 58 | 59 | /** 缩小画布 */ 60 | 'ZOOM OUT', 61 | 62 | /** 组合 */ 63 | 'GROUP', 64 | 65 | /** 打散组合 */ 66 | 'BREAK GROUP', 67 | 68 | /** 锁定 */ 69 | 'LOCK', 70 | 71 | /** 解锁 */ 72 | 'UNLOCK', 73 | 74 | /** 预览 */ 75 | 'PREVIEW', 76 | } 77 | 78 | export default CmdEnum; 79 | -------------------------------------------------------------------------------- /src/constants/Device.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 是否是Mac系统 3 | */ 4 | export const isMacOS = /Mac OS X/.test(window.navigator.userAgent); 5 | -------------------------------------------------------------------------------- /src/constants/Font.ts: -------------------------------------------------------------------------------- 1 | export enum FontWeightEnum { 2 | /** 400 */ 3 | Normal = 'normal', 4 | /** 700 */ 5 | Bold = 'bold', 6 | } 7 | 8 | export enum FontStyleEnum { 9 | /** 正常 */ 10 | Normal = 'normal', 11 | /** 斜体 */ 12 | Italic = 'italic', 13 | } 14 | 15 | export enum TextDecorationEnum { 16 | None = 'none', 17 | /** 删除线 */ 18 | LineThrough = 'line-through', 19 | /** 下划线 */ 20 | Underline = 'underline', 21 | } 22 | 23 | export enum TextAlignEnum { 24 | /** 左对齐 */ 25 | Left = 'left', 26 | /** 居中 */ 27 | Center = 'center', 28 | /** 右对齐 */ 29 | Right = 'right', 30 | /** 两边对齐 */ 31 | Justify = 'justify', 32 | } 33 | -------------------------------------------------------------------------------- /src/constants/FontSize.ts: -------------------------------------------------------------------------------- 1 | /** 最小文字大小 */ 2 | export const MIN_FONT_SIZE = 12; 3 | /** 最大文字大小 */ 4 | export const MAX_FONT_SIZE = Infinity; 5 | -------------------------------------------------------------------------------- /src/constants/HotKeyScope.ts: -------------------------------------------------------------------------------- 1 | enum HotKeyScope { 2 | CANVAS = '画布', 3 | LAYER = '图层', 4 | LAYOUT = '布局', 5 | MAGIC = '魔法', 6 | } 7 | 8 | export default HotKeyScope; 9 | -------------------------------------------------------------------------------- /src/constants/KeyCode.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 键盘值对照表 3 | */ 4 | const KeyCodeMap = { 5 | // 字母 6 | A: 65, 7 | B: 66, 8 | C: 67, 9 | D: 68, 10 | E: 69, 11 | F: 70, 12 | G: 71, 13 | H: 72, 14 | I: 73, 15 | J: 74, 16 | K: 75, 17 | L: 76, 18 | M: 77, 19 | N: 78, 20 | O: 79, 21 | P: 80, 22 | Q: 81, 23 | R: 82, 24 | S: 83, 25 | T: 84, 26 | U: 85, 27 | V: 86, 28 | W: 87, 29 | X: 88, 30 | Y: 89, 31 | Z: 90, 32 | 33 | // 横排数字键 34 | 0: 48, 35 | 1: 49, 36 | 2: 50, 37 | 3: 51, 38 | 4: 52, 39 | 5: 53, 40 | 6: 54, 41 | 7: 55, 42 | 8: 56, 43 | 9: 57, 44 | 45 | // 小键盘 46 | /** 小键盘0 */ 47 | MIN_0: 96, 48 | /** 小键盘1 */ 49 | MIN_1: 97, 50 | /** 小键盘2 */ 51 | MIN_2: 98, 52 | /** 小键盘3 */ 53 | MIN_3: 99, 54 | /** 小键盘4 */ 55 | MIN_4: 100, 56 | /** 小键盘5 */ 57 | MIN_5: 101, 58 | /** 小键盘6 */ 59 | MIN_6: 102, 60 | /** 小键盘7 */ 61 | MIN_7: 103, 62 | /** 小键盘8 */ 63 | MIN_8: 104, 64 | /** 小键盘9 */ 65 | MIN_9: 105, 66 | /** 小键盘* */ 67 | '*': 106, 68 | /** 小键盘+ */ 69 | '+': 107, 70 | /** 小键盘回车 */ 71 | MIN_ENTER: 108, 72 | /** 小键盘- */ 73 | '-': 109, 74 | /** 小键盘 小数点 . */ 75 | '.': 110, 76 | /** 小键盘/ */ 77 | '/': 111, 78 | 79 | // F键位 80 | F1: 112, 81 | F2: 113, 82 | F3: 114, 83 | F4: 115, 84 | F5: 116, 85 | F6: 117, 86 | F7: 118, 87 | F8: 119, 88 | F9: 120, 89 | F10: 121, 90 | F11: 122, 91 | F12: 123, 92 | 93 | // 控制键 94 | BACKSPACE: 8, 95 | TAB: 9, 96 | CLEAR: 12, 97 | ENTER: 13, 98 | SHIFT: 16, 99 | CTRL: 17, 100 | ALT: 18, 101 | CAPE_LOCK: 20, 102 | ESC: 27, 103 | SPACEBAR: 32, 104 | PAGE_UP: 33, 105 | PAGE_DOWN: 34, 106 | END: 35, 107 | HOME: 36, 108 | LEFT: 37, 109 | UP: 38, 110 | RIGHT: 39, 111 | DOWN: 40, 112 | INSERT: 45, 113 | DELETE: 46, 114 | NUM_LOCK: 144, 115 | 116 | // 标点符号键 117 | ';:': 186, 118 | '=+': 187, 119 | ',<': 188, 120 | '-_': 189, 121 | '.>': 190, 122 | '/?': 191, 123 | '`~': 192, 124 | '[{': 219, 125 | '|': 220, 126 | ']}': 221, 127 | '"': 222, 128 | 129 | // 多媒体按键 130 | /** 音量加 */ 131 | VOLUME_UP: 175, 132 | /** 音量减 */ 133 | VOLUME_DOWN: 174, 134 | /** 停止 */ 135 | STOP: 179, 136 | /** 静音 */ 137 | MUTE: 173, 138 | /** 浏览器 */ 139 | BROWSER: 172, 140 | /** 邮件 */ 141 | EMAIL: 180, 142 | /** 搜索 */ 143 | SEARCH: 170, 144 | /** 收藏 */ 145 | COLLECT: 171, 146 | }; 147 | 148 | export default KeyCodeMap; 149 | -------------------------------------------------------------------------------- /src/constants/LayerRatio.ts: -------------------------------------------------------------------------------- 1 | /** 复制位置偏移量 与画布大小的百分比 */ 2 | export const COPY_OFFSET_RATIO = 0.01; 3 | -------------------------------------------------------------------------------- /src/constants/LayerTypeEnum.ts: -------------------------------------------------------------------------------- 1 | export enum LayerTypeEnum { 2 | BACKGROUND = 'Background', 3 | TEXT = 'Text', 4 | IMAGE = 'Image', 5 | GROUP = 'Group', 6 | SHAPE = 'Shape', 7 | UNKNOWN = 'Unknown', 8 | } 9 | -------------------------------------------------------------------------------- /src/constants/MaterialEnum.ts: -------------------------------------------------------------------------------- 1 | /** 侧边栏 */ 2 | export enum MaterialEnum { 3 | /** 背景 */ 4 | BACK = 'back', 5 | /** 图片 */ 6 | IMAGE = 'image', 7 | /** 文字贴纸 */ 8 | TEXT = 'text', 9 | /** 图形 */ 10 | SHAPE = 'shape', 11 | /** 空 */ 12 | DEFAULT = '', 13 | } 14 | -------------------------------------------------------------------------------- /src/constants/MimeTypes.ts: -------------------------------------------------------------------------------- 1 | export const MIME_TYPES = { 2 | jpg: 'image/jpeg', 3 | jpeg: 'image/jpeg', 4 | jpe: 'image/jpeg', 5 | png: 'image/png', 6 | gif: 'image/gif', 7 | bmp: 'image/bmp', 8 | ief: 'image/ief', 9 | ico: 'image/x-icon', 10 | icon: 'image/x-icon', 11 | svg: 'image/svg+xml', 12 | tiff: 'image/tiff', 13 | tif: 'image/tiff', 14 | webp: 'image/webp', 15 | }; 16 | 17 | /** 可透明图片 */ 18 | export const TRANSPARENT_PICTURE_MIME_TYPES = [ 19 | MIME_TYPES.png, 20 | MIME_TYPES.webp, 21 | MIME_TYPES.gif, 22 | ]; 23 | -------------------------------------------------------------------------------- /src/constants/NodeNamePlate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 节点标识符 3 | */ 4 | export enum NodeNameplate { 5 | /** 舞台 */ 6 | STAGE = 'stage', 7 | 8 | /** 画布 */ 9 | CANVAS = 'canvas', 10 | 11 | /** 画布包装器 */ 12 | CANVAS_WRAP = 'canvas_wrap', 13 | 14 | /** 场景 */ 15 | SCENE = 'scene', 16 | 17 | /** 图层 */ 18 | LAYER = 'layer', 19 | } 20 | -------------------------------------------------------------------------------- /src/constants/PointList.ts: -------------------------------------------------------------------------------- 1 | import { POINT_TYPE } from '@p/EditorTools'; 2 | 3 | export const ALL_POINTS = [ 4 | POINT_TYPE.LEFT_BOTTOM, 5 | POINT_TYPE.LEFT_CENTER, 6 | POINT_TYPE.LEFT_TOP, 7 | POINT_TYPE.RIGHT_BOTTOM, 8 | POINT_TYPE.RIGHT_CENTER, 9 | POINT_TYPE.RIGHT_TOP, 10 | POINT_TYPE.TOP_CENTER, 11 | POINT_TYPE.BOTTOM_CENTER, 12 | ]; 13 | 14 | export const TEXT_POINTS = [ 15 | POINT_TYPE.LEFT_BOTTOM, 16 | POINT_TYPE.LEFT_CENTER, 17 | POINT_TYPE.LEFT_TOP, 18 | POINT_TYPE.RIGHT_BOTTOM, 19 | POINT_TYPE.RIGHT_CENTER, 20 | POINT_TYPE.RIGHT_TOP, 21 | ]; 22 | -------------------------------------------------------------------------------- /src/constants/Refs.ts: -------------------------------------------------------------------------------- 1 | import { createRef } from 'react'; 2 | 3 | /** 舞台 */ 4 | export const CANVAS_WRAPPER = createRef(); 5 | 6 | /** 画布 */ 7 | export const CANVAS_REF = createRef(); 8 | -------------------------------------------------------------------------------- /src/constants/TemplateSize.ts: -------------------------------------------------------------------------------- 1 | export const TEMPLATE_WIDTH = 1080; 2 | 3 | export const TEMPLATE_HEIGHT = 1920; 4 | -------------------------------------------------------------------------------- /src/constants/ZoomLevel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 最小缩放等级 3 | */ 4 | export const CANVAS_MIN_ZOOM_LEVEL = 0.2; 5 | 6 | /** 7 | * 最大缩放等级 8 | */ 9 | export const CANVAS_MAX_ZOOM_LEVEL = 4; 10 | -------------------------------------------------------------------------------- /src/core/Decorator/History.ts: -------------------------------------------------------------------------------- 1 | import { HistoryRecord } from '@/types/history'; 2 | import HistoryManager from '../Manager/History'; 3 | import LayerStruc from '@/models/LayerStruc'; 4 | 5 | type ReverseAction = (this: T, ...rest: any[]) => void; 6 | 7 | type ReverseActionCreator = ( 8 | this: T, 9 | ...rest: any[] 10 | ) => ReverseAction | null; 11 | 12 | /** 13 | * 创建历史记录装饰器 14 | * 传入函数为当前动作的逆向动作:添加 --> 删除 15 | * 若记录的动作没有逆向动作,则不记录 16 | */ 17 | export function createDecorator() { 18 | return function historyDecorator(reverseAction: ReverseActionCreator) { 19 | return function decotatorRecord( 20 | _target: T /** class */, 21 | name: string /** method key */, 22 | desc: PropertyDescriptor /** 属性的描述对象 */ 23 | ) { 24 | /** 装饰的属性方法 */ 25 | const originalAction = desc.value; 26 | /** 重新给装饰的方式赋值 */ 27 | desc.value = function decotatorAction(this: T, ...rest: any[]) { 28 | /** 获取逆向动作,若返回`null`,则不记录,传入的参数 callback 的返回值 */ 29 | const reverse = reverseAction.apply(this, rest); 30 | 31 | if (typeof reverse === 'function') { 32 | /** 重做动作,则取原始动作 */ 33 | const obverse = () => originalAction.apply(this, rest); 34 | const record: HistoryRecord = { 35 | name, 36 | context: rest, 37 | reverse, 38 | obverse, 39 | }; 40 | HistoryManager.push(record); 41 | } 42 | return originalAction.apply(this, rest); 43 | }; 44 | return desc; 45 | }; 46 | }; 47 | } 48 | 49 | /** 50 | * 作品操作历史记录 51 | */ 52 | export const magicHistoryDecorator = createDecorator(); 53 | 54 | /** 55 | * 场景操作历史记录 56 | */ 57 | export const sceneHistoryDecorator = createDecorator(); 58 | 59 | /** 60 | * 图层操作历史记录 61 | */ 62 | export const layerHistoryDecorator = createDecorator(); 63 | -------------------------------------------------------------------------------- /src/core/FormatData/Scene.ts: -------------------------------------------------------------------------------- 1 | import { getSceneDefaultValues } from '@/config/DefaultValues'; 2 | import { createBackData } from './Layer'; 3 | import { randomString } from '@/utils/random'; 4 | 5 | /** 6 | * 创建一个空场景 7 | */ 8 | export function createSceneData(data?: Partial | null): SceneModel { 9 | const layers = [ 10 | createBackData(getSceneDefaultValues()), 11 | ...(data?.layers ?? []), 12 | ]; 13 | return { ...getSceneDefaultValues(), id: randomString(), layers, ...data }; 14 | } 15 | -------------------------------------------------------------------------------- /src/core/Manager/Clipboard.ts: -------------------------------------------------------------------------------- 1 | import { MAGIC_PREFIX } from '@/constants/CacheKeys'; 2 | import CreateLayerStruc from '@/models/FactoryStruc/LayerFactory'; 3 | import MagicStore from '@/store/Magic'; 4 | 5 | const CLIPBOARD_CHANNEL_KEY = 'clipboard_channel_key'; 6 | 7 | export default class ClipboardManager { 8 | private static magic: MagicStore; 9 | 10 | private static channel: BroadcastChannel; 11 | 12 | /** 13 | * 注册剪切板并监听粘贴事件 14 | * @param magic 作品数据 15 | */ 16 | static register(magic: MagicStore) { 17 | this.magic = magic; 18 | this.channel = new BroadcastChannel(CLIPBOARD_CHANNEL_KEY); 19 | this.channel.onmessage = (e: MessageEvent) => { 20 | const data = e.data; 21 | if (data && typeof data === 'string') { 22 | this.parseLayersFromText(data); 23 | } 24 | }; 25 | } 26 | 27 | /** 28 | * 复制到剪切板 29 | */ 30 | static copyToClipboard() { 31 | const layers = this.magic.clipboard; 32 | if (!layers || !layers.length) return; 33 | const text = JSON.stringify(layers.map(layer => layer.model())); 34 | this.channel.postMessage(this.formatText(text)); 35 | } 36 | 37 | /** 38 | * 格式化剪贴板内容,防止系统其他复制动作干扰 39 | * @param text 当前复制的内容 40 | */ 41 | private static formatText(text: string) { 42 | const host = encodeURIComponent(window.location.href); 43 | return `###${MAGIC_PREFIX}:COPY###${host}###${text}###`; 44 | } 45 | 46 | /** 47 | * 从剪贴板内容解析出 layers 48 | * @param text 剪贴板内容 49 | */ 50 | private static parseLayersFromText(text: string) { 51 | const { models = [] } = this.parseText(text) || {}; 52 | if (!models.length) return; 53 | const layers = models.map(model => CreateLayerStruc(model.type, model)); 54 | this.magic.copyLayers(layers); 55 | } 56 | 57 | /** 58 | * 字符串转换为LayerModel 59 | * @param text 剪贴板内容 60 | */ 61 | private static parseText(text: string) { 62 | const reg = /###MAGIC:COPY###(.+?)###(.+?)###/; 63 | const result = text.match(reg); 64 | if (!result) return null; 65 | return { 66 | key: result[1], 67 | models: JSON.parse(result[2]) as LayerModel.Layer[], 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/core/Manager/ContextMenuManager.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect } from 'react'; 2 | import { Stores, useStores } from '@/store'; 3 | import { CANVAS_WRAPPER } from '@/constants/Refs'; 4 | import ContextMenu, { ContextMenuProps } from '@/components/ContextMenu'; 5 | import { getStageContextMenuProps } from '@/config/StageContextMenu'; 6 | 7 | type ContextSourceKeys = RefObject; 8 | type ContextSourceValues = (store: Stores, e: MouseEvent) => ContextMenuProps; 9 | 10 | const contextSourceMap = new Map(); 11 | contextSourceMap.set(CANVAS_WRAPPER, getStageContextMenuProps); 12 | 13 | export default function ContextMenuManager() { 14 | const store = useStores(); 15 | 16 | const handleContextMenu = (e: MouseEvent) => { 17 | const node = e.target as HTMLElement; 18 | 19 | let options: ContextMenuProps | undefined; 20 | 21 | for (const ref of contextSourceMap.keys()) { 22 | if (ref.current?.contains(node)) { 23 | e.preventDefault(); 24 | options = contextSourceMap.get(ref)?.(store, e); 25 | break; 26 | } 27 | } 28 | 29 | options && ContextMenu.show(options); 30 | }; 31 | 32 | useEffect(() => { 33 | document.addEventListener('contextmenu', handleContextMenu, false); 34 | return () => { 35 | document.removeEventListener('contextmenu', handleContextMenu); 36 | }; 37 | }); 38 | 39 | return null; 40 | } 41 | -------------------------------------------------------------------------------- /src/core/Manager/History.ts: -------------------------------------------------------------------------------- 1 | import HistoryStore from '@/store/History'; 2 | import { HistoryRecord } from '@/types/history'; 3 | 4 | /** 5 | * 操作记录管理中心 6 | */ 7 | export default class HistoryManager { 8 | private static history: HistoryStore; 9 | 10 | /** 11 | * 注册历史管理中心 12 | * @static 13 | * @memberof HistoryManager 14 | */ 15 | static register(history: HistoryStore) { 16 | HistoryManager.history = history; 17 | } 18 | 19 | /** 20 | * 追加新纪录 21 | * @param record 新的记录 22 | */ 23 | static push(record: HistoryRecord) { 24 | HistoryManager.history?.push(record); 25 | } 26 | 27 | /** 28 | * 撤销操作 29 | */ 30 | static undo() { 31 | const current = HistoryManager.history.undo(); 32 | current?.reverse(); 33 | } 34 | 35 | /** 36 | * 恢复操作 37 | */ 38 | static redo() { 39 | const current = HistoryManager.history.redo(); 40 | current?.obverse(); 41 | } 42 | 43 | /** 44 | * 重置 45 | */ 46 | static reset() { 47 | HistoryManager.history.clear(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/core/Manager/Keyboard.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import hotKeyMaps, { HotKey } from '@/config/HotKeys'; 3 | import CmdEnum from '@/constants/CmdEnum'; 4 | import { atInput, getInputHotKeys } from '@/helpers/HotKey'; 5 | import CmdManager from './Cmd'; 6 | 7 | export default function KeyboardManager() { 8 | /** 9 | * 执行快捷键命令 10 | */ 11 | const pressHotKey = (hotKey: HotKey) => { 12 | CmdManager.execute(hotKey.name); 13 | }; 14 | 15 | /** 16 | * 处理键盘事件 17 | */ 18 | const handleKeyDown = (e: KeyboardEvent) => { 19 | const keyCode = e.keyCode || e.which; 20 | const { shiftKey, ctrlKey, altKey, metaKey } = e; 21 | 22 | /** 兼容win和mac */ 23 | const ctrl = metaKey || ctrlKey; 24 | 25 | for (const keyBoard of hotKeyMaps) { 26 | /** 键值未注册 */ 27 | if (!keyBoard) continue; 28 | 29 | const code = keyBoard.keyCode; 30 | 31 | if (Array.isArray(code)) { 32 | if (!code.includes(keyCode)) continue; 33 | } else if (code !== keyCode) { 34 | continue; 35 | } 36 | 37 | if ((shiftKey && !keyBoard.shiftKey) || (keyBoard.shiftKey && !shiftKey)) 38 | continue; 39 | 40 | if ((ctrl && !keyBoard.ctrlKey) || (keyBoard.ctrlKey && !ctrl)) continue; 41 | 42 | if ((altKey && !keyBoard.altKey) || (keyBoard.altKey && !altKey)) 43 | continue; 44 | 45 | if ( 46 | atInput(e.target as HTMLElement) && 47 | getInputHotKeys().includes(keyBoard) 48 | ) 49 | return; 50 | 51 | /** 保留系统粘贴 */ 52 | if (keyBoard.name !== CmdEnum.PASTE) e.preventDefault(); 53 | 54 | pressHotKey(keyBoard); 55 | break; 56 | } 57 | }; 58 | 59 | useEffect(() => { 60 | document.addEventListener('keydown', handleKeyDown, false); 61 | return () => { 62 | document.removeEventListener('keydown', handleKeyDown); 63 | }; 64 | }, []); 65 | 66 | return null; 67 | } 68 | -------------------------------------------------------------------------------- /src/core/Manager/LocalCache.ts: -------------------------------------------------------------------------------- 1 | const cacheDataType = { 2 | string: (value: string) => value, 3 | number: (value: string) => (value ? +value : null), 4 | boolean: (value: string) => value === 'true', 5 | object: (value: string) => (value ? JSON.parse(value) : null), 6 | }; 7 | 8 | /** 9 | * 用户本地缓存设置 10 | */ 11 | export default class LocalCache { 12 | static get( 13 | key: string, 14 | type: T 15 | ): ReturnType<(typeof cacheDataType)[T]> | null { 16 | try { 17 | const value = window.localStorage.getItem(key); 18 | const dataType = cacheDataType[type]; 19 | return value && dataType ? dataType(value) : value; 20 | } catch (error: unknown & any) { 21 | console.error('缓存读取异常=%s', error.message); 22 | return null; 23 | } 24 | } 25 | 26 | static set(key: string, value: any) { 27 | try { 28 | if (typeof value === 'object') { 29 | value = JSON.stringify(value); 30 | } 31 | window.localStorage.setItem(key, String(value)); 32 | } catch (error: unknown & any) { 33 | console.error('缓存写入异常=%s', error.message); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/helpers/HotKey.ts: -------------------------------------------------------------------------------- 1 | import hotKeyMaps, { HotKey } from '@/config/HotKeys'; 2 | import CmdEnum from '@/constants/CmdEnum'; 3 | import { isMacOS } from '@/constants/Device'; 4 | 5 | /** 6 | * 通过命令获取快捷键 7 | * @param cmd 命令名称 8 | * @returns { HotKey | undefined } 9 | */ 10 | export function getHotKeyByCmd(cmd: CmdEnum): HotKey | undefined { 11 | return hotKeyMaps.find(hotKey => hotKey.name === cmd); 12 | } 13 | 14 | export function getHotKeyCmdOfOS(hotKey?: HotKey): string { 15 | if (!hotKey || !hotKey.combination) return ''; 16 | const key = hotKey.combination.split('/'); 17 | if (!key || key.length <= 0) return hotKey.combination || ''; 18 | return isMacOS ? key[0] : key[1]; 19 | } 20 | 21 | /** 22 | * 获取快捷键描述 23 | * @param cmd 命令名称 24 | * @param reverse 逆向命令 25 | * @returns { string } 26 | */ 27 | export function getCombination(cmd: CmdEnum, reverse?: boolean): string { 28 | const hotKey = getHotKeyByCmd(cmd); 29 | const combination = getHotKeyCmdOfOS(hotKey); 30 | const label = reverse ? hotKey?.reverseLabel : hotKey?.label; 31 | return `${label}(${combination})`; 32 | } 33 | 34 | /** 35 | * 当前光标是否在输入框内 36 | * @param element 当前的节点位置 37 | * @returns {boolean} 38 | */ 39 | export function atInput(element: HTMLElement): boolean { 40 | return ( 41 | ['INPUT', 'TEXTAREA'].includes(element.nodeName) || 42 | element.contentEditable === 'true' 43 | ); 44 | } 45 | 46 | /** 47 | * 过滤输入框内的快捷键 48 | */ 49 | export function getInputHotKeys(): Array { 50 | return [ 51 | getHotKeyByCmd(CmdEnum.COPY), 52 | getHotKeyByCmd(CmdEnum.CUT), 53 | getHotKeyByCmd(CmdEnum.PASTE), 54 | getHotKeyByCmd(CmdEnum.UNDO), 55 | getHotKeyByCmd(CmdEnum.REDO), 56 | getHotKeyByCmd(CmdEnum.DELETE), 57 | getHotKeyByCmd(CmdEnum['SELECT ALL']), 58 | getHotKeyByCmd(CmdEnum['TO UP']), 59 | getHotKeyByCmd(CmdEnum['TO BUTTOM']), 60 | getHotKeyByCmd(CmdEnum['TO LEFT']), 61 | getHotKeyByCmd(CmdEnum['TO RIGHT']), 62 | getHotKeyByCmd(CmdEnum['TO UP 10PX']), 63 | getHotKeyByCmd(CmdEnum['TO BUTTOM 10PX']), 64 | getHotKeyByCmd(CmdEnum['TO LEFT 10PX']), 65 | getHotKeyByCmd(CmdEnum['TO RIGHT 10PX']), 66 | ]; 67 | } 68 | -------------------------------------------------------------------------------- /src/helpers/Node.ts: -------------------------------------------------------------------------------- 1 | import { NodeNameplate } from '@/constants/NodeNamePlate'; 2 | 3 | /** 4 | * 返回画布节点 5 | * @returns {HTMLElement | null} `HTMLElement` 画布真实节点 6 | */ 7 | export function getCanvasNode(): HTMLElement | null { 8 | return document.querySelector( 9 | `[data-nameplate="${NodeNameplate.CANVAS}"]` 10 | ); 11 | } 12 | 13 | /** 14 | * 返回画布的真实尺寸 15 | * @returns {DOMRect | null } `DOMRect` dom的矩形信息 16 | */ 17 | export function getCanvasRectInfo(): DOMRect | null { 18 | const $canvas = getCanvasNode(); 19 | if (!$canvas) return null; 20 | return $canvas.getBoundingClientRect(); 21 | } 22 | 23 | /** 24 | * 返回画布包装器节点 25 | * @returns {HTMLElement | null} `HTMLElement` 画布包装器真实节点 26 | */ 27 | export function getCanvasWrapNode(): HTMLElement | null { 28 | return document.querySelector( 29 | `[data-nameplate="${NodeNameplate.CANVAS_WRAP}"]` 30 | ); 31 | } 32 | 33 | /** 34 | * 返回画布包装器盒子信息 35 | * @returns {DOMRect | null} `DOMRect` 画布包装器盒子信息 36 | */ 37 | export function getCanvasWrapRectInfo(): DOMRect | null { 38 | const node = getCanvasWrapNode(); 39 | if (!node) return null; 40 | return node.getBoundingClientRect(); 41 | } 42 | 43 | /** 44 | * 返回相对于画布的坐标 45 | * @param point 相对的可视坐标 46 | * @returns {Point} 47 | */ 48 | export function toCanvasPoint(point: Point): Point { 49 | const cnavasNode = getCanvasNode(); 50 | if (!cnavasNode) return point; 51 | const { left, top } = cnavasNode.getBoundingClientRect(); 52 | return { x: point.x - left, y: point.y - top }; 53 | } 54 | -------------------------------------------------------------------------------- /src/helpers/Obb.ts: -------------------------------------------------------------------------------- 1 | import { toRadian } from '@p/EditorTools'; 2 | 3 | /** 4 | * 2d 向量 5 | */ 6 | export class Vector2d { 7 | x: number; 8 | 9 | y: number; 10 | 11 | constructor(x = 0, y = 0) { 12 | this.x = x; 13 | this.y = y; 14 | } 15 | 16 | /** 若b为单位矢量,则a与b的点积即为a在方向b的投影 */ 17 | sub(v: Vector2d) { 18 | return new Vector2d(this.x - v.x, this.y - v.y); 19 | } 20 | 21 | /** 向量点积 */ 22 | dot(v: Vector2d) { 23 | return this.x * v.x + this.y * v.y; 24 | } 25 | } 26 | 27 | /** 28 | * obb 碰撞 29 | **/ 30 | export class OBB { 31 | /** 宽 */ 32 | width: number; 33 | 34 | /** 高 */ 35 | height: number; 36 | 37 | /** 弧度 */ 38 | radian: number; 39 | 40 | /** 范围 */ 41 | extents: [number, number]; 42 | 43 | /** 旋转轴 */ 44 | axes: [Vector2d, Vector2d]; 45 | 46 | /** 中心点 */ 47 | centerPoint: Vector2d; 48 | 49 | constructor( 50 | centerPoint: Vector2d, 51 | width: number, 52 | height: number, 53 | rotate = 0 54 | ) { 55 | this.radian = toRadian(rotate); 56 | this.centerPoint = centerPoint; 57 | this.extents = [width / 2, height / 2]; 58 | this.axes = [ 59 | new Vector2d(Math.cos(this.radian), Math.sin(this.radian)), 60 | new Vector2d(-1 * Math.sin(this.radian), Math.cos(this.radian)), 61 | ]; 62 | this.width = width; 63 | this.height = height; 64 | } 65 | 66 | /** 获取投影半径 */ 67 | getProjectionRadius(axis: Vector2d) { 68 | return ( 69 | this.extents[0] * Math.abs(axis.dot(this.axes[0])) + 70 | this.extents[1] * Math.abs(axis.dot(this.axes[1])) 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/helpers/Styles.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'react'; 2 | import { LayerStrucType } from '@/types/model'; 3 | 4 | /** 5 | * 应用在容器上的样式 6 | */ 7 | const onContainerKeys = ['width', 'height', 'opacity']; 8 | 9 | /** 10 | * 在图层上的样式 11 | */ 12 | const onLayerKeys = [ 13 | 'fontFamily', 14 | 'color', 15 | 'fontSize', 16 | 'lineHeight', 17 | 'letterSpacing', 18 | 'fontWeight', 19 | 'fontStyle', 20 | 'textDecoration', 21 | 'textAlign', 22 | 'backgroundColor', 23 | ]; 24 | 25 | /** 26 | * 获取图层容器上的样式 27 | * @param model 组件数据 28 | * @param zoomLevel 缩放比例 29 | * @returns CSSProperties 30 | */ 31 | export function getLayerOuterStyles( 32 | model: M, 33 | zoomLevel = 1 34 | ): CSSProperties { 35 | let containeStyle: CSSProperties = onContainerKeys.reduce((styles, key) => { 36 | if (Reflect.has(model, key)) { 37 | return { 38 | ...styles, 39 | [key]: model[key], 40 | }; 41 | } 42 | return styles; 43 | }, {}); 44 | 45 | /** 禁用 */ 46 | if (model.disabled) { 47 | const { opacity = 1 } = model; 48 | containeStyle.opacity = +opacity * 0.5; 49 | } 50 | containeStyle = { 51 | ...containeStyle, 52 | ...getLayerRectStyles(model, zoomLevel), 53 | }; 54 | 55 | return containeStyle; 56 | } 57 | 58 | /** 59 | * 单独拼接transform 60 | * @param style 组件样式属性 61 | * @zoomLevel zoomLevel 缩放比例 62 | * @returns 图层的transform 63 | */ 64 | export function getLayerRectStyles( 65 | model: M, 66 | zoomLevel = 1 67 | ): CSSProperties { 68 | const { x, y, rotate, width, height, scale } = model.getRectData(); 69 | 70 | return { 71 | width: width * zoomLevel, 72 | height: height * zoomLevel, 73 | transform: `translate(${x * zoomLevel}px,${ 74 | y * zoomLevel 75 | }px) rotate(${rotate}deg) scale(${scale.x},${scale.y}) `, 76 | }; 77 | } 78 | 79 | /** 80 | * 获取图层的内部样式 81 | * @param model 82 | * @param zoomLevel 83 | * @returns {CSSProperties} 84 | */ 85 | export function getLayerInnerStyles( 86 | model: M, 87 | zoomLevel = 1 88 | ): CSSProperties { 89 | let styles = onLayerKeys.reduce((styles, key) => { 90 | if (Reflect.has(model, key)) { 91 | return { 92 | ...styles, 93 | [key]: model[key], 94 | }; 95 | } 96 | return styles; 97 | }, {}); 98 | 99 | if (model.isImage()) { 100 | styles = { 101 | ...styles, 102 | ...getMaskStyle(model, zoomLevel), 103 | }; 104 | } 105 | 106 | return styles; 107 | } 108 | 109 | /** 110 | * 获取蒙层样式 111 | * @param model 112 | * @param zoomLevel 113 | * @returns {CSSProperties} 114 | */ 115 | export function getMaskStyle( 116 | model: M, 117 | zoomLevel = 1 118 | ): CSSProperties { 119 | const { mask, width = 0, height = 0 } = model; 120 | if (!mask) return {}; 121 | const { x, y } = mask; 122 | 123 | return { 124 | width: width * zoomLevel, 125 | height: height * zoomLevel, 126 | transform: `translate(${-x * zoomLevel}px,${-y * zoomLevel}px)`, 127 | }; 128 | } 129 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useEscapeClosed } from './useResizeObserver'; 2 | export { default as useEscapeClose } from './useEscapeClose'; 3 | export { default as useGlobalClick } from './useGlobalClick'; 4 | -------------------------------------------------------------------------------- /src/hooks/useEscapeClose.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import KeyCodeMap from '@/constants/KeyCode'; 3 | 4 | /** 5 | * 是否启用ESC键hooks 6 | * @param handler 执行钩子 7 | * @param effective 是否真实有效的交互 8 | */ 9 | export default function useEscapeClose( 10 | close: () => void, 11 | escapeClosable: boolean, 12 | visibility: boolean 13 | ) { 14 | useEffect(() => { 15 | const handleKeyDown = (e: KeyboardEvent) => { 16 | const keyCode = e.keyCode || e.which; 17 | if (keyCode === KeyCodeMap.ESC) close(); 18 | }; 19 | 20 | if (escapeClosable && visibility) { 21 | document.addEventListener('keydown', handleKeyDown, false); 22 | } 23 | 24 | return () => document.removeEventListener('keydown', handleKeyDown); 25 | }, [escapeClosable, visibility]); 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useGlobalClick.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, RefObject } from 'react'; 2 | /** 3 | * 全局点击事件hooks 4 | * @param handler 执行钩子 5 | * @param effective 是否真实有效的交互 6 | * @param container 面板容器 7 | */ 8 | export default function useGlobalClick( 9 | handler: (e: MouseEvent) => void, 10 | effective: boolean, 11 | container?: RefObject 12 | ) { 13 | const handlerClick = (e: MouseEvent) => { 14 | if (!container?.current) { 15 | handler(e); 16 | } 17 | 18 | if (!container?.current?.contains(e.target as HTMLElement)) { 19 | handler(e); 20 | } 21 | }; 22 | 23 | useEffect(() => { 24 | if (effective) { 25 | window.addEventListener('click', handlerClick, false); 26 | } 27 | 28 | return () => { 29 | window.removeEventListener('click', handlerClick); 30 | }; 31 | }, [handler, effective]); 32 | } 33 | -------------------------------------------------------------------------------- /src/hooks/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import { RefObject, useEffect, useRef, useState } from 'react'; 2 | 3 | /** 4 | * 监听dom大小变化 5 | * @param {RefObject} [target] 元素ref 6 | * @return [entryData,observerRef] 7 | * entryData 回调事件对象 8 | * observerRef ResizeObserver 实例 9 | */ 10 | export default function useResizeObserver( 11 | target: RefObject 12 | ): [ResizeObserverEntry | null, ResizeObserver | null] { 13 | const [entryData, setEntryData] = useState(null); 14 | const observerRef = useRef(null); 15 | 16 | /** 注册监听事件 */ 17 | const registerHandler = (node: Element) => { 18 | if (!ResizeObserver) { 19 | console.log('不支持监听元素大小'); 20 | return; 21 | } 22 | observerRef.current = new ResizeObserver(entries => { 23 | entries[0] && setEntryData(entries[0]); 24 | }); 25 | observerRef.current.observe(node); 26 | }; 27 | 28 | useEffect(() => { 29 | const node = target.current; 30 | node && registerHandler(node); 31 | return () => { 32 | node && observerRef.current?.unobserve(node); 33 | }; 34 | }, []); 35 | 36 | return [entryData, observerRef.current]; 37 | } 38 | -------------------------------------------------------------------------------- /src/layout/Header/Header.module.less: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | height: 60px; 6 | padding: 0 16px; 7 | border-bottom: 1px solid #d9d9d9; 8 | } 9 | 10 | .product_name { 11 | min-width: 20px; 12 | max-width: 240px; 13 | height: 100%; 14 | min-height: 32px; 15 | padding: 6px 4px; 16 | overflow: hidden; 17 | color: rgb(34, 37, 41); 18 | font-size: 14px; 19 | line-height: 20px; 20 | border: none; 21 | border-radius: 6px; 22 | outline: none; 23 | cursor: pointer; 24 | resize: none; 25 | 26 | &:hover { 27 | background-color: rgba(0, 0, 0, 0.04); 28 | } 29 | } 30 | 31 | .github_icon { 32 | margin-left: 20px; 33 | cursor: pointer; 34 | } 35 | -------------------------------------------------------------------------------- /src/layout/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useState } from 'react'; 2 | import { Button, Modal, Image } from 'antd'; 3 | import { GithubOutlined } from '@ant-design/icons'; 4 | import cls from 'classnames'; 5 | import screenshot from '@p/Screenshot'; 6 | import { magic } from '@/store'; 7 | import { CANVAS_REF } from '@/constants/Refs'; 8 | import { singleDownload } from '@/utils/download'; 9 | import { randomString } from '@/utils/random'; 10 | import Style from './Header.module.less'; 11 | 12 | const goGithub = () => { 13 | window.open('https://github.com/qiangqiang-id/magic'); 14 | }; 15 | 16 | export default function Header() { 17 | const { activedScene, name } = magic; 18 | 19 | const [blobUrl, setBlobUrl] = useState(''); 20 | 21 | /** 导出 */ 22 | const handleExport = async () => { 23 | const node = CANVAS_REF.current?.firstChild as HTMLElement; 24 | if (!node || !activedScene) return; 25 | const { width, height } = activedScene; 26 | const blob = await screenshot(node, { 27 | width, 28 | height, 29 | style: { transform: 'none' }, 30 | }); 31 | setBlobUrl(URL.createObjectURL(blob)); 32 | }; 33 | 34 | const open = useMemo(() => !!blobUrl, [blobUrl]); 35 | 36 | /** 取消 */ 37 | const closeModal = () => { 38 | URL.revokeObjectURL(blobUrl); 39 | setBlobUrl(''); 40 | }; 41 | 42 | const onDownload = () => { 43 | singleDownload(blobUrl, name || randomString()); 44 | }; 45 | 46 | return ( 47 |
48 |
49 |
50 | {magic.name} 51 |
52 |
53 |
54 | 57 | 58 | 59 |
60 | 61 | 71 | 72 | 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/layout/Header/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Header'; 2 | -------------------------------------------------------------------------------- /src/layout/Layout.module.less: -------------------------------------------------------------------------------- 1 | .layout { 2 | height: 100vh; 3 | } 4 | 5 | .main { 6 | display: flex; 7 | height: calc(100% - 60px); 8 | } 9 | -------------------------------------------------------------------------------- /src/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from './Header'; 2 | import Material from './Material'; 3 | import Stage from './Stage'; 4 | import Setting from './Setting'; 5 | 6 | import Style from './Layout.module.less'; 7 | 8 | export default function Layout() { 9 | return ( 10 |
11 |
12 | 13 |
14 | 15 | 16 | 17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Back/Back.module.less: -------------------------------------------------------------------------------- 1 | .title { 2 | margin-bottom: 8px; 3 | padding: 6px 0; 4 | border-bottom: 1px solid #e6e9f0; 5 | } 6 | 7 | .color_wrapper { 8 | display: grid; 9 | grid-gap: 8px; 10 | grid-template-columns: repeat(auto-fill, 40px); 11 | padding: 0 3px; 12 | } 13 | 14 | .color_item { 15 | height: 40px; 16 | border-radius: 4px; 17 | cursor: pointer; 18 | 19 | &:hover { 20 | transform: scale(1.1); 21 | transition: transform 0.3s; 22 | } 23 | } 24 | 25 | .picture_wrapper { 26 | margin-top: 10px; 27 | } 28 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Back/Back.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import { Button } from 'antd'; 4 | import Upload from '@/components/Upload'; 5 | import { BackColorList } from '@/config/ColorList'; 6 | import { fileToBase64 } from '@/utils/file'; 7 | import { useStores } from '@/store'; 8 | import Style from './Back.module.less'; 9 | 10 | interface BackContentProps { 11 | title: string; 12 | children: ReactNode; 13 | className?: string; 14 | } 15 | 16 | function BackContent(props: BackContentProps) { 17 | const { title, children, className } = props; 18 | 19 | return ( 20 |
21 |
{title}
22 | {children} 23 |
24 | ); 25 | } 26 | 27 | function Back() { 28 | const { magic } = useStores(); 29 | 30 | const { activedScene } = magic; 31 | 32 | const addBackImage = async (files: File[]) => { 33 | const file = files[0]; 34 | if (!file) return; 35 | const dataUrl = await fileToBase64(file); 36 | activedScene?.updateSceneBack({ fillType: 'Image', url: dataUrl }); 37 | }; 38 | 39 | const addBackColor = (color: string) => { 40 | activedScene?.updateSceneBack({ fillType: 'Color', color }); 41 | }; 42 | 43 | return ( 44 |
45 | 46 |
    47 | {BackColorList.map(color => ( 48 |
  • addBackColor(color)} 55 | /> 56 | ))} 57 |
58 |
59 | 60 | 61 | 62 | 65 | 66 | 67 |
68 | ); 69 | } 70 | 71 | export default observer(Back); 72 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Back/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Back'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import { observer } from 'mobx-react'; 2 | import { Button } from 'antd'; 3 | import { fileToBase64 } from '@/utils/file'; 4 | import Upload from '@/components/Upload'; 5 | import { makeImage } from '@/utils/image'; 6 | import { magic } from '@/store'; 7 | 8 | function Image() { 9 | const { activedScene } = magic; 10 | const addImage = async (files: File[]) => { 11 | const file = files[0]; 12 | if (!file) return; 13 | const url = await fileToBase64(file); 14 | const { width, height } = await makeImage(url); 15 | 16 | activedScene?.addImage({ 17 | name: file.name, 18 | width, 19 | height, 20 | url, 21 | mimeType: file.type, 22 | }); 23 | }; 24 | 25 | return ( 26 | 27 | 30 | 31 | ); 32 | } 33 | 34 | export default observer(Image); 35 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Image/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Image'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Shape/Shape.module.less: -------------------------------------------------------------------------------- 1 | .shape { 2 | display: grid; 3 | grid-gap: 10px; 4 | grid-template-columns: 1fr 1fr; 5 | } 6 | 7 | .shape_item { 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | aspect-ratio: 80 / 116; 12 | background: #eff0f5; 13 | border-radius: 4px; 14 | cursor: pointer; 15 | 16 | &:hover { 17 | transform: scale(1.1); 18 | transition: 0.3s; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Shape/Shape.tsx: -------------------------------------------------------------------------------- 1 | import { ShapeList } from '@/config/Shape'; 2 | import { useStores } from '@/store'; 3 | import ShapeLayer from '@/components/Renderer/Layer/Shape'; 4 | 5 | import Style from './Shape.module.less'; 6 | 7 | export default function Shape() { 8 | const { magic } = useStores(); 9 | 10 | const addShape = (shape: Partial) => { 11 | magic.activedScene?.addShape(shape); 12 | }; 13 | 14 | return ( 15 |
16 | {ShapeList.map((shape, index) => ( 17 |
addShape(shape)} 19 | className={Style.shape_item} 20 | key={`${shape.name}-${index}`} 21 | > 22 | 23 |
24 | ))} 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Shape/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Shape'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Text/Text.module.less: -------------------------------------------------------------------------------- 1 | .text { 2 | position: relative; 3 | } 4 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from 'antd'; 2 | import { magic } from '@/store'; 3 | import Style from './Text.module.less'; 4 | 5 | export default function Text() { 6 | const { activedScene } = magic; 7 | 8 | return ( 9 |
10 | 20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Text/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Text'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Material.module.less: -------------------------------------------------------------------------------- 1 | .material { 2 | width: 300px; 3 | min-width: 300px; 4 | height: 100%; 5 | border-right: 1px solid #d9d9d9; 6 | } 7 | -------------------------------------------------------------------------------- /src/layout/Material/Material.tsx: -------------------------------------------------------------------------------- 1 | import { useStores } from '@/store'; 2 | import { MATERIAL_MENUS } from './constants'; 3 | import SidebarMenu from './SidebarMenu'; 4 | import Style from './Material.module.less'; 5 | 6 | export default function Material() { 7 | const { material } = useStores(); 8 | return ( 9 |
10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/layout/Material/SidebarMenu/SidebarMenu.module.less: -------------------------------------------------------------------------------- 1 | .sidebar_menu { 2 | display: flex; 3 | height: 100%; 4 | } 5 | 6 | .menu { 7 | height: 100%; 8 | border-right: 1px solid #d9d9d9; 9 | 10 | :global { 11 | .ant-tabs { 12 | width: 68px; 13 | } 14 | 15 | .ant-tabs-tab { 16 | display: flex; 17 | flex-direction: column; 18 | align-items: center; 19 | justify-content: center; 20 | box-sizing: border-box; 21 | width: 68px; 22 | margin-top: 14px; 23 | padding: 8px 0 !important; 24 | cursor: pointer; 25 | } 26 | 27 | .ant-tabs-ink-bar { 28 | display: none; 29 | } 30 | 31 | .ant-tabs-content-holder { 32 | border: 0; 33 | } 34 | } 35 | } 36 | 37 | .menu_content { 38 | display: flex; 39 | flex-direction: column; 40 | align-items: center; 41 | justify-content: center; 42 | } 43 | 44 | .content { 45 | flex: 1; 46 | padding: 20px; 47 | } 48 | -------------------------------------------------------------------------------- /src/layout/Material/SidebarMenu/SidebarMenu.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, createElement } from 'react'; 2 | import { Tabs } from 'antd'; 3 | import { observer } from 'mobx-react'; 4 | import MaterialStore from '@/store/Material'; 5 | import { MenuItemModel } from '@/types/material'; 6 | 7 | import Style from './SidebarMenu.module.less'; 8 | 9 | interface MenuProps { 10 | /** 侧边栏store */ 11 | materialStore: MaterialStore; 12 | 13 | /** 菜单配置项 */ 14 | options: MenuItemModel[]; 15 | } 16 | 17 | function SidebarMenu(props: MenuProps) { 18 | const { options = [], materialStore } = props; 19 | 20 | const { activeMenu } = materialStore; 21 | 22 | /** 切换菜单 */ 23 | const handleSwitchMenu = menuKey => { 24 | materialStore.changeMenu(menuKey); 25 | }; 26 | 27 | /** 当前激活的菜单配置 */ 28 | const actived = useMemo( 29 | () => options.find(item => item.name === activeMenu), 30 | [activeMenu, options] 31 | ); 32 | 33 | /** 需要展示的菜单 */ 34 | const showOptions = useMemo( 35 | () => options.filter(item => !item.hiddenLabel), 36 | [options] 37 | ); 38 | 39 | const items = useMemo( 40 | () => 41 | showOptions.map(item => ({ 42 | key: item.name, 43 | label: ( 44 |
45 | 46 | {item.label} 47 |
48 | ), 49 | children: null, 50 | })), 51 | [showOptions] 52 | ); 53 | 54 | return ( 55 |
56 | {/* 菜单区域 */} 57 |
58 | 64 |
65 | 66 |
67 | {actived && createElement(actived.component)} 68 |
69 |
70 | ); 71 | } 72 | 73 | export default observer(SidebarMenu); 74 | -------------------------------------------------------------------------------- /src/layout/Material/SidebarMenu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './SidebarMenu'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/constants.ts: -------------------------------------------------------------------------------- 1 | import { MaterialEnum } from '@/constants/MaterialEnum'; 2 | import { MenuItemModel } from '@/types/material'; 3 | import Image from './Content/Image'; 4 | import Back from './Content/Back'; 5 | import Text from './Content/Text'; 6 | import Shape from './Content/Shape'; 7 | 8 | export const MATERIAL_MENUS: MenuItemModel[] = [ 9 | { 10 | label: '图片', 11 | name: MaterialEnum.IMAGE, 12 | component: Image, 13 | icon: 'icon-left-image', 14 | }, 15 | { 16 | label: '背景', 17 | name: MaterialEnum.BACK, 18 | component: Back, 19 | icon: 'icon-left-background', 20 | }, 21 | { 22 | label: '文字', 23 | name: MaterialEnum.TEXT, 24 | component: Text, 25 | icon: 'icon-left-text', 26 | }, 27 | { 28 | label: '图形', 29 | name: MaterialEnum.SHAPE, 30 | component: Shape, 31 | icon: 'icon-left-element', 32 | }, 33 | ]; 34 | -------------------------------------------------------------------------------- /src/layout/Material/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Material'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Canvas/Canvas.module.less: -------------------------------------------------------------------------------- 1 | .row_layout { 2 | display: flex; 3 | align-items: center; 4 | justify-content: space-between; 5 | } 6 | 7 | .background_color { 8 | margin-top: 10px; 9 | } 10 | -------------------------------------------------------------------------------- /src/layout/Setting/Canvas/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import cls from 'classnames'; 4 | import { Button, ColorPicker } from 'antd'; 5 | import { Color } from 'antd/es/color-picker'; 6 | import SettingContainer from '@/components/SettingContainer'; 7 | // import { BackgroundStruc } from '@/models/LayerStruc'; 8 | // import { SettingProps } from '../Setting'; 9 | import { magic } from '@/store'; 10 | import Style from './Canvas.module.less'; 11 | import { BackColorList } from '@/config/ColorList'; 12 | 13 | // interface CanvasProps extends SettingProps {} 14 | 15 | function Canvas() { 16 | const { activedScene } = magic; 17 | 18 | if (!activedScene) return null; 19 | 20 | const { width, height, backgroundLayer } = activedScene; 21 | 22 | const colorValue = useMemo(() => { 23 | if (!backgroundLayer || backgroundLayer.fillType !== 'Color') return ''; 24 | return backgroundLayer.color; 25 | }, [backgroundLayer?.color, backgroundLayer?.fillType]); 26 | 27 | const onChangeColor = (color: Color) => { 28 | backgroundLayer?.update({ 29 | fillType: 'Color', 30 | color: color.toRgbString(), 31 | }); 32 | }; 33 | 34 | return ( 35 | 36 |
37 |
38 |
画布尺寸
39 |
40 | {width} x {height} px 41 |
42 |
43 | 44 | 47 |
48 | 49 |
52 |
背景色
53 | 58 |
59 |
60 | ); 61 | } 62 | 63 | export default observer(Canvas); 64 | -------------------------------------------------------------------------------- /src/layout/Setting/Canvas/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Canvas'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Group/Group.tsx: -------------------------------------------------------------------------------- 1 | import SettingContainer from '@/components/SettingContainer'; 2 | import LayerBaseSetting from '@/components/LayerBaseSetting'; 3 | 4 | import { GroupStruc } from '@/models/LayerStruc'; 5 | import { SettingProps } from '../Setting'; 6 | 7 | interface GroupProps extends SettingProps {} 8 | 9 | export default function Group(props: GroupProps) { 10 | const { model } = props; 11 | return ( 12 | 13 |
14 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/layout/Setting/Group/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Group'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Image/Image.module.less: -------------------------------------------------------------------------------- 1 | .feature_wrapper { 2 | margin: 20px 0; 3 | } 4 | 5 | .feature_item { 6 | display: inline-flex; 7 | flex-direction: column; 8 | gap: 4px; 9 | align-items: center; 10 | justify-content: center; 11 | width: 36px; 12 | padding: 4px; 13 | color: #7f8792; 14 | background: #f4f4f480; 15 | border-radius: 4px; 16 | cursor: pointer; 17 | 18 | &:hover { 19 | background: #e5e7eb; 20 | } 21 | } 22 | 23 | .feature_icon { 24 | font-size: 22px; 25 | } 26 | 27 | .feature_name { 28 | font-size: 12px; 29 | } 30 | -------------------------------------------------------------------------------- /src/layout/Setting/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import cls from 'classnames'; 2 | import { observer } from 'mobx-react'; 3 | import { Button } from 'antd'; 4 | import SettingContainer from '@/components/SettingContainer'; 5 | import LayerBaseSetting from '@/components/LayerBaseSetting'; 6 | import Upload from '@/components/Upload'; 7 | import { ImageStruc } from '@/models/LayerStruc'; 8 | import { makeImage } from '@/utils/image'; 9 | import { fileToBase64 } from '@/utils/file'; 10 | import { magic } from '@/store'; 11 | 12 | import { SettingProps } from '../Setting'; 13 | 14 | import Style from './Image.module.less'; 15 | 16 | interface ImageProps extends SettingProps {} 17 | 18 | function Image(props: ImageProps) { 19 | const { model } = props; 20 | const { isOpenImageCrop } = magic; 21 | 22 | const handleChange = async (files: File[]) => { 23 | const file = files[0]; 24 | if (!file) return; 25 | const url = await fileToBase64(file); 26 | const { width, height } = await makeImage(url); 27 | model.replaceUrl(url, { width, height }); 28 | }; 29 | 30 | const onCrop = () => { 31 | if (model.isLock) return; 32 | isOpenImageCrop ? magic.closeImageCrop() : magic.openImageCrop(); 33 | }; 34 | 35 | return ( 36 | 37 | 38 | 41 | 42 | 43 |
44 |
45 |
e.stopPropagation()} 51 | > 52 | 55 | 裁剪 56 |
57 |
58 |
59 | 60 | 61 |
62 | ); 63 | } 64 | 65 | export default observer(Image); 66 | -------------------------------------------------------------------------------- /src/layout/Setting/Image/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Image'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Setting.module.less: -------------------------------------------------------------------------------- 1 | .setting { 2 | width: 280px; 3 | min-width: 280px; 4 | height: 100%; 5 | border-left: 1px solid #d9d9d9; 6 | } 7 | -------------------------------------------------------------------------------- /src/layout/Setting/Setting.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType } from 'react'; 2 | import { observer } from 'mobx-react'; 3 | import { LayerTypeEnum } from '@/constants/LayerTypeEnum'; 4 | import TextSetting from './Text'; 5 | import ImageSetting from './Image'; 6 | import CanvasSetting from './Canvas'; 7 | import ShapeSetting from './Shape'; 8 | import GroupSetting from './Group'; 9 | import { useStores } from '@/store'; 10 | import { LayerStrucType } from '@/types/model'; 11 | import Style from './Setting.module.less'; 12 | 13 | const SettingMap = { 14 | [LayerTypeEnum.BACKGROUND]: CanvasSetting, 15 | [LayerTypeEnum.IMAGE]: ImageSetting, 16 | [LayerTypeEnum.TEXT]: TextSetting, 17 | [LayerTypeEnum.SHAPE]: ShapeSetting, 18 | [LayerTypeEnum.GROUP]: GroupSetting, 19 | }; 20 | 21 | export interface SettingProps< 22 | M extends LayerStrucType | null = LayerStrucType 23 | > { 24 | model: M; 25 | } 26 | 27 | function Setting() { 28 | const { magic } = useStores(); 29 | const { activedLayers, isMultiple } = magic; 30 | 31 | const getSettingRender = () => { 32 | if (!activedLayers.length) return ; 33 | 34 | /** 选中多个图层 */ 35 | if (isMultiple) { 36 | return
选择多个组件
; 37 | } 38 | const layer = activedLayers[0]; 39 | 40 | const LayerSetting = SettingMap[layer.type] as ComponentType< 41 | SettingProps 42 | >; 43 | 44 | if (!LayerSetting) return null; 45 | 46 | return ; 47 | }; 48 | 49 | return
{getSettingRender()}
; 50 | } 51 | 52 | export default observer(Setting); 53 | -------------------------------------------------------------------------------- /src/layout/Setting/Shape/Shape.tsx: -------------------------------------------------------------------------------- 1 | import SettingContainer from '@/components/SettingContainer'; 2 | import LayerBaseSetting from '@/components/LayerBaseSetting'; 3 | 4 | import { ShapeStruc } from '@/models/LayerStruc'; 5 | import { SettingProps } from '../Setting'; 6 | 7 | interface ShapeProps extends SettingProps {} 8 | 9 | export default function Shape(props: ShapeProps) { 10 | const { model } = props; 11 | return ( 12 | 13 |
14 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/layout/Setting/Shape/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Shape'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Text/Text.module.less: -------------------------------------------------------------------------------- 1 | .icon_item { 2 | font-size: 22px; 3 | } 4 | 5 | .text_family_with_size { 6 | display: flex; 7 | gap: 10px; 8 | margin-bottom: 10px; 9 | 10 | .font_family_select { 11 | width: 180px; 12 | } 13 | } 14 | 15 | .text_style_active { 16 | font-weight: bold; 17 | } 18 | 19 | .text_color_picker { 20 | width: 100%; 21 | height: 24px; 22 | border: 0; 23 | 24 | :global { 25 | .ant-color-picker-color-block { 26 | width: 100%; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/layout/Setting/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | import SettingContainer from '@/components/SettingContainer'; 2 | import LayerBaseSetting from '@/components/LayerBaseSetting'; 3 | import { TextStruc } from '@/models/LayerStruc'; 4 | import { SettingProps } from '../Setting'; 5 | import TextStyle from './TextStyle'; 6 | import TextAlign from './TextAlign'; 7 | import TextFamilyWithSize from './TextFamilyWithSize'; 8 | import TextColor from './TextColor'; 9 | 10 | export interface TextProps extends SettingProps {} 11 | 12 | export default function Text(props: TextProps) { 13 | const { model } = props; 14 | return ( 15 | 16 | <> 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /src/layout/Setting/Text/TextAlign.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from 'antd'; 2 | import cls from 'classnames'; 3 | import { observer } from 'mobx-react'; 4 | import { TextProps } from './Text'; 5 | import { TextAlignEnum } from '@/constants/Font'; 6 | 7 | import Style from './Text.module.less'; 8 | 9 | function TextAlign(props: TextProps) { 10 | const { model } = props; 11 | 12 | const { textAlign } = model; 13 | 14 | const setTextAlign = (textAlign: TextAlignEnum) => { 15 | model.update({ textAlign }); 16 | }; 17 | 18 | return ( 19 |
20 | 21 | setTextAlign(TextAlignEnum.Left)} 23 | className={cls( 24 | 'iconfont icon-09zuoduiqi', 25 | 'icon-item', 26 | Style.icon_item, 27 | { [Style.text_style_active]: textAlign === TextAlignEnum.Left } 28 | )} 29 | /> 30 | 31 | 32 | 33 | setTextAlign(TextAlignEnum.Center)} 35 | className={cls( 36 | 'iconfont icon-11juzhongduiqi', 37 | 'icon-item', 38 | Style.icon_item, 39 | { [Style.text_style_active]: textAlign === TextAlignEnum.Center } 40 | )} 41 | /> 42 | 43 | 44 | 45 | setTextAlign(TextAlignEnum.Right)} 47 | className={cls( 48 | 'iconfont icon-10youduiqi', 49 | 'icon-item', 50 | Style.icon_item, 51 | { [Style.text_style_active]: textAlign === TextAlignEnum.Right } 52 | )} 53 | /> 54 | 55 | 56 | 57 | setTextAlign(TextAlignEnum.Justify)} 59 | className={cls( 60 | 'iconfont icon-12liangduanduiqi', 61 | 'icon-item', 62 | Style.icon_item, 63 | { [Style.text_style_active]: textAlign === TextAlignEnum.Justify } 64 | )} 65 | /> 66 | 67 |
68 | ); 69 | } 70 | 71 | export default observer(TextAlign); 72 | -------------------------------------------------------------------------------- /src/layout/Setting/Text/TextColor.tsx: -------------------------------------------------------------------------------- 1 | import { ColorPicker } from 'antd'; 2 | import cls from 'classnames'; 3 | import { observer } from 'mobx-react'; 4 | import { TextProps } from './Text'; 5 | import Style from './Text.module.less'; 6 | 7 | function TextColor(props: TextProps) { 8 | const { model } = props; 9 | 10 | const changeColor = (_value, color: string) => { 11 | model.update({ color }); 12 | }; 13 | 14 | return ( 15 |
16 | 21 |
22 | ); 23 | } 24 | 25 | export default observer(TextColor); 26 | -------------------------------------------------------------------------------- /src/layout/Setting/Text/TextFamilyWithSize.tsx: -------------------------------------------------------------------------------- 1 | import { InputNumber, Select } from 'antd'; 2 | import { observer } from 'mobx-react'; 3 | import { font } from '@/store'; 4 | import { TextProps } from './Text'; 5 | 6 | import { MAX_FONT_SIZE, MIN_FONT_SIZE } from '@/constants/FontSize'; 7 | 8 | import Style from './Text.module.less'; 9 | 10 | function TextFamilyWithSize(props: TextProps) { 11 | const { model } = props; 12 | 13 | const changeFamily = (family: string) => { 14 | model.update({ fontFamily: family }); 15 | }; 16 | 17 | const changeFontsize = (fontSize: number | null) => { 18 | if (fontSize === null) return; 19 | model.update({ fontSize }); 20 | }; 21 | 22 | return ( 23 |
24 |