├── mock └── .gitkeep ├── src ├── services │ ├── index.ts │ ├── user.ts │ └── photo.ts ├── pages │ ├── index.less │ ├── .umi │ │ ├── polyfills.js │ │ ├── initHistory.js │ │ ├── router.js │ │ └── umi.js │ ├── index.tsx │ ├── demo.tsx │ └── login │ │ ├── login.less │ │ └── index.tsx ├── utils │ ├── index.ts │ ├── request.ts │ ├── Keyboard.ts │ ├── local-storage │ │ ├── index.md │ │ └── index.ts │ ├── util.ts │ └── line.ts ├── assets │ └── images │ │ ├── 3.jpg │ │ └── 4.jpeg ├── enum.ts ├── styles │ ├── index.less │ └── theme │ │ └── default.less ├── canvas-components │ ├── layout │ │ ├── slider-left │ │ │ ├── text-panel │ │ │ │ ├── textPanel.less │ │ │ │ └── index.tsx │ │ │ ├── template-panel │ │ │ │ ├── template.less │ │ │ │ └── index.tsx │ │ │ ├── bg-panel │ │ │ │ ├── bgPanel.less │ │ │ │ └── index.tsx │ │ │ ├── image-panel │ │ │ │ ├── imagePanel.less │ │ │ │ └── index.tsx │ │ │ ├── slider.less │ │ │ └── index.tsx │ │ ├── layout.less │ │ ├── content │ │ │ ├── content.less │ │ │ └── index.tsx │ │ ├── slider-right │ │ │ ├── slider.less │ │ │ └── index.tsx │ │ ├── layout.tsx │ │ ├── header │ │ │ ├── header.less │ │ │ └── index.tsx │ │ └── index.tsx │ ├── index.tsx │ ├── shape-panel │ │ ├── index.tsx │ │ ├── panel-title │ │ │ ├── index.tsx │ │ │ └── panelTitle.less │ │ ├── text-panel │ │ │ ├── textPanel.less │ │ │ └── index.tsx │ │ ├── image-panel │ │ │ ├── imagePanel.less │ │ │ └── index.tsx │ │ └── canvas-panel │ │ │ ├── canvasPanel.less │ │ │ └── index.tsx │ ├── canvas │ │ ├── canvas.less │ │ ├── index.tsx │ │ └── index copy.tsx │ └── transformer-wrapper │ │ ├── groupTransformer.tsx │ │ └── index.tsx ├── bomponents │ ├── index.ts │ ├── file-modal │ │ ├── index.less │ │ └── index.tsx │ ├── uitls │ │ └── modelViewUtils.tsx │ └── upload │ │ └── index.tsx ├── core │ ├── shape │ │ ├── index.ts │ │ ├── image │ │ │ └── Image.ts │ │ ├── transformer │ │ │ └── index.ts │ │ ├── stage │ │ │ └── index.ts │ │ └── text │ │ │ └── index.ts │ ├── utils │ │ ├── util.ts │ │ ├── group.ts │ │ └── line.ts │ ├── context-menu │ │ └── index.ts │ └── Canvas.ts ├── components │ ├── toolbar │ │ ├── toolbar.less │ │ └── index.tsx │ ├── index.tsx │ ├── waterfall-flow │ │ ├── waterfallFlow.less │ │ ├── image.tsx │ │ └── index.tsx │ ├── infinite-scroll-list │ │ ├── index.less │ │ └── index.tsx │ ├── color-select │ │ ├── colorSelect.less │ │ └── index.tsx │ └── image │ │ └── index.tsx ├── hooks │ ├── useAuth.ts │ ├── useToken.ts │ └── useImage.tsx ├── models │ ├── useCanvasModel.tsx │ └── useCanvasDataModel.tsx ├── app.ts ├── global.css ├── typing.ts └── models1 │ ├── canvasModel.ts │ └── canvasDataModel.ts ├── public ├── code.jpg ├── bg1 │ ├── 1.jpeg │ ├── 10.jpeg │ ├── 2.jpeg │ ├── 3.jpeg │ ├── 4.jpeg │ ├── 5.jpeg │ ├── 6.jpeg │ ├── 7.jpeg │ ├── 8.jpeg │ └── 9.jpeg ├── bg2 │ ├── 1.jpeg │ ├── 10.jpeg │ ├── 2.jpeg │ ├── 3.jpeg │ ├── 4.jpeg │ ├── 5.jpeg │ ├── 6.jpeg │ ├── 7.jpeg │ ├── 8.jpeg │ └── 9.jpeg ├── psd │ └── 1.psd ├── thumb │ ├── 1.png │ ├── 2.png │ ├── 3.png │ └── 12323.png ├── image1 │ ├── 1.jpg │ ├── 10.jpg │ ├── 11.jpg │ ├── 12.jpg │ ├── 13.jpg │ ├── 14.png │ ├── 2.jpg │ ├── 3.jpg │ ├── 4.jpg │ ├── 5.jpg │ ├── 6.jpg │ ├── 7.jpg │ ├── 8.jpg │ ├── 9.jpg │ ├── 15.jpeg │ └── 画布图像.png └── image2 │ ├── 1.jpg │ ├── 10.jpg │ ├── 11.jpg │ ├── 12.jpg │ ├── 2.jpg │ ├── 3.jpg │ ├── 4.jpg │ ├── 5.jpg │ ├── 6.jpg │ ├── 7.jpg │ ├── 8.jpg │ ├── 9.jpg │ ├── 13.jpeg │ └── 14.jpeg ├── .prettierignore ├── deploy.sh ├── .prettierrc ├── typings.d.ts ├── .editorconfig ├── .gitignore ├── .umirc.ts ├── tsconfig.json ├── package.json └── README.md /mock/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/index.less: -------------------------------------------------------------------------------- 1 | #root { 2 | height: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /src/pages/.umi/polyfills.js: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill'; 2 | 3 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as LocalStorage } from './local-storage'; 2 | -------------------------------------------------------------------------------- /public/code.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/code.jpg -------------------------------------------------------------------------------- /public/bg1/1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/1.jpeg -------------------------------------------------------------------------------- /public/bg1/10.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/10.jpeg -------------------------------------------------------------------------------- /public/bg1/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/2.jpeg -------------------------------------------------------------------------------- /public/bg1/3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/3.jpeg -------------------------------------------------------------------------------- /public/bg1/4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/4.jpeg -------------------------------------------------------------------------------- /public/bg1/5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/5.jpeg -------------------------------------------------------------------------------- /public/bg1/6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/6.jpeg -------------------------------------------------------------------------------- /public/bg1/7.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/7.jpeg -------------------------------------------------------------------------------- /public/bg1/8.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/8.jpeg -------------------------------------------------------------------------------- /public/bg1/9.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg1/9.jpeg -------------------------------------------------------------------------------- /public/bg2/1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/1.jpeg -------------------------------------------------------------------------------- /public/bg2/10.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/10.jpeg -------------------------------------------------------------------------------- /public/bg2/2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/2.jpeg -------------------------------------------------------------------------------- /public/bg2/3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/3.jpeg -------------------------------------------------------------------------------- /public/bg2/4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/4.jpeg -------------------------------------------------------------------------------- /public/bg2/5.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/5.jpeg -------------------------------------------------------------------------------- /public/bg2/6.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/6.jpeg -------------------------------------------------------------------------------- /public/bg2/7.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/7.jpeg -------------------------------------------------------------------------------- /public/bg2/8.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/8.jpeg -------------------------------------------------------------------------------- /public/bg2/9.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/bg2/9.jpeg -------------------------------------------------------------------------------- /public/psd/1.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/psd/1.psd -------------------------------------------------------------------------------- /public/thumb/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/thumb/1.png -------------------------------------------------------------------------------- /public/thumb/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/thumb/2.png -------------------------------------------------------------------------------- /public/thumb/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/thumb/3.png -------------------------------------------------------------------------------- /public/image1/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/1.jpg -------------------------------------------------------------------------------- /public/image1/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/10.jpg -------------------------------------------------------------------------------- /public/image1/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/11.jpg -------------------------------------------------------------------------------- /public/image1/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/12.jpg -------------------------------------------------------------------------------- /public/image1/13.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/13.jpg -------------------------------------------------------------------------------- /public/image1/14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/14.png -------------------------------------------------------------------------------- /public/image1/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/2.jpg -------------------------------------------------------------------------------- /public/image1/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/3.jpg -------------------------------------------------------------------------------- /public/image1/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/4.jpg -------------------------------------------------------------------------------- /public/image1/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/5.jpg -------------------------------------------------------------------------------- /public/image1/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/6.jpg -------------------------------------------------------------------------------- /public/image1/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/7.jpg -------------------------------------------------------------------------------- /public/image1/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/8.jpg -------------------------------------------------------------------------------- /public/image1/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/9.jpg -------------------------------------------------------------------------------- /public/image2/1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/1.jpg -------------------------------------------------------------------------------- /public/image2/10.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/10.jpg -------------------------------------------------------------------------------- /public/image2/11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/11.jpg -------------------------------------------------------------------------------- /public/image2/12.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/12.jpg -------------------------------------------------------------------------------- /public/image2/2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/2.jpg -------------------------------------------------------------------------------- /public/image2/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/3.jpg -------------------------------------------------------------------------------- /public/image2/4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/4.jpg -------------------------------------------------------------------------------- /public/image2/5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/5.jpg -------------------------------------------------------------------------------- /public/image2/6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/6.jpg -------------------------------------------------------------------------------- /public/image2/7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/7.jpg -------------------------------------------------------------------------------- /public/image2/8.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/8.jpg -------------------------------------------------------------------------------- /public/image2/9.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/9.jpg -------------------------------------------------------------------------------- /public/image1/15.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/15.jpeg -------------------------------------------------------------------------------- /public/image1/画布图像.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image1/画布图像.png -------------------------------------------------------------------------------- /public/image2/13.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/13.jpeg -------------------------------------------------------------------------------- /public/image2/14.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/image2/14.jpeg -------------------------------------------------------------------------------- /public/thumb/12323.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/public/thumb/12323.png -------------------------------------------------------------------------------- /src/assets/images/3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/src/assets/images/3.jpg -------------------------------------------------------------------------------- /src/enum.ts: -------------------------------------------------------------------------------- 1 | export enum ShapePanelEnum { 2 | ShapePanel = 1, 3 | TextPanel, 4 | ImagePanel, 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd/lib/style/themes/default.less'; 2 | @import './theme/default.less'; 3 | -------------------------------------------------------------------------------- /src/assets/images/4.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wizard-a/fast-image-editor/HEAD/src/assets/images/4.jpeg -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | **/*.ejs 4 | **/*.html 5 | package.json 6 | .umi 7 | .umi-production 8 | .umi-test 9 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/text-panel/textPanel.less: -------------------------------------------------------------------------------- 1 | .panel { 2 | width: 100%; 3 | padding: 10px; 4 | } 5 | -------------------------------------------------------------------------------- /src/canvas-components/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Canvas } from './canvas'; 2 | export { default as Layout } from './layout/index'; 3 | -------------------------------------------------------------------------------- /src/bomponents/index.ts: -------------------------------------------------------------------------------- 1 | export { default as FileModal } from './file-modal/index'; 2 | export { default as Upload } from './upload/index'; 3 | -------------------------------------------------------------------------------- /src/canvas-components/layout/layout.less: -------------------------------------------------------------------------------- 1 | .layout { 2 | display: flex; 3 | width: 100%; 4 | height: 100%; 5 | flex-direction: column; 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/.umi/initHistory.js: -------------------------------------------------------------------------------- 1 | // create history 2 | window.g_history = require('umi/_createHistory').default({ 3 | basename: window.routerBase, 4 | }); 5 | -------------------------------------------------------------------------------- /src/canvas-components/layout/content/content.less: -------------------------------------------------------------------------------- 1 | .content { 2 | display: flex; 3 | flex: 1; 4 | // background: #ccc; 5 | height: 0; 6 | // display: flex; 7 | } 8 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 编译任务界面 3 | echo 'start build' 4 | yarn build 5 | echo 'build success' 6 | scp -r dist/* root@39.97.252.98:/data/fast-image-editor 7 | echo 'deploy success' 8 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-right/slider.less: -------------------------------------------------------------------------------- 1 | .slider { 2 | height: 100%; 3 | width: 275px; 4 | background: rgba(16, 38, 58, 0.02); 5 | border-left: 1px solid rgba(16, 38, 58, 0.15); 6 | } 7 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as CanvasPanel } from './canvas-panel'; 2 | export { default as TextPanel } from './text-panel'; 3 | export { default as ImagePanel } from './image-panel'; 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "printWidth": 80, 5 | "overrides": [ 6 | { 7 | "files": ".prettierrc", 8 | "options": { "parser": "json" } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/core/shape/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Stage } from './stage/index'; 2 | export { default as Image } from './image/Image'; 3 | export { default as Text } from './text/index'; 4 | export { default as Transformer } from './transformer/index'; 5 | -------------------------------------------------------------------------------- /src/canvas-components/canvas/canvas.less: -------------------------------------------------------------------------------- 1 | .canvas { 2 | flex: 1; 3 | height: 100%; 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | flex-direction: column; 8 | overflow: auto; 9 | position: relative; 10 | padding: 60px; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/toolbar/toolbar.less: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | position: fixed; 3 | right: 332px; 4 | z-index: 1; 5 | top: 78px; 6 | display: flex; 7 | align-items: center; 8 | .text { 9 | padding: 0 10px; 10 | // color: rgba(16, 38, 58, 0.25); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import React, { Component, useState, useEffect } from 'react'; 2 | import { LocalStorage } from '@/utils/index'; 3 | 4 | const useAuth = () => { 5 | let token = LocalStorage.get('token'); 6 | return Boolean(token); 7 | }; 8 | 9 | export default useAuth; 10 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/template-panel/template.less: -------------------------------------------------------------------------------- 1 | .template { 2 | width: 100%; 3 | 4 | padding: 20px; 5 | img { 6 | margin-bottom: 10px; 7 | width: 100%; 8 | border-radius: 15px; 9 | // height: 100px; 10 | cursor: pointer; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/services/user.ts: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | 3 | export async function login(userName: string, pwd: string) { 4 | return request('/api/user/login', { 5 | data: { 6 | login_name: userName, 7 | pwd: pwd, 8 | }, 9 | method: 'POST', 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | declare module '*.png'; 4 | declare module '*.svg' { 5 | export function ReactComponent( 6 | props: React.SVGProps, 7 | ): React.ReactElement; 8 | const url: string; 9 | export default url; 10 | } 11 | -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as ColorSelect } from './color-select'; 2 | export { default as Image } from './image'; 3 | export { default as Toolbar } from './toolbar'; 4 | export { default as InfiniteScrollList } from './infinite-scroll-list'; 5 | export { default as WaterfallFlow } from './waterfall-flow'; 6 | -------------------------------------------------------------------------------- /src/canvas-components/layout/layout.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import styles from './layout.less'; 3 | 4 | export interface ILayoutProps {} 5 | 6 | const Layout: FC = (props) => { 7 | return
{props.children}
; 8 | }; 9 | 10 | export default Layout; 11 | -------------------------------------------------------------------------------- /src/components/waterfall-flow/waterfallFlow.less: -------------------------------------------------------------------------------- 1 | .container { 2 | // min-height: 500px; 3 | // height: 100%; 4 | // height: 500px; 5 | position: relative; 6 | .row { 7 | margin-top: 10px; 8 | } 9 | .img { 10 | border-radius: 10px; 11 | margin-left: 10px; 12 | cursor: pointer; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import { request } from 'umi'; 2 | 3 | async function MyRequest(url: string, options: any) { 4 | const result = await request(url, options); 5 | console.log('result', result); 6 | if (result) { 7 | return result.data; 8 | } 9 | return null; 10 | } 11 | 12 | export default MyRequest; 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /src/canvas-components/layout/content/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import styles from './content.less'; 3 | 4 | export interface IHeaderProps {} 5 | 6 | const Content: FC = (props) => { 7 | return
{props.children}
; 8 | }; 9 | 10 | export default Content; 11 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/panel-title/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import styles from './panelTitle.less'; 3 | 4 | export interface IPanelTitleProps {} 5 | 6 | const PanelTitle: FC = (props) => { 7 | return
{props.children}
; 8 | }; 9 | 10 | export default PanelTitle; 11 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/panel-title/panelTitle.less: -------------------------------------------------------------------------------- 1 | .title { 2 | height: 40px; 3 | // box-shadow: 0px -1px 0px 0px rgba(16, 38, 58, 0.15); 4 | border-bottom: 1px solid rgba(16, 38, 58, 0.15); 5 | font-size: 14px; 6 | font-weight: 500; 7 | color: rgba(16, 38, 58, 0.85); 8 | display: flex; 9 | align-items: center; 10 | padding: 0 16px; 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | 16 | # umi 17 | /src/.umi 18 | /src/.umi-production 19 | /src/.umi-test 20 | /.env.local 21 | -------------------------------------------------------------------------------- /src/bomponents/file-modal/index.less: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | .fileList { 5 | justify-content: space-around; 6 | padding: 0 !important; 7 | margin-top: 10px; 8 | .box { 9 | width: 128px; 10 | // height: 128px; 11 | // line-height: 128px; 12 | text-align: center; 13 | background: #f5f5f5; 14 | margin-bottom: 15px; 15 | } 16 | } 17 | 18 | .img { 19 | width: 128px; 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/Keyboard.ts: -------------------------------------------------------------------------------- 1 | class Keyboard {} 2 | 3 | export const shiftAndClick = () => { 4 | document.onmousedown = (e: MouseEvent) => { 5 | // console.log('ddd') 6 | console.log('e.shiftKey', e); 7 | if (e.shiftKey) { 8 | // 鼠标左键按下,同时也按下了shift 9 | } 10 | }; 11 | // document.onkeydown = (e: KeyboardEvent) => { 12 | // console.log('e', e.keyCode); 13 | // } 14 | }; 15 | -------------------------------------------------------------------------------- /src/hooks/useToken.ts: -------------------------------------------------------------------------------- 1 | import React, { Component, useState, useEffect } from 'react'; 2 | import { LocalStorage } from '@/utils/index'; 3 | import { getToken } from '@/utils/util'; 4 | 5 | const useToken = () => { 6 | const [token, setToken] = useState(); 7 | useEffect(() => { 8 | let tokenStr = getToken(); 9 | setToken(tokenStr); 10 | }, []); 11 | 12 | return token; 13 | }; 14 | 15 | export default useToken; 16 | -------------------------------------------------------------------------------- /src/components/infinite-scroll-list/index.less: -------------------------------------------------------------------------------- 1 | 2 | :global { 3 | .infinite-scroll-component__outerdiv { 4 | height: 100%; 5 | } 6 | } 7 | .list { 8 | // display: flex; 9 | // flex-wrap: wrap; 10 | // max-height: 400px; 11 | } 12 | 13 | .loading { 14 | width: 100%; 15 | text-align: center; 16 | margin-top: 10px; 17 | } 18 | .loadingSuccess { 19 | text-align: center; 20 | margin: 10px 0; 21 | font-size: 14px 22 | } 23 | -------------------------------------------------------------------------------- /src/services/photo.ts: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | 3 | /** 4 | * 5 | * @param pageIndex 6 | * @param pageSize 7 | * @param source 1 为系统 2为个人 8 | * @returns 9 | */ 10 | export async function getPage( 11 | pageIndex: number, 12 | pageSize: number, 13 | source: 1 | 2 = 1, 14 | ) { 15 | return request('/api/upload/getPage', { 16 | params: { 17 | pageIndex, 18 | pageSize, 19 | source, 20 | }, 21 | method: 'GET', 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/bg-panel/bgPanel.less: -------------------------------------------------------------------------------- 1 | .panel { 2 | width: 100%; 3 | padding: 10px; 4 | height: 100%; 5 | overflow: auto; 6 | display: flex; 7 | .left { 8 | width: 50%; 9 | padding-right: 5px; 10 | } 11 | 12 | .right { 13 | width: 50%; 14 | padding-left: 5px; 15 | } 16 | 17 | img { 18 | height: auto; 19 | width: 100%; 20 | border-radius: 5px; 21 | margin-bottom: 10px; 22 | cursor: pointer; 23 | background: rgba(16, 38, 58, 0.04); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/image-panel/imagePanel.less: -------------------------------------------------------------------------------- 1 | .panel { 2 | width: 100%; 3 | // padding: 10px; 4 | height: 100%; 5 | overflow: auto; 6 | display: flex; 7 | .left { 8 | width: 50%; 9 | padding-right: 5px; 10 | } 11 | 12 | .right { 13 | width: 50%; 14 | padding-left: 5px; 15 | } 16 | 17 | img { 18 | // height: auto; 19 | // width: 100%; 20 | // border-radius: 5px; 21 | // margin-bottom: 10px; 22 | // cursor: pointer; 23 | // background: rgba(16, 38, 58, 0.04); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/canvas-components/layout/header/header.less: -------------------------------------------------------------------------------- 1 | @import '../../../styles/index.less'; 2 | 3 | .header { 4 | width: 100%; 5 | height: 64px; 6 | background: @bg-color; 7 | border-bottom: 1px solid @font-border-color; 8 | display: flex; 9 | align-items: center; 10 | padding: 16px; 11 | .title { 12 | font-size: 18px; 13 | font-weight: 500; 14 | color: @default-font-title-color; 15 | width: 340px; 16 | } 17 | .center { 18 | flex: 1; 19 | } 20 | .right { 21 | // flex: 1; 22 | width: 400px; 23 | display: flex; 24 | justify-content: flex-end; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/color-select/colorSelect.less: -------------------------------------------------------------------------------- 1 | .swatch { 2 | padding: 5px; 3 | width: 100%; 4 | background: #fff; 5 | border-radius: 1px; 6 | box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.1); 7 | display: inline-block; 8 | cursor: pointer; 9 | } 10 | 11 | .color { 12 | width: 100%; 13 | height: 20px; 14 | border-radius: 2px; 15 | // background: `rgba(${ this.state.color.r }, ${ thsis.state.color.g }, ${ this.state.color.b }, ${ this.state.color.a })`, 16 | } 17 | 18 | .popover { 19 | position: absolute; 20 | z-index: 2; 21 | } 22 | 23 | .cover { 24 | position: fixed; 25 | top: 0px; 26 | right: 0px; 27 | bottom: 0px; 28 | left: 0px; 29 | } 30 | -------------------------------------------------------------------------------- /src/canvas-components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import Layout from './layout'; 2 | import Header from './header'; 3 | import Content from './content'; 4 | import SliderLeft from './slider-left'; 5 | import SliderRight from './slider-right'; 6 | 7 | interface LayoutType extends React.FC { 8 | Header: typeof Header; 9 | Content: typeof Content; 10 | SliderLeft: typeof SliderLeft; 11 | SliderRight: typeof SliderRight; 12 | } 13 | 14 | const LayoutContainer = Layout as LayoutType; 15 | 16 | LayoutContainer.Header = Header; 17 | LayoutContainer.Content = Content; 18 | LayoutContainer.SliderLeft = SliderLeft; 19 | LayoutContainer.SliderRight = SliderRight; 20 | 21 | export default LayoutContainer; 22 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/slider.less: -------------------------------------------------------------------------------- 1 | @import '../../../styles/index.less'; 2 | 3 | .slider { 4 | height: 100%; 5 | width: 320px; 6 | background: @bg-color; 7 | display: flex; 8 | border-right: 1px solid @font-border-color; 9 | .toolbar { 10 | width: 60px; 11 | border-right: 1px solid @font-border-color; 12 | background: @bg-color; 13 | 14 | .item { 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | width: 100%; 19 | height: 60px; 20 | cursor: pointer; 21 | } 22 | 23 | .active { 24 | background: #1890ff; 25 | } 26 | } 27 | 28 | .area { 29 | width: 100%; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/core/shape/image/Image.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva'; 2 | import Canvas from '../../Canvas'; 3 | import type { DatModelItem, DataModel, ImageModel } from '@/typing'; 4 | 5 | class Image { 6 | constructor( 7 | shape: ImageModel, 8 | canvas: Canvas, 9 | cbk?: (node: Konva.Shape) => void, 10 | ) { 11 | Konva.Image.fromURL(shape.url, (darthNode: Konva.Shape) => { 12 | cbk?.(darthNode); 13 | darthNode.setAttrs({ 14 | ...shape, 15 | }); 16 | 17 | darthNode.on('dragend', (event: any) => { 18 | // console.log('event', event); 19 | }); 20 | canvas.layer.add(darthNode); 21 | // this.layer.batchDraw(); 22 | }); 23 | } 24 | } 25 | 26 | export default Image; 27 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/text-panel/textPanel.less: -------------------------------------------------------------------------------- 1 | .textPanel { 2 | display: flex; 3 | flex-direction: column; 4 | 5 | .item { 6 | display: flex; 7 | flex-direction: column; 8 | padding: 5px 16px; 9 | margin-bottom: 15px; 10 | .title { 11 | font-size: 13px; 12 | font-weight: 400; 13 | color: rgba(16, 38, 58, 0.85); 14 | display: flex; 15 | justify-content: space-between; 16 | margin-bottom: 10px; 17 | } 18 | 19 | .edit { 20 | color: #1976d2; 21 | cursor: pointer; 22 | } 23 | 24 | .content { 25 | // margin-top: 16px; 26 | display: flex; 27 | font-size: 16px; 28 | justify-content: space-between; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/image-panel/imagePanel.less: -------------------------------------------------------------------------------- 1 | .textPanel { 2 | display: flex; 3 | flex-direction: column; 4 | 5 | .item { 6 | display: flex; 7 | flex-direction: column; 8 | padding: 5px 16px; 9 | margin-bottom: 15px; 10 | .title { 11 | font-size: 13px; 12 | font-weight: 400; 13 | color: rgba(16, 38, 58, 0.85); 14 | display: flex; 15 | justify-content: space-between; 16 | margin-bottom: 10px; 17 | } 18 | 19 | .edit { 20 | color: #1976d2; 21 | cursor: pointer; 22 | } 23 | 24 | .content { 25 | // margin-top: 16px; 26 | display: flex; 27 | font-size: 16px; 28 | justify-content: space-between; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/image/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, forwardRef, useRef } from 'react'; 2 | import { Image } from 'react-konva'; 3 | import useImage from '@/hooks/useImage'; 4 | 5 | export interface IImageProps { 6 | // url?: string; 7 | [x: string]: any; 8 | } 9 | 10 | const ImageC: FC = forwardRef((props, ref: any) => { 11 | const { url, ...otherProps } = props; 12 | // console.log('otherProps=>', otherProps) 13 | const [image, status, width, height] = useImage(url, 'Anonymous'); 14 | // console.log('width', height, otherProps) 15 | return ( 16 | 23 | ); 24 | }); 25 | 26 | export default ImageC; 27 | -------------------------------------------------------------------------------- /.umirc.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'umi'; 2 | const serverUrl = 'http://localhost:7001'; 3 | const imageUrl = 'http://localhost:7002'; 4 | 5 | export default defineConfig({ 6 | nodeModulesTransform: { 7 | type: 'none', 8 | }, 9 | routes: [ 10 | { 11 | path: '/login', 12 | component: '@/pages/login', 13 | }, 14 | { path: '/', component: '@/pages/index' }, 15 | { path: '/demo', component: '@/pages/demo' }, 16 | ], 17 | fastRefresh: {}, 18 | proxy: { 19 | '/api': { 20 | target: serverUrl, 21 | changeOrigin: true, 22 | pathRewrite: { '^/api': '/api' }, 23 | }, 24 | '/upload': { 25 | target: imageUrl, 26 | // changeOrigin: true, 27 | pathRewrite: { '^/upload': '/upload' }, 28 | }, 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "jsx": "react-jsx", 8 | "esModuleInterop": true, 9 | "sourceMap": true, 10 | "baseUrl": "./", 11 | "strict": true, 12 | "paths": { 13 | "@/*": ["src/*"], 14 | "@@/*": ["src/.umi/*"] 15 | }, 16 | "allowSyntheticDefaultImports": true 17 | }, 18 | "include": [ 19 | "mock/**/*", 20 | "src/**/*", 21 | "config/**/*", 22 | ".umirc.ts", 23 | "typings.d.ts" 24 | ], 25 | "exclude": [ 26 | "node_modules", 27 | "lib", 28 | "es", 29 | "dist", 30 | "typings", 31 | "**/__test__", 32 | "test", 33 | "docs", 34 | "tests" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/canvas-panel/canvasPanel.less: -------------------------------------------------------------------------------- 1 | .canvasPanel { 2 | display: flex; 3 | flex-direction: column; 4 | 5 | .item { 6 | display: flex; 7 | flex-direction: column; 8 | padding: 5px 16px; 9 | margin-bottom: 15px; 10 | .title { 11 | font-size: 13px; 12 | font-weight: 400; 13 | color: rgba(16, 38, 58, 0.85); 14 | display: flex; 15 | justify-content: space-between; 16 | margin-bottom: 10px; 17 | } 18 | 19 | .edit { 20 | color: #1976d2; 21 | cursor: pointer; 22 | } 23 | 24 | .content { 25 | // margin-top: 16px; 26 | display: flex; 27 | font-size: 16px; 28 | justify-content: space-between; 29 | } 30 | } 31 | 32 | .container { 33 | padding: 20px 0; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/core/utils/util.ts: -------------------------------------------------------------------------------- 1 | import type { DatModelItem, DataModel, ImageModel } from '@/typing'; 2 | 3 | export const uuid = () => { 4 | const temp_url = URL.createObjectURL(new Blob()); 5 | const uuid = temp_url.toString(); // blob:https://xxx.com/b250d159-e1b6-4a87-9002-885d90033be3 6 | URL.revokeObjectURL(temp_url); 7 | return uuid.substr(uuid.lastIndexOf('/') + 1); 8 | }; 9 | 10 | export const getCenterXY = ( 11 | canvasWidth: number, 12 | canvasHeight: number, 13 | elementWidth: number, 14 | elementHeight: number, 15 | ) => { 16 | const x = canvasWidth / 2 - elementWidth / 2; 17 | const y = canvasHeight / 2 - elementHeight / 2; 18 | return [x, y]; 19 | }; 20 | 21 | export const isBg = (modelItem: DatModelItem): boolean => { 22 | return modelItem.type === 'color' || modelItem.type === 'bg-image'; 23 | }; 24 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/text-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Button } from 'antd'; 3 | import useModel from 'flooks'; 4 | import canvasDataModel from '@/models1/canvasDataModel'; 5 | import canvasModel from '@/models1/canvasModel'; 6 | 7 | import styles from './textPanel.less'; 8 | 9 | export interface ITextPanelProps {} 10 | 11 | const TextPanel: FC = (props) => { 12 | const { canvasRef } = useModel(canvasModel); 13 | 14 | const addText = () => { 15 | canvasRef?.addText(); 16 | }; 17 | return ( 18 |
19 | 27 |
28 | ); 29 | }; 30 | 31 | export default TextPanel; 32 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react'; 2 | import styles from './index.less'; 3 | import { Canvas, Layout } from '../canvas-components'; 4 | // import { ContextMenu } from '@/components'; 5 | import { history } from 'umi'; 6 | import useAuth from '@/hooks/useAuth'; 7 | 8 | const { Header, Content, SliderLeft, SliderRight } = Layout; 9 | export interface IIndexProps {} 10 | 11 | const Index: FC = (props) => { 12 | const isAuth = useAuth(); 13 | useEffect(() => { 14 | if (!isAuth) { 15 | history.push('/login'); 16 | } 17 | }, []); 18 | 19 | return ( 20 | 21 | {/* */} 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default Index; 35 | -------------------------------------------------------------------------------- /src/models/useCanvasModel.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { useImmer } from 'use-immer'; 3 | import { ShapePanelEnum } from '@/enum'; 4 | import { DatModelItem } from '@/typing'; 5 | import Konva from 'konva'; 6 | 7 | /** 8 | * 用于描述画布上的属性 9 | */ 10 | type Canvas = { 11 | shapePanelType: ShapePanelEnum; // 右侧panel显示的类型 12 | selectNode: DatModelItem | null; 13 | }; 14 | 15 | type Transformer = { 16 | isSelected: boolean; 17 | }; 18 | 19 | export default function useCanvas() { 20 | const [canvas, setCanvas] = useImmer({ 21 | shapePanelType: ShapePanelEnum.ShapePanel, // 用枚举代替 22 | selectNode: null, 23 | }); 24 | const [transformer, setTransformer] = useImmer({}); 25 | 26 | const changeCanvas = useCallback((currCanvasModel) => { 27 | setCanvas((draft) => { 28 | Object.assign(draft, currCanvasModel); 29 | }); 30 | }, []); 31 | 32 | return { 33 | canvas, 34 | changeCanvas, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /src/components/waterfall-flow/image.tsx: -------------------------------------------------------------------------------- 1 | import React from 'React'; 2 | import classnames from 'classnames'; 3 | 4 | import type { ImageItem } from './index'; 5 | import styles from './waterfallFlow.less'; 6 | 7 | export interface IImageProps { 8 | style?: React.CSSProperties; 9 | imageItem: ImageItem; 10 | classNameImg?: string; 11 | onClick?: (e: React.MouseEvent, data: ImageItem) => void; 12 | } 13 | 14 | const Image = ({ imageItem, classNameImg, onClick, style }: IImageProps) => { 15 | const classNameImgList = classnames(styles.img, classNameImg); 16 | const path = imageItem.thumb_path || imageItem.path; 17 | 18 | const onImageClick = (e: React.MouseEvent) => { 19 | onClick?.(e, { ...imageItem }); 20 | }; 21 | return ( 22 | 28 | ); 29 | }; 30 | 31 | export default Image; 32 | -------------------------------------------------------------------------------- /src/pages/.umi/router.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router as DefaultRouter, Route, Switch } from 'react-router-dom'; 3 | import dynamic from 'umi/dynamic'; 4 | import renderRoutes from 'umi/_renderRoutes'; 5 | 6 | 7 | let Router = DefaultRouter; 8 | 9 | let routes = [ 10 | { 11 | "path": "/", 12 | "exact": true, 13 | "component": require('../index.tsx').default 14 | }, 15 | { 16 | "path": "/demo", 17 | "exact": true, 18 | "component": require('../demo.tsx').default 19 | }, 20 | { 21 | "component": () => React.createElement(require('/Users/qiaojie/.config/yarn/global/node_modules/umi-build-dev/lib/plugins/404/NotFound.js').default, { pagesPath: 'src/pages', hasRoutesInConfig: false }) 22 | } 23 | ]; 24 | window.g_plugins.applyForEach('patchRoutes', { initialValue: routes }); 25 | 26 | export default function RouterWrapper() { 27 | return ( 28 | 29 | { renderRoutes(routes, {}) } 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /src/pages/demo.tsx: -------------------------------------------------------------------------------- 1 | import text from '@/components/text'; 2 | import React, { FC, useRef, useState } from 'react'; 3 | import { useRouteMatch } from 'umi'; 4 | 5 | export interface IDemoProps {} 6 | 7 | const Demo: FC = (props) => { 8 | const inputRef = useRef(); 9 | 10 | const match = useRouteMatch(); 11 | console.log('match', match); 12 | const [state, setState] = useState(null); 13 | const onInput = (e: any) => { 14 | console.log('value', e.target.value); 15 | inputRef.current.parentNode.dataset.replicatedValue = e.target.value; 16 | }; 17 | 18 | const handleChange = (e: any) => { 19 | const value = e.target.value; 20 | setState(value); 21 | }; 22 | return ( 23 |
24 |
28 | 34 |
35 |
36 | ); 37 | }; 38 | 39 | export default Demo; 40 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/image-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Select, Button, Tooltip } from 'antd'; 3 | import PanelTitle from '../panel-title'; 4 | import useModel from 'flooks'; 5 | import canvasDataModel from '@/models1/canvasDataModel'; 6 | import canvasModel from '@/models1/canvasModel'; 7 | import styles from './imagePanel.less'; 8 | 9 | export interface ITextPanelProps {} 10 | 11 | const TextPanel: FC = (props) => { 12 | const { width, height, changeCanvasModelDataItem, changeCanvasModel } = 13 | useModel(canvasDataModel); 14 | const { selectNode, changeCanvas } = useModel(canvasModel); 15 | 16 | return ( 17 |
18 | 图片 19 |
20 |
21 |
背景
22 |
23 |
24 | 27 |
28 |
29 |
30 | ); 31 | }; 32 | 33 | export default TextPanel; 34 | -------------------------------------------------------------------------------- /src/pages/.umi/umi.js: -------------------------------------------------------------------------------- 1 | import './polyfills'; 2 | 3 | import '@tmp/initHistory'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | 7 | 8 | // runtime plugins 9 | window.g_plugins = require('umi/_runtimePlugin'); 10 | window.g_plugins.init({ 11 | validKeys: ['patchRoutes','render','rootContainer','modifyRouteProps',], 12 | }); 13 | 14 | 15 | 16 | // render 17 | let oldRender = () => { 18 | const rootContainer = window.g_plugins.apply('rootContainer', { 19 | initialValue: React.createElement(require('./router').default), 20 | }); 21 | ReactDOM.render( 22 | rootContainer, 23 | document.getElementById('root'), 24 | ); 25 | }; 26 | const render = window.g_plugins.compose('render', { initialValue: oldRender }); 27 | 28 | const moduleBeforeRendererPromises = []; 29 | 30 | Promise.all(moduleBeforeRendererPromises).then(() => { 31 | render(); 32 | }).catch((err) => { 33 | if (process.env.NODE_ENV === 'development') { 34 | console.error(err); 35 | } 36 | }); 37 | 38 | require('../../global.css'); 39 | 40 | // hot module replacement 41 | if (module.hot) { 42 | module.hot.accept('./router', () => { 43 | oldRender(); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/hooks/useImage.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, useState, useEffect } from 'react'; 2 | 3 | var defaultState = { image: undefined, status: 'loading' }; 4 | 5 | const useImage = (url: string, crossOrigin?: any): any => { 6 | const [state, setState] = useState(defaultState); 7 | 8 | useEffect(() => { 9 | if (!url) return; 10 | var img = document.createElement('img'); 11 | 12 | function onload() { 13 | setState({ 14 | image: img, 15 | status: 'loaded', 16 | width: img.width, 17 | height: img.height, 18 | }); 19 | } 20 | 21 | function onerror() { 22 | setState({ image: undefined, status: 'failed' }); 23 | } 24 | 25 | img.addEventListener('load', onload); 26 | img.addEventListener('error', onerror); 27 | crossOrigin && (img.crossOrigin = crossOrigin); 28 | img.src = url; 29 | 30 | return () => { 31 | img.removeEventListener('load', onload); 32 | img.removeEventListener('error', onerror); 33 | setState(defaultState); 34 | }; 35 | }, [url, crossOrigin]); 36 | 37 | return [state.image, state.status, state.width, state.height]; 38 | }; 39 | 40 | export default useImage; 41 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-right/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback } from 'react'; 2 | import { CanvasPanel, TextPanel, ImagePanel } from '../../shape-panel'; 3 | import { ShapePanelEnum } from '@/enum'; 4 | import useModel from 'flooks'; 5 | import canvasDataModel from '@/models1/canvasDataModel'; 6 | import canvasModel from '@/models1/canvasModel'; 7 | import { getShapePanelTypeBySelectNode } from '@/utils/util'; 8 | import styles from './slider.less'; 9 | 10 | export interface IHeaderProps {} 11 | 12 | const Slider: FC = (props) => { 13 | const { selectNode } = useModel(canvasModel); 14 | 15 | const shapePanelType = getShapePanelTypeBySelectNode(selectNode); 16 | const getPanelJsx = useCallback(() => { 17 | switch (shapePanelType) { 18 | case ShapePanelEnum.ShapePanel: 19 | return ; 20 | case ShapePanelEnum.TextPanel: 21 | return ; 22 | case ShapePanelEnum.ImagePanel: 23 | return ; 24 | default: 25 | break; 26 | } 27 | }, [shapePanelType]); 28 | 29 | return
{getPanelJsx()}
; 30 | }; 31 | 32 | export default Slider; 33 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { RequestConfig, history } from 'umi'; 2 | import { getToken } from '@/utils/util'; 3 | 4 | export const request: RequestConfig = { 5 | errorConfig: { 6 | adaptor: (resData) => { 7 | // console.log('resData', resData); 8 | // if (resData.toString() !== '[object Object]') { 9 | // // history.push('/login'); 10 | // // window.location.reload(); 11 | // return { 12 | // success: false, 13 | // errorMessage: '后台错误!' 14 | // } 15 | // } 16 | return { 17 | success: resData.code === 0, 18 | errorMessage: resData.message, 19 | }; 20 | }, 21 | }, 22 | requestInterceptors: [ 23 | (url: string, options: Record) => { 24 | const token = getToken(); 25 | options.headers.Authorization = token; 26 | return { 27 | url, 28 | options, 29 | }; 30 | }, 31 | ], 32 | // parseResponse: false, 33 | // getResponse: true, 34 | // responseInterceptors: [ 35 | // (response, options) => { 36 | // console.log('responseInterceptors', response,options); 37 | // return { 38 | // data: 1 39 | // }; 40 | // } 41 | // ] 42 | }; 43 | -------------------------------------------------------------------------------- /src/pages/login/login.less: -------------------------------------------------------------------------------- 1 | .bg { 2 | // 从这里找 https://unsplash.com/s/photos 3 | // background-image: url('../../assets/images/3.jpg'); 4 | background-size: cover; 5 | height: 100%; 6 | background: #ebe7e7; 7 | 8 | .wrapper { 9 | position: absolute; 10 | top: -100px; 11 | right: 0; 12 | bottom: 0; 13 | left: 0; 14 | display: flex; 15 | align-items: center; 16 | justify-content: space-between; 17 | max-width: 1000px; 18 | margin: 0 auto; 19 | // background: #ccc; 20 | } 21 | 22 | .slogan { 23 | font-size: 36px; 24 | line-height: 48px; 25 | letter-spacing: 2px; 26 | text-align: center; 27 | color: #9d48c5; 28 | } 29 | 30 | .login { 31 | width: 420px; 32 | background: #fff; 33 | height: 350px; 34 | // text-align: center; 35 | padding: 20px; 36 | .title { 37 | color: #000; 38 | font-size: 30px; 39 | text-align: center; 40 | font-weight: 400; 41 | margin-bottom: 24px 42 | } 43 | 44 | .apply { 45 | margin-top: 20px; 46 | text-align: center; 47 | cursor: pointer; 48 | } 49 | } 50 | } 51 | 52 | .loginWay { 53 | color: #2f54eb; 54 | margin-bottom: 15px; 55 | cursor: pointer; 56 | } 57 | -------------------------------------------------------------------------------- /src/core/shape/transformer/index.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva'; 2 | import type { TransformerConfig } from 'konva/lib/shapes/Transformer'; 3 | import Canvas from '../../Canvas'; 4 | import { isBg } from '../../utils/util'; 5 | import { 6 | rectangleStart, 7 | rectangleMove, 8 | rectangleEnd, 9 | rectangleVisible, 10 | } from '../../utils/group'; 11 | import { createContextMenu } from '../../context-menu'; 12 | import type { DatModelItem, DataModel, ImageModel } from '@/typing'; 13 | 14 | class Transformer { 15 | transformer: Konva.Transformer; 16 | tmpNodes: any; 17 | constructor(config: TransformerConfig, canvas: Canvas) { 18 | this.transformer = new Konva.Transformer(config); 19 | // canvas.layer.add(this.transformer); 20 | const tr = this.transformer; 21 | tr.on('dragstart', () => { 22 | tr.hide(); 23 | // this.tmpNodes = tr.nodes(); 24 | // if (this.tmpNodes.length === 1) { 25 | // tr.nodes([]); 26 | // } 27 | }); 28 | // tr.on('dragmove', () => { 29 | // tr.nodes(); 30 | // }) 31 | tr.on('dragend', () => { 32 | tr.show(); 33 | // if (this.tmpNodes.length === 1) { 34 | // tr.nodes(this.tmpNodes); 35 | // this.tmpNodes = null; 36 | // } 37 | }); 38 | } 39 | } 40 | 41 | export default Transformer; 42 | -------------------------------------------------------------------------------- /src/bomponents/uitls/modelViewUtils.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Modal, ConfigProvider } from 'antd'; 4 | import type { ModalProps } from 'antd'; 5 | 6 | import zhCN from 'antd/lib/locale/zh_CN'; 7 | 8 | class ModalViewUtils { 9 | private div: HTMLDivElement; 10 | private Component: React.ComponentType; 11 | // 构造函数接收一个组件 12 | constructor(Component: any) { 13 | this.div = document.createElement('div'); 14 | // this.modalRef = React.createRef(); 15 | this.Component = Component; 16 | } 17 | 18 | onCancel = () => { 19 | this.close(); 20 | }; 21 | 22 | show = ({ title, ...otherProps }: ModalProps) => { 23 | const CurrComponent = this.Component; 24 | document.body.appendChild(this.div); 25 | ReactDOM.render( 26 | 35 | 36 | , 37 | this.div, 38 | ); 39 | }; 40 | 41 | close = () => { 42 | const unmountResult = ReactDOM.unmountComponentAtNode(this.div); 43 | if (unmountResult && this.div.parentNode) { 44 | this.div.parentNode.removeChild(this.div); 45 | } 46 | }; 47 | } 48 | 49 | export default ModalViewUtils; 50 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | #root { 2 | height: 100%; 3 | } 4 | .grow-wrap { 5 | /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ 6 | display: grid; 7 | width: 100px; 8 | } 9 | 10 | .grow-wrap::after { 11 | /* Note the weird space! Needed to preventy jumpy behavior */ 12 | content: attr(data-replicated-value) " "; 13 | 14 | /* This is how textarea text behaves */ 15 | white-space: pre-wrap; 16 | 17 | /* Hidden from view, clicks, and screen readers */ 18 | visibility: hidden; 19 | } 20 | 21 | .grow-wrap>textarea { 22 | /* You could leave this, but after a user resizes, then it ruins the auto sizing */ 23 | resize: none; 24 | 25 | /* Firefox shows scrollbar on growth, you can hide like this. */ 26 | overflow: hidden; 27 | } 28 | 29 | .grow-wrap>textarea, 30 | .grow-wrap::after { 31 | /* Identical styling required!! */ 32 | border: 1px solid black; 33 | padding: 0.5rem; 34 | font: inherit; 35 | 36 | /* Place on top of each other */ 37 | grid-area: 1 / 1 / 2 / 2; 38 | } 39 | 40 | 41 | .core-context-menu { 42 | display: none; 43 | position: absolute; 44 | width: 130px; 45 | background-color: white; 46 | box-shadow: 0 0 5px grey; 47 | border-radius: 3px; 48 | z-index: 1; 49 | } 50 | 51 | .core-context-menu button { 52 | width: 100%; 53 | background-color: white; 54 | border: none; 55 | margin: 0; 56 | padding: 6px; 57 | } 58 | 59 | .core-context-menu button:hover { 60 | background-color: lightgray; 61 | } 62 | -------------------------------------------------------------------------------- /src/bomponents/upload/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { Upload, Button, message } from 'antd'; 3 | import { UploadOutlined } from '@ant-design/icons'; 4 | import useToken from '@/hooks/useToken'; 5 | 6 | import type { UploadChangeParam } from 'antd/lib/upload/interface'; 7 | 8 | export interface IUploadProps { 9 | onUploadSuccess?: () => void; 10 | } 11 | 12 | const MyUpload: FC = (props) => { 13 | const { onUploadSuccess } = props; 14 | const [loading, setLoading] = useState(false); 15 | 16 | const token = useToken(); 17 | const uploadProps = { 18 | name: 'file', 19 | action: '/api/upload', 20 | showUploadList: false, 21 | headers: { 22 | Authorization: token as string, 23 | }, 24 | onChange(info: UploadChangeParam) { 25 | setLoading(true); 26 | if (info.file.status !== 'uploading') { 27 | } 28 | if (info.file.status === 'done') { 29 | // console.log('info=>', info); 30 | setLoading(false); 31 | onUploadSuccess?.(); 32 | message.success(`${info.file.name} 文件上传成功!`); 33 | } else if (info.file.status === 'error') { 34 | setLoading(false); 35 | message.error(`${info.file.name} 文件上传失败.`); 36 | } 37 | }, 38 | }; 39 | 40 | // console.log('token=>', token); 41 | return ( 42 | 43 | 46 | 47 | ); 48 | }; 49 | 50 | export default MyUpload; 51 | -------------------------------------------------------------------------------- /src/components/toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react'; 2 | import { Button } from 'antd'; 3 | import useModel from 'flooks'; 4 | import canvasDataModel from '@/models1/canvasDataModel'; 5 | import canvasModel from '@/models1/canvasModel'; 6 | import { 7 | BorderOuterOutlined, 8 | AppstoreAddOutlined, 9 | LineHeightOutlined, 10 | FileImageOutlined, 11 | GatewayOutlined, 12 | ZoomInOutlined, 13 | ZoomOutOutlined, 14 | } from '@ant-design/icons'; 15 | import { useImmer } from 'use-immer'; 16 | import { getScalePercent } from '@/utils/util'; 17 | import styles from './toolbar.less'; 18 | 19 | export interface IToolbarProps {} 20 | 21 | const Toolbar: FC = (props) => { 22 | const { canvasRef, update } = useModel(canvasModel); 23 | const { scale } = canvasRef?.canvasAttr || {}; 24 | const zoom = (type: string) => { 25 | // console.log('canvas=>', scale); 26 | if (type === 'zoomIn') { 27 | canvasRef?.zoomIn?.(); 28 | } else { 29 | canvasRef?.zoomOut?.(); 30 | } 31 | update(); 32 | }; 33 | 34 | return ( 35 |
36 | zoom('zoomOut')} 39 | /> 40 | {getScalePercent(scale)} 41 | zoom('zoomIn')} 44 | /> 45 |
46 | ); 47 | }; 48 | 49 | export default Toolbar; 50 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/bg-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Button } from 'antd'; 3 | import useModel from 'flooks'; 4 | import canvasDataModel from '@/models1/canvasDataModel'; 5 | import canvasModel from '@/models1/canvasModel'; 6 | 7 | import styles from './bgPanel.less'; 8 | 9 | const image1 = [ 10 | '/bg1/1.jpeg', 11 | '/bg1/2.jpeg', 12 | '/bg1/3.jpeg', 13 | '/bg1/4.jpeg', 14 | '/bg1/5.jpeg', 15 | '/bg1/6.jpeg', 16 | '/bg1/7.jpeg', 17 | '/bg1/8.jpeg', 18 | '/bg1/9.jpeg', 19 | '/bg1/10.jpeg', 20 | ]; 21 | const image2 = [ 22 | '/bg2/1.jpeg', 23 | '/bg2/2.jpeg', 24 | '/bg2/3.jpeg', 25 | '/bg2/4.jpeg', 26 | '/bg2/5.jpeg', 27 | '/bg2/6.jpeg', 28 | '/bg2/7.jpeg', 29 | '/bg2/8.jpeg', 30 | '/bg2/9.jpeg', 31 | '/bg2/10.jpeg', 32 | ]; 33 | 34 | export interface IBGPanelProps {} 35 | 36 | const BGPanel: FC = (props) => { 37 | // const { changeCanvasModelDataItem, nodes, width, height } = 38 | // useModel(canvasDataModel); 39 | 40 | const { canvasRef } = useModel(canvasModel); 41 | 42 | const addBg = (item: string) => { 43 | canvasRef?.addBgImage(item); 44 | }; 45 | 46 | return ( 47 |
48 |
49 | {image1.map((item) => { 50 | return addBg(item)} key={item} src={item} />; 51 | })} 52 |
53 |
54 | {image2.map((item) => { 55 | return addBg(item)} key={item} src={item} />; 56 | })} 57 |
58 |
59 | ); 60 | }; 61 | 62 | export default BGPanel; 63 | -------------------------------------------------------------------------------- /src/typing.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva'; 2 | 3 | /** 4 | * 画布内元素类型 5 | */ 6 | export type ElementType = 7 | | 'color' 8 | | 'image' 9 | | 'text' 10 | | 'rect' 11 | | 'text-input' 12 | | 'bg-image' 13 | | 'group'; 14 | 15 | export type BaseModel = { 16 | id: string; 17 | name?: string; 18 | type: ElementType; 19 | draggable?: boolean; 20 | x?: number; 21 | y?: number; 22 | }; 23 | 24 | /** 25 | * 画布背景类 26 | */ 27 | export type BgModel = BaseModel & { 28 | color?: string; // 背景颜色 29 | url?: string; // 背景图片 30 | }; 31 | 32 | /** 33 | * 文本类 34 | */ 35 | export type TextModel = BaseModel & Konva.TextConfig; 36 | 37 | /** 38 | * 矩形类 39 | */ 40 | export type ReactModel = BaseModel & Konva.RectConfig; 41 | /** 42 | * 矩形类 43 | */ 44 | export type ImageModel = BaseModel & Konva.RectConfig; 45 | 46 | export type GroupModel = BaseModel & { 47 | children: Array; 48 | }; 49 | 50 | /** 51 | * DataModel Item 52 | */ 53 | export type DatModelItem = 54 | | BgModel 55 | | TextModel 56 | | ReactModel 57 | | GroupModel 58 | | ImageModel; 59 | 60 | // 画布内数据类 61 | export type DataModel = Array; 62 | 63 | export type ShapePanelType = 'canvas' | 'text'; 64 | 65 | // 节点位置信息 66 | export type LocationItem = { 67 | id: string; 68 | x: number; 69 | y: number; 70 | w: number; 71 | h: number; 72 | l: number; // 左侧对齐线 73 | r: number; // 右侧对齐线 74 | t: number; // 顶部对齐线 75 | b: number; // 底部对齐线 76 | lc: number; // 左侧居中对齐线 77 | tc: number; // 顶部居中弄对齐线 78 | }; 79 | 80 | export type UndoRedoActionType = { 81 | type: 'push' | 'undo' | 'redo'; 82 | data: DatModelItem | null; 83 | }; 84 | 85 | export type PaginationParams = { 86 | pageIndex: number; 87 | pageSize: number; 88 | }; 89 | 90 | export type IInfiniteScrollListResponseData = { 91 | rows: Array>; 92 | count: number; 93 | }; 94 | -------------------------------------------------------------------------------- /src/components/color-select/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useEffect } from 'react'; 2 | import { useImmer } from 'use-immer'; 3 | import { SketchPicker } from 'react-color'; 4 | import { getRGBAValue } from '@/utils/util'; 5 | import styles from './colorSelect.less'; 6 | 7 | export interface IColorSelectProps { 8 | onChange: (value: string) => void; 9 | value: string; 10 | } 11 | 12 | const ColorSelect: FC = (props) => { 13 | const [state, setState] = useImmer({ 14 | displayColorPicker: false, 15 | color: props.value, 16 | }); 17 | 18 | useEffect(() => { 19 | if (props.value) { 20 | setState((draft) => { 21 | draft.color = props.value; 22 | }); 23 | } 24 | }, [props.value]); 25 | const handleClick = () => { 26 | setState((draft) => { 27 | draft.displayColorPicker = !draft.displayColorPicker; 28 | }); 29 | }; 30 | 31 | const handleClose = () => { 32 | setState((draft) => { 33 | draft.displayColorPicker = false; 34 | }); 35 | }; 36 | 37 | const handleChange = (color: any) => { 38 | console.log('color=>', color); 39 | const colorRgba = getRGBAValue(color.rgb); 40 | setState((draft) => { 41 | draft.color = colorRgba; 42 | }); 43 | props.onChange(colorRgba); 44 | }; 45 | // console.log('state=>', state); 46 | 47 | return ( 48 | 49 |
50 |
56 |
57 | {state.displayColorPicker ? ( 58 |
59 |
60 | 61 |
62 | ) : null} 63 | 64 | ); 65 | }; 66 | 67 | export default ColorSelect; 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fast-image-editor", 3 | "version": "0.0.1", 4 | "description": "一块开源图片编辑器,采用React+Typescript+React-Konva开发。", 5 | "author": "杰出D", 6 | "license": "MIT", 7 | "homepage": "https://github.com/jiechud/fast-image-editor#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/jiechud/fast-image-editor.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/jiechud/fast-image-editor/issues" 14 | }, 15 | "scripts": { 16 | "start": "umi dev", 17 | "build": "umi build", 18 | "postinstall": "umi generate tmp", 19 | "prettier": "prettier --write '**/*.{js,jsx,tsx,ts,less,md,json}'", 20 | "test": "umi-test", 21 | "test:coverage": "umi-test --coverage" 22 | }, 23 | "gitHooks": { 24 | "pre-commit": "lint-staged" 25 | }, 26 | "lint-staged": { 27 | "*.{js,jsx,md,json}": [ 28 | "prettier --write" 29 | ], 30 | "*.ts?(x)": [ 31 | "prettier --parser=typescript --write" 32 | ] 33 | }, 34 | "dependencies": { 35 | "@ant-design/pro-form": "^1.33.1", 36 | "@ant-design/pro-layout": "^6.5.0", 37 | "@ant-design/pro-table": "^2.47.1", 38 | "@umijs/preset-react": "1.x", 39 | "ahooks": "^2.10.9", 40 | "flooks": "^4.0.2", 41 | "immer": "^9.0.5", 42 | "konva": "^8.1.1", 43 | "lodash": "^4.17.21", 44 | "mousetrap": "^1.6.5", 45 | "react-color": "^2.19.3", 46 | "react-infinite-scroll-component": "^6.1.0", 47 | "react-konva": "^17.0.2-4", 48 | "react-konva-utils": "^0.1.7", 49 | "umi": "^3.5.3", 50 | "use-image": "^1.0.7", 51 | "use-immer": "^0.6.0" 52 | }, 53 | "devDependencies": { 54 | "@types/react": "^17.0.0", 55 | "@types/react-dom": "^17.0.0", 56 | "@umijs/test": "^3.5.3", 57 | "lint-staged": "^10.0.7", 58 | "prettier": "^2.2.0", 59 | "react": "17.x", 60 | "react-dom": "17.x", 61 | "typescript": "^4.1.2", 62 | "yorkie": "^2.0.0" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/utils/local-storage/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: localStorage 本地存储 3 | order: 12 4 | group: 5 | path: / 6 | nav: 7 | path: /proComponent 8 | --- 9 | 10 | # LocalStorage 存储 11 | 12 | 使用 13 | 14 | 15 | ## add(添加) 16 | 17 | ```typescript 18 | LocalStorage.add(key: string, value: any) 19 | ``` 20 | 将传入的value以字符串的形式存储到window.localStorage的key字段当中 21 | 22 | ##### 参数 23 | 24 | * `key`: (String) localStorage中的key 25 | * `value`: (any) localStorage中的key所存储的值 26 | 27 | ## addTag(根据tag增加最大存储条数功能) 28 | 29 | ```typescript 30 | addTag: (key: String, value: Any, tag: String) 31 | ``` 32 | 在window.localStorage的key中可添加多条value值。若添加的数量大于了指定的条数,则按照先进先出的规则进行对应的value项的删除 33 | 34 | 35 | ###### 示例 36 | 37 | 38 | ```typescript 39 | 40 | LocalStorage.get('test') // zetGroup: {name: 'zetyun', age: 6} 41 | LocalStorage.addTag('test', {address: 'haidian'}, 'zetGroup') // zetGroup: {name: 'zetyun', age: 6, address: 'haidian'} 42 | 43 | 44 | ``` 45 | 46 | 47 | ###### 参数 48 | 49 | * `key`: (String) 存储的key 50 | * `value`: (Any) 存储的value 51 | * `tag`: (String) 所要追加数据的key 52 | 53 | ## get(读取) 54 | 55 | ```typescript 56 | LocalStorage.get(key: string) 57 | ``` 58 | 通过get方法获取window.localStorage的值,以json或者对象的格式返回 59 | 60 | ###### 参数 61 | * `key`: (String) 传入要获取的 62 | 63 | ## remove (移除某一个值) 64 | 65 | ```typescript 66 | LocalStorage.remove(key: string) 67 | ``` 68 | 69 | 移除window.localStorage中的key值 70 | 71 | 72 | ## removeTag(清除标签项) 73 | 74 | ```typescript 75 | LocalStorage.removeTag(key:string, tag: string) 76 | ``` 77 | 78 | 在window.localStorage中查找到属性为key的值,并根据传递的tag进行删除 79 | 80 | 81 | ###### 参数 82 | 83 | * `key`: (string): 要删除window.localStorage的key 84 | * `tag`:要删除的标签 85 | 86 | 87 | ###### 示例 88 | 89 | 90 | ```typescript 91 | 92 | LocalStorage.get('tagGroup') // {name: 'zetyun', age: 6} 93 | 94 | LocalStorage.removeTag('tagGroup', 'age'); // tagGroup: {name: 'zetyun'} 95 | 96 | 97 | 98 | ``` 99 | 100 | 101 | 102 | ## clear (移除所有的项) 103 | 104 | ```typescript 105 | LocalStorage.clear() 106 | ``` 107 | 108 | 移除window.localStorage中所有的值 109 | -------------------------------------------------------------------------------- /src/utils/local-storage/index.ts: -------------------------------------------------------------------------------- 1 | const storage = window.localStorage; 2 | const MAX_TAG_NUM = 50; 3 | const PREFIX_KEY = 'zet_storage_cache_'; 4 | 5 | const LocalStorage = { 6 | add: (key: string, value: any) => { 7 | try { 8 | if (typeof value === 'string') storage.setItem(key, value); 9 | else storage.setItem(key, JSON.stringify(value)); 10 | } catch (err) { 11 | console.error(`添加localStorage失败==>key:${key}.`, err); 12 | } 13 | }, 14 | /** 15 | * 添加,根据tag增加最大存储条数功能 16 | * @param key 17 | * @param value 18 | * @param tag 同一个tag最多可以存储MAX_TAG_NUM条记录 19 | * @returns 20 | */ 21 | addTag: (key: string, value: any, tag: string) => { 22 | try { 23 | if (tag) { 24 | let cacheArr: string[] = JSON.parse( 25 | storage.getItem(PREFIX_KEY + tag) || '[]', 26 | ); 27 | 28 | cacheArr.push(key); 29 | 30 | if (cacheArr.length > MAX_TAG_NUM) { 31 | for (let i = 0; i <= cacheArr.length - MAX_TAG_NUM; i++) { 32 | const delKey = cacheArr.shift() || ''; 33 | storage.removeItem(delKey); 34 | } 35 | } 36 | 37 | storage.setItem(PREFIX_KEY + tag, JSON.stringify(cacheArr)); 38 | } else { 39 | console.error( 40 | `缺少第三个参数tag。tag:同一个tag最多可以存储MAX_TAG_NUM=${MAX_TAG_NUM}条记录`, 41 | ); 42 | } 43 | 44 | storage.setItem(key, JSON.stringify(value)); 45 | } catch (err) { 46 | console.error(`添加localStorage失败==>key:${key}.`, err); 47 | } 48 | }, 49 | get: (key: string): any => { 50 | const result = storage.getItem(key); 51 | try { 52 | let obj = JSON.parse(result || ''); 53 | return result ? obj : null; 54 | } catch (err) { 55 | return result; 56 | } 57 | }, 58 | remove: (key: string) => { 59 | storage.removeItem(key); 60 | }, 61 | /** 62 | * 63 | * @param key 64 | * @param tag 65 | */ 66 | removeTag: (key: string, tag: string) => { 67 | storage.removeItem(key); 68 | 69 | if (tag) { 70 | const cacheArr: string[] = JSON.parse( 71 | storage.getItem(PREFIX_KEY + tag) || '[]', 72 | ); 73 | 74 | const newArr = cacheArr.filter((item) => { 75 | if (item !== key) { 76 | return true; 77 | } 78 | }); 79 | 80 | storage.setItem(PREFIX_KEY + tag, JSON.stringify(newArr)); 81 | } else { 82 | console.error( 83 | `缺少第二个参数tag。tag:同一个tag最多可以存储MAX_TAG_NUM条记录`, 84 | ); 85 | } 86 | }, 87 | clear: () => { 88 | storage.clear(); 89 | }, 90 | }; 91 | 92 | export default LocalStorage; 93 | -------------------------------------------------------------------------------- /src/core/context-menu/index.ts: -------------------------------------------------------------------------------- 1 | import Stage from '../shape/stage/index'; 2 | import Canvas from '../Canvas'; 3 | import { uuid } from '../../utils/util'; 4 | import type { DatModelItem, DataModel, ImageModel } from '@/typing'; 5 | 6 | export const createContextMenu = ( 7 | stage: Stage, 8 | canvas: Canvas, 9 | ): HTMLDivElement => { 10 | const contextMenu = document.querySelector('.core-context-menu'); 11 | if (contextMenu) { 12 | contextMenu.remove(); 13 | } 14 | const box = document.createElement('div'); 15 | box.className = 'core-context-menu'; 16 | box.innerHTML = `
17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 |
25 | `; 26 | 27 | document.body.appendChild(box); 28 | window.addEventListener('click', () => { 29 | if (box) { 30 | box.style.display = 'none'; 31 | } 32 | }); 33 | 34 | document 35 | .getElementById('context-menu-move-up') 36 | ?.addEventListener('click', () => { 37 | // console.log('上移', stage.currNode) 38 | stage.currNode?.moveUp(); 39 | }); 40 | document 41 | .getElementById('context-menu-move-down') 42 | ?.addEventListener('click', () => { 43 | stage.currNode?.moveDown(); 44 | }); 45 | 46 | document 47 | .getElementById('context-menu-move-top') 48 | ?.addEventListener('click', () => { 49 | // console.log('顶层', stage.currNode) 50 | stage.currNode?.moveToTop(); 51 | }); 52 | 53 | document 54 | .getElementById('context-menu-move-bottom') 55 | ?.addEventListener('click', () => { 56 | stage.currNode?.moveToBottom(); 57 | stage.currNode?.moveUp(); 58 | }); 59 | 60 | // copy 61 | 62 | document 63 | .getElementById('context-menu-copy') 64 | ?.addEventListener('click', () => { 65 | if (stage.currNode) { 66 | const modelItem = stage.currNode.attrs; 67 | console.log(modelItem); 68 | modelItem.id = uuid(); 69 | modelItem.x += 20; 70 | modelItem.y += 20; 71 | canvas.copy(modelItem); 72 | } 73 | }); 74 | 75 | document.getElementById('context-menu-del')?.addEventListener('click', () => { 76 | console.log('删除'); 77 | canvas.selectBg(); 78 | canvas.tr.nodes([]); 79 | stage.currNode?.destroy(); 80 | }); 81 | 82 | return box; 83 | }; 84 | -------------------------------------------------------------------------------- /src/core/utils/group.ts: -------------------------------------------------------------------------------- 1 | import canvas from '@/canvas-components/canvas'; 2 | import Konva from 'konva'; 3 | import Canvas from '../Canvas'; 4 | import { isBg } from './util'; 5 | 6 | let x1: number, y1: number, x2: number, y2: number; 7 | 8 | let selectionRectangle: Konva.Rect; 9 | 10 | export const rectangleVisible = () => { 11 | return selectionRectangle && selectionRectangle.visible(); 12 | }; 13 | 14 | export const addRectangle = (layer: Konva.Layer) => { 15 | selectionRectangle?.destroy(); 16 | selectionRectangle = new Konva.Rect({ 17 | fill: 'rgba(0,0,255,0.5)', 18 | visible: false, 19 | }); 20 | console.log('add', layer); 21 | layer.add(selectionRectangle); 22 | }; 23 | 24 | export const rectangleStart = (stage: Konva.Stage, canvas: Canvas) => { 25 | // console.log('canvas', canvas); 26 | console.log('rectangleStart=>'); 27 | addRectangle(canvas.layer); 28 | const position = stage.getPointerPosition(); 29 | 30 | if (position) { 31 | const scale = canvas.canvasAttr.scale; 32 | let { x, y } = position; 33 | 34 | console.log('position: ', x, y, canvas.canvasAttr.scale); 35 | x = x / scale; 36 | y = y / scale; 37 | x1 = x; 38 | y1 = y; 39 | x2 = x; 40 | y2 = y; 41 | selectionRectangle.visible(true); 42 | selectionRectangle.width(0); 43 | selectionRectangle.height(0); 44 | } 45 | // layer.add(selectionRectangle); 46 | }; 47 | 48 | export const rectangleMove = (stage: Konva.Stage, canvas: Canvas) => { 49 | if (!selectionRectangle) { 50 | return; 51 | } 52 | if (!selectionRectangle.visible()) { 53 | return; 54 | } 55 | // console.log('move1312=>', selectionRectangle) 56 | const position = stage.getPointerPosition(); 57 | if (position) { 58 | const scale = canvas.canvasAttr.scale; 59 | const { x, y } = position; 60 | x2 = x / scale; 61 | y2 = y / scale; 62 | selectionRectangle.setAttrs({ 63 | x: Math.min(x1, x2), 64 | y: Math.min(y1, y2), 65 | width: Math.abs(x2 - x1), 66 | height: Math.abs(y2 - y1), 67 | }); 68 | } 69 | }; 70 | 71 | export const rectangleEnd = (stage: Konva.Stage, canvas: Canvas) => { 72 | console.log('rectangleEnd=>'); 73 | if (!selectionRectangle) { 74 | return; 75 | } 76 | if (!selectionRectangle.visible()) { 77 | return; 78 | } 79 | 80 | setTimeout(() => { 81 | selectionRectangle.visible(false); 82 | }); 83 | 84 | const shapes = stage.find('.node'); 85 | const box = selectionRectangle.getClientRect(); 86 | const selected = shapes.filter((shape) => 87 | Konva.Util.haveIntersection(box, shape.getClientRect()), 88 | ); 89 | // console.log('selected shape', selected); 90 | canvas.tr.nodes([...selected]); 91 | canvas.layer.add(canvas.tr); 92 | }; 93 | -------------------------------------------------------------------------------- /src/canvas-components/layout/header/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Button, message, Tooltip } from 'antd'; 3 | import useModel from 'flooks'; 4 | import { UndoOutlined, RedoOutlined } from '@ant-design/icons'; 5 | import canvasModel from '@/models1/canvasModel'; 6 | import canvasDataModel from '@/models1/canvasDataModel'; 7 | import { downloadURI } from '@/utils/util'; 8 | import styles from './header.less'; 9 | 10 | export interface IHeaderProps {} 11 | 12 | const Header: FC = (props) => { 13 | // const { stageRef, updateUndoRedoData, undoRedoData, canvasRef } = 14 | // useModel(canvasModel); 15 | const { nodes } = useModel(canvasDataModel); 16 | const { canvasRef } = useModel(canvasModel); 17 | 18 | const download = () => { 19 | var dataURL = canvasRef.stage.toDataURL({ pixelRatio: 0.5 }); 20 | console.log('dataURL', dataURL); 21 | downloadURI(dataURL, '画布图像.png'); 22 | }; 23 | 24 | const getTemplate = () => { 25 | const template = canvasRef.getTemplate(); 26 | console.log('节点内容=>', JSON.stringify(template)); 27 | message.success('请在控制台查看JSON'); 28 | }; 29 | 30 | const undo = () => { 31 | // updateUndoRedoData({ type: 'undo' }); 32 | }; 33 | 34 | const redo = () => { 35 | // updateUndoRedoData({ type: 'redo' }); 36 | }; 37 | return ( 38 |
39 |
图片编辑器
40 |
41 | 42 |
65 |
66 | 69 | 76 |
77 |
78 | ); 79 | }; 80 | 81 | export default Header; 82 | -------------------------------------------------------------------------------- /src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Input, Form, Button, Popover } from 'antd'; 3 | import { login } from '@/services/user'; 4 | import { history } from 'umi'; 5 | import ProForm, { ProFormText, ProFormCaptcha } from '@ant-design/pro-form'; 6 | // import FormValid from '@/utils/formValid'; 7 | 8 | import { LocalStorage } from '@/utils'; 9 | import styles from './login.less'; 10 | 11 | class Login extends Component { 12 | onFinish = async ({ userName, pwd }: any) => { 13 | const res = await login(userName, pwd); 14 | console.log('res=>', res); 15 | LocalStorage.add('user', res.user); 16 | LocalStorage.add('token', res.token); 17 | history.push('/'); 18 | }; 19 | 20 | render() { 21 | const codeJsx = ( 22 |
23 | 24 |
25 | 扫描二维码,关注【前端有话说】 26 |
27 | 公众号,回复"编辑器" 即可获取 28 |
29 |
30 | ); 31 | return ( 32 |
33 |
34 |
{/* 图片编辑器 */}
35 |
36 |
登录
37 | dom.pop(), 44 | submitButtonProps: { 45 | size: 'large', 46 | style: { 47 | width: '100%', 48 | }, 49 | }, 50 | }} 51 | > 52 | 65 | 78 | 79 |
如何获取登录码?
80 |
81 |
82 |
83 |
84 |
85 | ); 86 | } 87 | } 88 | 89 | export default Login; 90 | -------------------------------------------------------------------------------- /src/models/useCanvasDataModel.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 该Model主要用来实现Canvas数据逻辑 3 | */ 4 | import { useState, useCallback } from 'react'; 5 | import { useImmer } from 'use-immer'; 6 | import { ShapePanelEnum } from '@/enum'; 7 | import { uuid, getCenterXY } from '@/utils/util'; 8 | import { useModel } from 'umi'; 9 | import type { DataModel, DatModelItem } from '@/typing'; 10 | 11 | export type CanvasModel = { 12 | width: number; 13 | height: number; 14 | nodes: DataModel; 15 | }; 16 | 17 | const initData: DataModel = [ 18 | { 19 | id: 'bg', 20 | type: 'color', 21 | color: '#A8D7D7', 22 | }, 23 | { 24 | id: '1', 25 | type: 'text', 26 | text: 'ss11231231', 27 | fontSize: 22, 28 | fill: '#000', 29 | }, 30 | { 31 | x: 150, 32 | y: 150, 33 | id: '3', 34 | type: 'text', 35 | text: 'abc', 36 | // fontSize: 22, 37 | fill: '#000', 38 | }, 39 | { 40 | x: 150, 41 | y: 150, 42 | id: '5', 43 | type: 'text-input', 44 | text: '双击编辑文字', 45 | fontSize: 50, 46 | fill: '#000', 47 | width: 300, 48 | height: 50, 49 | }, 50 | ]; 51 | 52 | export default function useCanvasData() { 53 | const [canvasModel, setCanvasModel] = useImmer({ 54 | width: 1200, 55 | height: 700, 56 | nodes: initData, 57 | }); 58 | 59 | const { changeCanvas } = useModel('useCanvasModel'); 60 | 61 | const changeCanvasModel = useCallback((currCanvasModel) => { 62 | setCanvasModel((draft) => { 63 | Object.assign(draft, currCanvasModel); 64 | }); 65 | }, []); 66 | 67 | const changeCanvasModelDataItem = (currDataModelItem: DatModelItem) => { 68 | // console.log('currDataModelItem=>', currDataModelItem); 69 | setCanvasModel((draft) => { 70 | let index = draft.nodes.findIndex( 71 | (item) => item.id === currDataModelItem.id, 72 | ); 73 | draft.nodes[index] = currDataModelItem; 74 | }); 75 | }; 76 | 77 | const addText = useCallback(() => { 78 | const textWidth = 360; 79 | const textHeight = 60; 80 | const [x, y] = getCenterXY( 81 | canvasModel.width, 82 | canvasModel.height, 83 | textWidth, 84 | textHeight, 85 | ); 86 | const currTextDateItem: DatModelItem = { 87 | x, 88 | y, 89 | id: uuid(), 90 | fontSize: 60, 91 | type: 'text-input', 92 | text: '双击编辑文字', 93 | fill: '#000', 94 | width: textWidth, 95 | height: textHeight, 96 | }; 97 | setCanvasModel((draft) => { 98 | draft.nodes.push(currTextDateItem); 99 | }); 100 | changeCanvas({ 101 | selectNode: currTextDateItem, 102 | }); 103 | }, []); 104 | 105 | return { 106 | canvasModel, 107 | changeCanvasModel, 108 | changeCanvasModelDataItem, 109 | addText, 110 | }; 111 | } 112 | -------------------------------------------------------------------------------- /src/core/shape/stage/index.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva'; 2 | import type { StageConfig } from 'konva/lib/Stage'; 3 | import Canvas from '../../Canvas'; 4 | import { isBg } from '../../utils/util'; 5 | import { 6 | rectangleStart, 7 | rectangleMove, 8 | rectangleEnd, 9 | rectangleVisible, 10 | } from '../../utils/group'; 11 | import { createContextMenu } from '../../context-menu'; 12 | import type { DatModelItem, DataModel, ImageModel } from '@/typing'; 13 | 14 | class Satge { 15 | stage: Konva.Stage; 16 | contextMenu: HtmlDivElement; 17 | currNode: Konva.Shape | null; 18 | constructor(config: StageConfig, canvas: Canvas) { 19 | const stage = new Konva.Stage(config); 20 | this.stage = stage; 21 | this.currNode = null; 22 | stage.on('click', (event) => { 23 | // 框选框是否显示 24 | if (rectangleVisible()) { 25 | return; 26 | } 27 | if (!isBg(event.target.attrs)) { 28 | // && event.target.attrs.type !== 'text-input' 29 | const metaPressed = 30 | event.evt.shiftKey || event.evt.ctrlKey || event.evt.metaKey; 31 | const isSelected = canvas.tr.nodes().indexOf(event.target) >= 0; 32 | if (!metaPressed && !isSelected) { 33 | canvas.tr.nodes([event.target]); 34 | } else if (metaPressed && isSelected) { 35 | const nodes = canvas.tr.nodes().slice(); // use slice to have new copy of array 36 | nodes.splice(nodes.indexOf(event.target), 1); 37 | canvas.tr.nodes(nodes); 38 | } else if (metaPressed && !isSelected) { 39 | const nodes = canvas.tr.nodes().concat([event.target]); 40 | canvas.tr.nodes(nodes); 41 | } 42 | canvas.layer.add(canvas.tr); // TODO: 可能重复添加 43 | } else { 44 | canvas.tr.nodes([]); 45 | } 46 | }); 47 | 48 | stage.on('mousedown', (e) => { 49 | // console.log('mousedownmousedown', e.evt.button) 50 | // 判断鼠标左击 51 | 52 | if (!isBg(e.target.attrs)) { 53 | return; 54 | } 55 | console.log('e.evt.button', e.evt.button); 56 | if (e.evt.button === 0) { 57 | rectangleStart(stage, canvas); 58 | } 59 | }); 60 | 61 | stage.on('mousemove', () => { 62 | rectangleMove(stage, canvas); 63 | }); 64 | 65 | stage.on('mouseup', () => { 66 | rectangleEnd(stage, canvas); 67 | }); 68 | 69 | // 上下文菜单 70 | console.log('context=>'); 71 | this.contextMenu = createContextMenu(this, canvas); 72 | 73 | stage.on('contextmenu', (e: Konva.KonvaEventObject) => { 74 | e.evt.preventDefault(); 75 | if (isBg(e.target.attrs)) { 76 | return; 77 | } 78 | this.currNode = e.target as Konva.Shape; 79 | this.contextMenu.style.display = 'initial'; 80 | // var containerRect = stage 81 | // .container() 82 | // .getBoundingClientRect(); 83 | 84 | // console.log('containerRect=>', containerRect, stageRef.current.getPointerPosition()); 85 | this.contextMenu.style.top = `${e?.evt?.clientY + 1}px`; 86 | this.contextMenu.style.left = `${e?.evt?.clientX + 1}px`; 87 | }); 88 | } 89 | } 90 | 91 | export default Satge; 92 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/image-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Button } from 'antd'; 3 | import useModel from 'flooks'; 4 | import canvasModel from '@/models1/canvasModel'; 5 | import { InfiniteScrollList, WaterfallFlow } from '@/components'; 6 | import { getPage } from '@/services/photo'; 7 | 8 | import type { InfiniteScrollListRef } from '@/components/infinite-scroll-list'; 9 | import type { ImageItem } from '@/components/waterfall-flow'; 10 | 11 | import styles from './imagePanel.less'; 12 | 13 | const image1 = [ 14 | '/image1/1.jpg', 15 | '/image1/2.jpg', 16 | '/image1/3.jpg', 17 | '/image1/4.jpg', 18 | '/image1/5.jpg', 19 | '/image1/6.jpg', 20 | '/image1/7.jpg', 21 | '/image1/8.jpg', 22 | '/image1/9.jpg', 23 | '/image1/10.jpg', 24 | '/image1/11.jpg', 25 | '/image1/12.jpg', 26 | '/image1/13.jpg', 27 | '/image1/14.png', 28 | '/image1/15.jpeg', 29 | ]; 30 | const image2 = [ 31 | '/image2/1.jpg', 32 | '/image2/2.jpg', 33 | '/image2/3.jpg', 34 | '/image2/4.jpg', 35 | '/image2/5.jpg', 36 | '/image2/6.jpg', 37 | '/image2/7.jpg', 38 | '/image2/8.jpg', 39 | '/image2/9.jpg', 40 | '/image2/10.jpg', 41 | '/image2/11.jpg', 42 | '/image2/12.jpg', 43 | , 44 | '/image2/13.jpeg', 45 | '/image2/14.jpeg', 46 | ]; 47 | 48 | export interface IPanelPanelProps {} 49 | 50 | const ImagePanel: FC = (props) => { 51 | const { canvasRef } = useModel(canvasModel); 52 | const ref = React.useRef(); 53 | 54 | const requestPhotoList = async (params: PaginationParams) => { 55 | return await getPage(params.pageIndex, 35, 2); 56 | }; 57 | 58 | const onImageClick = ( 59 | e: React.MouseEvent, 60 | data: ImageItem, 61 | ) => { 62 | console.log('e', e, data); 63 | }; 64 | 65 | return ( 66 |
67 | 73 | {(data) => { 74 | return ( 75 | 81 | ); 82 | }} 83 | 84 | {/*
85 | {image1.map((item) => { 86 | return ( 87 | canvasRef?.addImage(item)} 89 | key={item} 90 | src={item} 91 | /> 92 | ); 93 | })} 94 |
95 |
96 | {image2.map((item) => { 97 | return ( 98 | canvasRef?.addImage(item)} 100 | key={item} 101 | src={item} 102 | /> 103 | ); 104 | })} 105 |
*/} 106 |
107 | ); 108 | }; 109 | 110 | export default ImagePanel; 111 | -------------------------------------------------------------------------------- /src/bomponents/file-modal/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useRef } from 'react'; 2 | import { Modal, Button, Tabs, List } from 'antd'; 3 | import { useImmer } from 'use-immer'; 4 | import ModalViewUtils from '../uitls/modelViewUtils'; 5 | import { InfiniteScrollList, WaterfallFlow } from '@/components'; 6 | import request from '@/utils/request'; 7 | import { getPage } from '@/services/photo'; 8 | import Upload from '../upload/index'; 9 | 10 | import type { PaginationParams } from '@/typing'; 11 | import type { InfiniteScrollListRef } from '@/components/infinite-scroll-list'; 12 | import type { ImageItem } from '@/components/waterfall-flow'; 13 | import styles from './index.less'; 14 | 15 | const { TabPane } = Tabs; 16 | 17 | export interface IModalViewProps extends IViewProps { 18 | title?: string; 19 | onCancel?: () => void; 20 | } 21 | 22 | // view 组件的具体逻辑 23 | const ModalView: FC = (props) => { 24 | const ref = React.useRef(); 25 | const requestPhotoList = async (params: PaginationParams) => { 26 | return await getPage(params.pageIndex, 35, 2); 27 | }; 28 | 29 | const requestPhotoListSystem = async (params: PaginationParams) => { 30 | return await getPage(params.pageIndex, 35, 1); 31 | }; 32 | 33 | const onUploadSuccess = () => { 34 | console.log('upload success'); 35 | ref.current?.reload(); 36 | }; 37 | 38 | const onImageClick = ( 39 | e: React.MouseEvent, 40 | data: ImageItem, 41 | ) => { 42 | console.log('e', e, data); 43 | }; 44 | 45 | return ( 46 |
47 | 48 | 49 | 50 | 59 | {(data) => { 60 | return ( 61 | 67 | ); 68 | }} 69 | 70 | 71 | 72 | 81 | {(data) => { 82 | return ( 83 | 89 | ); 90 | }} 91 | 92 | 93 | 94 |
95 | ); 96 | }; 97 | 98 | // 实例化工具类,传入对用的组件 99 | export default new ModalViewUtils(ModalView); 100 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import classNames from 'classnames'; 3 | import { Tooltip } from 'antd'; 4 | import { 5 | BorderOuterOutlined, 6 | AppstoreAddOutlined, 7 | LineHeightOutlined, 8 | FileImageOutlined, 9 | GatewayOutlined, 10 | BarcodeOutlined, 11 | } from '@ant-design/icons'; 12 | import { useImmer } from 'use-immer'; 13 | import TextPanel from './text-panel'; 14 | import ImagePanel from './image-panel'; 15 | import TemplatePanel from './template-panel'; 16 | import BGPanel from './bg-panel'; 17 | import styles from './slider.less'; 18 | 19 | const itemData = [ 20 | { 21 | id: 'template', 22 | name: '模板1', 23 | icon: , 24 | activeIcon: ( 25 | 26 | ), 27 | }, 28 | { 29 | id: 'text', 30 | name: '文字', 31 | icon: , 32 | activeIcon: ( 33 | 34 | ), 35 | }, 36 | { 37 | id: 'image', 38 | name: '图片', 39 | icon: , 40 | activeIcon: ( 41 | 42 | ), 43 | }, 44 | { 45 | id: 'label', 46 | name: '标记', 47 | icon: , 48 | activeIcon: , 49 | }, 50 | { 51 | id: 'background', 52 | name: '背景', 53 | icon: , 54 | activeIcon: , 55 | }, 56 | ]; 57 | 58 | export interface IHeaderProps {} 59 | 60 | const Slider: FC = (props) => { 61 | const [state, setState] = useImmer({ 62 | active: 'template', 63 | }); 64 | 65 | const handleItemClick = (key: string) => { 66 | setState({ active: key }); 67 | }; 68 | 69 | return ( 70 |
71 |
72 | {itemData.map((item) => { 73 | return ( 74 | 75 |
handleItemClick(item.id)} 77 | className={`${styles.item} ${ 78 | state.active === item.id ? styles.active : '' 79 | }`} 80 | > 81 | {state.active === item.id ? item.icon : item.activeIcon} 82 | {/* */} 91 |
92 |
93 | ); 94 | })} 95 |
96 |
97 | {state.active === 'template' && } 98 | {state.active === 'text' && } 99 | {state.active === 'image' && } 100 | {state.active === 'background' && } 101 |
102 |
103 | ); 104 | }; 105 | 106 | export default Slider; 107 | -------------------------------------------------------------------------------- /src/styles/theme/default.less: -------------------------------------------------------------------------------- 1 | // ui框架 class 前缀 2 | @fast-prefix: 'fast'; 3 | 4 | // base 5 | @font-size-sm: 12px; 6 | @font-size-base: 14px; 7 | @font-size-lg: @font-size-base + 2px; 8 | 9 | // paddings 10 | @padding-lg: 24px; 11 | @padding-md: 16px; 12 | @padding-sm: 12px; 13 | @padding-xs: 8px; 14 | 15 | // margins 16 | @margin-lg: 24px; 17 | @margin-md: 16px; 18 | @margin-sm: 12px; 19 | @margin-xs: 8px; 20 | 21 | // height rules 22 | @height-base: 32px; 23 | @height-lg: 40px; 24 | @height-sm: 24px; 25 | 26 | /** 头部导航*/ 27 | @header-bg-color:rgb (16, 38, 58); 28 | @header-select-bg-color:rgb (25, 118, 210); 29 | @header-avatar:rgb (0, 57, 108); 30 | @header-default-font-color: #e4e6ea; 31 | @header-default-font-color:rgb (228, 230, 234); 32 | @header-menu-height: 50px; 33 | @header-submenu-height: 40px; 34 | 35 | /** 默认设置*/ 36 | @blue:rgb(25, 118, 210); 37 | @black: rgb(0, 0, 0); 38 | @activeColor: #1890ff; 39 | @focusColor: #40a9ff; 40 | 41 | //超链接,已选中,确认、提交按钮底色 42 | @default-color:fade(@blue, 100%); 43 | // 字体超链接 44 | @default-link-color:fade(@blue, 85%); 45 | // 列表hover状态背景底色 46 | @default-bg-color:fade(@blue, 9%); 47 | // 暂时未找到已用的 48 | @box-shadow-color: #d5dfe8; 49 | 50 | // 图表成功色 51 | @chart-color: rgba(19, 194, 194); 52 | 53 | /** line up 排队中*/ 54 | @line-up-color: rgba(28, 122, 238); 55 | 56 | // 项目提示框颜色区份 57 | 58 | /** warning(橘黄色) 异常,未发布,未部署 */ 59 | @warn-icon-color: rgba(250, 173, 20); 60 | /** warning 提示色背景 */ 61 | @warn-bg-color: rgba(255, 251, 230); 62 | /** warning 提示色边框 */ 63 | @warn-bor-color: rgba(255, 229, 143); 64 | 65 | /** success(绿色) 提示色icon 成功色, 已部署,部署成功,已上线 */ 66 | @suc-icon-color: rgba(82, 196, 26); 67 | /** success 提示色背景 */ 68 | @suc-bg-color: rgba(246, 255, 237); 69 | /** success 提示色边框 */ 70 | @suc-bor-color: rgba(183, 235, 143); 71 | 72 | /** fail(红色) 失败 */ 73 | @fail-icon-color: rgba(245, 34, 45); 74 | /** fail 提示色背景 */ 75 | @fail-bg-color: rgba(255, 241, 240); 76 | /** fail 提示色边框 */ 77 | @fail-bor-color: rgba(255, 163, 158); 78 | 79 | /** 通知(蓝色) 运行中,发布中 */ 80 | @mes-icon-color: @default-color; 81 | /** 通知 提示色背景 */ 82 | @mes-bg-color: rgba(230, 247, 255); 83 | /** 通知 提示色边框 */ 84 | @mes-bor-color: rgba(145, 213, 255); 85 | 86 | //Font 文本颜色 87 | // 面包屑当前项 88 | @fontColor: rgb(16, 38, 58); 89 | 90 | @default-font-color: @fontColor; 91 | // 主标题文字颜色(form中label标题) 92 | @default-font-title-color:fade(@fontColor, 85%); 93 | // 正文颜色,输入类文字颜色,操作类涉及文字,鼠标划过hover文字颜色,状态提示类文字,tab无底色时选中状态,icon默认颜色 94 | @title-font-color:fade(@fontColor, 65%); 95 | // 次文字颜色,placeholder颜色,tab无底色时未选中状态 96 | @minor-font-color:fade(@fontColor, 45%); 97 | // input disable文字颜色,icon disabled状态设计颜色 98 | @font-disable-color:fade(@fontColor, 25%); 99 | // 分割线颜色,input默认边框。 100 | @font-border-color:fade(@fontColor, 15%); 101 | //鼠标划过hover文字颜色 102 | @hover-font-color:fade(@fontColor, 85%); 103 | 104 | /** 按钮*/ 105 | @btn-default-bg: @default-color; 106 | @btn-default-hover-color: @default-color; 107 | @btn-delete-bg: #e0714f; 108 | @btn-delete-hover-color: #e0653f; 109 | // @btn-gray-color:@text-color; 110 | @btn-gray-bg: #fff; 111 | @default-white-bg: #fff; 112 | 113 | /** 分割线 */ 114 | @divider-color:fade(@fontColor, 15%); 115 | 116 | /** input*/ 117 | @input-hover-color: #0c71ed; 118 | @input-focus-color: #196dd4; 119 | 120 | /** slider*/ 121 | @slider-color: #e7edf5; 122 | // @bg-color:fade(@fontColor, 9%); 123 | @bg-slider-color: rgba(19, 194, 194, 0.1); 124 | @bg-slider-fill-color: rgba(19, 194, 194); 125 | @bg-color:rgba(16, 38, 58, 0.04); 126 | 127 | // 组件/浮层圆角 128 | @border-radius-base: 4px; 129 | 130 | @disabled-color: #f5f5f5; 131 | @border-color-base: #d9d9d9; 132 | -------------------------------------------------------------------------------- /src/models1/canvasModel.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback } from 'react'; 2 | import { ShapePanelEnum } from '@/enum'; 3 | import { DatModelItem, LocationItem, UndoRedoActionType } from '@/typing'; 4 | import Konva from 'konva'; 5 | import _ from 'lodash'; 6 | 7 | let threshold = 100; 8 | 9 | const CanvasModel = ({ get, set }: any) => ({ 10 | shapePanelType: ShapePanelEnum.ShapePanel, // 用枚举代替 11 | selectNode: null, 12 | editNode: null, 13 | stageRef: null, 14 | layerRef: null, 15 | canvasRef: null, 16 | bgRef: null, // 背景实例对象 17 | stageData: { 18 | width: 1200, 19 | height: 700, 20 | scale: 1, 21 | }, 22 | loading: false, 23 | nodeLocations: [], 24 | undoRedoData: { 25 | // 撤销重做数据结构 26 | activeSnapshot: null, // 当前激活的快照数据 27 | snapshots: [], // 存储的快照数据 28 | current: -1, // 当前索引 29 | }, 30 | changeCanvas: (currCanvasModel: any) => { 31 | set((state: any) => { 32 | return Object.assign(state, currCanvasModel); 33 | }); 34 | }, 35 | setTemplate: (data: any) => { 36 | const { stageData } = get(); 37 | set((state: any) => { 38 | return { 39 | selectNode: data?.nodes[0], 40 | stageData: { 41 | width: data.width * stageData.scale, 42 | height: data.height * stageData.scale, 43 | scale: stageData.scale, 44 | }, 45 | }; 46 | }); 47 | }, 48 | setLoading: (loading: boolean) => { 49 | set((state: any) => { 50 | return { loading: loading }; 51 | }); 52 | }, 53 | addNodeLocation: (locationItem: LocationItem) => { 54 | set((state: any) => { 55 | // console.log('state.nodeLocations=>', nodeLocations); 56 | const result = [...state.nodeLocations, locationItem]; 57 | return { 58 | nodeLocations: result, 59 | }; 60 | }); 61 | }, 62 | updateNodeLocation: (locationItem: LocationItem) => { 63 | set((state: any) => { 64 | let index = state.nodeLocations.findIndex( 65 | (item: LocationItem) => item.id === locationItem.id, 66 | ); 67 | state.nodeLocations[index] = locationItem; 68 | return { 69 | nodeLocations: [...state.nodeLocations], 70 | }; 71 | }); 72 | }, 73 | // 更新快照数据 74 | updateUndoRedoData: ({ type, data }: UndoRedoActionType) => { 75 | const newUndoRedoData: any = {}; 76 | 77 | set((state: any) => { 78 | const { current, snapshots } = state.undoRedoData; 79 | if (type === 'push') { 80 | const newData = _.cloneDeep(data); 81 | if (current === -1) { 82 | newUndoRedoData.snapshots = [...snapshots, newData]; 83 | } else { 84 | // 当前已经撤销,重新操作的时候要把某些记录取消 85 | newUndoRedoData.snapshots = snapshots 86 | .slice(0, current) 87 | .concat([newData]); 88 | } 89 | newUndoRedoData.activeSnapshot = null; 90 | newUndoRedoData.current = -1; 91 | } 92 | 93 | if (type === 'undo') { 94 | // debugger; 95 | if (current === -1) { 96 | newUndoRedoData.current = snapshots.length - 1; 97 | } else { 98 | newUndoRedoData.current = current - 1; 99 | } 100 | newUndoRedoData.activeSnapshot = snapshots[newUndoRedoData.current]; 101 | } 102 | 103 | if (type === 'redo') { 104 | if (current != -1) { 105 | newUndoRedoData.current = current + 1; 106 | } 107 | if (current === snapshots.length - 1) { 108 | newUndoRedoData.activeSnapshot = null; 109 | newUndoRedoData.current = -1; 110 | } else { 111 | newUndoRedoData.activeSnapshot = snapshots[newUndoRedoData.current]; 112 | } 113 | } 114 | 115 | if (newUndoRedoData?.snapshots?.length > threshold) { 116 | // debugger; 117 | newUndoRedoData.snapshots = newUndoRedoData.snapshots.splice( 118 | -threshold, 119 | ); 120 | } 121 | 122 | return { 123 | undoRedoData: { 124 | ...state.undoRedoData, 125 | ...newUndoRedoData, 126 | }, 127 | }; 128 | }); 129 | }, 130 | }); 131 | 132 | export default CanvasModel; 133 | -------------------------------------------------------------------------------- /src/components/infinite-scroll-list/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | useEffect, 4 | useRef, 5 | useImperativeHandle, 6 | ReactElement, 7 | } from 'react'; 8 | import InfiniteScroll from 'react-infinite-scroll-component'; 9 | import { useImmer } from 'use-immer'; 10 | import classnames from 'classnames'; 11 | import { Spin, Empty } from 'antd'; 12 | import type { 13 | PaginationParams, 14 | IInfiniteScrollListResponseData, 15 | } from '@/typing'; 16 | 17 | import styles from './index.less'; 18 | 19 | const EmptyCpt = () => ( 20 | 29 | ); 30 | 31 | export type InfiniteScrollListRef = { 32 | reload: () => void; 33 | }; 34 | export interface IInfiniteScrollListProps { 35 | style?: React.CSSProperties; 36 | className?: string; 37 | request: ( 38 | params: PaginationParams, 39 | ) => Promise; 40 | endMessage?: React.ReactNode; 41 | renderItem?: (item: Record) => React.ReactNode; 42 | children?: React.ReactElement | ((data: Array) => ReactElement); 43 | } 44 | 45 | const InfiniteScrollList = React.forwardRef( 46 | (props: IInfiniteScrollListProps, ref) => { 47 | const { request, renderItem, style, className, children } = props; 48 | 49 | const pageIndexRef = useRef(1); 50 | const [state, setState] = useImmer({ 51 | loading: false, 52 | data: [], 53 | hasMore: true, 54 | } as any); 55 | 56 | useImperativeHandle( 57 | ref, 58 | () => { 59 | return { 60 | reload: () => { 61 | setState((draft: Record) => { 62 | draft.loading = false; 63 | draft.data = []; 64 | draft.hasMore = true; 65 | }); 66 | pageIndexRef.current = 1; 67 | loadMoreData(); 68 | }, 69 | }; 70 | }, 71 | [], 72 | ); 73 | 74 | const loadMoreData = async () => { 75 | if (state.loading) { 76 | return; 77 | } 78 | const res = await request({ 79 | pageIndex: pageIndexRef.current, 80 | } as PaginationParams); 81 | 82 | pageIndexRef.current += 1; 83 | // console.log('res=>12312', res); 84 | setState((draft: any) => { 85 | const newData = [...state.data, ...res.rows]; 86 | draft.data = newData; 87 | draft.hasMore = newData.length < res.count; 88 | }); 89 | }; 90 | 91 | useEffect(() => { 92 | loadMoreData(); 93 | }, []); 94 | 95 | const classNames = classnames(styles.list, className); 96 | console.log('hasMOre=>', state.hasMore); 97 | 98 | return ( 99 |
111 | 119 | 120 |
121 | ) : ( 122 | '' 123 | ) 124 | } 125 | endMessage={ 126 | state.data.length === 0 ? ( 127 | 128 | ) : ( 129 |
已经加载到底了!
130 | ) 131 | } 132 | scrollableTarget="scrollableDiv" 133 | > 134 | {typeof children === 'function' ? ( 135 | children(state.data) 136 | ) : ( 137 |
138 | {state.data.map((item: any) => { 139 | return renderItem?.(item); 140 | })} 141 |
142 | )} 143 | 144 |
145 | ); 146 | }, 147 | ); 148 | 149 | export default InfiniteScrollList; 150 | -------------------------------------------------------------------------------- /src/utils/util.ts: -------------------------------------------------------------------------------- 1 | import type { DatModelItem, LocationItem } from '@/typing'; 2 | import { ShapePanelEnum } from '@/enum'; 3 | import Konva from 'konva'; 4 | import { Shape, Group } from 'konva/lib/Shape'; 5 | import LocalStorage from './local-storage'; 6 | 7 | export const uuid = () => { 8 | const temp_url = URL.createObjectURL(new Blob()); 9 | const uuid = temp_url.toString(); // blob:https://xxx.com/b250d159-e1b6-4a87-9002-885d90033be3 10 | URL.revokeObjectURL(temp_url); 11 | return uuid.substr(uuid.lastIndexOf('/') + 1); 12 | }; 13 | 14 | export const getToken = () => { 15 | console.log('getToken=>'); 16 | return `Bearer ${LocalStorage.get('token')}`; 17 | }; 18 | 19 | export const getCenterXY = ( 20 | canvasWidth: number, 21 | canvasHeight: number, 22 | elementWidth: number, 23 | elementHeight: number, 24 | ) => { 25 | const x = canvasWidth / 2 - elementWidth / 2; 26 | const y = canvasHeight / 2 - elementHeight / 2; 27 | return [x, y]; 28 | }; 29 | 30 | export const getShapePanelTypeBySelectNode = (selectNode: DatModelItem) => { 31 | if (!selectNode) { 32 | return ShapePanelEnum.ShapePanel; 33 | } 34 | if (selectNode.type === 'text-input' || selectNode.type === 'text') { 35 | return ShapePanelEnum.TextPanel; 36 | } 37 | if (selectNode.type === 'image') { 38 | return ShapePanelEnum.ImagePanel; 39 | } 40 | return ShapePanelEnum.ShapePanel; 41 | }; 42 | 43 | export const getRGBAValue = (color: any) => { 44 | return `rgba(${color.r}, ${color.g}, ${color.b}, ${color.a})`; 45 | }; 46 | 47 | export const isEqual = (one: number, two: number) => { 48 | return two - one >= 0; 49 | }; 50 | 51 | export const downloadURI = (uri: string, name: string) => { 52 | let link: any = document.createElement('a'); 53 | link.download = name; 54 | link.href = uri; 55 | document.body.appendChild(link); 56 | link.click(); 57 | document.body.removeChild(link); 58 | link = null; 59 | }; 60 | 61 | export const getScalePercent = (scale: number) => { 62 | return parseInt((scale * 100).toString()) + '%'; 63 | }; 64 | 65 | export const equal: any = (num1: number, num2: number) => { 66 | return num1.toFixed(2) == num2.toFixed(2); 67 | }; 68 | 69 | export const equalOne: any = (num1: number) => { 70 | if (num1 <= 1.03 && num1 >= 0.99) { 71 | return true; 72 | } 73 | 74 | return false; 75 | }; 76 | 77 | export const getShapeChildren = (children: Array) => { 78 | return children.map((item) => { 79 | return { 80 | ...item.attrs, 81 | x: item.x(), 82 | y: item.y(), 83 | }; 84 | }); 85 | }; 86 | 87 | export const getShape = (shape: Group | Shape) => { 88 | const currShape: Group | Shape = { 89 | ...shape.attrs, 90 | x: shape.x(), 91 | y: shape.y(), 92 | rotation: shape.rotation(), 93 | skewX: shape.skewX(), 94 | skewY: shape.skewY(), 95 | scaleX: shape.scaleX(), 96 | scaleY: shape.scaleY(), 97 | }; 98 | 99 | if (shape.children && shape.children.length > 0) { 100 | currShape.children = getShapeChildren(shape.children); 101 | } 102 | return currShape; 103 | }; 104 | 105 | /** 106 | * 一维数组,转换成二维数据 107 | * @param splitCount 每组的数量 108 | */ 109 | export function splitArray( 110 | data: Array, 111 | splitCount: number, 112 | ): Array> { 113 | if (data.length === 0) { 114 | return []; 115 | } 116 | const result = []; 117 | const group = Math.ceil(data.length / splitCount); // 可以拆分成多少组 118 | let upGroupIndex = 0; 119 | for (let i = 0; i < group; i++) { 120 | const nextGroupIndex = upGroupIndex + splitCount; 121 | const groupData = data.slice(upGroupIndex, nextGroupIndex); 122 | result.push(groupData); 123 | upGroupIndex = nextGroupIndex; 124 | } 125 | return result; 126 | } 127 | 128 | /** 129 | * 拆分成group 130 | * @param data 131 | * @param groupCount 132 | */ 133 | export function splitGroupArray( 134 | data: Array, 135 | groupCount: number, 136 | ): Array> { 137 | if (data.length === 0) { 138 | return []; 139 | } 140 | const result = []; 141 | for (let i = 0; i < data.length; i++) { 142 | const currData = data[i]; 143 | const index = i % groupCount; 144 | if (result[index]) { 145 | result[index].push(currData); 146 | } else { 147 | result[index] = [currData]; 148 | } 149 | } 150 | return result; 151 | } 152 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Welcome to fast-image-editor 👋

2 |

3 | Version 4 | 5 | Documentation 6 | 7 | 8 | Maintenance 9 | 10 | 11 | License: MIT 12 | 13 |

14 | 15 | > 一块开源图片编辑器,采用React+Typescript+React-Konva开发。 16 | 17 | ### 推荐一款AI对话平台 EVO Chat 18 | > 19 | Evo Chat(Evolution Chat)是一个现代化的开源 AI 对话平台。致力于打造最优雅的大模型交互入口。它支持对接 ChatGPT,Deepseak, 等主流大语言模型(LLM)服务商,并在此基础上不断进化,识库增强、多模态处理、MCP(Model Control Protocol)等扩展能力,让 AI 能力更加丰富。支持全平台部署(Web、App、Windows、Mac、Linux),为用户提供无处不在的 AI 能力入口。 20 | 21 | * 访问地址:https://hevoai.com 22 | * github: https://github.com/evo-family/evo-chat 23 | 24 | ### 🏠 [Homepage](https://github.com/jiechud/fast-image-editor#readme) 25 | 26 | ### ✨ [演示地址](http://39.97.252.98:3000/) 27 | 28 | ![示例图](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/97cb450cd5664d92b6d9ccffa4ef9998~tplv-k3u1fbpfcp-watermark.awebp) 29 | 30 | 31 | ## Install 32 | 33 | ```sh 34 | yarn install 35 | ``` 36 | 37 | ## Usage 38 | 39 | ```sh 40 | yarn run start 41 | ``` 42 | 43 | ## Run tests 44 | 45 | ```sh 46 | yarn run test 47 | ``` 48 | 49 | 50 | ## 项目目录 51 | ``` 52 | . 53 | ├── canvas-components 54 | │ ├── canvas //画布组件 55 | │ ├── layout //页面布局 56 | │ ├── shape-panel // 右侧面板 57 | │ └── transformer-wrapper // 支持transformer高阶组件 58 | ├── components 59 | │ ├── color-select // 颜色选择器 60 | │ ├── context-menu // 右键菜单 61 | │ ├── image // 图片 62 | │ ├── text // 文本 63 | │ ├── text-input // 文本输入 64 | │ └── toolbar // 导航 65 | ├── enum.ts 66 | ├── global.css 67 | ├── hooks 68 | │ └── useImage.tsx // 图片kooks 69 | ├── models1 // 状态管理 70 | │ ├── canvasDataModel.ts 71 | │ └── canvasModel.ts 72 | ├── pages 73 | │ ├── index.less 74 | │ └── index.tsx 75 | ├── styles 76 | │ ├── index.less 77 | │ └── theme 78 | ├── typing.ts 79 | └── utils 80 | └── util.ts 81 | ``` 82 | ## 功能特性 83 | 84 | 目前主要实现了简单的图片编辑,支持文字,图片等。 85 | 86 | ### 已支持的功能列表 87 | 88 | - [x] layout布局 89 | - [x] 文字编辑组件 90 | - [x] 图片编辑组件 91 | - [x] 画布放大缩小 92 | - [x] 画布右键菜单 93 | - [x] 图片下载 94 | - [x] 背景图支持 95 | 96 | 97 | 98 | ### 待实现的功能列表 99 | 100 | - [ ] 工具类操作支持上一步下一步 101 | - [ ] 图形组件 102 | - [ ] 标记组件 103 | - [ ] 画布多个元素组合 104 | - [ ] 文字组件增加,字体丰富,透明度等。 105 | - [ ] 画布参考线 106 | - [ ] 画布多个尺寸,支持多平台 107 | - [ ] 接入后台,实现登录,保存模板 108 | 109 | 110 | ## 系列文章 111 | 112 | * [两个周末写了个图片编辑器](https://juejin.cn/post/6996926544182542366) 113 | * [给图片编辑器添加了辅助线](https://juejin.cn/post/6997926959917318181) 114 | * [给图片编辑器添加了【撤销重做】功能](https://juejin.cn/post/6998287682593882142) 115 | * [给图片编辑器添加了【框选节点】功能](https://juejin.cn/post/7003604608320798734) 116 | ## 项目架构 117 | 118 | 项目用React umi开发框架,采用typescript编写,图片编辑功能用的是`react-konva`,考虑后期可能核心的编辑功能整体做成一个组件,所以没有umi里提供的`useModel`去做状态处理,采用的是`flooks`。 119 | 120 | 121 | 技术栈 122 | | 技术 | 说明 | 官网 | 123 | | ---- | ---- | ---- | 124 | | typescript | JavaScript 的一个超集,支持 ECMAScript 6 | https://www.tslang.cn/ | 125 | | umi | 插件化的企业级前端应用框架。 | https://umijs.org/zh-CN | 126 | | react-konva |用于使用[React](http://facebook.github.io/react/)绘制复杂的画布图形 。 | https://github.com/konvajs/react-konva | 127 | | immer | 创建不可变数据 | https://immerjs.github.io/immer/docs/introduction | 128 | | flooks | 一个 React Hooks 状态管理器,支持惊人的 re-render 自动优化 | https://github.com/nanxiaobei/flooks | 129 | | ahooks | 提供了大量自应用的高级 Hooks | https://github.com/alibaba/hooks | 130 | | react-color| 一个React颜色选择器 | https://github.com/casesandberg/react-color | 131 | 132 | ## 联系我 133 | 134 | 建立了一个微信交流群,请添加微信号`q1454763497`,备注`image editor`,我会拉你进群 135 | 136 | ## Author 137 | 138 | 👤 **杰出D** 139 | 140 | * Website: https://juejin.cn/user/2981531265821416/posts 141 | * Github: [@jiechud](https://github.com/jiechud) 142 | 143 | ## 🤝 Contributing 144 | 145 | Contributions, issues and feature requests are welcome!
Feel free to check [issues page](https://github.com/jiechud/fast-image-editor/issues). You can also take a look at the [contributing guide](https://github.com/jiechud/fast-image-editor/blob/master/CONTRIBUTING.md). 146 | 147 | ## Show your support 148 | 149 | Give a ⭐️ if this project helped you! 150 | 151 | ## 📝 License 152 | 153 | Copyright © 2021 [杰出D](https://github.com/jiechud).
154 | This project is [MIT](https://github.com/jiechud/fast-image-editor/blob/master/LICENSE) licensed. 155 | 156 | *** 157 | _This README was generated with ❤️ by [readme-md-generator](https://github.com/kefranabg/readme-md-generator)_ 158 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/canvas-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import PanelTitle from '../panel-title'; 3 | import { Form, Radio, Button } from 'antd'; 4 | import ProForm, { 5 | ModalForm, 6 | ProFormText, 7 | ProFormDateRangePicker, 8 | ProFormDigit, 9 | } from '@ant-design/pro-form'; 10 | import { useImmer } from 'use-immer'; 11 | import type { RadioChangeEvent } from 'antd'; 12 | import { ColorSelect } from '@/components'; 13 | import useModel from 'flooks'; 14 | import canvasDataModel from '@/models1/canvasDataModel'; 15 | import canvasModel from '@/models1/canvasModel'; 16 | import { FileModal } from '@/bomponents'; 17 | import styles from './canvasPanel.less'; 18 | 19 | export interface ICanvasPanelProps {} 20 | 21 | const canvasOptions = [ 22 | { label: '颜色', value: 'color' }, 23 | { label: '背景', value: 'bg' }, 24 | ]; 25 | 26 | const CanvasPanel: FC = (props) => { 27 | const [form] = Form.useForm(); 28 | const { width, height, changeCanvasModelDataItem, changeCanvasModel } = 29 | useModel(canvasDataModel); 30 | const { selectNode, canvasRef } = useModel(canvasModel); 31 | 32 | const [state, setState] = useImmer({ 33 | canvasOptionsValue: 'color', 34 | }); 35 | const onVisibleChange = (visible: boolean) => { 36 | if (visible) { 37 | // form.setFieldsValue({ 38 | // width: canvas.width, 39 | // height: canvas.height, 40 | // }); 41 | } 42 | }; 43 | 44 | const onChange = (e: RadioChangeEvent) => { 45 | setState((draft) => { 46 | draft.canvasOptionsValue = e.target.value; 47 | }); 48 | }; 49 | 50 | const colorChange = (color: string) => { 51 | // console.log('color=>', color); 52 | canvasRef?.updateShapeAttrsById(selectNode.id, { fill: color }); 53 | // changeCanvasModelDataItem({ 54 | // ...selectNode, 55 | // color, 56 | // }); 57 | }; 58 | 59 | const changeImage = () => { 60 | console.log('==='); 61 | FileModal.show({ 62 | title: '选择图片', 63 | width: 1200, 64 | }); 65 | }; 66 | 67 | console.log('selectNode?.color=>', selectNode?.fill, selectNode); 68 | return ( 69 |
70 | 画布 71 |
72 |
73 |
画布尺寸
74 | {/* 78 | form={form} 79 | title="调整画布尺寸" 80 | layout="horizontal" 81 | labelCol={{ span: 4, offset: 2 }} 82 | initialValues={{ 83 | width: width, 84 | height: height 85 | }} 86 | onVisibleChange={onVisibleChange} 87 | wrapperCol={{ span: 15 }} 88 | width={400} 89 | trigger={
编辑
} 90 | modalProps={{ 91 | onCancel: () => console.log('run'), 92 | }} 93 | onFinish={async (values) => { 94 | changeCanvasModel(values); 95 | return Promise.resolve(true); 96 | // return true; 97 | }} 98 | > 99 | 105 | 111 | */} 112 |
113 | 114 | {/*
*/} 115 |
119 |
宽:{width}px
120 | 121 |
高:{height}px
122 |
123 |
124 | 125 |
126 |
127 |
画布背景
128 |
129 | 130 |
134 |
135 | 143 | {state.canvasOptionsValue === 'color' ? ( 144 | 145 | ) : ( 146 | 153 | )} 154 |
155 |
156 |
157 |
158 | ); 159 | }; 160 | 161 | export default CanvasPanel; 162 | -------------------------------------------------------------------------------- /src/canvas-components/layout/slider-left/template-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Button } from 'antd'; 3 | import useModel from 'flooks'; 4 | import canvasModel from '@/models1/canvasModel'; 5 | import canvasDataModel from '@/models1/canvasDataModel'; 6 | import styles from './template.less'; 7 | 8 | const data = [ 9 | { 10 | id: 1, 11 | thumb: '/thumb/1.png', 12 | width: '1200', 13 | height: '700', 14 | nodes: [ 15 | { 16 | id: 'bg', 17 | type: 'color', 18 | fill: 'rgba(17, 167, 238, 1)', 19 | width: 1200, 20 | height: 700, 21 | }, 22 | { x: 372.0302029568915, y: 277.34530938123754 }, 23 | { 24 | id: 'd5b502bf-9994-4f9b-9c2d-dc619cf655fb', 25 | type: 'image', 26 | url: '/image1/14.png', 27 | width: 400, 28 | height: 200, 29 | x: 417.6325821808232, 30 | y: 221.17508755476706, 31 | name: 'node', 32 | draggable: true, 33 | rotation: 179.6909031540857, 34 | scaleX: 1.0000000000000047, 35 | scaleY: 1.000000000000023, 36 | skewX: -6.019490461639354e-16, 37 | }, 38 | { 39 | id: '4dca93ae-1e65-4d38-8e20-edfb4050147d', 40 | type: 'image', 41 | url: '/image1/14.png', 42 | width: 400, 43 | height: 200, 44 | x: 791.2887393353599, 45 | y: 479.1417165668663, 46 | name: 'node', 47 | draggable: true, 48 | }, 49 | { 50 | id: '4dbdfdb6-50c3-4fba-9c59-4fb69fb9029f', 51 | fontSize: 60, 52 | type: 'text-input', 53 | text: '添加了节点框选', 54 | fill: 'rgba(250, 250, 250, 1)', 55 | width: 485.77138050064934, 56 | x: 349.89892862820756, 57 | y: 249.4011976047904, 58 | name: 'node', 59 | draggable: true, 60 | scaleY: 0.9999999999999984, 61 | }, 62 | { fill: 'rgba(0,0,255,0.5)', visible: false }, 63 | ], 64 | }, 65 | 66 | { 67 | id: 2, 68 | thumb: '/thumb/2.png', 69 | width: '1200', 70 | height: '700', 71 | nodes: [ 72 | { id: 'bg', type: 'color', color: '#008CE5' }, 73 | { 74 | draggable: true, 75 | id: '9a97ba59-1c99-4b88-8d82-04d3616401dc', 76 | type: 'image', 77 | url: '/image2/13.jpeg', 78 | x: -73.68823795090792, 79 | y: -22.985759747934907, 80 | scaleX: 1, 81 | width: 1284.0483160382598, 82 | height: 722.2771777715211, 83 | rotation: 0, 84 | skewX: 0, 85 | skewY: 0, 86 | }, 87 | { 88 | draggable: false, 89 | x: 423.8188145751038, 90 | y: 289.4545454545455, 91 | id: '345c4003-38bb-413f-a71e-d80180496bb8', 92 | fontSize: 72, 93 | type: 'text-input', 94 | text: '清 爽 周 末', 95 | fill: 'rgba(255, 255, 255, 1)', 96 | width: 360, 97 | isSelected: false, 98 | height: 63, 99 | fontFamily: 'cursive', 100 | }, 101 | ], 102 | }, 103 | { 104 | id: 3, 105 | thumb: '/thumb/3.png', 106 | width: '1200', 107 | height: '700', 108 | nodes: [ 109 | { 110 | id: 'bg', 111 | width: 1200, 112 | height: 700, 113 | type: 'bg-image', 114 | url: '/bg2/10.jpeg', 115 | }, 116 | { 117 | draggable: true, 118 | x: 123.8380594517084, 119 | y: 67.56253190116469, 120 | id: '28608db7-7f68-4fb4-bfc9-54522364c617', 121 | fontSize: 107.99999999999949, 122 | type: 'text-input', 123 | text: '七夕快乐', 124 | fill: 'rgba(255, 255, 255, 1)', 125 | width: 476.99999999999966, 126 | isSelected: true, 127 | scaleX: 1, 128 | rotation: 23.962488974577667, 129 | skewX: -8.881784197001235e-16, 130 | skewY: 0, 131 | fontFamily: 'cursive', 132 | }, 133 | { 134 | draggable: true, 135 | x: 323.3506366307537, 136 | y: 462.7723796804081, 137 | id: '96bfb1a7-2c42-41a4-9255-ef774bca1d33', 138 | fontSize: 107.99999999999928, 139 | type: 'text-input', 140 | text: '七夕快乐', 141 | fill: 'rgba(255, 255, 255, 1)', 142 | width: 477.00000000000045, 143 | isSelected: true, 144 | scaleX: 1, 145 | rotation: 0, 146 | skewX: 0, 147 | skewY: 0, 148 | fontFamily: 'cursive', 149 | }, 150 | { 151 | draggable: true, 152 | x: 651.9310956836752, 153 | y: 276.0457854016509, 154 | id: '820b2926-ce01-445a-8eee-94f7b76871fa', 155 | fontSize: 108.00000000000152, 156 | type: 'text-input', 157 | text: '七夕快乐', 158 | fill: 'rgba(255, 255, 255, 1)', 159 | width: 476.9999999999952, 160 | isSelected: true, 161 | scaleX: 1, 162 | rotation: -29.70308988675858, 163 | skewX: -4.440892098500626e-16, 164 | skewY: 0, 165 | fontFamily: 'cursive', 166 | }, 167 | ], 168 | }, 169 | ]; 170 | 171 | export interface ITemplatePanelProps {} 172 | 173 | const TemplatePanel: FC = (props) => { 174 | const { stageRef, setLoading } = useModel(canvasModel); 175 | const { nodes, setTemplate } = useModel(canvasDataModel); 176 | 177 | const template = (data: any) => { 178 | setLoading(true); 179 | setTemplate(data); 180 | setTimeout(() => { 181 | setLoading(false); 182 | }, 500); 183 | }; 184 | 185 | const add = () => { 186 | const data = { 187 | id: 'new', 188 | thumb: '', 189 | width: '1200', 190 | height: '700', 191 | nodes: [ 192 | { 193 | id: 'bg', 194 | type: 'color', 195 | fill: '#F5EDDF', 196 | }, 197 | { 198 | draggable: true, 199 | x: 422.35063663075414, 200 | y: 291.7723796804092, 201 | id: '28608db7-7f68-4fb4-bfc9-54522364c617', 202 | fontSize: 60, 203 | type: 'text-input', 204 | text: '双击编辑文字', 205 | fill: '#000', 206 | width: 360, 207 | }, 208 | ], 209 | }; 210 | template(data); 211 | }; 212 | 213 | return ( 214 |
215 |
216 | 223 |
224 | {data.map((item) => { 225 | return ( 226 | template(item)} src={item.thumb} /> 227 | ); 228 | })} 229 |
230 | ); 231 | }; 232 | 233 | export default TemplatePanel; 234 | -------------------------------------------------------------------------------- /src/canvas-components/transformer-wrapper/groupTransformer.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | useRef, 4 | useEffect, 5 | useState, 6 | useCallback, 7 | memo, 8 | } from 'react'; 9 | import { Stage, Layer, Rect, Transformer } from 'react-konva'; 10 | // import { useModel } from 'umi'; 11 | import type { DataModel, DatModelItem, LocationItem } from '@/typing'; 12 | import { useTimeout } from 'ahooks'; 13 | import { useDebounceFn } from 'ahooks'; 14 | import Konva from 'konva'; 15 | import useModel from 'flooks'; 16 | import { isEqual, equal, getShapeChildren, getShape } from '@/utils/util'; 17 | 18 | import canvasDataModel from '@/models1/canvasDataModel'; 19 | import canvasModel from '@/models1/canvasModel'; 20 | import { useImmer } from 'use-immer'; 21 | 22 | type BaseProps = { 23 | [key: string]: any; 24 | }; 25 | 26 | export interface ITransformerWrapperProps { 27 | Component: FC; 28 | // shapeProps: any; 29 | // isSelected: boolean; 30 | // onSelect: any; 31 | // onChange: any; 32 | } 33 | 34 | let isA = false; 35 | let currSeItem = null; 36 | 37 | const TransformerWrapper = (Component: FC) => { 38 | const WrapperComponent: FC = (props) => { 39 | const { 40 | selectNode, 41 | shapePanelType, 42 | layerRef, 43 | changeCanvas, 44 | editNode, 45 | addNodeLocation, 46 | updateNodeLocation, 47 | nodeLocations, 48 | } = useModel(canvasModel); 49 | const { changeCanvasModelDataItem } = useModel(canvasDataModel); 50 | 51 | const [state, setState] = useImmer({ 52 | isDrag: false, 53 | }); 54 | 55 | const shapeRef = React.useRef(); 56 | const trRef = React.useRef(); 57 | const currScale = React.useRef(); 58 | 59 | const isSelected = 60 | props.id === selectNode?.id && props.id !== editNode?.id && !state.isDrag; 61 | 62 | // console.log( 63 | // 'group=>isSelected', 64 | // isSelected, 65 | // props, 66 | // selectNode?.id 67 | // ); 68 | 69 | const { run } = useDebounceFn( 70 | (e) => { 71 | console.log('onSelect=>执行了', e); 72 | // 阻止事件冒泡 73 | e.cancelBubble = true; 74 | 75 | changeCanvas({ 76 | selectNode: props, 77 | }); 78 | }, 79 | { wait: 300, leading: true, trailing: false }, 80 | ); 81 | 82 | useEffect(() => {}, []); 83 | 84 | useEffect(() => { 85 | if (isSelected) { 86 | // we need to attach transformer manually 87 | trRef.current.nodes([shapeRef.current]); 88 | trRef.current.node = shapeRef.current; 89 | trRef.current.getLayer().batchDraw(); 90 | } 91 | 92 | return () => { 93 | console.log('hooks destory', props.id); 94 | }; 95 | }, [isSelected]); 96 | 97 | const onTransform = () => { 98 | // console.log('onTransform=>'); 99 | if (props.type !== 'text-input') { 100 | //只有text-input处理 101 | return; 102 | } 103 | const node = shapeRef.current; 104 | const scaleX = node.scaleX(); 105 | const scaleY = node.scaleY(); 106 | 107 | // const currItem: any = { 108 | // ...props, 109 | // width: node.width() * scaleX, 110 | // height: node.height() * scaleY, 111 | // scaleX: 1, 112 | // }; 113 | // changeCanvasModelDataItem(currItem as DatModelItem); 114 | 115 | if ( 116 | !isEqual(scaleX, currScale.current.scaleX) && 117 | !isEqual(scaleY, currScale.current.scaleY) 118 | ) { 119 | // 反方向会出错 120 | // isEqual(scaleX, currScale.current.scaleX) 121 | // console.log('===============>',scaleX, scaleY, isEqual(scaleX, currScale.current.scaleX), isEqual(scaleY, currScale.current.scaleY)) 122 | return; 123 | } 124 | // const currItem: any = { 125 | // ...props, 126 | // width: node.width() * scaleX, 127 | // // height: node.height() * scaleY, 128 | // scaleX: 1, 129 | // }; 130 | 131 | const textNode = shapeRef.current; 132 | 133 | textNode.setAttrs({ 134 | width: textNode.width() * textNode.scaleX(), 135 | height: 'auto', 136 | // height: textNode.height() * textNode.scaleY(), 137 | scaleX: 1, 138 | // scaleY: 1, 139 | }); 140 | 141 | // console.log(textNode.height()); 142 | // changeCanvasModelDataItem(currItem as DatModelItem); 143 | }; 144 | 145 | const onDragStart = useCallback(() => { 146 | setState((draft) => { 147 | draft.isDrag = true; 148 | }); 149 | }, [layerRef?.current]); 150 | 151 | const onDragEnd = (e: Konva.KonvaEventObject) => { 152 | setState((draft) => { 153 | draft.isDrag = false; 154 | }); 155 | const currItem = getShape(e.target); 156 | console.log('gropItem=>', currItem); 157 | // debugger; 158 | changeCanvasModelDataItem(currItem as DatModelItem); 159 | }; 160 | 161 | const onTransformEnd = (e: Konva.KonvaEventObject) => { 162 | const node = shapeRef.current; 163 | const currItem = getShape(node); 164 | 165 | changeCanvasModelDataItem(currItem as DatModelItem); 166 | }; 167 | 168 | // console.log('props=>', props) 169 | return ( 170 | 171 | 183 | {isSelected && ( 184 | { 196 | // console.log('newBox', newBox); 197 | // 新盒子和旧盒子的宽度 198 | // const currItem = { 199 | // ...props, 200 | // height: newBox.height, 201 | // width: newBox.width 202 | // } 203 | // changeCanvasModelDataItem(currItem as DatModelItem); 204 | newBox.width = Math.max(30, newBox.width); 205 | return newBox; 206 | }} 207 | /> 208 | )} 209 | 210 | ); 211 | }; 212 | return WrapperComponent; 213 | }; 214 | 215 | export default TransformerWrapper; 216 | -------------------------------------------------------------------------------- /src/canvas-components/canvas/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | useRef, 4 | useEffect, 5 | useState, 6 | useCallback, 7 | useMemo, 8 | } from 'react'; 9 | import { Stage, Layer, Rect, Text, Group, Circle } from 'react-konva'; 10 | import Konva from 'konva'; 11 | import TransformerWrapper from '../transformer-wrapper'; 12 | import GroupTransformerWrapper from '../transformer-wrapper/groupTransformer'; 13 | import { TextInput, ContextMenu, Image, Toolbar } from '@/components'; 14 | import useModel from 'flooks'; 15 | import canvasDataModel from '@/models1/canvasDataModel'; 16 | import canvasModel from '@/models1/canvasModel'; 17 | import { useSize, useUpdate } from 'ahooks'; 18 | import { Spin } from 'antd'; 19 | // import mousetrap from 'mousetrap'; 20 | // import { shiftAndClick } from '@/utils/Keyboard'; 21 | import { uuid } from '@/utils/util'; 22 | import type { DatModelItem, BgModel, TextModel, GroupModel } from '@/typing'; 23 | import { removeLines, detectionToLine } from '@/core/utils/line1'; 24 | import CanvasClass from '@/core/Canvas'; 25 | 26 | import styles from './canvas.less'; 27 | import { render } from 'react-dom'; 28 | 29 | export interface ICanvasProps {} 30 | const newData = [ 31 | { 32 | id: 'bg', 33 | type: 'color', 34 | fill: '#F5EDDF', 35 | }, 36 | { 37 | name: 'node', 38 | draggable: true, 39 | x: 436, 40 | y: 49.772379680409145, 41 | id: '28608db7-7f68-4fb4-bfc9-54522364c617', 42 | fontSize: 60, 43 | type: 'text-input', 44 | text: '撤销重做案例', 45 | fill: '#000', 46 | width: 360.0000000000001, 47 | isSelected: true, 48 | lineHeight: 1, 49 | rotation: 0, 50 | scaleX: 1, 51 | scaleY: 1, 52 | offsetX: 0, 53 | offsetY: 0, 54 | skewX: 0, 55 | skewY: 0, 56 | visible: true, 57 | // height: 60.99999999999992, 58 | }, 59 | { 60 | width: 162.8722198534792, 61 | height: 123.15416489010939, 62 | name: 'node', 63 | draggable: true, 64 | id: '1', 65 | type: 'image', 66 | x: 142.42857142857156, 67 | y: 357.7714218418357, 68 | isSelected: false, 69 | rotation: 0, 70 | scaleX: 1, 71 | scaleY: 1, 72 | offsetX: 0, 73 | offsetY: 0, 74 | skewX: 0, 75 | skewY: 0, 76 | // "child": true, 77 | url: '/image2/1.jpg', 78 | }, 79 | { 80 | width: 193.94019696803468, 81 | height: 146.45514772602604, 82 | name: 'node', 83 | draggable: true, 84 | id: '2', 85 | type: 'image', 86 | x: 142.42857142857156, 87 | y: 81.90962940621816, 88 | isSelected: false, 89 | rotation: 0, 90 | scaleX: 1, 91 | scaleY: 1, 92 | offsetX: 0, 93 | offsetY: 0, 94 | skewX: 0, 95 | skewY: 0, 96 | // "child": true, 97 | url: '/image2/14.jpeg', 98 | }, 99 | { 100 | width: 193.94019696803468, 101 | height: 146.45514772602604, 102 | name: 'node', 103 | draggable: true, 104 | id: '3', 105 | type: 'image', 106 | x: 342.42857142857156, 107 | y: 181.90962940621816, 108 | isSelected: false, 109 | rotation: 0, 110 | scaleX: 1, 111 | scaleY: 1, 112 | offsetX: 0, 113 | offsetY: 0, 114 | skewX: 0, 115 | skewY: 0, 116 | // "child": true, 117 | url: '/image2/14.jpeg', 118 | }, 119 | // { 120 | // "draggable": true, 121 | // "name": "group", 122 | // "id": "b366673e-a7cf-445e-8e52-65ce3ecb7b81", 123 | // "type": "group", 124 | // "x": -142.61460101867573, 125 | // "y": 29.278321955451514, 126 | // "isSelected": true, 127 | // "rotation": 0, 128 | // "scaleX": 1, 129 | // "scaleY": 1, 130 | // "offsetX": 0, 131 | // "offsetY": 0, 132 | // "skewX": 0, 133 | // "skewY": 0, 134 | // "children": [ 135 | 136 | // ] 137 | // } 138 | ]; 139 | // const TransformerHtml2 = TransformerWrapper(Group); 140 | 141 | const Canvas: FC = (props) => { 142 | const { changeCanvas, loading } = useModel(canvasModel); 143 | const { nodes } = useModel(canvasDataModel); 144 | const ref = useRef(null); 145 | const refCanvas = useRef(); 146 | const size = useSize(ref); 147 | const update = useUpdate(); 148 | 149 | useEffect(() => { 150 | console.log('nodes', nodes, loading); 151 | if (loading) { 152 | return; 153 | } 154 | 155 | let canvas = new CanvasClass('container', 1200, 700); 156 | // console.log('canvas', canvas); 157 | canvas.init(nodes as any); 158 | canvas.on('clickNode', (itemModel) => { 159 | console.log('itemModel', itemModel); 160 | changeCanvas({ 161 | selectNode: itemModel, 162 | }); 163 | }); 164 | 165 | window.canvas = canvas; 166 | refCanvas.current = canvas; 167 | 168 | if (ref.current) { 169 | const { width, height } = canvas.canvasAttr; 170 | const scaleX = (ref.current.offsetHeight - 120) / height; 171 | const scaleY = (ref.current.offsetWidth - 120) / width; 172 | let scale = Math.min(scaleX, scaleY); 173 | if (scale > 1) scale = 1; 174 | console.log('scale=>', scale); 175 | canvas.updateCanvasAttr(scale); 176 | // update(); 177 | 178 | changeCanvas({ 179 | canvasRef: refCanvas.current, 180 | update: update, 181 | }); 182 | } 183 | 184 | return () => { 185 | canvas.stage.destroyChildren(); 186 | canvas.stage.destroy(); 187 | }; 188 | }, [loading, nodes]); 189 | 190 | // useEffect(() => { 191 | // if (loading) { 192 | 193 | // } 194 | // }, [loading]) 195 | 196 | useEffect(() => { 197 | // canvasRef 198 | if (ref.current) { 199 | const left = (ref.current.scrollWidth - ref.current.offsetWidth) / 2; 200 | const top = (ref.current.scrollHeight - ref.current.offsetHeight) / 2; 201 | // console.log('top', top, left) 202 | ref.current.scrollLeft = left; 203 | ref.current.scrollTop = top; 204 | } 205 | }, [size, refCanvas.current?.canvasAttr.scale]); 206 | 207 | let style = {}; 208 | 209 | if (refCanvas.current && ref.current) { 210 | const width = refCanvas.current.stage.getWidth(); 211 | const height = refCanvas.current.stage.getHeight(); 212 | const baseTop = height - (ref.current.clientHeight - 120); 213 | const baseLeft = width - (ref.current.clientWidth - 120); 214 | const top = baseTop > 0 ? baseTop : 0; 215 | const left = baseLeft > 0 ? baseLeft : 0; 216 | style = { 217 | marginTop: top, 218 | marginLeft: left, 219 | }; 220 | } 221 | 222 | // const loading = false; 223 | 224 | return ( 225 | 226 |
227 | 228 | {loading ? ( 229 | 230 | ) : ( 231 |
232 |
233 |
234 | )} 235 |
236 |
237 | ); 238 | }; 239 | 240 | export default Canvas; 241 | -------------------------------------------------------------------------------- /src/core/shape/text/index.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva'; 2 | import type { TextConfig } from 'konva/lib/shapes/Text'; 3 | import Canvas from '../../Canvas'; 4 | import { isBg } from '../../utils/util'; 5 | import { equalOne } from '../../../utils/util'; 6 | import { 7 | rectangleStart, 8 | rectangleMove, 9 | rectangleEnd, 10 | rectangleVisible, 11 | } from '../../utils/group'; 12 | import { createContextMenu } from '../../context-menu'; 13 | import type { DatModelItem, DataModel, ImageModel } from '@/typing'; 14 | import text from '@/components/text'; 15 | 16 | class Text { 17 | text: Konva.Text; 18 | constructor(config: TextConfig, canvas: Canvas) { 19 | this.text = new Konva.Text(config); 20 | canvas.layer.add(this.text); 21 | 22 | const textNode = this.text; 23 | const stage = canvas.stage; 24 | 25 | textNode.on('dblclick dbltap', () => { 26 | // hide text node and transformer: 27 | textNode.hide(); 28 | canvas.tr.hide(); 29 | // tr.hide(); // TODO 用之前的是否可以 30 | 31 | // create textarea over canvas with absolute position 32 | // first we need to find position for textarea 33 | // how to find it? 34 | 35 | // at first lets find position of text node relative to the stage: 36 | var textPosition = textNode.absolutePosition(); 37 | 38 | // then lets find position of stage container on the page: 39 | var stageBox = stage.container().getBoundingClientRect(); 40 | 41 | // so position of textarea will be the sum of positions above: 42 | var areaPosition = { 43 | x: stageBox.left + textPosition.x, 44 | y: stageBox.top + textPosition.y, 45 | }; 46 | 47 | // create textarea and style it 48 | var textarea = document.createElement('textarea'); 49 | document.body.appendChild(textarea); 50 | 51 | // apply many styles to match text on canvas as close as possible 52 | // remember that text rendering on canvas and on the textarea can be different 53 | // and sometimes it is hard to make it 100% the same. But we will try... 54 | textarea.value = textNode.text(); 55 | textarea.style.position = 'absolute'; 56 | textarea.style.top = areaPosition.y + 'px'; 57 | textarea.style.left = areaPosition.x + 'px'; 58 | 59 | textarea.style.width = textNode.width() - textNode.padding() * 2 + 'px'; 60 | textarea.style.height = 61 | textNode.height() - textNode.padding() * 2 + 5 + 'px'; 62 | 63 | textarea.style.fontSize = textNode.fontSize() + 'px'; 64 | console.log('textNodeFontSize', textNode.fontSize()); 65 | textarea.style.border = 'none'; 66 | textarea.style.padding = '0px'; 67 | textarea.style.margin = '0px'; 68 | textarea.style.overflow = 'hidden'; 69 | textarea.style.background = 'none'; 70 | textarea.style.outline = 'none'; 71 | textarea.style.resize = 'none'; 72 | // textarea.style.lineHeight = textNode.lineHeight(); 73 | textarea.style.lineHeight = textNode.lineHeight(); 74 | textarea.style.fontFamily = textNode.fontFamily(); 75 | textarea.style.transformOrigin = 'left top'; 76 | textarea.style.textAlign = textNode.align(); 77 | textarea.style.color = textNode.fill(); 78 | const rotation = textNode.rotation(); 79 | // var transform = ''; 80 | var transform = `scale(${canvas.canvasAttr.scale})`; 81 | if (rotation) { 82 | transform += ' rotateZ(' + rotation + 'deg)'; 83 | } 84 | 85 | var px = 0; 86 | // also we need to slightly move textarea on firefox 87 | // because it jumps a bit 88 | var isFirefox = navigator.userAgent.toLowerCase().indexOf('firefox') > -1; 89 | if (isFirefox) { 90 | px += 2 + Math.round(textNode.fontSize() / 20); 91 | } 92 | transform += 'translateY(-' + px + 'px)'; 93 | 94 | textarea.style.transform = transform; 95 | 96 | // reset height 97 | textarea.style.height = 'auto'; 98 | // after browsers resized it we can set actual value 99 | textarea.style.height = textarea.scrollHeight + 3 + 'px'; 100 | 101 | textarea.focus(); 102 | 103 | function removeTextarea() { 104 | textarea.parentNode.removeChild(textarea); 105 | window.removeEventListener('click', handleOutsideClick); 106 | textNode.show(); 107 | canvas.tr.show(); 108 | // tr.forceUpdate(); 109 | } 110 | 111 | function setTextareaWidth(newWidth) { 112 | if (!newWidth) { 113 | // set width for placeholder 114 | newWidth = textNode.placeholder.length * textNode.fontSize(); 115 | } 116 | // some extra fixes on different browsers 117 | var isSafari = /^((?!chrome|android).)*safari/i.test( 118 | navigator.userAgent, 119 | ); 120 | var isFirefox = 121 | navigator.userAgent.toLowerCase().indexOf('firefox') > -1; 122 | if (isSafari || isFirefox) { 123 | newWidth = Math.ceil(newWidth); 124 | } 125 | 126 | var isEdge = document.documentMode || /Edge/.test(navigator.userAgent); 127 | if (isEdge) { 128 | newWidth += 1; 129 | } 130 | // console.log('newWidth', newWidth); 131 | textarea.style.width = newWidth + 'px'; 132 | } 133 | 134 | textarea.addEventListener('keydown', function (e) { 135 | // hide on enter 136 | // but don't hide on shift + enter 137 | if (e.keyCode === 13 && !e.shiftKey) { 138 | textNode.text(textarea.value); 139 | removeTextarea(); 140 | } 141 | // on esc do not set value back to node 142 | if (e.keyCode === 27) { 143 | removeTextarea(); 144 | } 145 | }); 146 | 147 | textarea.addEventListener('keydown', function (e) { 148 | const scale = textNode.getAbsoluteScale().x; 149 | // console.log('scale', scale),; 150 | setTextareaWidth(textNode.width()); 151 | textarea.style.height = 'auto'; 152 | textarea.style.height = 153 | textarea.scrollHeight + textNode.fontSize() + 'px'; 154 | }); 155 | 156 | function handleOutsideClick(e) { 157 | if (e.target !== textarea) { 158 | textNode.text(textarea.value); 159 | removeTextarea(); 160 | } 161 | } 162 | setTimeout(() => { 163 | window.addEventListener('click', handleOutsideClick); 164 | }); 165 | }); 166 | 167 | textNode.on('transform', (e) => { 168 | const an = canvas.tr.getActiveAnchor(); 169 | if (an === 'middle-left' || an === 'middle-right') { 170 | textNode.setAttrs({ 171 | width: textNode.width() * textNode.scaleX(), 172 | scaleX: 1, 173 | }); 174 | } else { 175 | textNode.setAttrs({ 176 | width: textNode.width() * textNode.scaleX(), 177 | scaleX: 1, 178 | fontSize: textNode.fontSize() * textNode.scaleX(), 179 | }); 180 | } 181 | }); 182 | } 183 | } 184 | 185 | export default Text; 186 | -------------------------------------------------------------------------------- /src/core/utils/line.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 处理辅助线的工具方法 3 | */ 4 | 5 | import Konva from 'konva'; 6 | 7 | let GUIDELINE_OFFSET = 5; 8 | 9 | type LineGuide = { 10 | vertical: Array; 11 | horizontal: Array; 12 | }; 13 | 14 | type ObjectSnappingEdgeItem = { 15 | guide: number; 16 | offset: number; 17 | snap: string; 18 | }; 19 | 20 | type ObjectSnappingEdge = { 21 | vertical: Array; 22 | horizontal: Array; 23 | }; 24 | 25 | type GuidItem = { 26 | lineGuide: number; 27 | offset: number; 28 | orientation: 'H' | 'V'; 29 | snap: string; 30 | }; 31 | 32 | type Guides = Array; 33 | 34 | export function getLineGuideStops(skipShape: Konva.Shape, layer: Konva.Layer) { 35 | let vertical = [0, layer.width() / 2, layer.width()]; 36 | let horizontal = [0, layer.height() / 2, layer.height()]; 37 | 38 | layer.find('.node').forEach((guideItem) => { 39 | if (guideItem === skipShape) { 40 | return; 41 | } 42 | let box = guideItem.getClientRect(); 43 | 44 | vertical.push(...[box.x, box.x + box.width, box.x + box.width / 2]); 45 | horizontal.push(...[box.y, box.y + box.height, box.y + box.height / 2]); 46 | }); 47 | return { 48 | vertical: vertical, 49 | horizontal: horizontal, 50 | }; 51 | } 52 | 53 | export function getObjectSnappingEdges(node: Konva.Shape) { 54 | let box = node.getClientRect(); 55 | let absPos = node.absolutePosition(); 56 | 57 | return { 58 | vertical: [ 59 | { 60 | guide: Math.round(box.x), 61 | offset: Math.round(absPos.x - box.x), 62 | snap: 'start', 63 | }, 64 | { 65 | guide: Math.round(box.x + box.width / 2), 66 | offset: Math.round(absPos.x - box.x - box.width / 2), 67 | snap: 'center', 68 | }, 69 | { 70 | guide: Math.round(box.x + box.width), 71 | offset: Math.round(absPos.x - box.x - box.width), 72 | snap: 'end', 73 | }, 74 | ], 75 | horizontal: [ 76 | { 77 | guide: Math.round(box.y), 78 | offset: Math.round(absPos.y - box.y), 79 | snap: 'start', 80 | }, 81 | { 82 | guide: Math.round(box.y + box.height / 2), 83 | offset: Math.round(absPos.y - box.y - box.height / 2), 84 | snap: 'center', 85 | }, 86 | { 87 | guide: Math.round(box.y + box.height), 88 | offset: Math.round(absPos.y - box.y - box.height), 89 | snap: 'end', 90 | }, 91 | ], 92 | }; 93 | } 94 | 95 | // find all snapping possibilities 96 | const getGuides = ( 97 | lineGuideStops: LineGuide, 98 | itemBounds: ObjectSnappingEdge, 99 | ) => { 100 | let resultV: any = []; 101 | let resultH: any = []; 102 | 103 | lineGuideStops.vertical.forEach((lineGuide) => { 104 | itemBounds.vertical.forEach((itemBound) => { 105 | let diff = Math.abs(lineGuide - itemBound.guide); 106 | // if the distance between guild line and object snap point is close we can consider this for snapping 107 | if (diff < GUIDELINE_OFFSET) { 108 | resultV.push({ 109 | lineGuide: lineGuide, 110 | diff: diff, 111 | snap: itemBound.snap, 112 | offset: itemBound.offset, 113 | }); 114 | } 115 | }); 116 | }); 117 | 118 | lineGuideStops.horizontal.forEach((lineGuide) => { 119 | itemBounds.horizontal.forEach((itemBound) => { 120 | let diff = Math.abs(lineGuide - itemBound.guide); 121 | if (diff < GUIDELINE_OFFSET) { 122 | resultH.push({ 123 | lineGuide: lineGuide, 124 | diff: diff, 125 | snap: itemBound.snap, 126 | offset: itemBound.offset, 127 | }); 128 | } 129 | }); 130 | }); 131 | 132 | let guides: Guides = []; 133 | 134 | // find closest snap 135 | let minV = resultV.sort((a: any, b: any) => a.diff - b.diff)[0]; 136 | let minH = resultH.sort((a: any, b: any) => a.diff - b.diff)[0]; 137 | if (minV) { 138 | guides.push({ 139 | lineGuide: minV.lineGuide, 140 | offset: minV.offset, 141 | orientation: 'V', 142 | snap: minV.snap, 143 | } as GuidItem); 144 | } 145 | if (minH) { 146 | guides.push({ 147 | lineGuide: minH.lineGuide, 148 | offset: minH.offset, 149 | orientation: 'H', 150 | snap: minH.snap, 151 | } as GuidItem); 152 | } 153 | return guides; 154 | }; 155 | 156 | export function drawGuides(guides: Guides, layer: Konva.Layer) { 157 | guides.forEach((lg) => { 158 | if (lg.orientation === 'H') { 159 | let line = new Konva.Line({ 160 | points: [-6000, 0, 6000, 0], 161 | stroke: '#1976D2', 162 | strokeWidth: 1, 163 | name: 'guid-line', 164 | dash: [5, 8], 165 | }); 166 | layer.add(line); 167 | line.absolutePosition({ 168 | x: 0, 169 | y: lg.lineGuide, 170 | }); 171 | } else if (lg.orientation === 'V') { 172 | let line = new Konva.Line({ 173 | points: [0, -6000, 0, 6000], 174 | // stroke: 'rgb(0, 161, 255)', 175 | stroke: '#1976D2', // '#1976D2', 176 | strokeWidth: 1, 177 | name: 'guid-line', 178 | dash: [5, 8], 179 | }); 180 | layer.add(line); 181 | line.absolutePosition({ 182 | x: lg.lineGuide, 183 | y: 0, 184 | }); 185 | } 186 | }); 187 | } 188 | 189 | export const detectionToLine = (layer: Konva.Layer, currNode: Konva.Shape) => { 190 | removeLines(layer); 191 | let lineGuideStops = getLineGuideStops(currNode, layer); 192 | // find snapping points of current object 193 | let itemBounds = getObjectSnappingEdges(currNode); 194 | 195 | // now find where can we snap current object 196 | let guides = getGuides(lineGuideStops, itemBounds); 197 | 198 | // do nothing of no snapping 199 | if (!guides.length) { 200 | return; 201 | } 202 | 203 | drawGuides(guides, layer); 204 | 205 | let absPos = currNode.absolutePosition(); 206 | // now force object position 207 | guides.forEach((lg) => { 208 | switch (lg.snap) { 209 | case 'start': { 210 | switch (lg.orientation) { 211 | case 'V': { 212 | absPos.x = lg.lineGuide + lg.offset; 213 | break; 214 | } 215 | case 'H': { 216 | absPos.y = lg.lineGuide + lg.offset; 217 | break; 218 | } 219 | } 220 | break; 221 | } 222 | case 'center': { 223 | switch (lg.orientation) { 224 | case 'V': { 225 | absPos.x = lg.lineGuide + lg.offset; 226 | break; 227 | } 228 | case 'H': { 229 | absPos.y = lg.lineGuide + lg.offset; 230 | break; 231 | } 232 | } 233 | break; 234 | } 235 | case 'end': { 236 | switch (lg.orientation) { 237 | case 'V': { 238 | absPos.x = lg.lineGuide + lg.offset; 239 | break; 240 | } 241 | case 'H': { 242 | absPos.y = lg.lineGuide + lg.offset; 243 | break; 244 | } 245 | } 246 | break; 247 | } 248 | } 249 | }); 250 | currNode.absolutePosition(absPos); 251 | }; 252 | 253 | export const removeLines = (layer: Konva.Layer) => { 254 | layer.find('.guid-line').forEach((l) => l.destroy()); 255 | }; 256 | -------------------------------------------------------------------------------- /src/components/waterfall-flow/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useRef, useEffect, useState } from 'react'; 2 | import { splitArray, splitGroupArray } from '@/utils/util'; 3 | import ImageCpt from './image'; 4 | 5 | import styles from './waterfallFlow.less'; 6 | 7 | /** 8 | * 找出数组中最小的树的索引 9 | * @param data 10 | */ 11 | const findArrayMinIndex = (data: Array): Array => { 12 | let minIndex = 0; 13 | let minValue = data[0]; 14 | for (let i = 1; i < data.length; i++) { 15 | if (minValue > data[i]) { 16 | minValue = data[i]; 17 | minIndex = i; 18 | } 19 | } 20 | return [minIndex, minValue]; 21 | }; 22 | 23 | export type ImageItem = { 24 | thumb_width: number; 25 | thumb_height: number; 26 | width: number; 27 | height: number; 28 | path: string; 29 | thumb_path: string; 30 | top?: number; 31 | left?: number; 32 | }; 33 | 34 | export interface IWaterfallFlowProps { 35 | type: 'vertical' | 'horizontal'; // vertical 等宽瀑布流, horizontal 等宽瀑布流 36 | size: number; // 37 | data: Array; 38 | classNameImg?: string; 39 | gap: number; 40 | onClick?: (e: React.MouseEvent, data: ImageItem) => void; // 鼠标点击事件 41 | // onClick?: (e: React.MouseEvent) => void; 42 | // onClick?: (e: React.MouseEvent) => void; 43 | } 44 | 45 | const WaterfallFlow = ({ 46 | size, 47 | data, 48 | type, 49 | classNameImg, 50 | onClick, 51 | gap, 52 | }: IWaterfallFlowProps) => { 53 | const [splitData, setSplitData] = useState([]); 54 | const containerRef = useRef(null); 55 | const rowWidth = useRef(0); // 记录每行的宽度 56 | const rowCount = useRef(0); //记录现在到第几行了 57 | 58 | /** 59 | * vertical 需要的state和Ref 60 | */ 61 | const [verticalData, setVerticalData] = useState([]); 62 | const recordHeight = useRef>([]); // 63 | 64 | const init = () => { 65 | rowCount.current = 0; 66 | rowWidth.current = 0; 67 | setSplitData([]); 68 | 69 | recordHeight.current = []; 70 | setVerticalData([]); 71 | 72 | if (containerRef.current) { 73 | containerRef.current.style.height = 'auto'; 74 | } 75 | }; 76 | 77 | const updateRecordHeight = (columIndex: number, height: number) => { 78 | console.log('columIndex', columIndex, height); 79 | let currHeight = recordHeight.current[columIndex]; 80 | if (!currHeight) { 81 | recordHeight.current[columIndex] = height; 82 | } else { 83 | recordHeight.current[columIndex] = currHeight + height; 84 | } 85 | }; 86 | 87 | useEffect(() => { 88 | init(); 89 | }, [data]); 90 | 91 | useEffect(() => { 92 | if (verticalData.length === 0 && type === 'vertical') { 93 | if (!containerRef.current) { 94 | return; 95 | } 96 | const { clientWidth: containerWidth } = containerRef.current; 97 | const columnCount = Math.floor(containerWidth / (size + gap)); // 每列放置几个 98 | let res = []; 99 | for (let i = 0; i < data.length; i++) { 100 | let columIndex = i % columnCount; 101 | const item = data[i]; 102 | 103 | const width = size; 104 | const height = size / (item.thumb_width / item.thumb_height); // 图片宽高的比率*缩放后的图片的宽度 105 | let top = null; 106 | let left = null; 107 | if (i < columnCount) { 108 | // 第一行内容 109 | top = gap; 110 | left = i * size + (i + 1) * gap; 111 | } else { 112 | const [minIndex, minHeight] = findArrayMinIndex(recordHeight.current); // 找出最小值的索引 113 | // console.log('minIndex, minHeight', minIndex, minHeight) 114 | columIndex = minIndex; 115 | top = minHeight + gap; 116 | left = minIndex * size + (minIndex + 1) * gap; 117 | } 118 | 119 | res.push({ ...item, top, left, width, height }); 120 | updateRecordHeight(columIndex, height + gap); 121 | } 122 | const maxHeight = Math.max(...recordHeight.current); 123 | containerRef.current.style.height = `${maxHeight}px`; 124 | setVerticalData(res); 125 | } 126 | }, [verticalData]); 127 | 128 | useEffect(() => { 129 | if (splitData.length === 0 && type === 'horizontal') { 130 | if (!containerRef.current) { 131 | return; 132 | } 133 | 134 | for (let i = 0; i < data.length; i++) { 135 | const item = data[i]; 136 | const height = size; 137 | const width = size * (item.thumb_width / item.thumb_height); 138 | 139 | let { clientWidth: containerWidth } = containerRef.current; // 容器的宽度 140 | //计算每行宽度 141 | rowWidth.current += width; 142 | containerWidth = containerWidth - 20; 143 | if (rowWidth.current > containerWidth) { 144 | containerWidth = 145 | containerWidth - splitData[rowCount.current].length * 10; 146 | //精确计算容器的宽度 147 | containerWidth = containerWidth; 148 | rowWidth.current = rowWidth.current - width; 149 | const groupHeight = (containerWidth * size) / rowWidth.current; // 计算出每组的高度 150 | 151 | splitData[rowCount.current] = splitData[rowCount.current].map((d) => { 152 | return { 153 | ...d, 154 | height: groupHeight, 155 | }; 156 | }); 157 | 158 | //把多余图片放入到下一行 159 | rowWidth.current = width; 160 | rowCount.current++; 161 | splitData[rowCount.current] = [{ ...item, width, height }]; 162 | } else { 163 | const currItem = { ...item, width, height }; 164 | if (!splitData[rowCount.current]) { 165 | splitData[rowCount.current] = [currItem]; 166 | } else { 167 | splitData[rowCount.current].push(currItem); 168 | } 169 | } 170 | setSplitData([...splitData]); 171 | } 172 | } 173 | }, [splitData]); 174 | 175 | const renderHorizontal = () => { 176 | return splitData.map((row, index) => { 177 | return ( 178 |
179 | {row.map((item) => { 180 | const path = item.thumb_path || item.path; 181 | return ( 182 | 188 | ); 189 | })} 190 |
191 | ); 192 | }); 193 | }; 194 | 195 | const renderVertical = () => { 196 | return verticalData.map((row) => { 197 | const path = row.thumb_path || row.path; 198 | return ( 199 | 213 | ); 214 | }); 215 | }; 216 | return ( 217 | <> 218 |
219 | {type === 'horizontal' ? renderHorizontal() : renderVertical()} 220 |
221 | 222 | ); 223 | }; 224 | 225 | WaterfallFlow.defaultProps = { 226 | type: 'vertical', 227 | size: 120, 228 | gap: 10, 229 | }; 230 | 231 | export default WaterfallFlow; 232 | -------------------------------------------------------------------------------- /src/utils/line.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva'; 2 | import { LocationItem } from '@/typing'; 3 | 4 | const threshold = 6; 5 | // 存放所有的辅助线 6 | let lines: Array = []; 7 | let locationItems: Array = []; 8 | 9 | enum DIRECTION { 10 | left = 1, 11 | right, 12 | top, 13 | bottom, 14 | leftCenter, 15 | topCenter, 16 | center, 17 | } 18 | 19 | export const getLocationItem = (shapeObject: Konva.Shape) => { 20 | const id = shapeObject.id(); 21 | const width = shapeObject.width(); 22 | const height = shapeObject.height(); 23 | const x = shapeObject.x(); 24 | const y = shapeObject.y(); 25 | 26 | // console.log('element=>render', shapeRef.current); 27 | const locationItem: LocationItem = { 28 | id, 29 | w: width, 30 | h: height, 31 | x, 32 | y, 33 | l: x, 34 | r: x + width, 35 | t: y, 36 | b: y + height, 37 | lc: x + width / 2, 38 | tc: y + height / 2, 39 | }; 40 | return locationItem; 41 | // console.log('locationItem=>', locationItem); 42 | }; 43 | 44 | export const setLocationItems = (layer: Konva.Layer) => { 45 | locationItems = []; 46 | layer.children?.forEach((item) => { 47 | if (item.className !== 'Transformer') { 48 | locationItems.push(getLocationItem(item)); 49 | } 50 | }); 51 | console.log('locationItems=>', locationItems); 52 | }; 53 | 54 | /** 55 | * 56 | * @param sourceItem 拖动的图形 57 | * @param targetItem 目标图形 58 | * @param targetItem 方向 59 | */ 60 | const getPoints = ( 61 | sourceItem: LocationItem, 62 | targetItem: LocationItem, 63 | direction: DIRECTION, 64 | ) => { 65 | let minItem: LocationItem, maxItem: LocationItem; 66 | let points: any = []; 67 | 68 | let po = { 69 | [DIRECTION.left]: [ 70 | [targetItem.l, sourceItem.b, targetItem.l, targetItem.t], 71 | [targetItem.l, targetItem.b, targetItem.l, sourceItem.t], 72 | ], 73 | [DIRECTION.right]: [ 74 | [targetItem.r, sourceItem.b, targetItem.r, targetItem.t], 75 | [targetItem.r, targetItem.b, targetItem.r, sourceItem.t], 76 | ], 77 | [DIRECTION.leftCenter]: [ 78 | [targetItem.lc, sourceItem.b, targetItem.lc, targetItem.t], 79 | [targetItem.lc, targetItem.b, targetItem.lc, sourceItem.t], 80 | ], 81 | [DIRECTION.top]: [ 82 | [sourceItem.r, targetItem.t, targetItem.l, targetItem.t], 83 | [targetItem.r, targetItem.t, sourceItem.l, targetItem.t], 84 | ], 85 | [DIRECTION.bottom]: [ 86 | [sourceItem.r, targetItem.b, targetItem.l, targetItem.b], 87 | [targetItem.r, targetItem.b, sourceItem.l, targetItem.b], 88 | ], 89 | [DIRECTION.topCenter]: [ 90 | [sourceItem.r, targetItem.tc, targetItem.l, targetItem.tc], 91 | [targetItem.r, targetItem.tc, sourceItem.l, targetItem.tc], 92 | ], 93 | }; 94 | 95 | switch (direction) { 96 | case DIRECTION.left: 97 | return sourceItem.y < targetItem.y 98 | ? po[DIRECTION.left][0] 99 | : po[DIRECTION.left][1]; 100 | 101 | case DIRECTION.right: 102 | // 目标图形是否在上边 103 | return sourceItem.y < targetItem.y 104 | ? po[DIRECTION.right][0] 105 | : po[DIRECTION.right][1]; 106 | 107 | case DIRECTION.leftCenter: 108 | return sourceItem.y < targetItem.y 109 | ? po[DIRECTION.leftCenter][0] 110 | : po[DIRECTION.leftCenter][1]; 111 | 112 | case DIRECTION.top: 113 | return sourceItem.x < targetItem.x 114 | ? po[DIRECTION.top][0] 115 | : po[DIRECTION.top][1]; 116 | 117 | case DIRECTION.bottom: 118 | return sourceItem.x < targetItem.x 119 | ? po[DIRECTION.bottom][0] 120 | : po[DIRECTION.bottom][1]; 121 | 122 | case DIRECTION.topCenter: 123 | return sourceItem.x < targetItem.x 124 | ? po[DIRECTION.topCenter][0] 125 | : po[DIRECTION.topCenter][1]; 126 | default: 127 | break; 128 | } 129 | return points; 130 | }; 131 | 132 | export const addLine = ( 133 | layer: Konva.Layer, 134 | sourceItem: LocationItem, 135 | targetItem: LocationItem, 136 | direction: DIRECTION, 137 | ) => { 138 | const points = getPoints(sourceItem, targetItem, direction); 139 | var greenLine = new Konva.Line({ 140 | points: points, 141 | stroke: 'green', 142 | strokeWidth: 1, 143 | lineJoin: 'round', 144 | dash: [10, 10], 145 | }); 146 | // greenLine.direction = direction 147 | 148 | lines.push(greenLine); 149 | layer.add(greenLine); 150 | layer.draw(); 151 | }; 152 | 153 | const updateLine = ( 154 | sourceItem: LocationItem, 155 | targetItem: LocationItem, 156 | direction: DIRECTION, 157 | ) => { 158 | const points = getPoints(sourceItem, targetItem, direction); 159 | lines.forEach((line) => { 160 | line.points(points); 161 | }); 162 | }; 163 | 164 | export const removeLines = (layer: Konva.Layer) => { 165 | if (lines.length == 0) { 166 | return; 167 | } 168 | lines.forEach((item) => { 169 | item.remove(); 170 | }); 171 | lines = []; 172 | layer.draw(); 173 | }; 174 | 175 | /** 176 | * 检测是否有租房户线 177 | */ 178 | export const detectionToLine = (layer: Konva.Layer, shape: Konva.Shape) => { 179 | const locationItem = getLocationItem(shape); 180 | const compareLocations = locationItems.filter( 181 | (item: LocationItem) => item.id !== locationItem.id, 182 | ); 183 | removeLines(layer); 184 | compareLocations.forEach((item: LocationItem) => { 185 | if (Math.abs(locationItem.x - item.x) <= threshold) { 186 | // 处理左侧方向 187 | shape.setPosition({ x: item.x, y: locationItem.y }); 188 | addLine(layer, locationItem, item, DIRECTION.left); 189 | } 190 | if (Math.abs(locationItem.x - item.r) <= threshold) { 191 | // 处理右侧 192 | shape.setPosition({ x: item.r, y: locationItem.y }); 193 | addLine(layer, locationItem, item, DIRECTION.right); 194 | } 195 | 196 | if (Math.abs(locationItem.lc - item.lc) <= threshold) { 197 | // 处理左侧居中 198 | shape.setPosition({ x: item.lc - locationItem.w / 2, y: locationItem.y }); 199 | addLine(layer, locationItem, item, DIRECTION.leftCenter); 200 | } 201 | 202 | if (Math.abs(locationItem.r - item.x) <= threshold) { 203 | // 处理右侧方向 204 | shape.setPosition({ x: item.l - locationItem.w, y: locationItem.t }); 205 | addLine(layer, item, locationItem, DIRECTION.right); 206 | } 207 | if (Math.abs(locationItem.r - item.r) <= threshold) { 208 | // 右侧相等 209 | shape.setPosition({ x: item.r - locationItem.w, y: locationItem.t }); 210 | addLine(layer, item, locationItem, DIRECTION.right); 211 | } 212 | 213 | if (Math.abs(locationItem.y - item.y) <= threshold) { 214 | // 处理垂直方向顶部 215 | shape.setPosition({ x: locationItem.x, y: item.y }); 216 | addLine(layer, locationItem, item, DIRECTION.top); 217 | } 218 | 219 | if (Math.abs(locationItem.y - item.b) <= threshold) { 220 | // 处理底部 221 | shape.setPosition({ x: locationItem.x, y: item.b }); 222 | addLine(layer, locationItem, item, DIRECTION.bottom); 223 | } 224 | 225 | if (Math.abs(locationItem.tc - item.tc) <= threshold) { 226 | // 处理顶部居中 227 | shape.setPosition({ x: locationItem.x, y: item.tc - locationItem.h / 2 }); 228 | addLine(layer, locationItem, item, DIRECTION.topCenter); 229 | } 230 | 231 | if (Math.abs(locationItem.b - item.t) <= threshold) { 232 | // 处理垂底部方向 233 | shape.setPosition({ x: locationItem.l, y: item.t - locationItem.h }); 234 | addLine(layer, item, locationItem, DIRECTION.bottom); 235 | } 236 | 237 | if (Math.abs(locationItem.b - item.b) <= threshold) { 238 | // 右侧相等 239 | shape.setPosition({ x: locationItem.l, y: item.b - locationItem.h }); 240 | addLine(layer, item, locationItem, DIRECTION.bottom); 241 | } 242 | }); 243 | }; 244 | -------------------------------------------------------------------------------- /src/core/Canvas.ts: -------------------------------------------------------------------------------- 1 | import Konva from 'konva'; 2 | import { Image, Stage, Text, Transformer } from './shape'; 3 | import type { DatModelItem, DataModel, BgModel } from '@/typing'; 4 | import { removeLines, detectionToLine } from '@/core/utils/line'; 5 | import { uuid, getCenterXY } from '../utils/util'; 6 | import { addRectangle } from './utils/group'; 7 | 8 | type canvasAttr = { 9 | width: number; 10 | height: number; 11 | scale: number; 12 | }; 13 | 14 | type EventKey = 'clickNode'; 15 | type Callback = (data: DatModelItem) => void; 16 | type Listener = { 17 | [x: string]: Array; 18 | }; 19 | 20 | class Canvas { 21 | data: DataModel; 22 | canvasAttr: canvasAttr; 23 | stage: Konva.Stage; 24 | layer: Konva.Layer; 25 | tr: Konva.Transformer; 26 | listener: Listener; 27 | bgNode: Konva.Shape | null; 28 | constructor(id: string, width: number, height: number) { 29 | this.data = []; 30 | this.bgNode = null; 31 | this.listener = {}; 32 | this.canvasAttr = { 33 | width, 34 | height, 35 | scale: 1, 36 | }; 37 | 38 | this.stage = new Stage( 39 | { 40 | container: id, 41 | width: width, 42 | height: height, 43 | }, 44 | this, 45 | ).stage; 46 | 47 | this.tr = new Transformer({}, this).transformer; 48 | 49 | this.stage.on('click', (event) => { 50 | const modelItem = event.target.attrs as any; 51 | this.emit('clickNode', modelItem); 52 | }); 53 | 54 | this.layer = new Konva.Layer({}); 55 | 56 | // 辅助线 57 | this.layer.on('dragmove', (e) => { 58 | detectionToLine(this.layer, e.target as Konva.Shape); 59 | }); 60 | this.layer.on('dragend', () => { 61 | removeLines(this.layer); 62 | }); 63 | 64 | // 框选矩形 65 | // addRectangle(this.layer); 66 | 67 | this.stage.add(this.layer); 68 | } 69 | 70 | /** 71 | * 初始化数据 72 | */ 73 | init(data: DataModel): void { 74 | // this.layer.destroyChildren(); 75 | this.data = data; 76 | this._renderShape(data); 77 | } 78 | 79 | selectBg(): void { 80 | const bg = this.layer.find('#bg'); 81 | if (bg.length > 0) { 82 | this.emit('clickNode', bg[0].attrs); 83 | } 84 | } 85 | /** 86 | * 添加一个图形 87 | * @param item 88 | */ 89 | add(item: DatModelItem): void { 90 | this.data.push(item); 91 | this._renderItemShape(item); 92 | } 93 | 94 | copy(item: DatModelItem): void { 95 | this.add(item); 96 | } 97 | 98 | addNode(node: DatModelItem, nodeWidth: number, nodeHeight: number): void { 99 | const [x, y] = getCenterXY( 100 | this.canvasAttr.width, 101 | this.canvasAttr.height, 102 | nodeWidth, 103 | nodeHeight, 104 | ); 105 | node.x = x; 106 | node.y = y; 107 | (node.name = 'node'), (node.draggable = true), this.add(node); 108 | } 109 | 110 | addText(): void { 111 | const textWidth = 360; 112 | const textHeight = 60; 113 | const currTextDateItem: DatModelItem = { 114 | id: uuid(), 115 | fontSize: 60, 116 | type: 'text-input', 117 | text: '双击编辑文字', 118 | fill: '#000', 119 | width: textWidth, 120 | }; 121 | this.addNode(currTextDateItem, textWidth, textHeight); 122 | } 123 | 124 | addImage(url: string): void { 125 | const width = 400; 126 | const height = 200; 127 | const currTextDateItem: DatModelItem = { 128 | id: uuid(), 129 | type: 'image', 130 | url, 131 | width, 132 | height, 133 | }; 134 | this.addNode(currTextDateItem, width, height); 135 | } 136 | 137 | addBgImage(url: string): void { 138 | const { id } = this.bgNode?.attrs; 139 | const { width, height } = this.canvasAttr; 140 | const node: DatModelItem = { 141 | x: 0, 142 | y: 0, 143 | id, 144 | width, 145 | height, 146 | type: 'bg-image', 147 | url, 148 | }; 149 | this.bgNode?.destroy(); 150 | this.add(node); 151 | // this. 152 | } 153 | 154 | private _renderShape(data: DataModel): void { 155 | data.forEach((item) => { 156 | this._renderItemShape(item); 157 | }); 158 | this.layer.draw(); 159 | } 160 | 161 | private _renderItemShape(shape: DatModelItem): void { 162 | switch (shape.type) { 163 | case 'color': 164 | console.log('shape', shape); 165 | const color = new Konva.Rect({ 166 | ...shape, 167 | width: this.canvasAttr.width, 168 | height: this.canvasAttr.height, 169 | }); 170 | this.bgNode = color; 171 | this.layer.add(color); 172 | return; 173 | 174 | case 'bg-image': 175 | new Image(shape, this, (node) => { 176 | this.bgNode = node; 177 | // console.log('node===?', node); 178 | setTimeout(() => { 179 | node.moveToBottom(); // TODO: 放到最底层 180 | // console.log('bottomm') 181 | }, 0); 182 | }); 183 | 184 | return; 185 | 186 | case 'text-input': 187 | new Text(shape, this); 188 | return; 189 | case 'image': 190 | new Image(shape, this); 191 | 192 | return; 193 | default: 194 | break; 195 | } 196 | } 197 | 198 | redo(): void {} 199 | 200 | undo(): void {} 201 | 202 | updateCanvasAttr(scale: number): void { 203 | // const newCanvasAttr = Object.assign(this.canvasAttr, attr); 204 | const { width, height } = this.canvasAttr; 205 | // console.log('newCanvasAttr', newCanvasAttr); 206 | const newWidth = width * scale; 207 | const newHeight = height * scale; 208 | const canvasAttr = { 209 | width: width, 210 | height: height, 211 | scale: scale, 212 | }; 213 | this.canvasAttr = canvasAttr; 214 | this.stage.setAttrs({ 215 | width: newWidth, 216 | height: newHeight, 217 | scaleX: scale, 218 | scaleY: scale, 219 | }); 220 | // this.layer.draw(); 221 | } 222 | 223 | /** 224 | * 根据id修改图形的属性 225 | * @param id 226 | * @param item 227 | */ 228 | updateShapeAttrsById(id: string, item: DatModelItem): void { 229 | // console.log('item', item); 230 | const currItem = this.layer.find(`#${id}`); 231 | if (currItem.length > 0) { 232 | // currItem[0].attrs 233 | currItem[0].setAttrs(item); 234 | } 235 | // console.log('currItem=>', currItem); 236 | } 237 | 238 | on(type: EventKey, cbk: (item: DatModelItem) => void): void { 239 | if (this.listener[type]) { 240 | this.listener[type].push(cbk); 241 | } else { 242 | this.listener[type] = [cbk]; 243 | } 244 | } 245 | 246 | emit(type: EventKey, modelItem: DatModelItem): void { 247 | this.listener[type]?.forEach((cbk) => { 248 | cbk?.(modelItem); 249 | }); 250 | } 251 | 252 | private zoom(type: 'zoomIn' | 'zoomOut'): canvasAttr { 253 | const { width, height, scale } = this.canvasAttr; 254 | let oldScale = this.stage.scaleX(); 255 | let newScale = scale; 256 | if (type === 'zoomIn') { 257 | console.log('zoomIn'); 258 | newScale = oldScale + 0.1; 259 | } else { 260 | console.log('zoomOut'); 261 | newScale = oldScale - 0.1; 262 | } 263 | newScale = parseFloat(newScale.toFixed(1)); 264 | if (newScale <= 0.3 || newScale >= 1.8) { 265 | return this.canvasAttr; 266 | } 267 | this.updateCanvasAttr(newScale); 268 | return this.canvasAttr; 269 | } 270 | 271 | zoomIn(): canvasAttr { 272 | return this.zoom('zoomIn'); 273 | } 274 | 275 | zoomOut(): canvasAttr { 276 | return this.zoom('zoomOut'); 277 | } 278 | 279 | getTemplate() { 280 | const res = this.layer.toObject(); 281 | // debugger; 282 | return res.children.map((child: any) => { 283 | return { 284 | ...child.attrs, 285 | }; 286 | }); 287 | } 288 | } 289 | 290 | export default Canvas; 291 | -------------------------------------------------------------------------------- /src/canvas-components/shape-panel/text-panel/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Select, Button, Tooltip } from 'antd'; 3 | import PanelTitle from '../panel-title'; 4 | import useModel from 'flooks'; 5 | import { ColorSelect } from '@/components'; 6 | import { 7 | BoldOutlined, 8 | ItalicOutlined, 9 | UnderlineOutlined, 10 | StrikethroughOutlined, 11 | AlignCenterOutlined, 12 | AlignLeftOutlined, 13 | AlignRightOutlined, 14 | } from '@ant-design/icons'; 15 | import canvasDataModel from '@/models1/canvasDataModel'; 16 | import canvasModel from '@/models1/canvasModel'; 17 | import { useThrottleFn } from 'ahooks'; 18 | import styles from './textPanel.less'; 19 | 20 | const { Option } = Select; 21 | const fonts = [ 22 | { name: '微软雅黑体', value: 'Microsoft YaHei' }, 23 | { name: '微软正黑体', value: 'Microsoft JhengHei' }, 24 | { name: '黑体', value: 'SimHei' }, 25 | { name: '宋体', value: 'SimSun' }, 26 | { name: '新宋体', value: 'NSimSun' }, 27 | 28 | { name: '仿宋', value: 'FangSong' }, 29 | { name: '楷体', value: 'KaiTi' }, 30 | { name: '新细明体', value: 'PMingLiU' }, 31 | { name: 'cursive', value: 'cursive' }, 32 | { name: '隶书', value: 'LiSu' }, 33 | { name: '幼圆', value: 'YouYuan' }, 34 | { name: '华文彩云', value: 'STCaiyun' }, 35 | ]; 36 | const fontSizes = [ 37 | 12, 14, 16, 18, 20, 24, 30, 36, 48, 60, 72, 84, 96, 108, 120, 140, 38 | ]; 39 | 40 | export interface ITextPanelProps {} 41 | 42 | const TextPanel: FC = (props) => { 43 | const { width, height, changeCanvasModelDataItem, changeCanvasModel } = 44 | useModel(canvasDataModel); 45 | const { selectNode, canvasRef } = useModel(canvasModel); 46 | // console.log('selectNode=>', selectNode); 47 | 48 | const changeFont = (key: string, value: string) => { 49 | canvasRef?.updateShapeAttrsById(selectNode.id, { [key]: value }); 50 | // changeCanvasModelDataItem({ 51 | // ...selectNode, 52 | // [key]: value, 53 | // }); 54 | }; 55 | 56 | const { run } = useThrottleFn( 57 | (key: string, value: string) => { 58 | changeFont(key, value); 59 | }, 60 | { 61 | wait: 200, 62 | }, 63 | ); 64 | 65 | return ( 66 |
67 | 文字 68 |
69 |
70 |
字体
71 |
72 |
73 | 87 | 88 | 102 |
103 |
104 | 105 |
106 |
107 |
字体描述
108 |
109 |
113 | 114 |
176 |
177 | 178 |
179 |
180 |
对齐方式
181 |
182 |
186 | 187 |
220 |
221 | 222 |
223 |
224 |
字体颜色
225 |
226 |
230 | run('fill', value)} 233 | /> 234 |
235 |
236 |
237 | ); 238 | }; 239 | 240 | export default TextPanel; 241 | -------------------------------------------------------------------------------- /src/canvas-components/transformer-wrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | useRef, 4 | useEffect, 5 | useState, 6 | useCallback, 7 | memo, 8 | } from 'react'; 9 | import { Stage, Layer, Rect, Transformer } from 'react-konva'; 10 | // import { useModel } from 'umi'; 11 | import type { DataModel, DatModelItem, LocationItem } from '@/typing'; 12 | import { useTimeout } from 'ahooks'; 13 | import { useDebounceFn } from 'ahooks'; 14 | import Konva from 'konva'; 15 | import useModel from 'flooks'; 16 | import { isEqual, equal } from '@/utils/util'; 17 | import { 18 | addLine, 19 | removeLines, 20 | setLocationItems, 21 | detectionToLine, 22 | } from '@/utils/line'; 23 | import canvasDataModel from '@/models1/canvasDataModel'; 24 | import canvasModel from '@/models1/canvasModel'; 25 | import { useImmer } from 'use-immer'; 26 | 27 | type BaseProps = { 28 | [key: string]: any; 29 | }; 30 | 31 | export interface ITransformerWrapperProps { 32 | Component: FC; 33 | // shapeProps: any; 34 | // isSelected: boolean; 35 | // onSelect: any; 36 | // onChange: any; 37 | } 38 | 39 | let isA = false; 40 | let currSeItem = null; 41 | 42 | const TransformerWrapper = (Component: FC) => { 43 | const WrapperComponent: FC = (props) => { 44 | const { 45 | selectNode, 46 | shapePanelType, 47 | layerRef, 48 | changeCanvas, 49 | editNode, 50 | addNodeLocation, 51 | updateNodeLocation, 52 | nodeLocations, 53 | child, 54 | } = useModel(canvasModel); 55 | const { changeCanvasModelDataItem } = useModel(canvasDataModel); 56 | 57 | const [state, setState] = useImmer({ 58 | isDrag: false, 59 | }); 60 | 61 | const shapeRef = React.useRef(); 62 | const trRef = React.useRef(); 63 | const currScale = React.useRef(); 64 | 65 | const isSelected = 66 | props.id === selectNode?.id && props.id !== editNode?.id && !state.isDrag; 67 | 68 | // console.log( 69 | // 'isSelected', 70 | // isSelected, 71 | // props, 72 | // selectNode?.id 73 | // ); 74 | 75 | const { run } = useDebounceFn( 76 | (e) => { 77 | console.log('onSelect=>执行了'); 78 | if (props.child) { 79 | console.log('是子集'); 80 | return; 81 | } 82 | // 阻止事件冒泡 83 | e.cancelBubble = true; 84 | 85 | changeCanvas({ 86 | selectNode: props, 87 | }); 88 | }, 89 | { wait: 300, leading: true, trailing: false }, 90 | ); 91 | 92 | useEffect(() => { 93 | if (isSelected) { 94 | // we need to attach transformer manually 95 | trRef.current.nodes([shapeRef.current]); 96 | trRef.current.node = shapeRef.current; 97 | trRef.current.getLayer().batchDraw(); 98 | 99 | currScale.current = { 100 | scaleX: shapeRef.current.scaleX(), 101 | scaleY: shapeRef.current.scaleY(), 102 | }; 103 | } 104 | 105 | return () => { 106 | console.log('hooks destory', props.id, shapeRef); 107 | }; 108 | }, [isSelected]); 109 | 110 | const onTransform = () => { 111 | // console.log('onTransform=>'); 112 | if (props.type !== 'text-input') { 113 | //只有text-input处理 114 | return; 115 | } 116 | const node = shapeRef.current; 117 | const scaleX = node.scaleX(); 118 | const scaleY = node.scaleY(); 119 | 120 | // const currItem: any = { 121 | // ...props, 122 | // width: node.width() * scaleX, 123 | // height: node.height() * scaleY, 124 | // scaleX: 1, 125 | // }; 126 | // changeCanvasModelDataItem(currItem as DatModelItem); 127 | 128 | if ( 129 | !isEqual(scaleX, currScale.current.scaleX) && 130 | !isEqual(scaleY, currScale.current.scaleY) 131 | ) { 132 | // 反方向会出错 133 | // isEqual(scaleX, currScale.current.scaleX) 134 | // console.log('===============>',scaleX, scaleY, isEqual(scaleX, currScale.current.scaleX), isEqual(scaleY, currScale.current.scaleY)) 135 | return; 136 | } 137 | // const currItem: any = { 138 | // ...props, 139 | // width: node.width() * scaleX, 140 | // // height: node.height() * scaleY, 141 | // scaleX: 1, 142 | // }; 143 | 144 | const textNode = shapeRef.current; 145 | 146 | textNode.setAttrs({ 147 | width: textNode.width() * textNode.scaleX(), 148 | height: 'auto', 149 | // height: textNode.height() * textNode.scaleY(), 150 | scaleX: 1, 151 | // scaleY: 1, 152 | }); 153 | 154 | // console.log(textNode.height()); 155 | // changeCanvasModelDataItem(currItem as DatModelItem); 156 | }; 157 | 158 | const onDragStart = useCallback(() => { 159 | setState((draft) => { 160 | draft.isDrag = true; 161 | }); 162 | }, [layerRef?.current]); 163 | 164 | const onDragEnd = useCallback( 165 | (e: Konva.KonvaEventObject) => { 166 | // console.warn('drag-end', props); 167 | setState((draft) => { 168 | draft.isDrag = false; 169 | }); 170 | 171 | changeCanvasModelDataItem({ 172 | ...e.currentTarget.attrs, 173 | x: e.target.x(), 174 | y: e.target.y(), 175 | } as DatModelItem); 176 | }, 177 | [layerRef?.current], 178 | ); 179 | 180 | // console.log('props=>', props) 181 | return ( 182 | 183 | ) => { 194 | const node = shapeRef.current; 195 | const scaleX = node.scaleX(); 196 | const scaleY = node.scaleY(); 197 | // we will reset it back 198 | // console.log('e', e, scaleX); 199 | node.scaleX(1); 200 | node.scaleY(1); 201 | // console.log('scaleX=>', scaleX, scaleY); 202 | const currItem: any = { 203 | // ...props, 204 | ...e.currentTarget.attrs, 205 | x: node.x(), 206 | y: node.y(), 207 | // scaleX: 1, 208 | // width: node.width(), 209 | // height: node.height(), 210 | width: Math.max(5, node.width() * scaleX), 211 | height: Math.max(node.height() * scaleY) + 1, 212 | rotation: node.rotation(), 213 | // skewX: node.skewX(), 214 | // skewY: node.skewY(), 215 | }; 216 | if (props.fontSize) { 217 | if (equal(scaleX, scaleY)) { 218 | currItem.fontSize = node.fontSize() * scaleX; 219 | } else { 220 | currItem.fontSize = node.fontSize() * Math.min(scaleX, scaleY); 221 | } 222 | // console.log(' node.fontSize()', node.fontSize(), scaleX, scaleY, scaleX === scaleY, currItem, Math.max(node.height() * scaleY) + 1); 223 | } 224 | changeCanvasModelDataItem(currItem as DatModelItem); 225 | }} 226 | /> 227 | {isSelected && ( 228 | { 240 | // console.log('newBox', newBox); 241 | // 新盒子和旧盒子的宽度 242 | // const currItem = { 243 | // ...props, 244 | // height: newBox.height, 245 | // width: newBox.width 246 | // } 247 | // changeCanvasModelDataItem(currItem as DatModelItem); 248 | newBox.width = Math.max(30, newBox.width); 249 | return newBox; 250 | }} 251 | /> 252 | )} 253 | 254 | ); 255 | }; 256 | return WrapperComponent; 257 | }; 258 | 259 | export default TransformerWrapper; 260 | -------------------------------------------------------------------------------- /src/canvas-components/canvas/index copy.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | FC, 3 | useRef, 4 | useEffect, 5 | useState, 6 | useCallback, 7 | useMemo, 8 | } from 'react'; 9 | import { Stage, Layer, Rect, Text, Group, Circle } from 'react-konva'; 10 | import Konva from 'konva'; 11 | import TransformerWrapper from '../transformer-wrapper'; 12 | import GroupTransformerWrapper from '../transformer-wrapper/groupTransformer'; 13 | import { TextInput, ContextMenu, Image, Toolbar } from '@/components'; 14 | import useModel from 'flooks'; 15 | import canvasDataModel from '@/models1/canvasDataModel'; 16 | import canvasModel from '@/models1/canvasModel'; 17 | import { useSize } from 'ahooks'; 18 | import { Spin } from 'antd'; 19 | // import mousetrap from 'mousetrap'; 20 | // import { shiftAndClick } from '@/utils/Keyboard'; 21 | import { uuid } from '@/utils/util'; 22 | import type { DatModelItem, BgModel, TextModel, GroupModel } from '@/typing'; 23 | import { removeLines, detectionToLine } from '@/core/utils/line'; 24 | import styles from './canvas.less'; 25 | 26 | export interface ICanvasProps {} 27 | 28 | const TransformerTextInput = TransformerWrapper(TextInput); 29 | const TransformerText = TransformerWrapper(Text); 30 | const TransformerImage = TransformerWrapper(Image); 31 | const TransformerGroup = GroupTransformerWrapper(Group); 32 | // const TransformerHtml2 = TransformerWrapper(Group); 33 | 34 | const Canvas: FC = (props) => { 35 | const { width, height, nodes, addGroup, removeGroup } = 36 | useModel(canvasDataModel); 37 | const { 38 | changeCanvas, 39 | selectNode, 40 | stageData, 41 | loading, 42 | updateUndoRedoData, 43 | undoRedoData, 44 | } = useModel(canvasModel); 45 | const ref = useRef(null); 46 | const stageRef = useRef(null); 47 | const layerRef = useRef(null); 48 | const bgRef = useRef(null); 49 | const size = useSize(ref); 50 | window.stage = stageRef.current; 51 | const tmpGroup = useRef>([]); 52 | const tmpGroupKey = useRef(uuid()); 53 | const ref2 = useRef(null); 54 | const ref3 = useRef(null); 55 | 56 | useEffect(() => { 57 | if (ref.current && !loading) { 58 | const scaleX = (ref.current.offsetHeight - 120) / stageData.height; 59 | const scaleY = (ref.current.offsetWidth - 120) / stageData.width; 60 | let scale = Math.min(scaleX, scaleY); 61 | if (scale > 1) scale = 1; 62 | // console.log('nodes=>', nodes[0]) 63 | changeCanvas({ 64 | stageRef, 65 | layerRef, 66 | bgRef: bgRef, 67 | canvasRef: ref, 68 | selectNode: nodes[0], 69 | editNode: null, 70 | stageData: { 71 | ...stageData, 72 | width: width * scale, 73 | height: height * scale, 74 | scale, 75 | }, 76 | }); 77 | setTimeout(() => { 78 | changeCanvasPanel(); 79 | }, 0); 80 | } 81 | // shiftAndClick(); 82 | // console.log('mousetrap=>', mousetrap); 83 | }, []); 84 | 85 | useEffect(() => { 86 | // canvasRef 87 | if (ref.current) { 88 | const left = (ref.current.scrollWidth - ref.current.offsetWidth) / 2; 89 | const top = (ref.current.scrollHeight - ref.current.offsetHeight) / 2; 90 | // console.log('top', top, left) 91 | ref.current.scrollLeft = left; 92 | ref.current.scrollTop = top; 93 | } 94 | }, [stageData.scale, size]); 95 | 96 | const changeCanvasPanel = () => { 97 | changeCanvas({ 98 | selectNode: nodes[0], 99 | editNode: null, 100 | }); 101 | }; 102 | 103 | const getJsxItem = (item: DatModelItem) => { 104 | switch (item.type) { 105 | case 'color': 106 | const bgModel = item as BgModel; 107 | return ( 108 | 116 | ); 117 | case 'bg-image': 118 | return ( 119 | 126 | ); 127 | case 'text': 128 | const textModel = item as TextModel; 129 | return ; 130 | 131 | case 'text-input': 132 | const textInputModel = item as TextModel; 133 | return ( 134 | 140 | ); 141 | 142 | case 'group': 143 | const groupModel = item as GroupModel; 144 | console.log('groupModel=>', groupModel); 145 | // return ( 146 | // ) 147 | return ( 148 | 149 | {/* 150 | 151 | */} 152 | {groupModel?.children?.map((item: DatModelItem) => { 153 | return getJsxItem({ 154 | ...item, 155 | child: true, 156 | name: `group-${item.id}`, 157 | }); 158 | })} 159 | 160 | ); 161 | 162 | case 'image': 163 | return ; 164 | default: 165 | break; 166 | } 167 | }; 168 | 169 | const getJsx = () => { 170 | const data = undoRedoData.activeSnapshot || nodes; 171 | return data.map((item: DatModelItem) => { 172 | return getJsxItem(item); 173 | }); 174 | }; 175 | const onStageClick = (e: any) => { 176 | // tmpGroup.current 177 | Promise.resolve().then(() => { 178 | // 事件消息有延时 179 | if (e?.type !== 'dblclick') { 180 | // console.log('onStageClick', e?.currentTarget?.nodeType, JSON.stringify(e), e); 181 | changeCanvasPanel(); 182 | console.log('canvas-click-e=>', e); 183 | tmpGroup.current = []; 184 | removeGroup(tmpGroupKey.current); 185 | } 186 | }); 187 | 188 | // const overlapping = Konva.Util.haveIntersection(ref1.current.getClientRect(), ref2.current.getClientRect()); 189 | // console.log('overlapping=>', overlapping, ref1.current.getClientRect(), ref2.current.getClientRect(),); 190 | }; 191 | 192 | const onDragMove = useCallback((e: Konva.KonvaEventObject) => { 193 | if (layerRef.current) 194 | detectionToLine(layerRef.current, e.target as Konva.Shape); 195 | }, []); 196 | 197 | const onDragEnd = useCallback(() => { 198 | if (layerRef.current) removeLines(layerRef.current); 199 | }, []); 200 | 201 | const onStateMouseDown = (e: Konva.KonvaEventObject) => { 202 | if (e.evt.shiftKey) { 203 | tmpGroup.current.push({ 204 | ...e.target.attrs, 205 | child: true, 206 | draggable: false, 207 | } as Konva.Shape); 208 | if (tmpGroup.current.length > 1) { 209 | addGroup(tmpGroupKey.current, tmpGroup.current); 210 | } 211 | // console.log('e.', e, e.evt.shiftKey); 212 | } else { 213 | console.log('e', e.target); 214 | if (tmpGroup.current.length === 0 || tmpGroup.current.length === 1) { 215 | tmpGroup.current = [ 216 | { ...e.target.attrs, child: true, draggable: false }, 217 | ]; 218 | } 219 | } 220 | }; 221 | 222 | const content = getJsx(); 223 | // console.log('content=>', content); 224 | const top = 225 | ref.current && stageData.height - (ref.current.clientHeight - 120) > 0 226 | ? stageData.height - (ref.current.clientHeight - 120) 227 | : 0; 228 | const left = 229 | ref.current && stageData.width - (ref.current.clientWidth - 120) > 0 230 | ? stageData.width - (ref.current.clientWidth - 120) 231 | : 0; 232 | // const top = stageData.height - 835 >= 0 ? stageData.height - 835 : 0; 233 | // const left = stageData.width - 0 >= 0 ? stageData.width - 0 : 0; 234 | const style = { 235 | marginTop: top, 236 | marginLeft: left, 237 | }; 238 | console.log('nodes=>', nodes); 239 | // console.log('ref.current.scrollHeight', stageData.height, height, 835, 955, style) 240 | return ( 241 | 242 | {!loading && } 243 |
244 | 245 | {loading ? ( 246 | 247 | ) : ( 248 |
249 | 258 | 263 | {content} 264 | 265 | 266 |
267 | )} 268 |
269 |
270 | ); 271 | }; 272 | 273 | export default Canvas; 274 | -------------------------------------------------------------------------------- /src/models1/canvasDataModel.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 该Model主要用来实现Canvas数据逻辑 3 | */ 4 | import { useState, useCallback } from 'react'; 5 | import { useImmer } from 'use-immer'; 6 | import { ShapePanelEnum } from '@/enum'; 7 | import { uuid, getCenterXY } from '@/utils/util'; 8 | import { useModel } from 'umi'; 9 | import type { DataModel, DatModelItem } from '@/typing'; 10 | import _ from 'lodash'; 11 | import canvasModel from './canvasModel'; 12 | 13 | export type CanvasModel = { 14 | width: number; 15 | height: number; 16 | nodes: DataModel; 17 | }; 18 | 19 | const initData: DataModel = [ 20 | { 21 | id: 'bg', 22 | type: 'color', 23 | color: '#F5EDDF', 24 | }, 25 | { 26 | name: 'node', 27 | draggable: true, 28 | x: 436, 29 | y: 49.772379680409145, 30 | id: '28608db7-7f68-4fb4-bfc9-54522364c617', 31 | fontSize: 60, 32 | type: 'text-input', 33 | text: '节点框选功能', 34 | fill: '#000', 35 | width: 360.0000000000001, 36 | isSelected: true, 37 | lineHeight: 1, 38 | rotation: 0, 39 | scaleX: 1, 40 | scaleY: 1, 41 | offsetX: 0, 42 | offsetY: 0, 43 | skewX: 0, 44 | skewY: 0, 45 | visible: true, 46 | height: 60.99999999999992, 47 | }, 48 | // { 49 | // name: 'node', 50 | // draggable: true, 51 | // id: '3f0dfc51-5481-49d0-8f3a-8a93dd3e3bfa', 52 | // type: 'image', 53 | // x: 142.42857142857156, 54 | // y: 532.0390814535209, 55 | // isSelected: false, 56 | // width: 174.80503425244814, 57 | // height: 132.10377568933617, 58 | // url: '/image2/1.jpg', 59 | // rotation: 0, 60 | // scaleX: 1, 61 | // scaleY: 1, 62 | // offsetX: 0, 63 | // offsetY: 0, 64 | // skewX: 0, 65 | // skewY: 0, 66 | // }, 67 | { 68 | name: 'group', 69 | draggable: true, 70 | id: 'b366673e-a7cf-445e-8e52-65ce3ecb7b81', 71 | type: 'group', 72 | x: 0, 73 | y: 0, 74 | children: [ 75 | { 76 | name: 'node', 77 | draggable: false, 78 | id: '1', 79 | type: 'image', 80 | url: '/image2/1.jpg', 81 | x: 142.42857142857156, 82 | y: 357.7714218418357, 83 | isSelected: false, 84 | width: 162.8722198534792, 85 | height: 123.15416489010939, 86 | rotation: 0, 87 | scaleX: 1, 88 | scaleY: 1, 89 | offsetX: 0, 90 | offsetY: 0, 91 | skewX: 0, 92 | skewY: 0, 93 | }, 94 | { 95 | name: 'node', 96 | draggable: false, 97 | id: '2', 98 | type: 'image', 99 | url: '/image2/14.jpeg', 100 | x: 142.42857142857156, 101 | y: 81.90962940621816, 102 | isSelected: false, 103 | width: 193.94019696803468, 104 | height: 146.45514772602604, 105 | rotation: 0, 106 | scaleX: 1, 107 | scaleY: 1, 108 | offsetX: 0, 109 | offsetY: 0, 110 | skewX: 0, 111 | skewY: 0, 112 | }, 113 | ], 114 | }, 115 | // { 116 | // draggable: true, 117 | // id: '1789615c-ef9a-4378-99cf-292c7e5d47b2', 118 | // type: 'image', 119 | // url: '/image2/1.jpg', 120 | // x: 936.2857142857144, 121 | // y: 176.19534369193246, 122 | // isSelected: true, 123 | // width: 240.7681041998711, 124 | // height: 181.57607814990322, 125 | // rotation: 0, 126 | // scaleX: 1, 127 | // scaleY: 1, 128 | // skewX: 0, 129 | // skewY: 0, 130 | // offsetX: 0, 131 | // offsetY: 0, 132 | // }, 133 | ]; 134 | 135 | const newData = [ 136 | { 137 | id: 'bg', 138 | type: 'color', 139 | fill: '#F5EDDF', 140 | }, 141 | { 142 | name: 'node', 143 | draggable: true, 144 | x: 436, 145 | y: 49.772379680409145, 146 | id: '28608db7-7f68-4fb4-bfc9-54522364c617', 147 | fontSize: 60, 148 | type: 'text-input', 149 | text: '节点框选案例', 150 | fill: '#000', 151 | width: 360.0000000000001, 152 | isSelected: true, 153 | lineHeight: 1, 154 | rotation: 0, 155 | scaleX: 1, 156 | scaleY: 1, 157 | offsetX: 0, 158 | offsetY: 0, 159 | skewX: 0, 160 | skewY: 0, 161 | visible: true, 162 | // height: 60.99999999999992, 163 | }, 164 | { 165 | width: 162.8722198534792, 166 | height: 123.15416489010939, 167 | name: 'node', 168 | draggable: true, 169 | id: '1', 170 | type: 'image', 171 | x: 142.42857142857156, 172 | y: 357.7714218418357, 173 | isSelected: false, 174 | rotation: 0, 175 | scaleX: 1, 176 | scaleY: 1, 177 | offsetX: 0, 178 | offsetY: 0, 179 | skewX: 0, 180 | skewY: 0, 181 | // "child": true, 182 | url: '/image2/1.jpg', 183 | }, 184 | { 185 | width: 193.94019696803468, 186 | height: 146.45514772602604, 187 | name: 'node', 188 | draggable: true, 189 | id: '2', 190 | type: 'image', 191 | x: 142.42857142857156, 192 | y: 81.90962940621816, 193 | isSelected: false, 194 | rotation: 0, 195 | scaleX: 1, 196 | scaleY: 1, 197 | offsetX: 0, 198 | offsetY: 0, 199 | skewX: 0, 200 | skewY: 0, 201 | // "child": true, 202 | url: '/image2/14.jpeg', 203 | }, 204 | { 205 | width: 193.94019696803468, 206 | height: 146.45514772602604, 207 | name: 'node', 208 | draggable: true, 209 | id: '3', 210 | type: 'image', 211 | x: 342.42857142857156, 212 | y: 181.90962940621816, 213 | isSelected: false, 214 | rotation: 0, 215 | scaleX: 1, 216 | scaleY: 1, 217 | offsetX: 0, 218 | offsetY: 0, 219 | skewX: 0, 220 | skewY: 0, 221 | // "child": true, 222 | url: '/image2/14.jpeg', 223 | }, 224 | // { 225 | // "draggable": true, 226 | // "name": "group", 227 | // "id": "b366673e-a7cf-445e-8e52-65ce3ecb7b81", 228 | // "type": "group", 229 | // "x": -142.61460101867573, 230 | // "y": 29.278321955451514, 231 | // "isSelected": true, 232 | // "rotation": 0, 233 | // "scaleX": 1, 234 | // "scaleY": 1, 235 | // "offsetX": 0, 236 | // "offsetY": 0, 237 | // "skewX": 0, 238 | // "skewY": 0, 239 | // "children": [ 240 | 241 | // ] 242 | // } 243 | ]; 244 | 245 | const recordPush = (nodes: any, undoRedoData: any, updateUndoRedoData: any) => { 246 | // const currNodes = undoRedoData.activeSnapshot || nodes; 247 | const newNodes = undoRedoData.activeSnapshot || nodes; 248 | updateUndoRedoData({ type: 'push', data: newNodes }); 249 | return newNodes; 250 | }; 251 | 252 | const canvasDataModel = ({ get, set }: any) => ({ 253 | width: 1200, 254 | height: 700, 255 | nodes: newData || initData, 256 | changeCanvasModel: (currCanvasModel: any) => { 257 | set(currCanvasModel); 258 | }, 259 | changeCanvasModelDataItem: (currDataModelItem: DatModelItem) => { 260 | const { nodes } = get(); 261 | // window.nodes = nodes; 262 | const { changeCanvas, updateUndoRedoData, undoRedoData } = get(canvasModel); 263 | changeCanvas({ 264 | selectNode: currDataModelItem, 265 | }); 266 | const currNodes = undoRedoData.activeSnapshot || nodes; 267 | updateUndoRedoData({ type: 'push', data: currNodes }); 268 | set((state: any) => { 269 | let index = currNodes.findIndex( 270 | (item: DatModelItem) => item.id === currDataModelItem.id, 271 | ); 272 | currNodes[index] = currDataModelItem; 273 | // console.log('currNodescurrNodescurrNodescurrNodescurrNodes=>', currNodes) 274 | 275 | return { 276 | nodes: [...currNodes], 277 | }; 278 | }); 279 | }, 280 | addGroup: (groupKey: string, group: Array) => { 281 | const { changeCanvas, updateUndoRedoData, undoRedoData } = get(canvasModel); 282 | 283 | set((state: any) => { 284 | console.log('group=>', group); 285 | _.remove(state.nodes, (n) => { 286 | const index = group.findIndex((f) => f.id === n.id); 287 | console.log('index=>', index); 288 | return index != -1; 289 | }); 290 | console.log('nodes=>', state.nodes); 291 | const index = state.nodes.findIndex( 292 | (f: DatModelItem) => f.id === groupKey, 293 | ); 294 | let currNode = null; 295 | if (index !== -1) { 296 | currNode = { ...state.nodes[index], children: group }; 297 | state.nodes[index] = currNode; 298 | // currGroup.children = group; 299 | // state.nodes.push(currGroup); 300 | } else { 301 | currNode = { 302 | id: groupKey, 303 | type: 'group', 304 | draggable: true, 305 | children: group, 306 | }; 307 | state.nodes.push(currNode); 308 | } 309 | changeCanvas({ 310 | selectNode: currNode, 311 | }); 312 | 313 | return { 314 | nodes: [...state.nodes], 315 | }; 316 | }); 317 | }, 318 | removeGroup: (groupKey: string) => { 319 | set((state: any) => { 320 | const index = state.nodes.findIndex( 321 | (f: DatModelItem) => f.id === groupKey, 322 | ); 323 | const currGroup = state.nodes?.[index]; 324 | if (!currGroup) { 325 | return; 326 | } 327 | console.log('remove-group,', currGroup); 328 | currGroup.children?.forEach((item: any) => { 329 | item.child = undefined; 330 | item.draggable = true; 331 | item.x = item.x + currGroup.x; 332 | item.y = item.y + currGroup.y; 333 | item.offsetX = item.width / 2; 334 | item.offsetY = item.height / 2; 335 | item.rotation = currGroup.rotation; 336 | item.skewX = item.skewX + currGroup.skewX; 337 | item.scaleX = currGroup.scaleX; 338 | 339 | item.skewY = currGroup.skewY; 340 | item.scaleY = currGroup.scaleY; 341 | state.nodes.push(item); 342 | }); 343 | _.remove(state.nodes, (n) => { 344 | return n.id == groupKey; 345 | }); 346 | 347 | return { 348 | nodes: [...state.nodes], 349 | }; 350 | }); 351 | }, 352 | 353 | addNode: (node: DatModelItem, nodeWidth: number, nodeHeight: number) => { 354 | const { width, height, nodes } = get(); 355 | const { changeCanvas, undoRedoData, updateUndoRedoData } = get(canvasModel); 356 | const [x, y] = getCenterXY(width, height, nodeWidth, nodeHeight); 357 | node.x = x; 358 | node.y = y; 359 | // debugger; 360 | const newNodes = recordPush(nodes, undoRedoData, updateUndoRedoData); 361 | set((state: any) => { 362 | return { 363 | nodes: [...newNodes, node], 364 | }; 365 | }); 366 | 367 | changeCanvas({ 368 | selectNode: node, 369 | }); 370 | }, 371 | addText: () => { 372 | const { addNode } = get(); 373 | const textWidth = 360; 374 | const textHeight = 60; 375 | 376 | const currTextDateItem: DatModelItem = { 377 | x: 0, 378 | y: 0, 379 | id: uuid(), 380 | fontSize: 60, 381 | type: 'text-input', 382 | text: '双击编辑文字', 383 | fill: '#000', 384 | width: textWidth, 385 | }; 386 | addNode(currTextDateItem, textWidth, textHeight); 387 | }, 388 | addImage: (url: string) => { 389 | const { addNode } = get(); 390 | const textWidth = 400; 391 | const textHeight = 200; 392 | const currTextDateItem: DatModelItem = { 393 | id: uuid(), 394 | // x: 0, 395 | // y: 0, 396 | type: 'image', 397 | url, 398 | }; 399 | addNode(currTextDateItem, textWidth, textHeight); 400 | }, 401 | removeNode: (id: string) => { 402 | const { nodes } = get(); 403 | const { undoRedoData, updateUndoRedoData } = get(canvasModel); 404 | const currNodes = recordPush(nodes, undoRedoData, updateUndoRedoData); 405 | set((state: any) => { 406 | let newNodes = currNodes.filter((item: DatModelItem) => item.id !== id); 407 | return { 408 | nodes: [...newNodes], 409 | }; 410 | }); 411 | }, 412 | copyNode: (item: DatModelItem) => { 413 | const { nodes } = get(); 414 | const { changeCanvas, undoRedoData, updateUndoRedoData } = get(canvasModel); 415 | item.id = uuid(); 416 | item.x += 20; 417 | item.y += 20; 418 | const newNodes = recordPush(nodes, undoRedoData, updateUndoRedoData); 419 | set((state: any) => { 420 | return { 421 | nodes: [...newNodes, item], 422 | }; 423 | }); 424 | changeCanvas({ 425 | selectNode: item, 426 | }); 427 | }, 428 | setTemplate: (data: any) => { 429 | const { setTemplate } = get(canvasModel); 430 | set((state: any) => { 431 | setTemplate(data); 432 | return { 433 | width: data.width, 434 | height: data.height, 435 | nodes: data.nodes, 436 | }; 437 | }); 438 | }, 439 | }); 440 | 441 | export default canvasDataModel; 442 | --------------------------------------------------------------------------------