├── .eslintrc.json ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .lintstagedrc.js ├── .vscode └── settings.json ├── README.EN.md ├── README.md ├── commitlint.config.js ├── doc ├── matrix-decompose.md ├── other.md └── referrence.md ├── electron-builder.yml ├── electron.main.js ├── experiment ├── README.md └── matrix.js ├── jest.config.js ├── package.json ├── postcss.config.js ├── src ├── ContextMenu │ └── index.ts ├── Editor.ts ├── Export.ts ├── __tests__ │ └── util │ │ └── EventEmitter.spec.ts ├── activedElsManager.ts ├── app.ts ├── assets │ └── css │ │ └── iconfont │ │ ├── demo.css │ │ ├── demo_index.html │ │ ├── iconfont.css │ │ ├── iconfont.eot │ │ ├── iconfont.js │ │ ├── iconfont.json │ │ ├── iconfont.svg │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ └── iconfont.woff2 ├── command │ ├── arranging.ts │ ├── commandManager.ts │ ├── commands.ts │ └── path.ts ├── config │ └── editorDefaultConfig.ts ├── constants.ts ├── editorEventContext.ts ├── element │ ├── README.md │ ├── __test__ │ │ └── index.spec.废弃.ts │ ├── baseElement.ts │ ├── box.ts │ ├── div.ts │ ├── group.ts │ ├── index.ts │ ├── line.ts │ ├── path.ts │ └── rect.ts ├── huds │ ├── PredictedCurve.ts │ ├── elementOutlinesHud.ts │ ├── hudAbstract.ts │ ├── index.ts │ ├── outlineBoxHud.ts │ ├── pathDraw.ts │ ├── pencilDraw.ts │ ├── selectArea.ts │ └── transformHud.ts ├── index.html ├── interface.ts ├── layer │ └── layer.ts ├── setting │ └── editorSetting.ts ├── shortcut.ts ├── tools │ ├── ToolAbstract.ts │ ├── addRect.ts │ ├── dragCanvas.ts │ ├── index.ts │ ├── pen.ts │ ├── pencil.ts │ ├── select │ │ ├── modes │ │ │ ├── Mode.ts │ │ │ ├── MoveMode.ts │ │ │ ├── ScaleMode.ts │ │ │ ├── SelectAreaMode.ts │ │ │ └── index.ts │ │ └── select.ts │ └── zoom.ts ├── util │ ├── EventEmitter.ts │ ├── IdGenerator.ts │ ├── common.ts │ ├── debug.ts │ ├── math.ts │ ├── svg.ts │ └── typescript-ds.ts ├── viewport.ts └── views │ ├── App.css │ ├── App.tsx │ ├── ContextMenu │ ├── index.less │ └── index.tsx │ ├── EditorHeader │ ├── CmdBtnItem.tsx │ ├── CmdBtnList.tsx │ ├── EditorHeader.tsx │ └── Zoom.tsx │ ├── PanelsArea │ ├── HistoryPanel.tsx │ ├── InfoPanel.tsx │ └── PanelsArea.tsx │ ├── ToolBar │ ├── FillAndStrokeSelector.tsx │ ├── ShortcutBtn.tsx │ ├── StrokeWidthSetting.tsx │ ├── ToolBar.tsx │ ├── ToolItem.tsx │ └── components │ │ └── ColorPicker.tsx │ ├── common │ └── globalVar.ts │ └── index.tsx ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:react-hooks/recommended" 7 | ], 8 | "plugins": [ 9 | "@typescript-eslint", 10 | "react", 11 | "react-hooks" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "rules": { 15 | // 缩进 16 | "indent": ["error", 2], 17 | // 必须使用分号 18 | "semi": ["error", "never"], 19 | // 使用单引号 20 | "quotes": [ 21 | "error", 22 | "single", 23 | { 24 | "avoidEscape": true 25 | } 26 | ], 27 | // 末尾不能有空格符 28 | "no-trailing-spaces": "error", 29 | // 不能有多个空格 30 | "no-multi-spaces": "error", 31 | // 多行空白行 32 | "no-multiple-empty-lines": [ 33 | "error", 34 | { 35 | "max": 2, // 最多两行空白行 36 | "maxEOF": 0 // 文件末尾最后一行空白行 37 | } 38 | ], 39 | // 对象里的 key,如果可以不用引号,就尽量不用 40 | "quote-props": ["warn", "as-needed"], 41 | // 单行允许最大字符数 42 | "max-len": [ "warn", { "code": 120 }], 43 | // 对象的花括号留一个空格,更美观一些 44 | "object-curly-spacing": [ "warn", "always" ], 45 | "comma-dangle": ["warn", "always-multiline"], 46 | 47 | 48 | // --------- TypeScript 相关 --------------- 49 | // TS 的一些规则和 ESLint 自身的规则有重复,注意处理(把 ESLint 相同的规则 off) 50 | "@typescript-eslint/explicit-module-boundary-types": 0, 51 | // 不能有未使用的变量 52 | "@typescript-eslint/no-unused-vars": "warn", 53 | // class、enum 等使用大驼峰风格 54 | "@typescript-eslint/naming-convention": [ 55 | "warn", 56 | { 57 | "selector": "typeLike", 58 | "format": [ 59 | "PascalCase" 60 | ] 61 | } 62 | ], 63 | 64 | 65 | // -------- React 相关 -------------- 66 | // jsx 如果有多行,用括号包裹,且括号独自一行 67 | "react/jsx-wrap-multilines": ["warn", { 68 | "return": "parens-new-line" 69 | }], 70 | // props 后的 = 左右不能有空格符 71 | "react/jsx-equals-spacing": ["error", "never"], 72 | // 如果 tag 下没有内容,需要用
的形式 73 | "react/self-closing-comp": ["error", { 74 | "component": true 75 | }] 76 | } 77 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | /node_modules 3 | /dist 4 | 5 | /release 6 | 7 | ~vimfilesundodir 8 | 9 | # misc 10 | .DS_Store 11 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "src/**/*.{js,jsx,ts,tsx}": "eslint --fix", 3 | }; -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": true 4 | } 5 | } -------------------------------------------------------------------------------- /README.EN.md: -------------------------------------------------------------------------------- 1 | # svg-editor 2 | 3 | A simple web SVG editor(work in process). 4 | 5 | Built by React + Typescript 6 | 7 | Try the editor: https://blog.fstars.wang/app/svg-editor/ 8 | 9 | ## Run project 10 | 11 | ```sh 12 | yarn 13 | yarn dev 14 | ``` 15 | 16 | ## Build Electron App 17 | 18 | There is not electron dependency in `package.json`, because electron Installation is too big and someone maybe want not to build electron app. So you have to install it manually: 19 | 20 | ```sh 21 | npm install -g electron 22 | ``` 23 | 24 | If you are in China, maybe you should set npm config before install electron: 25 | 26 | ``` 27 | npm config set registry https://registry.npm.taobao.org/ 28 | npm config set ELECTRON_MIRROR http://npm.taobao.org/mirrors/electron/ 29 | ``` 30 | 31 | build, then view or package: 32 | 33 | ```sh 34 | # electron will use /dist/index as entry, so you have to build while you changed code. 35 | npm run build 36 | 37 | # view the result of electron 38 | npm run electron 39 | # or package electron app according to your OS 40 | npm run electron:package 41 | ``` 42 | 43 | ## Code Construction 44 | 45 | ``` 46 | Editor 47 | Setting 48 | ToolManager(add rect, select...) 49 | CommandManager 50 | ActivedElsManager 51 | Shortcut 52 | Viewport(scroll, zoom) 53 | ... 54 | FSVG 55 | ``` 56 | 57 | event summary(bind listener): 58 | 59 | - Zoom change 60 | - Redo/undo stack is empty 61 | - Fill/stroke/strokeWidth change 62 | - Switch tool 63 | 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 【重要】本项目不再开发,所有精力放在基于 Canvas 的图形编辑器:[Suika](https://github.com/F-star/suika) 2 | 3 | > 有问题可以关注公众号「前端西瓜哥」并留言 4 | 5 | # svg-editor 6 | 7 | 一款简单的 Web 端 SVG 编辑器(半成品)。 8 | 9 | 使用 React + TypeScript。 10 | 11 | ![image](https://user-images.githubusercontent.com/18698939/206910372-86a41560-8b52-46b3-ab6e-dab25319fb9e.png) 12 | 13 | DEMO: https://blog.fstars.wang/app/svg-editor/ 14 | 15 | [view English Doc](./README.EN.md) 16 | ## 运行项目 17 | 18 | ```sh 19 | yarn 20 | yarn dev 21 | ``` 22 | 23 | ## 构建 Electron 应用(不完善) 24 | 25 | package.json 中没有 electron 相关的依赖,因为 electron 的包太大了而且大多数人不需要构建 electron 应用。所以如果你要构建 electron 应用,需要自己手动全局安装 electron 26 | 27 | ```sh 28 | npm install -g electron 29 | ``` 30 | 31 | 国内的话需要修改一下 npm config 解决下载问题: 32 | 33 | ```sh 34 | npm config set registry https://registry.npm.taobao.org/ 35 | npm config set ELECTRON_MIRROR http://npm.taobao.org/mirrors/electron/ 36 | ``` 37 | 38 | 构建,然后预览或打包: 39 | 40 | ```sh 41 | # electron 会使用 /dist/index 作为入口,所以你每次修改代码后都需要 build 一下 42 | npm run build 43 | 44 | # 预览 electron 的运行效果 45 | npm run electron 46 | 47 | # 或 根据所在操作系统正式打包 48 | npm run electron:package 49 | ``` 50 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | extends: ['@commitlint/config-conventional'], 4 | } -------------------------------------------------------------------------------- /doc/matrix-decompose.md: -------------------------------------------------------------------------------- 1 | 2 | The collections of matrix decompose: 3 | 4 | - https://math.stackexchange.com/questions/237369/given-this-transformation-matrix-how-do-i-decompose-it-into-translation-rotati 5 | - https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix 6 | - https://gamedev.stackexchange.com/questions/50963/how-to-extract-euler-angles-from-transformation-matrix -------------------------------------------------------------------------------- /doc/other.md: -------------------------------------------------------------------------------- 1 | 1. try to add react UI 2 | 3 | Maintain Control: A Guide to Webpack and React, Pt. 1: https://www.toptal.com/react/webpack-react-tutorial-pt-1 -------------------------------------------------------------------------------- /doc/referrence.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | - 一些 SVG 编辑器介绍:http://svgtrick.com/tricks/0c3cfe02f3638078b7900ba24de60292 4 | 5 | --- 6 | 7 | 主要参考对象: 8 | 9 | - boxy-svg: https://boxy-svg.com/app 10 | - svg-edit: https://svg-edit.github.io/svgedit/dist/editor/index.html 11 | 12 | svg lib: 13 | 14 | - Raphaël 15 | - glMatrix -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | productName: "SVG Editor" 2 | appId: "com.electron.svg-editor" 3 | 4 | 5 | directories: 6 | output: ./release -------------------------------------------------------------------------------- /electron.main.js: -------------------------------------------------------------------------------- 1 | const { app, BrowserWindow } = require('electron') 2 | 3 | function createWindow() { 4 | const win = new BrowserWindow({ 5 | width: 1180, 6 | height: 860, 7 | webPreferences: { 8 | nodeIntegration: true 9 | }, 10 | }) 11 | win.menuBarVisible = false 12 | win.loadFile('./dist/index.html') 13 | } 14 | 15 | app.whenReady().then(createWindow) 16 | 17 | app.on('window-all-closed', () => { 18 | if (process.platform !== 'darwin') { 19 | app.quit() 20 | } 21 | }) 22 | 23 | app.on('activate', () => { 24 | if (BrowserWindow.getAllWindows().length === 0) { 25 | createWindow() 26 | } 27 | }) 28 | -------------------------------------------------------------------------------- /experiment/README.md: -------------------------------------------------------------------------------- 1 | 2 | some algorithm experiment: 3 | 4 | - matrix decompose(rotate matrix and so on) -------------------------------------------------------------------------------- /experiment/matrix.js: -------------------------------------------------------------------------------- 1 | function rad2deg(rad) { 2 | return rad * 180 / Math.PI 3 | } 4 | 5 | class Matrix { 6 | constructor(row, col, values) { 7 | this.row = row 8 | this.col = col 9 | 10 | const size = row * col 11 | this.values = new Array(size) 12 | for (let i = 0; i < size; i++) { 13 | this.values[i] = values[i] || 0 14 | } 15 | } 16 | 17 | add(otherMatrix) { 18 | if (otherMatrix.row != this.row || otherMatrix.col != this.col) { 19 | throw new Error('rows (or columns) are not equal') 20 | } 21 | const size = this.row * this.col 22 | const values = new Array(size) 23 | for (let i = 0; i < size; i++) { 24 | values[i] = this.values[i] + otherMatrix.values[i] 25 | } 26 | return new Matrix(this.row, this.col, values) 27 | } 28 | 29 | mul(otherMatrix) { 30 | if (this.row != otherMatrix.col) { 31 | throw new Error('the row of first matrix is not equal to the other') 32 | } 33 | 34 | } 35 | 36 | formatLog() { 37 | const arr = new Array(this.col) 38 | for (let i = 0; i < this.row; i++) { 39 | for (let j = 0; j < this.col; j++) { 40 | arr[j] = this.values[i * this.row + j] 41 | } 42 | console.log(arr) 43 | } 44 | } 45 | // https://math.stackexchange.com/questions/13150/extracting-rotation-scale-values-from-2d-transformation-matrix 46 | getRotate() { 47 | if (!this.isTransformMatix()) { 48 | throw new Error('only supported 3x2 matrix') 49 | } 50 | const a = this.values[0] 51 | const b = this.values[1] 52 | return Math.atan2(-b, a) 53 | } 54 | getScaleX() { 55 | if (!this.isTransformMatix()) { 56 | throw new Error('only supported 3x2 matrix') 57 | } 58 | const a = this.values[0] 59 | const b = this.values[1] 60 | const sign = a >= 0 ? 1 : -1 61 | return sign * Math.sqrt(a * a + b * b) 62 | } 63 | isTransformMatix() { 64 | return this.row == 2 && this.col == 3 65 | } 66 | // TODO: 67 | decompose() {} 68 | } 69 | 70 | // matrix add 71 | const a = new Matrix(2, 2, [ 72 | 1, 2, 73 | 3, 4, 74 | ]) 75 | const b = new Matrix(2, 2, [ 76 | 88, 3, 77 | 2, 1 78 | ]) 79 | a.add(b).formatLog() 80 | 81 | // calc rotate 82 | const rotate = new Matrix(2, 3, [ 83 | 0.87967, 0.475585, -0.475585, 84 | 0.87967, -3.74121, -97.1122 85 | ]).getRotate() 86 | console.log(rotate) 87 | console.log(rad2deg(rotate)) 88 | 89 | // calc scaleX 90 | const sx = new Matrix(2, 3, [ 91 | 0.774591, 0.632463, -0.632463, 92 | 0.774591, 76.884117, -35.365387 93 | ]).getScaleX() 94 | console.log(sx) -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svg-editor", 3 | "version": "0.1.0", 4 | "description": "", 5 | "private": true, 6 | "main": "electron.main.js", 7 | "scripts": { 8 | "dev": "webpack serve", 9 | "build": "webpack --env prod", 10 | "electron": "electron .", 11 | "electron:package": "cross-env ELECTRON_BUILDER_BINARIES_MIRROR=http://npm.taobao.org/mirrors/electron/ electron-builder build --publish never", 12 | "test": "jest", 13 | "prepare": "husky install" 14 | }, 15 | "keywords": [], 16 | "author": "fstar", 17 | "license": "ISC", 18 | "dependencies": { 19 | "@material-ui/core": "^4.11.2", 20 | "classnames": "^2.3.1", 21 | "react": "^17.0.1", 22 | "react-color": "^2.19.3", 23 | "react-dom": "^17.0.1", 24 | "styled-components": "^5.2.1" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.12.10", 28 | "@babel/preset-env": "^7.12.11", 29 | "@babel/preset-react": "^7.12.10", 30 | "@commitlint/cli": "^17.2.0", 31 | "@commitlint/config-conventional": "^17.2.0", 32 | "@types/jest": "^26.0.20", 33 | "@types/node": "^14.14.20", 34 | "@types/react": "^17.0.0", 35 | "@types/react-dom": "^17.0.0", 36 | "@types/styled-components": "^5.1.7", 37 | "@typescript-eslint/eslint-plugin": "^5.42.0", 38 | "@typescript-eslint/parser": "^4.12.0", 39 | "autoprefixer": "^10.3.3", 40 | "babel-jest": "^26.6.3", 41 | "babel-loader": "^8.2.2", 42 | "clean-webpack-plugin": "^3.0.0", 43 | "cross-env": "^7.0.3", 44 | "css-loader": "^5.0.1", 45 | "electron-builder": "^22.9.1", 46 | "eslint": "^7.17.0", 47 | "eslint-config-standard": "^16.0.2", 48 | "eslint-plugin-import": "^2.22.1", 49 | "eslint-plugin-node": "^11.1.0", 50 | "eslint-plugin-promise": "^4.2.1", 51 | "eslint-plugin-react": "^7.31.10", 52 | "eslint-plugin-react-hooks": "^4.6.0", 53 | "html-webpack-plugin": "^4.5.0", 54 | "husky": "^8.0.1", 55 | "jest": "^26.6.3", 56 | "less": "^4.1.1", 57 | "less-loader": "^10.0.1", 58 | "lint-staged": "^13.0.3", 59 | "mini-css-extract-plugin": "^1.3.3", 60 | "postcss": "^8.3.6", 61 | "postcss-loader": "^6.1.1", 62 | "style-loader": "^2.0.0", 63 | "ts-jest": "^26.5.1", 64 | "ts-loader": "^8.0.14", 65 | "typescript": "^4.8.4", 66 | "webpack": "^5.4.0", 67 | "webpack-cli": "^4.2.0", 68 | "webpack-dev-server": "^3.11.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: { 4 | overrideBrowserslist: ['> 0.5%', 'last 5 versions'], 5 | }, 6 | }, 7 | } 8 | -------------------------------------------------------------------------------- /src/ContextMenu/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 右键菜单 3 | * 4 | * 对 React 组件的封装,本质调用 react 组件。 5 | */ 6 | import Editor from '../Editor' 7 | import EventEmiter from '../util/EventEmitter' 8 | 9 | type ItemType = { 10 | name: string, 11 | disable: boolean, 12 | // shortcut: string, 13 | command: () => void 14 | } 15 | 16 | export type ItemGroupType = { 17 | group: string, 18 | items: ItemType[] 19 | } 20 | 21 | export type ShowEventOptions = { 22 | x: number, 23 | y: number, 24 | items: ItemGroupType[] 25 | } 26 | 27 | type EventName = 'show' | 'hide' 28 | 29 | class ContextMenu { 30 | private items: ItemGroupType[] = [] 31 | private eventEmitter = new EventEmiter() 32 | constructor(private editor: Editor) { 33 | this.items = [ 34 | { 35 | group: 'history', 36 | items: [ 37 | { 38 | name: '撤销', 39 | disable: false, 40 | command: () => { 41 | this.editor.executeCommand('undo') 42 | this.hide() 43 | } 44 | }, 45 | { 46 | name: '重做', 47 | disable: false, 48 | command: () => { 49 | this.editor.executeCommand('redo') 50 | this.hide() 51 | } 52 | } 53 | ] 54 | } 55 | ] 56 | } 57 | setItems(items: ItemGroupType[]) { 58 | this.items = items 59 | } 60 | getItems() { 61 | return this.items 62 | } 63 | show(x: number, y: number) { 64 | // 1. 检测是否要禁用 undo 或 redo 65 | this.setItemsByEditorState() 66 | this.eventEmitter.emit('show', { x, y, items: this.items }) 67 | } 68 | hide() { 69 | this.eventEmitter.emit('hide') 70 | } 71 | on(eventName: EventName, handler: (options: T) => void) { 72 | return this.eventEmitter.on(eventName, handler) 73 | } 74 | off(eventName: EventName, handler: (...args: any) => void) { 75 | return this.eventEmitter.off(eventName, handler) 76 | } 77 | // 根据编辑器状态,调整 items 78 | private setItemsByEditorState() { 79 | this.items = [ 80 | { 81 | group: 'history', 82 | items: [ 83 | { 84 | name: '撤销', 85 | disable: !!this.editor.commandManager.undoSize(), 86 | command: () => { 87 | this.editor.executeCommand('undo') 88 | this.hide() 89 | } 90 | }, 91 | { 92 | name: '重做', 93 | disable: !!this.editor.commandManager.redoSize(), 94 | command: () => { 95 | this.editor.executeCommand('redo') 96 | this.hide() 97 | } 98 | } 99 | ] 100 | } 101 | ] 102 | } 103 | } 104 | 105 | export default ContextMenu 106 | -------------------------------------------------------------------------------- /src/Editor.ts: -------------------------------------------------------------------------------- 1 | import { ActivedElsManager } from './activedElsManager' 2 | import CommandManager from './command/commandManager' 3 | import { Huds } from './huds/index' 4 | import { ToolAbstract } from './tools/ToolAbstract' 5 | import { EditorSetting } from './setting/editorSetting' 6 | import { Shortcut } from './shortcut' 7 | import { ToolManager } from './tools/index' 8 | import { Viewport } from './viewport' 9 | import { LayerManager } from './layer/layer' 10 | import Export from './Export' 11 | import editorDefaultConfig from './config/editorDefaultConfig' 12 | import ContextMenu from './ContextMenu/index' 13 | 14 | class Editor { 15 | setting: EditorSetting 16 | commandManager: CommandManager 17 | activedElsManager: ActivedElsManager 18 | shortcut: Shortcut 19 | tools: ToolManager 20 | viewport: Viewport 21 | layerManager: LayerManager 22 | huds: Huds 23 | export: Export 24 | contextMenu: ContextMenu 25 | 26 | // elements 27 | viewportElement: HTMLElement 28 | svgContainer: HTMLElement 29 | svgRoot: SVGSVGElement 30 | svgStage: SVGSVGElement 31 | svgContent: SVGGElement 32 | 33 | constructor() { 34 | this.setting = null 35 | this.contextMenu = new ContextMenu(this) 36 | this.commandManager = null 37 | this.activedElsManager = new ActivedElsManager(this) 38 | this.shortcut = new Shortcut(this) 39 | this.tools = null 40 | this.viewport = new Viewport(this) 41 | this.layerManager = new LayerManager(this) 42 | this.huds = new Huds(this) 43 | this.export = new Export(this) 44 | 45 | const svgRootW = editorDefaultConfig.svgRootW 46 | const svgRootH = editorDefaultConfig.svgRootH 47 | const svgStageW = editorDefaultConfig.svgStageW 48 | const svgStageH = editorDefaultConfig.svgStageH 49 | 50 | this.viewportElement = null 51 | 52 | const svgContainer = document.createElement('div') 53 | svgContainer.id = 'svg-container' 54 | svgContainer.style.backgroundColor = '#999' 55 | svgContainer.style.width = svgRootW + 'px' 56 | svgContainer.style.height = svgRootH + 'px' 57 | this.svgContainer = svgContainer 58 | 59 | const svgRoot = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 60 | svgRoot.id = 'svg-root' 61 | svgRoot.setAttribute('width', svgRootW + '') 62 | svgRoot.setAttribute('height', svgRootH + '') 63 | svgRoot.setAttribute('viewBox', `0 0 ${svgRootW} ${svgRootH}`) 64 | this.svgRoot = svgRoot 65 | 66 | const svgStage = document.createElementNS('http://www.w3.org/2000/svg', 'svg') 67 | svgStage.id = 'svg-stage' 68 | svgStage.setAttribute('width', String(svgStageW)) 69 | svgStage.setAttribute('height', String(svgStageH)) 70 | svgStage.setAttribute('x', String(Math.floor((svgRootW - svgStageW) / 2))) 71 | svgStage.setAttribute('y', String(Math.floor((svgRootH - svgStageH) / 2))) 72 | svgStage.style.overflow = 'visible' 73 | this.svgStage = svgStage 74 | 75 | const svgBg = document.createElementNS('http://www.w3.org/2000/svg', 'g') 76 | svgBg.id = 'background' 77 | // svgBg.setAttribute('width', 400) 78 | // svgBg.setAttribute('height', 300) 79 | svgBg.setAttribute('x', '0') 80 | svgBg.setAttribute('y', '0') 81 | 82 | const bgRect = document.createElementNS('http://www.w3.org/2000/svg', 'rect') 83 | bgRect.setAttribute('width', '100%') 84 | bgRect.setAttribute('height', '100%') 85 | bgRect.setAttribute('fill', '#fff') 86 | 87 | const svgContent = document.createElementNS('http://www.w3.org/2000/svg', 'g') 88 | svgContent.id = 'content' 89 | // svgContent.setAttribute('width', 400) 90 | // svgContent.setAttribute('height', 300) 91 | svgContent.setAttribute('x', '0') 92 | svgContent.setAttribute('y', '0') 93 | this.svgContent = svgContent 94 | 95 | svgContainer.appendChild(this.svgRoot) 96 | svgRoot.appendChild(svgStage) 97 | 98 | svgStage.appendChild(svgBg) 99 | svgBg.appendChild(bgRect) 100 | svgStage.appendChild(svgContent) 101 | 102 | this.layerManager.createInitLayerAndMount() 103 | this.huds.mount() 104 | 105 | /** mount!! */ 106 | // viewportElement.appendChild(svgContainer) 107 | // document.body.appendChild(viewportElement) 108 | } 109 | mount(selector: string) { 110 | const viewportElement = document.querySelector(selector) as HTMLDivElement 111 | viewportElement.style.overflow = 'scroll' 112 | this.viewportElement = viewportElement 113 | viewportElement.appendChild(this.svgContainer) 114 | } 115 | getCurrentLayer() { 116 | return this.layerManager.getCurrent() 117 | } 118 | 119 | setToolManager(tools: ToolManager) { 120 | this.tools = tools 121 | } 122 | // tool relatived methods 123 | setCurrentTool(name: string) { 124 | this.tools.setCurrentTool(name) 125 | } 126 | registerTool(tool: ToolAbstract) { 127 | this.tools.registerTool(tool) 128 | } 129 | setSetting(setting: EditorSetting) { 130 | this.setting = setting 131 | } 132 | setCursor(val: string) { 133 | this.svgRoot.style.cursor = val 134 | } 135 | 136 | // 命令相关 137 | setCommandManager(commandManager: CommandManager) { 138 | this.commandManager = commandManager 139 | } 140 | executeCommand(name: string, ...params: any[]) { 141 | if (name === 'undo') { 142 | this.commandManager.undo() 143 | return 144 | } 145 | if (name === 'redo') { 146 | this.commandManager.redo() 147 | return 148 | } 149 | this.commandManager.execute(name, ...params) 150 | } 151 | 152 | // TODO: set any type temporarily 153 | isContentElement(el: any) { 154 | while (el) { 155 | if (el.parentElement === this.svgContent) { 156 | return true 157 | } 158 | if (el.parentElement === this.svgRoot) { 159 | return false 160 | } 161 | el = el.parentElement 162 | } 163 | return false 164 | } 165 | } 166 | 167 | export default Editor 168 | -------------------------------------------------------------------------------- /src/Export.ts: -------------------------------------------------------------------------------- 1 | import { NS } from './constants' 2 | import Editor from './Editor' 3 | 4 | class Export { 5 | constructor(private editor: Editor) {} 6 | private getSVGData(): string { 7 | const svgStage = this.editor.svgStage 8 | const w = parseFloat(svgStage.getAttribute('width')) 9 | const h = parseFloat(svgStage.getAttribute('height')) 10 | const group = this.editor.svgContent 11 | const arr = [ 12 | ``, 14 | group.outerHTML, 15 | '' 16 | ] 17 | return arr.join('') 18 | } 19 | downloadSVG(filename: string) { 20 | const content = this.getSVGData() 21 | const blob = new Blob([content], { type: 'image/svg+xml' }) 22 | const url = URL.createObjectURL(blob) 23 | const a = document.createElement('a') 24 | a.href = url 25 | a.download = filename 26 | a.click() 27 | } 28 | } 29 | 30 | 31 | export default Export 32 | -------------------------------------------------------------------------------- /src/__tests__/util/EventEmitter.spec.ts: -------------------------------------------------------------------------------- 1 | import EventEmiter from '../../util/EventEmitter' 2 | 3 | describe('EventEmitter class', () => { 4 | test('on and emit method', () => { 5 | const emitter = new EventEmiter() 6 | let num = -1 7 | emitter.on('event', (n: number) => { 8 | num = n 9 | }) 10 | emitter.emit('event', 999) 11 | expect(num).toBe(999) 12 | }) 13 | 14 | test('multi listeners call in added order', () => { 15 | const emitter = new EventEmiter() 16 | const arr: number[] = [] 17 | emitter 18 | .on('event', () => { arr.push(0) }) 19 | .on('event', () => { arr.push(1) }) 20 | .on('event', () => { arr.push(2) }) 21 | .on('event', () => { arr.push(3) }) 22 | emitter.emit('event') 23 | 24 | expect(arr).toEqual([0, 1, 2, 3]) 25 | }) 26 | 27 | test('off method', () => { 28 | const emitter = new EventEmiter() 29 | const arr: number[] = [] 30 | const addZero = function() { arr.push(0) } 31 | emitter 32 | .on('event', addZero) 33 | .on('event', () => { arr.push(1) }) 34 | .off('event', addZero) 35 | emitter.emit('event') 36 | 37 | expect(arr).toEqual([1]) 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/activedElsManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 激活元素管理类 3 | */ 4 | 5 | import Editor from './Editor' 6 | import { FElement } from './element/baseElement' 7 | import { FSVG } from './element/index' 8 | import { getElementsInBox } from './util/common' 9 | import { IBox } from './element/box' 10 | 11 | export class ActivedElsManager { 12 | els: Array 13 | 14 | constructor(private editor: Editor) { 15 | this.els = [] 16 | } 17 | setEls(els: Array | FElement) { 18 | if (!Array.isArray(els)) els = [els] 19 | this.els = els 20 | // TODO: highlight outline, according to current tool 21 | this.heighligthEls() 22 | 23 | this.setSetting('fill') 24 | this.setSetting('stroke') 25 | this.setSetting('stroke-width') 26 | } 27 | getEls() { 28 | return this.els 29 | } 30 | setElsInBox(box: IBox) { 31 | if (box.width === 0 || box.height === 0) { 32 | this.clear() 33 | return 34 | } 35 | 36 | const elsInBox = getElementsInBox(box, this.editor.svgContent) 37 | if (elsInBox.length === 0) { 38 | this.clear() 39 | } else { 40 | this.setEls(elsInBox) 41 | } 42 | } 43 | isEmpty() { 44 | return this.els.length === 0 45 | } 46 | isNoEmpty() { 47 | return this.els.length > 0 48 | } 49 | clear() { 50 | this.els = [] 51 | // clear outline 52 | const huds = this.editor.huds 53 | huds.outlineBoxHud.clear() 54 | huds.elsOutlinesHub.clear() 55 | } 56 | contains(el: HTMLOrSVGElement) { 57 | for (let i = 0; i < this.els.length; i++) { 58 | if (this.els[i].el() === el) { 59 | return true 60 | } 61 | } 62 | return false 63 | } 64 | getMergeBBox() { 65 | // TODO: 66 | /* let x, y, w, h 67 | this.els.forEach(el => { 68 | const bbox = el.el().getbbox() 69 | }) */ 70 | } 71 | // heightlight the elements 72 | heighligthEls() { 73 | const els = this.els 74 | if (els.length === 0) { 75 | console.warn('Can\'t heightlight Empty Elements') 76 | return 77 | } 78 | const huds = this.editor.huds 79 | 80 | const firstBox = new FSVG.Box(els[0].getBBox()) 81 | const mergedBox = els.reduce((pre, curEl) => { 82 | const curBox = curEl.getBBox() 83 | return pre.merge(new FSVG.Box(curBox)) 84 | }, firstBox) 85 | 86 | huds.outlineBoxHud.drawRect(mergedBox.x, mergedBox.y, mergedBox.width, mergedBox.height) // 绘制元素的包围盒子 87 | huds.elsOutlinesHub.draw(els) 88 | } 89 | setSetting(name: string) { 90 | const els = this.els 91 | 92 | const vals = els.map(el => { 93 | return el.getAttr(name) 94 | }) 95 | 96 | this.editor.setting.set(name, vals[0]) // FIXME: 97 | } 98 | setElsAttr(name: string, val: string) { 99 | if (this.isNoEmpty()) { 100 | this.editor.executeCommand('setAttr', this.els, { [name]: val }) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | 2 | import Editor from './Editor' 3 | import CommandManager from './command/commandManager' 4 | import { EditorSetting } from './setting/editorSetting' 5 | import { ToolManager } from './tools/index' 6 | import defaultConfig from './config/editorDefaultConfig' 7 | 8 | function initEditor() { 9 | const editor = new Editor() 10 | ;(window as any).editor = editor // debug in devtool 11 | 12 | const commandManager = new CommandManager(editor) 13 | editor.setCommandManager(commandManager) 14 | editor.setSetting(new EditorSetting()) 15 | const tools = new ToolManager(editor) 16 | editor.setToolManager(tools) 17 | tools.setCurrentTool(defaultConfig.tool) 18 | tools.initToolEvent() 19 | 20 | // 注册全局快捷键 21 | // 考虑使用 hotkeys-js https://www.npmjs.com/package/hotkeys-js 貌似很好用 22 | editor.shortcut.register('Undo', 'Cmd+Z', () => { editor.executeCommand('undo') }) // 撤销 23 | editor.shortcut.register('Undo', 'Ctrl+Z', () => { editor.executeCommand('undo') }) 24 | editor.shortcut.register('Redo', 'Cmd+Shift+Z', () => { editor.executeCommand('redo') }) // 重做 25 | editor.shortcut.register('Redo', 'Ctrl+Shift+Z', () => { editor.executeCommand('redo') }) 26 | editor.shortcut.register('Delete', 'Backspace', () => { // 删除 27 | if (editor.activedElsManager.isNoEmpty()) { 28 | editor.executeCommand('removeElements') 29 | } 30 | }) 31 | 32 | // editor.mount(selector) // do those when react components do mount 33 | // editor.viewport.center() 34 | return editor 35 | } 36 | 37 | export { initEditor } 38 | 39 | /** 40 | * 理想 api 使用例子 41 | * 42 | * const editorBuilder = new Editor.builder() 43 | * editorBuilder 44 | * .setCanvasSize(400, 300) 45 | * .setStageSize(1000, 800) 46 | * .setViewportSize(800, 500) 47 | * .setZoom(100) 48 | * 49 | * const editor = editorBuilder.build() 50 | * editor.registerTool(toolMove) 51 | * 52 | */ 53 | -------------------------------------------------------------------------------- /src/assets/css/iconfont/demo.css: -------------------------------------------------------------------------------- 1 | /* Logo 字体 */ 2 | @font-face { 3 | font-family: "iconfont logo"; 4 | src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834'); 5 | src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'), 6 | url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'), 7 | url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'), 8 | url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg'); 9 | } 10 | 11 | .logo { 12 | font-family: "iconfont logo"; 13 | font-size: 160px; 14 | font-style: normal; 15 | -webkit-font-smoothing: antialiased; 16 | -moz-osx-font-smoothing: grayscale; 17 | } 18 | 19 | /* tabs */ 20 | .nav-tabs { 21 | position: relative; 22 | } 23 | 24 | .nav-tabs .nav-more { 25 | position: absolute; 26 | right: 0; 27 | bottom: 0; 28 | height: 42px; 29 | line-height: 42px; 30 | color: #666; 31 | } 32 | 33 | #tabs { 34 | border-bottom: 1px solid #eee; 35 | } 36 | 37 | #tabs li { 38 | cursor: pointer; 39 | width: 100px; 40 | height: 40px; 41 | line-height: 40px; 42 | text-align: center; 43 | font-size: 16px; 44 | border-bottom: 2px solid transparent; 45 | position: relative; 46 | z-index: 1; 47 | margin-bottom: -1px; 48 | color: #666; 49 | } 50 | 51 | 52 | #tabs .active { 53 | border-bottom-color: #f00; 54 | color: #222; 55 | } 56 | 57 | .tab-container .content { 58 | display: none; 59 | } 60 | 61 | /* 页面布局 */ 62 | .main { 63 | padding: 30px 100px; 64 | width: 960px; 65 | margin: 0 auto; 66 | } 67 | 68 | .main .logo { 69 | color: #333; 70 | text-align: left; 71 | margin-bottom: 30px; 72 | line-height: 1; 73 | height: 110px; 74 | margin-top: -50px; 75 | overflow: hidden; 76 | *zoom: 1; 77 | } 78 | 79 | .main .logo a { 80 | font-size: 160px; 81 | color: #333; 82 | } 83 | 84 | .helps { 85 | margin-top: 40px; 86 | } 87 | 88 | .helps pre { 89 | padding: 20px; 90 | margin: 10px 0; 91 | border: solid 1px #e7e1cd; 92 | background-color: #fffdef; 93 | overflow: auto; 94 | } 95 | 96 | .icon_lists { 97 | width: 100% !important; 98 | overflow: hidden; 99 | *zoom: 1; 100 | } 101 | 102 | .icon_lists li { 103 | width: 100px; 104 | margin-bottom: 10px; 105 | margin-right: 20px; 106 | text-align: center; 107 | list-style: none !important; 108 | cursor: default; 109 | } 110 | 111 | .icon_lists li .code-name { 112 | line-height: 1.2; 113 | } 114 | 115 | .icon_lists .icon { 116 | display: block; 117 | height: 100px; 118 | line-height: 100px; 119 | font-size: 42px; 120 | margin: 10px auto; 121 | color: #333; 122 | -webkit-transition: font-size 0.25s linear, width 0.25s linear; 123 | -moz-transition: font-size 0.25s linear, width 0.25s linear; 124 | transition: font-size 0.25s linear, width 0.25s linear; 125 | } 126 | 127 | .icon_lists .icon:hover { 128 | font-size: 100px; 129 | } 130 | 131 | .icon_lists .svg-icon { 132 | /* 通过设置 font-size 来改变图标大小 */ 133 | width: 1em; 134 | /* 图标和文字相邻时,垂直对齐 */ 135 | vertical-align: -0.15em; 136 | /* 通过设置 color 来改变 SVG 的颜色/fill */ 137 | fill: currentColor; 138 | /* path 和 stroke 溢出 viewBox 部分在 IE 下会显示 139 | normalize.css 中也包含这行 */ 140 | overflow: hidden; 141 | } 142 | 143 | .icon_lists li .name, 144 | .icon_lists li .code-name { 145 | color: #666; 146 | } 147 | 148 | /* markdown 样式 */ 149 | .markdown { 150 | color: #666; 151 | font-size: 14px; 152 | line-height: 1.8; 153 | } 154 | 155 | .highlight { 156 | line-height: 1.5; 157 | } 158 | 159 | .markdown img { 160 | vertical-align: middle; 161 | max-width: 100%; 162 | } 163 | 164 | .markdown h1 { 165 | color: #404040; 166 | font-weight: 500; 167 | line-height: 40px; 168 | margin-bottom: 24px; 169 | } 170 | 171 | .markdown h2, 172 | .markdown h3, 173 | .markdown h4, 174 | .markdown h5, 175 | .markdown h6 { 176 | color: #404040; 177 | margin: 1.6em 0 0.6em 0; 178 | font-weight: 500; 179 | clear: both; 180 | } 181 | 182 | .markdown h1 { 183 | font-size: 28px; 184 | } 185 | 186 | .markdown h2 { 187 | font-size: 22px; 188 | } 189 | 190 | .markdown h3 { 191 | font-size: 16px; 192 | } 193 | 194 | .markdown h4 { 195 | font-size: 14px; 196 | } 197 | 198 | .markdown h5 { 199 | font-size: 12px; 200 | } 201 | 202 | .markdown h6 { 203 | font-size: 12px; 204 | } 205 | 206 | .markdown hr { 207 | height: 1px; 208 | border: 0; 209 | background: #e9e9e9; 210 | margin: 16px 0; 211 | clear: both; 212 | } 213 | 214 | .markdown p { 215 | margin: 1em 0; 216 | } 217 | 218 | .markdown>p, 219 | .markdown>blockquote, 220 | .markdown>.highlight, 221 | .markdown>ol, 222 | .markdown>ul { 223 | width: 80%; 224 | } 225 | 226 | .markdown ul>li { 227 | list-style: circle; 228 | } 229 | 230 | .markdown>ul li, 231 | .markdown blockquote ul>li { 232 | margin-left: 20px; 233 | padding-left: 4px; 234 | } 235 | 236 | .markdown>ul li p, 237 | .markdown>ol li p { 238 | margin: 0.6em 0; 239 | } 240 | 241 | .markdown ol>li { 242 | list-style: decimal; 243 | } 244 | 245 | .markdown>ol li, 246 | .markdown blockquote ol>li { 247 | margin-left: 20px; 248 | padding-left: 4px; 249 | } 250 | 251 | .markdown code { 252 | margin: 0 3px; 253 | padding: 0 5px; 254 | background: #eee; 255 | border-radius: 3px; 256 | } 257 | 258 | .markdown strong, 259 | .markdown b { 260 | font-weight: 600; 261 | } 262 | 263 | .markdown>table { 264 | border-collapse: collapse; 265 | border-spacing: 0px; 266 | empty-cells: show; 267 | border: 1px solid #e9e9e9; 268 | width: 95%; 269 | margin-bottom: 24px; 270 | } 271 | 272 | .markdown>table th { 273 | white-space: nowrap; 274 | color: #333; 275 | font-weight: 600; 276 | } 277 | 278 | .markdown>table th, 279 | .markdown>table td { 280 | border: 1px solid #e9e9e9; 281 | padding: 8px 16px; 282 | text-align: left; 283 | } 284 | 285 | .markdown>table th { 286 | background: #F7F7F7; 287 | } 288 | 289 | .markdown blockquote { 290 | font-size: 90%; 291 | color: #999; 292 | border-left: 4px solid #e9e9e9; 293 | padding-left: 0.8em; 294 | margin: 1em 0; 295 | } 296 | 297 | .markdown blockquote p { 298 | margin: 0; 299 | } 300 | 301 | .markdown .anchor { 302 | opacity: 0; 303 | transition: opacity 0.3s ease; 304 | margin-left: 8px; 305 | } 306 | 307 | .markdown .waiting { 308 | color: #ccc; 309 | } 310 | 311 | .markdown h1:hover .anchor, 312 | .markdown h2:hover .anchor, 313 | .markdown h3:hover .anchor, 314 | .markdown h4:hover .anchor, 315 | .markdown h5:hover .anchor, 316 | .markdown h6:hover .anchor { 317 | opacity: 1; 318 | display: inline-block; 319 | } 320 | 321 | .markdown>br, 322 | .markdown>p>br { 323 | clear: both; 324 | } 325 | 326 | 327 | .hljs { 328 | display: block; 329 | background: white; 330 | padding: 0.5em; 331 | color: #333333; 332 | overflow-x: auto; 333 | } 334 | 335 | .hljs-comment, 336 | .hljs-meta { 337 | color: #969896; 338 | } 339 | 340 | .hljs-string, 341 | .hljs-variable, 342 | .hljs-template-variable, 343 | .hljs-strong, 344 | .hljs-emphasis, 345 | .hljs-quote { 346 | color: #df5000; 347 | } 348 | 349 | .hljs-keyword, 350 | .hljs-selector-tag, 351 | .hljs-type { 352 | color: #a71d5d; 353 | } 354 | 355 | .hljs-literal, 356 | .hljs-symbol, 357 | .hljs-bullet, 358 | .hljs-attribute { 359 | color: #0086b3; 360 | } 361 | 362 | .hljs-section, 363 | .hljs-name { 364 | color: #63a35c; 365 | } 366 | 367 | .hljs-tag { 368 | color: #333333; 369 | } 370 | 371 | .hljs-title, 372 | .hljs-attr, 373 | .hljs-selector-id, 374 | .hljs-selector-class, 375 | .hljs-selector-attr, 376 | .hljs-selector-pseudo { 377 | color: #795da3; 378 | } 379 | 380 | .hljs-addition { 381 | color: #55a532; 382 | background-color: #eaffea; 383 | } 384 | 385 | .hljs-deletion { 386 | color: #bd2c00; 387 | background-color: #ffecec; 388 | } 389 | 390 | .hljs-link { 391 | text-decoration: underline; 392 | } 393 | 394 | /* 代码高亮 */ 395 | /* PrismJS 1.15.0 396 | https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript */ 397 | /** 398 | * prism.js default theme for JavaScript, CSS and HTML 399 | * Based on dabblet (http://dabblet.com) 400 | * @author Lea Verou 401 | */ 402 | code[class*="language-"], 403 | pre[class*="language-"] { 404 | color: black; 405 | background: none; 406 | text-shadow: 0 1px white; 407 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 408 | text-align: left; 409 | white-space: pre; 410 | word-spacing: normal; 411 | word-break: normal; 412 | word-wrap: normal; 413 | line-height: 1.5; 414 | 415 | -moz-tab-size: 4; 416 | -o-tab-size: 4; 417 | tab-size: 4; 418 | 419 | -webkit-hyphens: none; 420 | -moz-hyphens: none; 421 | -ms-hyphens: none; 422 | hyphens: none; 423 | } 424 | 425 | pre[class*="language-"]::-moz-selection, 426 | pre[class*="language-"] ::-moz-selection, 427 | code[class*="language-"]::-moz-selection, 428 | code[class*="language-"] ::-moz-selection { 429 | text-shadow: none; 430 | background: #b3d4fc; 431 | } 432 | 433 | pre[class*="language-"]::selection, 434 | pre[class*="language-"] ::selection, 435 | code[class*="language-"]::selection, 436 | code[class*="language-"] ::selection { 437 | text-shadow: none; 438 | background: #b3d4fc; 439 | } 440 | 441 | @media print { 442 | 443 | code[class*="language-"], 444 | pre[class*="language-"] { 445 | text-shadow: none; 446 | } 447 | } 448 | 449 | /* Code blocks */ 450 | pre[class*="language-"] { 451 | padding: 1em; 452 | margin: .5em 0; 453 | overflow: auto; 454 | } 455 | 456 | :not(pre)>code[class*="language-"], 457 | pre[class*="language-"] { 458 | background: #f5f2f0; 459 | } 460 | 461 | /* Inline code */ 462 | :not(pre)>code[class*="language-"] { 463 | padding: .1em; 464 | border-radius: .3em; 465 | white-space: normal; 466 | } 467 | 468 | .token.comment, 469 | .token.prolog, 470 | .token.doctype, 471 | .token.cdata { 472 | color: slategray; 473 | } 474 | 475 | .token.punctuation { 476 | color: #999; 477 | } 478 | 479 | .namespace { 480 | opacity: .7; 481 | } 482 | 483 | .token.property, 484 | .token.tag, 485 | .token.boolean, 486 | .token.number, 487 | .token.constant, 488 | .token.symbol, 489 | .token.deleted { 490 | color: #905; 491 | } 492 | 493 | .token.selector, 494 | .token.attr-name, 495 | .token.string, 496 | .token.char, 497 | .token.builtin, 498 | .token.inserted { 499 | color: #690; 500 | } 501 | 502 | .token.operator, 503 | .token.entity, 504 | .token.url, 505 | .language-css .token.string, 506 | .style .token.string { 507 | color: #9a6e3a; 508 | background: hsla(0, 0%, 100%, .5); 509 | } 510 | 511 | .token.atrule, 512 | .token.attr-value, 513 | .token.keyword { 514 | color: #07a; 515 | } 516 | 517 | .token.function, 518 | .token.class-name { 519 | color: #DD4A68; 520 | } 521 | 522 | .token.regex, 523 | .token.important, 524 | .token.variable { 525 | color: #e90; 526 | } 527 | 528 | .token.important, 529 | .token.bold { 530 | font-weight: bold; 531 | } 532 | 533 | .token.italic { 534 | font-style: italic; 535 | } 536 | 537 | .token.entity { 538 | cursor: help; 539 | } 540 | -------------------------------------------------------------------------------- /src/assets/css/iconfont/demo_index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IconFont Demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

