├── .browserslistrc ├── .env.lib ├── public ├── favicon.ico └── index.html ├── .husky └── pre-commit ├── docs └── diffpatch │ ├── img │ ├── uuu.png │ ├── patch.jpg │ ├── redo.png │ ├── vote1.png │ ├── vote2.png │ ├── vote3.png │ ├── vote5.png │ ├── unpatch.jpg │ ├── diffpatchdemo.png │ ├── maxSnapshots.jpg │ └── unpatchWithPatch.jpg │ └── README.md ├── src ├── assets │ ├── img │ │ └── phone.png │ └── css │ │ ├── custom.less │ │ ├── index.less │ │ ├── elementUI-overwrite.less │ │ └── reset.css ├── components │ ├── Editor │ │ ├── Action │ │ │ ├── abstractAction.ts │ │ │ ├── toast │ │ │ │ └── index.ts │ │ │ ├── alert │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── redirect │ │ │ │ └── index.ts │ │ │ ├── factory.ts │ │ │ └── request │ │ │ │ └── index.ts │ │ ├── BuiltInComponents │ │ │ ├── CommonInterface │ │ │ │ ├── Text.ts │ │ │ │ └── Container.ts │ │ │ ├── Button │ │ │ │ ├── Button.vue │ │ │ │ └── index.ts │ │ │ ├── Img │ │ │ │ ├── index.ts │ │ │ │ ├── ImgSetting.vue │ │ │ │ └── Img.vue │ │ │ ├── Text │ │ │ │ ├── index.ts │ │ │ │ ├── Text.vue │ │ │ │ └── TextSetting.vue │ │ │ ├── Layout.ts │ │ │ ├── Container │ │ │ │ ├── Container.vue │ │ │ │ ├── ContainerSetting.vue │ │ │ │ └── index.ts │ │ │ ├── Component │ │ │ │ └── index.ts │ │ │ └── ComponentWrapper │ │ │ │ └── index.vue │ │ ├── TrilateralComponents │ │ │ └── Vant │ │ │ │ ├── Swiper │ │ │ │ ├── index.ts │ │ │ │ └── Swiper.vue │ │ │ │ ├── Tab │ │ │ │ ├── index.ts │ │ │ │ ├── Tab.vue │ │ │ │ └── TabSetting.vue │ │ │ │ ├── NavBar │ │ │ │ ├── index.ts │ │ │ │ ├── NavBarSetting.vue │ │ │ │ └── NavBar.vue │ │ │ │ └── NoticeBar │ │ │ │ ├── index.ts │ │ │ │ ├── index.vue │ │ │ │ └── NoticeBarSetting.vue │ │ ├── Event │ │ │ └── index.ts │ │ ├── Uploader │ │ │ └── Uploader.vue │ │ ├── AroundValue │ │ │ └── ComputedModel.vue │ │ ├── ComponentTypes.ts │ │ ├── Factory.ts │ │ ├── ComponentsRegister.ts │ │ ├── SettingBar │ │ │ ├── ToolBar.vue │ │ │ ├── index.vue │ │ │ ├── Layout.vue │ │ │ └── General.vue │ │ ├── Header │ │ │ └── Header.vue │ │ └── Contextmenu │ │ │ └── index.vue │ ├── Previewer │ │ ├── index.ts │ │ ├── package.json │ │ ├── webpack.config.js │ │ ├── render.vue │ │ ├── index.vue │ │ └── router.ts │ └── ElIcon │ │ └── index.vue ├── hooks │ ├── useDialog.ts │ ├── useEventBus.ts │ ├── useContextmenu.ts │ ├── useDynamicVars.ts │ ├── useBindEvent.ts │ ├── useDrag.ts │ ├── useStyle.ts │ └── useResize.ts ├── pages │ ├── Components │ │ └── index.vue │ ├── index.vue │ └── Editor │ │ └── index.vue ├── shims-vue.d.ts ├── store │ ├── Editor │ │ ├── getters.ts │ │ ├── mutations │ │ │ ├── datasource.ts │ │ │ ├── mutation-type.ts │ │ │ ├── page.ts │ │ │ ├── event.ts │ │ │ ├── index.ts │ │ │ └── components.ts │ │ ├── index.ts │ │ └── util.ts │ └── index.ts ├── App.vue ├── main.ts ├── router │ └── index.ts ├── api │ └── document.ts ├── axios │ └── index.ts ├── plugins │ └── ElementUI.ts └── util │ ├── index.ts │ └── diffpatch │ └── index.ts ├── .prettierrc.js ├── server ├── src │ ├── config │ │ └── index.ts │ ├── document.ts │ ├── route │ │ ├── index.ts │ │ ├── upload.ts │ │ ├── preview.ts │ │ └── document.ts │ ├── model │ │ └── index.ts │ ├── util │ │ └── index.ts │ ├── middleware │ │ └── fileUploader.ts │ ├── index.ts │ └── views │ │ └── index.ejs ├── tsconfig.json ├── ecosystem.config.js └── package.json ├── babel.config.js ├── docker ├── Dockerfile ├── docker-entrypoint.sh └── nginx.conf ├── .gitignore ├── gulpfile.js ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── main.yml ├── .eslintrc.js ├── tsconfig.json ├── vue.config.js ├── .stylelintrc.js ├── LICENSE ├── package.json └── README.md /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | -------------------------------------------------------------------------------- /.env.lib: -------------------------------------------------------------------------------- 1 | TARGET=lib 2 | NODE_ENV=production 3 | VUE_APP_LIB=lib 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /docs/diffpatch/img/uuu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/uuu.png -------------------------------------------------------------------------------- /src/assets/img/phone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/src/assets/img/phone.png -------------------------------------------------------------------------------- /docs/diffpatch/img/patch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/patch.jpg -------------------------------------------------------------------------------- /docs/diffpatch/img/redo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/redo.png -------------------------------------------------------------------------------- /docs/diffpatch/img/vote1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/vote1.png -------------------------------------------------------------------------------- /docs/diffpatch/img/vote2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/vote2.png -------------------------------------------------------------------------------- /docs/diffpatch/img/vote3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/vote3.png -------------------------------------------------------------------------------- /docs/diffpatch/img/vote5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/vote5.png -------------------------------------------------------------------------------- /docs/diffpatch/img/unpatch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/unpatch.jpg -------------------------------------------------------------------------------- /src/assets/css/custom.less: -------------------------------------------------------------------------------- 1 | .hidden-scrollbar { 2 | &::-webkit-scrollbar { 3 | display: none; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Editor/Action/abstractAction.ts: -------------------------------------------------------------------------------- 1 | export abstract class Action { 2 | abstract handle(): void; 3 | } 4 | -------------------------------------------------------------------------------- /docs/diffpatch/img/diffpatchdemo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/diffpatchdemo.png -------------------------------------------------------------------------------- /docs/diffpatch/img/maxSnapshots.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/maxSnapshots.jpg -------------------------------------------------------------------------------- /docs/diffpatch/img/unpatchWithPatch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mgsod/h5-editor/HEAD/docs/diffpatch/img/unpatchWithPatch.jpg -------------------------------------------------------------------------------- /src/components/Previewer/index.ts: -------------------------------------------------------------------------------- 1 | import register from '@/components/Editor/ComponentsRegister'; 2 | 3 | export default register; 4 | -------------------------------------------------------------------------------- /src/hooks/useDialog.ts: -------------------------------------------------------------------------------- 1 | import { ref } from 'vue'; 2 | export default () => { 3 | const showDialog = ref(false); 4 | return { 5 | showDialog, 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | useTabs: false, 4 | semi: true, 5 | singleQuote: true, 6 | trailingComma: 'es5', 7 | insertPragma: false, 8 | }; 9 | -------------------------------------------------------------------------------- /server/src/config/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | export default { 3 | port: process.env.NODE_ENV === 'development' ? 3000 : 5555, 4 | dataPath: path.join(__dirname, '../../', 'data'), 5 | }; 6 | -------------------------------------------------------------------------------- /server/src/document.ts: -------------------------------------------------------------------------------- 1 | export interface IDocument { 2 | _id: string; 3 | name: string; 4 | content: T; 5 | cover: string; 6 | } 7 | 8 | export type DocumentModel = Omit; 9 | -------------------------------------------------------------------------------- /src/pages/Components/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue'; 4 | const component: DefineComponent<{}, {}, any>; 5 | export default component; 6 | } 7 | declare module 'uuid'; 8 | -------------------------------------------------------------------------------- /src/assets/css/index.less: -------------------------------------------------------------------------------- 1 | @import 'reset.css'; 2 | @import 'elementUI-overwrite'; 3 | @import 'custom'; 4 | 5 | html { 6 | font-family: sans-serif; 7 | font-size: 14px; 8 | } 9 | 10 | * { 11 | box-sizing: border-box; 12 | } 13 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "CommonJS", 4 | "types": ["node"], 5 | "esModuleInterop": true, 6 | "allowSyntheticDefaultImports": true, 7 | "outDir": "../dist/server", 8 | "target": "ES6" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/src/route/index.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import document from './document'; 3 | import uploader from './upload'; 4 | const router = Router(); 5 | 6 | router.use('/document', document); 7 | router.use('/upload', uploader); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | plugins: [ 4 | [ 5 | 'import', 6 | { 7 | libraryName: 'vant', 8 | libraryDirectory: 'es', 9 | style: true, 10 | }, 11 | ], 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/CommonInterface/Text.ts: -------------------------------------------------------------------------------- 1 | export interface ICommonText { 2 | color: string; 3 | fontFamily: string; 4 | fontSize: string | number; 5 | fontWeight: string; 6 | fontStyle: string; 7 | textAlign: string; 8 | lineHeight?: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Previewer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "previewer", 3 | "version": "1.0.0", 4 | "description": "h5预览器", 5 | "main": "index.umd.min.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC" 11 | } 12 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.14 2 | RUN apk add --no-cache --update nodejs npm nginx curl 3 | WORKDIR /data 4 | COPY . /data 5 | RUN npm config set registry https://registry.npmmirror.com 6 | RUN chmod 777 /data/docker-entrypoint.sh 7 | EXPOSE 5555 8 | EXPOSE 80 9 | ENTRYPOINT ["./docker-entrypoint.sh"] 10 | 11 | -------------------------------------------------------------------------------- /server/src/model/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import config from '../config'; 3 | const Datastore = require('nedb-promises'); 4 | const dataBase = Datastore.create({ 5 | filename: path.join(config.dataPath, '/db/document.db'), 6 | timestampData: true, 7 | }); 8 | 9 | export default dataBase; 10 | -------------------------------------------------------------------------------- /src/components/Editor/Action/toast/index.ts: -------------------------------------------------------------------------------- 1 | import { Alert } from '@/components/Editor/Action/alert'; 2 | import { Toast as VToast } from 'vant'; 3 | 4 | VToast.setDefaultOptions({ 5 | duration: 2000, 6 | }); 7 | export class Toast extends Alert { 8 | handle() { 9 | VToast(this.content); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'h5-editor-server', 5 | script: './index.js', 6 | watch: true, 7 | // Delay between restart 8 | watch_delay: 1000, 9 | ignore_watch: ['node_modules', 'db', 'static/img', 'static/covers'], 10 | }, 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 拷贝工作目录到app 3 | mkdir -p /app 4 | cp -r ./ /app 5 | cd /app 6 | # 服务端 7 | cd /app/server 8 | # 安装依赖 9 | npm install 10 | # 安装pm2 11 | npm install pm2 -g 12 | # 启动服务端 13 | pm2 start ecosystem.config.js 14 | # 配置nginx 15 | cp -f /app/nginx.conf /etc/nginx/http.d/default.conf 16 | # 启动nginx 17 | nginx 18 | # 防止进程推出关闭容器 19 | tail -f /dev/null 20 | -------------------------------------------------------------------------------- /server/src/route/upload.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import fileUploader from '../middleware/fileUploader'; 3 | const router = Router(); 4 | router.post('/', fileUploader().single('singleFile'), (req, res) => { 5 | res.json({ 6 | code: 200, 7 | message: '上传成功', 8 | data: `/static/img/${(req as any).file.filename}`, 9 | }); 10 | }); 11 | export default router; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /previewer 5 | 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | server/data 26 | /server/src/static/previewer/ 27 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "h5-editor-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "author": "", 7 | "license": "ISC", 8 | "dependencies": { 9 | "compression": "^1.7.4", 10 | "ejs": "^3.1.6", 11 | "express": "4.18.1", 12 | "express-async-errors": "^3.1.1", 13 | "nedb-promises": "^5.0.2", 14 | "multer": "^1.4.4" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/components/Editor/Action/alert/index.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@/components/Editor/Action/abstractAction'; 2 | 3 | export interface IAlert { 4 | content: string; 5 | } 6 | 7 | export class Alert extends Action implements IAlert { 8 | content: string; 9 | constructor(props: IAlert) { 10 | super(); 11 | this.content = props.content; 12 | } 13 | handle() { 14 | alert(this.content); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/store/Editor/getters.ts: -------------------------------------------------------------------------------- 1 | import { GetterTree } from 'vuex'; 2 | import { IState } from '@/store/Editor/index'; 3 | 4 | const getters: GetterTree = { 5 | currentPage: (state) => { 6 | return state.pages.find((item) => item.id === state.pageActive); 7 | }, 8 | isSelectRoot: (state) => { 9 | return state.selectedComponents?.id === 'root'; 10 | }, 11 | extractComponents: (state) => { 12 | return state.extractComponents; 13 | }, 14 | }; 15 | 16 | export default getters; 17 | -------------------------------------------------------------------------------- /server/src/route/preview.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import dataBase from '../model'; 3 | const route = Router(); 4 | 5 | route.get('/:id', async (req, res, next) => { 6 | const { id } = req.params; 7 | const data = await dataBase.findOne({ _id: id }); 8 | if (!data) return next(new Error('未查询到此文档')); 9 | res.render('index', { 10 | pages: JSON.stringify(data.content.pages), 11 | datasource: JSON.stringify(data.content.datasource || {}), 12 | name: data.name, 13 | }); 14 | }); 15 | 16 | export default route; 17 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | gulp.task('copy', function () { 3 | gulp 4 | .src([ 5 | 'server/src/views*/**', 6 | 'server/package.json', 7 | 'server/ecosystem.config.js', 8 | ]) 9 | .pipe(gulp.dest('./dist/server/')); 10 | gulp.src(['docker/**']).pipe(gulp.dest('./dist')); 11 | return gulp 12 | .src(['server/src/static/previewer*/**']) 13 | .pipe(gulp.dest('./dist/server/static/')); 14 | }); 15 | gulp.task( 16 | 'default', 17 | gulp.series('copy', (cb) => { 18 | cb(); 19 | }) 20 | ); 21 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 24 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | 5 | # Everything is a 404 6 | location / { 7 | root /app/front; 8 | index index.html; 9 | } 10 | 11 | location ~ ^/(api|static|preview)/ { 12 | proxy_set_header Host $host; 13 | proxy_set_header X-Real-IP $remote_addr; 14 | proxy_set_header X-Forwarded-Proto https; 15 | proxy_set_header X-Forwarded-For $remote_addr; 16 | proxy_set_header X-Forwarded-Host $remote_addr; 17 | proxy_pass http://127.0.0.1:5555; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/ElIcon/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 28 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Button/Button.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 26 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/Swiper/index.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from '@/components/Editor/ComponentTypes'; 2 | import Tab, { 3 | getNewTabContainer, 4 | ITab, 5 | } from '@/components/Editor/TrilateralComponents/Vant/Tab'; 6 | import { fastInitProps } from '@/util'; 7 | 8 | export default class Swiper extends Tab { 9 | type = ComponentType.Swiper; 10 | active = 0; 11 | width = ''; 12 | height = 200; 13 | children = [getNewTabContainer('轮播1'), getNewTabContainer('轮播2')]; 14 | 15 | constructor(props?: ITab) { 16 | super(props); 17 | fastInitProps(props, this); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Editor/Action/index.ts: -------------------------------------------------------------------------------- 1 | // 动作类型 2 | import { IRedirect } from '@/components/Editor/Action/redirect'; 3 | import { IAlert } from '@/components/Editor/Action/alert'; 4 | import { IRequest } from '@/components/Editor/Action/request'; 5 | 6 | export type ActionType = 'redirect' | 'alert' | 'request' | 'toast'; 7 | 8 | // 动作参数 9 | export type ActionProps = IRedirect & IAlert & IRequest; 10 | 11 | export const ActionList: { name: string; value: ActionType }[] = [ 12 | { name: '链接跳转', value: 'redirect' }, 13 | { name: '浏览器弹窗', value: 'alert' }, 14 | { name: '请求数据源', value: 'request' }, 15 | { name: '轻提示', value: 'toast' }, 16 | ]; 17 | -------------------------------------------------------------------------------- /src/assets/css/elementUI-overwrite.less: -------------------------------------------------------------------------------- 1 | .el-tabs--left .el-tabs__nav-wrap.is-left::after, 2 | .el-tabs--left .el-tabs__nav-wrap.is-right::after, 3 | .el-tabs--right .el-tabs__nav-wrap.is-left::after, 4 | .el-tabs--right .el-tabs__nav-wrap.is-right::after { 5 | width: 1px; 6 | } 7 | 8 | .el-tabs__nav-wrap::after { 9 | height: 1px; 10 | } 11 | 12 | .el-input-group__append, 13 | .el-input-group__prepend { 14 | padding: 0 8px; 15 | } 16 | 17 | .el-input-number__decrease, 18 | .el-input-number__increase { 19 | top: 2px; 20 | } 21 | 22 | .el-overlay { 23 | overflow: hidden; 24 | 25 | .el-overlay-dialog { 26 | overflow: hidden; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/store/Editor/mutations/datasource.ts: -------------------------------------------------------------------------------- 1 | import { MUTATION_TYPE } from '@/store/Editor/mutations/mutation-type'; 2 | import { MutationTree } from 'vuex'; 3 | import { IState } from '@/store/Editor'; 4 | 5 | const datasourceMutations: MutationTree = { 6 | [MUTATION_TYPE.UPDATE_DATASOURCE]: (state, { target, data }) => { 7 | data.msg = data.msg || 'msg'; 8 | data.code = data.code || 'code'; 9 | data.data = data.data || 'data'; 10 | state.datasource[target] = data; 11 | }, 12 | [MUTATION_TYPE.DELETE_DATASOURCE]: (state, target) => { 13 | delete state.datasource[target]; 14 | }, 15 | }; 16 | 17 | export default datasourceMutations; 18 | -------------------------------------------------------------------------------- /src/components/Editor/Event/index.ts: -------------------------------------------------------------------------------- 1 | import { ActionProps, ActionType } from '@/components/Editor/Action'; 2 | 3 | export type EventType = 'click' | 'mouseenter' | 'mouseleave' | 'mounted'; 4 | export interface IEvent { 5 | eventType: EventType; 6 | actionType: ActionType; 7 | actionProps: Partial; 8 | } 9 | export const EventTypeList: { name: string; value: EventType }[] = [ 10 | { name: '点击', value: 'click' }, 11 | { name: '鼠标进入', value: 'mouseenter' }, 12 | { name: '鼠标离开', value: 'mouseleave' }, 13 | { name: '初始化', value: 'mounted' }, 14 | ]; 15 | // 把EventType每个类型作为key,再把IEvent作为值 16 | export type EventTypeKey = Record; 17 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/typescript/recommended', 10 | '@vue/prettier', 11 | '@vue/prettier/@typescript-eslint', 12 | ], 13 | parserOptions: { 14 | ecmaVersion: 2020, 15 | }, 16 | rules: { 17 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 18 | 'no-unused-vars': 'off', 19 | '@typescript-eslint/no-unused-vars': 'off', 20 | '@typescript-eslint/no-empty-function': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-var-requires': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import App from './App.vue'; 3 | import router from './router'; 4 | import store, { key } from './store'; 5 | import elementUI from './plugins/ElementUI'; 6 | import 'element-plus/dist/index.css'; 7 | import '@/assets/css/index.less'; 8 | import register from '@/components/Editor/ComponentsRegister'; 9 | import ElIcon from '@/components/ElIcon/index.vue'; 10 | import 'default-passive-events'; 11 | import useDynamicVars from '@/hooks/useDynamicVars'; 12 | 13 | const app = createApp(App); 14 | const { parseExpression } = useDynamicVars(); 15 | elementUI(app); 16 | app.use(store, key); 17 | app.use(register); 18 | app.component('el-icons', ElIcon); 19 | app.use(router).mount('#app'); 20 | app.mixin({ 21 | methods: { 22 | parseExpression, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/hooks/useEventBus.ts: -------------------------------------------------------------------------------- 1 | export enum EventType { 2 | updateBorder, 3 | } 4 | type handle = (payload: any) => void; 5 | interface IEventPool { 6 | [key: string]: handle[]; 7 | } 8 | 9 | class EventBus { 10 | private readonly pool: IEventPool; 11 | constructor() { 12 | this.pool = {}; 13 | } 14 | $on(name: EventType, handle: handle) { 15 | this.pool[name] = this.pool[name] || []; 16 | (this.pool[name] as handle[]).push(handle); 17 | } 18 | $emit(name: EventType, payload?: any) { 19 | if (this.pool[name]) { 20 | this.pool[name].forEach((item) => { 21 | item(payload); 22 | }); 23 | } 24 | } 25 | $off(name: EventType) { 26 | if (this.pool[name]) { 27 | delete this.pool[name]; 28 | } 29 | } 30 | } 31 | 32 | const eventBus = new EventBus(); 33 | 34 | export default eventBus; 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "skipLibCheck": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "baseUrl": ".", 14 | "types": [ 15 | "webpack-env" 16 | ], 17 | "paths": { 18 | "@/*": [ 19 | "src/*" 20 | ] 21 | }, 22 | "lib": [ 23 | "esnext", 24 | "dom", 25 | "dom.iterable", 26 | "scripthost" 27 | ] 28 | }, 29 | "include": [ 30 | "src/**/*.ts", 31 | "src/**/*.tsx", 32 | "src/**/*.vue", 33 | "tests/**/*.ts", 34 | "tests/**/*.tsx" 35 | ], 36 | "exclude": [ 37 | "node_modules" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /server/src/util/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import config from '../config'; 4 | 5 | const { dataPath } = config; 6 | export const writeImgByBase64 = function ( 7 | _path: string, 8 | base64: string, 9 | name: number | string = Date.now() 10 | ) { 11 | const pwd = path.join(dataPath, 'static/', _path); 12 | const filepath = path.join(pwd, `${name}.png`); 13 | base64 = base64.replace(/^data:image\/\w+;base64,/, ''); 14 | const dataBuffer = Buffer.from(base64, 'base64'); 15 | return new Promise((resolve, reject) => { 16 | fs.mkdir(pwd, { recursive: true }, () => { 17 | fs.writeFile(filepath, dataBuffer, (err: Error | null) => { 18 | if (err) { 19 | reject(err); 20 | } else { 21 | resolve(`/static/${_path}/${name}.png`); 22 | } 23 | }); 24 | }); 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const target = process.env.TARGET || ''; 2 | // 构建组件包配置 3 | const libConfig = require('./src/components/Previewer/webpack.config'); 4 | // 项目默认配置 5 | const defaultConfig = { 6 | publicPath: './', 7 | outputDir: './dist/front', 8 | devServer: { 9 | host: '0.0.0.0', 10 | proxy: { 11 | '/api': { 12 | target: 'http://127.0.0.1:3000', 13 | }, 14 | '/static': { 15 | target: 'http://127.0.0.1:3000', 16 | }, 17 | '/preview': { 18 | target: 'http://127.0.0.1:3000', 19 | }, 20 | }, 21 | }, 22 | configureWebpack: { 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.mjs$/, 27 | include: /node_modules/, 28 | type: 'javascript/auto', 29 | }, 30 | ], 31 | }, 32 | }, 33 | }; 34 | const config = target === 'lib' ? libConfig : defaultConfig; 35 | module.exports = config; 36 | -------------------------------------------------------------------------------- /src/components/Editor/Action/redirect/index.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@/components/Editor/Action/abstractAction'; 2 | import { Router } from '@/components/Previewer/router'; 3 | type RedirectType = 'inside' | 'outside'; 4 | export const redirectTypeList: { name: string; value: RedirectType }[] = [ 5 | { name: '内部跳转', value: 'inside' }, 6 | { name: '外部跳转', value: 'outside' }, 7 | ]; 8 | export interface IRedirect { 9 | url: string; 10 | type: RedirectType; 11 | } 12 | export class Redirect extends Action implements IRedirect { 13 | url: string; 14 | type: RedirectType; 15 | constructor(event: IRedirect) { 16 | super(); 17 | this.url = event.url; 18 | this.type = event.type; 19 | } 20 | handle() { 21 | switch (this.type) { 22 | case 'inside': 23 | Router.go(this.url); 24 | break; 25 | case 'outside': 26 | location.href = this.url; 27 | break; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/Editor/Action/factory.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@/components/Editor/Action/abstractAction'; 2 | import { IEvent } from '@/components/Editor/Event'; 3 | import { IRedirect, Redirect } from '@/components/Editor/Action/redirect'; 4 | import { Alert, IAlert } from '@/components/Editor/Action/alert'; 5 | import { IRequest, Request } from '@/components/Editor/Action/request'; 6 | import { Toast } from '@/components/Editor/Action/toast'; 7 | 8 | export class ActionFactory { 9 | static getAction(event: IEvent, payload?: any): Action { 10 | switch (event.actionType) { 11 | case 'redirect': 12 | return new Redirect(event.actionProps as IRedirect); 13 | case 'alert': 14 | return new Alert(event.actionProps as IAlert); 15 | case 'request': 16 | return new Request(event.actionProps as IRequest, payload); 17 | case 'toast': 18 | return new Toast(event.actionProps as IAlert); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/src/middleware/fileUploader.ts: -------------------------------------------------------------------------------- 1 | import multer from 'multer'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import config from '../config'; 5 | const uploader = () => { 6 | return multer({ 7 | storage: multer.diskStorage({ 8 | destination(req, file, cb: (a: any, b: string) => void) { 9 | const fullPath = `${path.join(config.dataPath, 'static/img')}`; 10 | const exist = fs.existsSync(fullPath); 11 | if (!exist) { 12 | fs.mkdirSync(fullPath, { recursive: true }); 13 | } 14 | cb(null, fullPath); 15 | }, 16 | filename(req, file, cb: (a: any, b: string) => void) { 17 | const hz = file.originalname 18 | .substring(file.originalname.lastIndexOf('.') + 1) 19 | .toLowerCase(); 20 | const changedName = new Date().getTime() + '.' + hz; 21 | cb(null, changedName); 22 | }, 23 | }), 24 | }); 25 | }; 26 | export default uploader; 27 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'stylelint-config-standard', 4 | 'stylelint-config-recess-order', 5 | 'stylelint-config-prettier', 6 | 'stylelint-config-recommended-vue', 7 | ], 8 | rules: { 9 | 'declaration-colon-space-after': 'always-single-line', 10 | 'declaration-colon-space-before': 'never', 11 | 'font-family-no-missing-generic-family-keyword': null, 12 | 'font-family-name-quotes': null, 13 | 'no-invalid-double-slash-comments': null, // 允许使用双斜杠注释 14 | 'at-rule-no-unknown': null, // 允许自定义less变量 15 | 'color-function-notation': null, 16 | 'selector-class-pattern': null, 17 | 'no-descending-specificity': null, 18 | 'rule-empty-line-before': [ 19 | 'always', 20 | { 21 | ignore: ['after-comment', 'first-nested'], 22 | }, 23 | ], 24 | 'value-no-vendor-prefix': [ 25 | true, 26 | { 27 | ignoreValues: ['box'], 28 | }, 29 | ], 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Button/index.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | IBackground, 3 | IComponent, 4 | } from '@/components/Editor/BuiltInComponents/Component'; 5 | import { fastInitProps } from '@/util'; 6 | import { ComponentType } from '@/components/Editor/ComponentTypes'; 7 | import { ICommonText } from '@/components/Editor/BuiltInComponents/CommonInterface/Text'; 8 | 9 | export interface IButton extends IComponent, Partial { 10 | text: string; 11 | } 12 | 13 | export default class Button extends Component implements IButton { 14 | type = ComponentType.Button; 15 | width = 120; 16 | height = 40; 17 | text = '按钮'; 18 | color = '#fff'; 19 | fontFamily = ''; 20 | fontStyle = ''; 21 | fontWeight = ''; 22 | fontSize = ''; 23 | background: IBackground = { 24 | color: '#409eff', 25 | }; 26 | borderRadius = '4,4,4,4'; 27 | 28 | constructor(props: IButton) { 29 | super(props); 30 | fastInitProps(props, this); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/CommonInterface/Container.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 继承于基础组件的容器组件基类 3 | */ 4 | import Component, { 5 | IComponent, 6 | } from '@/components/Editor/BuiltInComponents/Component'; 7 | import { fastInitProps } from '@/util'; 8 | import { PartOfComponent } from '@/components/Editor/ComponentTypes'; 9 | import { FLEX_DIRECTION } from '@/components/Editor/BuiltInComponents/Container'; 10 | 11 | export interface ICommonContainer extends IComponent { 12 | isContainer: boolean; 13 | // 是否是根,包括页面的根,tab组件中的根容器 14 | // isRoot = true 表示不可拖拽、缩放 15 | isRoot?: boolean; 16 | children: PartOfComponent[]; 17 | direction: FLEX_DIRECTION; 18 | } 19 | 20 | export abstract class CommonContainer 21 | extends Component 22 | implements ICommonContainer 23 | { 24 | isContainer = true; 25 | children: PartOfComponent[] = []; 26 | direction: FLEX_DIRECTION = FLEX_DIRECTION.ROW; 27 | 28 | protected constructor(props?: ICommonContainer) { 29 | super(props); 30 | fastInitProps(props, this); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/Previewer/webpack.config.js: -------------------------------------------------------------------------------- 1 | const CopyPlugin = require('copy-webpack-plugin'); 2 | const path = require('path'); 3 | module.exports = { 4 | outputDir: 'server/src/static/previewer', 5 | productionSourceMap: false, 6 | configureWebpack: { 7 | output: { 8 | // 默认导出 9 | libraryExport: 'default', 10 | // 包名 11 | library: 'previewer', 12 | }, 13 | plugins: [ 14 | new CopyPlugin([ 15 | { 16 | from: path.resolve(__dirname, './package.json'), 17 | to: '', 18 | }, 19 | ]), 20 | ], 21 | }, 22 | css: { 23 | loaderOptions: { 24 | postcss: { 25 | plugins: [ 26 | require('postcss-pxtorem')({ 27 | rootValue: 37.5, 28 | unitPrecision: 5, 29 | propList: ['*'], 30 | selectorBlackList: [], 31 | replace: true, 32 | mediaQuery: false, 33 | minPixelValue: 0, 34 | exclude: /node_modules/i, 35 | }), 36 | ], 37 | }, 38 | }, 39 | }, 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/Tab/index.ts: -------------------------------------------------------------------------------- 1 | import Container from '@/components/Editor/BuiltInComponents/Container'; 2 | import { 3 | ComponentType, 4 | PartOfComponent, 5 | } from '@/components/Editor/ComponentTypes'; 6 | import { fastInitProps } from '@/util'; 7 | import { 8 | CommonContainer, 9 | ICommonContainer, 10 | } from '@/components/Editor/BuiltInComponents/CommonInterface/Container'; 11 | 12 | export const getNewTabContainer = (name = '新标签') => { 13 | return new Container({ 14 | isRoot: true, 15 | width: '', 16 | height: '', 17 | alias: name, 18 | }); 19 | }; 20 | 21 | export interface ITab extends ICommonContainer { 22 | active: number; 23 | children: PartOfComponent[]; 24 | } 25 | 26 | export default class Tab extends CommonContainer implements ITab { 27 | type = ComponentType.Tab; 28 | width = ''; 29 | height = 300; 30 | children = [getNewTabContainer('标签1'), getNewTabContainer('标签2')]; 31 | active = 0; 32 | 33 | constructor(props?: ITab) { 34 | super(props); 35 | fastInitProps(props, this); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/NavBar/index.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | IComponent, 3 | } from '@/components/Editor/BuiltInComponents/Component'; 4 | import { fastInitProps } from '@/util'; 5 | import { ComponentType } from '@/components/Editor/ComponentTypes'; 6 | import { Position } from '@/components/Editor/BuiltInComponents/Layout'; 7 | import { ICommonText } from '@/components/Editor/BuiltInComponents/CommonInterface/Text'; 8 | 9 | export interface INavBar extends IComponent, Pick { 10 | title: string; 11 | showBack: boolean; 12 | showBottomLine: boolean; 13 | fullScreen: boolean; 14 | } 15 | 16 | export default class NavBar extends Component implements INavBar { 17 | type = ComponentType.NavBar; 18 | title = '标题'; 19 | width = 375; 20 | height = 46; 21 | top = 0; 22 | left = 0; 23 | showBack = true; 24 | showBottomLine = true; 25 | position: Position = 'absolute'; 26 | color = '#000'; 27 | fullScreen = false; 28 | 29 | constructor(props: INavBar) { 30 | super(props); 31 | fastInitProps(props, this); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/store/Editor/mutations/mutation-type.ts: -------------------------------------------------------------------------------- 1 | export enum MUTATION_TYPE { 2 | LOAD = 'LOAD', 3 | LOAD_BY_CACHE = 'LOAD_BY_CACHE', 4 | ADD_PAGE = 'ADD_PAGE', 5 | REMOVE_PAGE = 'REMOVE_PAGE', 6 | SELECT_PAGE = 'SELECT_PAGE', 7 | CHANGE_PAGE = 'CHANGE_PAGE', 8 | EDIT_PAGE = 'EDIT_PAGE', 9 | DELETE_PAGE = 'DELETE_PAGE', 10 | COPY_PAGE = 'COPY_PAGE', 11 | ADD_COMPONENT = 'ADD_COMPONENT', 12 | REMOVE_COMPONENT = 'REMOVE_COMPONENT', 13 | SELECT_COMPONENT = 'SELECT_COMPONENT', 14 | EXTRACT_COMPONENT = 'EXTRACT_COMPONENT', 15 | DELETE_EXTRACT_COMPONENT = 'DELETE_EXTRACT_COMPONENT', 16 | UNDO = 'UNDO', 17 | REDO = 'REDO', 18 | INIT = 'INIT', 19 | UPDATE_COMPONENT = 'UPDATE_COMPONENT', 20 | RESIZE = 'RESIZE', 21 | ADD_EVENT = 'ADD_EVENT', 22 | REMOVE_EVENT = 'REMOVE_EVENT', 23 | UPDATE_EVENT = 'UPDATE_EVENT', 24 | DRAG_COMPONENT = 'DRAG_COMPONENT', 25 | DRAG_TREE = 'DRAG_TREE', 26 | ENTER_CONTAINER = 'ENTER_CONTAINER', 27 | LEAVE_CONTAINER = 'LEAVE_CONTAINER', 28 | UPDATE_DATASOURCE = 'UPDATE_DATASOURCE', 29 | DELETE_DATASOURCE = 'DELETE_DATASOURCE', 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/useContextmenu.ts: -------------------------------------------------------------------------------- 1 | import { ref, reactive, Ref } from 'vue'; 2 | 3 | const contextMens: { id: string; show: Ref }[] = []; 4 | document.addEventListener('click', closeHandler); 5 | 6 | function closeHandler(e: MouseEvent) { 7 | const target = e.target as HTMLElement; 8 | if (!target.closest('.contextmenu')) { 9 | closeContextmenu(); 10 | } 11 | } 12 | 13 | function closeContextmenu() { 14 | contextMens.forEach((item) => { 15 | item.show.value = false; 16 | }); 17 | } 18 | 19 | export default (id = 'default') => { 20 | const showContextmenu = ref(false); 21 | contextMens.push({ 22 | id, 23 | show: showContextmenu, 24 | }); 25 | const position = reactive({ 26 | x: 0, 27 | y: 0, 28 | }); 29 | const preventDefault = (e: MouseEvent) => { 30 | e.preventDefault(); 31 | const { clientX, clientY } = e; 32 | position.x = clientX; 33 | position.y = clientY; 34 | closeContextmenu(); 35 | showContextmenu.value = true; 36 | }; 37 | 38 | return { 39 | preventDefault, 40 | showContextmenu, 41 | position, 42 | closeContextmenu, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 mgso 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@master 12 | - name: use Node.js 14.17.0 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 14.17.0 16 | - name: npm install 17 | run: | 18 | npm config set registry http://registry.npm.taobao.org/ 19 | npm i 20 | npm run build 21 | - name: Prepare SSH 22 | run: > 23 | cd ~ && mkdir .ssh && 24 | touch ~/.ssh/known_hosts && 25 | ssh-keyscan -H "$IP" >>~/.ssh/known_hosts 26 | env: 27 | IP: ${{ secrets.IP }} 28 | - name: Deploy to Staging server 29 | uses: easingthemes/ssh-deploy@main 30 | env: 31 | SSH_PRIVATE_KEY: ${{ secrets.SERVER_SSH_KEY }} 32 | ARGS: '-rltgoDzvO' 33 | SOURCE: 'dist/' 34 | REMOTE_HOST: ${{ secrets.REMOTE_HOST }} 35 | REMOTE_USER: ${{ secrets.REMOTE_USER }} 36 | TARGET: ${{ secrets.REMOTE_TARGET }} 37 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, RouteRecordRaw, createWebHashHistory } from 'vue-router'; 2 | 3 | const routes: Array = [ 4 | { 5 | path: '/', 6 | name: 'Home', 7 | redirect: '/documents', 8 | component: () => 9 | import(/* webpackChunkName: "Home" */ '../pages/index.vue'), 10 | children: [ 11 | { 12 | path: 'documents', 13 | name: 'documents', 14 | component: () => 15 | import( 16 | /* webpackChunkName: "documents" */ '../pages/Documents/index.vue' 17 | ), 18 | }, 19 | { 20 | path: 'components', 21 | name: 'components', 22 | component: () => 23 | import( 24 | /* webpackChunkName: "components" */ '../pages/Components/index.vue' 25 | ), 26 | }, 27 | ], 28 | }, 29 | { 30 | path: '/editor', 31 | name: 'editor', 32 | component: () => 33 | import(/* webpackChunkName: "editor" */ '../pages/Editor/index.vue'), 34 | }, 35 | ]; 36 | 37 | const router = createRouter({ 38 | history: createWebHashHistory(process.env.BASE_URL), 39 | routes, 40 | }); 41 | 42 | export default router; 43 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/NoticeBar/index.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | IBackground, 3 | IComponent, 4 | } from '@/components/Editor/BuiltInComponents/Component'; 5 | import { ComponentType } from '@/components/Editor/ComponentTypes'; 6 | import { fastInitProps } from '@/util'; 7 | 8 | export type NoticeBarMode = 'link' | 'closeable' | ''; 9 | export const NoticeBarModeList: { name: string; value: NoticeBarMode }[] = [ 10 | { name: '链接', value: 'link' }, 11 | { name: '关闭', value: 'closeable' }, 12 | { name: '无', value: '' }, 13 | ]; 14 | 15 | export interface INoticeBar extends IComponent { 16 | text: string; 17 | color: string; 18 | // 垂直滚动 19 | vertical: boolean; 20 | mode?: NoticeBarMode; 21 | multiple?: string[]; 22 | speed: number | string; 23 | } 24 | 25 | export default class NoticeBar extends Component implements INoticeBar { 26 | type = ComponentType.NoticeBar; 27 | text = '这是一段通知文本'; 28 | height = 40; 29 | color = '#ed6a0c'; 30 | background: IBackground = { 31 | color: '#fffbe8', 32 | }; 33 | vertical = false; 34 | multiple = ['滚动文本']; 35 | speed = '3000'; 36 | mode: NoticeBarMode = ''; 37 | 38 | constructor(props: INoticeBar) { 39 | super(props); 40 | fastInitProps(props, this); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/NavBar/NavBarSetting.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Img/index.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | IComponent, 3 | } from '@/components/Editor/BuiltInComponents/Component'; 4 | import { fastInitProps } from '@/util'; 5 | import { ComponentType } from '@/components/Editor/ComponentTypes'; 6 | export type objectFit = 'cover' | 'contain' | 'none' | 'fill' | 'scale-down'; 7 | export const objectFitList: { name: string; value: objectFit }[] = [ 8 | { name: '覆盖', value: 'cover' }, 9 | { name: '包含', value: 'contain' }, 10 | { name: '原始', value: 'none' }, 11 | { name: '拉伸', value: 'fill' }, 12 | { name: '弹性缩放', value: 'scale-down' }, 13 | ]; 14 | export interface IImg extends IComponent { 15 | src?: string; 16 | objectFit: objectFit; 17 | } 18 | 19 | export default class Img extends Component implements IImg { 20 | type = ComponentType.Img; 21 | objectFit: objectFit = 'cover'; 22 | src?: string = 23 | 'https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fpic1.win4000.com%2Fwallpaper%2F2018-01-09%2F5a54397bf0512.jpg%3Fdown&refer=http%3A%2F%2Fpic1.win4000.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=jpeg?sec=1642234674&t=38100f2faabb0ecc91c226718de13632'; 24 | width = ''; 25 | height = 100; 26 | constructor(props: IImg) { 27 | super(props); 28 | fastInitProps(props, this); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/useDynamicVars.ts: -------------------------------------------------------------------------------- 1 | import { reactive, inject } from 'vue'; 2 | export const DATASOURCE: { [key: string]: any } = reactive({}); 3 | export default () => { 4 | const store = reactive<{ [key: string]: any }>(DATASOURCE); 5 | const setItem = (key: string, value: any) => { 6 | store[key] = value; 7 | }; 8 | const getItem = (key: string[]) => { 9 | try { 10 | let root: any = store[key.splice(0, 1)[0]]; 11 | const result: any = ''; 12 | return key.reduce((re, current) => { 13 | re = root[current]; 14 | root = re; 15 | return re; 16 | }, result); 17 | } catch (e) { 18 | return e.toString(); 19 | } 20 | }; 21 | const parseExpression = (expression?: string) => { 22 | const preview = !!inject('isPreview'); 23 | if (!preview) return expression; 24 | if (!/\{\{.*?\}\}/.test(expression as string)) return expression; 25 | return expression?.replaceAll(/{{(.*?)}}/g, ($1) => { 26 | const vars = $1.replaceAll('{', '').replaceAll('}', '').trim(); 27 | const tree = vars.match(/\w+/g); 28 | if (tree) { 29 | return getItem(tree) || ''; 30 | } 31 | return ''; 32 | }); 33 | }; 34 | return { 35 | getItem, 36 | setItem, 37 | parseExpression, 38 | store, 39 | }; 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Editor/Uploader/Uploader.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 50 | -------------------------------------------------------------------------------- /src/store/Editor/index.ts: -------------------------------------------------------------------------------- 1 | import { Module } from 'vuex'; 2 | import mutations from '@/store/Editor/mutations'; 3 | import { state } from '@/store'; 4 | import getters from '@/store/Editor/getters'; 5 | import { 6 | PartOfComponent, 7 | TComponent, 8 | } from '@/components/Editor/ComponentTypes'; 9 | import { IDataSources } from '@/components/Editor/Action/request'; 10 | 11 | export interface IPage { 12 | order: number; 13 | components: PartOfComponent[]; 14 | id: string; 15 | name: string; 16 | } 17 | 18 | export interface IExtractComponents { 19 | name: string; 20 | payload: TComponent; 21 | } 22 | 23 | export interface IState { 24 | pages: IPage[]; 25 | pageActive: string; 26 | selectedComponents: PartOfComponent | null; 27 | allowUndo: boolean; 28 | allowRedo: boolean; 29 | isDrag: boolean; 30 | enterContainer: PartOfComponent | null; 31 | extractComponents: IExtractComponents[]; 32 | datasource: IDataSources; 33 | } 34 | 35 | const module: Module = { 36 | state: { 37 | pageActive: '', 38 | pages: [], 39 | selectedComponents: null, 40 | allowRedo: false, 41 | allowUndo: false, 42 | isDrag: false, 43 | enterContainer: null, 44 | extractComponents: [], 45 | datasource: {}, 46 | }, 47 | mutations: { 48 | ...mutations, 49 | }, 50 | getters: { 51 | ...getters, 52 | }, 53 | }; 54 | 55 | export default module; 56 | -------------------------------------------------------------------------------- /src/api/document.ts: -------------------------------------------------------------------------------- 1 | import axios from '@/axios'; 2 | import { DocumentModel, IDocument } from '../../server/src/document'; 3 | import { IExtractComponents, IPage } from '@/store/Editor'; 4 | import { IDataSources } from '@/components/Editor/Action/request'; 5 | 6 | export interface IEditorDoc { 7 | pages: IPage[]; 8 | extractComponents: IExtractComponents[]; 9 | datasource: IDataSources; 10 | } 11 | 12 | /** 13 | * 获取文档列表 14 | */ 15 | export const getDocumentList = () => { 16 | return axios.get('/document'); 17 | }; 18 | 19 | /** 20 | * 查询某个文档 21 | * @param id 22 | */ 23 | export const getDocument = (id: string) => { 24 | return axios.get(`/document/${id}`); 25 | }; 26 | 27 | /** 28 | * 新增文档 29 | * @param name 文档名称 30 | * @param content 文档内容 31 | * @param cover 封面 32 | */ 33 | export const addDocument = ({ name, content, cover }: DocumentModel) => { 34 | return axios.post>(`/document`, { 35 | name, 36 | content, 37 | cover, 38 | }); 39 | }; 40 | 41 | /** 42 | * 更新文档 43 | * @param id 文档id 44 | * @param name 文档名称 45 | * @param content 文档内容 46 | */ 47 | export const updateDocument = ({ 48 | _id, 49 | name, 50 | content, 51 | cover, 52 | }: IDocument) => { 53 | return axios.put(`/document/${_id}`, { name, content, cover }); 54 | }; 55 | 56 | /** 57 | * 删除某个文档 58 | * @param id 59 | */ 60 | export const delDocument = (id: string) => { 61 | return axios.delete(`/document/${id}`); 62 | }; 63 | -------------------------------------------------------------------------------- /src/components/Previewer/render.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 52 | -------------------------------------------------------------------------------- /src/axios/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig, AxiosResponse } from 'axios'; 2 | import { ElMessage } from 'element-plus'; 3 | 4 | export interface CustomInstance { 5 | get( 6 | url: string, 7 | config?: AxiosRequestConfig 8 | ): Promise>; 9 | 10 | delete( 11 | url: string, 12 | config?: AxiosRequestConfig 13 | ): Promise>; 14 | 15 | head( 16 | url: string, 17 | config?: AxiosRequestConfig 18 | ): Promise>; 19 | 20 | options( 21 | url: string, 22 | config?: AxiosRequestConfig 23 | ): Promise>; 24 | 25 | post( 26 | url: string, 27 | data?: D, 28 | config?: AxiosRequestConfig 29 | ): Promise>; 30 | 31 | put( 32 | url: string, 33 | data?: D, 34 | config?: AxiosRequestConfig 35 | ): Promise>; 36 | } 37 | 38 | interface IResponse { 39 | code: number; 40 | data: T; 41 | message?: string; 42 | } 43 | 44 | const instance = axios.create(); 45 | instance.interceptors.request.use((config) => { 46 | config.baseURL = '/api'; 47 | return config; 48 | }); 49 | instance.interceptors.response.use((res: AxiosResponse) => { 50 | if (res.data.code !== 200) { 51 | ElMessage.error(res.data.message); 52 | } 53 | return res.data; 54 | }); 55 | 56 | export default instance as CustomInstance; 57 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Text/index.ts: -------------------------------------------------------------------------------- 1 | import { ComponentType } from '@/components/Editor/ComponentTypes'; 2 | import { ICommonText } from '@/components/Editor/BuiltInComponents/CommonInterface/Text'; 3 | import { fastInitProps } from '@/util'; 4 | import Component, { 5 | IComponent, 6 | } from '@/components/Editor/BuiltInComponents/Component'; 7 | 8 | export const fontFamilyList: { name: string; value: string }[] = [ 9 | { 10 | name: '默认', 11 | value: 12 | "'PingFang SC', 'STHeitiSC-Light', 'Helvetica-Light', arial, sans-serif, 'Droid Sans Fallback'", 13 | }, 14 | { name: 'PingFang-SC-Regular', value: 'PingFang-SC-Regular, PingFang-SC' }, 15 | { name: 'PingFangSC-Medium', value: 'PingFangSC-Medium, PingFang SC' }, 16 | { name: 'DINAlternate-Bold', value: 'DINAlternate-Bold, DINAlternate;' }, 17 | { name: '继承父级', value: '' }, 18 | ]; 19 | 20 | export interface IText extends IComponent, ICommonText { 21 | text?: string; 22 | overflow: boolean; 23 | maxLines?: number; 24 | fontSize: string | number; 25 | } 26 | 27 | class Text extends Component implements IText { 28 | type = ComponentType.Text; 29 | text = 'text'; 30 | color = ''; 31 | lineHeight = ''; 32 | fontFamily = ''; 33 | textAlign = ''; 34 | fontStyle = ''; 35 | fontWeight = ''; 36 | overflow = false; 37 | fontSize = ''; 38 | maxLines = 1; 39 | height = ''; 40 | 41 | constructor(props?: IText) { 42 | super(props); 43 | fastInitProps(props, this); 44 | } 45 | } 46 | 47 | export default Text; 48 | -------------------------------------------------------------------------------- /src/components/Editor/AroundValue/ComputedModel.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/plugins/ElementUI.ts: -------------------------------------------------------------------------------- 1 | import { App } from 'vue'; 2 | import { 3 | ElButton, 4 | ElSelect, 5 | ElOption, 6 | ElTabs, 7 | ElTabPane, 8 | ElIcon, 9 | ElTree, 10 | ElForm, 11 | ElFormItem, 12 | ElInput, 13 | ElColorPicker, 14 | ElSwitch, 15 | ElInputNumber, 16 | ElTable, 17 | ElTableColumn, 18 | ElLink, 19 | ElDialog, 20 | ElPopconfirm, 21 | ElPopover, 22 | ElCard, 23 | ElCheckbox, 24 | ElCollapseTransition, 25 | ElMenu, 26 | ElSubMenu, 27 | ElMenuItem, 28 | ElMenuItemGroup, 29 | ElLoading, 30 | ElPagination, 31 | ElDropdown, 32 | ElDropdownMenu, 33 | ElDropdownItem, 34 | ElUpload, 35 | ElDivider, 36 | } from 'element-plus'; 37 | 38 | export default (app: App) => { 39 | app 40 | .use(ElButton) 41 | .use(ElSelect) 42 | .use(ElOption) 43 | .use(ElTabs) 44 | .use(ElTabPane) 45 | .use(ElTree) 46 | .use(ElForm) 47 | .use(ElFormItem) 48 | .use(ElInput) 49 | .use(ElIcon) 50 | .use(ElSwitch) 51 | .use(ElInputNumber) 52 | .use(ElColorPicker) 53 | .use(ElTable) 54 | .use(ElTableColumn) 55 | .use(ElDialog) 56 | .use(ElPopconfirm) 57 | .use(ElPopover) 58 | .use(ElCard) 59 | .use(ElCheckbox) 60 | .use(ElCollapseTransition) 61 | .use(ElMenu) 62 | .use(ElMenuItem) 63 | .use(ElSubMenu) 64 | .use(ElMenuItemGroup) 65 | .use(ElLoading) 66 | .use(ElPagination) 67 | .use(ElDropdownMenu) 68 | .use(ElDropdown) 69 | .use(ElDropdownItem) 70 | .use(ElUpload) 71 | .use(ElDivider) 72 | .use(ElLink); 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Layout.ts: -------------------------------------------------------------------------------- 1 | // 定位 2 | export type Position = 'static' | 'relative' | 'absolute' | 'sticky'; 3 | export const positionList: { 4 | name: string; 5 | value: Position; 6 | }[] = [ 7 | { name: '静止', value: 'static' }, 8 | { name: '相对定位', value: 'relative' }, 9 | { name: '绝对定位', value: 'absolute' }, 10 | { name: '粘性定位', value: 'sticky' }, 11 | ]; 12 | 13 | export const layoutType = [ 14 | { 15 | name: '默认布局', 16 | value: 'block', 17 | }, 18 | { 19 | name: '弹性布局', 20 | value: 'flex', 21 | }, 22 | ]; 23 | // 文本对齐 24 | export type TextAlign = 'left' | 'center' | 'right'; 25 | 26 | // 交叉轴(纵轴)方向上的对齐方式 27 | export type AlignItems = 28 | | 'auto' 29 | | 'flex-start' 30 | | 'center' 31 | | 'flex-end' 32 | | 'stretch' 33 | | 'baseline'; 34 | // flex子项单独在交叉轴(纵轴)方向上的对齐方式 35 | export type AlignSelf = AlignItems; 36 | // flex多行时,整体在交叉轴上的对齐方式 37 | export type AlignContent = AlignItems; 38 | // 外边距 39 | export type Margin = 40 | | 'margin-top' 41 | | 'margin-right' 42 | | 'margin-bottom' 43 | | 'margin-left'; 44 | // 内边距 45 | export type Padding = 46 | | 'padding-top' 47 | | 'padding-right' 48 | | 'padding-bottom' 49 | | `padding-left`; 50 | 51 | export type Border = 52 | | 'border-top-width' 53 | | 'border-right-width' 54 | | 'border-bottom-width' 55 | | `border-left-width`; 56 | export type BorderStyle = 'dotted' | 'solid' | 'dashed'; 57 | export const borderStyleList: { name: string; value: BorderStyle }[] = [ 58 | { name: '点状', value: 'dotted' }, 59 | { name: '实线', value: 'solid' }, 60 | { name: '虚线', value: 'dashed' }, 61 | ]; 62 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Img/ImgSetting.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Img/Img.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 42 | 43 | 63 | -------------------------------------------------------------------------------- /src/pages/index.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 43 | 44 | 79 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Container/Container.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 58 | 59 | 66 | -------------------------------------------------------------------------------- /src/components/Editor/ComponentTypes.ts: -------------------------------------------------------------------------------- 1 | import { IComponent } from './BuiltInComponents/Component'; 2 | import { IContainer } from './BuiltInComponents/Container'; 3 | import { IImg } from '@/components/Editor/BuiltInComponents/Img'; 4 | import { ITab } from '@/components/Editor/TrilateralComponents/Vant/Tab'; 5 | import { IButton } from '@/components/Editor/BuiltInComponents/Button'; 6 | import { INoticeBar } from '@/components/Editor/TrilateralComponents/Vant/NoticeBar/index'; 7 | 8 | // 所有组件类型及名称 9 | export enum ComponentType { 10 | Container = 'HContainer', 11 | Img = 'HImg', 12 | Text = 'HText', 13 | Tab = 'HTab', 14 | Button = 'HButton', 15 | NoticeBar = 'NoticeBar', 16 | Swiper = 'Swiper', 17 | NavBar = 'NavBar', 18 | } 19 | 20 | export enum ComponentSettingType { 21 | Container = 'ContainerSetting', 22 | Img = `ImgSetting`, 23 | } 24 | 25 | // 用于侧边栏组件列表中单个组件的接口 26 | export interface IComponentItem { 27 | type: ComponentType; 28 | icon: string; 29 | name: string; 30 | } 31 | 32 | // 侧边栏组件列表 33 | export const ComponentList: IComponentItem[] = [ 34 | { type: ComponentType.Container, icon: 'xxx', name: '容器' }, 35 | { type: ComponentType.Img, icon: 'xxx', name: '图片' }, 36 | { type: ComponentType.Text, icon: 'xxx', name: '文本' }, 37 | { type: ComponentType.Tab, icon: 'xxx', name: '选项卡' }, 38 | { type: ComponentType.Button, icon: 'xxx', name: '按钮' }, 39 | { type: ComponentType.NoticeBar, icon: 'xxx', name: '通知栏' }, 40 | { type: ComponentType.Swiper, icon: 'xxx', name: '轮播' }, 41 | { type: ComponentType.NavBar, icon: 'xxx', name: '导航栏' }, 42 | ]; 43 | 44 | export type TComponent = IComponent & 45 | IContainer & 46 | IImg & 47 | ITab & 48 | IButton & 49 | INoticeBar; 50 | export type PartOfComponent = 51 | | IComponent 52 | | IContainer 53 | | IImg 54 | | ITab 55 | | IButton 56 | | INoticeBar; 57 | -------------------------------------------------------------------------------- /src/components/Editor/Action/request/index.ts: -------------------------------------------------------------------------------- 1 | import { Action } from '@/components/Editor/Action/abstractAction'; 2 | import axios, { Method, AxiosRequestConfig } from 'axios'; 3 | import { DATASOURCE } from '@/hooks/useDynamicVars'; 4 | import { Toast } from 'vant'; 5 | export interface IDataSourceItem { 6 | alias: string; 7 | method: Method; 8 | url: string; 9 | body: string; 10 | code: string; 11 | msg: string; 12 | data: string; 13 | headers: string; 14 | } 15 | export interface IDataSources { 16 | [key: string]: IDataSourceItem; 17 | } 18 | 19 | export interface IRequest { 20 | datasource: string; 21 | } 22 | 23 | export class Request extends Action implements IRequest { 24 | datasource: string; 25 | constructor(public props: IRequest, public dataSourceMap: IDataSources) { 26 | super(); 27 | this.datasource = props.datasource; 28 | this.dataSourceMap = dataSourceMap; 29 | } 30 | async handle() { 31 | if (!this.dataSourceMap[this.datasource]) { 32 | console.warn( 33 | `请求数据源出错:未找到该数据源,请确认数据池中是否有${this.datasource}` 34 | ); 35 | return; 36 | } 37 | const { url, msg, body, code, method, data, headers } = 38 | this.dataSourceMap[this.datasource]; 39 | const options: AxiosRequestConfig = { 40 | url: url, 41 | method, 42 | }; 43 | if (headers) { 44 | options.headers = JSON.parse(headers); 45 | } 46 | if (body && method !== 'get') { 47 | options.data = JSON.parse(body); 48 | } 49 | const { data: requestData, status, statusText } = await axios(options); 50 | if (status !== 200) { 51 | return Toast(statusText); 52 | } 53 | const { [code]: _code, [msg]: _message, [data]: _data } = requestData; 54 | if (_code === 200) { 55 | DATASOURCE[this.datasource] = _data; 56 | } else { 57 | Toast(_message); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import express, { Response, Request, NextFunction } from 'express'; 2 | import compression from 'compression'; 3 | import * as path from 'path'; 4 | import * as bodyParser from 'body-parser'; 5 | import routes from './route'; 6 | import preview from './route/preview'; 7 | import config from './config'; 8 | require('express-async-errors'); 9 | 10 | const { port, dataPath } = config; 11 | const app = express(); 12 | 13 | app.use(compression()); 14 | app.set('view engine', 'ejs'); 15 | app.all('*', function (req: Request, res: Response, next: NextFunction) { 16 | res.header('Access-Control-Allow-Origin', req.headers.origin); 17 | // res.header("Access-Control-Allow-Origin", '*'); 18 | res.header( 19 | 'Access-Control-Allow-Headers', 20 | 'Content-Type,Content-Length, Authorization, Accept,X-Requested-With' 21 | ); 22 | res.header('Access-Control-Allow-Methods', 'PUT,POST,GET,DELETE,OPTIONS'); 23 | res.header('Access-Control-Allow-Credentials', 'true'); 24 | res.header('X-Powered-By', ' 3.2.1'); 25 | if (req.method === 'OPTIONS') res.send(200); 26 | /*让options请求快速返回*/ else next(); 27 | }); 28 | 29 | // 预览器静态服务 30 | app.use('/static', express.static(path.join(__dirname, './static'))); 31 | // 图片资源静态服务 32 | app.use('/static', express.static(path.join(dataPath, '/static'))); 33 | 34 | app.use(bodyParser.json({ limit: '50mb' })); 35 | app.set('views', path.join(__dirname, './views')); 36 | app.use('/api', routes); 37 | app.use('/preview', preview); 38 | app.listen(port, () => { 39 | console.log(`server run at:http://127.0.0.1:${port}`); 40 | }); 41 | app.use(function (req: Request, res: Response) { 42 | res.status(404).send('404 Not Found'); 43 | }); 44 | app.use(function (err: Error, req: Request, res: Response, next: NextFunction) { 45 | res.status(500).json({ 46 | msg: err.stack || err.message || err, 47 | code: 500, 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/components/Editor/Factory.ts: -------------------------------------------------------------------------------- 1 | import Component, { 2 | IComponent, 3 | } from '@/components/Editor/BuiltInComponents/Component'; 4 | import Container, { 5 | IContainer, 6 | } from '@/components/Editor/BuiltInComponents/Container'; 7 | import { ComponentType, PartOfComponent } from './ComponentTypes'; 8 | import Img, { IImg } from '@/components/Editor/BuiltInComponents/Img'; 9 | import Text, { IText } from '@/components/Editor/BuiltInComponents/Text'; 10 | import Tab, { ITab } from '@/components/Editor/TrilateralComponents/Vant/Tab'; 11 | import Button, { IButton } from '@/components/Editor/BuiltInComponents/Button'; 12 | import NoticeBar, { 13 | INoticeBar, 14 | } from '@/components/Editor/TrilateralComponents/Vant/NoticeBar/index'; 15 | import Swiper from '@/components/Editor/TrilateralComponents/Vant/Swiper'; 16 | import NavBar, { 17 | INavBar, 18 | } from '@/components/Editor/TrilateralComponents/Vant/NavBar'; 19 | 20 | /** 21 | *构造组件的工厂函数 22 | */ 23 | export default class ComponentFactory { 24 | static createComponent( 25 | type: ComponentType, 26 | // 通过传入的type映射作component的类型推断 27 | component?: Partial 28 | ): IComponent { 29 | switch (type) { 30 | case ComponentType.Container: 31 | return new Container(component); 32 | case ComponentType.Img: 33 | return new Img(component); 34 | case ComponentType.Text: 35 | return new Text(component); 36 | case ComponentType.Tab: 37 | return new Tab(component); 38 | case ComponentType.Button: 39 | return new Button(component); 40 | case ComponentType.NoticeBar: 41 | return new NoticeBar(component); 42 | case ComponentType.Swiper: 43 | return new Swiper(component); 44 | case ComponentType.NavBar: 45 | return new NavBar(component); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, Store, useStore as baseUseStore } from 'vuex'; 2 | import moduleEditor, { IState } from './Editor'; 3 | import { InjectionKey } from 'vue'; 4 | import { MUTATION_TYPE } from '@/store/Editor/mutations/mutation-type'; 5 | import { CACHE_KEY, diffPatcher } from '@/store/Editor/util'; 6 | 7 | export interface state { 8 | editor: IState; 9 | } 10 | 11 | export default createStore({ 12 | strict: true, 13 | modules: { 14 | editor: moduleEditor, 15 | }, 16 | getters: {}, 17 | plugins: [ 18 | (store) => { 19 | const needCacheMutations = [ 20 | MUTATION_TYPE.ADD_PAGE, 21 | MUTATION_TYPE.ADD_EVENT, 22 | MUTATION_TYPE.REMOVE_EVENT, 23 | MUTATION_TYPE.UPDATE_EVENT, 24 | MUTATION_TYPE.UPDATE_COMPONENT, 25 | MUTATION_TYPE.REMOVE_COMPONENT, 26 | MUTATION_TYPE.REDO, 27 | MUTATION_TYPE.UNDO, 28 | MUTATION_TYPE.RESIZE, 29 | MUTATION_TYPE.DRAG_TREE, 30 | MUTATION_TYPE.ADD_COMPONENT, 31 | MUTATION_TYPE.DRAG_COMPONENT, 32 | MUTATION_TYPE.SELECT_COMPONENT, 33 | MUTATION_TYPE.EDIT_PAGE, 34 | MUTATION_TYPE.DELETE_PAGE, 35 | MUTATION_TYPE.COPY_PAGE, 36 | MUTATION_TYPE.DELETE_EXTRACT_COMPONENT, 37 | MUTATION_TYPE.UPDATE_DATASOURCE, 38 | MUTATION_TYPE.DELETE_DATASOURCE, 39 | ]; 40 | store.subscribe((mutation, state) => { 41 | if (needCacheMutations.includes(mutation.type as MUTATION_TYPE)) { 42 | localStorage.setItem( 43 | CACHE_KEY, 44 | JSON.stringify({ 45 | editorData: state.editor, 46 | diffPatcher, 47 | }) 48 | ); 49 | } 50 | }); 51 | }, 52 | ], 53 | }); 54 | export const key: InjectionKey> = Symbol(); 55 | 56 | export function useStore() { 57 | return baseUseStore(key); 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/NoticeBar/index.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 61 | 62 | 72 | -------------------------------------------------------------------------------- /server/src/route/document.ts: -------------------------------------------------------------------------------- 1 | import { Router } from 'express'; 2 | import { DocumentModel, IDocument } from '../document'; 3 | import dataBase from '../model'; 4 | import { writeImgByBase64 } from '../util'; 5 | const route = Router(); 6 | // 新增 7 | route.post('/', async (req, res) => { 8 | const { name, content, cover } = req.body as DocumentModel; 9 | const isExist = await dataBase.findOne({ name }); 10 | if (isExist) { 11 | return res.json({ 12 | code: 400, 13 | message: '添加失败,改文档已存在', 14 | }); 15 | } 16 | const coverPath = await writeImgByBase64('covers', cover); 17 | const data = await dataBase.insert({ name, content, cover: coverPath }); 18 | res.json({ 19 | code: 200, 20 | message: '添加成功', 21 | data: { _id: data._id }, 22 | }); 23 | }); 24 | 25 | // 更新 26 | route.put('/:id', async (req, res) => { 27 | const id = req.params.id; 28 | const { name, content, cover } = req.body as DocumentModel; 29 | const coverPath = await writeImgByBase64('covers', cover, id); 30 | await dataBase.update( 31 | { _id: id }, 32 | { $set: { name, content, cover: coverPath } } 33 | ); 34 | res.json({ 35 | code: 200, 36 | message: '更新成功', 37 | }); 38 | }); 39 | 40 | route.get('/', async (req, res) => { 41 | const data = await dataBase.find({}); 42 | // 添加预览地址 43 | data.forEach((item: IDocument) => { 44 | (item as any).previewUrl = `/preview/${item._id}`; 45 | }); 46 | res.json({ 47 | code: 200, 48 | message: '查询成功', 49 | data, 50 | }); 51 | }); 52 | 53 | // 查询 54 | route.get('/:id', async (req, res) => { 55 | const id = req.params.id; 56 | const data = await dataBase.findOne({ _id: id }); 57 | res.json({ 58 | code: 200, 59 | message: '查询成功', 60 | data, 61 | }); 62 | }); 63 | 64 | route.delete('/:id', async (req, res) => { 65 | const id = req.params.id; 66 | await dataBase.remove({ _id: id }); 67 | res.json({ 68 | code: 200, 69 | message: '删除成功', 70 | }); 71 | }); 72 | 73 | export default route; 74 | -------------------------------------------------------------------------------- /src/assets/css/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v5.0.1 | 20191019 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | menu, 53 | ol, 54 | ul, 55 | li, 56 | fieldset, 57 | form, 58 | label, 59 | legend, 60 | table, 61 | caption, 62 | tbody, 63 | tfoot, 64 | thead, 65 | tr, 66 | th, 67 | td, 68 | article, 69 | aside, 70 | canvas, 71 | details, 72 | embed, 73 | figure, 74 | figcaption, 75 | footer, 76 | header, 77 | hgroup, 78 | main, 79 | menu, 80 | nav, 81 | output, 82 | ruby, 83 | section, 84 | summary, 85 | time, 86 | mark, 87 | audio, 88 | video { 89 | padding: 0; 90 | margin: 0; 91 | font: inherit; 92 | font-size: 100%; 93 | vertical-align: baseline; 94 | border: 0; 95 | } 96 | 97 | /* HTML5 display-role reset for older browsers */ 98 | article, 99 | aside, 100 | details, 101 | figcaption, 102 | figure, 103 | footer, 104 | header, 105 | hgroup, 106 | main, 107 | menu, 108 | nav, 109 | section { 110 | display: block; 111 | } 112 | 113 | /* HTML5 hidden-attribute fix for newer browsers */ 114 | *[hidden] { 115 | display: none; 116 | } 117 | 118 | body { 119 | line-height: 1; 120 | } 121 | 122 | menu, 123 | ol, 124 | ul { 125 | list-style: none; 126 | } 127 | 128 | blockquote, 129 | q { 130 | quotes: none; 131 | } 132 | 133 | blockquote::before, 134 | blockquote::after, 135 | q::before, 136 | q::after { 137 | content: ''; 138 | content: none; 139 | } 140 | 141 | table { 142 | border-spacing: 0; 143 | border-collapse: collapse; 144 | } 145 | -------------------------------------------------------------------------------- /src/components/Previewer/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 65 | 66 | 87 | -------------------------------------------------------------------------------- /server/src/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 31 | Title 32 | 48 | 49 | 50 |
51 | 52 |
53 | 54 | 68 | 69 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Text/Text.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 42 | 43 | 89 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Component/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BorderStyle, 3 | Position, 4 | } from '@/components/Editor/BuiltInComponents/Layout'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | import { fastInitProps } from '@/util'; 7 | import { ComponentType } from '@/components/Editor/ComponentTypes'; 8 | import { IEvent } from '@/components/Editor/Event'; 9 | import { DISPLAY } from '@/components/Editor/BuiltInComponents/Container'; 10 | 11 | export interface IBackground { 12 | color?: string; 13 | url?: string; 14 | repeat?: string; 15 | size?: string; 16 | horizontal?: string; 17 | vertical?: string; 18 | } 19 | 20 | export interface IAroundValue { 21 | top?: number; 22 | right?: number; 23 | bottom?: number; 24 | left?: number; 25 | } 26 | 27 | export interface IComponent { 28 | type: ComponentType; 29 | id: string; 30 | width: number | string; 31 | height: number | string; 32 | position: Position; 33 | background: IBackground; 34 | lock: boolean; 35 | display: DISPLAY; 36 | zIndex?: number; 37 | parentId?: string; 38 | alias?: string; 39 | top?: number; 40 | left?: number; 41 | right?: number; 42 | bottom?: number; 43 | padding?: IAroundValue; 44 | margin?: IAroundValue; 45 | border?: IAroundValue; 46 | borderStyle?: BorderStyle; 47 | borderColor?: string; 48 | borderRadius?: string; 49 | events?: IEvent[]; 50 | flex?: string; 51 | } 52 | 53 | /** 54 | *基础组件类,包含一个组件最基本的信息 55 | */ 56 | abstract class Component implements IComponent { 57 | abstract type: ComponentType; 58 | id: string; 59 | width: string | number = ''; 60 | height: string | number = 100; 61 | position: Position = 'static'; 62 | lock = false; 63 | alias?: string = ''; 64 | borderRadius?: string = '0,0,0,0'; 65 | borderStyle: BorderStyle = 'solid'; 66 | background: IBackground = { 67 | color: '', 68 | url: '', 69 | size: '', 70 | horizontal: '', 71 | vertical: '', 72 | repeat: '', 73 | }; 74 | display: DISPLAY = DISPLAY.BLOCK; 75 | 76 | protected constructor(props?: Partial) { 77 | this.id = uuidv4(); 78 | this.alias = props?.type; 79 | fastInitProps(props, this); 80 | } 81 | } 82 | 83 | export default Component; 84 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/Tab/Tab.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 66 | 67 | 85 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/Swiper/Swiper.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 61 | 62 | 83 | -------------------------------------------------------------------------------- /src/store/Editor/mutations/page.ts: -------------------------------------------------------------------------------- 1 | import { MUTATION_TYPE } from '@/store/Editor/mutations/mutation-type'; 2 | import { addPage, mutationWithSnapshot } from '@/store/Editor/util'; 3 | import { MutationTree } from 'vuex'; 4 | import { IPage, IState } from '@/store/Editor'; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | import cloneDeep from 'lodash/cloneDeep'; 7 | const pageMutations: MutationTree = { 8 | [MUTATION_TYPE.LOAD]: (state, payload) => { 9 | state.pages = payload.pages; 10 | state.extractComponents = payload.extractComponents; 11 | state.datasource = payload.datasource; 12 | }, 13 | [MUTATION_TYPE.LOAD_BY_CACHE]: (state: IState, payload: IState) => { 14 | state.pageActive = payload.pageActive; 15 | state.pages = payload.pages; 16 | state.selectedComponents = payload.selectedComponents; 17 | state.allowRedo = payload.allowRedo; 18 | state.allowUndo = payload.allowUndo; 19 | state.isDrag = payload.isDrag; 20 | state.enterContainer = payload.enterContainer; 21 | state.extractComponents = payload.extractComponents; 22 | state.datasource = payload.datasource; 23 | }, 24 | // 新增一页 25 | [MUTATION_TYPE.ADD_PAGE]: (state) => { 26 | mutationWithSnapshot(state, () => { 27 | addPage(state); 28 | }); 29 | }, 30 | // 选择一页 31 | [MUTATION_TYPE.SELECT_PAGE]: (state, payload: string) => { 32 | if (payload === state.pageActive) return; 33 | state.selectedComponents = null; 34 | state.pageActive = payload; 35 | }, 36 | [MUTATION_TYPE.EDIT_PAGE]: (state, payload: IPage) => { 37 | const editIndex = state.pages.findIndex((item) => item.id === payload.id); 38 | state.pages[editIndex].name = payload.name; 39 | }, 40 | [MUTATION_TYPE.DELETE_PAGE]: (state, pageId: string) => { 41 | const editIndex = state.pages.findIndex((item) => item.id === pageId); 42 | state.pages.splice(editIndex, 1); 43 | const activeIndex = editIndex === 0 ? editIndex + 1 : editIndex - 1; 44 | state.pageActive = state.pages[activeIndex].id; 45 | }, 46 | [MUTATION_TYPE.COPY_PAGE]: (state, pageId: string) => { 47 | const copyIndex = state.pages.findIndex((item) => item.id === pageId); 48 | const newPage = cloneDeep(state.pages[copyIndex]); 49 | newPage.id = uuidv4(); 50 | console.log(newPage.id, pageId); 51 | state.pages.push(newPage); 52 | state.pageActive = newPage.id; 53 | }, 54 | }; 55 | export default pageMutations; 56 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Container/ContainerSetting.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 80 | 81 | 82 | -------------------------------------------------------------------------------- /src/store/Editor/mutations/event.ts: -------------------------------------------------------------------------------- 1 | import { MUTATION_TYPE } from '@/store/Editor/mutations/mutation-type'; 2 | import { IEvent } from '@/components/Editor/Event'; 3 | import { 4 | mutationWithSnapshot, 5 | updateSelectedComponent, 6 | } from '@/store/Editor/util'; 7 | import { IPage, IState } from '@/store/Editor'; 8 | import { findItemById } from '@/util'; 9 | import { IComponent } from '@/components/Editor/BuiltInComponents/Component'; 10 | import { MutationTree } from 'vuex'; 11 | import { 12 | PartOfComponent, 13 | TComponent, 14 | } from '@/components/Editor/ComponentTypes'; 15 | 16 | const eventMutations: MutationTree = { 17 | // 添加一个事件 18 | [MUTATION_TYPE.ADD_EVENT]: (state, event: IEvent) => { 19 | mutationWithSnapshot(state, () => { 20 | const currentPage = state.pages.find( 21 | (item) => item.id === state.pageActive 22 | ) as IPage; 23 | const target = findItemById( 24 | currentPage.components, 25 | (state.selectedComponents as IComponent).id 26 | ); 27 | if (target) { 28 | const events = target.events || []; 29 | events.push(event); 30 | target.events = events; 31 | updateSelectedComponent(state); 32 | } 33 | }); 34 | }, 35 | // 移除事件 36 | [MUTATION_TYPE.REMOVE_EVENT]: (state, eventIndex: number) => { 37 | mutationWithSnapshot(state, () => { 38 | const currentPage = state.pages.find( 39 | (item) => item.id === state.pageActive 40 | ) as IPage; 41 | const target = findItemById( 42 | currentPage.components, 43 | (state.selectedComponents as IComponent).id 44 | ); 45 | if (target) { 46 | const events = target.events; 47 | events?.splice(eventIndex, 1); 48 | } 49 | updateSelectedComponent(state); 50 | }); 51 | }, 52 | // 更新事件 53 | [MUTATION_TYPE.UPDATE_EVENT]: (state, { eventIndex, event }) => { 54 | mutationWithSnapshot(state, () => { 55 | const currentPage = state.pages.find( 56 | (item) => item.id === state.pageActive 57 | ) as IPage; 58 | const target = findItemById( 59 | currentPage.components, 60 | (state.selectedComponents as IComponent).id 61 | ); 62 | if (target) { 63 | const events = target.events as IEvent[]; 64 | events[eventIndex] = { ...event }; 65 | } 66 | updateSelectedComponent(state); 67 | }); 68 | }, 69 | }; 70 | export default eventMutations; 71 | -------------------------------------------------------------------------------- /src/hooks/useBindEvent.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 给组件绑定自定义事件 3 | */ 4 | import { ref, onMounted, inject } from 'vue'; 5 | import { EventType, IEvent, EventTypeKey } from '@/components/Editor/Event'; 6 | import { ActionFactory } from '@/components/Editor/Action/factory'; 7 | import { Action } from '@/components/Editor/Action/abstractAction'; 8 | 9 | export default (events?: IEvent[]) => { 10 | const root = ref(); 11 | const datasource = inject('datasource'); 12 | if (events) { 13 | // 构建所有事件池对象 14 | const eventsObj: EventTypeKey = { 15 | click: [], 16 | mouseenter: [], 17 | mouseleave: [], 18 | mounted: [], 19 | }; 20 | // 遍历组件中的所有事件 21 | events.forEach((item) => { 22 | // 判断是否存在于所有事件池中 23 | const targetEvent = eventsObj[item.eventType]; 24 | // 如果没有,在对应的事件类型中添加事件 25 | if (!targetEvent) { 26 | eventsObj[item.eventType] = [item]; 27 | } else { 28 | eventsObj[item.eventType] = [...targetEvent, item]; 29 | } 30 | }); 31 | 32 | /** 33 | * 触发action 34 | * @param actions 35 | */ 36 | const trigger = (actions: Action[]) => { 37 | // 执行action中的handle 触发事件 38 | actions.forEach((item) => item.handle()); 39 | }; 40 | 41 | onMounted(() => { 42 | // 遍历所有事件池,click:[...events],mouseenter:[...events],mouseleave:[...events]... 43 | for (const eventType in eventsObj) { 44 | // 取出当前事件类型下的所有events 45 | // 例如:click:[events1,events2,events3,....] 46 | const currentEvents = eventsObj[eventType as EventType] as IEvent[]; 47 | // 初始化当前事件下对应的action 48 | const handlePool: Action[] = []; 49 | // 如果有这个事件类型绑定了事件 50 | if (currentEvents.length > 0) { 51 | // 遍历事件,通过ActionFactory.getAction工厂函数,根据事件中的actionType,actionProps实例化一个Action 52 | currentEvents.forEach((item) => { 53 | // 实例化Action,添加到handlePool中。 [Action,Action,Action,.....] 54 | handlePool.push(ActionFactory.getAction(item, datasource)); 55 | }); 56 | 57 | // 开始绑定事件 58 | 59 | // 如果是初始化事件 60 | if ((eventType as EventType) === 'mounted') { 61 | trigger(handlePool); 62 | } else { 63 | // 其他类型事件,均通过ref绑定在dom元素上 64 | (root.value as HTMLElement).addEventListener(eventType, (e) => { 65 | // 阻止事件冒泡 66 | e.stopPropagation(); 67 | trigger(handlePool); 68 | }); 69 | } 70 | } 71 | } 72 | }); 73 | } 74 | return { 75 | root, 76 | }; 77 | }; 78 | -------------------------------------------------------------------------------- /src/store/Editor/mutations/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PartOfComponent, 3 | TComponent, 4 | } from '@/components/Editor/ComponentTypes'; 5 | import { IPage, IState } from '../index'; 6 | import { MUTATION_TYPE } from './mutation-type'; 7 | import { MutationTree } from 'vuex'; 8 | import { findItemById } from '@/util'; 9 | import { IComponent } from '@/components/Editor/BuiltInComponents/Component'; 10 | import eventBus, { EventType } from '@/hooks/useEventBus'; 11 | import { 12 | addPage, 13 | mutationWithSnapshot, 14 | updateSelectedComponent, 15 | updateRedoUndoState, 16 | diffPatcher, 17 | } from '@/store/Editor/util'; 18 | import componentMutations from './components'; 19 | import eventMutations from './event'; 20 | import pageMutations from './page'; 21 | import datasourceMutations from '@/store/Editor/mutations/datasource'; 22 | 23 | const mutations: MutationTree = { 24 | ...componentMutations, 25 | ...eventMutations, 26 | ...pageMutations, 27 | ...datasourceMutations, 28 | // 撤销 29 | [MUTATION_TYPE.UNDO]: (state) => { 30 | const result = diffPatcher.undo(); 31 | if (result) { 32 | state.pages = result; 33 | updateSelectedComponent(state); 34 | updateRedoUndoState(state); 35 | } 36 | }, 37 | // 重做 38 | [MUTATION_TYPE.REDO]: (state) => { 39 | const result = diffPatcher.redo(); 40 | if (result) { 41 | state.pages = result; 42 | updateSelectedComponent(state); 43 | updateRedoUndoState(state); 44 | } 45 | }, 46 | // 初始化 47 | [MUTATION_TYPE.INIT]: (state: IState) => { 48 | // 如果已经存在,不需要在初始化 49 | if (state.pages.length > 0) return; 50 | state.pages = []; 51 | addPage(state); 52 | }, 53 | [MUTATION_TYPE.RESIZE]: (state: IState, payload: TComponent) => { 54 | const currentPage = state.pages.find( 55 | (item) => item.id === state.pageActive 56 | ) as IPage; 57 | const target = findItemById( 58 | currentPage.components, 59 | payload.id 60 | ); 61 | if (target) { 62 | Object.assign(target, { ...payload }); 63 | updateSelectedComponent(state); 64 | eventBus.$emit(EventType.updateBorder); 65 | } 66 | }, 67 | [MUTATION_TYPE.DRAG_TREE]: (state, payload: IComponent[]) => { 68 | mutationWithSnapshot(state, () => { 69 | const currentPage = state.pages.find( 70 | (item) => item.id === state.pageActive 71 | ); 72 | (currentPage as IPage).components = payload; 73 | }); 74 | eventBus.$emit(EventType.updateBorder); 75 | }, 76 | }; 77 | export default mutations; 78 | -------------------------------------------------------------------------------- /src/store/Editor/util.ts: -------------------------------------------------------------------------------- 1 | import { IPage, IState } from '@/store/Editor/index'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | import { findItemById, getCache } from '@/util'; 4 | import { DiffPatcher } from '@/util/diffpatch'; 5 | import ComponentFactory from '@/components/Editor/Factory'; 6 | import { 7 | ComponentType, 8 | PartOfComponent, 9 | TComponent, 10 | } from '@/components/Editor/ComponentTypes'; 11 | 12 | // 缓存key 13 | export const CACHE_KEY = 'editorData'; 14 | 15 | export interface IEditorCache { 16 | editorData: IState; 17 | diffPatcher: DiffPatcher; 18 | } 19 | 20 | const cache = getCache(CACHE_KEY); 21 | // 实例化diffPatcher 22 | export const diffPatcher = new DiffPatcher(cache?.diffPatcher); 23 | 24 | /** 25 | * 新增一页 26 | * @param state 27 | */ 28 | export const addPage = (state: IState) => { 29 | const id = uuidv4(); 30 | state.pages.push({ 31 | order: 0, 32 | components: [], 33 | id, 34 | name: `页面${state.pages.length + 1}`, 35 | }); 36 | state.pageActive = id; 37 | (state.pages.find((item) => item.id === id) as IPage).components.push( 38 | ComponentFactory.createComponent(ComponentType.Container, { 39 | id: 'root', 40 | width: 375, 41 | height: '', 42 | position: 'relative', 43 | isRoot: true, 44 | alias: '根组件', 45 | }) 46 | ); 47 | state.selectedComponents = null; 48 | }; 49 | 50 | /** 51 | * 更新当前选中的组件(目的是同步右侧与画布中的属性设置) 52 | * @param state 53 | */ 54 | export const updateSelectedComponent = (state: IState) => { 55 | if (state.selectedComponents) { 56 | const currentPage = state.pages.find( 57 | (item) => item.id === state.pageActive 58 | ) as IPage; 59 | const find = findItemById( 60 | currentPage.components, 61 | state.selectedComponents.id as string 62 | ); 63 | if (find) { 64 | state.selectedComponents = { ...find }; 65 | } else { 66 | state.selectedComponents = null; 67 | } 68 | } 69 | }; 70 | 71 | /** 72 | * 更新撤销重做状态,以标识当前是否可以撤销/重做 73 | * @param state 74 | */ 75 | export const updateRedoUndoState = (state: IState) => { 76 | state.allowUndo = diffPatcher.allowUndo(); 77 | state.allowRedo = diffPatcher.allowRedo(); 78 | }; 79 | /** 80 | * 带快照的mutation 81 | * @param state 82 | * @param callback 83 | */ 84 | export const mutationWithSnapshot = (state: IState, callback: () => void) => { 85 | const left = DiffPatcher.clone(state.pages); 86 | callback(); 87 | // 记录快照 88 | diffPatcher.saveSnapshots(left, state.pages); 89 | updateRedoUndoState(state); 90 | }; 91 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/ComponentWrapper/index.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 73 | 74 | 85 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/NoticeBar/NoticeBarSetting.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 78 | 79 | 95 | -------------------------------------------------------------------------------- /src/components/Editor/ComponentsRegister.ts: -------------------------------------------------------------------------------- 1 | import { App, defineAsyncComponent } from 'vue'; 2 | import Previewer from '@/components/Previewer/index.vue'; 3 | import '@/assets/css/reset.css'; 4 | import HContainer from '@/components/Editor/BuiltInComponents/Container/Container.vue'; 5 | import { ComponentType } from '@/components/Editor/ComponentTypes'; 6 | import useDynamicVars from '@/hooks/useDynamicVars'; 7 | 8 | const { parseExpression } = useDynamicVars(); 9 | const HImg = defineAsyncComponent( 10 | () => 11 | import( 12 | /* webpackChunkName: "HImg" */ '@/components/Editor/BuiltInComponents/Img/Img.vue' 13 | ) 14 | ); 15 | const Swiper = defineAsyncComponent( 16 | () => 17 | import( 18 | /* webpackChunkName: "Swiper" */ '@/components/Editor/TrilateralComponents/Vant/Swiper/Swiper.vue' 19 | ) 20 | ); 21 | const NoticeBar = defineAsyncComponent( 22 | () => 23 | import( 24 | /* webpackChunkName: "NoticeBar" */ '@/components/Editor/TrilateralComponents/Vant/NoticeBar/index.vue' 25 | ) 26 | ); 27 | const HButton = defineAsyncComponent( 28 | () => 29 | import( 30 | /* webpackChunkName: "HButton" */ '@/components/Editor/BuiltInComponents/Button/Button.vue' 31 | ) 32 | ); 33 | const HText = defineAsyncComponent( 34 | () => 35 | import( 36 | /* webpackChunkName: "HText" */ '@/components/Editor/BuiltInComponents/Text/Text.vue' 37 | ) 38 | ); 39 | const HTab = defineAsyncComponent( 40 | () => 41 | import( 42 | /* webpackChunkName: "HTab" */ '@/components/Editor/TrilateralComponents/Vant/Tab/Tab.vue' 43 | ) 44 | ); 45 | const NavBar = defineAsyncComponent( 46 | () => 47 | import( 48 | /* webpackChunkName: "NavBar" */ '@/components/Editor/TrilateralComponents/Vant/NavBar/NavBar.vue' 49 | ) 50 | ); 51 | 52 | const components = [Previewer, HContainer]; 53 | // 异步组件,分包加载 54 | const asyncComponents = [ 55 | { name: ComponentType.Img, asyncComponentWrapper: HImg }, 56 | { name: ComponentType.Swiper, asyncComponentWrapper: Swiper }, 57 | { name: ComponentType.Text, asyncComponentWrapper: HText }, 58 | { name: ComponentType.Tab, asyncComponentWrapper: HTab }, 59 | { name: ComponentType.Button, asyncComponentWrapper: HButton }, 60 | { name: ComponentType.NoticeBar, asyncComponentWrapper: NoticeBar }, 61 | { name: ComponentType.NavBar, asyncComponentWrapper: NavBar }, 62 | ]; 63 | export default { 64 | install(app: App) { 65 | components.forEach((item) => { 66 | app.component(item.name, item); 67 | }); 68 | asyncComponents.forEach((item) => { 69 | app.component(item.name, item.asyncComponentWrapper); 70 | }); 71 | 72 | app.mixin({ 73 | methods: { 74 | parseExpression, 75 | }, 76 | }); 77 | }, 78 | }; 79 | -------------------------------------------------------------------------------- /src/hooks/useDrag.ts: -------------------------------------------------------------------------------- 1 | import { IComponentItem, TComponent } from '@/components/Editor/ComponentTypes'; 2 | import ComponentFactory from '@/components/Editor/Factory'; 3 | import { useStore } from '@/store'; 4 | import { MUTATION_TYPE } from '@/store/Editor/mutations/mutation-type'; 5 | import cloneDeep from 'lodash/cloneDeep'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | import { IExtractComponents } from '@/store/Editor'; 8 | 9 | function componentsCopy( 10 | component: TComponent, 11 | parentId = '', 12 | depth = 0 13 | ): TComponent { 14 | component.id = uuidv4(); 15 | if (parentId) { 16 | component.parentId = parentId; 17 | component.lock = true; 18 | } 19 | if (component.children) { 20 | component.children.forEach((item) => { 21 | componentsCopy(item as TComponent, component.id, ++depth); 22 | }); 23 | } else { 24 | if (!depth) return component; 25 | } 26 | return component; 27 | } 28 | 29 | export default () => { 30 | const store = useStore(); 31 | const dragstart = ( 32 | e: DragEvent, 33 | item: IComponentItem, 34 | isExtractCom = false 35 | ) => { 36 | const data = isExtractCom 37 | ? { 38 | type: 'extract', 39 | name: item.name, 40 | } 41 | : { type: item.type }; 42 | (e.dataTransfer as DataTransfer).setData('dragInfo', JSON.stringify(data)); 43 | store.commit(MUTATION_TYPE.DRAG_COMPONENT); 44 | }; 45 | const dragenter = (e: DragEvent, targetComponent?: TComponent) => { 46 | e.stopPropagation(); 47 | if (targetComponent?.isContainer) { 48 | store.commit(MUTATION_TYPE.ENTER_CONTAINER, targetComponent); 49 | } 50 | }; 51 | 52 | const dragleave = (e: MouseEvent) => { 53 | e.stopPropagation(); 54 | }; 55 | 56 | const drop = (e: DragEvent, targetComponent?: TComponent) => { 57 | e.stopPropagation(); 58 | const dragInfo = JSON.parse( 59 | (e.dataTransfer as DataTransfer).getData('dragInfo') 60 | ); 61 | const { type, name } = dragInfo; 62 | if (targetComponent?.isContainer) { 63 | let component; 64 | if (type === 'extract') { 65 | component = ( 66 | store.state.editor.extractComponents.find( 67 | (item) => item.name === name 68 | ) as IExtractComponents 69 | ).payload; 70 | component = componentsCopy(cloneDeep(component)); 71 | } else { 72 | component = ComponentFactory.createComponent(type); 73 | } 74 | store.commit(`${MUTATION_TYPE.ADD_COMPONENT}`, { 75 | targetComponent: targetComponent, 76 | component: component, 77 | }); 78 | store.commit(MUTATION_TYPE.SELECT_COMPONENT, component); 79 | } 80 | }; 81 | 82 | const dragover = (e: DragEvent) => { 83 | e.preventDefault(); 84 | }; 85 | return { 86 | dragstart, 87 | dragenter, 88 | dragleave, 89 | drop, 90 | dragover, 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/Tab/TabSetting.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 71 | 72 | 114 | -------------------------------------------------------------------------------- /src/hooks/useStyle.ts: -------------------------------------------------------------------------------- 1 | import { computed, Ref } from 'vue'; 2 | import { TComponent } from '@/components/Editor/ComponentTypes'; 3 | import { formatPositionValues } from '@/util'; 4 | import { IBackground } from '@/components/Editor/BuiltInComponents/Component'; 5 | import { DISPLAY } from '@/components/Editor/BuiltInComponents/Container'; 6 | 7 | export const getBorderRadius = (borderRadius?: string) => { 8 | const arr = borderRadius?.split(',').map((item) => `${item}px`) || [ 9 | 0, 0, 0, 0, 10 | ]; 11 | return { 12 | 'border-top-left-radius': arr[0], 13 | 'border-top-right-radius': arr[1], 14 | 'border-bottom-right-radius': arr[2], 15 | 'border-bottom-left-radius': arr[3], 16 | }; 17 | }; 18 | export default (property: Ref) => { 19 | const getBackgroundStyle = ({ 20 | color, 21 | url, 22 | repeat, 23 | size, 24 | horizontal, 25 | vertical, 26 | }: IBackground) => { 27 | return { 28 | backgroundColor: color, 29 | backgroundImage: url ? `url(${url})` : 'none', 30 | backgroundRepeat: repeat, 31 | backgroundSize: size, 32 | backgroundPosition: `${horizontal} ${vertical}`, 33 | }; 34 | }; 35 | return computed(() => { 36 | const base: { [key: string]: any } = { 37 | height: formatPositionValues(property.value.height) 38 | ? formatPositionValues(property.value.height) 39 | : property.value.id === 'root' 40 | ? 0 41 | : 'auto', 42 | width: formatPositionValues(property.value.width) || 'auto', 43 | fontSize: formatPositionValues(property.value.fontSize), 44 | position: property.value.position, 45 | top: formatPositionValues(property.value.top), 46 | left: formatPositionValues(property.value.left), 47 | right: formatPositionValues(property.value.right), 48 | bottom: formatPositionValues(property.value.bottom), 49 | paddingTop: formatPositionValues(property.value.padding?.top), 50 | paddingLeft: formatPositionValues(property?.value.padding?.left), 51 | paddingRight: formatPositionValues(property?.value.padding?.right), 52 | paddingBottom: formatPositionValues(property?.value.padding?.bottom), 53 | marginTop: formatPositionValues(property?.value.margin?.top), 54 | marginLeft: formatPositionValues(property?.value.margin?.left), 55 | marginRight: formatPositionValues(property?.value.margin?.right), 56 | marginBottom: formatPositionValues(property?.value.margin?.bottom), 57 | borderTopWidth: formatPositionValues(property?.value.border?.top), 58 | borderLeftWidth: formatPositionValues(property?.value.border?.left), 59 | borderRightWidth: formatPositionValues(property?.value.border?.right), 60 | borderBottomWidth: formatPositionValues(property?.value.border?.bottom), 61 | borderStyle: property.value.borderStyle, 62 | borderColor: property.value.borderColor, 63 | flex: property.value.flex, 64 | zIndex: property.value.zIndex, 65 | ...getBorderRadius(property.value.borderRadius), 66 | ...getBackgroundStyle(property.value.background), 67 | }; 68 | if (property.value.display === DISPLAY.NONE) { 69 | base.display = DISPLAY.NONE; 70 | } 71 | return base; 72 | }); 73 | }; 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "h5editor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "back-server": "cross-env NODE_ENV=development nodemon -e ts -w ./server -x ts-node-transpile-only ./server/src/index.ts", 7 | "front-serve": "vue-cli-service serve", 8 | "build-previewer": "vue-cli-service build --mode lib --target lib --name index ./src/components/Previewer/index.ts", 9 | "build-front": "vue-cli-service build", 10 | "build-server": "npm run build-previewer && tsc --build ./server/tsconfig.json && gulp", 11 | "build": "npm run build-front && npm run build-server", 12 | "prepare": "husky install" 13 | }, 14 | "dependencies": { 15 | "@element-plus/icons-vue": "^0.2.4", 16 | "axios": "^0.24.0", 17 | "body-parser": "^1.19.1", 18 | "compression": "^1.7.4", 19 | "core-js": "^3.6.5", 20 | "d3-scale": "^4.0.2", 21 | "default-passive-events": "^2.0.0", 22 | "ejs": "^3.1.6", 23 | "element-plus": "^2.2.18", 24 | "express": "4.18.1", 25 | "express-async-errors": "^3.1.1", 26 | "html2canvas": "^1.0.0-rc.5", 27 | "jsondiffpatch": "^0.4.1", 28 | "lodash": "^4.17.21", 29 | "multer": "^1.4.4", 30 | "nedb-promises": "^5.0.2", 31 | "qrcode": "^1.5.0", 32 | "uuid": "^8.3.2", 33 | "vant": "3.4.6", 34 | "vue": "^3.0.0", 35 | "vue-router": "^4.0.0-0", 36 | "vuex": "^4.0.2" 37 | }, 38 | "devDependencies": { 39 | "@types/d3-scale": "^4.0.2", 40 | "@types/express": "^4.17.13", 41 | "@types/lodash": "^4.14.172", 42 | "@types/multer": "^1.4.7", 43 | "@types/qrcode": "^1.4.2", 44 | "@types/uuid": "^8.3.1", 45 | "@typescript-eslint/eslint-plugin": "^4.18.0", 46 | "@typescript-eslint/parser": "^4.18.0", 47 | "@vue/cli-plugin-babel": "~4.5.0", 48 | "@vue/cli-plugin-eslint": "~4.5.0", 49 | "@vue/cli-plugin-router": "~4.5.0", 50 | "@vue/cli-plugin-typescript": "~4.5.0", 51 | "@vue/cli-plugin-vuex": "~4.5.0", 52 | "@vue/cli-service": "~4.5.0", 53 | "@vue/compiler-sfc": "^3.0.0", 54 | "@vue/eslint-config-prettier": "^6.0.0", 55 | "@vue/eslint-config-typescript": "^7.0.0", 56 | "babel-plugin-import": "^1.13.3", 57 | "copy-webpack-plugin": "5.0.0", 58 | "cross-env": "^7.0.3", 59 | "eslint": "7.32.0", 60 | "eslint-config-prettier": "^8.5.0", 61 | "eslint-plugin-prettier": "^3.3.1", 62 | "eslint-plugin-vue": "^7.0.0", 63 | "gulp": "^4.0.2", 64 | "husky": "^8.0.1", 65 | "less": "^3.0.4", 66 | "less-loader": "^5.0.0", 67 | "lint-staged": "^13.0.3", 68 | "postcss": "8.4.4", 69 | "postcss-html": "^1.5.0", 70 | "postcss-less": "^6.0.0", 71 | "postcss-pxtorem": "^5.1.1", 72 | "prettier": "^2.7.1", 73 | "stylelint": "^14.2.0", 74 | "stylelint-config-prettier": "^9.0.3", 75 | "stylelint-config-recess-order": "^3.0.0", 76 | "stylelint-config-recommended-vue": "^1.1.0", 77 | "stylelint-config-standard": "^24.0.0", 78 | "stylelint-order": "^5.0.0", 79 | "stylelint-prettier": "^2.0.0", 80 | "ts-node": "^10.9.1", 81 | "typescript": "~4.3.5" 82 | }, 83 | "lint-staged": { 84 | "*.{js,ts,vue}": [ 85 | "eslint" 86 | ], 87 | "*.{vue,css,less}": [ 88 | "stylelint" 89 | ] 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Container/index.ts: -------------------------------------------------------------------------------- 1 | import Component from '@/components/Editor/BuiltInComponents/Component'; 2 | import { AlignItems } from '@/components/Editor/BuiltInComponents/Layout'; 3 | import { fastInitProps } from '@/util'; 4 | import { ComponentType, TComponent } from '@/components/Editor/ComponentTypes'; 5 | import { ICommonText } from '@/components/Editor/BuiltInComponents/CommonInterface/Text'; 6 | import { ICommonContainer } from '@/components/Editor/BuiltInComponents/CommonInterface/Container'; 7 | 8 | // 主轴(横轴)方向上的对齐方式 9 | export enum JUSTIFY_CONTENT { 10 | START = 'flex-start', 11 | END = 'flex-end', 12 | CENTER = 'center', 13 | BETWEEN = 'space-between', 14 | AROUND = 'space-around', 15 | } 16 | 17 | export enum FLEX_DIRECTION { 18 | ROW = 'row', 19 | 'ROW-REVERSE' = 'row-reverse', 20 | COLUMN = 'column', 21 | 'COLUMN-REVERSE' = 'column-reverse', 22 | } 23 | 24 | export const FlexDirectionList = [ 25 | { name: '水平排列-从左往右', value: FLEX_DIRECTION.ROW }, 26 | { name: '水平排列-从右往左', value: FLEX_DIRECTION['ROW-REVERSE'] }, 27 | { name: '垂直排列-自上而下', value: FLEX_DIRECTION.COLUMN }, 28 | { name: '垂直排列-自下而上', value: FLEX_DIRECTION['COLUMN-REVERSE'] }, 29 | ]; 30 | export const JustifyContentList = [ 31 | { name: '起点对齐', value: JUSTIFY_CONTENT.START }, 32 | { name: '终点对齐', value: JUSTIFY_CONTENT.END }, 33 | { name: '居中对齐', value: JUSTIFY_CONTENT.CENTER }, 34 | { name: '两端对齐', value: JUSTIFY_CONTENT.BETWEEN }, 35 | { name: '等分间隔', value: JUSTIFY_CONTENT.AROUND }, 36 | ]; 37 | 38 | export enum ALIGN_ITEMS { 39 | START = 'flex-start', 40 | END = 'flex-end', 41 | CENTER = 'center', 42 | BASELINE = 'baseline', 43 | STRETCH = 'stretch', 44 | } 45 | 46 | export const AlignItemsList = [ 47 | { name: '默认(撑满容器)', value: ALIGN_ITEMS.STRETCH }, 48 | { name: '起点对齐', value: ALIGN_ITEMS.START }, 49 | { name: '终点对齐', value: ALIGN_ITEMS.END }, 50 | { name: '居中对齐', value: ALIGN_ITEMS.CENTER }, 51 | { name: '文本基线对齐', value: ALIGN_ITEMS.BASELINE }, 52 | ]; 53 | 54 | // 布局类型 55 | export enum DISPLAY { 56 | BLOCK = 'block', 57 | FLEX = 'flex', 58 | NONE = 'none', 59 | } 60 | 61 | export const displayList = [ 62 | { 63 | name: '默认布局', 64 | value: DISPLAY.BLOCK, 65 | }, 66 | { 67 | name: '弹性布局', 68 | value: DISPLAY.FLEX, 69 | }, 70 | ]; 71 | 72 | export interface Layout { 73 | JustifyContent?: JUSTIFY_CONTENT; 74 | AlignItems?: AlignItems; 75 | } 76 | 77 | export interface IContainer extends ICommonContainer, ICommonText, Layout {} 78 | 79 | /** 80 | * 容器组件,继承与基础组件,实现容器的接口 81 | */ 82 | class Container extends Component implements IContainer { 83 | type = ComponentType.Container; 84 | isContainer = true; 85 | children: TComponent[] = []; 86 | JustifyContent: JUSTIFY_CONTENT = JUSTIFY_CONTENT.START; 87 | AlignItems: ALIGN_ITEMS = ALIGN_ITEMS.STRETCH; 88 | width = 200; 89 | height = 200; 90 | display: DISPLAY = DISPLAY.BLOCK; 91 | color = '#000'; 92 | direction: FLEX_DIRECTION = FLEX_DIRECTION.ROW; 93 | fontFamily = 94 | "'PingFang SC', 'STHeitiSC-Light', 'Helvetica-Light', arial, sans-serif, 'Droid Sans Fallback'"; 95 | fontSize: string | number = 14; 96 | fontStyle = 'normal'; 97 | fontWeight = 'normal'; 98 | textAlign = 'left'; 99 | 100 | constructor(props?: Partial) { 101 | super(props); 102 | fastInitProps(props, this); 103 | } 104 | } 105 | 106 | export default Container; 107 | -------------------------------------------------------------------------------- /src/components/Editor/SettingBar/ToolBar.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 90 | 128 | -------------------------------------------------------------------------------- /src/components/Editor/TrilateralComponents/Vant/NavBar/NavBar.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 112 | 113 | 134 | -------------------------------------------------------------------------------- /src/util/index.ts: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep'; 2 | import debounce from 'lodash/debounce'; 3 | import throttle from 'lodash/throttle'; 4 | import { Commit } from 'vuex'; 5 | import { TComponent } from '@/components/Editor/ComponentTypes'; 6 | 7 | // 树桩结构接口 8 | export interface ITree { 9 | id: string; 10 | children?: T[]; 11 | } 12 | 13 | export const fastInitProps = (source: any, target: any) => { 14 | if (source) { 15 | for (const prop in source) { 16 | target[prop] = source[prop]; 17 | } 18 | } 19 | }; 20 | export const objectMerge = (source: any, target: any) => { 21 | if (source) { 22 | for (const prop in source) { 23 | if (typeof source === 'object') { 24 | target[prop] = cloneDeep(source[prop]); 25 | } else { 26 | target[prop] = source[prop]; 27 | } 28 | } 29 | for (const prop in target) { 30 | // eslint-disable-next-line no-prototype-builtins 31 | if (!source.hasOwnProperty(prop)) { 32 | delete target[prop]; 33 | } 34 | } 35 | } 36 | }; 37 | 38 | export function eachComponentTreeDown( 39 | component: TComponent, 40 | callback: (item: TComponent) => void, 41 | condition: (item: TComponent) => boolean = () => true 42 | ) { 43 | condition(component) && callback(component); 44 | if (component.children && component.children.length > 0) { 45 | component.children.forEach((item) => { 46 | eachComponentTreeDown(item as TComponent, callback); 47 | }); 48 | } 49 | } 50 | 51 | export function findItemById = any>( 52 | tree: T[], 53 | id: string 54 | ): T | undefined { 55 | let result = null; 56 | for (let i = 0; i < tree.length; i++) { 57 | const item = tree[i]; 58 | if (item.id === id) { 59 | return item; 60 | } 61 | if (item.children && item.children?.length > 0) { 62 | result = findItemById(item.children, id); 63 | if (result) return result; 64 | } 65 | } 66 | } 67 | 68 | export function findItemAndParentById>( 69 | tree: T[], 70 | id: string 71 | ): { parent: T[]; index: number } | undefined { 72 | // 查找当前tree数组中是否有满足条件的 73 | const targetIndex = tree.findIndex((item) => item.id === id); 74 | // 有则返回对应索引 75 | if (targetIndex > -1) { 76 | return { parent: tree, index: targetIndex }; 77 | } 78 | 79 | // 无则遍历每一项 80 | for (let i = 0; i < tree.length; i++) { 81 | const item = tree[i]; 82 | // 查询是否有子集 83 | if (item.children && item.children?.length > 0) { 84 | // 再查询子集数组是否有满足条件 85 | const result = findItemAndParentById(item.children, id); 86 | // 查询到满足条件的后返回结果 87 | if (result) return result; 88 | } 89 | } 90 | } 91 | 92 | export function getDebounceCommit(commit: Commit, commitType: string) { 93 | const commitHandel = (payload?: T) => { 94 | commit(commitType, payload); 95 | }; 96 | return debounce(commitHandel, 500); 97 | } 98 | 99 | export function getThrottleCommit(commit: Commit, commitType: string) { 100 | const commitHandel = (payload?: T) => { 101 | commit(commitType, payload); 102 | }; 103 | return throttle(commitHandel, 500); 104 | } 105 | 106 | export function downLoadContent(name: string, content: string) { 107 | const link = URL.createObjectURL(new Blob([content])); 108 | const a = document.createElement('a'); 109 | a.download = name; 110 | a.href = link; 111 | a.click(); 112 | } 113 | 114 | export function getCache(key: string): undefined | T { 115 | const cache = localStorage.getItem(key); 116 | return cache ? JSON.parse(cache) : undefined; 117 | } 118 | 119 | export const formatPositionValues = ( 120 | val?: number | string, 121 | rem = process.env.VUE_APP_LIB === 'lib' 122 | ) => { 123 | if (val === 0 || val) { 124 | if (rem) { 125 | return `${(val as number) / 37.5}rem`; 126 | } 127 | return `${val}px`; 128 | } 129 | return ''; 130 | }; 131 | -------------------------------------------------------------------------------- /src/components/Editor/BuiltInComponents/Text/TextSetting.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 122 | 123 | 124 | -------------------------------------------------------------------------------- /src/pages/Editor/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 117 | 118 | 150 | -------------------------------------------------------------------------------- /src/components/Editor/SettingBar/index.vue: -------------------------------------------------------------------------------- 1 | 22 | 79 | 80 | 154 | -------------------------------------------------------------------------------- /src/components/Editor/Header/Header.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 120 | 121 | 137 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # h5-editor 4 | 5 |
6 | 7 | 一个 h5 页面编辑器。不同于使用`绝对定位`布局的方式,h5-editor 使用了标准的文档流进行布局,减少关联组件之间定位所需要的各种计算, 8 | 使得组件之间父子级关系一目了然,整块操作(拖拽、锁定/解锁、统一样式设置)更为方便。使用 h5-editor,只需稍加熟悉,你便你能像专业前端开发者那样得心应手,创建出你想要的精美页面 9 | 10 | > 目前 h5-editor 内有 8 个[组件](#组件)可供使用,其中 4 个基础组件,集成 4 个[Vant(有赞)](https://youzan.github.io/vant/#/zh-CN) 组件,满足基本业务场景 11 | 12 | ## 特点 13 | 14 | - **所见即所得** - h5-editor 抽离了"解析器"组件,编辑和预览都是通过它解析,所以编辑器里是什么样,结果就是什么样 15 | - **自定义组件** - 当你需要在不同页面频繁拖拽一个相同或者大致相同的组件时,可以右键选择`做成组件`功能,让该组件成为自定义组件,此后可以在左侧列表中拖拽复用,拒绝重复操作 16 | - **样式继承** - 由于使用了标准文档流,你可以通过一个容器(div)组件预先设置好样式,其内部组件样式会默认继承该组件样式,拒绝重复操作 17 | - **一键预览** - 右侧工具栏中点击`预览`可随时查看页面效果 18 | - **自适应布局** - 预览器内部通过计算,将编辑时得到的`px`像素,转化为可自适应的`rem`单位,实现不同分辨率端自适应 19 | - **扫码或链接查看/分享** - 在文档库页面,可以通过扫码或访问链接的方式进行查看或分享 20 | - **事件绑定** - 可以通过属性面板为组件绑定对应的事件并执行指定动作 21 | - **支持接口数据源** - 可在数据源面板添加三方接口作为数据源 22 | - **动态渲染变量** - 通过`{{数据源}}`的方式可以将数据源中的数据渲染到页面上 23 | 24 | 25 | ## [Demo](http://h5editor.mgso.site) 26 | 27 | ## 快速开始 28 | 29 | ```shell 30 | # 依赖安装 31 | $ npm install 32 | # 启动前端工程 33 | $ npm run front-serve 34 | # 启动后端接口服务 35 | $ npm run back-server 36 | ``` 37 | 38 | > [关于后端接口服务启动报错](#expres启动报错) 39 | 40 | ## 如何部署 41 | 42 | ```shell 43 | $ npm run build 44 | ``` 45 | 46 | 执行上述命令后会在根目录得到`dist`文件夹,其中包含`front`前端代码和`server`后端代码 47 | 48 | ### 前端部署 49 | 50 | 直接将打包后得到的`front`目录部署到`nginx`或其它服务器中,并将`/api`和 `/static`露路径代理到接口服务中, 51 | 可参照此[nginx.conf](docker/nginx.conf)配置文件 52 | 53 | ### 后端部署 54 | 55 | 1. 将打包后的`server`目录上传服务器 56 | 57 | 2. `$ npm install --production` 安装依赖 58 | 59 | 3. 启动服务 60 | 61 | - 推荐使用 **[pm2](https://github.com/Unitech/pm2)** 62 | 63 | ```shell 64 | $ pm2 start ecosystem.config.js 65 | ``` 66 | 67 | > pm2 方式启动配置了监听文件变动重启,后续更新代码会自动重启 68 | 69 | - 直接启动 70 | 71 | ```shell 72 | $ node index.js 73 | ``` 74 | 75 | ### Docker 76 | 77 | 1. 将执行完`build`后的到的`dist`目录上传到服务器中 78 | 2. 构建镜像 79 | 80 | ```shell 81 | # 进入根目录 82 | $ cd dist 83 | # 构建镜像 84 | $ docker build -t h5editor . 85 | ``` 86 | 87 | 3. 启动容器 88 | 89 | ```shell 90 | $ docker run -dit --name h5editor -v $PWD:/app -p 5000:80 h5editor 91 | ``` 92 | 93 | > 上述启动容器是在 dist 目录下进行的,即把 dist 目录挂载到容器中,随后文件变动/更新将同步到容器中 94 | > 。 当然,服务端代码变动也会自动重启 95 | 96 | ## 关于前端 97 | 98 | 前端采用`vue3`+`typescript`开发,并使用以下库 99 | 100 | - [element-plus](https://github.com/element-plus/element-plus) 整个前端 UI 框架 101 | - [Vant](https://github.com/vant-ui/vant) 构建三方组件库 102 | - [html2canvas](https://github.com/niklasvh/html2canvas) 文稿封面截图 103 | - [jsondiffpatch](https://github.com/benjamine/jsondiffpatch) json 差异对比,h5-editor 通过`jsondiffpatch`进行差异对比,并通过差异进行`patch`和`unpatch`以实现撤销/重做 104 | - [axios](https://github.com/axios/axios) http 请求 105 | - [qrcode](https://github.com/soldair/node-qrcode) 生成二维码 106 | > 关于撤销/重做的实现逻辑可查看[此文档](docs/diffpatch/README.md) 107 | 108 | ### 组件 109 | 110 | #### 内置组件 111 | 112 | - `Component`:所有小组件的基类,开发组件需要继承它 113 | - `ComponentWrapper`:用来包裹组件,实现了点击、拖拽、缩放的功能性组件。任何组件都需要包裹在其中 114 | - `Container`:容器组件,类似于`div`。 115 | - `Button`:按钮 116 | - `Img`:图片 117 | - `Text`:文本 118 | 119 | #### 集成三方组件(Vant) 120 | 121 | - `NvaBar`:标题/导航栏 122 | - `NoticeBar`:通知栏 123 | - `Swiper`:轮播 124 | - `Tab`:标签页组件 125 | 126 | ## 关于后端 127 | 128 | 后端采用 [Express(4.x)](https://expressjs.com/) + `typescript`开发,并使用了如下库 129 | 130 | - [nedb-promises](https://github.com/bajankristof/nedb-promises) 以 json 文件本地存储的数据库 131 | - [ejs](https://github.com/mde/ejs) js 模板引擎,用于服务端渲染页面 132 | 133 | ## 备份&迁移 134 | 135 | 文档数据存储在文件根目录`data`目录中。备份迁移只需要此文件夹即可。容器挂载目录`/app/data` 136 | 137 | ## Q&A 138 | 139 | ### #expres 启动报错 140 | 141 | 启动后端接口时得到如下错误 142 | 143 | `throw new TypeError('Router.use() requires a middleware function but got a ' + gettype(fn))` 144 | 145 | 遇到此问题,请在`server/src/index.ts`中把设置模板引擎的代码随意换个位置即可,具体原因不详。 146 | 根据 express [issue 中的解释](https://github.com/expressjs/express/issues/4930) 是因为未使用`app.set`替代`app.use`设置模板引擎 147 | 但是此项目中确实用的是`app.set`却依旧报错。根据尝试只需要随意换个位置即可成功运行. 148 | 149 | ```diff 150 | const compression = require('compression'); 151 | app.use(compression()); 152 | - app.set('view engine', 'ejs'); 153 | require('express-async-errors'); 154 | + app.set('view engine', 'ejs'); 155 | app.all('*', function (req: Request, res: Response, next: NextFunction) { 156 | ``` 157 | 158 | ## Licenses 159 | 160 | [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](https://lbesson.mit-license.org/) 161 | 162 | ## 致谢 163 | 感谢[JetBrains](https://www.jetbrains.com)提供的开源License 164 | 165 | ![JetBrains Logo (Main) logo](https://resources.jetbrains.com/storage/products/company/brand/logos/jb_beam.svg) 166 | 167 | [https://jb.gg/OpenSourceSupport](https://jb.gg/OpenSourceSupport) 168 | -------------------------------------------------------------------------------- /src/components/Editor/SettingBar/Layout.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | 148 | -------------------------------------------------------------------------------- /src/store/Editor/mutations/components.ts: -------------------------------------------------------------------------------- 1 | import { MUTATION_TYPE } from '@/store/Editor/mutations/mutation-type'; 2 | import { ComponentType, TComponent } from '@/components/Editor/ComponentTypes'; 3 | import { 4 | mutationWithSnapshot, 5 | updateSelectedComponent, 6 | } from '@/store/Editor/util'; 7 | import { IPage, IState } from '@/store/Editor'; 8 | import { findItemAndParentById, findItemById } from '@/util'; 9 | import { IComponent } from '@/components/Editor/BuiltInComponents/Component'; 10 | import eventBus, { EventType } from '@/hooks/useEventBus'; 11 | import { IContainer } from '@/components/Editor/BuiltInComponents/Container'; 12 | import { MutationTree } from 'vuex'; 13 | import { ITab } from '@/components/Editor/TrilateralComponents/Vant/Tab'; 14 | import { ElMessageBox } from 'element-plus'; 15 | 16 | const componentMutations: MutationTree = { 17 | // 新增一个组件 18 | [MUTATION_TYPE.ADD_COMPONENT]: ( 19 | state: IState, 20 | { 21 | targetComponent, 22 | component, 23 | }: { 24 | targetComponent: IContainer | undefined | ITab; 25 | component: TComponent; 26 | } 27 | ) => { 28 | mutationWithSnapshot(state, () => { 29 | const page = ( 30 | state.pages.find((item: IPage) => item.id === state.pageActive) 31 | ); 32 | 33 | // 是否添加到目标容器 34 | if (targetComponent) { 35 | let _target = targetComponent; 36 | if (targetComponent.type === ComponentType.Tab) { 37 | _target = (targetComponent as ITab).children[ 38 | (targetComponent as ITab).active 39 | ] as ITab | IContainer; 40 | } 41 | 42 | component.parentId = _target.id; 43 | _target.children.push(component); 44 | } else { 45 | page.components.push(component); 46 | } 47 | }); 48 | state.enterContainer = null; 49 | state.isDrag = false; 50 | }, 51 | // 拖拽一个组件 52 | [MUTATION_TYPE.DRAG_COMPONENT]: (state: IState, payload = true) => { 53 | state.isDrag = payload; 54 | state.enterContainer = null; 55 | }, 56 | // 更新组件信息 57 | [MUTATION_TYPE.UPDATE_COMPONENT]: (state: IState, payload: TComponent) => { 58 | mutationWithSnapshot(state, () => { 59 | const currentPage = state.pages.find( 60 | (item) => item.id === state.pageActive 61 | ) as IPage; 62 | const target = findItemById( 63 | currentPage.components, 64 | payload.id 65 | ); 66 | if (target) { 67 | Object.assign(target, { ...payload }); 68 | updateSelectedComponent(state); 69 | } 70 | }); 71 | }, 72 | // 选中一个组件 73 | [MUTATION_TYPE.SELECT_COMPONENT]: (state: IState, payload?: TComponent) => { 74 | if (payload) { 75 | // 如果选中的id和当前已选一致 76 | if (payload.id === state.selectedComponents?.id) return; 77 | } else { 78 | payload = state.selectedComponents; 79 | } 80 | // 如果不存在 81 | if (!payload) return; 82 | // 设置当前选中的组件 83 | state.selectedComponents = { ...payload }; 84 | // 通知更新虚拟边框 85 | eventBus.$emit(EventType.updateBorder, payload.id); 86 | }, 87 | // 移除一个组件 88 | [MUTATION_TYPE.REMOVE_COMPONENT]: (state: IState) => { 89 | // 前提是当前已经有选中的组件 90 | if (state.selectedComponents) { 91 | mutationWithSnapshot(state, () => { 92 | // 查询当前所在页面 93 | const currentPage = state.pages.find( 94 | (item) => item.id === state.pageActive 95 | ) as IPage; 96 | // 找到容器 97 | const target = findItemAndParentById( 98 | currentPage.components, 99 | (state.selectedComponents as IComponent).id 100 | ); 101 | console.log(target); 102 | if (target) { 103 | // 删除 104 | target.parent.splice(target.index, 1); 105 | } 106 | // 更新选中的组件信息 107 | updateSelectedComponent(state); 108 | }); 109 | } 110 | }, 111 | // 鼠标进入容器 112 | [MUTATION_TYPE.ENTER_CONTAINER]: (state: IState, target) => { 113 | state.enterContainer = target; 114 | }, 115 | // 鼠标离开容器 116 | [MUTATION_TYPE.LEAVE_CONTAINER]: (state: IState) => { 117 | state.enterContainer = null; 118 | }, 119 | // 抽离组件 120 | [MUTATION_TYPE.EXTRACT_COMPONENT]: ( 121 | state: IState, 122 | { name, component }: { name: string; component: TComponent } 123 | ) => { 124 | if (state.extractComponents.find((item) => item.name === name)) { 125 | return ElMessageBox({ 126 | type: 'warning', 127 | title: '错误', 128 | message: `${name}组件已存在`, 129 | }); 130 | } 131 | component.alias = name; 132 | state.extractComponents.push({ 133 | name, 134 | payload: component, 135 | }); 136 | }, 137 | [MUTATION_TYPE.DELETE_EXTRACT_COMPONENT]: (state: IState, name: string) => { 138 | const index = state.extractComponents.findIndex( 139 | (item) => item.name === name 140 | ); 141 | state.extractComponents.splice(index, 1); 142 | }, 143 | }; 144 | export default componentMutations; 145 | -------------------------------------------------------------------------------- /src/util/diffpatch/index.ts: -------------------------------------------------------------------------------- 1 | import { Config, create, Delta } from 'jsondiffpatch'; 2 | const diffPatcher = create({ 3 | // used to match objects when diffing arrays, by default only === operator is used 4 | // 在对象数组中,根据objectHash来匹配对象 5 | objectHash: function (obj) { 6 | // this function is used only to when objects are not equal by ref 7 | return obj._id || obj.id; 8 | }, 9 | arrays: { 10 | // default true, detect items moved inside the array (otherwise they will be registered as remove+add) 11 | detectMove: true, 12 | // default false, the value of items moved is not included in deltas 13 | includeValueOnMove: false, 14 | }, 15 | textDiff: { 16 | // default 60, minimum string length (left and right sides) to use text diff algorythm: google-diff-match-patch 17 | minLength: 60, 18 | }, 19 | // 过滤掉不需要监听的属性 20 | propertyFilter: function (name, context) { 21 | /* 22 | this optional function can be specified to ignore object properties (eg. volatile data) 23 | name: property name, present in either context.left or context.right objects 24 | context: the diff context (has context.left and context.right objects) 25 | */ 26 | return name.slice(0, 1) !== '$'; 27 | }, 28 | cloneDiffValues: 29 | true /* default false. if true, values in the obtained delta will be cloned 30 | (using jsondiffpatch.clone by default), to ensure delta keeps no references to left or right objects. this becomes useful if you're diffing and patching the same objects multiple times without serializing deltas. 31 | instead of true, a function can be specified here to provide a custom clone(value) 32 | */, 33 | }); 34 | 35 | export enum ModifyAction { 36 | Create = 'create', 37 | Remove = 'remove', 38 | Move = 'moveIndex', 39 | Update = 'update', 40 | Null = 'null', 41 | } 42 | 43 | export class DiffPatcher { 44 | // 快照 45 | private snapshots: Delta[] = []; 46 | // 快照索引 47 | private index = -1; 48 | // 最后一次更改的行为 49 | private lastModifyAction: ModifyAction = ModifyAction.Null; 50 | // 存储快照最大数 51 | private readonly maxSnapshotLength: number; 52 | 53 | current: T | undefined; 54 | 55 | constructor(props?: number); 56 | constructor(props?: DiffPatcher); 57 | constructor(props: number | DiffPatcher = 20) { 58 | if (typeof props === 'number') { 59 | this.maxSnapshotLength = props; 60 | } else { 61 | const { snapshots, maxSnapshotLength, index, current } = 62 | props as DiffPatcher; 63 | this.snapshots = snapshots; 64 | this.index = index; 65 | this.maxSnapshotLength = maxSnapshotLength; 66 | this.current = current; 67 | } 68 | } 69 | 70 | // 静态clone函数 71 | static clone(value: T): T { 72 | return diffPatcher.clone(value); 73 | } 74 | /** 75 | * 判断此次补丁的行为 76 | * @param delta 补丁 77 | * @return ModifyAction 78 | */ 79 | private static getModifyType(delta: Delta): ModifyAction { 80 | if (!delta) return ModifyAction.Null; 81 | const originType = delta._t; 82 | delete delta._t; 83 | // 获取key 84 | const key = Object.keys(delta)[0]; 85 | const firstDelta = delta[key]; 86 | 87 | // 新增 或 修改某个属性 88 | if (!key.startsWith('_')) { 89 | const modify = firstDelta[Object.keys(firstDelta)[0]]; 90 | // 新增 91 | if (Array.isArray(modify)) { 92 | return ModifyAction.Update; 93 | } 94 | return ModifyAction.Remove; 95 | } else { 96 | const modify = firstDelta; 97 | if (modify[1] === 0 && modify[2] === 0) { 98 | // 删除 99 | return ModifyAction.Create; 100 | } 101 | return ModifyAction.Move; 102 | } 103 | } 104 | 105 | /** 106 | * 重做 107 | */ 108 | redo(): T | false { 109 | // 如果索引在最后一位,无法继续重做 110 | if (this.index === this.snapshots.length - 1) return false; 111 | // 取下一个补丁 112 | this.index += 1; 113 | const delta = this.snapshots[this.index]; 114 | const current = diffPatcher.clone(this.current); 115 | this.lastModifyAction = DiffPatcher.getModifyType(delta); 116 | // 打补丁 117 | this.current = diffPatcher.patch(current, delta); 118 | return this.current; 119 | } 120 | 121 | /** 122 | * 撤销 123 | */ 124 | undo(): T | false { 125 | // 已经撤回到第一步 或者 没有快照 126 | if (this.index < 0 || this.snapshots.length < 1) return false; 127 | const current = diffPatcher.clone(this.current); 128 | // 获取当前索引快照 129 | const delta = this.snapshots[this.index]; 130 | this.lastModifyAction = DiffPatcher.getModifyType(delta); 131 | // 卸载布丁 132 | this.current = diffPatcher.unpatch(current, delta); 133 | // 索引位 - 1 134 | this.index -= 1; 135 | return this.current; 136 | } 137 | 138 | /** 139 | *存储diff快照 140 | * @param left 原始值 141 | * @param right 新值 142 | */ 143 | saveSnapshots(left: T, right: T) { 144 | const delta = diffPatcher.diff(left, right); 145 | if (!delta) return; 146 | 147 | // 如果当前快照索引不在最后一个 148 | if (this.index !== this.snapshots.length - 1) { 149 | // 删掉当前索引之后的所有快照 150 | this.snapshots = this.snapshots.slice(0, this.index + 1); 151 | } 152 | 153 | this.snapshots.push(delta); 154 | 155 | // 如果超出最大快照数,截取掉前面部分 156 | if (this.snapshots.length > this.maxSnapshotLength) { 157 | this.snapshots = this.snapshots.slice(-this.maxSnapshotLength); 158 | } 159 | // 最后再设置索引 160 | this.index = this.snapshots.length - 1; 161 | this.current = right; 162 | } 163 | 164 | getModifyType(): ModifyAction { 165 | return this.lastModifyAction; 166 | } 167 | allowRedo() { 168 | return this.index < this.snapshots.length - 1; 169 | } 170 | allowUndo() { 171 | return this.index > -1 && this.snapshots.length > 0; 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/components/Editor/SettingBar/General.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 132 | 168 | -------------------------------------------------------------------------------- /src/components/Editor/Contextmenu/index.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 158 | 159 | 207 | -------------------------------------------------------------------------------- /docs/diffpatch/README.md: -------------------------------------------------------------------------------- 1 | # 撤销/重做 2 | 3 | ## 逻辑梳理 4 | 5 | ### 保存变更 6 | 7 |
8 | 9 |
10 | 11 | ### 撤销变更 12 | 13 |
14 | 15 |
16 | 17 | ### 撤销途中新增变更 18 | 19 |
20 | 21 |
22 | 23 | ### 撤销途中新增后继续撤销 24 | 25 |
26 | 27 |
28 | 29 | ### 重做 30 | 31 |
32 | 33 |
34 | 35 | ### 超出最大存储长度 36 | 37 |
38 | 39 |
40 | 41 | ## diff库 42 | 43 | [jsondiffpatch](https://github.com/benjamine/jsondiffpatch) 44 | 45 | [Live Demo](http://benjamine.github.io/jsondiffpatch/demo/index.html) 46 | 47 | 48 | ## 代码实现 49 | 50 | ```typescript 51 | import {Config, create, Delta} from "jsondiffpatch"; 52 | 53 | const diffPatcher = create({ 54 | // used to match objects when diffing arrays, by default only === operator is used 55 | // 在对象数组中,根据objectHash来匹配对象 56 | objectHash: function (obj) { 57 | // this function is used only to when objects are not equal by ref 58 | return obj._id || obj.id; 59 | }, 60 | arrays: { 61 | // default true, detect items moved inside the array (otherwise they will be registered as remove+add) 62 | detectMove: true, 63 | // default false, the value of items moved is not included in deltas 64 | includeValueOnMove: false, 65 | }, 66 | textDiff: { 67 | // default 60, minimum string length (left and right sides) to use text diff algorythm: google-diff-match-patch 68 | minLength: 60, 69 | }, 70 | // 过滤掉不需要监听的属性 71 | propertyFilter: function (name, context) { 72 | /* 73 | this optional function can be specified to ignore object properties (eg. volatile data) 74 | name: property name, present in either context.left or context.right objects 75 | context: the diff context (has context.left and context.right objects) 76 | */ 77 | return name.slice(0, 1) !== "$"; 78 | }, 79 | cloneDiffValues: 80 | true /* default false. if true, values in the obtained delta will be cloned 81 | (using jsondiffpatch.clone by default), to ensure delta keeps no references to left or right objects. this becomes useful if you're diffing and patching the same objects multiple times without serializing deltas. 82 | instead of true, a function can be specified here to provide a custom clone(value) 83 | */, 84 | }); 85 | 86 | export enum ModifyAction { 87 | Create = "create", 88 | Remove = "remove", 89 | Move = "moveIndex", 90 | Update = "update", 91 | Null = "null", 92 | } 93 | 94 | export class DiffPatcher { 95 | // 快照 96 | private snapshots: Delta[] = []; 97 | // 快照索引 98 | private index = -1; 99 | // 最后一次更改的行为 100 | private lastModifyAction: ModifyAction = ModifyAction.Null; 101 | // 存储快照最大数 102 | private readonly maxSnapshotLength: number; 103 | 104 | left: T | undefined; 105 | 106 | constructor(maxSnapshotLength = 20) { 107 | this.maxSnapshotLength = maxSnapshotLength; 108 | } 109 | 110 | 111 | /** 112 | *存储diff快照 113 | * @param left 原始值 114 | * @param right 新值 115 | */ 116 | saveSnapshots(left: T, right: T) { 117 | const delta = diffPatcher.diff(left, right); 118 | if (!delta) return; 119 | 120 | // 如果当前快照索引不在最后一个 121 | if (this.index !== this.snapshots.length - 1) { 122 | // 删掉当前索引之后的所有快照 123 | this.snapshots = this.snapshots.slice(0, this.index + 1); 124 | } 125 | // 添加索引 126 | this.index = this.snapshots.push(delta) - 1; 127 | // 如果超出最大快照数,截取掉前面部分 128 | if (this.snapshots.length > this.maxSnapshotLength) { 129 | this.snapshots = this.snapshots.slice(-this.maxSnapshotLength); 130 | } 131 | this.left = left; 132 | } 133 | 134 | /** 135 | * 撤销 136 | */ 137 | undo(): T | false { 138 | if (this.index < 0) return false; 139 | if (this.snapshots.length < 1 || !this.left) return false; 140 | const cloneLeft = diffPatcher.clone(this.left); 141 | const delta = this.snapshots[this.index]; 142 | this.index -= 1; 143 | this.lastModifyAction = DiffPatcher.getModifyType(delta); 144 | this.left = diffPatcher.unpatch(cloneLeft, delta); 145 | return this.left; 146 | } 147 | 148 | /** 149 | * 重做 150 | */ 151 | redo(): T | false { 152 | if (this.index === this.snapshots.length) return false; 153 | this.index += 1; 154 | const index = this.index; 155 | if (!this.snapshots[index] || !this.left) return false; 156 | const delta = this.snapshots[index]; 157 | const cloneLeft = diffPatcher.clone(this.left); 158 | this.lastModifyAction = DiffPatcher.getModifyType(delta); 159 | this.left = diffPatcher.patch(cloneLeft, delta); 160 | return this.left; 161 | } 162 | 163 | // 静态clone函数 164 | static clone(value: T): T { 165 | return diffPatcher.clone(value); 166 | } 167 | 168 | 169 | /** 170 | * 判断此次补丁的行为 171 | * @param delta 补丁 172 | * @return ModifyAction 173 | */ 174 | private static getModifyType(delta: Delta): ModifyAction { 175 | if (!delta) return ModifyAction.Null; 176 | const originType = delta._t; 177 | delete delta._t; 178 | // 获取key 179 | const key = Object.keys(delta)[0]; 180 | const firstDelta = delta[key]; 181 | 182 | // 新增 或 修改某个属性 183 | if (!key.startsWith("_")) { 184 | const modify = firstDelta[Object.keys(firstDelta)[0]]; 185 | // 新增 186 | if (Array.isArray(modify)) { 187 | return ModifyAction.Update; 188 | } 189 | return ModifyAction.Create; 190 | } else { 191 | const modify = firstDelta; 192 | if (modify[1] === 0 && modify[2] === 0) { 193 | // 删除 194 | return ModifyAction.Remove; 195 | } 196 | return ModifyAction.Move; 197 | } 198 | } 199 | 200 | 201 | 202 | getModifyType(): ModifyAction { 203 | return this.lastModifyAction; 204 | } 205 | } 206 | ``` 207 | 208 | 209 | 210 | 211 | 212 | -------------------------------------------------------------------------------- /src/components/Previewer/router.ts: -------------------------------------------------------------------------------- 1 | import { ref, Ref } from 'vue'; 2 | 3 | type RouterMode = 'query' | 'hash'; 4 | type PageActionType = 'prev' | 'next'; 5 | 6 | export interface IRoute { 7 | components: []; 8 | id: string; 9 | name: string; 10 | } 11 | 12 | interface IRouter { 13 | routes: IRoute[]; 14 | routerKey?: string; 15 | mode?: RouterMode; 16 | homePage?: string; 17 | onChange?: (router: Router) => void; 18 | } 19 | 20 | export class Router { 21 | // 路由参数标识符 key=xxxxxxx 22 | static key = ''; 23 | static mode: RouterMode = 'hash'; 24 | // 路由表 25 | private routes: IRoute[]; 26 | // 路由模式,hash或query。 27 | private readonly mode: RouterMode; 28 | private routerKey = ''; 29 | // 上个路由 30 | from: IRoute | null = null; 31 | // 当前路由 32 | current: IRoute | null = null; 33 | // 匹配路由的正则 34 | private reg: RegExp; 35 | // 默认首页 36 | private readonly homePage?: string; 37 | // 当前路由对应渲染的组件列表 38 | renderComponents: Ref = ref([]); 39 | history: IRoute[] = []; 40 | onChange: (router: Router) => void = () => {}; 41 | onChangeListenerHandel: () => void; 42 | 43 | constructor({ 44 | routes = [], 45 | mode = 'hash', 46 | homePage = '', 47 | routerKey = 'hpath', 48 | onChange = () => {}, 49 | }: IRouter) { 50 | Router.key = routerKey; 51 | Router.mode = mode; 52 | 53 | this.routes = routes; 54 | this.routerKey = routerKey; 55 | this.mode = mode; 56 | this.homePage = homePage; 57 | this.onChange = onChange; 58 | this.reg = Router.generateRouterKeyReg(); 59 | 60 | this.onChangeListenerHandel = () => { 61 | this.renderComponents.value = this.getRouteComponents(); 62 | if (this.onChange) { 63 | this.onChange(this); 64 | } 65 | }; 66 | // 如果是hash模式,需要监听hashchange事件 67 | if (mode === 'hash') { 68 | window.addEventListener('hashchange', this.onChangeListenerHandel); 69 | } 70 | 71 | // 如果有首页,设置首页 72 | if (this.homePage) { 73 | Router.go(this.routerIdFix(this.homePage)); 74 | } else { 75 | const routerId = this.routerIdFix(this.getRouteId()); 76 | Router.go(routerId); 77 | } 78 | this.renderComponents.value = this.getRouteComponents(); 79 | if (this.onChange) { 80 | this.onChange(this); 81 | } 82 | } 83 | 84 | // 通过routerKey构建用来获取path的的正则表达式 85 | static generateRouterKeyReg(): RegExp { 86 | return new RegExp(`${this.key}=([^&/]*)`); 87 | } 88 | 89 | /** 90 | * 路由跳转 91 | * @param routerId 92 | */ 93 | static go(routerId: string) { 94 | const mode = this.mode; 95 | mode === 'query' 96 | ? this.setPathByQuery(routerId) 97 | : this.setPathByHash(routerId); 98 | } 99 | 100 | /** 101 | * 通过hash获取路由id 102 | * @private 103 | */ 104 | private static getRouteIdByHash() { 105 | const hash = window.location.hash; 106 | return this.getRoureIdByQuery(hash); 107 | } 108 | 109 | /** 110 | * 通过query获取路由id 111 | * @private 112 | */ 113 | private static getRoureIdByQuery(search = window.location.search) { 114 | const routerMatch = search.match(this.generateRouterKeyReg()); 115 | if (routerMatch) return routerMatch[1]; 116 | } 117 | 118 | /** 119 | * 通过query设置path 120 | * @param routeId 121 | * @private 122 | */ 123 | private static setPathByQuery(routeId: string) { 124 | let query = location.search; 125 | const routerKey = this.key; 126 | const reg = this.generateRouterKeyReg(); 127 | if (!query) { 128 | query = `?${routerKey}=${routeId}`; 129 | } else { 130 | const hasPath = query.match(reg); 131 | if (hasPath) { 132 | query = query.replace(reg, `${routerKey}=${routeId}`); 133 | } else { 134 | query += `&${routerKey}=${routeId}`; 135 | } 136 | } 137 | location.search = query; 138 | } 139 | 140 | /** 141 | * 通过hash设置path 142 | * @param routeId 143 | * @private 144 | */ 145 | private static setPathByHash(routeId: string) { 146 | let hash = location.hash; 147 | // 获取routerKey 148 | const routerKey = this.key; 149 | const reg = this.generateRouterKeyReg(); 150 | const hasPath = hash.match(reg); 151 | if (!hasPath) { 152 | hash += `?${routerKey}=${routeId}`; 153 | } else { 154 | hash = hash.replace(reg, `${routerKey}=${routeId}`); 155 | } 156 | location.hash = hash; 157 | } 158 | 159 | /** 160 | * 修正当前routerId,有可能传入的routerId不在路由表中 161 | * 检测到不存在时,返回路由表首页 162 | * @param routerId 163 | */ 164 | routerIdFix(routerId?: string): string { 165 | const inRouters = 166 | this.routes.findIndex((item) => item.id === routerId) > -1; 167 | if (inRouters) return routerId as string; 168 | return this.routes[0].id; 169 | } 170 | 171 | /** 172 | * 设置路由 173 | * @param param 路由id或者翻页动作 174 | */ 175 | setPath(param: string | PageActionType) { 176 | let index = this.routes.findIndex((item) => item.id === this.current?.id); 177 | index = index < 0 ? 0 : index; 178 | let routerId = ''; 179 | // 如果是翻页 180 | if (param === 'next' || param === 'prev') { 181 | if (param === 'prev') { 182 | index--; 183 | index = index < 0 ? 0 : index; 184 | } 185 | if (param === 'next') { 186 | index++; 187 | index = index > this.routes.length - 1 ? this.routes.length - 1 : index; 188 | } 189 | routerId = this.routes[index].id; 190 | } else { 191 | routerId = param; 192 | } 193 | Router.go(routerId); 194 | } 195 | 196 | /** 197 | * 跳转到指定步数,同vue-router go 198 | * @param step 199 | */ 200 | go(step: number) { 201 | step = this.history.length - (1 - step); 202 | step = step < 0 ? 0 : step; 203 | const page = this.history[step]; 204 | Router.go(page.id); 205 | } 206 | 207 | /** 208 | * 获取路由id 209 | */ 210 | getRouteId() { 211 | if (this.mode === 'query') return Router.getRoureIdByQuery(); 212 | return Router.getRouteIdByHash(); 213 | } 214 | 215 | /** 216 | * 获取当前路由下的组件 217 | */ 218 | getRouteComponents() { 219 | const routerId = this.getRouteId() || (this.routes[0] as IRoute).id; 220 | const page = (this.routes as IRoute[]).find((page) => page.id === routerId); 221 | if (page) { 222 | this.from = this.current; 223 | this.current = page; 224 | if (!this.history.find((item) => item.id === this.current?.id)) { 225 | this.history.push(this.current); 226 | } 227 | document.title = page.name; 228 | return page.components; 229 | } 230 | return []; 231 | } 232 | 233 | back() { 234 | if (this.history.length === 1) return; 235 | this.go(-1); 236 | this.history.pop(); 237 | } 238 | 239 | destroy() { 240 | this.routes = []; 241 | window.removeEventListener('hashchange', this.onChangeListenerHandel); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/hooks/useResize.ts: -------------------------------------------------------------------------------- 1 | import { computed, ref } from 'vue'; 2 | import { useStore } from '@/store'; 3 | import { MUTATION_TYPE } from '@/store/Editor/mutations/mutation-type'; 4 | import cloneDeep from 'lodash/cloneDeep'; 5 | import { diffPatcher } from '@/store/Editor/util'; 6 | import { IComponent } from '@/components/Editor/BuiltInComponents/Component'; 7 | import { TComponent } from '@/components/Editor/ComponentTypes'; 8 | import { IPage } from '@/store/Editor'; 9 | 10 | const CRITICAL = 20; 11 | 12 | let currentDomSize: { width: number; height: number } | null = null; 13 | 14 | function getDomSize(id: string) { 15 | if (currentDomSize) return currentDomSize; 16 | const currentDom = document.getElementById(id) as HTMLElement; 17 | currentDomSize = { 18 | width: currentDom.offsetWidth, 19 | height: currentDom.offsetHeight, 20 | }; 21 | return currentDomSize; 22 | } 23 | 24 | export default () => { 25 | const store = useStore(); 26 | const startX = ref(0); 27 | const startY = ref(0); 28 | const offsetX = ref(0); 29 | const offsetY = ref(0); 30 | const resize = ref(false); 31 | const rePosition = ref(false); 32 | let currentComponent: TComponent | null = null; 33 | 34 | // 定位方式 35 | const position = computed(() => { 36 | return store.state.editor.selectedComponents?.position || ''; 37 | }); 38 | 39 | // 设置拖拽点 40 | const resizePoint = computed(() => { 41 | const all = ['lt', 'rt', 'lb', 'rb', 'l', 't', 'r', 'b']; 42 | if (store.state.editor.selectedComponents) { 43 | // 如果是根节点,或者带有根节点属性(例如tab组件中默认会又一个container容器,这个容器标识为根组件) 44 | // 不需要拖拽点 45 | if ( 46 | store.state.editor.selectedComponents.id === 'root' || 47 | (store.state.editor.selectedComponents as TComponent).isRoot 48 | ) 49 | return []; 50 | // 如果当前有组件拖拽进本组件,也不需要展示拖拽点。橙色虚线边控优先级高于选中边控 51 | if ( 52 | store.state.editor.selectedComponents.id === 53 | store.state.editor.enterContainer?.id 54 | ) { 55 | return []; 56 | } 57 | if (position.value === 'relative' || position.value === 'static') { 58 | // 相对定位只能拖拽r,rb,b 三个点 59 | return all.filter( 60 | (item) => !['lt', 'rt', 'lb', 'l', 't'].includes(item) 61 | ); 62 | } else { 63 | return all; 64 | } 65 | } 66 | return []; 67 | }); 68 | 69 | // 当前状态 70 | let left: IPage[]; 71 | let resizeHandle: string; 72 | 73 | /** 74 | * 鼠标按下的行为 75 | * @param event 鼠标事件对象 76 | * @param handle 当handle为string标识拖拽的各个点位,即resize。如果传入的是一个组件,则是改变位置 77 | */ 78 | function mouseDown(event: MouseEvent, handle: string | TComponent) { 79 | //Event.preventDefault(); 80 | event.stopPropagation(); 81 | // 如果鼠标按下的是一个组件 82 | if (handle && typeof handle !== 'string') { 83 | // 但是这个组件又不是当前选中的组件。不往下执行操作 84 | if (handle.id !== store.state.editor.selectedComponents?.id) { 85 | return; 86 | } 87 | } 88 | if (event.button !== 0) return; 89 | // 更新left 90 | left = cloneDeep(store.state.editor.pages); 91 | const { clientX, clientY } = event; 92 | startX.value = clientX; 93 | startY.value = clientY; 94 | currentComponent = { 95 | ...store.state.editor.selectedComponents, 96 | } as TComponent; 97 | if ( 98 | currentComponent && 99 | currentComponent.id !== 'root' && 100 | !currentComponent.isRoot 101 | ) { 102 | if (typeof handle === 'string') { 103 | resize.value = true; 104 | resizeHandle = handle as string; 105 | } else { 106 | rePosition.value = true; 107 | } 108 | document.body.addEventListener('mousemove', mouseMove); 109 | document.body.addEventListener('mouseup', mouseUp); 110 | } 111 | } 112 | 113 | /** 114 | * 鼠标按下 115 | * @param event 116 | * @param handle 117 | */ 118 | 119 | /** 120 | * 鼠标移动期间,更新坐标 121 | * @param event 122 | */ 123 | const mouseMove = (event: MouseEvent) => { 124 | event.stopPropagation(); 125 | //Event.preventDefault(); 126 | const { clientX, clientY } = event; 127 | offsetX.value = clientX - startX.value; 128 | offsetY.value = clientY - startY.value; 129 | let width, height, top, left, margin; 130 | 131 | // 如果是更改大小 132 | if (resize.value) { 133 | ({ 134 | width, 135 | height, 136 | top = 0, 137 | left = 0, 138 | margin, 139 | } = currentComponent as TComponent); 140 | if (width === '') { 141 | width = getDomSize((currentComponent as TComponent).id).width; 142 | } 143 | if (height === '') { 144 | height = getDomSize((currentComponent as TComponent).id).height; 145 | } 146 | if (resizeHandle.includes('r')) { 147 | (width as number) += offsetX.value; 148 | } 149 | if (resizeHandle.includes('b')) { 150 | (height as number) += offsetY.value; 151 | } 152 | if (resizeHandle.includes('t')) { 153 | const tempHeight = (height as number) - offsetY.value; 154 | if (tempHeight >= CRITICAL) { 155 | height = tempHeight; 156 | top = top + offsetY.value; 157 | } else { 158 | const offset = (height as number) - CRITICAL; 159 | height = CRITICAL; 160 | top = top + offset; 161 | } 162 | } 163 | if (resizeHandle.includes('l')) { 164 | const tempWidth = (width as number) - offsetX.value; 165 | if (tempWidth >= CRITICAL) { 166 | (width as number) -= offsetX.value; 167 | left = left + offsetX.value; 168 | } else { 169 | const offset = (width as number) - CRITICAL; 170 | width = CRITICAL; 171 | left = left + offset; 172 | } 173 | } 174 | } 175 | 176 | // 如果是更改位置 177 | if (rePosition.value) { 178 | ({ 179 | top = 0, 180 | left = 0, 181 | width, 182 | height, 183 | margin, 184 | } = currentComponent as IComponent); 185 | if (position.value === 'static') { 186 | if (margin) { 187 | const { top: marginTop = 0, left: marginLeft = 0 } = margin; 188 | margin = { 189 | top: marginTop + offsetY.value, 190 | left: marginLeft + offsetX.value, 191 | }; 192 | } else { 193 | margin = { 194 | top: offsetY.value, 195 | left: offsetX.value, 196 | }; 197 | } 198 | } else { 199 | top += offsetY.value; 200 | left += offsetX.value; 201 | } 202 | } 203 | store.commit(MUTATION_TYPE.RESIZE, { 204 | id: (currentComponent as IComponent).id, 205 | width, 206 | height, 207 | top, 208 | left, 209 | margin, 210 | }); 211 | }; 212 | const mouseUp = (event: MouseEvent) => { 213 | event.stopPropagation(); 214 | //Event.preventDefault(); 215 | resize.value = false; 216 | rePosition.value = false; 217 | document.body.removeEventListener('mousemove', mouseMove); 218 | document.body.removeEventListener('mouseup', mouseUp); 219 | // 记录一次快照 220 | diffPatcher.saveSnapshots(left, store.state.editor.pages); 221 | resizeHandle = ''; 222 | offsetY.value = 0; 223 | offsetX.value = 0; 224 | currentDomSize = null; 225 | }; 226 | 227 | return { 228 | mouseDown, 229 | resizePoint, 230 | }; 231 | }; 232 | --------------------------------------------------------------------------------