├── .browserslistrc ├── vite-env.d.ts ├── src ├── layout │ ├── index.ts │ ├── Header │ │ ├── index.ts │ │ ├── Header.module.less │ │ └── Header.tsx │ ├── Setting │ │ ├── index.ts │ │ ├── Group │ │ │ ├── index.ts │ │ │ └── Group.tsx │ │ ├── Image │ │ │ ├── index.ts │ │ │ ├── Image.module.less │ │ │ └── Image.tsx │ │ ├── Shape │ │ │ ├── index.ts │ │ │ └── Shape.tsx │ │ ├── Text │ │ │ ├── index.ts │ │ │ ├── Text.module.less │ │ │ ├── TextColor.tsx │ │ │ ├── Text.tsx │ │ │ ├── TextFamilyWithSize.tsx │ │ │ ├── TextAlign.tsx │ │ │ └── TextStyle.tsx │ │ ├── Canvas │ │ │ ├── index.ts │ │ │ ├── Canvas.module.less │ │ │ └── Canvas.tsx │ │ ├── Setting.module.less │ │ └── Setting.tsx │ ├── Stage │ │ ├── index.ts │ │ ├── Canvas │ │ │ ├── index.ts │ │ │ ├── Canvas.module.less │ │ │ └── Canvas.tsx │ │ ├── Scenes │ │ │ ├── index.ts │ │ │ ├── Scene │ │ │ │ ├── index.ts │ │ │ │ ├── Scene.module.less │ │ │ │ └── Scene.tsx │ │ │ └── Scenes.module.less │ │ ├── Stage.module.less │ │ └── Stage.tsx │ ├── Material │ │ ├── index.ts │ │ ├── Content │ │ │ ├── Back │ │ │ │ ├── index.ts │ │ │ │ ├── Back.module.less │ │ │ │ └── Back.tsx │ │ │ ├── Image │ │ │ │ ├── index.ts │ │ │ │ └── Image.tsx │ │ │ ├── Shape │ │ │ │ ├── index.ts │ │ │ │ ├── Shape.module.less │ │ │ │ └── Shape.tsx │ │ │ └── Text │ │ │ │ ├── index.ts │ │ │ │ ├── Text.module.less │ │ │ │ └── Text.tsx │ │ ├── SidebarMenu │ │ │ ├── index.ts │ │ │ ├── SidebarMenu.module.less │ │ │ └── SidebarMenu.tsx │ │ ├── Material.module.less │ │ ├── Material.tsx │ │ └── constants.ts │ ├── Layout.module.less │ └── Layout.tsx ├── components │ ├── Crop │ │ ├── index.ts │ │ └── Crop.module.less │ ├── Editor │ │ ├── index.ts │ │ ├── Hover │ │ │ ├── index.ts │ │ │ ├── Hover.module.less │ │ │ └── Hover.tsx │ │ ├── RichText │ │ │ ├── index.ts │ │ │ └── RichText.module.less │ │ ├── EditorControl │ │ │ ├── index.ts │ │ │ └── EditorControl.module.less │ │ ├── Editor.module.less │ │ └── Editor.tsx │ ├── Upload │ │ ├── index.ts │ │ └── Upload.tsx │ ├── Renderer │ │ ├── Layer │ │ │ ├── index.ts │ │ │ ├── Back │ │ │ │ ├── index.ts │ │ │ │ ├── Back.module.less │ │ │ │ └── Back.tsx │ │ │ ├── Group │ │ │ │ ├── index.ts │ │ │ │ └── Group.tsx │ │ │ ├── Image │ │ │ │ ├── index.ts │ │ │ │ ├── Image.module.less │ │ │ │ └── Image.tsx │ │ │ ├── Shape │ │ │ │ ├── index.ts │ │ │ │ └── Shape.tsx │ │ │ ├── Text │ │ │ │ ├── index.ts │ │ │ │ ├── Text.module.less │ │ │ │ └── Text.tsx │ │ │ ├── Layer.module.less │ │ │ └── Layer.tsx │ │ ├── index.ts │ │ ├── Renderer.module.less │ │ └── Renderer.tsx │ ├── LayerBaseSetting │ │ ├── Flip │ │ │ ├── index.ts │ │ │ ├── Flip.module.less │ │ │ └── Flip.tsx │ │ ├── index.ts │ │ ├── LayerPosition │ │ │ └── index.ts │ │ └── LayerBaseSetting.module.less │ ├── MenuPopover │ │ ├── index.ts │ │ ├── MenuPopover.module.less │ │ └── MenuPopover.tsx │ ├── SettingContainer │ │ ├── index.ts │ │ ├── SettingContainer.module.less │ │ └── SettingContainer.tsx │ ├── SliderNumberInput │ │ ├── index.ts │ │ ├── SliderNumberInput.module.less │ │ └── SliderNumberInput.tsx │ └── ContextMenu │ │ ├── index.ts │ │ ├── props.ts │ │ ├── ContextMenuContent.tsx │ │ ├── ContextMenu.module.less │ │ ├── SubMenu.tsx │ │ ├── MenuItemComponent.tsx │ │ └── ContextMenu.tsx ├── models │ ├── MagicStruc │ │ └── index.ts │ ├── SceneStruc │ │ └── index.ts │ ├── FactoryStruc │ │ ├── SceneFactory.ts │ │ └── LayerFactory.ts │ └── LayerStruc │ │ ├── index.ts │ │ ├── BackgroundStruc.ts │ │ ├── ShapeStruc.ts │ │ ├── GroupStruc.ts │ │ └── ImageStruc.ts ├── constants │ ├── LayerRatio.ts │ ├── TemplateSize.ts │ ├── Device.ts │ ├── FontSize.ts │ ├── CacheKeys.ts │ ├── HotKeyScope.ts │ ├── ZoomLevel.ts │ ├── LayerTypeEnum.ts │ ├── Refs.ts │ ├── MaterialEnum.ts │ ├── NodeNamePlate.ts │ ├── MimeTypes.ts │ ├── PointList.ts │ ├── Font.ts │ ├── CmdEnum.ts │ └── KeyCode.ts ├── types │ ├── canvas.ts │ ├── history.ts │ ├── updateOptions.ts │ ├── componentProps.ts │ ├── material.ts │ └── model.ts ├── assets │ └── styles │ │ ├── index.less │ │ ├── setting.less │ │ ├── common.less │ │ └── reset.less ├── hooks │ ├── index.ts │ ├── useEscapeClose.ts │ ├── useGlobalClick.ts │ └── useResizeObserver.ts ├── main.tsx ├── config │ ├── Shape.ts │ ├── ColorList.ts │ ├── Fonts.ts │ ├── Mocks.ts │ └── Cmd.ts ├── utils │ ├── download.ts │ ├── filterData.ts │ ├── file.ts │ ├── copyText.ts │ ├── image.ts │ ├── logo.ts │ ├── mergeData.ts │ ├── getRectData.ts │ ├── collision.ts │ ├── random.ts │ ├── charAttrs.ts │ ├── font.ts │ ├── move.ts │ ├── getPreviewSizePosition.ts │ ├── portalRender.ts │ ├── layers.ts │ └── equals.ts ├── store │ ├── Font.ts │ ├── Material.ts │ ├── index.ts │ ├── Magic.ts │ ├── History.ts │ └── OS.ts ├── core │ ├── FormatData │ │ └── Scene.ts │ ├── Manager │ │ ├── History.ts │ │ ├── LocalCache.ts │ │ ├── ContextMenuManager.ts │ │ ├── Keyboard.ts │ │ └── Clipboard.ts │ └── Decorator │ │ └── History.ts ├── App.tsx └── helpers │ ├── Node.ts │ ├── Obb.ts │ ├── HotKey.ts │ └── Styles.ts ├── packages ├── Screenshot │ ├── index.ts │ ├── File.ts │ ├── Fetch.ts │ ├── ImageToBlob.ts │ ├── Document.ts │ ├── CreateContext.ts │ ├── Svg.ts │ └── Screenshot.ts └── EditorTools │ ├── EditorBox │ ├── index.ts │ └── props.ts │ ├── MagneticLine │ ├── index.ts │ ├── props.ts │ ├── MagneticLine.less │ └── MagneticLine.tsx │ ├── constants │ ├── Magnetic.ts │ ├── AxleDirection.ts │ ├── Points.ts │ └── EditorBox.ts │ ├── types │ ├── MagneticLine.ts │ └── Editor.ts │ ├── enum │ └── point-type.ts │ ├── core │ ├── dragAction.ts │ ├── RotateHandler.ts │ └── MaskContainScaleHandler.ts │ ├── index.ts │ └── helper │ ├── magneticLine.ts │ └── utils.ts ├── commitlint.config.cjs ├── .husky ├── commit-msg └── pre-commit ├── .eslintignore ├── .prettierignore ├── .stylelintignore ├── scripts └── prepare.js ├── .lintstagedrc ├── tsconfig.image.json ├── .hintrc ├── tsconfig.node.json ├── .prettierrc ├── .editorconfig ├── .gitignore ├── index.html ├── babel.config.js ├── vite.config.ts ├── .github └── workflows │ └── ci.yml ├── README.md ├── .stylelintrc ├── lib.d.ts └── tsconfig.json /.browserslistrc: -------------------------------------------------------------------------------- 1 | Chrome >= 45 2 | ie >= 10 3 | > 1% 4 | -------------------------------------------------------------------------------- /vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/layout/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Layout'; 2 | -------------------------------------------------------------------------------- /src/components/Crop/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Crop'; 2 | -------------------------------------------------------------------------------- /src/layout/Header/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Header'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Setting'; 2 | -------------------------------------------------------------------------------- /src/layout/Stage/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Stage'; 2 | -------------------------------------------------------------------------------- /packages/Screenshot/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Screenshot'; 2 | -------------------------------------------------------------------------------- /src/components/Editor/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Editor'; 2 | -------------------------------------------------------------------------------- /src/components/Upload/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Upload'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Material'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Group/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Group'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Image/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Image'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Shape/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Shape'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Text/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Text'; 2 | -------------------------------------------------------------------------------- /src/layout/Stage/Canvas/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Canvas'; 2 | -------------------------------------------------------------------------------- /src/layout/Stage/Scenes/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Scenes'; 2 | -------------------------------------------------------------------------------- /src/components/Editor/Hover/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Hover'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Layer'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Renderer'; 2 | -------------------------------------------------------------------------------- /src/layout/Setting/Canvas/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Canvas'; 2 | -------------------------------------------------------------------------------- /src/models/MagicStruc/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MagicStruc'; 2 | -------------------------------------------------------------------------------- /src/models/SceneStruc/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './SceneStruc'; 2 | -------------------------------------------------------------------------------- /packages/EditorTools/EditorBox/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './EditorBox'; 2 | -------------------------------------------------------------------------------- /src/components/Editor/RichText/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './RichText'; 2 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/Flip/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Flip'; 2 | -------------------------------------------------------------------------------- /src/components/MenuPopover/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MenuPopover'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Back/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Back'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Group/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Group'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Image/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Image'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Shape/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Shape'; 2 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Text/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Text'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Back/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Back'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Image/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Image'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Shape/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Shape'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Text/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Text'; 2 | -------------------------------------------------------------------------------- /packages/EditorTools/MagneticLine/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './MagneticLine'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/SidebarMenu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './SidebarMenu'; 2 | -------------------------------------------------------------------------------- /src/components/Editor/EditorControl/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './EditorControl'; 2 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './LayerBaseSetting'; 2 | -------------------------------------------------------------------------------- /src/components/SettingContainer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './SettingContainer'; 2 | -------------------------------------------------------------------------------- /src/components/SliderNumberInput/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './SliderNumberInput'; 2 | -------------------------------------------------------------------------------- /src/layout/Material/Content/Text/Text.module.less: -------------------------------------------------------------------------------- 1 | .text { 2 | position: relative; 3 | } 4 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /src/components/LayerBaseSetting/LayerPosition/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './LayerPosition'; 2 | -------------------------------------------------------------------------------- /src/constants/LayerRatio.ts: -------------------------------------------------------------------------------- 1 | /** 复制位置偏移量 与画布大小的百分比 */ 2 | export const COPY_OFFSET_RATIO = 0.01; 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /packages/EditorTools/constants/Magnetic.ts: -------------------------------------------------------------------------------- 1 | export const DISTANCE = 3; 2 | 3 | export const ZOOM_LEVEL = 1; 4 | -------------------------------------------------------------------------------- /packages/EditorTools/constants/AxleDirection.ts: -------------------------------------------------------------------------------- 1 | export enum AxleDirection { 2 | x = 'x', 3 | y = 'y', 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Layer.module.less: -------------------------------------------------------------------------------- 1 | .layer { 2 | position: absolute; 3 | overflow: hidden; 4 | } 5 | -------------------------------------------------------------------------------- /src/constants/TemplateSize.ts: -------------------------------------------------------------------------------- 1 | export const TEMPLATE_WIDTH = 1080; 2 | 3 | export const TEMPLATE_HEIGHT = 1920; 4 | -------------------------------------------------------------------------------- /src/components/Renderer/Layer/Group/Group.tsx: -------------------------------------------------------------------------------- 1 | export default function Group() { 2 | return
Group
; 3 | } 4 | -------------------------------------------------------------------------------- /src/constants/Device.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 是否是Mac系统 3 | */ 4 | export const isMacOS = /Mac OS X/.test(window.navigator.userAgent); 5 | -------------------------------------------------------------------------------- /src/layout/Stage/Scenes/Scene/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Scene'; 2 | 3 | export type { SceneProps } from './Scene'; 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintcache 2 | .stylelintcache 3 | .vscode 4 | dist 5 | node_modules 6 | package-lock.json 7 | scripts 8 | src/utils/*.js -------------------------------------------------------------------------------- /src/components/ContextMenu/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ContextMenu'; 2 | export type { ContextMenuProps } from './ContextMenu'; 3 | -------------------------------------------------------------------------------- /src/constants/FontSize.ts: -------------------------------------------------------------------------------- 1 | /** 最小文字大小 */ 2 | export const MIN_FONT_SIZE = 12; 3 | /** 最大文字大小 */ 4 | export const MAX_FONT_SIZE = Infinity; 5 | -------------------------------------------------------------------------------- /src/types/canvas.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 坐标轴 3 | */ 4 | export type Axis = 'x' | 'y'; 5 | 6 | export type PixelKey = Axis | 'width' | 'height'; 7 | -------------------------------------------------------------------------------- /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/components/Editor/Hover/Hover.module.less: -------------------------------------------------------------------------------- 1 | .hover_wrap { 2 | position: absolute; 3 | border: 2px solid #0f0; 4 | pointer-events: none; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/history.ts: -------------------------------------------------------------------------------- 1 | export interface HistoryRecord { 2 | name: string; 3 | context: any; 4 | reverse: () => void; 5 | obverse: () => void; 6 | } 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .eslintcache 4 | .stylelintcache 5 | .vscode 6 | .history 7 | dist 8 | node_modules 9 | package-lock.json 10 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .eslintcache 4 | .stylelintcache 5 | .vscode 6 | .history 7 | dist 8 | node_modules 9 | package-lock.json 10 | -------------------------------------------------------------------------------- /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/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/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/types/updateOptions.ts: -------------------------------------------------------------------------------- 1 | export interface UpdateOptions { 2 | /** 是否忽略历史记录 */ 3 | ignore?: boolean; 4 | /** 是否连续的 */ 5 | isContinuous?: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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/HotKeyScope.ts: -------------------------------------------------------------------------------- 1 | enum HotKeyScope { 2 | CANVAS = '画布', 3 | LAYER = '图层', 4 | LAYOUT = '布局', 5 | MAGIC = '魔法', 6 | } 7 | 8 | export default HotKeyScope; 9 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /packages/EditorTools/MagneticLine/props.ts: -------------------------------------------------------------------------------- 1 | import { MagneticLineType } from '../types/MagneticLine'; 2 | 3 | export interface MagneticLineProps { 4 | lines?: MagneticLineType[] | null; 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | magic编辑器 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/Renderer/Layer/Shape/Shape.tsx: -------------------------------------------------------------------------------- 1 | import { ShapeStruc } from '@/models/LayerStruc'; 2 | import { LayerProps } from '../Layer'; 3 | 4 | interface ShapeProps 5 | extends LayerProps> {} 6 | 7 | export default function Shape(props: ShapeProps) { 8 | const { model } = props; 9 | const { width, height, rx, ry } = model; 10 | 11 | return ( 12 | 19 | 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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/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/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/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 |