19 | 29 |
30 |
31 |
    32 | 33 |
  • 34 | 35 |
    zoom
    36 |
    &#xe60b;
    37 |
  • 38 | 39 |
  • 40 | 41 |
    pan
    42 |
    &#xe60a;
    43 |
  • 44 | 45 |
  • 46 | 47 |
    pen
    48 |
    &#xe609;
    49 |
  • 50 | 51 |
  • 52 | 53 |
    pencil
    54 |
    &#xe608;
    55 |
  • 56 | 57 |
  • 58 | 59 |
    rect
    60 |
    &#xe607;
    61 |
  • 62 | 63 |
  • 64 | 65 |
    select
    66 |
    &#xe606;
    67 |
  • 68 | 69 |
70 |
71 |

Unicode 引用

72 |
73 | 74 |

Unicode 是字体在网页端最原始的应用方式,特点是:

75 |
    76 |
  • 兼容性最好,支持 IE6+,及所有现代浏览器。
  • 77 |
  • 支持按字体的方式去动态调整图标大小,颜色等等。
  • 78 |
  • 但是因为是字体,所以不支持多色。只能使用平台里单色的图标,就算项目里有多色图标也会自动去色。
  • 79 |
80 |
81 |

注意:新版 iconfont 支持多色图标,这些多色图标在 Unicode 模式下将不能使用,如果有需求建议使用symbol 的引用方式

