├── src ├── packages │ ├── index.tsx │ ├── utils │ │ └── useModel.tsx │ ├── visual-editor.props.tsx │ ├── components │ │ └── block-resizer │ │ │ ├── style.scss │ │ │ └── index.tsx │ ├── visual.config.tsx │ ├── visual-editor.utils.ts │ ├── visual-editor-block.tsx │ ├── visual-editor.scss │ ├── visual-editor-operator.tsx │ └── visual-editor.tsx ├── assets │ └── logo.png ├── lib │ └── iconfont │ │ ├── iconfont.eot │ │ ├── iconfont.ttf │ │ ├── iconfont.woff │ │ ├── iconfont.woff2 │ │ ├── iconfont.json │ │ ├── iconfont.css │ │ ├── iconfont.svg │ │ └── iconfont.js ├── shims-vue.d.ts ├── main.ts ├── App.vue └── components │ └── HelloWorld.vue ├── public ├── favicon.ico └── index.html ├── babel.config.js ├── .gitignore ├── README.md ├── tsconfig.json └── package.json /src/packages/index.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Miller-Wang/visual-editor-vue/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Miller-Wang/visual-editor-vue/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/iconfont/iconfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Miller-Wang/visual-editor-vue/HEAD/src/lib/iconfont/iconfont.eot -------------------------------------------------------------------------------- /src/lib/iconfont/iconfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Miller-Wang/visual-editor-vue/HEAD/src/lib/iconfont/iconfont.ttf -------------------------------------------------------------------------------- /src/lib/iconfont/iconfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Miller-Wang/visual-editor-vue/HEAD/src/lib/iconfont/iconfont.woff -------------------------------------------------------------------------------- /src/lib/iconfont/iconfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Miller-Wang/visual-editor-vue/HEAD/src/lib/iconfont/iconfont.woff2 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import ElementPlus from "element-plus"; 4 | import "element-plus/lib/theme-chalk/index.css"; 5 | 6 | const app = createApp(App); 7 | app.use(ElementPlus); 8 | app.mount("#app"); 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # visual-editor-vue 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn build 16 | ``` 17 | 18 | ### Lints and fixes files 19 | ``` 20 | yarn lint 21 | ``` 22 | 23 | ### Customize configuration 24 | See [Configuration Reference](https://cli.vuejs.org/config/). 25 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 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 | -------------------------------------------------------------------------------- /src/packages/utils/useModel.tsx: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, ref, watch } from "vue"; 2 | 3 | // 用jsx封装组件的时候,实现双向数据绑定 4 | export function useModel(getter: () => T, emitter: (val: T) => void) { 5 | const state = ref(getter()) as { value: T }; 6 | 7 | watch(getter, (val) => { 8 | if (val !== state.value) { 9 | state.value = val; 10 | } 11 | }); 12 | 13 | return { 14 | get value() { 15 | return state.value; 16 | }, 17 | set value(val: T) { 18 | if (state.value !== val) { 19 | state.value = val; 20 | emitter(val); 21 | } 22 | }, 23 | }; 24 | } 25 | 26 | // modelValue 外部可以用v-model绑定 27 | export const TestUseModel = defineComponent({ 28 | props: { 29 | modelValue: { type: String }, 30 | }, 31 | emits: { 32 | "update:modelValue": (val?: string) => true, 33 | }, 34 | setup(props, ctx) { 35 | const model = useModel( 36 | () => props.modelValue, 37 | (val) => ctx.emit("update:modelValue", val) 38 | ); 39 | return () => ( 40 |
41 | 自定义输入框 42 | 43 |
44 | ); 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /src/packages/visual-editor.props.tsx: -------------------------------------------------------------------------------- 1 | export enum VisualEditorPropsType { 2 | input = "input", 3 | color = "color", 4 | select = "select", 5 | } 6 | 7 | export interface VisualEditorProps { 8 | type: VisualEditorPropsType; 9 | label: string; 10 | options?: VisualEditorSelectOptions; 11 | } 12 | 13 | /** ------input------- */ 14 | export function createEditorInputProps(label: string): VisualEditorProps { 15 | return { 16 | type: VisualEditorPropsType.input, 17 | label, 18 | }; 19 | } 20 | 21 | /** ------color------- */ 22 | export function createEditorColorProps(label: string): VisualEditorProps { 23 | return { 24 | type: VisualEditorPropsType.color, 25 | label, 26 | }; 27 | } 28 | 29 | /** ------select------- */ 30 | export type VisualEditorSelectOptions = { 31 | label: string; 32 | val: string; 33 | }[]; 34 | 35 | export function createEditorSelectProps( 36 | label: string, 37 | options: VisualEditorSelectOptions 38 | ): VisualEditorProps { 39 | return { 40 | type: VisualEditorPropsType.select, 41 | label, 42 | options, 43 | }; 44 | } 45 | 46 | /** ------table------- */ 47 | export type VisualEditorTableOptions = { 48 | options: { 49 | label: string; 50 | field: string; // 列绑定字段 51 | }[]; 52 | showKey: string; 53 | }; 54 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 50 | 51 | 58 | -------------------------------------------------------------------------------- /src/packages/components/block-resizer/style.scss: -------------------------------------------------------------------------------- 1 | $space: 6px; 2 | $size: 6px; 3 | $primary: #409eff; 4 | 5 | .block-resize { 6 | position: absolute; 7 | top: -$space; 8 | left: -$space; 9 | right: -$space; 10 | bottom: -$space; 11 | width: $size; 12 | height: $size; 13 | background-color: $primary; 14 | z-index: 99; 15 | user-select: none; 16 | &.block-resize-top { 17 | left: calc(50% - #{$size / 2}); 18 | right: initial; 19 | bottom: initial; 20 | cursor: n-resize; 21 | } 22 | 23 | &.block-resize-bottom { 24 | left: calc(50% - #{$size / 2}); 25 | right: initial; 26 | top: initial; 27 | cursor: s-resize; 28 | } 29 | 30 | &.block-resize-left { 31 | top: calc(50% - #{$size / 2}); 32 | bottom: initial; 33 | right: initial; 34 | cursor: w-resize; 35 | } 36 | 37 | &.block-resize-right { 38 | top: calc(50% - #{$size / 2}); 39 | left: initial; 40 | bottom: initial; 41 | cursor: e-resize; 42 | } 43 | 44 | &.block-resize-top-left { 45 | right: initial; 46 | bottom: initial; 47 | cursor: nw-resize; 48 | } 49 | 50 | &.block-resize-top-right { 51 | left: initial; 52 | bottom: initial; 53 | cursor: ne-resize; 54 | } 55 | 56 | &.block-resize-bottom-left { 57 | top: initial; 58 | right: initial; 59 | cursor: sw-resize; 60 | } 61 | 62 | &.block-resize-bottom-right { 63 | left: initial; 64 | top: initial; 65 | cursor: se-resize; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo-visual-editor", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "core-js": "^3.6.5", 12 | "deepcopy": "^2.1.0", 13 | "element-plus": "^1.0.2-beta.28", 14 | "vue": "^3.0.0", 15 | "vue-router": "^4.0.0-0" 16 | }, 17 | "devDependencies": { 18 | "@typescript-eslint/eslint-plugin": "^2.33.0", 19 | "@typescript-eslint/parser": "^2.33.0", 20 | "@vue/cli-plugin-babel": "~4.5.0", 21 | "@vue/cli-plugin-eslint": "~4.5.0", 22 | "@vue/cli-plugin-router": "~4.5.0", 23 | "@vue/cli-plugin-typescript": "~4.5.0", 24 | "@vue/cli-service": "~4.5.0", 25 | "@vue/compiler-sfc": "^3.0.0", 26 | "@vue/eslint-config-typescript": "^5.0.2", 27 | "eslint": "^6.7.2", 28 | "eslint-plugin-vue": "^7.0.0-0", 29 | "sass": "^1.26.5", 30 | "sass-loader": "^8.0.2", 31 | "typescript": "~3.9.3" 32 | }, 33 | "eslintConfig": { 34 | "root": true, 35 | "env": { 36 | "node": true 37 | }, 38 | "extends": [ 39 | "plugin:vue/vue3-essential", 40 | "eslint:recommended", 41 | "@vue/typescript/recommended" 42 | ], 43 | "parserOptions": { 44 | "ecmaVersion": 2020 45 | }, 46 | "rules": { 47 | "no-debugger": "off", 48 | "no-unused-vars": "off", 49 | "no-unused-expressions": "off", 50 | "prefer-const":"warn", 51 | "no-empty-function":"off" 52 | } 53 | }, 54 | "browserslist": [ 55 | "> 1%", 56 | "last 2 versions", 57 | "not dead" 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /src/packages/visual.config.tsx: -------------------------------------------------------------------------------- 1 | import { createVisualEditorConfig } from "./visual-editor.utils"; 2 | import { ElButton, ElInput } from "element-plus"; 3 | import { 4 | createEditorInputProps, 5 | createEditorSelectProps, 6 | } from "./visual-editor.props"; 7 | 8 | const visualConfig = createVisualEditorConfig(); 9 | 10 | visualConfig.registry("text", { 11 | label: "文本", 12 | preview: () => "预览文本", 13 | render: () => "渲染文本", 14 | }); 15 | 16 | visualConfig.registry("button", { 17 | label: "按钮", 18 | preview: () => 按钮, 19 | render: ({ props, size }) => { 20 | return ( 21 | 29 | {props.text || "按钮"} 30 | 31 | ); 32 | }, 33 | resize: { width: true, height: true }, 34 | props: { 35 | text: createEditorInputProps("显示文本"), 36 | type: createEditorSelectProps("按钮类型", [ 37 | { label: "基础", val: "primary" }, 38 | { label: "成功", val: "success" }, 39 | { label: "警告", val: "warning" }, 40 | { label: "危险", val: "danger" }, 41 | { label: "提示", val: "info" }, 42 | { label: "文本", val: "text" }, 43 | ]), 44 | size: createEditorSelectProps("按钮大小", [ 45 | { label: "默认", val: "" }, 46 | { label: "中等", val: "medium" }, 47 | { label: "小", val: "small" }, 48 | { label: "极小", val: "mini" }, 49 | ]), 50 | }, 51 | }); 52 | 53 | visualConfig.registry("input", { 54 | label: "输入框", 55 | preview: () => , 56 | render: ({ size }) => , 57 | resize: { width: true }, 58 | }); 59 | 60 | export default visualConfig; 61 | -------------------------------------------------------------------------------- /src/packages/visual-editor.utils.ts: -------------------------------------------------------------------------------- 1 | import { VisualEditorProps } from "./visual-editor.props"; 2 | 3 | export interface VisualEditorBlockData { 4 | top: number; 5 | left: number; 6 | componentKey: string; 7 | adjustPosition: boolean; // 是否需要调整位置 8 | focus: boolean; // 是否是选中状态 9 | width: number; 10 | height: number; 11 | hasResize: boolean; // 是否调整过宽高 12 | props?: object; 13 | } 14 | 15 | export interface VisualEditorModelValue { 16 | container: { 17 | width: number; 18 | height: number; 19 | }; 20 | blocks: VisualEditorBlockData[]; 21 | } 22 | 23 | export interface VisualEditorComponent { 24 | key: string; 25 | label: string; 26 | preview: () => JSX.Element; 27 | render: (data: { 28 | size: { width?: number; height?: number }; 29 | props: any; 30 | }) => JSX.Element; 31 | resize?: { width?: boolean; height?: boolean }; 32 | props?: Record; 33 | } 34 | 35 | export function createNewBlock(data: { 36 | component: VisualEditorComponent; 37 | top: number; 38 | left: number; 39 | }): VisualEditorBlockData { 40 | return { 41 | componentKey: data.component!.key, 42 | top: data.top, 43 | left: data.left, 44 | adjustPosition: true, 45 | focus: false, 46 | width: 0, 47 | height: 0, 48 | hasResize: false, 49 | props: {}, 50 | }; 51 | } 52 | 53 | export function createVisualEditorConfig() { 54 | const componentList: VisualEditorComponent[] = []; 55 | const componentMap: Record = {}; 56 | 57 | return { 58 | componentList, 59 | componentMap, 60 | registry: (key: string, component: Omit) => { 61 | const comp = { ...component, key }; 62 | componentList.push(comp); 63 | componentMap[key] = comp; 64 | }, 65 | }; 66 | } 67 | 68 | // 配置类型 69 | export type VisualEditorConfig = ReturnType; 70 | 71 | // 辅助线的数据类型 72 | export interface VisualEditorMarkLine { 73 | x: { left: number; showLeft: number }[]; 74 | y: { top: number; showTop: number }[]; 75 | } 76 | -------------------------------------------------------------------------------- /src/packages/visual-editor-block.tsx: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, onMounted, PropType, ref } from "vue"; 2 | import { 3 | VisualEditorBlockData, 4 | VisualEditorConfig, 5 | } from "./visual-editor.utils"; 6 | 7 | import { BlockResizer } from "./components/block-resizer"; 8 | 9 | export const VisualEditorBlock = defineComponent({ 10 | props: { 11 | block: { 12 | type: Object as PropType, 13 | }, 14 | config: { 15 | type: Object as PropType, 16 | }, 17 | }, 18 | setup(props) { 19 | const el = ref({} as HTMLDivElement); 20 | const styles = computed(() => ({ 21 | top: `${props.block?.top}px`, 22 | left: `${props.block?.left}px`, 23 | })); 24 | 25 | const classes = computed(() => [ 26 | "visual-editor-block", 27 | { 28 | "visual-editor-block-focus": props.block?.focus, 29 | }, 30 | ]); 31 | 32 | onMounted(() => { 33 | // 放置block时,让组件居中,对准鼠标点 34 | const block = props.block; 35 | if (block?.adjustPosition) { 36 | const { offsetWidth, offsetHeight } = el.value; 37 | block.left -= offsetWidth / 2; 38 | block.top -= offsetHeight / 2; 39 | block.adjustPosition = false; 40 | block.width = offsetWidth; 41 | block.height = offsetHeight; 42 | } 43 | }); 44 | 45 | return () => { 46 | const component = props.config?.componentMap[props.block!.componentKey]; 47 | const { width, height } = component?.resize || {}; 48 | const renderProps = { 49 | size: props.block?.hasResize 50 | ? { 51 | width: props.block.width, 52 | height: props.block.height, 53 | } 54 | : {}, 55 | props: props.block?.props || {}, 56 | }; 57 | const Render = component?.render(renderProps); 58 | return ( 59 |
60 | {Render} 61 | {props.block?.focus && (width || height) && ( 62 | 66 | )} 67 |
68 | ); 69 | }; 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /src/lib/iconfont/iconfont.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "1221926", 3 | "name": "demo", 4 | "font_family": "iconfont", 5 | "css_prefix_text": "icon-", 6 | "description": "", 7 | "glyphs": [ 8 | { 9 | "icon_id": "13296364", 10 | "name": "close", 11 | "font_class": "close", 12 | "unicode": "e689", 13 | "unicode_decimal": 59017 14 | }, 15 | { 16 | "icon_id": "3255777", 17 | "name": "置顶", 18 | "font_class": "place-top", 19 | "unicode": "e647", 20 | "unicode_decimal": 58951 21 | }, 22 | { 23 | "icon_id": "8195933", 24 | "name": "置底", 25 | "font_class": "place-bottom", 26 | "unicode": "e606", 27 | "unicode_decimal": 58886 28 | }, 29 | { 30 | "icon_id": "13692889", 31 | "name": " menu", 32 | "font_class": "entypomenu", 33 | "unicode": "e96d", 34 | "unicode_decimal": 59757 35 | }, 36 | { 37 | "icon_id": "14995160", 38 | "name": "edit", 39 | "font_class": "edit", 40 | "unicode": "e74d", 41 | "unicode_decimal": 59213 42 | }, 43 | { 44 | "icon_id": "7712721", 45 | "name": "reset", 46 | "font_class": "reset", 47 | "unicode": "e739", 48 | "unicode_decimal": 59193 49 | }, 50 | { 51 | "icon_id": "15838489", 52 | "name": "import", 53 | "font_class": "import", 54 | "unicode": "e675", 55 | "unicode_decimal": 58997 56 | }, 57 | { 58 | "icon_id": "15838430", 59 | "name": "ashbin", 60 | "font_class": "delete", 61 | "unicode": "e665", 62 | "unicode_decimal": 58981 63 | }, 64 | { 65 | "icon_id": "15838432", 66 | "name": "browse", 67 | "font_class": "browse", 68 | "unicode": "e666", 69 | "unicode_decimal": 58982 70 | }, 71 | { 72 | "icon_id": "15838434", 73 | "name": "back", 74 | "font_class": "back", 75 | "unicode": "e667", 76 | "unicode_decimal": 58983 77 | }, 78 | { 79 | "icon_id": "15838468", 80 | "name": "export", 81 | "font_class": "export", 82 | "unicode": "e66e", 83 | "unicode_decimal": 58990 84 | }, 85 | { 86 | "icon_id": "15838488", 87 | "name": "forward", 88 | "font_class": "forward", 89 | "unicode": "e671", 90 | "unicode_decimal": 58993 91 | } 92 | ] 93 | } 94 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 45 | 46 | 47 | 63 | -------------------------------------------------------------------------------- /src/lib/iconfont/iconfont.css: -------------------------------------------------------------------------------- 1 | @font-face {font-family: "iconfont"; 2 | src: url('iconfont.eot?t=1610438286453'); /* IE9 */ 3 | src: url('iconfont.eot?t=1610438286453#iefix') format('embedded-opentype'), /* IE6-IE8 */ 4 | url('data:application/x-font-woff2;charset=utf-8;base64,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') format('woff2'), 5 | url('iconfont.woff?t=1610438286453') format('woff'), 6 | url('iconfont.ttf?t=1610438286453') format('truetype'), /* chrome, firefox, opera, Safari, Android, iOS 4.2+ */ 7 | url('iconfont.svg?t=1610438286453#iconfont') format('svg'); /* iOS 4.1- */ 8 | } 9 | 10 | .iconfont { 11 | font-family: "iconfont" !important; 12 | font-size: 16px; 13 | font-style: normal; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | .icon-close:before { 19 | content: "\e689"; 20 | } 21 | 22 | .icon-place-top:before { 23 | content: "\e647"; 24 | } 25 | 26 | .icon-place-bottom:before { 27 | content: "\e606"; 28 | } 29 | 30 | .icon-entypomenu:before { 31 | content: "\e96d"; 32 | } 33 | 34 | .icon-edit:before { 35 | content: "\e74d"; 36 | } 37 | 38 | .icon-reset:before { 39 | content: "\e739"; 40 | } 41 | 42 | .icon-import:before { 43 | content: "\e675"; 44 | } 45 | 46 | .icon-delete:before { 47 | content: "\e665"; 48 | } 49 | 50 | .icon-browse:before { 51 | content: "\e666"; 52 | } 53 | 54 | .icon-back:before { 55 | content: "\e667"; 56 | } 57 | 58 | .icon-export:before { 59 | content: "\e66e"; 60 | } 61 | 62 | .icon-forward:before { 63 | content: "\e671"; 64 | } 65 | 66 | -------------------------------------------------------------------------------- /src/packages/visual-editor.scss: -------------------------------------------------------------------------------- 1 | @import "../lib/iconfont/iconfont.css"; 2 | 3 | $menuSize: 355px; // 菜单列表宽度 4 | $headSize: 60px; // 顶部操作栏高度 5 | $operatorSize: 275px; // 右侧编辑宽度 6 | 7 | $ibc: #dcdfe6; // 边框 8 | $ibl: #ebeef5; // 边框 轻 9 | $itc: #314659; // 字体颜色 10 | $icc: rgba(0, 0, 0, 0.45); // 图标颜色 11 | $boxShadowColor: #f0f1f2; 12 | 13 | $primary: #409eff; 14 | 15 | .visual-editor { 16 | position: fixed; 17 | top: 20px; 18 | bottom: 20px; 19 | left: 20px; 20 | right: 20px; 21 | background-color: white; 22 | &::before { 23 | position: fixed; 24 | top: 0; 25 | bottom: 0; 26 | left: 0; 27 | right: 0; 28 | background-color: rgba($color: black, $alpha: 0.1); 29 | content: ""; 30 | } 31 | & > .menu { 32 | position: absolute; 33 | width: $menuSize; 34 | top: 0; 35 | left: 0; 36 | bottom: 0; 37 | background-color: white; 38 | .menu-item { 39 | position: relative; 40 | text-align: center; 41 | width: calc(100% - 20px); 42 | margin-left: 10px; 43 | border: 2px solid $ibl; 44 | margin-top: 20px; 45 | min-height: 80px; 46 | display: flex; 47 | align-items: center; 48 | justify-content: center; 49 | padding: 10px 20px; 50 | box-sizing: border-box; 51 | &::after { 52 | position: absolute; 53 | top: 0; 54 | left: 0; 55 | right: 0; 56 | bottom: 0; 57 | z-index: 2; 58 | content: ""; 59 | } 60 | &:hover { 61 | border-color: $primary; 62 | cursor: move; 63 | } 64 | &-label { 65 | position: absolute; 66 | top: 0; 67 | left: 0; 68 | background-color: $primary; 69 | color: white; 70 | padding: 4px 8px; 71 | z-index: 1; 72 | font-size: 14px; 73 | } 74 | } 75 | } 76 | & > .head { 77 | position: absolute; 78 | top: 0; 79 | left: $menuSize; 80 | right: $operatorSize; 81 | height: $headSize; 82 | display: flex; 83 | align-items: center; 84 | justify-content: center; 85 | z-index: 2; 86 | .head-btn { 87 | display: flex; 88 | flex-direction: column; 89 | align-items: center; 90 | justify-content: center; 91 | background-color: rgba($color: black, $alpha: 0.25); 92 | color: white; 93 | height: 55px; 94 | width: 55px; 95 | margin-right: 1px; 96 | cursor: pointer; 97 | i { 98 | font-size: 20px; 99 | } 100 | span { 101 | font-size: 12px; 102 | } 103 | &:first-child { 104 | border-top-left-radius: 4px; 105 | border-bottom-left-radius: 4px; 106 | } 107 | &:last-child { 108 | border-top-right-radius: 4px; 109 | border-bottom-right-radius: 4px; 110 | } 111 | &:hover { 112 | background-color: gray; 113 | } 114 | } 115 | } 116 | & > .operator { 117 | position: absolute; 118 | width: $operatorSize; 119 | top: 0; 120 | right: 0; 121 | bottom: 0; 122 | background-color: white; 123 | padding: 20px 10px; 124 | box-sizing: border-box; 125 | .el-input, 126 | .el-select, 127 | .el-input-number { 128 | width: 100%; 129 | } 130 | } 131 | 132 | & > .body { 133 | padding-top: $headSize; 134 | padding-left: $menuSize; 135 | padding-right: $operatorSize; 136 | box-sizing: border-box; 137 | height: 100%; 138 | position: relative; 139 | // z-index: -1; 140 | .content { 141 | height: 100%; 142 | width: 100%; 143 | overflow: scroll; 144 | display: flex; 145 | justify-content: center; 146 | .container { 147 | background-color: white; 148 | flex-shrink: 0; 149 | flex-grow: 0; 150 | position: relative; 151 | .visual-editor-block { 152 | position: absolute; 153 | .el-button, 154 | .el-input { 155 | transition: none; 156 | } 157 | &::after { 158 | $space: -3px; 159 | position: absolute; 160 | top: $space; 161 | left: $space; 162 | right: $space; 163 | bottom: $space; 164 | content: ""; 165 | } 166 | &-focus { 167 | &::after { 168 | // 边框显示在伪元素上面 169 | border: 1px dashed $primary; 170 | } 171 | } 172 | } 173 | 174 | .mark-line-y { 175 | position: absolute; 176 | left: 0; 177 | right: 0; 178 | border-top: 1px dashed $primary; 179 | } 180 | .mark-line-x { 181 | position: absolute; 182 | top: 0; 183 | bottom: 0; 184 | border-left: 1px dashed $primary; 185 | } 186 | } 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/packages/visual-editor-operator.tsx: -------------------------------------------------------------------------------- 1 | import deepcopy from "deepcopy"; 2 | import { 3 | ElButton, 4 | ElColorPicker, 5 | ElForm, 6 | ElFormItem, 7 | ElInput, 8 | ElInputNumber, 9 | ElOption, 10 | ElSelect, 11 | } from "element-plus"; 12 | import { defineComponent, PropType, reactive, watch } from "vue"; 13 | import { 14 | VisualEditorProps, 15 | VisualEditorPropsType, 16 | } from "./visual-editor.props"; 17 | import { 18 | VisualEditorBlockData, 19 | VisualEditorConfig, 20 | VisualEditorModelValue, 21 | } from "./visual-editor.utils"; 22 | 23 | export const VisualOperatorEditor = defineComponent({ 24 | props: { 25 | block: { type: Object as PropType }, 26 | config: { type: Object as PropType }, 27 | dataModel: { 28 | type: Object as PropType, 29 | required: true, 30 | }, 31 | updateBlock: { 32 | type: Function as PropType< 33 | ( 34 | newBlock: VisualEditorBlockData, 35 | oldBlock: VisualEditorBlockData 36 | ) => void 37 | >, 38 | required: true, 39 | }, 40 | updateModelValue: { 41 | type: Function as PropType<(...args: any[]) => void>, 42 | required: true, 43 | }, 44 | }, 45 | 46 | setup(props) { 47 | const state = reactive({ 48 | editData: {} as any, 49 | }); 50 | 51 | const methods = { 52 | apply: () => { 53 | if (!props.block) { 54 | // 当前编辑容器属性 55 | props.updateModelValue({ 56 | ...(props.dataModel as any).value, 57 | container: state.editData, 58 | }); 59 | } else { 60 | // 当前编辑block数据属性 61 | const newBlock = state.editData; 62 | props.updateBlock(newBlock, props.block); 63 | } 64 | }, 65 | reset: () => { 66 | if (!props.block) { 67 | state.editData = deepcopy((props.dataModel as any).value.container); 68 | } else { 69 | state.editData = deepcopy(props.block); 70 | } 71 | }, 72 | }; 73 | 74 | watch( 75 | () => props.block, 76 | () => { 77 | methods.reset(); 78 | }, 79 | { 80 | immediate: true, 81 | } 82 | ); 83 | 84 | const renderEditor = (propName: string, propConfig: VisualEditorProps) => { 85 | return { 86 | [VisualEditorPropsType.input]: () => ( 87 | 88 | ), 89 | [VisualEditorPropsType.color]: () => ( 90 | 91 | ), 92 | [VisualEditorPropsType.select]: () => ( 93 | 97 | {(() => { 98 | return propConfig.options!.map((opt, i) => ( 99 | 100 | )); 101 | })()} 102 | 103 | ), 104 | }[propConfig.type](); 105 | }; 106 | 107 | return () => { 108 | let content: JSX.Element[] = []; 109 | if (!props.block) { 110 | content.push( 111 | <> 112 | 113 | 117 | 118 | 119 | 123 | 124 | 125 | ); 126 | } else { 127 | const { componentKey } = props.block; 128 | const component = props.config?.componentMap[componentKey]; 129 | 130 | if (component) { 131 | content.push( 132 | 133 | 134 | 135 | ); 136 | if (component.props) { 137 | content.push( 138 | <> 139 | {Object.entries(component.props).map( 140 | ([propName, propConfig]) => ( 141 | 146 | {renderEditor(propName, propConfig)} 147 | 148 | ) 149 | )} 150 | 151 | ); 152 | } 153 | } 154 | } 155 | return ( 156 |
157 | 158 | {content.map((el) => el)} 159 | 160 | 161 | 应用 162 | 163 | 重置 164 | 165 | 166 |
167 | ); 168 | }; 169 | }, 170 | }); 171 | -------------------------------------------------------------------------------- /src/packages/components/block-resizer/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | VisualEditorBlockData, 3 | VisualEditorComponent, 4 | VisualEditorConfig, 5 | } from "@/packages/visual-editor.utils"; 6 | import { defineComponent, PropType } from "vue"; 7 | import "./style.scss"; 8 | 9 | enum Direction { 10 | start = "start", 11 | center = "center", 12 | end = "end", 13 | } 14 | 15 | export const BlockResizer = defineComponent({ 16 | props: { 17 | block: { type: Object as PropType, required: true }, 18 | component: { 19 | type: Object as PropType, 20 | required: true, 21 | }, 22 | }, 23 | setup(props) { 24 | const { width, height } = props.component.resize || {}; 25 | 26 | const onMousedown = (() => { 27 | let data = { 28 | startX: 0, 29 | startY: 0, 30 | startWidth: 0, 31 | startHeight: 0, 32 | startLeft: 0, 33 | startTop: 0, 34 | direction: {} as { horizontal: Direction; vertical: Direction }, 35 | }; 36 | 37 | const mousemove = (e: MouseEvent) => { 38 | const { 39 | startX, 40 | startY, 41 | startWidth, 42 | startHeight, 43 | direction, 44 | startLeft, 45 | startTop, 46 | } = data; 47 | let { clientX: moveX, clientY: moveY } = e; 48 | if (direction.horizontal === Direction.center) { 49 | moveX = startX; 50 | } 51 | if (direction.vertical === Direction.center) { 52 | moveY = startY; 53 | } 54 | 55 | let durX = moveX - startX; 56 | let durY = moveY - startY; 57 | const block = props.block as VisualEditorBlockData; 58 | 59 | if (direction.vertical === Direction.start) { 60 | durY = -durY; 61 | block.top = startTop - durY; 62 | } 63 | if (direction.horizontal === Direction.start) { 64 | durX = -durX; 65 | block.left = startLeft - durX; 66 | } 67 | 68 | const width = startWidth + durX; 69 | const height = startHeight + durY; 70 | 71 | block.width = width; 72 | block.height = height; 73 | block.hasResize = true; 74 | }; 75 | 76 | const mouseup = (e: MouseEvent) => { 77 | console.log(e); 78 | document.body.removeEventListener("mousemove", mousemove); 79 | document.body.removeEventListener("mouseup", mouseup); 80 | }; 81 | const mousedown = ( 82 | e: MouseEvent, 83 | direction: { horizontal: Direction; vertical: Direction } 84 | ) => { 85 | e.stopPropagation(); 86 | document.body.addEventListener("mousemove", mousemove); 87 | document.body.addEventListener("mouseup", mouseup); 88 | data = { 89 | startX: e.clientX, 90 | startY: e.clientY, 91 | direction, 92 | startWidth: props.block.width, 93 | startHeight: props.block.height, 94 | startLeft: props.block.left, 95 | startTop: props.block.top, 96 | }; 97 | }; 98 | 99 | return mousedown; 100 | })(); 101 | 102 | return () => ( 103 | <> 104 | {height && ( 105 | <> 106 |
109 | onMousedown(e, { 110 | horizontal: Direction.center, 111 | vertical: Direction.start, 112 | }) 113 | } 114 | >
115 |
118 | onMousedown(e, { 119 | horizontal: Direction.center, 120 | vertical: Direction.end, 121 | }) 122 | } 123 | >
124 | 125 | )} 126 | 127 | {width && ( 128 | <> 129 |
132 | onMousedown(e, { 133 | horizontal: Direction.start, 134 | vertical: Direction.center, 135 | }) 136 | } 137 | >
138 |
141 | onMousedown(e, { 142 | horizontal: Direction.end, 143 | vertical: Direction.center, 144 | }) 145 | } 146 | >
147 | 148 | )} 149 | 150 | {width && height && ( 151 | <> 152 |
155 | onMousedown(e, { 156 | horizontal: Direction.start, 157 | vertical: Direction.start, 158 | }) 159 | } 160 | >
161 |
164 | onMousedown(e, { 165 | horizontal: Direction.end, 166 | vertical: Direction.start, 167 | }) 168 | } 169 | >
170 | 171 |
174 | onMousedown(e, { 175 | horizontal: Direction.start, 176 | vertical: Direction.end, 177 | }) 178 | } 179 | >
180 |
183 | onMousedown(e, { 184 | horizontal: Direction.end, 185 | vertical: Direction.end, 186 | }) 187 | } 188 | >
189 | 190 | )} 191 | 192 | ); 193 | }, 194 | }); 195 | -------------------------------------------------------------------------------- /src/lib/iconfont/iconfont.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | Created by iconfont 9 | 10 | 11 | 12 | 13 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/lib/iconfont/iconfont.js: -------------------------------------------------------------------------------- 1 | !function(t){var c,e,o,a,h,l,s='',i=(i=document.getElementsByTagName("script"))[i.length-1].getAttribute("data-injectcss");if(i&&!t.__iconfont__svg__cssinject__){t.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(t){console&&console.log(t)}}function n(){h||(h=!0,o())}c=function(){var t,c,e,o;(o=document.createElement("div")).innerHTML=s,s=null,(e=o.getElementsByTagName("svg")[0])&&(e.setAttribute("aria-hidden","true"),e.style.position="absolute",e.style.width=0,e.style.height=0,e.style.overflow="hidden",t=e,(c=document.body).firstChild?(o=t,(e=c.firstChild).parentNode.insertBefore(o,e)):c.appendChild(t))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(c,0):(e=function(){document.removeEventListener("DOMContentLoaded",e,!1),c()},document.addEventListener("DOMContentLoaded",e,!1)):document.attachEvent&&(o=c,a=t.document,h=!1,(l=function(){try{a.documentElement.doScroll("left")}catch(t){return void setTimeout(l,50)}n()})(),a.onreadystatechange=function(){"complete"==a.readyState&&(a.onreadystatechange=null,n())})}(window); -------------------------------------------------------------------------------- /src/packages/visual-editor.tsx: -------------------------------------------------------------------------------- 1 | import { computed, defineComponent, PropType, ref, reactive } from "vue"; 2 | import { useModel } from "./utils/useModel"; 3 | import { VisualEditorBlock } from "./visual-editor-block"; 4 | import "./visual-editor.scss"; 5 | import { 6 | createNewBlock, 7 | VisualEditorBlockData, 8 | VisualEditorComponent, 9 | VisualEditorConfig, 10 | VisualEditorMarkLine, 11 | VisualEditorModelValue, 12 | } from "./visual-editor.utils"; 13 | import { VisualOperatorEditor } from "./visual-editor-operator"; 14 | import deepcopy from "deepcopy"; 15 | 16 | export const VisualEditor = defineComponent({ 17 | props: { 18 | modelValue: { 19 | type: Object as PropType, 20 | require: true, 21 | }, 22 | config: { 23 | type: Object as PropType, 24 | require: true, 25 | }, 26 | }, 27 | emits: { 28 | "update:modelValue": (val?: VisualEditorModelValue) => true, 29 | }, 30 | setup(props, ctx) { 31 | // 双向数据绑定 32 | const dataModel = useModel( 33 | () => props.modelValue, 34 | (val) => ctx.emit("update:modelValue", val) 35 | ); 36 | // container样式 37 | const containerStyles = computed(() => ({ 38 | width: `${props.modelValue?.container.width}px`, 39 | height: `${props.modelValue?.container.height}px`, 40 | })); 41 | // container dom引用 42 | const containerRef = ref({} as HTMLElement); 43 | 44 | // 计算选中与未选中的block数据 45 | const focusData = computed(() => { 46 | const focus: VisualEditorBlockData[] = 47 | dataModel.value?.blocks.filter((v) => v.focus) || []; 48 | const unfocus: VisualEditorBlockData[] = 49 | dataModel.value?.blocks.filter((v) => !v.focus) || []; 50 | return { 51 | focus, // 此时选中的数据 52 | unfocus, // 此时未选中的数据 53 | }; 54 | }); 55 | 56 | // 对外暴露的一些方法 57 | const methods = { 58 | clearFocus: (block?: VisualEditorBlockData) => { 59 | let blocks = dataModel.value?.blocks || []; 60 | if (blocks.length === 0) return; 61 | 62 | if (block) { 63 | blocks = blocks.filter((v) => v !== block); 64 | } 65 | blocks.forEach((block) => (block.focus = false)); 66 | }, 67 | updateBlocks: (blocks: VisualEditorBlockData[]) => { 68 | dataModel.value!.blocks = blocks; 69 | }, 70 | }; 71 | 72 | // 处理菜单拖拽进容器 73 | const menuDragger = (() => { 74 | let component = null as null | VisualEditorComponent; 75 | 76 | const containerHandler = { 77 | /** 78 | * 拖拽组件进入容器,设置鼠标可放置状态 79 | */ 80 | dragenter: (e: DragEvent) => { 81 | e.dataTransfer!.dropEffect = "move"; 82 | }, 83 | dragover: (e: DragEvent) => { 84 | e.preventDefault(); 85 | }, 86 | /** 87 | * 拖拽组件离开容器,设置鼠标禁用状态 88 | */ 89 | dragleave: (e: DragEvent) => { 90 | e.dataTransfer!.dropEffect = "none"; 91 | }, 92 | /** 93 | * 在容器中放置组件 94 | */ 95 | drop: (e: DragEvent) => { 96 | console.log("drop", component); 97 | const blocks = dataModel.value?.blocks || []; 98 | blocks.push( 99 | createNewBlock({ 100 | component: component!, 101 | top: e.offsetY, 102 | left: e.offsetX, 103 | }) 104 | ); 105 | console.log("x", e.offsetX); 106 | console.log("y", e.offsetY); 107 | dataModel.value = { 108 | ...dataModel.value, 109 | blocks, 110 | } as VisualEditorModelValue; 111 | }, 112 | }; 113 | 114 | const blockHandler = { 115 | dragstart: (e: DragEvent, current: VisualEditorComponent) => { 116 | containerRef.value.addEventListener( 117 | "dragenter", 118 | containerHandler.dragenter 119 | ); 120 | containerRef.value.addEventListener( 121 | "dragover", 122 | containerHandler.dragover 123 | ); 124 | containerRef.value.addEventListener( 125 | "dragleave", 126 | containerHandler.dragleave 127 | ); 128 | containerRef.value.addEventListener("drop", containerHandler.drop); 129 | component = current; 130 | }, 131 | dragend: (e: DragEvent) => { 132 | containerRef.value.removeEventListener( 133 | "dragenter", 134 | containerHandler.dragenter 135 | ); 136 | containerRef.value.removeEventListener( 137 | "dragover", 138 | containerHandler.dragover 139 | ); 140 | containerRef.value.removeEventListener( 141 | "dragleave", 142 | containerHandler.dragleave 143 | ); 144 | containerRef.value.removeEventListener("drop", containerHandler.drop); 145 | component = null; 146 | }, 147 | }; 148 | 149 | return blockHandler; 150 | })(); 151 | 152 | // 当前选中的block 153 | const state = reactive({ 154 | selectBlock: null as null | VisualEditorBlockData, 155 | }); 156 | 157 | // 处理组件在画布上他拖拽 158 | const blockDragger = (() => { 159 | let dragState = { 160 | startX: 0, 161 | startY: 0, 162 | startPos: [] as { left: number; top: number }[], 163 | 164 | startLeft: 0, 165 | startTop: 0, 166 | markLines: {} as VisualEditorMarkLine, 167 | }; 168 | 169 | // 用于视图展示的辅助线 170 | const mark = reactive({ 171 | x: null as null | number, 172 | y: null as null | number, 173 | }); 174 | 175 | const mousemove = (e: MouseEvent) => { 176 | let { clientX: moveX, clientY: moveY } = e; 177 | 178 | const { startX, startY } = dragState; 179 | 180 | // 按下shift键时,组件只能横向或纵向移动 181 | if (e.shiftKey) { 182 | // 当鼠标横向移动的距离 大于 纵向移动的距离,将纵向的偏移置为0 183 | if (Math.abs(e.clientX - startX) > Math.abs(e.clientY - startY)) { 184 | moveY = startY; 185 | } else { 186 | moveX = startX; 187 | } 188 | } 189 | 190 | const currentLeft = dragState.startLeft + moveX - startX; 191 | const currentTop = dragState.startTop + moveY - startY; 192 | const currentMark = { 193 | x: null as null | number, 194 | y: null as null | number, 195 | }; 196 | 197 | for (let i = 0; i < dragState.markLines.y.length; i++) { 198 | const { top, showTop } = dragState.markLines.y[i]; 199 | if (Math.abs(top - currentTop) < 5) { 200 | moveY = top + startY - dragState.startTop; 201 | currentMark.y = showTop; 202 | break; 203 | } 204 | } 205 | 206 | for (let i = 0; i < dragState.markLines.x.length; i++) { 207 | const { left, showLeft } = dragState.markLines.x[i]; 208 | if (Math.abs(left - currentLeft) < 5) { 209 | moveX = left + startX - dragState.startLeft; 210 | currentMark.x = showLeft; 211 | break; 212 | } 213 | } 214 | 215 | const durY = moveY - startY; 216 | const durX = moveX - startX; 217 | 218 | focusData.value.focus.forEach((block, i) => { 219 | block.top = dragState.startPos[i].top + durY; 220 | block.left = dragState.startPos[i].left + durX; 221 | }); 222 | 223 | mark.x = currentMark.x; 224 | mark.y = currentMark.y; 225 | }; 226 | const mouseup = (e: MouseEvent) => { 227 | document.removeEventListener("mousemove", mousemove); 228 | document.removeEventListener("mouseup", mouseup); 229 | }; 230 | 231 | const mousedown = (e: MouseEvent) => { 232 | dragState = { 233 | startX: e.clientX, 234 | startY: e.clientY, 235 | startPos: focusData.value.focus.map(({ top, left }) => ({ 236 | top, 237 | left, 238 | })), 239 | startTop: state.selectBlock!.top, 240 | startLeft: state.selectBlock!.left, 241 | markLines: (() => { 242 | const { focus, unfocus } = focusData.value; 243 | // 当前选中的block 244 | const { top, left, width, height } = state.selectBlock!; 245 | let lines = { x: [], y: [] } as VisualEditorMarkLine; 246 | unfocus.forEach((block) => { 247 | const { top: t, left: l, width: w, height: h } = block; 248 | 249 | // y轴对齐方式 250 | lines.y.push({ top: t, showTop: t }); // 顶对顶 251 | lines.y.push({ top: t + h, showTop: t + h }); // 底对底 252 | lines.y.push({ top: t + h / 2 - height / 2, showTop: t + h / 2 }); // 中对中 253 | lines.y.push({ top: t - height, showTop: t }); // 顶对底 254 | lines.y.push({ top: t + h - height, showTop: t + h }); // 255 | 256 | // x轴对齐方式 257 | lines.x.push({ left: l, showLeft: l }); // 顶对顶 258 | lines.x.push({ left: l + w, showLeft: l + w }); // 底对底 259 | lines.x.push({ 260 | left: l + w / 2 - width / 2, 261 | showLeft: l + w / 2, 262 | }); // 中对中 263 | lines.x.push({ left: l - width, showLeft: l }); // 顶对底 264 | lines.x.push({ left: l + w - width, showLeft: l + w }); // 中对中 265 | }); 266 | 267 | return lines; 268 | })(), 269 | }; 270 | document.addEventListener("mousemove", mousemove); 271 | document.addEventListener("mouseup", mouseup); 272 | }; 273 | 274 | return { mousedown, mark }; 275 | })(); 276 | 277 | // 处理组件的选中状态 278 | const focusHandler = (() => { 279 | return { 280 | container: { 281 | onMousedown: (e: MouseEvent) => { 282 | e.stopPropagation(); 283 | methods.clearFocus(); 284 | state.selectBlock = null; 285 | }, 286 | }, 287 | block: { 288 | onMousedown: (e: MouseEvent, block: VisualEditorBlockData) => { 289 | e.stopPropagation(); 290 | // e.preventDefault(); 291 | // 只有元素未选中状态下, 才去处理 292 | if (!block.focus) { 293 | if (!e.shiftKey) { 294 | block.focus = !block.focus; 295 | methods.clearFocus(block); 296 | } else { 297 | block.focus = true; 298 | } 299 | } 300 | state.selectBlock = block; 301 | // 处理组件的选中移动 302 | blockDragger.mousedown(e); 303 | }, 304 | }, 305 | }; 306 | })(); 307 | 308 | const toolButtons = [ 309 | { 310 | label: "撤销", 311 | icon: "icon-back", 312 | tip: "ctrl+z", 313 | }, 314 | { 315 | label: "重做", 316 | icon: "icon-forward", 317 | tip: "ctrl+y, ctrl+shift+z", 318 | }, 319 | { 320 | label: "删除", 321 | icon: "icon-delete", 322 | handler: () => { 323 | // 删除选中状态的 block 324 | dataModel.value!.blocks = [ 325 | ...focusData.value.unfocus, 326 | ] as VisualEditorBlockData[]; 327 | }, 328 | tip: "ctrl+d, backspance, delete,", 329 | }, 330 | ]; 331 | 332 | // 更新block属性 333 | const updateBlockProps = ( 334 | newBlock: VisualEditorBlockData, 335 | oldBlock: VisualEditorBlockData 336 | ) => { 337 | const blocks = [...dataModel.value!.blocks]; 338 | const index = dataModel.value!.blocks.indexOf(state.selectBlock!); 339 | if (index > -1) { 340 | blocks.splice(index, 1, newBlock); 341 | dataModel.value!.blocks = deepcopy(blocks); 342 | state.selectBlock = dataModel.value!.blocks[index]; 343 | } 344 | }; 345 | 346 | // 更新容器属性值 347 | const updateModelValue = (newVal: VisualEditorModelValue) => { 348 | props.modelValue!.container = { ...newVal.container }; 349 | }; 350 | 351 | return () => ( 352 |
353 | 366 |
367 | {toolButtons.map((btn, index) => ( 368 |
369 | 370 | {btn.label} 371 |
372 | ))} 373 |
374 |
375 |
376 |
382 | {(dataModel.value?.blocks || []).map((block, index: number) => ( 383 | 389 | focusHandler.block.onMousedown(e, block), 390 | }} 391 | /> 392 | ))} 393 | {blockDragger.mark.x && ( 394 |
398 | )} 399 | {blockDragger.mark.y && ( 400 |
404 | )} 405 |
406 |
407 |
408 | 415 |
416 | ); 417 | }, 418 | }); 419 | --------------------------------------------------------------------------------