├── .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 |
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 |
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 |
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 |
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 |
31 |
37 |
38 | );
39 | }
40 |
41 | export default observer(TextFamilyWithSize);
42 |
--------------------------------------------------------------------------------
/src/layout/Setting/Text/TextStyle.tsx:
--------------------------------------------------------------------------------
1 | import { Tooltip } from 'antd';
2 | import cls from 'classnames';
3 | import { observer } from 'mobx-react';
4 |
5 | import { TextProps } from './Text';
6 | import Style from './Text.module.less';
7 | import {
8 | FontWeightEnum,
9 | FontStyleEnum,
10 | TextDecorationEnum,
11 | } from '@/constants/Font';
12 |
13 | function TextStyle(props: TextProps) {
14 | const { model } = props;
15 |
16 | const { fontWeight, fontStyle, textDecoration } = model;
17 |
18 | const setFontWeight = () => {
19 | const newFontWeight =
20 | fontWeight === FontWeightEnum.Bold
21 | ? FontWeightEnum.Normal
22 | : FontWeightEnum.Bold;
23 | model.update({ fontWeight: newFontWeight });
24 | };
25 |
26 | const setFontStyle = () => {
27 | const newFontStyle =
28 | fontStyle === FontStyleEnum.Italic
29 | ? FontStyleEnum.Normal
30 | : FontStyleEnum.Italic;
31 | model.update({ fontStyle: newFontStyle });
32 | };
33 |
34 | const setTextDecoration = (type: TextDecorationEnum) => {
35 | const newTextDecoration =
36 | textDecoration !== type ? type : TextDecorationEnum.None;
37 |
38 | model.update({ textDecoration: newTextDecoration });
39 | };
40 |
41 | return (
42 |
43 |
44 |
53 |
54 |
55 |
56 |
65 |
66 |
67 |
68 | setTextDecoration(TextDecorationEnum.Underline)}
70 | className={cls(
71 | 'iconfont icon-03xiahuaxian',
72 | 'icon-item',
73 | Style.icon_item,
74 | {
75 | [Style.text_style_active]:
76 | textDecoration === TextDecorationEnum.Underline,
77 | }
78 | )}
79 | />
80 |
81 |
82 |
83 | setTextDecoration(TextDecorationEnum.LineThrough)}
85 | className={cls(
86 | 'iconfont icon-04shanchuxian',
87 | 'icon-item',
88 | Style.icon_item,
89 | {
90 | [Style.text_style_active]:
91 | textDecoration === TextDecorationEnum.LineThrough,
92 | }
93 | )}
94 | />
95 |
96 |
97 | );
98 | }
99 |
100 | export default observer(TextStyle);
101 |
--------------------------------------------------------------------------------
/src/layout/Setting/Text/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Text';
2 |
--------------------------------------------------------------------------------
/src/layout/Setting/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Setting';
2 |
--------------------------------------------------------------------------------
/src/layout/Stage/Canvas/Canvas.module.less:
--------------------------------------------------------------------------------
1 | .canvas {
2 | position: relative;
3 | }
4 |
5 | .renderer_wrapper {
6 | position: relative;
7 | width: 100%;
8 | height: 100%;
9 | background-color: #f5f5f5;
10 | }
11 |
--------------------------------------------------------------------------------
/src/layout/Stage/Canvas/Canvas.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties, forwardRef, Ref } from 'react';
2 | import { observer } from 'mobx-react';
3 | import Renderer from '@/components/Renderer';
4 | import Editor from '@/components/Editor';
5 | import { CANVAS_REF } from '@/constants/Refs';
6 | import { useStores } from '@/store';
7 | import Style from './Canvas.module.less';
8 | import { NodeNameplate } from '@/constants/NodeNamePlate';
9 |
10 | interface CanvasProps {
11 | canvasWidth: number;
12 | canvasHeight: number;
13 | style?: CSSProperties;
14 | }
15 |
16 | function Canvas(props: CanvasProps, ref: Ref) {
17 | const { canvasWidth, canvasHeight, style } = props;
18 |
19 | const { OS, magic } = useStores();
20 | const { activedScene, activedLayers, isMultiple } = magic;
21 | const { zoomLevel, magneticLines } = OS;
22 |
23 | if (!activedScene || !activedScene?.layers) return null;
24 |
25 | const rendererStyle = {
26 | width: canvasWidth,
27 | height: canvasHeight,
28 | transform: `scale(${zoomLevel})`,
29 | };
30 |
31 | return (
32 |
33 |
39 |
45 |
46 |
47 |
53 |
54 | );
55 | }
56 |
57 | export default observer(forwardRef(Canvas));
58 |
--------------------------------------------------------------------------------
/src/layout/Stage/Canvas/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Canvas';
2 |
--------------------------------------------------------------------------------
/src/layout/Stage/Scenes/Scene/Scene.module.less:
--------------------------------------------------------------------------------
1 | .scene_item {
2 | position: relative;
3 | margin-right: 10px;
4 | box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.08), 0 4px 12px 0 rgba(0, 0, 0, 0.04);
5 |
6 | &:hover {
7 | .menu_popover {
8 | visibility: visible;
9 | }
10 | }
11 | }
12 |
13 | .scene_renderer_item {
14 | width: 100%;
15 | height: 100%;
16 | overflow: hidden;
17 | border-radius: 4px;
18 | }
19 |
20 | .actived {
21 | &::before {
22 | position: absolute;
23 | top: -4px;
24 | left: -4px;
25 | box-sizing: content-box;
26 | width: 100%;
27 | height: 100%;
28 | padding: 2px;
29 | border: 2px solid #fd6b11;
30 | border-radius: 4px;
31 | content: '';
32 | }
33 | }
34 |
35 | .menu_popover {
36 | position: absolute;
37 | top: 10px;
38 | right: 10px;
39 | visibility: hidden;
40 | }
41 |
--------------------------------------------------------------------------------
/src/layout/Stage/Scenes/Scene/Scene.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo, forwardRef, Ref, CSSProperties } from 'react';
2 | import { observer } from 'mobx-react';
3 | import cls from 'classnames';
4 | import MenuPopover from '@/components/MenuPopover';
5 | import SceneStruc from '@/models/SceneStruc';
6 | import { magic } from '@/store';
7 | import Renderer from '@/components/Renderer';
8 | import Style from './Scene.module.less';
9 |
10 | export interface SceneProps {
11 | scene: SceneStruc;
12 | actived?: boolean;
13 | style?: CSSProperties;
14 | disableRemove?: boolean;
15 | addEmptyScene?: () => void;
16 | removeScene?: () => void;
17 | copyScene?: () => void;
18 | }
19 | /**
20 | * 预览场景大小
21 | */
22 | const SIZE = 130;
23 |
24 | function Scene(props: SceneProps, ref: Ref) {
25 | const {
26 | actived = false,
27 | scene,
28 | style,
29 | disableRemove = false,
30 | addEmptyScene,
31 | removeScene,
32 | copyScene,
33 | ...otherProps
34 | } = props;
35 |
36 | const { width = 0, height = 0 } = scene;
37 |
38 | const ratio = useMemo(() => SIZE / Math.max(width, height), [width, height]);
39 |
40 | const sceneWrapperStyle = {
41 | width: width * ratio,
42 | height: height * ratio,
43 | };
44 |
45 | const sceneStyle = {
46 | width,
47 | height,
48 | transform: `scale(${ratio})`,
49 | };
50 |
51 | const actions = [
52 | {
53 | name: '增加页面',
54 | handle: addEmptyScene,
55 | },
56 | {
57 | name: '复制',
58 | handle: copyScene,
59 | },
60 | {
61 | disable: disableRemove,
62 | name: '删除',
63 | handle: removeScene,
64 | },
65 | ];
66 |
67 | return (
68 |
69 |
magic.activeScene(scene)}
72 | className={cls(actived && Style.actived)}
73 | style={sceneWrapperStyle}
74 | {...otherProps}
75 | >
76 |
77 |
78 |
79 |
80 |
81 |
87 |
88 | );
89 | }
90 |
91 | export default observer(forwardRef(Scene));
92 |
--------------------------------------------------------------------------------
/src/layout/Stage/Scenes/Scene/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Scene';
2 |
3 | export type { SceneProps } from './Scene';
4 |
--------------------------------------------------------------------------------
/src/layout/Stage/Scenes/Scenes.module.less:
--------------------------------------------------------------------------------
1 | .scenes {
2 | position: absolute;
3 | bottom: 0;
4 | left: 0;
5 | width: 100%;
6 | height: 150px;
7 | overflow: auto;
8 | background-color: #fff;
9 | box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.08), 0 4px 12px 0 rgba(0, 0, 0, 0.04);
10 | }
11 |
12 | .scenes_content {
13 | display: inline-flex;
14 | align-items: center;
15 | height: 100%;
16 | padding: 0 20px;
17 | }
18 |
19 | .add_item {
20 | display: flex;
21 | align-items: center;
22 | justify-content: center;
23 | width: 80px;
24 | height: 130px;
25 | background: #f3f3f3;
26 | border-radius: 3px;
27 | cursor: pointer;
28 |
29 | &:hover {
30 | background: #e4e7e8;
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/layout/Stage/Scenes/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Scenes';
2 |
--------------------------------------------------------------------------------
/src/layout/Stage/Stage.module.less:
--------------------------------------------------------------------------------
1 | .stage {
2 | position: relative;
3 | flex: 1;
4 | min-width: 250px;
5 | padding-bottom: 150px;
6 | background-color: #f6f7f9;
7 | user-select: none;
8 | }
9 |
10 | .canvas_wrapper {
11 | position: relative;
12 | display: flex;
13 | align-items: center;
14 | justify-content: center;
15 | height: 100%;
16 | padding: 20px;
17 | overflow: scroll;
18 | }
19 |
20 | .crop_wrapper {
21 | position: absolute;
22 | top: 0;
23 | right: 0;
24 | bottom: 0;
25 | left: 0;
26 | }
27 |
--------------------------------------------------------------------------------
/src/layout/Stage/Stage.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useMemo, useRef } from 'react';
2 | import { observer } from 'mobx-react';
3 | import Canvas from './Canvas';
4 | import Scenes from './Scenes/Scenes';
5 | import Crop from '@/components/Crop';
6 | import useResizeObserver from '@/hooks/useResizeObserver';
7 | import { CANVAS_WRAPPER } from '@/constants/Refs';
8 | import { NodeNameplate } from '@/constants/NodeNamePlate';
9 | import { useStores } from '@/store';
10 | import Style from './Stage.module.less';
11 |
12 | function Stage() {
13 | const { OS, magic } = useStores();
14 |
15 | const { activedLayers, activedScene, isOpenImageCrop } = magic;
16 |
17 | const { zoomLevel } = OS;
18 |
19 | const cropRef = useRef(null);
20 | const canvasRef = useRef(null);
21 |
22 | const [entry] = useResizeObserver(CANVAS_WRAPPER);
23 |
24 | const templateWidth = activedScene?.width || 0;
25 | const templateHeight = activedScene?.height || 0;
26 |
27 | const canvasStyle = useMemo(
28 | () => ({
29 | width: templateWidth * zoomLevel,
30 | height: templateHeight * zoomLevel,
31 | }),
32 | [zoomLevel]
33 | );
34 |
35 | const adaptZoomLevel = (entry: ResizeObserverEntry) => {
36 | const { width, height } = entry.contentRect;
37 | const rateW = width / templateWidth;
38 | const rateH = height / templateHeight;
39 | OS.setZoomLevel(Math.min(rateH, rateW));
40 | };
41 |
42 | const handleStageMousedown = (e: React.MouseEvent) => {
43 | if (e.button !== 0 || !activedLayers.length) return;
44 | /** 尽量不使用阻止冒泡 */
45 | if (cropRef.current?.contains(e.target as Node)) return;
46 | if (canvasRef.current?.contains(e.target as Node)) return;
47 |
48 | magic.releaseAllLayers();
49 | };
50 |
51 | useEffect(() => {
52 | entry && adaptZoomLevel(entry);
53 | }, [entry, templateHeight, templateWidth]);
54 |
55 | if (!activedScene) return null;
56 |
57 | return (
58 |
59 |
65 |
71 |
72 | {isOpenImageCrop && (
73 |
74 |
75 |
76 | )}
77 |
78 |
79 |
80 | );
81 | }
82 |
83 | export default observer(Stage);
84 |
--------------------------------------------------------------------------------
/src/layout/Stage/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Stage';
2 |
--------------------------------------------------------------------------------
/src/layout/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './Layout';
2 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import App from './App';
4 | import './assets/styles/index.less';
5 |
6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
7 |
8 | );
9 |
--------------------------------------------------------------------------------
/src/models/FactoryStruc/LayerFactory.ts:
--------------------------------------------------------------------------------
1 | import { LayerTypeEnum } from '@/constants/LayerTypeEnum';
2 | import LayerStruc, {
3 | TextStruc,
4 | ImageStruc,
5 | ShapeStruc,
6 | GroupStruc,
7 | BackgroundStruc,
8 | } from '../LayerStruc';
9 | import SceneStruc from '../SceneStruc';
10 | import { LayerStrucType } from '@/types/model';
11 |
12 | const LayerTypeMapStruc: Record<
13 | LayerModel.LayerType,
14 | typeof LayerStruc | null
15 | > = {
16 | [LayerTypeEnum.TEXT]: TextStruc,
17 | [LayerTypeEnum.BACKGROUND]: BackgroundStruc,
18 | [LayerTypeEnum.IMAGE]: ImageStruc,
19 | [LayerTypeEnum.GROUP]: GroupStruc,
20 | [LayerTypeEnum.SHAPE]: ShapeStruc,
21 | [LayerTypeEnum.UNKNOWN]: null,
22 | };
23 |
24 | export default function CreateLayerStruc(
25 | type: T,
26 | data?: Partial,
27 | parent?: SceneStruc | GroupStruc | null
28 | ): LayerStrucType {
29 | const Structure = LayerTypeMapStruc[type];
30 | if (!Structure) throw new Error(`${type}组件暂未实现`);
31 |
32 | const structure = new Structure(data);
33 |
34 | if (parent instanceof SceneStruc) {
35 | structure.scene = parent;
36 | } else if (parent instanceof GroupStruc) {
37 | structure.group = parent;
38 | } else {
39 | structure.scene = null;
40 | structure.group = null;
41 | }
42 |
43 | return structure as LayerStrucType;
44 | }
45 |
--------------------------------------------------------------------------------
/src/models/FactoryStruc/SceneFactory.ts:
--------------------------------------------------------------------------------
1 | import SceneStruc from '../SceneStruc';
2 |
3 | export function CreateScene(data?: Partial | null) {
4 | return new SceneStruc(data);
5 | }
6 |
--------------------------------------------------------------------------------
/src/models/LayerStruc/BackgroundStruc.ts:
--------------------------------------------------------------------------------
1 | import { computed, makeObservable, observable } from 'mobx';
2 | import LayerStruc from './LayerStruc';
3 |
4 | export default class BackgroundStruc
5 | extends LayerStruc
6 | implements LayerModel.Background
7 | {
8 | /** 背景填充类型 */
9 | fillType?: 'Color' | 'Image';
10 |
11 | /** 图片地址 */
12 | url?: string;
13 |
14 | /** 颜色 */
15 | color?: string;
16 |
17 | constructor(data: LayerModel.Background) {
18 | super(data);
19 | makeObservable(this, {
20 | fillType: observable,
21 | url: observable,
22 | color: observable,
23 | isColorFill: computed,
24 | isImageFill: computed,
25 | });
26 |
27 | this.fillType = data.fillType || 'Color';
28 | this.url = data.url || '';
29 | this.color = data.color || '#000';
30 | }
31 |
32 | model(): LayerModel.Background {
33 | const model = super.model();
34 | return {
35 | ...model,
36 | fillType: this.fillType,
37 | url: this.url,
38 | color: this.color,
39 | };
40 | }
41 |
42 | /**
43 | * 是否是颜色填充
44 | * @readonly
45 | * @memberof BackgroundStruc
46 | */
47 | get isColorFill() {
48 | return this.fillType === 'Color';
49 | }
50 |
51 | /**
52 | * 是否是图片填充
53 | * @readonly
54 | * @memberof BackgroundStruc
55 | */
56 | get isImageFill() {
57 | return this.fillType === 'Image';
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/models/LayerStruc/GroupStruc.ts:
--------------------------------------------------------------------------------
1 | import { makeObservable, observable } from 'mobx';
2 | import LayerStruc from './LayerStruc';
3 | import { magic } from '@/store';
4 | import { LayerStrucType } from '@/types/model';
5 |
6 | export default class GroupStruc
7 | extends LayerStruc
8 | implements LayerModel.Group
9 | {
10 | layers?: LayerModel.Layer[];
11 |
12 | constructor(data: LayerModel.Group) {
13 | super(data);
14 | makeObservable(this, {
15 | layers: observable,
16 | });
17 |
18 | this.layers = data.layers || [];
19 | }
20 |
21 | model(): LayerModel.Group {
22 | const model = super.model();
23 |
24 | return {
25 | ...model,
26 | layers: this.layers,
27 | };
28 | }
29 |
30 | /**
31 | * 删除组件
32 | * @param layer选中的图层
33 | */
34 | removeLayer(layer: LayerStruc) {
35 | const index = this.getLayerIndex(layer);
36 | if (index < 0) return;
37 | magic.removeActivedLayer(layer);
38 | this.layers?.splice(index, 1);
39 | layer.scene = null;
40 | }
41 |
42 | /**
43 | * 复制组件
44 | * @param layer选中的图层
45 | */
46 | copyLayer(layer: LayerStruc) {
47 | const newLayer = layer.clone();
48 | this.layers?.push(newLayer);
49 | }
50 |
51 | /**
52 | * 获取组件的下标
53 | * @param layer 选中的图层
54 | * @returns {number} 组件的位置
55 | */
56 | getLayerIndex(layer: LayerStruc): number {
57 | return this.layers?.findIndex(item => item.id === layer.id) || -1;
58 | }
59 |
60 | /**
61 | * 添加图层
62 | */
63 | addLayer(layer?: LayerStrucType, index?: number) {
64 | if (!layer) return;
65 |
66 | const i = this.getLayerIndex(layer);
67 | if (i >= 0) return;
68 | layer.group = this;
69 | const layers = [...(this.layers || [])];
70 |
71 | typeof index === 'number'
72 | ? layers.splice(index, 0, layer)
73 | : layers.push(layer);
74 |
75 | this.update({ layers });
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/models/LayerStruc/ImageStruc.ts:
--------------------------------------------------------------------------------
1 | import { makeObservable, observable } from 'mobx';
2 | import { pointToAnchor } from '@p/EditorTools';
3 | import LayerStruc from './LayerStruc';
4 |
5 | export default class ImageStruc
6 | extends LayerStruc
7 | implements LayerModel.Image
8 | {
9 | url?: string;
10 |
11 | originalWidth?: number;
12 |
13 | originalHeight?: number;
14 |
15 | mimeType?: string;
16 |
17 | constructor(data: LayerModel.Image) {
18 | super(data);
19 | makeObservable(this, {
20 | url: observable,
21 | originalWidth: observable,
22 | originalHeight: observable,
23 | mimeType: observable,
24 | });
25 |
26 | this.url = data.url;
27 | this.originalWidth = data.originalWidth;
28 | this.originalHeight = data.originalHeight;
29 | this.mimeType = data?.mimeType;
30 | }
31 |
32 | model(): LayerModel.Image {
33 | const model = super.model();
34 |
35 | return {
36 | ...model,
37 | url: this.url,
38 | originalWidth: this.originalWidth,
39 | originalHeight: this.originalHeight,
40 | mimeType: this.mimeType,
41 | };
42 | }
43 |
44 | /**
45 | *
46 | * @param url 图片地址
47 | * @param size 图片的原始宽高
48 | */
49 | replaceUrl(url: string, size?: Size) {
50 | let updateDate: Partial = { url };
51 |
52 | if (size) {
53 | const { x, y } = this.getPointAtTopLeft();
54 |
55 | const { mask, anchor } = this.getSafetyModalData();
56 | const { width: maskW, height: maskH, x: maskX, y: maskY } = mask;
57 |
58 | const ratioW = size.width / maskW;
59 | const ratioH = size.height / maskH;
60 | const ratio = Math.min(ratioH, ratioW);
61 |
62 | const newWidth = size.width / ratio;
63 | const newHeight = size.height / ratio;
64 | /**
65 | * 偏移坐标,保证图片显示在蒙层的中间位置
66 | * 保证mask 物理位置不动,图层的位置需要重新计算
67 | * */
68 | const newMaskX = (newWidth - maskW) / 2;
69 | const newMaskY = (newHeight - maskH) / 2;
70 |
71 | const rectData = {
72 | width: newWidth,
73 | height: newHeight,
74 | x: x + maskX - newMaskX,
75 | y: y + maskY - newMaskY,
76 | anchor,
77 | };
78 |
79 | const position = pointToAnchor(rectData);
80 |
81 | updateDate = {
82 | ...updateDate,
83 |
84 | mask: {
85 | width: maskW,
86 | height: maskH,
87 | x: newMaskX,
88 | y: newMaskY,
89 | },
90 | originalHeight: size.height,
91 | originalWidth: size.width,
92 | ...rectData,
93 | ...position,
94 | };
95 | }
96 |
97 | this.update(updateDate);
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/src/models/LayerStruc/ShapeStruc.ts:
--------------------------------------------------------------------------------
1 | import { makeObservable, observable } from 'mobx';
2 | import LayerStruc from './LayerStruc';
3 |
4 | export default class ShapeStruc
5 | extends LayerStruc
6 | implements LayerModel.Shape
7 | {
8 | shapeType!: 'rect';
9 |
10 | rx?: number;
11 |
12 | ry?: number;
13 |
14 | fill?: string;
15 |
16 | strokeColor?: string;
17 |
18 | strokeWidth?: number;
19 |
20 | strokeType?: 'solid' | 'dashed' | 'dotted';
21 |
22 | strokeSpacing?: number;
23 |
24 | strokeLength?: number;
25 |
26 | constructor(data?: Partial) {
27 | super(data);
28 | makeObservable(this, {
29 | shapeType: observable,
30 | rx: observable,
31 | ry: observable,
32 | fill: observable,
33 | strokeType: observable,
34 | strokeColor: observable,
35 | strokeSpacing: observable,
36 | strokeLength: observable,
37 | });
38 |
39 | this.shapeType = data?.shapeType ?? 'rect';
40 | this.rx = data?.rx;
41 | this.ry = data?.ry;
42 | this.fill = data?.fill;
43 | this.strokeColor = data?.strokeColor;
44 | this.strokeWidth = data?.strokeWidth;
45 | this.strokeType = data?.strokeType;
46 | this.strokeSpacing = data?.strokeSpacing;
47 | this.strokeLength = data?.strokeLength;
48 | }
49 |
50 | model(): LayerModel.Shape {
51 | const model = super.model();
52 |
53 | return {
54 | ...model,
55 | shapeType: this.shapeType,
56 | rx: this.rx,
57 | ry: this.ry,
58 | fill: this.fill,
59 | strokeType: this.strokeType,
60 | strokeColor: this.strokeColor,
61 | strokeSpacing: this.strokeSpacing,
62 | strokeLength: this.strokeLength,
63 | };
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/models/LayerStruc/index.ts:
--------------------------------------------------------------------------------
1 | import TextStruc from './TextStruc';
2 | import ImageStruc from './ImageStruc';
3 | import BackgroundStruc from './BackgroundStruc';
4 | import ShapeStruc from './ShapeStruc';
5 | import GroupStruc from './GroupStruc';
6 |
7 | export { default } from './LayerStruc';
8 |
9 | export { TextStruc, ImageStruc, BackgroundStruc, ShapeStruc, GroupStruc };
10 |
--------------------------------------------------------------------------------
/src/models/MagicStruc/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './MagicStruc';
2 |
--------------------------------------------------------------------------------
/src/models/SceneStruc/index.ts:
--------------------------------------------------------------------------------
1 | export { default } from './SceneStruc';
2 |
--------------------------------------------------------------------------------
/src/store/Font.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import { SYS_FONTS } from '@/config/Fonts';
3 | import { isSupportFont } from '@/utils/font';
4 |
5 | export default class FontStore {
6 | fontList: typeof SYS_FONTS = [];
7 |
8 | constructor() {
9 | makeAutoObservable(this);
10 | this.setFontList();
11 | }
12 |
13 | setFontList() {
14 | this.fontList = SYS_FONTS.filter(font => isSupportFont(font.value));
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/src/store/History.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import HistoryChain, { ChainNode } from '@/helpers/Chain';
3 | import { HistoryRecord } from '@/types/history';
4 |
5 | export default class HistoryStore {
6 | /** 最大记录步骤,超过最大步骤数后,最新的顶替掉最老的 */
7 | static MAX_RECORD = 100;
8 |
9 | records: HistoryChain;
10 |
11 | current: ChainNode | null;
12 |
13 | constructor() {
14 | this.current = null;
15 | this.records = new HistoryChain();
16 | makeAutoObservable(this);
17 | }
18 |
19 | /**
20 | * 是否可以撤销
21 | * @readonly
22 | * @memberof HistoryStore
23 | */
24 | get canUndo() {
25 | return !!this.current;
26 | }
27 |
28 | /**
29 | * 是否可以恢复
30 | * @readonly
31 | * @memberof HistoryStore
32 | */
33 | get canRedo() {
34 | const { current, records } = this;
35 | return !!(current ? current.prev : records.last());
36 | }
37 |
38 | /**
39 | * 追加一份记录
40 | * @param record 新的记录
41 | */
42 | push(record: HistoryRecord) {
43 | const { current, records } = this;
44 | this.current = !current
45 | ? records.unshift(record)
46 | : records.before(record, current, true);
47 | this.overflow();
48 | }
49 |
50 | /**
51 | * 超过最大记录数,清除最旧的记录
52 | */
53 | overflow() {
54 | const { records } = this;
55 | if (records.depth > HistoryStore.MAX_RECORD) {
56 | const last = records.last();
57 | last && records.remove(last);
58 | }
59 | }
60 |
61 | /**
62 | * 撤销
63 | * @returns {: HistoryRecord | null}
64 | */
65 | undo(): HistoryRecord | null {
66 | const { current } = this;
67 | if (!current) return null;
68 | this.current = current.next;
69 | return current.value;
70 | }
71 |
72 | /**
73 | * 恢复
74 | * @returns {: HistoryRecord | null}
75 | */
76 | redo(): HistoryRecord | null {
77 | const { current, records } = this;
78 | const node = current ? current.prev : records.last();
79 | if (!node) return null;
80 | this.current = node;
81 | return node.value;
82 | }
83 |
84 | /**
85 | * 清除记录
86 | */
87 | clear() {
88 | this.current = null;
89 | this.records.clear();
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/store/Magic.ts:
--------------------------------------------------------------------------------
1 | import { makeObservable, observable } from 'mobx';
2 | import MagicStruc from '@/models/MagicStruc';
3 | import { getCreateMagicDefaultValues } from '@/config/DefaultValues';
4 | import { CreateScene } from '@/models/FactoryStruc/SceneFactory';
5 | import { product1 } from '@/config/Mocks';
6 | import isEquals from '@/utils/equals';
7 |
8 | export default class MagicStore extends MagicStruc {
9 | /** 全局loading */
10 | public globalLoading = false;
11 |
12 | /** 原本的作品数据 */
13 | private rawAppModel: MagicModel | null = null;
14 |
15 | constructor() {
16 | super();
17 | makeObservable(this, { globalLoading: observable });
18 |
19 | this.init();
20 | }
21 |
22 | private init() {
23 | const data = product1;
24 | if (data) {
25 | this.initData(data);
26 | } else {
27 | this.create();
28 | }
29 | }
30 |
31 | private initData(data: MagicModel) {
32 | const scenes = data.scenes.map(scene => CreateScene(scene));
33 | this.update({ ...data, scenes });
34 | this.activeScene(scenes[0] || null);
35 | }
36 |
37 | private create() {
38 | const scene = CreateScene();
39 | this.update({ ...getCreateMagicDefaultValues(), scenes: [scene] });
40 | this.activeScene(scene);
41 | }
42 |
43 | /** 检测内容是否改变 */
44 | hasChange(data: MagicModel) {
45 | return !isEquals(this.rawAppModel, data);
46 | }
47 |
48 | save() {
49 | console.log('保存数据');
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/store/Material.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import { MaterialEnum } from '@/constants/MaterialEnum';
3 |
4 | export default class MaterialStore {
5 | /** 当前激活 */
6 | public activeMenu: MaterialEnum = MaterialEnum.IMAGE;
7 |
8 | constructor() {
9 | makeAutoObservable(this);
10 | }
11 |
12 | changeMenu = (value: MaterialEnum) => {
13 | this.activeMenu = value;
14 | };
15 |
16 | closeMenu = () => {
17 | this.activeMenu = MaterialEnum.DEFAULT;
18 | };
19 | }
20 |
--------------------------------------------------------------------------------
/src/store/OS.ts:
--------------------------------------------------------------------------------
1 | import { makeAutoObservable } from 'mobx';
2 | import { MagneticLineType } from '@p/EditorTools';
3 | import LocalCache from '@/core/Manager/LocalCache';
4 | import { CANVAS_ZOOM_LEVEL } from '@/constants/CacheKeys';
5 | import {
6 | CANVAS_MAX_ZOOM_LEVEL,
7 | CANVAS_MIN_ZOOM_LEVEL,
8 | } from '@/constants/ZoomLevel';
9 |
10 | export default class OSStore {
11 | /** 画布缩放等级 */
12 | zoomLevel = LocalCache.get(CANVAS_ZOOM_LEVEL, 'number') ?? 1;
13 |
14 | /** 是否在移动中 */
15 | isMoveing = false;
16 |
17 | /** 是否旋转中 */
18 | isRotateing = false;
19 |
20 | /** 是否拉伸中 */
21 | isScaleing = false;
22 |
23 | /** 磁力线集合 */
24 | magneticLines: MagneticLineType[] | null = null;
25 |
26 | constructor() {
27 | makeAutoObservable(this);
28 | }
29 |
30 | /**
31 | * 是否可以放大
32 | * @readonly
33 | * @memberof OSStore
34 | */
35 | get canZoomIn() {
36 | return this.zoomLevel < CANVAS_MAX_ZOOM_LEVEL;
37 | }
38 |
39 | /**
40 | * 是否可以缩小
41 | * @readonly
42 | * @memberof OSStore
43 | */
44 | get canZoomOut() {
45 | return this.zoomLevel > CANVAS_MIN_ZOOM_LEVEL;
46 | }
47 |
48 | /**
49 | * 设置画布缩放登记
50 | * @param level 传入的等级
51 | */
52 | setZoomLevel(level: number) {
53 | this.handleSetZoomLevel(level);
54 | }
55 |
56 | /**
57 | * 放大画布
58 | */
59 | zoomIn() {
60 | this.setZoomLevel(this.zoomLevel + 0.1);
61 | }
62 |
63 | /**
64 | * 缩小画布
65 | */
66 | zoomOut() {
67 | this.setZoomLevel(this.zoomLevel - 0.1);
68 | }
69 |
70 | /**
71 | * 缩到最小
72 | */
73 | zoomMin() {
74 | this.setZoomLevel(CANVAS_MIN_ZOOM_LEVEL);
75 | }
76 |
77 | /**
78 | * 缩到最大
79 | */
80 | zoomMax() {
81 | this.setZoomLevel(CANVAS_MAX_ZOOM_LEVEL);
82 | }
83 |
84 | /**
85 | * 恢复默认
86 | */
87 | zoomReset() {
88 | this.setZoomLevel(1);
89 | }
90 |
91 | protected handleSetZoomLevel(level: number) {
92 | level = +level.toFixed(2);
93 | this.zoomLevel = Math.max(
94 | CANVAS_MIN_ZOOM_LEVEL,
95 | Math.min(CANVAS_MAX_ZOOM_LEVEL, level)
96 | );
97 | LocalCache.set(CANVAS_ZOOM_LEVEL, this.zoomLevel);
98 | }
99 |
100 | /**
101 | * 设置移动状态
102 | * @memberof OSStore
103 | */
104 | setMoveState(isMoveing: boolean) {
105 | this.isMoveing = isMoveing;
106 | }
107 |
108 | /**
109 | * 设置旋转状态
110 | * @memberof OSStore
111 | */
112 | setRotateState(isRotateing: boolean) {
113 | this.isRotateing = isRotateing;
114 | }
115 |
116 | /**
117 | * 设置移动状态
118 | * @memberof OSStore
119 | */
120 | setScaleState(isScaleing: boolean) {
121 | this.isScaleing = isScaleing;
122 | }
123 |
124 | /**
125 | * 设置磁力线
126 | */
127 | setMagneticLine(lines: MagneticLineType[]) {
128 | this.magneticLines = lines;
129 | }
130 |
131 | /**
132 | * 清除磁力线
133 | */
134 | clearMagneticLines() {
135 | this.magneticLines = null;
136 | }
137 |
138 | /**
139 | * 是否在编辑中
140 | * @readonly
141 | * @memberof OSStore
142 | */
143 | get isEditing() {
144 | return this.isMoveing || this.isRotateing || this.isScaleing;
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/store/index.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 |
3 | import MaterialStore from './Material';
4 | import OSStore from './OS';
5 | import MagicStore from './Magic';
6 | import HistoryStore from './History';
7 | import FontStore from './Font';
8 |
9 | export interface Stores {
10 | material: MaterialStore;
11 | OS: OSStore;
12 | magic: MagicStore;
13 | history: HistoryStore;
14 | font: FontStore;
15 | }
16 |
17 | export const material = new MaterialStore();
18 | export const OS = new OSStore();
19 | export const magic = new MagicStore();
20 | export const history = new HistoryStore();
21 | export const font = new FontStore();
22 |
23 | const storeContext = createContext({
24 | material,
25 | OS,
26 | magic,
27 | history,
28 | font,
29 | });
30 |
31 | export function useStores() {
32 | return useContext(storeContext);
33 | }
34 |
--------------------------------------------------------------------------------
/src/types/canvas.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 坐标轴
3 | */
4 | export type Axis = 'x' | 'y';
5 |
6 | export type PixelKey = Axis | 'width' | 'height';
7 |
--------------------------------------------------------------------------------
/src/types/componentProps.ts:
--------------------------------------------------------------------------------
1 | import { CSSProperties, PropsWithChildren } from 'react';
2 | /**
3 | * 组件基础属性
4 | */
5 | export interface ComponentProps {
6 | className?: string;
7 | style?: CSSProperties;
8 | }
9 |
10 | /**
11 | * 容器组件基础属性
12 | */
13 | export type ContainerComponentProps = ComponentProps & PropsWithChildren;
14 |
--------------------------------------------------------------------------------
/src/types/history.ts:
--------------------------------------------------------------------------------
1 | export interface HistoryRecord {
2 | name: string;
3 | context: any;
4 | reverse: () => void;
5 | obverse: () => void;
6 | }
7 |
--------------------------------------------------------------------------------
/src/types/material.ts:
--------------------------------------------------------------------------------
1 | import { MaterialEnum } from '@/constants/MaterialEnum';
2 |
3 | export interface MenuItemModel {
4 | /** name */
5 | name: MaterialEnum;
6 | /** 名称 */
7 | label: string;
8 | /** 图标 */
9 | icon: string;
10 | /** 是否隐藏名称 */
11 | hiddenLabel?: boolean;
12 | /** 组件 */
13 | component: () => JSX.Element;
14 | /** 隐藏右侧把手 */
15 | hiddenSidebar?: boolean;
16 | }
17 |
--------------------------------------------------------------------------------
/src/types/model.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TextStruc,
3 | ImageStruc,
4 | BackgroundStruc,
5 | ShapeStruc,
6 | GroupStruc,
7 | } from '@/models/LayerStruc';
8 | import { LayerTypeEnum } from '@/constants/LayerTypeEnum';
9 |
10 | export type LayerStrucType =
11 | | TextStruc
12 | | ImageStruc
13 | | BackgroundStruc
14 | | ShapeStruc
15 | | GroupStruc;
16 |
17 | export type LayerType =
18 | T extends LayerTypeEnum.BACKGROUND
19 | ? BackgroundStruc
20 | : T extends LayerTypeEnum.GROUP
21 | ? GroupStruc
22 | : T extends LayerTypeEnum.IMAGE
23 | ? ImageStruc
24 | : T extends LayerTypeEnum.TEXT
25 | ? TextStruc
26 | : T extends LayerTypeEnum.SHAPE
27 | ? ShapeStruc
28 | : never;
29 |
--------------------------------------------------------------------------------
/src/types/updateOptions.ts:
--------------------------------------------------------------------------------
1 | export interface UpdateOptions {
2 | /** 是否忽略历史记录 */
3 | ignore?: boolean;
4 | /** 是否连续的 */
5 | isContinuous?: boolean;
6 | }
7 |
--------------------------------------------------------------------------------
/src/utils/charAttrs.ts:
--------------------------------------------------------------------------------
1 | import Delta from 'quill-delta';
2 |
3 | /**
4 | * 获取文字索引和样式属性
5 | */
6 | export function getCharAttrs(delta: Delta, defaultColor: string): [] {
7 | return delta.ops
8 | ?.map((op: Delta, index: number) => {
9 | if (!op.attributes) return null;
10 | const { color, background } = op.attributes;
11 | return {
12 | influence: 0,
13 | /** 6表示背景颜色,0表示文字颜色,同时存在使用6,
14 | * 设置了背景,必须携带文字颜色 */
15 | style: background ? 6 : 0,
16 | color: color || defaultColor,
17 | bgColor: background || '',
18 | start: calcAttrIndex(delta.ops, op, index)[0],
19 | endPos: calcAttrIndex(delta.ops, op, index)[1],
20 | };
21 | })
22 | .filter(item => item);
23 | }
24 |
25 | /**
26 | * 计算索引
27 | */
28 | function calcAttrIndex(
29 | ops: Delta,
30 | curOp: Delta,
31 | index: number
32 | ): [number, number] {
33 | let start = 0;
34 | let end = 0;
35 | for (let i = 0; i < index; i += 1) {
36 | start += ops[i].insert.length;
37 | }
38 | end = start + curOp.insert.length;
39 |
40 | return [start, end];
41 | }
42 |
--------------------------------------------------------------------------------
/src/utils/collision.ts:
--------------------------------------------------------------------------------
1 | import { OBB } from '@/helpers/Obb';
2 |
3 | /**
4 | * 检测两矩形是否发生碰撞
5 | **/
6 | export function isCollision(rect1: OBB, rect2: OBB) {
7 | const nv = rect1.centerPoint.sub(rect2.centerPoint);
8 | const axisA1 = rect1.axes[0];
9 | if (
10 | rect1.getProjectionRadius(axisA1) + rect2.getProjectionRadius(axisA1) <=
11 | Math.abs(nv.dot(axisA1))
12 | )
13 | return false;
14 |
15 | const axisA2 = rect1.axes[1];
16 | if (
17 | rect1.getProjectionRadius(axisA2) + rect2.getProjectionRadius(axisA2) <=
18 | Math.abs(nv.dot(axisA2))
19 | )
20 | return false;
21 |
22 | const axisB1 = rect2.axes[0];
23 | if (
24 | rect1.getProjectionRadius(axisB1) + rect2.getProjectionRadius(axisB1) <=
25 | Math.abs(nv.dot(axisB1))
26 | )
27 | return false;
28 |
29 | const axisB2 = rect2.axes[1];
30 | if (
31 | rect1.getProjectionRadius(axisB2) + rect2.getProjectionRadius(axisB2) <=
32 | Math.abs(nv.dot(axisB2))
33 | )
34 | return false;
35 | return true;
36 | }
37 |
--------------------------------------------------------------------------------
/src/utils/copyText.ts:
--------------------------------------------------------------------------------
1 | export default function copyText(text: string) {
2 | const textArea = document.createElement('textarea');
3 | textArea.readOnly = true;
4 | textArea.style.position = 'absolute';
5 | textArea.style.left = '-9999999px';
6 | textArea.value = text;
7 | document.body.appendChild(textArea);
8 | textArea.select();
9 | textArea.setSelectionRange(0, textArea.value.length);
10 | const isCopy = document.execCommand('copy');
11 | if (!isCopy) console.warn('复制文本失败');
12 | document.body.removeChild(textArea);
13 | return isCopy;
14 | }
15 |
--------------------------------------------------------------------------------
/src/utils/download.ts:
--------------------------------------------------------------------------------
1 | import { fetchBlob } from '@p/Screenshot/Fetch';
2 | import FileSaver from 'file-saver';
3 |
4 | /**
5 | * 单个下载
6 | * @param url
7 | * @param name
8 | */
9 | export const singleDownload = async (
10 | url: string,
11 | name?: string
12 | ): Promise => {
13 | const blob = await fetchBlob(url);
14 | const suffix = blob.type.split('/')[1];
15 | FileSaver.saveAs(blob, `${name}.${suffix}`);
16 | };
17 |
--------------------------------------------------------------------------------
/src/utils/equals.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 对比两个对象全等
3 | * @param a 对比项
4 | * @param b 被对比项
5 | * @returns {boolean}
6 | */
7 | function isEqualsObject(a: Record, b: Record) {
8 | const keys1 = Object.keys(a);
9 | const keys2 = Object.keys(b);
10 | let len1 = keys1.length;
11 |
12 | if (len1 !== keys2.length) return false;
13 |
14 | while ((len1 -= 1)) {
15 | const key = keys1[len1];
16 | if (!isEquals(a[key], b[key])) return false;
17 | }
18 |
19 | if (
20 | 'constructor' in a &&
21 | 'constructor' in b &&
22 | a.constructor !== b.constructor
23 | ) {
24 | return false;
25 | }
26 |
27 | return true;
28 | }
29 |
30 | function isEqualsArray(a: any[], b: any[]) {
31 | if (a.length !== b.length) return false;
32 | let len = a.length;
33 | while ((len -= 1)) {
34 | if (!isEquals(a[len], b[len])) return false;
35 | }
36 | return true;
37 | }
38 |
39 | function isEqualsMap(a: Map, b: Map) {
40 | if (a.size !== b.size) return false;
41 | for (const key of a.keys()) {
42 | if (!isEquals(a.get(key), b.get(key))) return false;
43 | }
44 | return true;
45 | }
46 |
47 | function isEqualsSet(a: Set, b: Set) {
48 | if (a.size !== b.size) return false;
49 | const arr = Array.from(b);
50 | for (const v1 of a.values()) {
51 | let found = false;
52 | for (let i = 0; i < arr.length; i += 1) {
53 | if (isEquals(v1, arr[i])) {
54 | found = true;
55 | arr.splice(i, 1);
56 | break;
57 | }
58 | }
59 | if (!found) return false;
60 | }
61 | return true;
62 | }
63 |
64 | function isEqualsByTag(a: any, b: any, tag: string) {
65 | switch (tag) {
66 | case '[object Boolean]':
67 | case '[object Number]':
68 | case '[object Date]': {
69 | const v1 = +a;
70 | const v2 = +b;
71 | return v1 === v2;
72 | }
73 | case '[object String]':
74 | case '[object RegExp]': {
75 | return a === `${b}`;
76 | }
77 | case '[object Symbol]': {
78 | const { valueOf } = Symbol.prototype;
79 | return valueOf.call(a) === valueOf.call(b);
80 | }
81 | case '[object Map]': {
82 | return isEqualsMap(a, b);
83 | }
84 | case '[object Set]': {
85 | return isEqualsSet(a, b);
86 | }
87 | default:
88 | break;
89 | }
90 | return false;
91 | }
92 |
93 | export default function isEquals(a: any, b: any) {
94 | if (a === b) return true;
95 | if (a == null || b == null) return a === b;
96 |
97 | const tag1 = Object.prototype.toString.call(a);
98 | const tag2 = Object.prototype.toString.call(b);
99 |
100 | if (tag1 !== tag2) return false;
101 |
102 | if (tag1 !== '[object Array]' && tag1 !== '[object Object]') {
103 | return isEqualsByTag(a, b, tag1);
104 | }
105 |
106 | return Array.isArray(a) ? isEqualsArray(a, b) : isEqualsObject(a, b);
107 | }
108 |
--------------------------------------------------------------------------------
/src/utils/file.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 文件转dataURl
3 | * @param file 文件
4 | * @returns
5 | */
6 | export const fileToBase64 = (file: File): Promise =>
7 | new Promise((resolve, reject) => {
8 | const fileReader = new FileReader();
9 | fileReader.onload = (e: ProgressEvent) => {
10 | resolve((e.target?.result || '') as string);
11 | };
12 | fileReader.readAsDataURL(file);
13 | fileReader.onerror = () => {
14 | reject(new Error('fileToBase64 error'));
15 | };
16 | });
17 |
--------------------------------------------------------------------------------
/src/utils/filterData.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 过滤相同的数据
3 | * @param data 当前数据数据
4 | * @param targetData 目标数据
5 | * @returns
6 | */
7 | export function filterSameData>(
8 | data: T,
9 | targetData: Partial
10 | ) {
11 | const newTargetData: Partial = {};
12 | for (const key in targetData) {
13 | const val = targetData[key];
14 | if (data[key] === val) continue;
15 |
16 | newTargetData[key] = val;
17 | }
18 | return newTargetData;
19 | }
20 |
--------------------------------------------------------------------------------
/src/utils/font.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 判断操作系统是否存在某字体
3 | * @param fontName 字体名
4 | */
5 | export const isSupportFont = (fontName: string) => {
6 | if (typeof fontName !== 'string') return false;
7 |
8 | /** 默认字体 */
9 | const arial = 'Arial';
10 | if (fontName.toLowerCase() === arial.toLowerCase()) return true;
11 |
12 | const size = 100;
13 | const width = 100;
14 | const height = 100;
15 | const str = 'a';
16 |
17 | const canvas = document.createElement('canvas');
18 | const ctx = canvas.getContext('2d', { willReadFrequently: true });
19 |
20 | if (!ctx) return false;
21 |
22 | canvas.width = width;
23 | canvas.height = height;
24 | ctx.textAlign = 'center';
25 | ctx.fillStyle = 'black';
26 | ctx.textBaseline = 'middle';
27 |
28 | /** 将字体画入 canvas 与 默认字体对比 图像元数据 */
29 | const getDotArray = (_fontFamily: string) => {
30 | ctx.clearRect(0, 0, width, height);
31 | ctx.font = `${size}px ${_fontFamily}, ${arial}`;
32 | ctx.fillText(str, width / 2, height / 2);
33 | const imageData = ctx.getImageData(0, 0, width, height).data;
34 | return [].slice.call(imageData).filter(item => item !== 0);
35 | };
36 |
37 | return getDotArray(arial).join('') !== getDotArray(fontName).join('');
38 | };
39 |
--------------------------------------------------------------------------------
/src/utils/getPreviewSizePosition.ts:
--------------------------------------------------------------------------------
1 | import { getCanvasRectInfo, getCanvasWrapRectInfo } from '@/helpers/Node';
2 |
3 | export const PREVIEW_SIZE_OFFSET_X = 10;
4 | export const PREVIEW_SIZE_OFFSET_Y = 20;
5 |
6 | /**
7 | * 获取编辑区 预览宽高信息框的位置
8 | * @param point 鼠标以浏览器为基准的坐标点
9 | * @param previewSizeRef 预览信息框Ref
10 | * @returns
11 | */
12 | export function getPreviewSizePosition(
13 | point: Point,
14 | previewSizeDom: HTMLDivElement | null
15 | ): Point {
16 | const canvasWrapRectInfo: DOMRect | null = getCanvasWrapRectInfo();
17 | const canvasRectInfo: DOMRect | null = getCanvasRectInfo();
18 |
19 | if (!canvasWrapRectInfo || !canvasRectInfo || !previewSizeDom) return point;
20 | const mousePositionInCanvas = {
21 | x: point.x - canvasRectInfo.left,
22 | y: point.y - canvasRectInfo.top,
23 | };
24 |
25 | const result = {
26 | x: mousePositionInCanvas.x + PREVIEW_SIZE_OFFSET_X,
27 | y: mousePositionInCanvas.y + PREVIEW_SIZE_OFFSET_Y,
28 | };
29 |
30 | /** canvas 与 canvas wrap 横轴、纵轴 位置的偏移量 */
31 | const horizontalOffset = canvasRectInfo.x - canvasWrapRectInfo.x;
32 | const verticalOffset = canvasRectInfo.y - canvasWrapRectInfo.y;
33 |
34 | // =============== 判断是否超出了四个方向 ===============
35 | /** left */
36 | if (point.x + PREVIEW_SIZE_OFFSET_X < canvasWrapRectInfo.x) {
37 | result.x = -horizontalOffset;
38 | }
39 |
40 | /** right */
41 | const previewDomWidth = previewSizeDom.offsetWidth;
42 | if (point.x + previewDomWidth > canvasWrapRectInfo.right) {
43 | result.x = canvasRectInfo.width + horizontalOffset - previewDomWidth;
44 | }
45 |
46 | /** top */
47 | if (point.y + PREVIEW_SIZE_OFFSET_Y < canvasWrapRectInfo.y) {
48 | result.y = -verticalOffset;
49 | }
50 |
51 | /** bottom */
52 | const previewDomHeight = previewSizeDom.offsetHeight;
53 | if (
54 | point.y + previewDomHeight + PREVIEW_SIZE_OFFSET_Y >
55 | canvasWrapRectInfo.bottom
56 | ) {
57 | result.y = canvasRectInfo.height + verticalOffset - previewDomHeight;
58 | }
59 |
60 | return result;
61 | }
62 |
--------------------------------------------------------------------------------
/src/utils/getRectData.ts:
--------------------------------------------------------------------------------
1 | import { RectData } from '@p/EditorTools';
2 | import { LayerStrucType } from '@/types/model';
3 |
4 | /**
5 | * 获取cmp 的矩形信息
6 | * @param layer model
7 | * @returns RectData
8 | */
9 | export function getRectDataForLayer(layer: LayerStrucType): RectData {
10 | const { x, y, width, height, anchor, rotate, mask } =
11 | layer.getSafetyModalData();
12 |
13 | const rectData: RectData = { x, y, width, height, anchor, rotate };
14 |
15 | if (layer.isImage()) {
16 | rectData.mask = mask;
17 | }
18 |
19 | return rectData;
20 | }
21 |
22 | /**
23 | * 获取layers 的矩形信息
24 | * @param layers cmp model list
25 | * @param exclude 需要排除的layer,id list
26 | * @returns RectData list
27 | */
28 | export function getRectDataForLayers(
29 | layers: LayerStrucType[],
30 | exclude: string[] = []
31 | ): RectData[] {
32 | return layers
33 | .filter(layer => !exclude.includes(layer.id))
34 | .map(layer => getRectDataForLayer(layer));
35 | }
36 |
--------------------------------------------------------------------------------
/src/utils/image.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 制作一个图片
3 | * @param url 图片地址
4 | * @param width 设置宽
5 | * @param height 设置高
6 | * @returns image 对象
7 | */
8 | export function makeImage(
9 | url: string,
10 | width?: number,
11 | height?: number
12 | ): Promise {
13 | return new Promise((resolve, reject) => {
14 | const image = new Image(width, height);
15 | image.crossOrigin = 'anonymous';
16 | image.src = url;
17 | image.onload = () => {
18 | resolve(image);
19 | };
20 | image.onerror = () => {
21 | reject(new Error('制作图片失败'));
22 | };
23 | });
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils/layers.ts:
--------------------------------------------------------------------------------
1 | import { Coordinate, calcRotatedPoint, getRectCenter } from '@p/EditorTools';
2 | import { LayerStrucType } from '@/types/model';
3 | import { OBB, Vector2d } from '@/helpers/Obb';
4 | import { isCollision } from './collision';
5 |
6 | /**
7 | * 获取坐标点下面的图层
8 | * */
9 | export function getLayersByPoint(
10 | layerList: LayerStrucType[],
11 | point: Coordinate
12 | ) {
13 | return layerList.reduce(
14 | (layers: LayerStrucType[], curLayer: LayerStrucType) => {
15 | if (!curLayer.isLock) {
16 | const { width, height, x, y, rotate } = curLayer.getRectData();
17 |
18 | const center = getRectCenter({ width, height, x, y });
19 |
20 | const position = calcRotatedPoint(point, center, -rotate);
21 | /**
22 | * 是否在点内
23 | */
24 | const isInPoint =
25 | position.x > x &&
26 | position.x < x + width &&
27 | position.y > y &&
28 | position.y < y + height;
29 | isInPoint && layers.push(curLayer);
30 | }
31 | return layers;
32 | },
33 | []
34 | );
35 | }
36 |
37 | /**
38 | * 获取重叠层
39 | * @param targetLayer 目标图层,与目标图层有重叠的即满足条件
40 | * @param layers 对比的图层列表
41 | * @returns {LayerStruc[]} 重叠图层
42 | */
43 | export function getOverlayLayers(
44 | targetLayer: LayerStrucType,
45 | layers: LayerStrucType[]
46 | ) {
47 | const { x, y, width, height, rotate } = targetLayer.getRectData();
48 | const targetLayerObb = new OBB(
49 | new Vector2d(x + width / 2, y + height / 2),
50 | width,
51 | height,
52 | rotate
53 | );
54 |
55 | return layers.reduce((layerList: LayerStrucType[], layer: LayerStrucType) => {
56 | if (layer.isBack()) return layerList;
57 |
58 | const { x, y, width, height, rotate } = layer.getRectData();
59 | const layerObb = new OBB(
60 | new Vector2d(x + width / 2, y + height / 2),
61 | width,
62 | height,
63 | rotate
64 | );
65 | const collision = isCollision(targetLayerObb, layerObb);
66 | if (collision) layerList.push(layer);
67 | return layerList;
68 | }, []);
69 | }
70 |
--------------------------------------------------------------------------------
/src/utils/logo.ts:
--------------------------------------------------------------------------------
1 | const logo = `
2 |
3 | .-.
4 | ___ .-. .-. .---. .--. ( __) .--.
5 | ( ) ' \\ / .-, \\ / \\ (''") / \\
6 | | .-. .-. ; (__) ; | ; ,-. ' | | | .-. ;
7 | | | | | | | .'' | | | | | | | | |(___)
8 | | | | | | | / .'| | | | | | | | | |
9 | | | | | | | | / | | | | | | | | | | ___
10 | | | | | | | ; | ; | | ' | | | | | '( )
11 | | | | | | | ' '-' | ' '-' | | | ' '-' |
12 | (___)(___)(___)'.__.'_. '.__. | (___) '.__,'
13 | ( '-' ;
14 | '.__.
15 | `;
16 |
17 | console.log(logo);
18 |
--------------------------------------------------------------------------------
/src/utils/mergeData.ts:
--------------------------------------------------------------------------------
1 | type AnyObject = { [key: string]: any };
2 |
3 | function isObject(obj: any): obj is AnyObject {
4 | return obj !== null && typeof obj === 'object';
5 | }
6 |
7 | export function deepMerge(target: AnyObject, source: AnyObject): AnyObject {
8 | const output = { ...target };
9 |
10 | for (const key in source) {
11 | if (!Object.prototype.hasOwnProperty.call(source, key)) {
12 | continue;
13 | }
14 |
15 | const targetValue = target[key];
16 | const sourceValue = source[key];
17 |
18 | if (Array.isArray(targetValue) && Array.isArray(sourceValue)) {
19 | output[key] = [...targetValue, ...sourceValue];
20 | } else if (isObject(targetValue) && isObject(sourceValue)) {
21 | output[key] = deepMerge(targetValue, sourceValue);
22 | } else {
23 | output[key] = sourceValue;
24 | }
25 | }
26 |
27 | return output;
28 | }
29 |
--------------------------------------------------------------------------------
/src/utils/move.ts:
--------------------------------------------------------------------------------
1 | import { dragAction, MagneticLineHandler } from '@p/EditorTools';
2 | import { magic, OS } from '@/store';
3 | import { getRectDataForLayers, getRectDataForLayer } from './getRectData';
4 | import { LayerStrucType } from '@/types/model';
5 |
6 | /**
7 | * layer 移动
8 | * */
9 | export const moveHandle = (
10 | e: MouseEvent,
11 | model: LayerStrucType,
12 | zoomLevel: number
13 | ) => {
14 | const { activedScene } = magic;
15 | if (!activedScene) return;
16 |
17 | const { width = 0, height = 0, layers } = activedScene;
18 |
19 | /** 模板数据 */
20 | const templateRectData = {
21 | x: 0,
22 | y: 0,
23 | width,
24 | height,
25 | };
26 |
27 | /** 对比矩形数据 */
28 | const layersRectData = [
29 | templateRectData,
30 | ...getRectDataForLayers(layers || [], [model.id]),
31 | ];
32 |
33 | const magneticLineHandler = new MagneticLineHandler(
34 | getRectDataForLayer(model),
35 | layersRectData,
36 | {
37 | zoomLevel,
38 | }
39 | );
40 | const startPosition = {
41 | x: e.clientX,
42 | y: e.clientY,
43 | };
44 | dragAction(e, {
45 | move: e => {
46 | OS.setMoveState(true);
47 | const moveDistance = {
48 | x: e.clientX - startPosition.x,
49 | y: e.clientY - startPosition.y,
50 | };
51 | const { x, y, magneticLines } =
52 | magneticLineHandler.calcAlignmentLine(moveDistance);
53 | model.update({ x, y }, { isContinuous: true });
54 | OS.setMagneticLine(magneticLines);
55 | },
56 | end: () => {
57 | /** 历史记录 */
58 | model.update({ x: model.x, y: model.y }, { isContinuous: false });
59 | OS.clearMagneticLines();
60 | OS.setMoveState(false);
61 | },
62 | });
63 | };
64 |
--------------------------------------------------------------------------------
/src/utils/portalRender.ts:
--------------------------------------------------------------------------------
1 | import * as ReactDOM from 'react-dom';
2 | import { createRoot, type Root } from 'react-dom/client';
3 |
4 | type CreateRoot = (container: ContainerType) => Root;
5 |
6 | type ContainerType = (Element | DocumentFragment) & {
7 | [MARK]?: Root;
8 | };
9 |
10 | const fullClone = {
11 | ...ReactDOM,
12 | } as typeof ReactDOM & {
13 | __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED?: {
14 | usingClientEntryPoint?: boolean;
15 | };
16 | createRoot?: CreateRoot;
17 | };
18 |
19 | const MARK = '__magic_react_root__';
20 |
21 | function toggleWarning(skip: boolean) {
22 | const { __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED } = fullClone;
23 |
24 | if (
25 | __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED &&
26 | typeof __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED === 'object'
27 | ) {
28 | __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.usingClientEntryPoint =
29 | skip;
30 | }
31 | }
32 |
33 | /**
34 | * 渲染
35 | */
36 | export function render(node: React.ReactElement, container: ContainerType) {
37 | toggleWarning(true);
38 | const root = container[MARK] || createRoot?.(container);
39 | toggleWarning(false);
40 |
41 | root?.render(node);
42 | container[MARK] = root;
43 | }
44 |
45 | /**
46 | * 卸载
47 | */
48 | export async function unmount(container: ContainerType) {
49 | // 延迟卸载以避免React 18同步警告
50 | return Promise.resolve().then(() => {
51 | container[MARK]?.unmount();
52 | delete container[MARK];
53 | });
54 | }
55 |
56 | /**
57 | * 创建容器
58 | */
59 | export function createContainerById(id: string) {
60 | let container = document.getElementById(id);
61 | if (!container) {
62 | container = document.createElement('div');
63 | container.id = id;
64 | document.body.appendChild(container);
65 | }
66 | return container;
67 | }
68 |
69 | /**
70 | * 移除容器
71 | */
72 | export function removeContainerById(id: string) {
73 | const container = document.getElementById(id);
74 | if (container) {
75 | unmount(container);
76 | container.remove();
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/utils/random.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @example
3 | *
4 | * random(3)
5 | * 1
6 | *
7 | * random(3, 1)
8 | * // => 2
9 | *
10 | * random(45, 49)
11 | * // => 45
12 | *
13 | * random(1, 20)
14 | * // => 8
15 | */
16 | export const random = (min = 0, max = 10) => {
17 | const minVal = Math.min(min, max);
18 | const maxVal = Math.max(min, max);
19 | return Math.floor(Math.random() * (maxVal - minVal + 1) + minVal);
20 | };
21 |
22 | /**
23 | * 返回一个永不重复的字符串
24 | * @returns {string} 字符串结果
25 | */
26 | export function randomString(): string {
27 | return `${Math.random().toString(36).slice(2)}${new Date()
28 | .getTime()
29 | .toString(36)}`;
30 | }
31 |
32 | /**
33 | * 生成本地永不重复的id
34 | * @param prefix 前缀
35 | * @param binary 进制 - 任意输入返回`2 - 36`进制
36 | * @returns {string} 本地的id
37 | */
38 | export function localUniqueid(prefix?: string, binary?: number): string {
39 | const bin = binary ? (binary < 0 ? 2 : binary > 36 ? 36 : binary) : 16;
40 | const id = `${Date.now()}${Math.random().toString(bin).substring(2, 10)}`;
41 | return `${prefix || ''}${id}`;
42 | }
43 |
--------------------------------------------------------------------------------
/tsconfig.image.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "types": [],
4 | "esModuleInterop": true
5 | },
6 | "include": ["src/assets/images/**/data.ts"],
7 | "exclude": ["node_modules", "lib"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": [
6 | "DOM",
7 | "DOM.Iterable",
8 | "ES5",
9 | "ES2015",
10 | "ES2016",
11 | "ES2017",
12 | "ES2018",
13 | "ES2019",
14 | "ES2020",
15 | "ESNext"
16 | ],
17 | "allowJs": true,
18 | "checkJs": true,
19 | "composite": true,
20 | "allowSyntheticDefaultImports": true,
21 | "allowUnreachableCode": false,
22 | "baseUrl": "./",
23 | "downlevelIteration": true,
24 | "esModuleInterop": true,
25 | "experimentalDecorators": true,
26 | "forceConsistentCasingInFileNames": true,
27 | "isolatedModules": true,
28 | "jsx": "preserve",
29 | "module": "esnext",
30 | "moduleResolution": "node",
31 | "newLine": "lf",
32 | "noFallthroughCasesInSwitch": true,
33 | "noImplicitAny": false,
34 | "noImplicitReturns": true,
35 | "noImplicitThis": true,
36 | "noUnusedLocals": true,
37 | "noUnusedParameters": true,
38 | "paths": {
39 | "@/*": ["./src/*"],
40 | "@p/*": ["./packages/*"]
41 | },
42 | "pretty": true,
43 | "resolveJsonModule": true,
44 | "skipDefaultLibCheck": true,
45 | "skipLibCheck": true,
46 | "sourceMap": true,
47 | "strict": true,
48 | "strictBindCallApply": true,
49 | "strictFunctionTypes": true,
50 | "strictNullChecks": true,
51 | "strictPropertyInitialization": true,
52 | "types": ["@types/node", "vite/client"]
53 | },
54 | "include": [
55 | "packages",
56 | "src",
57 | "vite.config.ts",
58 | "lib.d.ts",
59 | "env.d.ts",
60 | "config/config.json"
61 | ],
62 | "exclude": ["node_modules"]
63 | }
64 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import { defineConfig } from 'vite';
3 | import react from '@vitejs/plugin-react';
4 |
5 | // https://vitejs.dev/config/
6 | export default defineConfig(({ mode }) => ({
7 | base: '/magic',
8 | plugins: [react()],
9 | css: {
10 | modules: {
11 | localsConvention: 'camelCase',
12 | },
13 | },
14 | resolve: {
15 | alias: {
16 | '@': path.resolve(__dirname, 'src'),
17 | '@p': path.resolve(__dirname, 'packages'),
18 | },
19 | },
20 | server: {
21 | open: true,
22 | port: 14000,
23 | host: '0.0.0.0',
24 | },
25 | esbuild: {
26 | drop: mode === 'prod' ? ['console', 'debugger'] : [],
27 | },
28 | }));
29 |
--------------------------------------------------------------------------------