82 |
83 |

Unicode 使用步骤如下:

84 |

第一步:拷贝项目下面生成的 @font-face

85 |
@font-face {
 87 |   font-family: 'svg-editor-iconfont';
 88 |   src: url('iconfont.eot');
 89 |   src: url('iconfont.eot?#iefix') format('embedded-opentype'),
 90 |       url('iconfont.woff2') format('woff2'),
 91 |       url('iconfont.woff') format('woff'),
 92 |       url('iconfont.ttf') format('truetype'),
 93 |       url('iconfont.svg#svg-editor-iconfont') format('svg');
 94 | }
 95 | 
96 |

第二步:定义使用 iconfont 的样式

97 |
.svg-editor-iconfont {
 99 |   font-family: "svg-editor-iconfont" !important;
100 |   font-size: 16px;
101 |   font-style: normal;
102 |   -webkit-font-smoothing: antialiased;
103 |   -moz-osx-font-smoothing: grayscale;
104 | }
105 | 
106 |

第三步:挑选相应图标并获取字体编码,应用于页面

107 |
108 | <span class="svg-editor-iconfont">&#x33;</span>
110 | 
111 |
112 |

"svg-editor-iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。

113 |
114 |
115 |
116 |
117 |
    118 | 119 |
  • 120 | 121 |
    122 | zoom 123 |
    124 |
    .icon-zoom 125 |
    126 |
  • 127 | 128 |
  • 129 | 130 |
    131 | pan 132 |
    133 |
    .icon-pan 134 |
    135 |
  • 136 | 137 |
  • 138 | 139 |
    140 | pen 141 |
    142 |
    .icon-pen 143 |
    144 |
  • 145 | 146 |
  • 147 | 148 |
    149 | pencil 150 |
    151 |
    .icon-pencil 152 |
    153 |
  • 154 | 155 |
  • 156 | 157 |
    158 | rect 159 |
    160 |
    .icon-rect 161 |
    162 |
  • 163 | 164 |
  • 165 | 166 |
    167 | select 168 |
    169 |
    .icon-select 170 |
    171 |
  • 172 | 173 |
174 |
175 |

font-class 引用

176 |
177 | 178 |

font-class 是 Unicode 使用方式的一种变种,主要是解决 Unicode 书写不直观,语意不明确的问题。

179 |

与 Unicode 使用方式相比,具有如下特点:

180 |
    181 |
  • 兼容性良好,支持 IE8+,及所有现代浏览器。
  • 182 |
  • 相比于 Unicode 语意明确,书写更直观。可以很容易分辨这个 icon 是什么。
  • 183 |
  • 因为使用 class 来定义图标,所以当要替换图标时,只需要修改 class 里面的 Unicode 引用。
  • 184 |
  • 不过因为本质上还是使用的字体,所以多色图标还是不支持的。
  • 185 |
186 |

使用步骤如下:

187 |

第一步:引入项目下面生成的 fontclass 代码:

188 |
<link rel="stylesheet" href="./iconfont.css">
189 | 
190 |

第二步:挑选相应图标并获取类名,应用于页面:

191 |
<span class="svg-editor-iconfont icon-xxx"></span>
192 | 
193 |
194 |

" 195 | svg-editor-iconfont" 是你项目下的 font-family。可以通过编辑项目查看,默认是 "iconfont"。

196 |
197 |
198 |
199 |
200 |
    201 | 202 |
  • 203 | 206 |
    zoom
    207 |
    #icon-zoom
    208 |
  • 209 | 210 |
  • 211 | 214 |
    pan
    215 |
    #icon-pan
    216 |
  • 217 | 218 |
  • 219 | 222 |
    pen
    223 |
    #icon-pen
    224 |
  • 225 | 226 |
  • 227 | 230 |
    pencil
    231 |
    #icon-pencil
    232 |
  • 233 | 234 |
  • 235 | 238 |
    rect
    239 |
    #icon-rect
    240 |
  • 241 | 242 |
  • 243 | 246 |
    select
    247 |
    #icon-select
    248 |
  • 249 | 250 |
251 |
252 |

Symbol 引用

253 |
254 | 255 |

这是一种全新的使用方式,应该说这才是未来的主流,也是平台目前推荐的用法。相关介绍可以参考这篇文章 256 | 这种用法其实是做了一个 SVG 的集合,与另外两种相比具有如下特点:

257 |
    258 |
  • 支持多色图标了,不再受单色限制。
  • 259 |
  • 通过一些技巧,支持像字体那样,通过 font-size, color 来调整样式。
  • 260 |
  • 兼容性较差,支持 IE9+,及现代浏览器。
  • 261 |
  • 浏览器渲染 SVG 的性能一般,还不如 png。
  • 262 |
263 |

使用步骤如下:

264 |

第一步:引入项目下面生成的 symbol 代码:

265 |
<script src="./iconfont.js"></script>
266 | 
267 |

第二步:加入通用 CSS 代码(引入一次就行):

268 |
<style>
269 | .icon {
270 |   width: 1em;
271 |   height: 1em;
272 |   vertical-align: -0.15em;
273 |   fill: currentColor;
274 |   overflow: hidden;
275 | }
276 | </style>
277 | 
278 |

第三步:挑选相应图标并获取类名,应用于页面:

279 |
<svg class="icon" aria-hidden="true">
280 |   <use xlink:href="#icon-xxx"></use>
281 | </svg>
282 | 
283 |
284 |
285 | 286 |
287 |
288 | 307 | 308 | 309 | -------------------------------------------------------------------------------- /src/assets/css/iconfont/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face {font-family: "svg-editor-iconfont"; 2 | src: url('iconfont.eot?t=1611242323097'); /* IE9 */ 3 | src: url('iconfont.eot?t=1611242323097#iefix') format('embedded-opentype'), /* IE6-IE8 */ 4 | url('data:application/x-font-woff2;charset=utf-8;base64,d09GMgABAAAAAAS8AAsAAAAACcAAAARvAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHEIGVgCDNgqFQIRNATYCJAMcCxAABCAFhXEHUxt0CBEVpDmQ/RxwN5cbrQTbLOvlI/9nNVt6LMccIykzM3fwfK69f26Szb4Sku1UM/jW1yIqFKZGsMNPhGqH7LK5wOQiACuxktA+/ncuk2PSEaYoR2jvWAH9tJy+NCPQyE7MuQXAD/czXZR+m9ZbAGB12ihSeGBRVn0VVGEHPJAT9lMQz2bCP0HXQwB/khEB07BnYgNSGLRlAsio5e8JUjEdpgQRfEqbs9cg68DBpxaw1oA19/TykXIhAIWj0dY2U10fsPGP9yZAj/8f77dRgN+fBeAOgQYKARiQwVxbN4r6sUKw8VcUsfLpBoE3Wc/jTcLmzfYfPLA4GDQKAbFoskDguwMW3pi8UJ6EBgfyFDQYyNPQoCHPQIOCPAvB2chce08wEAScICQCTVHpytSZ8M7i56IC5V18E29x/AODoscODrp7e739/YEtDNMroyid3eFhd++gf3A4ONzv7e8dERVBAH/Z6SPyKcpfbyCkHhqUIHRE0IXsPjpoGCBaW7iTmHR3GOOMM/uCe7qAEAQZIwiNIIq6emt62Avb23V5/puGYyctXNPRRVdH71R73N23tQ1GIBobe13razM+xv7xNslkEA0OTEuO0uMiLhAR2SIGd4KeHAZmZAh30tvbWmS0ns7uCTLpopCOlnxVn0NFfwUfVcUDhQA5pykND67in+qX3svWqnze+RrreZfGnlvCXSLipiKWIl1m4hYi3aLGTNj5PeA7Kyaow+/n/N9ZFiCoJaBNk8WvIxAJkQ7hTn0i2MsQvJImG0l3fnq8aufXGVi+eSnOtvwmOvq/5FwQ3q5asia1Xnnl3bEi4YHn/Wlm40u0G7dfbak9qB0pPdyo+bbe/4w5hl0Cy5CUjPI5Ex22YCY7Qw0dVQNNoPzldwmCrj69PnwwMwjXnlzLuc03fSbUW01OzCI2A5SrAY0WIo1Mi/0k3Pm4uGKtOM2oGccQcjs7k4hhusvds9dRBorXybUx8OrDg3JpYhk8YSWQhhDg/VdfdQ4AdKT6w0+7pIej2ODd0nWEDXShGkh81M2Rj+/mbwg9ntJ0J1f5nQowADxSpq1dn3fJ0F/ch4La/K+EX7PBqEJN7xq5vFmEAC41keUowB9/wIN2hXPRnyMKEFqECJ4PULjkgiYCcbTBa4FDAB1gicAD/CkgWhJAL27SiPED8hkFCNmcgiJYBDTZQo42eFlwiBZnic3DR4I/nRhFB5zj3R+zzWEqYEFmJ8SaM58ay0UmPDQkWFiCcSndWRuMA3fKc8tJp+oDDy3mE01WS5L74AMfj6NO0oWghHLmkT3qZuC6jASc2TAXsilEsKQoNO+V5DnzUAZtc5gKWJDZCbHmzKf2LJFB4aEh1RjfEoxLvKUN6I8Dd8rr1wC2PvQms+L9QTRZbYfel/ltS66Rj0m6EJRQUsg8skdbwNVjjAT5s2yYC9lsEQmWFKoVrSuTx1d4T6oNhDJIO38SR3AUx3AcJ3ASp1DslDEvGkx9EwE/EZlbboyfnScRgutoEQIA') format('woff2'), 5 | url('iconfont.woff?t=1611242323097') format('woff'), 6 | url('iconfont.ttf?t=1611242323097') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */ 7 | url('iconfont.svg?t=1611242323097#svg-editor-iconfont') format('svg'); /* iOS 4.1- */ 8 | } 9 | 10 | .svg-editor-iconfont { 11 | font-family: "svg-editor-iconfont" !important; 12 | font-size: 16px; 13 | font-style: normal; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | .icon-zoom:before { 19 | content: "\e60b"; 20 | } 21 | 22 | .icon-pan:before { 23 | content: "\e60a"; 24 | } 25 | 26 | .icon-pen:before { 27 | content: "\e609"; 28 | } 29 | 30 | .icon-pencil:before { 31 | content: "\e608"; 32 | } 33 | 34 | .icon-rect:before { 35 | content: "\e607"; 36 | } 37 | 38 | .icon-select:before { 39 | content: "\e606"; 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/assets/css/iconfont/iconfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/F-star/svg-editor/52f9750ed5c2e6fc66911324e65b27de5298c9df/src/assets/css/iconfont/iconfont.eot -------------------------------------------------------------------------------- /src/assets/css/iconfont/iconfont.js: -------------------------------------------------------------------------------- 1 | !function(e){var t,c,n,o,l,i,d='',s=(s=document.getElementsByTagName("script"))[s.length-1].getAttribute("data-injectcss");if(s&&!e.__iconfont__svg__cssinject__){e.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(e){console&&console.log(e)}}function a(){l||(l=!0,n())}t=function(){var e,t,c,n;(n=document.createElement("div")).innerHTML=d,d=null,(c=n.getElementsByTagName("svg")[0])&&(c.setAttribute("aria-hidden","true"),c.style.position="absolute",c.style.width=0,c.style.height=0,c.style.overflow="hidden",e=c,(t=document.body).firstChild?(n=e,(c=t.firstChild).parentNode.insertBefore(n,c)):t.appendChild(e))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(t,0):(c=function(){document.removeEventListener("DOMContentLoaded",c,!1),t()},document.addEventListener("DOMContentLoaded",c,!1)):document.attachEvent&&(n=t,o=e.document,l=!1,(i=function(){try{o.documentElement.doScroll("left")}catch(e){return void setTimeout(i,50)}a()})(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,a())})}(window); -------------------------------------------------------------------------------- /src/assets/css/iconfont/iconfont.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "2334912", 3 | "name": "svg-editor", 4 | "font_family": "svg-editor-iconfont", 5 | "css_prefix_text": "icon-", 6 | "description": "svg editor", 7 | "glyphs": [ 8 | { 9 | "icon_id": "19512746", 10 | "name": "zoom", 11 | "font_class": "zoom", 12 | "unicode": "e60b", 13 | "unicode_decimal": 58891 14 | }, 15 | { 16 | "icon_id": "19512739", 17 | "name": "pan", 18 | "font_class": "pan", 19 | "unicode": "e60a", 20 | "unicode_decimal": 58890 21 | }, 22 | { 23 | "icon_id": "19512729", 24 | "name": "pen", 25 | "font_class": "pen", 26 | "unicode": "e609", 27 | "unicode_decimal": 58889 28 | }, 29 | { 30 | "icon_id": "19512683", 31 | "name": "pencil", 32 | "font_class": "pencil", 33 | "unicode": "e608", 34 | "unicode_decimal": 58888 35 | }, 36 | { 37 | "icon_id": "19512675", 38 | "name": "rect", 39 | "font_class": "rect", 40 | "unicode": "e607", 41 | "unicode_decimal": 58887 42 | }, 43 | { 44 | "icon_id": "19512644", 45 | "name": "select", 46 | "font_class": "select", 47 | "unicode": "e606", 48 | "unicode_decimal": 58886 49 | } 50 | ] 51 | } 52 | -------------------------------------------------------------------------------- /src/assets/css/iconfont/iconfont.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Created by iconfont 9 | 10 | 11 | 12 | 13 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/assets/css/iconfont/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/F-star/svg-editor/52f9750ed5c2e6fc66911324e65b27de5298c9df/src/assets/css/iconfont/iconfont.ttf -------------------------------------------------------------------------------- /src/assets/css/iconfont/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/F-star/svg-editor/52f9750ed5c2e6fc66911324e65b27de5298c9df/src/assets/css/iconfont/iconfont.woff -------------------------------------------------------------------------------- /src/assets/css/iconfont/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/F-star/svg-editor/52f9750ed5c2e6fc66911324e65b27de5298c9df/src/assets/css/iconfont/iconfont.woff2 -------------------------------------------------------------------------------- /src/command/arranging.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | /** 4 | * front 5 | * forward 6 | * backward 7 | * back 8 | */ 9 | 10 | import Editor from '../Editor' 11 | import { FElement } from '../element/baseElement' 12 | import { BaseCommand } from './commands' 13 | 14 | class ArrangingFront extends BaseCommand { 15 | els: Array 16 | nextSiblings: Array 17 | 18 | constructor(editor: Editor, els?: Array) { 19 | super(editor) 20 | if (els === undefined) { 21 | this.els = editor.activedElsManager.getEls() 22 | } else { 23 | this.els = els 24 | } 25 | 26 | if (this.els.length === 0) { 27 | throw new Error('elements can not be empty.') 28 | } 29 | 30 | this.nextSiblings = new Array(this.els.length) 31 | for (let i = 0; i < this.els.length; i++) { 32 | const el = this.els[i] 33 | this.nextSiblings[i] = el.nextSibling() 34 | el.front() 35 | } 36 | } 37 | static cmdName() { 38 | return 'front' 39 | } 40 | cmdName() { 41 | return 'front' 42 | } 43 | undo() { 44 | const size = this.els.length 45 | for (let i = size - 1; i >= 0; i--) { 46 | const el = this.els[i] 47 | const nextSibling = this.nextSiblings[i] 48 | if (nextSibling !== null) { 49 | el.before(nextSibling) 50 | } 51 | } 52 | } 53 | redo() { 54 | for (let i = 0; i < this.els.length; i++) { 55 | const el = this.els[i] 56 | el.front() 57 | } 58 | } 59 | } 60 | 61 | class ArrangingBack extends BaseCommand { 62 | els: Array 63 | previousSiblings: Array 64 | 65 | constructor(editor: Editor, els?: Array) { 66 | super(editor) 67 | if (els === undefined) { 68 | this.els = editor.activedElsManager.getEls() 69 | } else { 70 | this.els = els 71 | } 72 | 73 | if (this.els.length === 0) { 74 | throw new Error('elements can not be empty.') 75 | } 76 | 77 | this.previousSiblings = new Array(this.els.length) 78 | for (let i = this.els.length - 1; i >= 0; i--) { 79 | const el = this.els[i] 80 | this.previousSiblings[i] = el.previousSibling() 81 | } 82 | 83 | this.exec() 84 | } 85 | static cmdName() { 86 | return 'back' 87 | } 88 | cmdName() { 89 | return 'back' 90 | } 91 | undo() { 92 | const size = this.els.length 93 | for (let i = 0; i < size; i++) { 94 | const el = this.els[i] 95 | const nextSibling = this.previousSiblings[i] 96 | if (nextSibling !== null) { 97 | el.after(nextSibling) 98 | } 99 | } 100 | } 101 | redo() { 102 | this.exec() 103 | } 104 | exec() { 105 | for (let i = this.els.length - 1; i >= 0; i--) { 106 | const el = this.els[i] 107 | el.back() 108 | } 109 | } 110 | } 111 | 112 | /** 113 | * forward elements 114 | */ 115 | class ArrangingForward extends BaseCommand { 116 | els: Array 117 | 118 | constructor(editor: Editor, els?: Array) { 119 | super(editor) 120 | if (els === undefined) { 121 | this.els = editor.activedElsManager.getEls() 122 | } else { 123 | this.els = els 124 | } 125 | 126 | if (this.els.length === 0) { 127 | throw new Error('elements can not be empty.') 128 | } 129 | 130 | this.exec() 131 | } 132 | static cmdName() { 133 | return 'forward' 134 | } 135 | cmdName() { 136 | return 'forward' 137 | } 138 | exec() { 139 | let lastForwardedEl = null 140 | for (let i = this.els.length - 1; i >= 0; i--) { 141 | const el = this.els[i] 142 | const nextSibling = el.el().nextElementSibling 143 | if (lastForwardedEl !== null && nextSibling === lastForwardedEl) { 144 | // do nothing 145 | } else if (nextSibling) { 146 | el.after(nextSibling as SVGElement) 147 | } 148 | lastForwardedEl = el.el() 149 | } 150 | } 151 | undo() { 152 | let lastBackwardedEl = null 153 | for (let i = 0; i < this.els.length; i++) { 154 | const el = this.els[i] 155 | const previousSibling = el.el().previousElementSibling 156 | if (lastBackwardedEl !== null && previousSibling === lastBackwardedEl) { 157 | // do nothing 158 | } else if (previousSibling) { 159 | el.before(previousSibling as SVGElement) 160 | } 161 | lastBackwardedEl = el.el() 162 | } 163 | } 164 | redo() { this.exec() } 165 | } 166 | 167 | /** 168 | * backward elements 169 | */ 170 | class ArrangingBackward extends BaseCommand { 171 | els: Array 172 | 173 | constructor(editor: Editor, els: Array) { 174 | super(editor) 175 | if (els === undefined) { 176 | this.els = editor.activedElsManager.getEls() 177 | } else { 178 | this.els = els 179 | } 180 | if (this.els.length === 0) { 181 | throw new Error('elements can not be empty.') 182 | } 183 | 184 | this.exec() 185 | } 186 | static cmdName() { 187 | return 'backward' 188 | } 189 | cmdName() { 190 | return 'backward' 191 | } 192 | exec() { 193 | let lastBackwardedEl = null 194 | for (let i = 0; i < this.els.length; i++) { 195 | const el = this.els[i] 196 | const previousSibling = el.el().previousElementSibling 197 | if (lastBackwardedEl !== null && previousSibling === lastBackwardedEl) { 198 | // do nothing 199 | } else if (previousSibling) { 200 | el.before(previousSibling as SVGElement) 201 | } 202 | lastBackwardedEl = el.el() 203 | } 204 | } 205 | undo() { 206 | let lastForwardedEl = null 207 | for (let i = this.els.length - 1; i >= 0; i--) { 208 | const el = this.els[i] 209 | const nextSibling = el.el().nextElementSibling 210 | if (lastForwardedEl !== null && nextSibling === lastForwardedEl) { 211 | // do nothing 212 | } else if (nextSibling) { 213 | el.after(nextSibling as SVGElement) 214 | } 215 | lastForwardedEl = el.el() 216 | } 217 | } 218 | redo() { this.exec() } 219 | } 220 | 221 | export { 222 | ArrangingFront, 223 | ArrangingBack, 224 | ArrangingForward, 225 | ArrangingBackward 226 | } 227 | -------------------------------------------------------------------------------- /src/command/commandManager.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CommandManager Class 3 | * 4 | * 5 | * CommandManager.undo() 6 | * CommandManager.redo() 7 | */ 8 | 9 | import EventEmitter from '../util/EventEmitter' 10 | import Editor from '../Editor' 11 | import { ArrangingBack, ArrangingBackward, ArrangingForward, ArrangingFront } from './arranging' 12 | import { AddPath, AddRect, BaseCommand, DMove, RemoveElements, SetAttr } from './commands' 13 | import { AddPathSeg } from './path' 14 | import { ArrayStack } from '../util/typescript-ds' 15 | 16 | class CommandManager { 17 | private redoStack = new ArrayStack() 18 | private undoStack = new ArrayStack() 19 | private commandClasses: { [key: string]: {new (editor: Editor, ...args: any[]): BaseCommand} } = {} 20 | private emitter: EventEmitter = new EventEmitter() 21 | 22 | constructor(private editor: Editor) { 23 | this.resigterCommandClass(AddRect, AddRect.cmdName()) 24 | this.resigterCommandClass(AddPath, AddPath.cmdName()) 25 | this.resigterCommandClass(AddPathSeg, AddPathSeg.cmdName()) 26 | this.resigterCommandClass(DMove, DMove.cmdName()) 27 | this.resigterCommandClass(SetAttr, SetAttr.cmdName()) 28 | this.resigterCommandClass(RemoveElements, RemoveElements.cmdName()) 29 | this.resigterCommandClass(ArrangingFront, ArrangingFront.cmdName()) 30 | this.resigterCommandClass(ArrangingBack, ArrangingBack.cmdName()) 31 | this.resigterCommandClass(ArrangingForward, ArrangingForward.cmdName()) 32 | this.resigterCommandClass(ArrangingBackward, ArrangingBackward.cmdName()) 33 | } 34 | execute(name: string, ...args: any[]) { 35 | const CommandClass = this.commandClasses[name] 36 | if (!CommandClass) throw new Error(`editor has not the ${name} command`) 37 | const command = new CommandClass(this.editor, ...args) // 创建 command 实例 38 | 39 | this.undoStack.push(command) 40 | this.redoStack.empty() 41 | 42 | this.emitEvent() 43 | this.editor.contextMenu.hide() 44 | } 45 | undo() { 46 | if (this.undoSize() === 0) { 47 | console.warn('Undo Stack is Empty') 48 | return 49 | } 50 | const command = this.undoStack.pop() 51 | this.redoStack.push(command) 52 | command.undo() 53 | command.afterUndo() 54 | 55 | this.emitEvent() 56 | this.editor.contextMenu.hide() 57 | } 58 | redo() { 59 | if (this.redoSize() === 0) { 60 | console.warn('Redo Stack is Empty!') 61 | return 62 | } 63 | const command = this.redoStack.pop() 64 | this.undoStack.push(command) 65 | command.redo() 66 | command.afterRedo() 67 | 68 | this.emitEvent() 69 | this.editor.contextMenu.hide() 70 | } 71 | go(pos: number) { 72 | if (pos === 0) { 73 | throw new Error('Param can not be zero!') 74 | } 75 | if (pos > 0) { 76 | for (let i = 0; i < pos; i++) { 77 | this.redo() 78 | } 79 | } else { 80 | pos = -pos 81 | for (let i = 0; i < pos; i++) { 82 | this.undo() 83 | } 84 | } 85 | } 86 | resigterCommandClass( 87 | commandClass: { new (editor: Editor, ...args: any[]): BaseCommand }, 88 | cmdName: string 89 | ) { 90 | this.commandClasses[cmdName] = commandClass 91 | } 92 | // Disable exec、redo、 undo temporarily 93 | lock() { /** TODO: */ } 94 | unlock() { /** TODO: */ } 95 | 96 | private emitEvent() { 97 | const undoNames = this.undoStack.getItems().map(item => item.cmdName()) 98 | const redoNames = this.redoStack.getItems().map(item => item.cmdName()).reverse() 99 | this.emitter.emit('change', undoNames, redoNames) 100 | } 101 | on(eventName: 'change', listener: (undos: string[], redos: string[]) => void) { 102 | this.emitter.on(eventName, listener) 103 | } 104 | off(eventName: 'change', listener: (n: number) => void) { 105 | this.emitter.off(eventName, listener) 106 | } 107 | redoSize(): number { 108 | return this.redoStack.size() 109 | } 110 | undoSize(): number { 111 | return this.undoStack.size() 112 | } 113 | } 114 | 115 | export default CommandManager 116 | -------------------------------------------------------------------------------- /src/command/commands.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 通用 command 3 | */ 4 | import Editor from '../Editor' 5 | import { FElement } from '../element/baseElement' 6 | import { FSVG, IFSVG } from '../element/index' 7 | import { ISegment } from '../interface' 8 | import { EditorSetting } from '../setting/editorSetting' 9 | 10 | function setDefaultAttrsBySetting(el: FElement, setting: EditorSetting) { 11 | const fill = setting.get('fill') 12 | const stroke = setting.get('stroke') 13 | const strokeWidth = setting.get('stroke-width') 14 | el.setAttr('fill', fill) 15 | el.setAttr('stroke', stroke) 16 | el.setAttr('stroke-width', strokeWidth) 17 | } 18 | 19 | abstract class BaseCommand { 20 | protected editor: Editor 21 | 22 | constructor(editor: Editor, ...args: any[]) { 23 | this.editor = editor 24 | } 25 | // TODO: abstract static method 26 | abstract cmdName(): string 27 | abstract undo(): void 28 | abstract redo(): void 29 | afterRedo() { /** */ } 30 | afterUndo() { /** */ } 31 | } 32 | 33 | /** 34 | * AddRect 35 | * 36 | * add rect svg element 37 | */ 38 | 39 | class AddRect extends BaseCommand { 40 | // private editor: Editor 41 | nextSibling: Element 42 | parent: Element 43 | rect: IFSVG['Rect'] 44 | 45 | constructor(editor: Editor, x: number, y: number, w: number, h: number) { 46 | super(editor) 47 | // this.editor = editor 48 | const rect = new FSVG.Rect(x, y, w, h) 49 | 50 | setDefaultAttrsBySetting(rect, editor.setting) 51 | editor.getCurrentLayer().addChild(rect) 52 | 53 | this.nextSibling = rect.el().nextElementSibling 54 | this.parent = rect.el().parentElement 55 | this.rect = rect 56 | 57 | this.editor.activedElsManager.setEls(this.rect) 58 | } 59 | static cmdName() { 60 | return 'addRect' 61 | } 62 | cmdName() { 63 | return 'addRect' 64 | } 65 | redo() { 66 | const el = this.rect.el() 67 | if (this.nextSibling) { 68 | this.parent.insertBefore(el, this.nextSibling) 69 | } else { 70 | this.parent.appendChild(el) 71 | } 72 | this.editor.activedElsManager.setEls(this.rect) 73 | } 74 | undo() { 75 | this.rect.el().remove() 76 | this.editor.activedElsManager.clear() 77 | } 78 | } 79 | 80 | /** 81 | * AddPath 82 | * 83 | * add path element 84 | */ 85 | class AddPath extends BaseCommand { 86 | // private editor: Editor 87 | nextSibling: Element 88 | parent: Element 89 | el: IFSVG['Path'] 90 | 91 | constructor( 92 | editor: Editor, 93 | params: { 94 | d: string, 95 | path?: IFSVG['Path'], 96 | seg: ISegment 97 | } 98 | ) { 99 | super(editor) 100 | const el = params.path || new FSVG.Path() 101 | 102 | setDefaultAttrsBySetting(el, editor.setting) 103 | el.setAttr('d', params.d) 104 | if (params.seg) { 105 | el.setMetaData('handleOut', params.seg.handleOut) 106 | } 107 | 108 | editor.getCurrentLayer().addChild(el) 109 | this.nextSibling = el.el().nextElementSibling 110 | this.parent = el.el().parentElement 111 | this.el = el 112 | 113 | this.editor.activedElsManager.setEls(this.el) 114 | } 115 | static cmdName() { 116 | return 'addPath' 117 | } 118 | cmdName() { 119 | return 'addPath' 120 | } 121 | redo() { 122 | const el = this.el.el() 123 | if (this.nextSibling) { 124 | this.parent.insertBefore(el, this.nextSibling) 125 | } else { 126 | this.parent.appendChild(el) 127 | } 128 | this.editor.activedElsManager.setEls(this.el) 129 | } 130 | undo() { 131 | this.el.el().remove() 132 | this.editor.activedElsManager.clear() 133 | } 134 | } 135 | /** 136 | * remove elements 137 | */ 138 | class RemoveElements extends BaseCommand { 139 | private els: Array 140 | private parents: Array 141 | private nextSiblings: Array 142 | 143 | constructor(editor: Editor) { 144 | super(editor) 145 | 146 | this.els = this.editor.activedElsManager.getEls() 147 | 148 | const size = this.els.length 149 | this.parents = new Array(size) 150 | this.nextSiblings = new Array(size) 151 | this.els.forEach((el, idx) => { 152 | this.nextSiblings[idx] = el.el().nextElementSibling 153 | this.parents[idx] = el.el().parentElement 154 | }) 155 | this.execute() 156 | } 157 | static cmdName() { 158 | return 'removeElements' 159 | } 160 | cmdName() { 161 | return 'removeElements' 162 | } 163 | private execute() { 164 | this.els.forEach(item => { 165 | item.remove() 166 | }) 167 | this.editor.activedElsManager.clear() 168 | } 169 | redo() { 170 | this.execute() 171 | } 172 | undo() { 173 | for (let idx = this.els.length - 1; idx >= 0; idx--) { 174 | const element = this.els[idx] 175 | const el = element.el() 176 | if (this.nextSiblings[idx]) { 177 | this.parents[idx].insertBefore(el, this.nextSiblings[idx]) 178 | } else { 179 | this.parents[idx].appendChild(el) 180 | } 181 | } 182 | 183 | this.editor.activedElsManager.setEls(this.els) 184 | } 185 | } 186 | 187 | /** 188 | * DMove 189 | * 190 | * dmove elements 191 | */ 192 | class DMove extends BaseCommand { 193 | private els: Array 194 | private dx: number 195 | private dy: number 196 | 197 | constructor(editor: Editor, els: Array, dx: number, dy: number) { 198 | super(editor) 199 | this.dx = dx 200 | this.dy = dy 201 | this.els = els 202 | 203 | this.els.forEach(el => { 204 | el.dmove(this.dx, this.dy) 205 | }) 206 | } 207 | static cmdName() { 208 | return 'dmove' 209 | } 210 | cmdName() { 211 | return 'dmove' 212 | } 213 | redo() { 214 | this.els.forEach(el => { 215 | el.dmove(this.dx, this.dy) 216 | }) 217 | } 218 | undo() { 219 | this.els.forEach(el => { 220 | el.dmove(-this.dx, -this.dy) 221 | }) 222 | } 223 | afterRedo() { 224 | this.editor.activedElsManager.setEls(this.els) 225 | } 226 | afterUndo() { 227 | this.editor.activedElsManager.setEls(this.els) 228 | } 229 | } 230 | 231 | /** 232 | * setAttr 233 | */ 234 | interface Attrs { [prop: string]: string } 235 | class SetAttr extends BaseCommand { 236 | private els: Array 237 | private attrs: Attrs 238 | private beforeAttrs: { [prop: string]: string[] } = {} 239 | 240 | constructor(editor: Editor, els: Array | FElement, attrs: Attrs) { 241 | super(editor) 242 | if (!Array.isArray(els)) els = [els] 243 | this.els = els 244 | 245 | // this.attrs = attrs 246 | for (const key in attrs) { 247 | this.beforeAttrs[key] = [] 248 | this.els.forEach(el => { 249 | const value = el.getAttr(key) 250 | this.beforeAttrs[key].push(value) 251 | 252 | el.setAttr(key, attrs[key]) 253 | }) 254 | } 255 | this.attrs = attrs 256 | } 257 | static cmdName() { 258 | return 'setAttr' 259 | } 260 | cmdName() { 261 | return 'setAttr' 262 | } 263 | redo() { 264 | const attrs = this.attrs 265 | for (const key in attrs) { 266 | this.els.forEach(el => { 267 | el.setAttr(key, attrs[key]) 268 | }) 269 | } 270 | 271 | this.editor.activedElsManager.heighligthEls() 272 | } 273 | undo() { 274 | const attrs = this.attrs 275 | for (const key in attrs) { 276 | this.els.forEach((el, index) => { 277 | el.setAttr(key, this.beforeAttrs[key][index]) 278 | }) 279 | } 280 | 281 | this.editor.activedElsManager.heighligthEls() 282 | } 283 | } 284 | 285 | /** 286 | * transform: scale 287 | */ 288 | class Scale extends BaseCommand { 289 | constructor( 290 | editor: Editor, private el: FElement, 291 | private scaleX: number, private scaleY: number, 292 | private cx?: number, private cy?: number 293 | ) { 294 | super(editor) 295 | // 296 | this.el.scale(scaleX, scaleY) 297 | } 298 | static cmdName() { 299 | return 'scale' 300 | } 301 | cmdName() { 302 | return 'scale' 303 | } 304 | redo() { 305 | // 306 | } 307 | undo() { 308 | // 309 | } 310 | } 311 | 312 | export { 313 | BaseCommand, 314 | AddRect, 315 | AddPath, 316 | RemoveElements, 317 | DMove, 318 | SetAttr, 319 | Scale, 320 | } 321 | -------------------------------------------------------------------------------- /src/command/path.ts: -------------------------------------------------------------------------------- 1 | import Editor from '../Editor' 2 | import { IFSVG } from '../element/index' 3 | import { BaseCommand } from './commands' 4 | import { ISegment, IPoint } from '../interface' 5 | 6 | class AddPathSeg extends BaseCommand { 7 | private path: IFSVG['Path'] 8 | private seg: ISegment 9 | 10 | constructor(editor: Editor, path: IFSVG['Path'], seg: ISegment) { 11 | super(editor) 12 | this.path = path 13 | this.seg = seg 14 | 15 | this.doit() 16 | } 17 | static cmdName() { 18 | return 'addPathSeg' 19 | } 20 | cmdName() { 21 | return 'addPathSeg' 22 | } 23 | private doit() { 24 | /** 25 | * 根据传入的 path 的 d 和 seg, 26 | * 更新 metaData.handOut,并追加 d 片段 27 | */ 28 | const path = this.path 29 | const seg = this.seg 30 | // const handleInOfPath = path.getMetaData('handleIn') as IPoint 31 | // if (!handleInOfPath) { 32 | // // TODO: 如果不存在,就取对称点 33 | // } 34 | const handleOutOfPath = path.getMetaData('handleOut') as IPoint 35 | if (!handleOutOfPath) { 36 | // TODO: 如果不存在,就取对称点 37 | } 38 | 39 | path.setAttr('d', path.getAttr('d') + ` C ${handleOutOfPath.x} ${handleOutOfPath.y} ${seg.handleIn.x} ${seg.handleIn.y} ${seg.x} ${seg.y}`) 40 | path.setMetaData('handleOut', seg.handleOut) 41 | } 42 | redo() { 43 | return this.doit() 44 | } 45 | undo() { 46 | let d = this.path.getAttr('d') 47 | const endIndex = d.lastIndexOf('C') 48 | const lastCurve = d.slice(endIndex) 49 | d = d.slice(0, endIndex).trim() 50 | this.path.setAttr('d', d) 51 | // 取最后一个 curve 的 cp1 作为 path 的 meta.handleOut 52 | const cp1 = lastCurve.split(/\s+/).slice(1, 3).map(v => parseFloat(v)) 53 | this.path.setMetaData('handleOut', { x: cp1[0], y: cp1[1] }) 54 | // 辅助线的修正 55 | this.editor.huds.predictedCurve.clear() 56 | this.editor.huds.pathDraw.segDraw.clear() 57 | this.editor.huds.pathDraw.setD(d) 58 | } 59 | } 60 | 61 | export { 62 | AddPathSeg, 63 | } 64 | -------------------------------------------------------------------------------- /src/config/editorDefaultConfig.ts: -------------------------------------------------------------------------------- 1 | const editorDefaultConfig = { 2 | svgRootW: 3000, 3 | svgRootH: 1500, 4 | svgStageW: 800, 5 | svgStageH: 520, 6 | 7 | tool: 'select', 8 | 9 | fill: '#fff', 10 | stroke: '#000', 11 | strokeWidth: '1px', 12 | 13 | selectAreaFill: 'rgba(200, 200, 200, .2)', 14 | selectAreaStroke: '#888', 15 | 16 | outlineColor: '#5183fb', 17 | scaleGridSize: 7, 18 | } as const 19 | 20 | export default editorDefaultConfig 21 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // 常量 2 | 3 | const NS = { 4 | HTML: 'http://www.w3.org/1999/xhtml', 5 | MATH: 'http://www.w3.org/1998/Math/MathML', 6 | SE: 'http://svg-edit.googlecode.com', 7 | SVG: 'http://www.w3.org/2000/svg', 8 | XLINK: 'http://www.w3.org/1999/xlink', 9 | XML: 'http://www.w3.org/XML/1998/namespace', 10 | XMLNS: 'http://www.w3.org/2000/xmlns/' // see http://www.w3.org/TR/REC-xml-names/#xmlReserved 11 | } 12 | 13 | export { 14 | NS, 15 | } 16 | -------------------------------------------------------------------------------- /src/editorEventContext.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * context class 4 | * 5 | * used for tool event 6 | */ 7 | 8 | import Editor from './Editor' 9 | 10 | export class EditorEventContext { 11 | nativeEvent: MouseEvent 12 | isEndInside: boolean 13 | private editor: Editor 14 | 15 | private startX: number // 最近一次的鼠标按下的坐标 16 | private startY: number 17 | private startClientX: number 18 | private startClientY: number 19 | 20 | constructor(editor: Editor, e: MouseEvent) { 21 | this.editor = editor 22 | this.nativeEvent = e 23 | this.isEndInside = false 24 | this.startX = 0 25 | this.startY = 0 26 | this.startClientX = 0 // 用于计算相对位移(dx 和 dy) 27 | this.startClientY = 0 28 | 29 | this.setStartPos() // 记录起始位置 30 | } 31 | setOriginEvent(e: MouseEvent) { 32 | this.nativeEvent = e 33 | } 34 | setStartPos() { 35 | const { x, y } = this.getPos() 36 | 37 | this.startX = x 38 | this.startY = y 39 | 40 | this.startClientX = this.nativeEvent.clientX 41 | this.startClientY = this.nativeEvent.clientY 42 | } 43 | getPos() { 44 | const zoom = this.editor.viewport.getZoom() 45 | const { x, y } = this.editor.viewport.getContentOffset() 46 | return { 47 | x: this.nativeEvent.offsetX / zoom - x, 48 | y: this.nativeEvent.offsetY / zoom - y, 49 | } 50 | } 51 | getStartPos() { 52 | return { 53 | x: this.startX, 54 | y: this.startY, 55 | } 56 | } 57 | // without calc with zoom value 58 | getDiffPos() { 59 | const x = this.nativeEvent.clientX - this.startClientX 60 | const y = this.nativeEvent.clientY - this.startClientY 61 | return { x, y } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/element/README.md: -------------------------------------------------------------------------------- 1 | 2 | FSVG 3 | 4 | --- 5 | 6 | a simple SVG library used for this editor. -------------------------------------------------------------------------------- /src/element/__test__/index.spec.废弃.ts: -------------------------------------------------------------------------------- 1 | import { Path } from '../path' 2 | 3 | /* 貌似是因为 jest 配置问题,进行单元测试时,会出现类型错误 */ 4 | describe('Element', () => { 5 | const path = new Path() 6 | path.setMetaData('meta-a', 'meta-a-value') 7 | it('get metaData', () => { 8 | expect(path.getMetaData('meta-a')).toBe('meta-a-value') 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/element/baseElement.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 对 SVG 元素的简单封装 4 | */ 5 | 6 | import { FSVG } from './index' 7 | 8 | interface hashMap { [key: string]: any } 9 | 10 | export class FElement { 11 | protected el_: SVGElement 12 | 13 | constructor() { 14 | this.el_ = null 15 | } 16 | el() { 17 | return this.el_ 18 | } 19 | tagName() { 20 | if (!this.el_) return '' 21 | return this.el_.tagName 22 | } 23 | setID(id: string) { 24 | this.el_.id = id 25 | } 26 | setAttr(prop: string, val: string) { 27 | return this.el_.setAttribute(prop, val) 28 | } 29 | getAttr(prop: string) { 30 | return this.el_.getAttribute(prop) 31 | } 32 | removeAttr(prop: string) { 33 | this.el_.removeAttribute(prop) 34 | } 35 | setNonScalingStroke() { 36 | this.setAttr('vector-effect', 'non-scaling-stroke') 37 | } 38 | getBBox() { 39 | return (this.el_ as SVGGraphicsElement).getBBox() 40 | } 41 | remove() { 42 | return this.el_.remove() 43 | } 44 | equal(el: FElement | SVGElement) { 45 | if ((el as FElement).el) { 46 | el = (el as FElement).el() 47 | } 48 | return this.el_ === el 49 | } 50 | 51 | /** DOM methods */ 52 | parent() { 53 | const p = this.el_.parentElement 54 | if (p instanceof SVGElement) { 55 | return FSVG.create(p) 56 | } 57 | throw new Error('parent is not SVGElement') 58 | } 59 | nextSibling() { 60 | const nextOne = this.el_.nextElementSibling 61 | if (nextOne === null) return null 62 | return FSVG.create(nextOne as SVGElement) 63 | } 64 | previousSibling() { 65 | const n = this.el_.previousElementSibling 66 | if (n === null) return null 67 | return FSVG.create(n as SVGElement) 68 | } 69 | append(el: FElement) { 70 | this.el_.appendChild(el.el()) 71 | } 72 | front() { 73 | const parent = this.el_.parentElement 74 | parent.appendChild(this.el_) 75 | } 76 | back() { 77 | const parent = this.el_.parentElement 78 | const firstChild = parent.firstElementChild 79 | if (firstChild) { 80 | parent.insertBefore(this.el_, firstChild) 81 | } 82 | } 83 | forward() { 84 | const parent = this.el_.parentElement 85 | const nextSibling = this.el_.nextSibling 86 | if (nextSibling) { 87 | parent.insertBefore(nextSibling, this.el_) 88 | } 89 | } 90 | backward() { 91 | const previousSibling = this.el_.previousElementSibling 92 | if (previousSibling) { 93 | this.before(previousSibling as SVGElement) 94 | } 95 | } 96 | before(referElement: FElement | SVGElement) { 97 | if ((referElement as FElement).el) { 98 | referElement = (referElement as FElement).el() 99 | } 100 | const parent = (referElement as SVGElement).parentElement 101 | parent.insertBefore(this.el_, referElement as SVGElement) 102 | } 103 | after(referElement: FElement | SVGElement) { 104 | if ((referElement as FElement).el) { 105 | referElement = (referElement as FElement).el() 106 | } 107 | const parent = (referElement as SVGElement).parentElement 108 | const nextSibling = referElement.nextSibling 109 | if (nextSibling) { 110 | parent.insertBefore(this.el_, nextSibling as SVGElement) 111 | } else { 112 | parent.appendChild(this.el_) 113 | } 114 | } 115 | 116 | getPos() { 117 | const x = parseFloat(this.getAttr('x')) 118 | const y = parseFloat(this.getAttr('y')) 119 | return { x, y } 120 | } 121 | dmove(dx: number, dy: number) { 122 | const pos = this.getPos() 123 | this.setAttr('x', pos.x + dx + '') 124 | this.setAttr('y', pos.y + dy + '') 125 | } 126 | 127 | hide() { 128 | this.el_.style.display = 'none' 129 | } 130 | visible() { 131 | this.el_.removeAttribute('style') 132 | } 133 | 134 | /** transform */ 135 | scale(scaleX: number, scaleY: number, cx?: number, cy?: number) { 136 | if (cx === undefined || cy === undefined) { 137 | // TODO: get center pos 138 | } 139 | const scale = [ 140 | `scale(${scaleX} ${scaleY})` 141 | ] 142 | const transfrom = this.getAttr('transform') || '' 143 | this.setAttr('tranform', transfrom + ' ' + scale) 144 | } 145 | translate(x: number, y: number) { 146 | this.setAttr('transform', `translate(${x} ${y})`) 147 | } 148 | 149 | /** 150 | * meta data store 151 | * 元数据保存 152 | */ 153 | setMetaData(key: string, value: any) { 154 | const el = this.el_ as hashMap 155 | if (!el.metaData) el.metaData = {} as hashMap 156 | el.metaData[key] = value 157 | } 158 | getMetaData(key: string): any { 159 | const el = this.el_ as hashMap 160 | if (!el.metaData) el.metaData = {} as hashMap 161 | return (el.metaData as hashMap)[key] 162 | } 163 | } 164 | 165 | -------------------------------------------------------------------------------- /src/element/box.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface IBox { 3 | x: number, 4 | y: number, 5 | width: number, 6 | height: number 7 | } 8 | 9 | export class Box { 10 | x: number 11 | y: number 12 | w: number 13 | h: number 14 | width: number // alias of w 15 | height: number // alias of h 16 | x2: number 17 | y2: number 18 | 19 | constructor(x: number, y: number, w: number, h: number) 20 | constructor(box: IBox) 21 | constructor(x: number | IBox, y?: number, w?: number, h?: number) { 22 | if (typeof x === 'object') { 23 | this.x = x.x 24 | this.y = x.y 25 | this.w = x.width 26 | this.h = x.height 27 | } else { 28 | this.x = x 29 | this.y = y 30 | this.w = w 31 | this.h = h 32 | } 33 | 34 | this.width = this.w 35 | this.height = this.h 36 | this.x2 = this.x + this.w 37 | this.y2 = this.y + this.h 38 | } 39 | 40 | contains(otherBox: IBox) { 41 | return this.x <= otherBox.x && this.y <= otherBox.y && 42 | this.x2 >= otherBox.x + otherBox.width && this.y2 >= otherBox.y + otherBox.height 43 | } 44 | 45 | merge(otherBox: Box) { 46 | const x = Math.min(this.x, otherBox.x) 47 | const y = Math.min(this.y, otherBox.y) 48 | const x2 = Math.max(this.x2, otherBox.x2) 49 | const y2 = Math.max(this.y2, otherBox.y2) 50 | const w = x2 - x 51 | const h = y2 - y 52 | return new Box(x, y, w, h) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/element/div.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * div 4 | */ 5 | 6 | // import { FElement } from './baseElement' 7 | 8 | export class Div { 9 | private el_: HTMLDivElement 10 | 11 | constructor(el?: HTMLDivElement) { 12 | if (el) { 13 | this.el_ = el 14 | } else { 15 | this.el_ = document.createElement('div') 16 | } 17 | } 18 | el() { 19 | return this.el_ 20 | } 21 | setID(id: string) { 22 | this.el_.id = id 23 | } 24 | setStyleProp(name: any, val: string) { 25 | this.el_.style[name] = val 26 | } 27 | getWidth(): number { 28 | const val = this.el_.getAttribute('width') 29 | if (val) { 30 | return parseFloat(val) 31 | } 32 | return this.el_.offsetWidth 33 | } 34 | setWidth(val: number) { 35 | this.el_.style.width = val + 'px' 36 | } 37 | getHeight(): number { 38 | const val = this.el_.getAttribute('height') 39 | if (val) { 40 | return parseFloat(val) 41 | } 42 | return this.el_.offsetHeight 43 | } 44 | setHeight(val: number) { 45 | this.el_.style.height = val + 'px' 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/element/group.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * group 4 | * 5 | * Encapsulation of 6 | */ 7 | 8 | import { NS } from '../constants' 9 | import { FElement } from './baseElement' 10 | 11 | export class Group extends FElement { 12 | el_: SVGElement 13 | 14 | constructor(el?: SVGElement) { 15 | super() 16 | if (el) { 17 | this.el_ = el 18 | } else { 19 | this.el_ = document.createElementNS(NS.SVG, 'g') as SVGElement 20 | } 21 | } 22 | clear() { 23 | this.el_.innerHTML = '' 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/element/index.ts: -------------------------------------------------------------------------------- 1 | import { Box } from './box' 2 | import { Rect } from './rect' 3 | import { Group } from './group' 4 | import { Path } from './path' 5 | import { Line } from './line' 6 | import { Div } from './div' 7 | import { FElement } from './baseElement' 8 | 9 | /** 10 | * FSVG 11 | * 12 | * simple SVGElement encapsulation 13 | */ 14 | function create(el: SVGElement): FElement { 15 | const tagName = el.tagName 16 | if (tagName === 'rect') { 17 | return new FSVG.Rect(el as SVGRectElement) 18 | } else if (tagName === 'g') { 19 | return new FSVG.Group(el) 20 | } else if (tagName === 'path') { 21 | return new FSVG.Path(el) 22 | } else { 23 | throw new Error(`Can not creat ${tagName} instance, no match class.`) 24 | } 25 | } 26 | 27 | export const FSVG = { 28 | create, 29 | Rect, 30 | Box, 31 | Group, 32 | Path, 33 | Line, 34 | Div, 35 | } 36 | 37 | interface IFSVG { 38 | Rect: Rect 39 | Box: Box 40 | Group: Group 41 | Path: Path 42 | Line: Line 43 | } 44 | 45 | export { IFSVG } 46 | -------------------------------------------------------------------------------- /src/element/line.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 对 rect 元素的简单封装 4 | */ 5 | 6 | import { NS } from '../constants' 7 | import { FElement } from './baseElement' 8 | 9 | export class Line extends FElement { 10 | el_: SVGElement 11 | 12 | constructor(x1: number, y1: number, x2: number, y2: number) 13 | constructor(el: SVGElement) 14 | constructor(x1: number | SVGElement, y1?: number, x2?: number, y2?: number) { 15 | super() 16 | if (typeof x1 === 'object') { 17 | this.el_ = x1 18 | } else { 19 | this.el_ = document.createElementNS(NS.SVG, 'line') as SVGElement 20 | this.setAttr('x1', String(x1)) 21 | this.setAttr('y1', String(y1)) 22 | this.setAttr('x2', String(x2)) 23 | this.setAttr('y2', String(y2)) 24 | } 25 | } 26 | setPos(x1: number, y1: number, x2: number, y2: number) { 27 | this.setAttr('x1', String(x1)) 28 | this.setAttr('y1', String(y1)) 29 | this.setAttr('x2', String(x2)) 30 | this.setAttr('y2', String(y2)) 31 | } 32 | // getPos() { 33 | // const x = parseFloat(this.getAttr('x')) 34 | // const y = parseFloat(this.getAttr('y')) 35 | // return { x, y } 36 | // } 37 | // dmove(dx: number, dy: number) { 38 | // const pos = this.getPos() 39 | // this.setAttr('x', pos.x + dx + '') 40 | // this.setAttr('y', pos.y + dy + '') 41 | // } 42 | } 43 | -------------------------------------------------------------------------------- /src/element/path.ts: -------------------------------------------------------------------------------- 1 | 2 | import { NS } from '../constants' 3 | import { IPoint } from '../interface' 4 | import { FElement } from './baseElement' 5 | 6 | export class Path extends FElement { 7 | el_: SVGElement 8 | cacheTail: IPoint 9 | cacheD: string 10 | 11 | constructor(el?: SVGElement) { 12 | super() 13 | if (el) { 14 | this.el_ = el 15 | } else { 16 | this.el_ = document.createElementNS(NS.SVG, 'path') as SVGPathElement 17 | } 18 | } 19 | dmove(dx: number, dy: number) { 20 | const d = this.getAttr('d') 21 | let offset = dx 22 | let s 23 | 24 | // TODO: to optimize the algorithm 25 | this.setAttr('d', d.replace(/\s+(-?[\d.]+)/g, (match, p1) => { 26 | s = ' ' + (parseFloat(p1) + offset) 27 | offset = offset === dx ? dy : dx 28 | return s 29 | })) 30 | } 31 | /** 32 | * 获取 path 的最后一个点坐标 33 | */ 34 | tail(): IPoint { 35 | let d = this.getAttr('d').trim() 36 | if (!d) return null 37 | if (d === this.cacheD) { 38 | return this.cacheTail 39 | } 40 | this.cacheD = d 41 | 42 | let pos = d.lastIndexOf(' ') // TODO: 优化算法 43 | const y = parseFloat(d.slice(pos + 1)) 44 | 45 | d = d.slice(0, pos).trim() 46 | pos = d.lastIndexOf(' ') 47 | const x = parseFloat(d.slice(pos + 1)) 48 | 49 | this.cacheTail = { x, y } 50 | return this.cacheTail 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/element/rect.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * 对 rect 元素的简单封装 4 | */ 5 | 6 | import { NS } from '../constants' 7 | import { IPoint } from '../interface' 8 | import { FElement } from './baseElement' 9 | 10 | export class Rect extends FElement { 11 | el_: SVGElement 12 | 13 | constructor(x: number, y: number, w: number, h: number) 14 | constructor(el: SVGElement) 15 | constructor(x: number | SVGElement, y?: number, w?: number, h?: number) { 16 | super() 17 | if (typeof x === 'object') { 18 | this.el_ = x 19 | } else { 20 | this.el_ = document.createElementNS(NS.SVG, 'rect') as SVGElement 21 | this.setAttr('x', x + '') 22 | this.setAttr('y', y + '') 23 | this.setAttr('width', w + '') 24 | this.setAttr('height', h + '') 25 | } 26 | } 27 | setPos(x: number, y: number) { 28 | this.setAttr('x', String(x)) 29 | this.setAttr('y', String(y)) 30 | } 31 | setCenterPos(cx: number, cy: number) { 32 | const w = parseFloat(this.getAttr('width')) 33 | const h = parseFloat(this.getAttr('height')) 34 | this.setPos(cx - w / 2, cy - h / 2) 35 | } 36 | getCenterPos(): IPoint { 37 | const { x, y } = this.getPos() 38 | const w = parseFloat(this.getAttr('width')) 39 | const h = parseFloat(this.getAttr('height')) 40 | return { 41 | x: x + w / 2, 42 | y: y + h / 2, 43 | } 44 | } 45 | getPos(): IPoint { 46 | const x = parseFloat(this.getAttr('x')) 47 | const y = parseFloat(this.getAttr('y')) 48 | return { x, y } 49 | } 50 | getWidth(): number { 51 | return parseFloat(this.getAttr('width')) 52 | } 53 | getHeight(): number { 54 | return parseFloat(this.getAttr('height')) 55 | } 56 | // dmove(dx: number, dy: number) { 57 | // const pos = this.getPos() 58 | // this.setAttr('x', pos.x + dx + '') 59 | // this.setAttr('y', pos.y + dy + '') 60 | // } 61 | } 62 | -------------------------------------------------------------------------------- /src/huds/PredictedCurve.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 预测曲线(钢笔工具用) 3 | */ 4 | // import { ISegment } from '../interface' 5 | import { FSVG, IFSVG } from '../element/index' 6 | import { IPoint } from '../interface' 7 | import editorDefaultConfig from '../config/editorDefaultConfig' 8 | 9 | class PredictedCurve { 10 | curve: IFSVG['Path'] 11 | constructor() { 12 | const curve = this.curve = new FSVG.Path() 13 | curve.setID('predicted-curve') 14 | curve.setAttr('fill', 'none') 15 | curve.setAttr('stroke', editorDefaultConfig.outlineColor) 16 | curve.setAttr('stroke-width', '1px') 17 | curve.setNonScalingStroke() 18 | this.curve.hide() 19 | } 20 | mount(parent: SVGGElement) { 21 | parent.appendChild(this.curve.el()) 22 | } 23 | draw(p1: IPoint, p2: IPoint, cp1: IPoint, cp2: IPoint) { 24 | this.curve.setAttr('d', `M ${p1.x} ${p1.y} C ${cp1.x} ${cp1.y} ${cp2.x} ${cp2.y} ${p2.x} ${p2.y}`) 25 | this.curve.visible() 26 | } 27 | clear() { 28 | this.curve.hide() 29 | } 30 | } 31 | 32 | export default PredictedCurve 33 | -------------------------------------------------------------------------------- /src/huds/elementOutlinesHud.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * elements outlines 3 | * 高亮指定元素的轮廓线 4 | */ 5 | 6 | import { FSVG, IFSVG } from '../element/index' 7 | import { FElement } from '../element/baseElement' 8 | import editorDefaultConfig from '../config/editorDefaultConfig' 9 | 10 | class ElementsOutlinesHub { 11 | container: IFSVG['Group'] 12 | 13 | constructor(parent: SVGGElement) { 14 | this.container = new FSVG.Group() // document.createElementNS(NS.SVG, 'g') as SVGGElement 15 | this.container.setID('element-outlines-hud') 16 | parent.appendChild(this.container.el()) 17 | // this.container.appendChild(this.outline) 18 | } 19 | draw(els: Array) { 20 | this.clear() 21 | 22 | let baseOutline: FElement 23 | for (const el of els) { 24 | if (el.tagName() === 'rect') { 25 | const rect = el as IFSVG['Rect'] 26 | const pos = rect.getPos() 27 | const outline = new FSVG.Rect(pos.x, pos.y, rect.getWidth(), rect.getHeight()) 28 | baseOutline = outline 29 | } if (el.tagName() === 'path') { 30 | const path = el as IFSVG['Path'] 31 | const outline = new FSVG.Path() 32 | outline.setAttr('d', path.getAttr('d')) 33 | baseOutline = outline 34 | } 35 | baseOutline.setAttr('fill', 'none') 36 | baseOutline.setAttr('stroke', editorDefaultConfig.outlineColor) 37 | baseOutline.setAttr('stroke-width', '1px') 38 | baseOutline.setNonScalingStroke() 39 | this.container.append(baseOutline) 40 | } 41 | } 42 | translate(x: number, y: number) { 43 | this.container.translate(x, y) 44 | } 45 | clear() { 46 | this.container.clear() 47 | this.container.removeAttr('transform') 48 | } 49 | } 50 | 51 | export default ElementsOutlinesHub 52 | -------------------------------------------------------------------------------- /src/huds/hudAbstract.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * elements outline 3 | * 对多个元素进行轮廓线高亮 4 | */ 5 | 6 | export abstract class Hud { 7 | constructor(el: Node) { 8 | // this.container = document.createElementNS(NS.SVG, 'g') as SVGGElement 9 | } 10 | draw() { 11 | // 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/huds/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * guide line layer 3 | */ 4 | 5 | import { OutlineBoxHud } from './outlineBoxHud' 6 | import { SelectArea } from './selectArea' 7 | import { PencilDraw } from './pencilDraw' 8 | import { PathDraw } from './pathDraw' 9 | import { NS } from '../constants' 10 | import Editor from '../Editor' 11 | import ElementsOutlinesHub from './elementOutlinesHud' 12 | import PredictedCurve from './PredictedCurve' 13 | 14 | export class Huds { 15 | container: SVGGElement 16 | 17 | selectArea: SelectArea 18 | outlineBoxHud: OutlineBoxHud 19 | pencilDraw: PencilDraw 20 | pathDraw: PathDraw 21 | elsOutlinesHub: ElementsOutlinesHub 22 | predictedCurve: PredictedCurve 23 | 24 | constructor(private editor: Editor) { 25 | this.container = document.createElementNS(NS.SVG, 'g') as SVGGElement 26 | this.container.id = 'huds' 27 | // 这里的顺序是由讲究的 28 | this.predictedCurve = new PredictedCurve() 29 | this.predictedCurve.mount(this.container) 30 | 31 | this.elsOutlinesHub = new ElementsOutlinesHub(this.container) 32 | this.outlineBoxHud = new OutlineBoxHud(this.container, editor) 33 | this.selectArea = new SelectArea(this.container) 34 | 35 | this.pencilDraw = new PencilDraw(this.container) 36 | this.pathDraw = new PathDraw(this.container, editor) 37 | } 38 | mount() { 39 | this.editor.svgStage.appendChild(this.container) 40 | } 41 | } 42 | 43 | -------------------------------------------------------------------------------- /src/huds/outlineBoxHud.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * elements outline box 3 | * 4 | */ 5 | 6 | import editorDefaultConfig from '../config/editorDefaultConfig' 7 | import Editor from '../Editor' 8 | import { IBox } from '../element/box' 9 | import { FSVG, IFSVG } from '../element/index' 10 | 11 | class ScaleGrids { 12 | private container: IFSVG['Group'] 13 | 14 | private topLeft: IFSVG['Rect'] 15 | private topRight: IFSVG['Rect'] 16 | private bottomLeft: IFSVG['Rect'] 17 | private bottomRight: IFSVG['Rect'] 18 | 19 | private top: IFSVG['Rect'] 20 | private right: IFSVG['Rect'] 21 | private bottom: IFSVG['Rect'] 22 | private left: IFSVG['Rect'] 23 | 24 | private size = editorDefaultConfig.scaleGridSize 25 | 26 | constructor(parent: SVGGElement, private editor: Editor) { 27 | this.container = new FSVG.Group() 28 | this.container.setID('segment-draw') 29 | 30 | this.topLeft = this.createGrip('topLeft') 31 | this.topRight = this.createGrip('topRight') 32 | this.bottomRight = this.createGrip('bottomRight') 33 | this.bottomLeft = this.createGrip('bottomLeft') 34 | 35 | this.top = this.createGrip('top') 36 | this.right = this.createGrip('right') 37 | this.bottom = this.createGrip('bottom') 38 | this.left = this.createGrip('left') 39 | 40 | this.container.append(this.top) 41 | this.container.append(this.right) 42 | this.container.append(this.bottom) 43 | this.container.append(this.left) 44 | this.container.append(this.topLeft) 45 | this.container.append(this.topRight) 46 | this.container.append(this.bottomRight) 47 | this.container.append(this.bottomLeft) 48 | 49 | parent.appendChild(this.container.el()) 50 | this.changeSizeWhenZoom() 51 | } 52 | private createGrip(id: string): IFSVG['Rect'] { 53 | const grid = new FSVG.Rect(0, 0, this.size, this.size) 54 | const prefix = 'scale-grid' 55 | grid.setID(prefix + id) 56 | grid.setAttr('stroke', editorDefaultConfig.outlineColor) 57 | grid.setAttr('fill', '#fff') 58 | grid.setNonScalingStroke() 59 | grid.hide() 60 | return grid 61 | } 62 | getOppositeGrip(grip: IFSVG['Rect']): IFSVG['Rect'] { 63 | let targetGrip = null 64 | if (grip === this.topLeft) targetGrip = this.bottomRight 65 | else if (grip === this.topRight) targetGrip = this.bottomLeft 66 | else if (grip === this.bottomRight) targetGrip = this.topLeft 67 | else if (grip === this.bottomLeft) targetGrip = this.topRight 68 | return targetGrip 69 | } 70 | getGripIfMatch(el: SVGElement): IFSVG['Rect'] { 71 | const grids = [ 72 | this.topLeft, this.topRight, this.bottomRight, this.bottomLeft, 73 | // this.top, this.right, this.bottom, this.left, 74 | ] 75 | const matchedGrid = grids.find(grid => grid.el() === el) 76 | return matchedGrid || null 77 | } 78 | private changeSizeWhenZoom() { 79 | this.editor.viewport.onZoomChange(zoom => { 80 | const size = this.size / zoom 81 | const grids = [ 82 | this.topLeft, this.topRight, this.bottomRight, this.bottomLeft, 83 | this.top, this.right, this.bottom, this.left, 84 | ] 85 | grids.forEach(grid => { 86 | const { x, y } = grid.getCenterPos() 87 | grid.setAttr('width', String(size)) 88 | grid.setAttr('height', String(size)) 89 | grid.setCenterPos(x, y) 90 | }) 91 | }) 92 | } 93 | drawPoints(box: IBox) { 94 | const { x, y, width, height } = box 95 | 96 | this.topLeft.setCenterPos(x, y) 97 | this.topRight.setCenterPos(x + width, y) 98 | this.bottomRight.setCenterPos(x + width, y + height) 99 | this.bottomLeft.setCenterPos(x, y + height) 100 | 101 | this.top.setCenterPos(x + width / 2, y) 102 | this.right.setCenterPos(x + width, y + height / 2) 103 | this.bottom.setCenterPos(x + width / 2, y + height) 104 | this.left.setCenterPos(x, y + height / 2) 105 | 106 | this.topLeft.visible() 107 | this.topRight.visible() 108 | this.bottomRight.visible() 109 | this.bottomLeft.visible() 110 | 111 | this.top.visible() 112 | this.right.visible() 113 | this.bottom.visible() 114 | this.left.visible() 115 | } 116 | clear() { 117 | this.topLeft.hide() 118 | this.topRight.hide() 119 | this.bottomRight.hide() 120 | this.bottomLeft.hide() 121 | 122 | this.top.hide() 123 | this.right.hide() 124 | this.bottom.hide() 125 | this.left.hide() 126 | } 127 | } 128 | 129 | export class OutlineBoxHud { 130 | private x = 0 131 | private y = 0 132 | private w = 0 133 | private h = 0 134 | private container: IFSVG['Group'] 135 | private outline: IFSVG['Path'] 136 | scaleGrids: ScaleGrids 137 | private scaleGripVisible_ = false 138 | 139 | constructor(parent: SVGGElement, editor: Editor) { 140 | this.container = new FSVG.Group() // document.createElementNS(NS.SVG, 'g') as SVGGElement 141 | this.container.setID('outline-box-hud') 142 | 143 | this.outline = new FSVG.Path() // document.createElementNS(NS.SVG, 'path') as SVGPathElement 144 | this.outline.setAttr('fill', 'none') 145 | this.outline.setAttr('stroke', editorDefaultConfig.outlineColor) 146 | this.outline.setAttr('stroke-width', '1px') 147 | this.outline.setNonScalingStroke() 148 | 149 | this.container.append(this.outline) 150 | parent.appendChild(this.container.el()) 151 | 152 | this.scaleGrids = new ScaleGrids(parent, editor) 153 | } 154 | enableScaleGrip(immediate = false) { 155 | this.scaleGripVisible_ = true 156 | immediate && this.scaleGrids.drawPoints(this.getBox()) 157 | } 158 | disableScaleGrip(immediate = false) { 159 | this.scaleGripVisible_ = false 160 | immediate && this.scaleGrids.clear() 161 | } 162 | drawRect(x: number, y: number, w: number, h: number) { 163 | this.x = x 164 | this.y = y 165 | this.w = w 166 | this.h = h 167 | 168 | this.drawRectWithPoints(x, y, x + w, y, x + w, y + h, x, y + h) 169 | } 170 | private drawRectWithPoints( 171 | x1: number, y1: number, x2: number, y2: number, 172 | x3: number, y3: number, x4: number, y4: number 173 | ) { 174 | const d = `M ${x1} ${y1} L ${x2} ${y2} L ${x3} ${y3} L ${x4} ${y4} Z` 175 | this.outline.setAttr('d', d) 176 | this.outline.visible() 177 | 178 | if (this.scaleGripVisible_) { 179 | this.scaleGrids.drawPoints(this.getBox()) 180 | } 181 | } 182 | clear() { 183 | this.outline.hide() 184 | this.scaleGrids.clear() 185 | } 186 | getGripIfMatch(el: SVGElement): IFSVG['Rect'] { 187 | return this.scaleGrids.getGripIfMatch(el) 188 | } 189 | getWidth() { return this.w } 190 | getHeight() { return this.h } 191 | getX() { return this.x } 192 | getY() { return this.y } 193 | getBox(): IBox { 194 | return { 195 | x: this.getX(), 196 | y: this.getY(), 197 | width: this.getWidth(), 198 | height: this.getHeight(), 199 | } 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/huds/pathDraw.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 贝塞尔曲线绘制相关辅助线 3 | */ 4 | import { NS } from '../constants' 5 | import Editor from '../Editor' 6 | import { FSVG, IFSVG } from '../element/index' 7 | import { ISegment } from '../interface' 8 | import editorDefaultConfig from '../config/editorDefaultConfig' 9 | 10 | /** 11 | * predict segment 12 | * 绘制中的 seg 13 | */ 14 | class SegmentDraw { 15 | private container: IFSVG['Group'] 16 | private size = 6 17 | private seg: ISegment = null 18 | 19 | // 锚点 x 1,控制点 x 2,锚点和控制点连线 x 2 20 | private anchorNode: IFSVG['Rect'] 21 | private handleInNode: IFSVG['Rect'] 22 | private handleOutNode: IFSVG['Rect'] 23 | private handleInLine: IFSVG['Line'] 24 | private handleOutLine: IFSVG['Line'] 25 | 26 | constructor(parent: SVGGElement, private editor: Editor) { 27 | this.container = new FSVG.Group() 28 | this.container.setID('segment-draw') 29 | this.container.hide() 30 | 31 | // point and handle line nodes 32 | this.handleInLine = new FSVG.Line(0, 0, 0, 0) 33 | this.handleInLine.setAttr('stroke', editorDefaultConfig.outlineColor) 34 | this.handleInLine.setNonScalingStroke() 35 | this.container.append(this.handleInLine) 36 | 37 | this.handleOutLine = new FSVG.Line(0, 0, 0, 0) 38 | this.handleOutLine.setAttr('stroke', editorDefaultConfig.outlineColor) 39 | this.handleOutLine.setNonScalingStroke() 40 | this.container.append(this.handleOutLine) 41 | 42 | this.anchorNode = new FSVG.Rect(0, 0, this.size, this.size) 43 | this.anchorNode.setAttr('stroke', '#000') 44 | this.anchorNode.setAttr('fill', '#fff') 45 | this.anchorNode.setNonScalingStroke() 46 | this.container.append(this.anchorNode) 47 | 48 | this.handleInNode = new FSVG.Rect(0, 0, this.size, this.size) 49 | this.handleInNode.setAttr('stroke', '#000') 50 | this.handleInNode.setAttr('fill', '#fff') 51 | this.handleInNode.setNonScalingStroke() 52 | this.container.append(this.handleInNode) 53 | 54 | this.handleOutNode = new FSVG.Rect(0, 0, this.size, this.size) 55 | this.handleOutNode.setAttr('stroke', '#000') 56 | this.handleOutNode.setAttr('fill', '#fff') 57 | this.handleOutNode.setNonScalingStroke() 58 | this.container.append(this.handleOutNode) 59 | 60 | this.changeSizeWhenZoom() 61 | 62 | parent.appendChild(this.container.el()) 63 | } 64 | 65 | private changeSizeWhenZoom() { 66 | this.editor.viewport.onZoomChange(zoom => { 67 | const size = this.size / zoom 68 | ;[this.anchorNode, this.handleInNode, this.handleOutNode].forEach(grid => { 69 | const { x, y } = grid.getCenterPos() 70 | grid.setAttr('width', String(size)) 71 | grid.setAttr('height', String(size)) 72 | grid.setCenterPos(x, y) 73 | }) 74 | }) 75 | } 76 | render(seg: ISegment) { 77 | this.seg = seg 78 | // 3 points 79 | this.anchorNode.setCenterPos(seg.x, seg.y) 80 | this.handleInNode.setCenterPos(seg.handleIn.x, seg.handleIn.y) 81 | this.handleOutNode.setCenterPos(seg.handleOut.x, seg.handleOut.y) 82 | // 2 handle lines 83 | this.handleInLine.setPos(seg.x, seg.y, seg.handleIn.x, seg.handleIn.y) 84 | this.handleOutLine.setPos(seg.x, seg.y, seg.handleOut.x, seg.handleOut.y) 85 | this.container.visible() 86 | } 87 | getSeg() { 88 | return this.seg 89 | } 90 | clear() { 91 | this.container.hide() 92 | this.seg = null 93 | } 94 | } 95 | 96 | /** 97 | * path 路径高亮 98 | */ 99 | export class PathDraw { 100 | private container: SVGGElement 101 | private path: IFSVG['Path'] 102 | segDraw: SegmentDraw 103 | 104 | constructor(parent: SVGGElement, editor: Editor) { 105 | this.container = document.createElementNS(NS.SVG, 'g') as SVGGElement 106 | this.container.id = 'path-draw' 107 | 108 | this.path = new FSVG.Path() 109 | this.path.setAttr('fill', 'none') 110 | this.path.setAttr('stroke', editorDefaultConfig.outlineColor) 111 | this.path.setAttr('vector-effect', 'non-scaling-stroke') 112 | this.container.appendChild(this.path.el()) 113 | parent.appendChild(this.container) 114 | this.segDraw = new SegmentDraw(parent, editor) 115 | } 116 | /* getD() { 117 | return this.path.getAttr('d') 118 | } */ 119 | setD(d: string) { 120 | this.path.setAttr('d', d) 121 | } 122 | clear() { 123 | this.path.hide() 124 | this.path.removeAttr('d') 125 | this.segDraw.clear() 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/huds/pencilDraw.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | import { NS } from '../constants' 4 | import { FSVG, IFSVG } from '../element/index' 5 | 6 | export class PencilDraw { 7 | container: SVGGElement 8 | path: IFSVG['Path'] 9 | 10 | constructor(parent: SVGGElement) { 11 | this.container = document.createElementNS(NS.SVG, 'g') as SVGGElement 12 | this.path = new FSVG.Path() 13 | parent.appendChild(this.container) 14 | 15 | this.path.setAttr('fill', 'none') 16 | this.path.setAttr('stroke', '#054') 17 | this.path.setAttr('vector-effect', 'non-scaling-stroke') 18 | 19 | this.container.appendChild(this.path.el()) 20 | } 21 | addPoint(x: number, y: number) { 22 | this.path.visible() 23 | 24 | let d = this.getD() 25 | if (d === '' || d === null) { 26 | d = `M ${x} ${y}` 27 | } else { 28 | d += ` L ${x} ${y}` 29 | } 30 | this.path.setAttr('d', d) 31 | } 32 | getD() { 33 | return this.path.getAttr('d') 34 | } 35 | clear() { 36 | this.path.hide() 37 | this.path.removeAttr('d') 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/huds/selectArea.ts: -------------------------------------------------------------------------------- 1 | 2 | import config from '../config/editorDefaultConfig' 3 | import { NS } from '../constants' 4 | 5 | /** 6 | * select area 7 | */ 8 | export class SelectArea { 9 | x = 0 10 | y = 0 11 | w = 0 12 | h = 0 13 | container: SVGGElement 14 | outline: SVGPathElement 15 | 16 | constructor(parent: SVGGElement) { 17 | this.container = document.createElementNS(NS.SVG, 'g') as SVGGElement 18 | this.container.id = 'select-area' 19 | parent.appendChild(this.container) 20 | 21 | this.outline = document.createElementNS(NS.SVG, 'path') as SVGPathElement 22 | this.outline.setAttribute('fill', config.selectAreaFill) 23 | this.outline.setAttribute('stroke', config.selectAreaStroke) 24 | this.outline.setAttribute('vector-effect', 'non-scaling-stroke') 25 | 26 | this.container.appendChild(this.outline) 27 | } 28 | clear() { 29 | this.x = this.y = this.w = this.h = 0 30 | this.outline.style.display = 'none' 31 | } 32 | drawRect(x: number, y: number, w: number, h: number) { 33 | this.x = x 34 | this.y = y 35 | this.w = w 36 | this.h = h 37 | 38 | // why don't I use rect, just solve the condition when width or height is 0 the outline is disapper 39 | const d = `M ${x} ${y} L ${x + w} ${y} L ${x + w} ${y + h} L ${x} ${y + h} Z` 40 | this.outline.setAttribute('d', d) 41 | 42 | /* this.outline.setAttribute('x', x) 43 | this.outline.setAttribute('y', y) 44 | this.outline.setAttribute('width', w) 45 | this.outline.setAttribute('height', h) */ 46 | this.outline.style.display = '' 47 | } 48 | getWidth() { return this.w } 49 | getHeight() { return this.h } 50 | getX() { return this.x } 51 | getY() { return this.y } 52 | getBox() { 53 | return { 54 | x: this.x, 55 | y: this.y, 56 | width: this.w, 57 | height: this.h, 58 | w: this.w, 59 | h: this.h, 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/huds/transformHud.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * TransformGuide 3 | * 4 | * transform element grips (with outline?) 5 | */ 6 | class TransformHud { 7 | constructor() {} 8 | // render(els) { 9 | // this.els = els 10 | // } 11 | // draw(x, y, w, h) { 12 | 13 | // } 14 | // drawGrips() { 15 | 16 | // } 17 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SVG Editor 6 | 36 | 37 | 38 | 39 | 40 |
41 | 42 | -------------------------------------------------------------------------------- /src/interface.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface IPoint { 3 | x: number 4 | y: number 5 | } 6 | 7 | 8 | export interface ISegment { 9 | x: number 10 | y: number 11 | handleIn: IPoint 12 | handleOut: IPoint 13 | } 14 | -------------------------------------------------------------------------------- /src/layer/layer.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * layer manager 4 | * 5 | * TODO: 6 | */ 7 | import Editor from '../Editor' 8 | import { FElement } from '../element/baseElement' 9 | import { FSVG, IFSVG } from '../element/index' 10 | import { IdGenerator } from '../util/IdGenerator' 11 | 12 | 13 | class Layer { 14 | el_: IFSVG['Group'] 15 | 16 | constructor(private editor: Editor, id: number) { 17 | this.el_ = new FSVG.Group() 18 | this.el_.setID('layer-' + id) 19 | } 20 | el() { return this.el_.el() } 21 | getVisible() {} 22 | visible() { this.el_.visible() } 23 | hide() { this.el_.hide() } 24 | addChild(child: FElement) { this.el_.append(child) } 25 | remove() { this.el_.remove() } 26 | } 27 | 28 | export class LayerManager { 29 | // layers: Array = [] 30 | private currentLayer: Layer = null 31 | private isInit = false 32 | private idGenerator = new IdGenerator() 33 | 34 | constructor(private editor: Editor) {} 35 | createInitLayerAndMount() { 36 | if (this.isInit) { 37 | throw new Error('Had inited! Don\'t call this methods again!') 38 | } 39 | const id = this.idGenerator.getId() 40 | const layer = new Layer(this.editor, id) 41 | this.addLayer(layer) 42 | this.isInit = true 43 | } 44 | setCurrent(layer: Layer) { 45 | this.currentLayer = layer 46 | } 47 | getCurrent() { 48 | return this.currentLayer 49 | } 50 | addLayer(layer: Layer) { 51 | // this.layers.push(layer) 52 | this.editor.svgContent.appendChild(layer.el()) 53 | this.currentLayer = layer 54 | } 55 | removeCurrLayer() { 56 | // 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/setting/editorSetting.ts: -------------------------------------------------------------------------------- 1 | import defaultConfig from '../config/editorDefaultConfig' 2 | 3 | 4 | type listener = (val: string) => void 5 | 6 | export class EditorSetting { 7 | private setting: {[prop: string]: string} 8 | private listeners: {[prop: string]: Array} 9 | 10 | constructor() { 11 | this.setting = {} 12 | this.listeners = {} 13 | this.setFill(defaultConfig.fill) 14 | this.setStroke(defaultConfig.stroke) 15 | this.setStrokeWidth(defaultConfig.strokeWidth) 16 | } 17 | setFill(val: string) { this.set('fill', val) } 18 | setStroke(val: string) { this.set('stroke', val) } 19 | setStrokeWidth(val: string) { this.set('stroke-width', val) } 20 | set(name: string, val: string) { 21 | this.setting[name] = val 22 | 23 | const toCallHandlers = this.listeners[name] 24 | if (toCallHandlers) { 25 | toCallHandlers.forEach(handler => { 26 | handler(val) 27 | }) 28 | } 29 | } 30 | get(name: string) { 31 | return this.setting[name] 32 | } 33 | on(name: string, handler: listener) { 34 | if (!this.listeners[name]) { 35 | this.listeners[name] = [] 36 | } 37 | this.listeners[name].push(handler) 38 | } 39 | // TODO: to test 40 | off(name: string, handler: listener): boolean { 41 | const targetListeners = this.listeners[name] 42 | if (this.listeners) { 43 | const idx = targetListeners.indexOf(handler) 44 | if (idx > -1) { 45 | targetListeners.splice(idx, 1) 46 | return true 47 | } 48 | } 49 | return false 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/shortcut.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * editor global shortcut 3 | */ 4 | import Editor from './Editor' 5 | 6 | 7 | type FnType = (e: KeyboardEvent) => void 8 | interface IRegisterItem { 9 | cmdName: string 10 | fn: FnType 11 | } 12 | 13 | export class Shortcut { 14 | private editor: Editor 15 | private registerItems: { [key: string]: Array } 16 | 17 | constructor(editor: Editor) { 18 | this.editor = editor 19 | this.registerItems = {} 20 | 21 | window.addEventListener('keydown', e => { 22 | const pressKeyName = getPressKeyName(e) 23 | // if (isDebug) { console.log(pressKeyName) } 24 | const registeredInstance = this.registerItems[pressKeyName] || [] 25 | if (registeredInstance.length > 0) { 26 | /** debug */ 27 | /** debug end */ 28 | e.preventDefault() 29 | registeredInstance.forEach(item => item.fn(e)) 30 | } 31 | }, false) 32 | } 33 | // usage: this.register('undo', 'Ctrl+Z', () => { editor.execCommand('undo') }) 34 | register(cmdName: string, shortcutName: string, fn: FnType) { 35 | // TODO: valid shortcutName 36 | const item = { cmdName, fn } 37 | if (!this.registerItems[shortcutName]) { 38 | this.registerItems[shortcutName] = [item] 39 | } else { 40 | this.registerItems[shortcutName].push(item) 41 | } 42 | } 43 | 44 | unregister(shortcutName: string, fn: FnType) { 45 | const items = this.registerItems[shortcutName] || [] 46 | const idx = items.findIndex(item => item.fn === fn) 47 | if (idx > -1) { 48 | items.splice(idx, 1) 49 | } 50 | } 51 | } 52 | 53 | function getPressKeyName(e: KeyboardEvent) { 54 | const pressedKeys = [] 55 | if (e.ctrlKey) pressedKeys.push('Ctrl') 56 | if (e.metaKey) pressedKeys.push('Cmd') 57 | if (e.shiftKey) pressedKeys.push('Shift') 58 | // only check A~Z 59 | // TODO: resolve all key 60 | if (/Key./.test(e.code)) { 61 | pressedKeys.push(e.code[e.code.length - 1]) 62 | } else if (/Digit./.test(e.code)) { 63 | pressedKeys.push(e.code[e.code.length - 1]) 64 | } else if (e.code === 'Escape') { 65 | pressedKeys.push('Esc') 66 | } else { 67 | pressedKeys.push(e.code) 68 | } 69 | const name = pressedKeys.join('+') 70 | return name 71 | } 72 | -------------------------------------------------------------------------------- /src/tools/ToolAbstract.ts: -------------------------------------------------------------------------------- 1 | import Editor from '../Editor' 2 | import { EditorEventContext } from '../editorEventContext' 3 | 4 | export abstract class ToolAbstract { 5 | constructor(protected editor: Editor) {} 6 | mounted() { /** Do Nothing */ } 7 | willUnmount() { /** Do Nothing */ } 8 | abstract name(): string 9 | abstract cursorNormal(): string 10 | abstract cursorPress(): string 11 | abstract start(ctx: EditorEventContext): void 12 | abstract move(ctx: EditorEventContext): void 13 | moveNoDrag(ctx: EditorEventContext) { /* nope */ } 14 | abstract end(ctx: EditorEventContext): void 15 | abstract endOutside(ctx: EditorEventContext): void 16 | /* beforeActive() { 17 | // do something before switch to current tool 18 | } */ 19 | } 20 | -------------------------------------------------------------------------------- /src/tools/addRect.ts: -------------------------------------------------------------------------------- 1 | 2 | import Editor from '../Editor' 3 | import { EditorEventContext } from '../editorEventContext' 4 | import { getBoxBy2points } from '../util/math' 5 | import { ToolAbstract } from './ToolAbstract' 6 | 7 | class AddRect extends ToolAbstract { 8 | constructor(editor: Editor) { 9 | super(editor) 10 | } 11 | name() { 12 | return 'addRect' 13 | } 14 | cursorNormal() { return 'crosshair' } 15 | cursorPress() { return 'crosshair' } 16 | start() { /** do nothing */ } 17 | move(ctx: EditorEventContext) { 18 | const { x: endX, y: endY } = ctx.getPos() 19 | const { x: startX, y: startY } = ctx.getStartPos() 20 | const { x, y, w, h } = getBoxBy2points(startX, startY, endX, endY) 21 | this.editor.huds.outlineBoxHud.drawRect(x, y, w, h) 22 | } 23 | end(ctx: EditorEventContext) { 24 | this.editor.huds.outlineBoxHud.clear() 25 | this.editor.huds.elsOutlinesHub.clear() 26 | 27 | const { x: endX, y: endY } = ctx.getPos() 28 | const { x: startX, y: startY } = ctx.getStartPos() 29 | const { x, y, w, h } = getBoxBy2points(startX, startY, endX, endY) 30 | if (w < 2 && h < 2) { 31 | // TODO: open a dialog to input width and height 32 | console.log('width and height both less equal to 2,drawing nothing') 33 | return 34 | } 35 | this.editor.executeCommand('addRect', x, y, w, h) 36 | } 37 | // mousedown outside viewport 38 | endOutside() { 39 | this.editor.huds.outlineBoxHud.clear() 40 | this.editor.huds.elsOutlinesHub.clear() 41 | } 42 | } 43 | 44 | export default AddRect 45 | -------------------------------------------------------------------------------- /src/tools/dragCanvas.ts: -------------------------------------------------------------------------------- 1 | import Editor from '../Editor' 2 | import { EditorEventContext } from '../editorEventContext' 3 | import { ToolAbstract } from './ToolAbstract' 4 | 5 | export class DragCanvas extends ToolAbstract { 6 | private startOffsetX: number 7 | private startOffsetY: number 8 | 9 | constructor(editor: Editor) { 10 | super(editor) 11 | this.startOffsetX = 0 12 | this.startOffsetY = 0 13 | } 14 | name() { 15 | return 'dragCanvas' 16 | } 17 | cursorNormal() { 18 | return 'grab' 19 | } 20 | cursorPress() { 21 | return 'grabbing' 22 | } 23 | beforeActive() { 24 | // do something before switch to current tool 25 | } 26 | start() { 27 | const scroll = this.editor.viewport.getScroll() 28 | this.startOffsetX = scroll.x 29 | this.startOffsetY = scroll.y 30 | } 31 | move(ctx: EditorEventContext) { 32 | const { x: dx, y: dy } = ctx.getDiffPos() 33 | this.editor.viewport.setScroll(this.startOffsetX - dx, this.startOffsetY - dy) 34 | } 35 | end() {} 36 | endOutside() {} 37 | } 38 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | import Editor from '../Editor' 2 | import { EditorEventContext } from '../editorEventContext' 3 | import AddRect from './addRect' 4 | import { DragCanvas } from './dragCanvas' 5 | import { Select } from './select/select' 6 | import { Pencil } from './pencil' 7 | import { Pen } from './pen' 8 | import { Zoom } from './zoom' 9 | import { ToolAbstract } from './ToolAbstract' 10 | 11 | export class ToolManager { 12 | private editor: Editor 13 | private tools: { [name: string]: ToolAbstract } 14 | private currentTool: ToolAbstract 15 | private invokeWhenSwitch: (toolName: string) => void 16 | private ctx: EditorEventContext 17 | private isInitEvent = false 18 | 19 | constructor(editor: Editor) { 20 | this.editor = editor 21 | this.tools = {} 22 | this.currentTool = null 23 | this.invokeWhenSwitch = () => { /* nope */ } 24 | this.ctx = null // tool context 25 | 26 | this.registerTool(new AddRect(editor)) 27 | this.registerTool(new DragCanvas(editor)) 28 | this.registerTool(new Select(editor)) 29 | this.registerTool(new Pencil(editor)) 30 | this.registerTool(new Pen(editor)) 31 | this.registerTool(new Zoom(editor)) 32 | } 33 | setCurrentTool(name: string) { 34 | const prevTool = this.getCurrentTool() 35 | prevTool && prevTool.willUnmount() 36 | 37 | this.currentTool = this.tools[name] 38 | this.currentTool.mounted() 39 | 40 | const cursor = this.currentTool.cursorNormal() 41 | this.editor.setCursor(cursor) 42 | 43 | // emit event 44 | const toolName = this.getCurrentToolName() 45 | this.invokeWhenSwitch(toolName) 46 | } 47 | onSwitchTool(fn: (toolName: string) => void) { 48 | this.invokeWhenSwitch = fn 49 | } 50 | getCurrentTool() { 51 | return this.currentTool 52 | } 53 | getCurrentToolName() { 54 | return this.currentTool.name() 55 | } 56 | registerTool(tool: ToolAbstract) { 57 | const toolName = tool.name() 58 | this.tools[toolName] = tool 59 | } 60 | 61 | // 绑定鼠标事件,传递给工具对象。 62 | initToolEvent() { 63 | if (this.isInitEvent) { 64 | console.warn('已经初始化过工具类事件,而你再尝试进行第二次初始化,静默失败') 65 | return 66 | } 67 | this.isInitEvent = true 68 | const svgRoot = this.editor.svgRoot 69 | 70 | svgRoot.addEventListener('mousedown', e => { 71 | if (e.button !== 0) return // 必须为左键鼠标 72 | const ctx = new EditorEventContext(this.editor, e) 73 | this.ctx = ctx 74 | 75 | const cursor = this.currentTool.cursorPress() 76 | this.editor.setCursor(cursor) 77 | 78 | this.currentTool.start(ctx) 79 | }, false) 80 | 81 | svgRoot.addEventListener('mousemove', e => { 82 | const ctx = this.ctx 83 | if (!ctx) { 84 | // TODO: 添加一个配置项,来确定是否调用 moveNoDrag 85 | this.currentTool.moveNoDrag(new EditorEventContext(this.editor, e)) 86 | return 87 | } 88 | // ctx 存在,为 “拖拽” 形式的鼠标移动事件 89 | ctx.setOriginEvent(e) // 修改了源 event 对象 90 | this.currentTool.move(ctx) // move 91 | }, false) 92 | 93 | svgRoot.addEventListener('mouseup', () => { 94 | const ctx = this.ctx 95 | if (!ctx) return 96 | // 最后一次 `鼠标移动时间的光标位置` 不等于 “释放鼠标时光标位置”。这是因为鼠标移动事件的触发是有间隔的。 97 | // 所以不可以调用 ctx.setOriginEvent(e) 98 | const cursor = this.currentTool.cursorNormal() 99 | this.editor.setCursor(cursor) 100 | 101 | this.currentTool.end(ctx) 102 | ctx.isEndInside = true 103 | }, false) 104 | 105 | window.addEventListener('mouseup', () => { 106 | if (this.ctx && this.ctx.isEndInside === false) { 107 | this.currentTool.endOutside(this.ctx) 108 | } 109 | this.ctx = null 110 | }, false) 111 | 112 | svgRoot.addEventListener('contextmenu', e => { 113 | const { editor } = this 114 | e.preventDefault() // 阻止默认菜单事件 115 | editor.contextMenu.show(e.clientX, e.clientY) 116 | }, false) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/tools/pen.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * quadratic Bezier curves 3 | * 三阶贝塞尔曲线(钢笔工具) 4 | */ 5 | import Editor from '../Editor' 6 | import { EditorEventContext } from '../editorEventContext' 7 | import { FSVG, IFSVG } from '../element/index' 8 | // import { ISegment } from '../interface' 9 | import { getSymmetryPoint } from '../util/math' 10 | import { ToolAbstract } from './ToolAbstract' 11 | 12 | 13 | export class Pen extends ToolAbstract { 14 | private x: number 15 | private y: number 16 | private CompleteDrawHandler: () => void 17 | private path: IFSVG['Path'] = null 18 | constructor(editor: Editor) { 19 | super(editor) 20 | } 21 | name() { return 'pen' } 22 | cursorNormal() { return 'default' } 23 | cursorPress() { return 'default' } 24 | start(ctx: EditorEventContext) { 25 | const { x, y } = ctx.getPos() 26 | this.x = x 27 | this.y = y 28 | this.editor.activedElsManager.clear() 29 | this.editor.huds.predictedCurve.clear() 30 | this.editor.huds.pathDraw.segDraw.render({ x, y, handleIn: { x, y }, handleOut: { x, y } }) 31 | } 32 | moveNoDrag(ctx: EditorEventContext) { 33 | if (!this.path) { 34 | return 35 | } 36 | const currPos = ctx.getPos() 37 | this.editor.huds.predictedCurve.draw( 38 | this.path.tail(), 39 | currPos, 40 | this.path.getMetaData('handleOut'), 41 | currPos, 42 | ) 43 | } 44 | move(ctx: EditorEventContext) { 45 | const handleOut = ctx.getPos() 46 | const handleIn = getSymmetryPoint(handleOut, this.x, this.y) 47 | 48 | this.editor.huds.pathDraw.segDraw.render({ x: this.x, y: this.y, handleIn, handleOut }) 49 | if (this.path) { 50 | this.editor.huds.predictedCurve.draw( 51 | this.path.tail(), 52 | { x: this.x, y: this.y }, 53 | this.path.getMetaData('handleOut'), 54 | handleIn, 55 | ) 56 | } 57 | } 58 | end() { 59 | const seg = this.editor.huds.pathDraw.segDraw.getSeg() 60 | if (!this.path) { // 第一个 seg,创建一个 path 61 | this.path = new FSVG.Path() 62 | this.editor.executeCommand('addPath', { 63 | d: `M ${this.x} ${this.y}`, 64 | path: this.path, 65 | seg 66 | }) 67 | } else { // 在这个 path 的基础上添加 seg 68 | this.editor.executeCommand('addPathSeg', this.path, seg) 69 | this.editor.huds.pathDraw.setD(this.path.getAttr('d')) 70 | } 71 | } 72 | endOutside() { /** Do Nothing */ } 73 | private completePath() { 74 | this.editor.huds.pathDraw.clear() 75 | this.editor.huds.predictedCurve.clear() 76 | this.path = null 77 | } 78 | mounted() { 79 | this.CompleteDrawHandler = () => { 80 | this.completePath() 81 | } 82 | this.editor.shortcut.register('Path tool: temp mount', 'Esc', this.CompleteDrawHandler) 83 | } 84 | willUnmount() { 85 | this.completePath() 86 | this.editor.shortcut.unregister('Esc', this.CompleteDrawHandler) 87 | this.editor.huds.pathDraw.clear() 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/tools/pencil.ts: -------------------------------------------------------------------------------- 1 | import { EditorEventContext } from '../editorEventContext' 2 | import { ToolAbstract } from './ToolAbstract' 3 | 4 | export class Pencil extends ToolAbstract { 5 | name() { 6 | return 'pencil' 7 | } 8 | cursorNormal() { 9 | return 'default' 10 | } 11 | cursorPress() { 12 | return 'default' 13 | } 14 | start(ctx: EditorEventContext) { 15 | const { x, y } = ctx.getPos() 16 | this.editor.huds.pencilDraw.addPoint(x, y) 17 | } 18 | move(ctx: EditorEventContext) { 19 | const { x, y } = ctx.getPos() 20 | this.editor.huds.pencilDraw.addPoint(x, y) 21 | } 22 | private doWhenEndOrEndOutside() { 23 | const d = this.editor.huds.pencilDraw.getD() 24 | this.editor.huds.pencilDraw.clear() 25 | 26 | this.editor.setting.set('fill', 'none') 27 | this.editor.executeCommand('addPath', { d }) 28 | } 29 | end() { this.doWhenEndOrEndOutside() } 30 | endOutside() { this.doWhenEndOrEndOutside() } 31 | } 32 | 33 | export default Pencil 34 | -------------------------------------------------------------------------------- /src/tools/select/modes/Mode.ts: -------------------------------------------------------------------------------- 1 | import Editor from '../../../Editor' 2 | import { EditorEventContext } from '../../../editorEventContext' 3 | 4 | abstract class Mode { 5 | constructor(protected editor: Editor) {} 6 | abstract start(ctx: EditorEventContext): void 7 | abstract move(ctx: EditorEventContext): void 8 | abstract end(ctx: EditorEventContext): void 9 | abstract endOutside(ctx: EditorEventContext): void 10 | } 11 | 12 | export default Mode 13 | -------------------------------------------------------------------------------- /src/tools/select/modes/MoveMode.ts: -------------------------------------------------------------------------------- 1 | import Mode from './Mode' 2 | import { EditorEventContext } from '../../../editorEventContext' 3 | import { FElement } from '../../../element/baseElement' 4 | 5 | import { FSVG } from '../../../element/index' 6 | 7 | 8 | class MoveMode extends Mode { 9 | private selectedEls: Array = [] 10 | 11 | start(ctx: EditorEventContext) { 12 | // encapsulate svg element 13 | const target = ctx.nativeEvent.target 14 | const activedElsManager = this.editor.activedElsManager 15 | 16 | const targetFElement = FSVG.create(target as SVGElement) 17 | // check whether target element is part of activedEls. 18 | if (activedElsManager.contains(target as SVGElement)) { 19 | activedElsManager.heighligthEls() 20 | } else { 21 | activedElsManager.setEls(targetFElement) 22 | } 23 | this.selectedEls = activedElsManager.getEls() 24 | } 25 | move(ctx: EditorEventContext) { 26 | const { x: dx, y: dy } = ctx.getDiffPos() 27 | const zoom = this.editor.viewport.getZoom() 28 | this.editor.huds.elsOutlinesHub.translate(dx / zoom, dy / zoom) 29 | } 30 | end(ctx: EditorEventContext) { 31 | this.editor.huds.outlineBoxHud.clear() 32 | this.editor.huds.elsOutlinesHub.clear() 33 | 34 | const { x: dx, y: dy } = ctx.getDiffPos() 35 | if (dx !== 0 || dy !== 0) { 36 | const zoom = this.editor.viewport.getZoom() 37 | this.editor.executeCommand('dmove', this.selectedEls, dx / zoom, dy / zoom) 38 | } 39 | 40 | this.editor.activedElsManager.setEls(this.selectedEls) // set global actived elements 41 | this.selectedEls = [] 42 | } 43 | endOutside() { 44 | this.selectedEls = [] 45 | } 46 | } 47 | 48 | export default MoveMode 49 | -------------------------------------------------------------------------------- /src/tools/select/modes/ScaleMode.ts: -------------------------------------------------------------------------------- 1 | import Mode from './Mode' 2 | import { EditorEventContext } from '../../../editorEventContext' 3 | // import { IBox } from '../../../element/box' 4 | 5 | class ScaleMode extends Mode { 6 | private cx: number 7 | private cy: number 8 | // private originBox: IBox 9 | 10 | start(ctx: EditorEventContext) { 11 | /** 12 | * 1. record match position 13 | * 记录相匹配位置 14 | */ 15 | const target = ctx.nativeEvent.target 16 | const outlineBoxHud = this.editor.huds.outlineBoxHud 17 | // 根据 target 获取对应缩放点(比如是左上还是右下) 18 | const grid = outlineBoxHud.getGripIfMatch(target as SVGElement) 19 | // const originBox = outlineBoxHud.getBox() 20 | const centerGrid = outlineBoxHud.scaleGrids.getOppositeGrip(grid) // 获取缩放中心点 21 | const pos = centerGrid.getCenterPos() 22 | this.cx = pos.x 23 | this.cy = pos.y 24 | } 25 | move(ctx: EditorEventContext) { 26 | /** 2. get current pos */ 27 | const { x, y } = ctx.getPos() 28 | /** calc size */ 29 | const x1 = Math.min(x, this.cx) 30 | const y1 = Math.min(y, this.cy) 31 | const x2 = Math.max(x, this.cx) 32 | const y2 = Math.max(y, this.cy) 33 | const width = x2 - x1 34 | const height = y2 - y1 35 | this.editor.huds.outlineBoxHud.drawRect(x1, y1, width, height) 36 | } 37 | end(ctx: EditorEventContext) { 38 | const { x: dx, y: dy } = ctx.getDiffPos() 39 | if (dx === 0 && dy === 0) return 40 | 41 | const { x, y, width, height } = this.editor.huds.outlineBoxHud.getBox() 42 | const elements = this.editor.activedElsManager.getEls() 43 | this.editor.executeCommand('setAttr', elements, { x, y, width, height }) 44 | // 计算新的图形位置 45 | this.editor.activedElsManager.heighligthEls() 46 | } 47 | endOutside() { 48 | this.editor.huds.outlineBoxHud.clear() 49 | this.editor.huds.elsOutlinesHub.clear() 50 | } 51 | } 52 | 53 | export default ScaleMode 54 | -------------------------------------------------------------------------------- /src/tools/select/modes/SelectAreaMode.ts: -------------------------------------------------------------------------------- 1 | import Mode from './Mode' 2 | import { EditorEventContext } from '../../../editorEventContext' 3 | import { getBoxBy2points } from '../../../util/math' 4 | 5 | class SelectAreaMode extends Mode { 6 | start() { /** Do Nothing */ } 7 | move(ctx: EditorEventContext) { 8 | const { x: endX, y: endY } = ctx.getPos() 9 | const { x: startX, y: startY } = ctx.getStartPos() 10 | const { x, y, w, h } = getBoxBy2points(startX, startY, endX, endY) 11 | this.editor.huds.selectArea.drawRect(x, y, w, h) 12 | } 13 | end() { 14 | const box = this.editor.huds.selectArea.getBox() 15 | this.editor.huds.selectArea.clear() 16 | this.editor.activedElsManager.setElsInBox(box) 17 | } 18 | endOutside() { 19 | this.editor.huds.selectArea.clear() 20 | this.editor.activedElsManager.clear() 21 | } 22 | } 23 | 24 | export default SelectAreaMode 25 | -------------------------------------------------------------------------------- /src/tools/select/modes/index.ts: -------------------------------------------------------------------------------- 1 | import Mode from './Mode' 2 | import SelectAreaMode from './SelectAreaMode' 3 | import MoveMode from './MoveMode' 4 | import ScaleMode from './ScaleMode' 5 | import Editor from '../../../Editor' 6 | 7 | 8 | type ModeType = 'selectArea' | 'move' | 'scale' 9 | 10 | class ModeFactory { 11 | private strategies: {[K in ModeType]: Mode} 12 | 13 | constructor(editor: Editor) { 14 | this.strategies = { 15 | selectArea: new SelectAreaMode(editor), 16 | move: new MoveMode(editor), 17 | scale: new ScaleMode(editor), 18 | } 19 | } 20 | getMode(type: ModeType) { 21 | return this.strategies[type] 22 | } 23 | } 24 | 25 | export { 26 | Mode, 27 | ModeFactory, 28 | } 29 | -------------------------------------------------------------------------------- /src/tools/select/select.ts: -------------------------------------------------------------------------------- 1 | import { EditorEventContext } from '../../editorEventContext' 2 | import { ToolAbstract } from '../ToolAbstract' 3 | import { Mode, ModeFactory } from './modes/index' 4 | import Editor from '../../Editor' 5 | 6 | /** 7 | * select 8 | * 9 | * 此模块非常复杂 10 | * 11 | * 1. 鼠标按下时,选中单个元素 12 | * 2. 鼠标按下为空,拖拽时产生选中框,可以选择多个元素 13 | * 3. 选中单个(或选区选中多个) 缩放 等控制点,拖拽改变宽高 14 | * 3. 切断到这个工具时,激活的元素进入被选中状态(轮廓线+控制点)。 15 | * 4. 选区和元素相交的判定 16 | * 5. 激活元素如何保存,保存到哪里 17 | */ 18 | export class Select extends ToolAbstract { 19 | private mode: Mode 20 | private modeFactory: ModeFactory 21 | 22 | constructor(editor: Editor) { 23 | super(editor) 24 | this.modeFactory = new ModeFactory(editor) 25 | } 26 | name() { return 'select' } 27 | cursorNormal() { return 'default' } 28 | cursorPress() { return 'default' } 29 | start(ctx: EditorEventContext) { 30 | const target = ctx.nativeEvent.target 31 | const outlineBoxHud = this.editor.huds.outlineBoxHud 32 | 33 | const transformGrid = outlineBoxHud.getGripIfMatch(target as SVGElement) 34 | if (transformGrid) { 35 | this.mode = this.modeFactory.getMode('scale') 36 | } else if (this.editor.isContentElement(ctx.nativeEvent.target)) { 37 | this.mode = this.modeFactory.getMode('move') 38 | } else { 39 | this.mode = this.modeFactory.getMode('selectArea') 40 | } 41 | 42 | this.mode.start(ctx) 43 | } 44 | move(ctx: EditorEventContext) { 45 | this.mode.move(ctx) 46 | } 47 | end(ctx: EditorEventContext) { 48 | this.mode.end(ctx) 49 | } 50 | // mousedown outside viewport 51 | endOutside(ctx: EditorEventContext) { 52 | this.mode.endOutside(ctx) 53 | } 54 | mounted() { 55 | this.editor.huds.outlineBoxHud.enableScaleGrip() 56 | if (!this.editor.activedElsManager.isEmpty()) { 57 | this.editor.activedElsManager.heighligthEls() 58 | } 59 | } 60 | willUnmount() { 61 | this.editor.huds.outlineBoxHud.disableScaleGrip(true) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/tools/zoom.ts: -------------------------------------------------------------------------------- 1 | 2 | import Editor from '../Editor' 3 | import { EditorEventContext } from '../editorEventContext' 4 | import { ToolAbstract } from './ToolAbstract' 5 | 6 | export class Zoom extends ToolAbstract { 7 | constructor(editor: Editor) { 8 | super(editor) 9 | } 10 | name() { 11 | return 'zoom' 12 | } 13 | cursorNormal() { 14 | return 'zoom-in' 15 | } 16 | cursorPress() { 17 | return 'zoom-in' 18 | } 19 | start(ctx: EditorEventContext) { 20 | const { x, y } = ctx.getPos() 21 | this.editor.viewport.zoomIn(x, y) 22 | } 23 | move(ctx: EditorEventContext) { 24 | // TODO: 25 | /* const { x: dx, y: dy } = ctx.getDiffPos() 26 | const distance = Math.sqrt(dx * dx + dy * dy) 27 | console.log(distance) */ 28 | } 29 | end(ctx: EditorEventContext) { 30 | 31 | } 32 | // mousedown outside viewport 33 | endOutside(ctx: EditorEventContext) {} 34 | } 35 | -------------------------------------------------------------------------------- /src/util/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * EventEmiter 4 | * 5 | * Publish-Subscribe Design Pattern 6 | */ 7 | 8 | type EventName = string | symbol 9 | type Listener = (...args: any[]) => void 10 | 11 | class EventEmiter { 12 | private hashMap: { [eventName: string]: Array } = {} 13 | 14 | on(eventName: EventName, listener: Listener): this { 15 | const name = eventName as string 16 | if (!this.hashMap[name]) { 17 | this.hashMap[name] = [] 18 | } 19 | this.hashMap[name].push(listener) 20 | return this 21 | } 22 | emit(eventName: EventName, ...args: any): boolean { 23 | const listeners = this.hashMap[eventName as string] 24 | if (!listeners || listeners.length === 0) return false 25 | listeners.forEach(listener => { 26 | listener(...args) 27 | }) 28 | return true 29 | } 30 | off(eventName: EventName, listener: Listener): this { 31 | const listeners = this.hashMap[eventName as string] 32 | if (listeners && listeners.length > 0) { 33 | const index = listeners.indexOf(listener) 34 | if (index > -1) { 35 | listeners.splice(index, 1) 36 | } 37 | } 38 | return this 39 | } 40 | } 41 | 42 | export default EventEmiter 43 | -------------------------------------------------------------------------------- /src/util/IdGenerator.ts: -------------------------------------------------------------------------------- 1 | 2 | export class IdGenerator { 3 | private i = 0 4 | 5 | constructor() {} 6 | getId() { 7 | return this.i++ 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/util/common.ts: -------------------------------------------------------------------------------- 1 | import { FElement } from '../element/baseElement' 2 | import { Box, IBox } from '../element/box' 3 | import { FSVG } from '../element/index' 4 | 5 | function getElementsInBox(box: IBox, parent: SVGElement) { 6 | const tagNameForbidList = ['g'] 7 | box = new FSVG.Box(box) 8 | const elsInBox: Array = [] 9 | 10 | function r(box: Box, parent: SVGElement) { 11 | const elements = parent.children 12 | for (let i = 0; i < elements.length; i++) { 13 | const el = elements[i] // FSVG.create(elements[i]) 14 | 15 | if (!tagNameForbidList.includes(el.tagName)) { 16 | const bbox = (el as SVGGraphicsElement).getBBox() 17 | if (box.contains(bbox)) { 18 | elsInBox.push(FSVG.create(el as SVGElement)) 19 | } 20 | } 21 | 22 | if (el.children.length > 0) r(box, el as SVGElement) 23 | } 24 | } 25 | r(box as Box, parent) 26 | return elsInBox 27 | } 28 | 29 | export { 30 | getElementsInBox, 31 | } 32 | -------------------------------------------------------------------------------- /src/util/debug.ts: -------------------------------------------------------------------------------- 1 | 2 | // 根据提供的 x, y 画点 3 | 4 | export function drawPoint(editor, x: number, y: number) { 5 | const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') 6 | circle.setAttribute('r', '6') 7 | circle.setAttribute('cx', x + '') 8 | circle.setAttribute('cy', y + '') 9 | editor.getCurrentLayer().appendChild(circle) 10 | } 11 | -------------------------------------------------------------------------------- /src/util/math.ts: -------------------------------------------------------------------------------- 1 | import { IPoint } from '../interface' 2 | 3 | export function getBoxBy2points(x1: number, y1: number, x2: number, y2: number) { 4 | const w = Math.abs(x2 - x1) 5 | const h = Math.abs(y2 - y1) 6 | const x = Math.min(x2, x1) 7 | const y = Math.min(y2, y1) 8 | return { x, y, w, h } 9 | } 10 | 11 | export function getSymmetryPoint(pt: IPoint, cx: number, cy: number): IPoint { 12 | return { 13 | x: cx * 2 - pt.x, 14 | y: cy * 2 - pt.y, 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/util/svg.ts: -------------------------------------------------------------------------------- 1 | 2 | // TODO: to finish 3 | export function getViewBox(el: SVGSVGElement) { 4 | const val = el.getAttribute('viewBox') 5 | if (!val) { 6 | throw new Error('has not viewBox attribute') 7 | } 8 | const [x, y, w, h] = val.split(/[\s,]+/).map(item => parseFloat(item)) 9 | return { x, y, w, h } 10 | } 11 | -------------------------------------------------------------------------------- /src/util/typescript-ds.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | class ArrayStack { 4 | private items: Array = [] 5 | 6 | size(): number { 7 | return this.items.length 8 | } 9 | push(item: T) { 10 | this.items.push(item) 11 | } 12 | pop(): T { 13 | return this.items.pop() 14 | } 15 | getItems(): Array { 16 | return this.items 17 | } 18 | /* peek */ 19 | empty() { 20 | this.items = [] 21 | } 22 | } 23 | 24 | export { 25 | ArrayStack 26 | } 27 | -------------------------------------------------------------------------------- /src/viewport.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Viewport 3 | * 4 | * scroll, zoom 5 | */ 6 | import Editor from './Editor' 7 | import { getViewBox } from './util/svg' 8 | 9 | type listener = (zoom: number) => void 10 | export class Viewport { 11 | private zoomListeners: Array = [] 12 | 13 | constructor(private editor: Editor) {} 14 | /** scroll */ 15 | getScroll() { 16 | return { 17 | x: this.editor.viewportElement.scrollLeft, 18 | y: this.editor.viewportElement.scrollTop, 19 | } 20 | } 21 | setScroll(x: number, y: number) { 22 | this.editor.viewportElement.scrollLeft = x 23 | this.editor.viewportElement.scrollTop = y 24 | } 25 | getContentOffset() { 26 | return { 27 | x: parseFloat(this.editor.svgStage.getAttribute('x')), 28 | y: parseFloat(this.editor.svgStage.getAttribute('y')), 29 | } 30 | } 31 | getViewportCenter() { 32 | // FIXME: 33 | const w = parseFloat(this.editor.svgStage.getAttribute('width')) 34 | const h = parseFloat(this.editor.svgStage.getAttribute('height')) 35 | return { 36 | x: w / 2, 37 | y: h / 2, 38 | } 39 | } 40 | /** zoom */ 41 | getZoom() { 42 | const actulWidth = parseFloat(this.editor.svgRoot.getAttribute('width')) 43 | const viewBox = getViewBox(this.editor.svgRoot) 44 | const zoom = actulWidth / viewBox.w 45 | return zoom 46 | } 47 | setZoom(zoom: number, cx?: number, cy?: number) { 48 | if (cx === undefined) { 49 | const point = this.getViewportCenter() 50 | cx = point.x 51 | cy = point.y 52 | } 53 | // adjust scroll position 54 | const { x: scrollX, y: scrollY } = this.getScroll() 55 | const { x: offsetX, y: offsetY } = this.getContentOffset() 56 | const oldZoom = this.getZoom() 57 | const dx = (cx + offsetX) * oldZoom - scrollX 58 | const dy = (cy + offsetY) * oldZoom - scrollY 59 | const newX = (cx + offsetX) * zoom - dx 60 | const newY = (cy + offsetY) * zoom - dy 61 | this.setScroll(newX, newY) 62 | 63 | const viewBox = getViewBox(this.editor.svgRoot) 64 | const width = viewBox.w * zoom 65 | const height = viewBox.h * zoom 66 | 67 | this.editor.svgRoot.setAttribute('width', String(width)) 68 | this.editor.svgRoot.setAttribute('height', String(height)) 69 | this.editor.svgContainer.style.width = width + 'px' 70 | this.editor.svgContainer.style.height = height + 'px' 71 | 72 | this.emitZoomListeners() 73 | } 74 | zoomIn(cx?: number, cy?: number) { 75 | const currentZoom = this.getZoom() 76 | this.setZoom(currentZoom + 0.2, cx, cy) 77 | } 78 | zoomOut(cx?: number, cy?: number) { 79 | const currentZoom = this.getZoom() 80 | this.setZoom(currentZoom - 0.2, cx, cy) 81 | } 82 | onZoomChange(fn: listener) { 83 | this.zoomListeners.push(fn) 84 | } 85 | private emitZoomListeners() { 86 | const zoom = this.getZoom() 87 | this.zoomListeners.forEach(fn => { 88 | fn(zoom) 89 | }) 90 | } 91 | center() { 92 | const scrollWidth = this.getSVGRootBox('width') 93 | const scrollHeight = this.getSVGRootBox('height') 94 | const viewportWidth = this.getViewportWidth() 95 | const viewportHeight = this.getViewportHeight() 96 | this.setScroll( 97 | (scrollWidth - viewportWidth) / 2, 98 | (scrollHeight - viewportHeight) / 2 99 | ) 100 | } 101 | 102 | getViewportWidth(): number { 103 | const widthVal = this.editor.viewportElement.getAttribute('width') 104 | if (widthVal) { 105 | return parseFloat(widthVal) 106 | } 107 | return this.editor.viewportElement.offsetWidth 108 | } 109 | 110 | getViewportHeight(): number { 111 | const val = this.editor.viewportElement.getAttribute('height') 112 | if (val) { 113 | return parseFloat(val) 114 | } 115 | return this.editor.viewportElement.offsetHeight 116 | } 117 | 118 | getSVGRootBox(prop: string) { 119 | return parseFloat(this.editor.svgRoot.getAttribute(prop)) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/views/App.css: -------------------------------------------------------------------------------- 1 | 2 | /* clear browser default style */ 3 | html, body, div, ul, li, h1, h2, h3, h4, h5, h6, p, dl, dt, dd, ol, form, input, textarea, th, td, select { 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | body { 9 | overflow: hidden; 10 | } 11 | -------------------------------------------------------------------------------- /src/views/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react' 2 | import './App.css' 3 | import ToolBar from './ToolBar/ToolBar' 4 | import EditorHeader from './EditorHeader/EditorHeader' 5 | import globalVar from './common/globalVar' 6 | import PanelsArea from './PanelsArea/PanelsArea' 7 | import ContextMenu from './ContextMenu' 8 | 9 | const App: FC = () => { 10 | useEffect(() => { 11 | const editor = globalVar.editor 12 | editor.mount('#editor-area') 13 | editor.viewport.center() 14 | }, []) 15 | 16 | return ( 17 |
18 | 19 |
20 | 21 |
28 | 29 | 30 |
31 |
32 | ) 33 | } 34 | 35 | export default App 36 | -------------------------------------------------------------------------------- /src/views/ContextMenu/index.less: -------------------------------------------------------------------------------- 1 | .contextmenu-mask { 2 | position: fixed; 3 | left: 0; 4 | right: 0; 5 | top: 0; 6 | bottom: 0; 7 | /* background-color: #000; */ 8 | /* opacity: .2; */ 9 | z-index: 45; 10 | } 11 | .contextmenu-content { 12 | position: fixed; 13 | left: 999999px; 14 | top: 999999px; 15 | z-index: 50; 16 | font-size: 14px; 17 | user-select: none; 18 | /* 内容 */ 19 | .list { 20 | padding: 3px; 21 | border: 1px solid #ddd; 22 | border-radius: 4px; 23 | // box-shadow: 1px 1px 6px 0px rgba(0, 0, 0, .2); 24 | box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.1),0px 2px 8px rgba(0, 0, 0, 0.08);; 25 | min-width: 130px; 26 | background-color: #fff; 27 | } 28 | .item { 29 | box-sizing: border-box; 30 | padding: 0 5px; 31 | border-radius: 4px; 32 | height: 28px; 33 | line-height: 28px; 34 | word-break: keep-all; /* 很重要,否则会换行 */ 35 | cursor: default; 36 | &.disabled { 37 | color: #ddd; 38 | pointer-events: none; 39 | } 40 | } 41 | .item:hover { 42 | background-color: dodgerblue; 43 | color: #fff; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/views/ContextMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useState, useEffect, useRef } from 'react' 3 | import globalVar from '../common/globalVar' 4 | import './index.less' 5 | import { ShowEventOptions, ItemGroupType } from '../../ContextMenu' 6 | import classnames from 'classnames' 7 | 8 | const PADDING_RIGHT = 6 // 右边留点空位,防止直接贴边了,不好看 9 | const PADDING_BOTTOM = 6 // 底部也留点空位 10 | 11 | const ContexMenu: React.FunctionComponent = () => { 12 | const [items, setItems] = useState([]) 13 | const [x, setX] = useState(99999) 14 | const [y, setY] = useState(99999) 15 | const [visible, setVisible] = useState(false) 16 | const contentRef = useRef(null) 17 | 18 | const hideHandler = () => { 19 | setVisible(false) 20 | setX(99999) 21 | setY(99999) 22 | } 23 | 24 | useEffect(() => { 25 | const contextMenu = globalVar.editor.contextMenu 26 | const adjustPos = (x: number, y: number, w: number, h: number) => { 27 | const vw = document.documentElement.clientWidth 28 | const vh = document.documentElement.clientHeight 29 | if (x + w > vw - PADDING_RIGHT) x -= w 30 | if (y + h > vh - PADDING_BOTTOM) y -= h 31 | return { x, y } 32 | } 33 | const showHandler = ({ x, y, items }: ShowEventOptions) => { 34 | console.log('show!!', x, y) 35 | 36 | 37 | const rect = contentRef.current.getBoundingClientRect(); 38 | ({ x, y } = adjustPos(x, y, rect.width, rect.height)) 39 | 40 | setX(x) 41 | setY(y) 42 | setVisible(true) 43 | setItems(items) 44 | } 45 | contextMenu.on('show', showHandler) 46 | contextMenu.on('hide', hideHandler) // 好像没啥用 47 | return () => { 48 | contextMenu.off('show', showHandler) 49 | contextMenu.off('hide', hideHandler) 50 | } 51 | }, []) 52 | 53 | return ( 54 | <> 55 |
61 |
62 |
63 | {items.map((item) => { 64 | return item.items.map((one) => { 65 | return ( 66 |
67 | {one.name} 68 |
69 | ) 70 | }) 71 | })} 72 |
73 |
74 | 75 | ) 76 | } 77 | 78 | export default ContexMenu 79 | -------------------------------------------------------------------------------- /src/views/EditorHeader/CmdBtnItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import styled from 'styled-components' 3 | 4 | const Div = styled.div` 5 | margin-right: 5px; 6 | padding: 0 4px; 7 | border: 1px solid #666; 8 | border-radius: 4px; 9 | height: 20px; 10 | line-height: 20px; 11 | text-align: center; 12 | font-size: 12px; 13 | color: #fff; 14 | background-color: #666; 15 | cursor: default; 16 | user-select: none; 17 | 18 | &:hover { 19 | background-color: #333; 20 | border-color: #322; 21 | } 22 | &.disabled { 23 | color: #999; 24 | border: 1px solid #555!important; 25 | background-color: #555!important; 26 | } 27 | ` 28 | 29 | type IProps = { 30 | label: string; 31 | disabled: boolean; 32 | onClick: () => void; 33 | } 34 | 35 | const CmdBtnItem: FC =(props) => { 36 | return ( 37 |
{ !props.disabled && props.onClick() }} 40 | > 41 | {props.label} 42 |
43 | ) 44 | } 45 | 46 | export default CmdBtnItem 47 | -------------------------------------------------------------------------------- /src/views/EditorHeader/CmdBtnList.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react' 2 | import globalVar from '../common/globalVar' 3 | import CmdBtnItem from './CmdBtnItem' 4 | 5 | const CmdBtnList: FC = () => { 6 | const [redoSize, setRedoSize] = useState(0) 7 | const [undoSize, setUndoSize] = useState(0) 8 | const items = [ 9 | { label: 'Undo', cb: () => { globalVar.editor.executeCommand('undo') } }, 10 | { label: 'Redo', cb: () => { globalVar.editor.executeCommand('redo') } }, 11 | { 12 | label: 'Delete', 13 | cb: () => { globalVar.editor.activedElsManager.isNoEmpty() && globalVar.editor.executeCommand('removeElements') }, 14 | }, 15 | { 16 | label: 'Front', 17 | cb: () => { globalVar.editor.activedElsManager.isNoEmpty() && globalVar.editor.executeCommand('front') }, 18 | }, 19 | { 20 | label: 'Forward', 21 | cb: () => { globalVar.editor.activedElsManager.isNoEmpty() && globalVar.editor.executeCommand('forward') }, 22 | }, 23 | { 24 | label: 'Backward', 25 | cb: () => { globalVar.editor.activedElsManager.isNoEmpty() && globalVar.editor.executeCommand('backward') }, 26 | }, 27 | { 28 | label: 'Back', 29 | cb: () => { globalVar.editor.activedElsManager.isNoEmpty() && globalVar.editor.executeCommand('back') }, 30 | }, 31 | { 32 | label: 'Export', 33 | cb: () => { globalVar.editor.export.downloadSVG('Untitled-1.svg') }, 34 | }, 35 | ] 36 | 37 | const execCmd = (index: number) => { 38 | items[index].cb() 39 | } 40 | 41 | const elements = items.map((item, index) => ( 42 | execCmd(index)} 50 | /> 51 | )) 52 | 53 | return ( 54 |
59 | {elements} 60 |
61 | ) 62 | } 63 | 64 | export default CmdBtnList 65 | -------------------------------------------------------------------------------- /src/views/EditorHeader/EditorHeader.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import styled from 'styled-components' 3 | import StrokeWidthSetting from '../ToolBar/StrokeWidthSetting' 4 | import CmdBtnList from './CmdBtnList' 5 | import Zoom from './Zoom' 6 | 7 | const StyledHeader = styled.div` 8 | position: relative; 9 | display: flex; 10 | align-items: center; 11 | padding-left: 100px; 12 | width: 100%; 13 | height: 30px; 14 | background-color: #555; 15 | box-shadow: 0 2px 3px rgba(20, 20, 20, .2); 16 | user-select: none; 17 | z-index: 34; 18 | ` 19 | 20 | const EditorHeader: FC = () =>{ 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | ) 28 | } 29 | 30 | export default EditorHeader 31 | -------------------------------------------------------------------------------- /src/views/EditorHeader/Zoom.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useState } from 'react' 2 | import globalVar from '../common/globalVar' 3 | 4 | const Zoom: FC = () => { 5 | const [zoom, setZoom] = useState('100') 6 | useEffect(() => { 7 | const editor = globalVar.editor 8 | 9 | editor.viewport.onZoomChange(zoom => { 10 | setZoom((zoom * 100).toFixed(2)) 11 | }) 12 | }) 13 | 14 | return ( 15 | <> 16 |
17 | zoom: 18 | {zoom}% 26 |
27 | 31 | 32 | 33 | ) 34 | } 35 | 36 | 37 | export default Zoom 38 | -------------------------------------------------------------------------------- /src/views/PanelsArea/HistoryPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useState } from 'react' 2 | import styled from 'styled-components' 3 | import globalVar from '../common/globalVar' 4 | 5 | const StyledContainer = styled.div` 6 | display: flex; 7 | flex-direction: column; 8 | padding: 4px; 9 | width: 100%; 10 | box-sizing: border-box; 11 | height: 100%; 12 | color: #fff; 13 | ` 14 | const StyledTitle = styled.div` 15 | border-bottom: 1px solid #999; 16 | ` 17 | const StyledTotal = styled.span` 18 | font-size: 12px; 19 | margin-left: 4px; 20 | 21 | ` 22 | const StyledList = styled.ul` 23 | flex: 1; 24 | margin-top: 5px; 25 | overflow: auto; 26 | ` 27 | const StyleItem = styled.li` 28 | line-height: 24px; 29 | font-size: 14px; 30 | color: #fff; 31 | text-indent: 4px; 32 | cursor: pointer; 33 | &.active { 34 | background-color: #666; 35 | } 36 | ` 37 | 38 | const HistoryPanel: FC = () => { 39 | const [items, setItems] = useState([]) 40 | const [currIndex, setCurrIndex] = useState(-1) 41 | const [total, setTotal] = useState(0) 42 | 43 | useEffect(() => { 44 | const cmdManager = globalVar.editor.commandManager 45 | cmdManager.on('change', (undos: string[], redos: string[]) => { 46 | setItems([...undos, ...redos]) 47 | setCurrIndex(undos.length - 1) 48 | setTotal(undos.length + redos.length) 49 | }) 50 | }, []) 51 | 52 | const changeCurrItem = (target: HTMLElement) => { 53 | if (!target.parentNode) return 54 | const index = Array.from(target.parentNode.children).indexOf(target) 55 | 56 | const cmdManager = globalVar.editor.commandManager 57 | if (index === currIndex) return 58 | cmdManager.go(index - currIndex) 59 | 60 | setCurrIndex(index) 61 | } 62 | 63 | 64 | return ( 65 | 66 | 67 | History 68 | {total} 69 | 70 | changeCurrItem(e.target as HTMLElement) }> 71 | { 72 | items.map((item, index) => { 73 | return ( 74 | {item} 75 | ) 76 | }) 77 | } 78 | 79 | 80 | ) 81 | } 82 | 83 | export default HistoryPanel 84 | -------------------------------------------------------------------------------- /src/views/PanelsArea/InfoPanel.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | 3 | const InfoPanel: FC =() => { 4 | return ( 5 |
element info
6 | ) 7 | } 8 | 9 | export default InfoPanel 10 | -------------------------------------------------------------------------------- /src/views/PanelsArea/PanelsArea.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import HistoryPanel from './HistoryPanel' 3 | 4 | const PanelsArea: FC = () => { 5 | return ( 6 |
12 | 13 |
14 | ) 15 | } 16 | 17 | export default PanelsArea 18 | -------------------------------------------------------------------------------- /src/views/ToolBar/FillAndStrokeSelector.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react' 2 | import config from '../../config/editorDefaultConfig' 3 | import globalVar from '../common/globalVar' 4 | import ColorPicker from './components/ColorPicker' 5 | 6 | const FillAndStrokeSelector = () => { 7 | const [fill, setFill] = useState(config.fill) 8 | const [stroke, setStroke] = useState(config.stroke) 9 | const [openFill, setOpenFill] = useState(false) 10 | const [openStroke, setOpenStroke] = useState(false) 11 | const [pickerFill, setPickerFill] = useState('#fff') 12 | const [pickerStroke, setPickerStroke] = useState('#fff') 13 | 14 | const fillRef = useRef(null) 15 | const strokeRef = useRef(null) 16 | 17 | useEffect(() => { 18 | const editor = globalVar.editor 19 | 20 | const _setFill = (fill: string) => { 21 | setFill(fill) 22 | setPickerFill(fill) 23 | } 24 | const _setStroke = (stroke: string) => { 25 | setStroke(stroke) 26 | setPickerStroke(stroke) 27 | } 28 | 29 | editor.setting.on('fill', _setFill) 30 | editor.setting.on('stroke', _setStroke) 31 | return () => { 32 | editor.setting.off('fill', _setFill) 33 | editor.setting.off('stroke', _setStroke) 34 | } 35 | }, []) 36 | 37 | 38 | const changeFill = (hex: string) => { 39 | setOpenFill(false) 40 | const editor = globalVar.editor 41 | editor.setting.setFill(hex) 42 | editor.activedElsManager.setElsAttr('fill', hex) 43 | } 44 | const changeStroke = (hex: string) => { 45 | setOpenStroke(false) 46 | const editor = globalVar.editor 47 | editor.setting.setStroke(hex) 48 | editor.activedElsManager.setElsAttr('stroke', hex) 49 | } 50 | 51 | const Fill = ( 52 |
{ setOpenFill(true) }} 63 | /> 64 | ) 65 | 66 | const Stroke = ( 67 |
{ setOpenStroke(true) }} 78 | /> 79 | ) 80 | 81 | 82 | return ( 83 |
88 | {Fill} 89 | {Stroke} 90 | { setPickerFill(color) } } 96 | onAccept={(color: string) => changeFill(color) } 97 | onCancel={() => { setOpenFill(false) } } 98 | /> 99 | 100 | { setPickerStroke(color) } } 106 | onAccept={(color: string) => changeStroke(color) } 107 | onCancel={() => { setOpenStroke(false) } } 108 | /> 109 |
110 | ) 111 | } 112 | 113 | export default FillAndStrokeSelector 114 | -------------------------------------------------------------------------------- /src/views/ToolBar/ShortcutBtn.tsx: -------------------------------------------------------------------------------- 1 | 2 | // 9 | // Profile 10 | // My account 11 | // Logout 12 | // 13 | 14 | import React, { FC, useState } from 'react' 15 | import Button from '@material-ui/core/Button' 16 | import Dialog from '@material-ui/core/Dialog' 17 | import DialogActions from '@material-ui/core/DialogActions' 18 | import DialogContent from '@material-ui/core/DialogContent' 19 | import DialogTitle from '@material-ui/core/DialogTitle' 20 | import styled from 'styled-components' 21 | 22 | 23 | const ShortcutBtn: FC = () => { 24 | const [open, setOpen] = useState(false) 25 | 26 | const handleClose = () => { 27 | setOpen(false) 28 | } 29 | 30 | 31 | const StyledColumn = styled.div` 32 | border-bottom: 1px solid #eee; 33 | width: 300px; 34 | height: 36px; 35 | line-height: 36px; 36 | font-size: 14px; 37 | overflow: hidden; 38 | backgroundColor: #555; 39 | &:last-child { 40 | border-bottom: none; 41 | } 42 | ` 43 | const StyledLeftItem = styled.span` 44 | float: left; 45 | font-weight: 500; 46 | ` 47 | const StyledRightItem = styled.span` 48 | float: right; 49 | ` 50 | const StyledKbd = styled.kbd` 51 | background: linear-gradient(180deg, #eee, #fff); 52 | background-color: #eee; 53 | border: 1px solid #cdd5d7; 54 | border-radius: 4px; 55 | box-shadow: 0 1px 2px 1px #cdd5d7; 56 | font-family: consolas,"Liberation Mono",courier,monospace; 57 | font-size: 14px; 58 | font-weight: 700; 59 | line-height: 1; 60 | margin: 3px; 61 | padding: 3px 6px; 62 | white-space: nowrap; 63 | ` 64 | 65 | const ShortcutList = [ 66 | { 67 | command: 'Undo', 68 | KeyBinding: <>Ctrl/Cmd + Z, 69 | }, 70 | { 71 | command: 'Redo', 72 | KeyBinding: <>Ctrl/Cmd + Shift + Y, 73 | }, 74 | { 75 | command: 'Delete Elements', 76 | KeyBinding: <>Backspace, 77 | }, 78 | ].map(item => { 79 | return ( 80 | 81 | { item.command } 82 | 83 | {item.KeyBinding} 84 | 85 | 86 | ) 87 | }) 88 | 89 | return ( 90 | <> 91 |
{ setOpen(true) }} 104 | > 105 | Keyboard 106 |
107 | { handleClose() }} 110 | aria-labelledby="alert-dialog-title" 111 | aria-describedby="alert-dialog-description" 112 | > 113 | {'Keyboard Shortcuts'} 114 | 115 | {ShortcutList} 116 | 117 | 118 | 121 | 122 | 123 | 124 | ) 125 | 126 | } 127 | 128 | export default ShortcutBtn 129 | -------------------------------------------------------------------------------- /src/views/ToolBar/StrokeWidthSetting.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect, useState } from 'react' 2 | import styled from 'styled-components' 3 | import globalVar from '../common/globalVar' 4 | import editorDefaultConfig from '../../config/editorDefaultConfig' 5 | 6 | const StyleDiv = styled.div` 7 | padding-right: 30px; 8 | ` 9 | const StyledLabel = styled.label` 10 | padding-right: 8px; 11 | font-size: 12px; 12 | color: #fff; 13 | ` 14 | const StyledInput = styled.input` 15 | padding: 0 4px; 16 | width: 40px; 17 | height: 16px; 18 | line-height: 16px; 19 | outline: none; 20 | ` 21 | const StrokeWidthSetting: FC = () => { 22 | const [strokeWidth, setStrokeWidth] = useState(parseFloat(editorDefaultConfig.strokeWidth)) 23 | 24 | useEffect(() => { 25 | globalVar.editor.setting.on('stroke-width', val => { 26 | const num = parseFloat(val) 27 | setStrokeWidth(num) 28 | }) 29 | // TODO: off event binding when component destroy. 30 | }, []) 31 | 32 | const handleChange = (e: React.ChangeEvent) => { 33 | let num = parseFloat(e.target.value) 34 | if (isNaN(num)) { 35 | num = 1 36 | } 37 | setStrokeWidth(num) 38 | } 39 | const handleBlur = () => { 40 | const val = strokeWidth + 'px' 41 | globalVar.editor.setting.setStrokeWidth(val) 42 | globalVar.editor.activedElsManager.setElsAttr('stroke-width', val) 43 | } 44 | const handleKeyUp = (e: React.KeyboardEvent) => { 45 | if (e.key === 'Enter') { 46 | (e.target as HTMLInputElement).blur() 47 | } 48 | } 49 | 50 | return ( 51 | 52 | Stroke-Width 53 | handleChange(e)} 59 | onKeyDown={e => e.stopPropagation()} 60 | onKeyUp={e => handleKeyUp(e)} 61 | onBlur={() => handleBlur()} 62 | /> 63 | 64 | ) 65 | } 66 | 67 | export default StrokeWidthSetting 68 | -------------------------------------------------------------------------------- /src/views/ToolBar/ToolBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react' 2 | import styled from 'styled-components' 3 | import ToolItem from './ToolItem' 4 | import defaultConfig from '../../config/editorDefaultConfig' 5 | import globalVar from '../common/globalVar' 6 | import FillAndStrokeSelector from './FillAndStrokeSelector' 7 | import ShortcutBtn from './ShortcutBtn' 8 | 9 | const ToolBar: FC = () => { 10 | const [tool, setTool] = useState(defaultConfig.tool) 11 | 12 | const switchEditorTool = (toolName: string) => { 13 | globalVar.editor.setCurrentTool(toolName) 14 | setTool(toolName) 15 | } 16 | 17 | const StyleToolBar = styled.div` 18 | padding-top: 6px; 19 | background-color: #555; 20 | ` 21 | 22 | const toolItems = [ 23 | { name: 'Select', iconName: 'icon-select', cmdName: 'select' }, 24 | { name: 'Rect', iconName: 'icon-rect', cmdName: 'addRect' }, 25 | { name: 'Pencil', iconName: 'icon-pencil', cmdName: 'pencil' }, 26 | { name: 'Pen', iconName: 'icon-pen', cmdName: 'pen' }, 27 | { name: 'Drag Canvas', iconName: 'icon-pan', cmdName: 'dragCanvas' }, 28 | { name: 'Zoom', iconName: 'icon-zoom', cmdName: 'zoom' }, 29 | ] 30 | 31 | const items = toolItems.map(item => { 32 | return ( 33 | switchEditorTool(v)} 40 | /> 41 | ) 42 | }) 43 | 44 | return ( 45 |
52 | 53 | {items} 54 | 55 | 56 | 57 | 58 |
59 | ) 60 | } 61 | 62 | export default ToolBar 63 | -------------------------------------------------------------------------------- /src/views/ToolBar/ToolItem.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import styled from 'styled-components' 3 | import Popover from '@material-ui/core/Popover' 4 | import { makeStyles, createStyles } from '@material-ui/core/styles' 5 | 6 | const Div = styled.div` 7 | margin: 0 auto 5px auto; 8 | border: 1px solid #666; 9 | border-radius: 4px; 10 | width: 40px; 11 | height: 40px; 12 | line-height: 40px; 13 | text-align: center; 14 | 15 | color: #fff; 16 | background-color: #666; 17 | cursor: default; 18 | user-select: none; 19 | 20 | i { 21 | font-size: 24px; 22 | } 23 | 24 | &:hover, &.active { 25 | background-color: #333; 26 | border-color: #322; 27 | } 28 | ` 29 | 30 | type Props ={ 31 | name: string, 32 | cmdName: string, 33 | iconName: string, 34 | currentTool: string, 35 | onClick: (val: string) => void 36 | } 37 | 38 | const useStyles = makeStyles(() => 39 | createStyles({ 40 | popover: { 41 | pointerEvents: 'none', 42 | }, 43 | }), 44 | ) 45 | 46 | const ToolItem: FC = (props) => { 47 | const classes = useStyles() 48 | const [anchorEl, setAnchorEl] = React.useState(null) 49 | 50 | const handlePopoverOpen = (event: React.MouseEvent) => { 51 | setAnchorEl(event.currentTarget) 52 | } 53 | 54 | const handlePopoverClose = () => { 55 | setAnchorEl(null) 56 | } 57 | 58 | const open = Boolean(anchorEl) 59 | 60 | return ( 61 |
{ props.onClick(props.cmdName) }} 64 | onMouseEnter={handlePopoverOpen} 65 | onMouseLeave={handlePopoverClose} 66 | > 67 | 68 | 83 |
{props.name}
91 |
92 |
93 | ) 94 | } 95 | 96 | export default ToolItem 97 | -------------------------------------------------------------------------------- /src/views/ToolBar/components/ColorPicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react' 2 | import { PhotoshopPicker } from 'react-color' 3 | import Popover from '@material-ui/core/Popover' 4 | 5 | type IProps = { 6 | open: boolean, 7 | anchorEl: Element, 8 | color: string, 9 | pickerColor: string 10 | onChange: (color: string) => void 11 | onAccept: (color: string) => void 12 | onCancel: () => void 13 | } 14 | 15 | 16 | const ColorPicker: FC = (props) => { 17 | 18 | 19 | return ( 20 | props.onCancel()} 24 | anchorOrigin={{ 25 | vertical: 'center', 26 | horizontal: 'right', 27 | }} 28 | transformOrigin={{ 29 | vertical: 'center', 30 | horizontal: -15, 31 | }} 32 | > 33 |
34 | props.onChange(color.hex)} 37 | onAccept={() => props.onAccept(props.pickerColor)} 38 | onCancel={() => props.onCancel()} 39 | /> 40 |
41 |
42 | ) 43 | } 44 | 45 | export default ColorPicker 46 | -------------------------------------------------------------------------------- /src/views/common/globalVar.ts: -------------------------------------------------------------------------------- 1 | import Editor from '../../Editor' 2 | 3 | const editor: Editor = null 4 | 5 | const globalVar = { 6 | editor 7 | } 8 | 9 | export default globalVar 10 | -------------------------------------------------------------------------------- /src/views/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { initEditor } from '../app' 4 | import '../assets/css/iconfont/iconfont.css' 5 | import App from './App' 6 | import globalVar from './common/globalVar' 7 | 8 | globalVar.editor = initEditor() 9 | 10 | ReactDOM.render( 11 | <> 12 | 13 | , 14 | document.getElementById('root') 15 | ) 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./compile", 4 | "noImplicitAny": true, 5 | "sourceMap": true, 6 | "module": "es2020", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 7 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "allowJs": true, /* Allow javascript files to be compiled. */ 9 | 10 | "allowSyntheticDefaultImports": true, 11 | "jsx": "react", 12 | // "strict": true, /* Enable all strict type-checking options. */ 13 | // "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 14 | 15 | // "skipLibCheck": true, /* Skip type checking of declaration files. */ 16 | // "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 17 | }, 18 | "include": ["src/*"], 19 | "exclude": ["node_modules"], 20 | } 21 | 22 | // ts learn 23 | // https://medium.com/jspoint/typescript-compilation-the-typescript-compiler-4cb15f7244bc 24 | // https://medium.com/jspoint/integrating-typescript-with-webpack-4534e840a02b -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | /* eslint-env node */ 4 | 5 | const path = require('path') 6 | const HtmlWebpackPlugin = require('html-webpack-plugin') 7 | const { CleanWebpackPlugin } = require('clean-webpack-plugin') 8 | const TerserPlugin = require('terser-webpack-plugin') 9 | const MiniCssExtractPlugin = require('mini-css-extract-plugin') 10 | const webpack = require('webpack') 11 | 12 | function createPlugins(env) { 13 | let plugins = [ 14 | new webpack.DefinePlugin({ 15 | __DEV__: !env.prod, 16 | }), 17 | ] 18 | 19 | if (env.prod) { 20 | plugins = [ 21 | ...plugins, 22 | new CleanWebpackPlugin(), 23 | new HtmlWebpackPlugin({ template: 'src/index.html' }), 24 | new MiniCssExtractPlugin({ 25 | filename: '[name].[contenthash:8].css', 26 | }), 27 | ] 28 | } else { 29 | plugins = [ 30 | ...plugins, 31 | new HtmlWebpackPlugin({ template: 'src/index.html' }), 32 | ] 33 | } 34 | return plugins 35 | } 36 | 37 | module.exports = (env) => { 38 | // use production config when `env.prod` is true 39 | console.log('env:', env.prod ? 'production' : 'development') 40 | 41 | const config = { 42 | mode: env.prod ? 'production' : 'development', 43 | // entry: './src/app.ts', 44 | entry: './src/views/index.tsx', 45 | output: { 46 | filename: 'app.[contenthash:8].js', 47 | path: path.resolve(__dirname, 'dist'), 48 | }, 49 | 50 | devtool: env.prod ? false : 'inline-source-map', 51 | devServer: { 52 | contentBase: './dist', 53 | }, 54 | module: { 55 | rules: [ 56 | { 57 | test: /\.tsx?$/, 58 | use: 'ts-loader', 59 | exclude: /node_modules/, 60 | }, 61 | { 62 | test: /\.jsx?$/, 63 | exclude: /node_modules/, 64 | use: { 65 | loader: 'babel-loader', 66 | options: { 67 | presets: [ 68 | ['@babel/preset-env', { targets: 'defaults' }], 69 | '@babel/preset-react', 70 | ], 71 | }, 72 | }, 73 | }, 74 | { 75 | test: /\.css$/, 76 | use: [ 77 | env.prod 78 | ? { 79 | loader: MiniCssExtractPlugin.loader, 80 | // fix issue: https://stackoverflow.com/questions/64294706/webpack5-automatic-publicpath-is-not-supported-in-this-browser 81 | options: { publicPath: '' }, 82 | } 83 | : 'style-loader', 84 | 'css-loader', 85 | 'postcss-loader', 86 | ], 87 | }, 88 | { 89 | test: /\.less$/, 90 | exclude: /node_modules/, 91 | use: [ 92 | env.prod 93 | ? { 94 | loader: MiniCssExtractPlugin.loader, 95 | // fix issue: https://stackoverflow.com/questions/64294706/webpack5-automatic-publicpath-is-not-supported-in-this-browser 96 | options: { publicPath: '' }, 97 | } 98 | : 'style-loader', 99 | 'css-loader', 100 | 'postcss-loader', 101 | 'less-loader', 102 | ], 103 | }, 104 | { 105 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 106 | type: 'asset/resource', 107 | }, 108 | { 109 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 110 | type: 'asset/resource', 111 | }, 112 | ], 113 | }, 114 | // TODO: how does it work 115 | resolve: { 116 | extensions: ['.ts', '.tsx', '.js', 'jsx'], 117 | }, 118 | plugins: createPlugins(env), 119 | optimization: env.prod 120 | ? { 121 | minimize: true, 122 | minimizer: [new TerserPlugin()], 123 | } 124 | : {}, 125 | } 126 | 127 | return config 128 | } 129 | --------------------------------------------------------------------------------