├── .browserslistrc ├── .editorconfig ├── .env ├── .eslintrc.js ├── .github └── workflows │ └── workflow.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── docs ├── .vuepress │ ├── .gitignore │ └── config.js ├── README.md ├── api │ ├── blueprint_component.md │ ├── canvas_component.md │ ├── craft_config.md │ ├── editor.md │ ├── editor_component.md │ ├── frame_component.md │ ├── node.md │ └── setting_mixin.md └── guide │ ├── README.md │ ├── installation.md │ └── tutorial.md ├── jest.config.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html └── preview.png ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── Blueprint.vue │ ├── Canvas.vue │ ├── Editor.vue │ ├── Frame.vue │ ├── HelloWorld.vue │ ├── Indicator.vue │ ├── Node.vue │ └── settingMixin.js ├── core │ ├── Editor.js │ ├── Indicator.js │ ├── Node.js │ └── services │ │ └── NodeService.js ├── example │ ├── App.vue │ ├── app.scss │ ├── components │ │ ├── ElementBlock.vue │ │ ├── ElementSidebar.vue │ │ ├── Navbar.vue │ │ ├── Paper.vue │ │ ├── Preview.vue │ │ ├── SettingSidebar.vue │ │ └── elements │ │ │ ├── Carousel.vue │ │ │ ├── CarouselSetting.vue │ │ │ ├── Container.vue │ │ │ ├── Heading.vue │ │ │ ├── HeadingSetting.vue │ │ │ ├── Paragraph.vue │ │ │ ├── Picture.vue │ │ │ ├── PictureSetting.vue │ │ │ ├── assets │ │ │ ├── default_carousel_image.jpg │ │ │ ├── default_carousel_image2.jpg │ │ │ └── default_picture_image.jpg │ │ │ ├── elementStyleMixin.js │ │ │ ├── styleSettings │ │ │ ├── Alignment.vue │ │ │ ├── Background.vue │ │ │ ├── Decoration.vue │ │ │ ├── Dimensions.vue │ │ │ ├── Margin.vue │ │ │ ├── Padding.vue │ │ │ ├── Typography.vue │ │ │ └── mixin.js │ │ │ └── utils │ │ │ └── Editor.vue │ └── demoData.js ├── index.js ├── main.js └── utils │ └── createNodeFromVNode.js ├── tests └── unit │ ├── components │ ├── Blueprint.spec.js │ ├── Canvas.spec.js │ ├── Editor.spec.js │ ├── Frame.spec.js │ ├── Indicator.spec.js │ └── Node.spec.js │ ├── core │ ├── Editor.spec.js │ ├── Indicator.spec.js │ ├── Node.spec.js │ └── services │ │ └── NodeService.spec.js │ ├── example.spec.js │ ├── helpers │ └── index.js │ └── utils │ └── createNodeFromVNode.spec.js └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | VUE_APP_GA= 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: [ 7 | 'plugin:vue/essential', 8 | '@vue/airbnb', 9 | ], 10 | parserOptions: { 11 | parser: 'babel-eslint', 12 | }, 13 | rules: { 14 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 15 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 16 | }, 17 | overrides: [ 18 | { 19 | files: [ 20 | '**/__tests__/*.{j,t}s?(x)', 21 | '**/tests/unit/**/*.spec.{j,t}s?(x)', 22 | ], 23 | env: { 24 | jest: true, 25 | }, 26 | }, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Main workflow 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Use Node.js 12 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 12 17 | - name: Install Dependencies 18 | run: npm ci 19 | - name: Run eslint 20 | run: npm run lint 21 | 22 | test: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Use Node.js 12 28 | uses: actions/setup-node@v1 29 | with: 30 | node-version: 12 31 | - name: Install Dependencies 32 | run: npm ci 33 | - name: Run unit test 34 | run: npm run test:unit 35 | - name: Upload coverage 36 | run: bash <(curl -s https://codecov.io/bash) 37 | 38 | build: 39 | if: github.ref == 'refs/heads/master' 40 | runs-on: ubuntu-latest 41 | needs: [lint, test] 42 | env: 43 | VUE_APP_GA: ${{secrets.google_analytics_id}} 44 | 45 | steps: 46 | - uses: actions/checkout@v2 47 | - name: Use Node.js 12 48 | uses: actions/setup-node@v1 49 | with: 50 | node-version: 12 51 | - name: Install Dependencies 52 | run: npm ci 53 | - name: Build demo site 54 | run: npm run build 55 | - name: Upload demo site 56 | uses: actions/upload-artifact@v1 57 | with: 58 | name: demo-site 59 | path: dist 60 | - name: Build docs 61 | run: npm run docs:build 62 | - name: Upload docs 63 | uses: actions/upload-artifact@v1 64 | with: 65 | name: docs 66 | path: docs/.vuepress/dist 67 | 68 | deploy: 69 | if: github.ref == 'refs/heads/master' 70 | runs-on: ubuntu-latest 71 | needs: build 72 | env: 73 | DEPLOY_TOKEN: ${{secrets.deploy_token}} 74 | USER_NAME: yoychen 75 | USER_EMAIL: yui12327@gmail.com 76 | PUBLISH_DIR: ./demo-site 77 | 78 | steps: 79 | - name: Download demo site 80 | uses: actions/download-artifact@v1 81 | with: 82 | name: demo-site 83 | - name: Download docs 84 | uses: actions/download-artifact@v1 85 | with: 86 | name: docs 87 | - name: Deploy demo site 88 | run: | 89 | mv docs $PUBLISH_DIR 90 | cd $PUBLISH_DIR 91 | git init 92 | git config --local user.email $USER_EMAIL 93 | git config --local user.name $USER_NAME 94 | git remote add origin https://$DEPLOY_TOKEN@github.com/$GITHUB_REPOSITORY.git 95 | git checkout -b gh-pages 96 | git add --all 97 | git commit -m "Deploy to GitHub Pages" 98 | git push origin gh-pages -f 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /coverage 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 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Project setup 4 | ``` 5 | npm install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | npm run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | npm run build 16 | ``` 17 | 18 | ### Run your unit tests 19 | ``` 20 | npm run test:unit 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | npm run lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Yong-Yuan Chen 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # V-Craft 2 | 3 | ![Main workflow](https://github.com/yoychen/v-craft/workflows/Main%20workflow/badge.svg) 4 | [![codecov](https://codecov.io/gh/yoychen/v-craft/branch/master/graph/badge.svg)](https://codecov.io/gh/yoychen/v-craft) 5 | 6 | V-Craft (inspired by [Craft.js](https://craft.js.org/)) is a toolset for building extensible page builders with Vue.js. Instead of a complete page builder with a user interface and designed element blocks out of the box, V-Craft only provides the essentials of the page builder. With V-Craft, you will be able to focus on the specifications and use cases of your page builder project, and build your page builder perfectly fit on your needs. 7 | 8 | [![](./public/preview.png)](https://yoychen.github.io/v-craft/) 9 | 10 | ## Features 11 | 12 | - Easily wrap your Vue.js components into page elements 13 | - Build-in drag-n-drop system 14 | - Control how page elements are edited yourself 15 | - Manipulatable and serializable editor state 16 | 17 | ## Installation 18 | 19 | ### npm 20 | 21 | ```bash 22 | npm install @v-craft/core --save 23 | ``` 24 | 25 | ## Documentation 26 | 27 | Read the [documentation](https://yoychen.github.io/v-craft/docs). 28 | 29 | ## Contributing 30 | 31 | All contributions are welcome. For more details, please see [CONTRIBUTING](./CONTRIBUTING.md). 32 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset', 4 | ], 5 | }; 6 | -------------------------------------------------------------------------------- /docs/.vuepress/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | -------------------------------------------------------------------------------- /docs/.vuepress/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | base: '/v-craft/docs/', 3 | title: 'V-Craft', 4 | themeConfig: { 5 | sidebarDepth: 2, 6 | sidebar: [ 7 | { 8 | title: 'Guide', 9 | collapsable: false, 10 | children: [ 11 | '/guide/', 12 | '/guide/installation', 13 | '/guide/tutorial', 14 | ], 15 | }, 16 | { 17 | title: 'API Reference', 18 | collapsable: false, 19 | children: [ 20 | '/api/editor_component', 21 | '/api/frame_component', 22 | '/api/canvas_component', 23 | '/api/blueprint_component', 24 | '/api/craft_config', 25 | '/api/setting_mixin', 26 | '/api/editor', 27 | '/api/node', 28 | ], 29 | }, 30 | ], 31 | }, 32 | markdown: { 33 | lineNumbers: true, 34 | }, 35 | plugins: [ 36 | [ 37 | '@vuepress/google-analytics', 38 | { 39 | 'ga': process.env.VUE_APP_GA, 40 | }, 41 | ], 42 | ], 43 | } 44 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | heroImage: null 4 | heroText: V-Craft 5 | tagline: A Toolset for Building Extensible Page Builders with Vue.js (inspired by Craft.js) 6 | actionText: Get Started → 7 | actionLink: /guide/ 8 | features: 9 | - title: Simple 10 | details: Build-in drag-n-drop system and simple components and API. 11 | - title: Extensible 12 | details: Easily wrap your Vue.js components into page elements. 13 | - title: Flexible 14 | details: Control how page elements are edited yourself. 15 | footer: MIT Licensed 16 | --- 17 | -------------------------------------------------------------------------------- /docs/api/blueprint_component.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | It is used to define a composition of the page elements. When the end-user drags it, it will create the composition we defined and insert to the point at which the end-user drop. 4 | 5 | ## Props 6 | 7 | ### component 8 | 9 | * type: `string | Object (component’s options object)` 10 | 11 | Specify the component or HTML tag to display. 12 | 13 | ## Slots 14 | 15 | ### blueprint 16 | 17 | ::: warning 18 | it only allows one immediate child. 19 | ::: 20 | 21 | It is used to define a composition of the page elements. 22 | -------------------------------------------------------------------------------- /docs/api/canvas_component.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | It defines the droppable region in the editable area of your page builder. the child page elements which in its default slots will be draggable. 4 | 5 | ## Props 6 | 7 | ### component 8 | 9 | * type: `string` 10 | 11 | Specify the page container which is defined in the resolverMap prop of `` to display. 12 | -------------------------------------------------------------------------------- /docs/api/craft_config.md: -------------------------------------------------------------------------------- 1 | # Craft Config 2 | 3 | A property in the Vue.js component's option for configuring the page element. 4 | 5 | ## Properties 6 | 7 | ### defaultProps? 8 | 9 | * type: `Object` 10 | 11 | Define the default values for the props of the page element. 12 | 13 | ### settings? 14 | 15 | * type: `Object` 16 | 17 | A map of the setting components that will be used to edit the page element's props. 18 | 19 | ### rules? 20 | 21 | * type: `Object` 22 | * canDrag? `(currentNode: Node) => boolean` 23 | * Used to specify if the page element is draggable. 24 | * canMoveIn? `(incomingNode: Node, currentNode: Node) => boolean` 25 | * Available for page container. Used to specify if the incoming page element can be dragged into the current page container. 26 | * canMoveOut? `(outgoingNode: Node, currentNode: Node) => boolean` 27 | * Available for page container. Used to specify if the outgoing page element can be dragged out of the current page container. You should be aware that it only applies to the immediate children of the current page container. 28 | 29 | ### addition? 30 | 31 | * type: `Object` 32 | 33 | Define the additional properties that will not be injected into the page element's props. 34 | -------------------------------------------------------------------------------- /docs/api/editor.md: -------------------------------------------------------------------------------- 1 | # Editor 2 | 3 | A object in the `` for maintaining the editor's context. 4 | 5 | ## Properties 6 | 7 | ### enabled 8 | 9 | * type: `boolean` 10 | 11 | The editor state. 12 | 13 | ### selectedNode 14 | 15 | * type: `Node` 16 | 17 | The current selected page element. 18 | 19 | ## Methods 20 | 21 | ### enable 22 | 23 | * type: `() => void` 24 | 25 | Set the editor state to enabled. 26 | 27 | ### disable 28 | 29 | * type: `() => void` 30 | 31 | Set the editor state to disabled. 32 | 33 | ### selectNode 34 | 35 | * type: `(node: Node) => void` 36 | 37 | Set the current selected page element. 38 | 39 | ### getCraftConfig 40 | 41 | * type: `(node: Node) => Object` 42 | 43 | Get the craft config of the inputted page element. 44 | 45 | ### getSettings 46 | 47 | * type: `(node: Node) => Object` 48 | 49 | Get the setting components of the inputted page element. 50 | 51 | ### export 52 | 53 | * type: `() => string` 54 | 55 | Serialize the editor's context to JSON. 56 | 57 | ### import 58 | 59 | * type: `(plainEditorData: string) => void` 60 | 61 | Deserialize the inputted JSON to the editor's context. 62 | -------------------------------------------------------------------------------- /docs/api/editor_component.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | The root component of the page editor, response to create and maintain the editor state. 4 | 5 | ## Props 6 | 7 | ### component 8 | 9 | * type: `string | Object (component’s options object)` 10 | 11 | Specify the component or HTML tag to display. 12 | 13 | ### resolverMap 14 | 15 | * type: `Object` 16 | 17 | A map of page elements that will be used in the editor. 18 | 19 | ### import? 20 | 21 | * type: `string (editor state JSON)` 22 | 23 | Optional. Used to set the initial content of the editable area (``). 24 | -------------------------------------------------------------------------------- /docs/api/frame_component.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | It defines the editable area of your page builder. The content which is rendered is based on the editor state. 4 | 5 | ## Props 6 | 7 | ### component 8 | 9 | * type: `string | Object (component’s options object)` 10 | 11 | Specify the component or HTML tag to display. 12 | 13 | ## Slots 14 | 15 | ### default 16 | 17 | It is used to define the initial content of the editable area when the initial editor state is empty. 18 | -------------------------------------------------------------------------------- /docs/api/node.md: -------------------------------------------------------------------------------- 1 | # Node 2 | 3 | A object for maintaining the page element's context. 4 | 5 | ## Properties 6 | 7 | ### componentName 8 | 9 | * type: `string` 10 | 11 | The page element's name. It should also exist in the `resolverMap` prop of `` for consistency. 12 | 13 | ### props 14 | 15 | * type: `Object` 16 | 17 | The page element's props. 18 | 19 | ### parent 20 | 21 | * type: `Node` 22 | 23 | The page element's parent page element. 24 | 25 | ### children 26 | 27 | * type: `Array` 28 | 29 | The page element's child page elements. 30 | 31 | ### addition 32 | 33 | * type: `Object` 34 | 35 | The page element's additional properties. 36 | 37 | ### uuid 38 | 39 | * type: `string` 40 | 41 | The page element's unique identifier. 42 | 43 | ## Methods 44 | 45 | ### setProps 46 | 47 | * type: `(change: Object) => void` 48 | 49 | A setter of the Node's props. 50 | 51 | ### isDroppable 52 | 53 | * type: `(incommingNode: Node) => boolean` 54 | 55 | Determine if the incomming page element can be dragged into itself. 56 | 57 | ### isDraggable 58 | 59 | * type: `() => boolean` 60 | 61 | Determine if the page element is draggable. 62 | 63 | ### duplicate 64 | 65 | * type: `() => Node` 66 | 67 | Deep clone current node and its children. it will return a new node instance with a different UUID. 68 | -------------------------------------------------------------------------------- /docs/api/setting_mixin.md: -------------------------------------------------------------------------------- 1 | # settingMixin 2 | 3 | A Vue.js mixin for defining the page element's setting component. 4 | 5 | ## Computed properties 6 | 7 | ### elementProps 8 | 9 | * type: `Object` 10 | 11 | The props of the target page element. 12 | 13 | ### elementPropsSetter 14 | 15 | * type: `(change: Object) => void` 16 | 17 | A setter of the props of the target page element. 18 | 19 | ## Props 20 | 21 | ### node 22 | 23 | * type: `Node` 24 | 25 | The `Node` object of the target page element. 26 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | V-Craft (inspired by [Craft.js](https://craft.js.org/)) is a toolset for building extensible page builders with Vue.js. Instead of a complete page builder with a user interface and designed element blocks out of the box, V-Craft only provides the essentials of the page builder. With V-Craft, you will be able to focus on the specifications and use cases of your page builder project, and build your page builder perfectly fit on your needs. 4 | 5 | ## Features 6 | 7 | - Easily wrap your Vue.js components into page elements 8 | - Build-in drag-n-drop system 9 | - Control how page elements are edited yourself 10 | - Manipulatable and serializable editor state 11 | -------------------------------------------------------------------------------- /docs/guide/installation.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | ### npm 4 | 5 | ```bash 6 | npm install @v-craft/core --save 7 | ``` 8 | -------------------------------------------------------------------------------- /docs/guide/tutorial.md: -------------------------------------------------------------------------------- 1 | # Tutorial 2 | 3 | In this tutorial, we will build a simple page builder step-by-step with Bootstrap CSS framework. 4 | 5 | ## Page element 6 | 7 | Page element is just a Vue.js component that will be displayed to our end user in the preview panel, the user will able to edit/create/move it around the preview panel. 8 | 9 | ```html 10 | 11 | 12 | 15 | 16 | 30 | ``` 31 | 32 | ## Page container 33 | 34 | Page container is a page element also, and it can allow our end user to drag page elements into its default slots. 35 | 36 | 37 | ```html 38 | 39 | 40 | 45 | 46 | 52 | ``` 53 | 54 | ## Basic editor 55 | 56 | The following is the simple composition of the page builder, The app must be wrapped with `` from V-Craft. The editor state will be managed and provided from ``. We use Bootstrap's grid system to simply present the simple page builder layout, which contains a preview panel and a setting panel. 57 | 58 | ### Setup 59 | 60 | - All page elements that we defined should be passed into `resolverMap` prop 61 | - `` is responsible for rendering the page preview. you can pass page elements into its default slots, and it will be used to the default content of the page preview. 62 | - If you want to present the page container, you should use `` and put the page container name in its props. `` will create a droppable region where its immediate children are draggable. 63 | 64 | 65 | ```html{5,11,12} 66 | 67 | 68 | 86 | 87 | 106 | ``` 107 | 108 | ## Setting component 109 | 110 | To edit the page element, we introduce the setting component interface that the developer can construct each page element's setting components with a consistent specification. 111 | 112 | For example, component `` is ``'s setting component. It uses a mixin `settingMixin` from V-Craft, the mixin will provide `elementProps` and `elementPropsSetter`. You can use them with any form input element to describe how to amend the page element. 113 | 114 | ```html 115 | 116 | 117 | 122 | 123 | 145 | ``` 146 | 147 | Let's use the config `craft.settings` to declare `` is one of the setting components of ``. 148 | 149 | ```html{8,18,19,20} 150 | 151 | 152 | 155 | 156 | 173 | ``` 174 | 175 | Now, we would like to show the setting components of the currently selected element in our editor. As the following code, relying on Vue's [Provide/Inject API](https://vuejs.org/v2/api/#provide-inject) we can use the injected value `editor` to access the ``'s internal context in ``, so we can use `editor.selectedNode` and `editor.getSettings()` to get the setting components of the currently selected element. 176 | 177 | ```html 178 | 179 | 180 | 192 | 193 | 212 | ``` 213 | 214 | Display `` in our editor. 215 | 216 | ```html{8,26,31} 217 | 218 | 219 | 237 | 238 | 258 | ``` 259 | 260 | 261 | ## Delete page element 262 | 263 | To delete the page element that the end-user selected, we can use the method `editor.removeNode()`. 264 | 265 | ```html{13,35-37} 266 | 267 | 268 | 281 | 282 | 306 | ``` 307 | 308 | ## Create new page element 309 | 310 | Until this point, we have built a page builder where our end-user can drag page elements around and edit the page element's parameter. But, we are missing an important feature - creating a new page element by dragging. 311 | 312 | V-Craft provides us with the component ``, which can be used to define a composition of the page elements. When the end-user drags it, it will create the composition we defined and insert to the point at which the end-user drop. 313 | 314 | ```html{9-22,33,39} 315 | 316 | 317 | 345 | 346 | 358 | ``` 359 | 360 | ## Retrieve and manipulate editor state 361 | 362 | We can get the editor's state from the injected value `editor.enabled`, and toggle it by using `editor.enable()` and `editor.disable()` methods. When the editor's state is disabled, all page elements in `` can not be dragged and selected, nor can they be edited. 363 | 364 | ```html{5-14,22-24,28-34} 365 | 366 | 367 | 383 | 384 | 402 | ``` 403 | 404 | ## Export / Import 405 | 406 | There is the last part of our page builder, we would like to save and restore the user's creation; we can do this by using the methods `editor.export()` and `editor.import()`. 407 | 408 | 409 | ```html 410 | 411 | 412 | 421 | 422 | 442 | ``` 443 | 444 | Display `` in our editor. 445 | 446 | ```html{8,9,20,25} 447 | 448 | 449 | 463 | 464 | 476 | ``` 477 | 478 | ## Conclusion 479 | 480 | Until the end, We implement the most of functionalities of a page builder with V-Craft. you can see this example on [this link](https://github.com/yoychen/v-craft-tutor). 481 | 482 | With V-Craft, we won't need to build a drag-n-drop system ourselves, just need to focus on our specific need. Wrapping page elements is the same as writing any other Vue.js component, so we won't need to learn additional skills to do that. We sincerely hope it can help you. 483 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest', 3 | collectCoverage: true, 4 | collectCoverageFrom: ['src/**/*.{js,vue}', '!src/example/**'], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@v-craft/core", 3 | "version": "0.3.0", 4 | "main": "dist/v-craft.umd.min.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "serve": "vue-cli-service serve", 8 | "build": "vue-cli-service build", 9 | "build:lib": "vue-cli-service build --target lib --name v-craft src/index.js", 10 | "publish:lib": "npm run build:lib && npm publish", 11 | "test:unit": "vue-cli-service test:unit", 12 | "lint": "vue-cli-service lint", 13 | "docs:dev": "vuepress dev docs", 14 | "docs:build": "vuepress build docs" 15 | }, 16 | "dependencies": { 17 | "core-js": "^3.6.4", 18 | "lodash": "^4.17.15", 19 | "uuid": "^7.0.2", 20 | "vue": "^2.6.11" 21 | }, 22 | "devDependencies": { 23 | "@vue/cli-plugin-babel": "~4.2.0", 24 | "@vue/cli-plugin-eslint": "~4.2.0", 25 | "@vue/cli-plugin-unit-jest": "~4.2.0", 26 | "@vue/cli-service": "~4.2.0", 27 | "@vue/eslint-config-airbnb": "^5.0.2", 28 | "@vue/test-utils": "1.0.0-beta.31", 29 | "@vuepress/plugin-google-analytics": "^1.4.0", 30 | "babel-eslint": "^10.0.3", 31 | "element-ui": "^2.13.0", 32 | "eslint": "^6.7.2", 33 | "eslint-plugin-import": "^2.20.1", 34 | "eslint-plugin-vue": "^6.1.2", 35 | "lint-staged": "^9.5.0", 36 | "node-sass": "^4.12.0", 37 | "sass-loader": "^8.0.2", 38 | "sinon": "^9.0.1", 39 | "vue-template-compiler": "^2.6.11", 40 | "vuepress": "^1.4.0" 41 | }, 42 | "gitHooks": { 43 | "pre-commit": "lint-staged" 44 | }, 45 | "lint-staged": { 46 | "*.{js,jsx,vue}": [ 47 | "vue-cli-service lint", 48 | "git add" 49 | ] 50 | }, 51 | "files": [ 52 | "dist" 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoychen/v-craft/5fd3fccfca90fe0252721bffdcd33be18ada4c6c/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | V-Craft 8 | 9 | 10 | 11 | 12 | 13 | 20 | 21 | 22 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoychen/v-craft/5fd3fccfca90fe0252721bffdcd33be18ada4c6c/public/preview.png -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 29 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yoychen/v-craft/5fd3fccfca90fe0252721bffdcd33be18ada4c6c/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/Blueprint.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 57 | -------------------------------------------------------------------------------- /src/components/Canvas.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /src/components/Editor.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 36 | -------------------------------------------------------------------------------- /src/components/Frame.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 46 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 42 | 43 | 44 | 60 | -------------------------------------------------------------------------------- /src/components/Indicator.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 34 | 35 | 47 | -------------------------------------------------------------------------------- /src/components/Node.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 100 | -------------------------------------------------------------------------------- /src/components/settingMixin.js: -------------------------------------------------------------------------------- 1 | import Node from '@/core/Node'; 2 | 3 | export default { 4 | props: { 5 | node: Node, 6 | }, 7 | computed: { 8 | elementPropsSetter() { 9 | let setter = this.node.setProps; 10 | setter = setter.bind(this.node); 11 | 12 | return setter; 13 | }, 14 | elementProps() { 15 | return this.node.props; 16 | }, 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/core/Editor.js: -------------------------------------------------------------------------------- 1 | import kebabCase from 'lodash/kebabCase'; 2 | import Indicator from './Indicator'; 3 | import Node from './Node'; 4 | 5 | class Editor { 6 | constructor(nodes = [], resolverMap = {}) { 7 | this.nodeMap = {}; 8 | this.selectedNode = null; 9 | this.draggedNode = null; 10 | this.indicator = new Indicator(); 11 | this.enabled = true; 12 | 13 | this.setTopLevelNodes(nodes); 14 | this.setResolverMap(resolverMap); 15 | } 16 | 17 | enable() { 18 | this.enabled = true; 19 | } 20 | 21 | disable() { 22 | this.selectNode(null); 23 | this.enabled = false; 24 | } 25 | 26 | setResolverMap(resolverMap) { 27 | this.resolverMap = {}; 28 | 29 | Object.entries(resolverMap).forEach(([key, value]) => { 30 | this.resolverMap[kebabCase(key)] = value; 31 | }); 32 | } 33 | 34 | initializeNodeMap(nodes) { 35 | nodes.forEach((node) => { 36 | this.nodeMap[node.uuid] = node; 37 | this.initializeNodeMap(node.children); 38 | }); 39 | } 40 | 41 | setTopLevelNodes(nodes) { 42 | this.nodes = nodes; 43 | this.initializeNodeMap(nodes); 44 | } 45 | 46 | findNode(uuid) { 47 | return this.nodeMap[uuid]; 48 | } 49 | 50 | selectNode(node) { 51 | this.selectedNode = node; 52 | } 53 | 54 | dragNode(node) { 55 | this.draggedNode = node; 56 | } 57 | 58 | findResolver(name) { 59 | return this.resolverMap[kebabCase(name)]; 60 | } 61 | 62 | removeNode(node) { 63 | node.makeOrphan(); 64 | 65 | if (node === this.selectedNode) { 66 | this.selectNode(null); 67 | } 68 | } 69 | 70 | getCraftConfig(node) { 71 | let resolver; 72 | if (node.isCanvas()) { 73 | resolver = this.findResolver(node.props.component); 74 | } else { 75 | resolver = this.findResolver(node.componentName); 76 | } 77 | 78 | return resolver.craft || {}; 79 | } 80 | 81 | getSettings(node) { 82 | return this.getCraftConfig(node).settings || {}; 83 | } 84 | 85 | export() { 86 | const nodesData = this.nodes.map((node) => node.serialize()); 87 | 88 | return JSON.stringify(nodesData); 89 | } 90 | 91 | import(plainNodesData) { 92 | try { 93 | const nodesData = JSON.parse(plainNodesData); 94 | this.nodes = nodesData.map((data) => Node.unserialize(this, data)); 95 | } catch { 96 | throw new Error('The input is not valid.'); 97 | } 98 | } 99 | } 100 | 101 | export default Editor; 102 | -------------------------------------------------------------------------------- /src/core/Indicator.js: -------------------------------------------------------------------------------- 1 | function getPadding(e) { 2 | const { 3 | paddingTop, paddingLeft, paddingRight, paddingBottom, 4 | } = getComputedStyle(e); 5 | const padding = { 6 | paddingTop, paddingLeft, paddingRight, paddingBottom, 7 | }; 8 | 9 | Object.keys(padding).forEach((key) => { 10 | padding[key] = parseInt(padding[key].slice(0, -2), 10); 11 | }); 12 | 13 | return padding; 14 | } 15 | 16 | class Indicator { 17 | constructor(barSize = 2) { 18 | this.barSize = barSize; 19 | this.show = false; 20 | this.position = { 21 | top: 0, 22 | left: 0, 23 | }; 24 | this.size = { 25 | width: 0, 26 | height: 0, 27 | }; 28 | this.isForbidden = false; 29 | } 30 | 31 | hide() { 32 | this.show = false; 33 | this.isForbidden = false; 34 | } 35 | 36 | setIsForbidden(bool) { 37 | this.isForbidden = bool; 38 | } 39 | 40 | showIndicator() { 41 | this.show = true; 42 | } 43 | 44 | pointBefore(element) { 45 | this.showIndicator(); 46 | 47 | const { top, left, height } = element.getBoundingClientRect(); 48 | 49 | this.position.top = top; 50 | this.position.left = left; 51 | 52 | this.size.width = this.barSize; 53 | this.size.height = height; 54 | } 55 | 56 | pointAfter(element) { 57 | this.showIndicator(); 58 | 59 | const { 60 | top, left, width, height, 61 | } = element.getBoundingClientRect(); 62 | 63 | this.position.top = top; 64 | this.position.left = left + width; 65 | 66 | this.size.width = this.barSize; 67 | this.size.height = height; 68 | } 69 | 70 | pointInside(element) { 71 | this.showIndicator(); 72 | 73 | const padding = getPadding(element); 74 | const { 75 | top, left, width, height, 76 | } = element.getBoundingClientRect(); 77 | 78 | this.position.top = top + height - padding.paddingBottom; 79 | this.position.left = left + padding.paddingLeft; 80 | 81 | this.size.width = width - padding.paddingLeft - padding.paddingRight; 82 | this.size.height = this.barSize; 83 | } 84 | 85 | pointInsideTop(element) { 86 | this.showIndicator(); 87 | 88 | const padding = getPadding(element); 89 | const { 90 | top, left, width, 91 | } = element.getBoundingClientRect(); 92 | 93 | this.position.top = top + padding.paddingTop; 94 | this.position.left = left + padding.paddingLeft; 95 | 96 | this.size.width = width - padding.paddingLeft - padding.paddingRight; 97 | this.size.height = this.barSize; 98 | } 99 | } 100 | 101 | export default Indicator; 102 | -------------------------------------------------------------------------------- /src/core/Node.js: -------------------------------------------------------------------------------- 1 | import cloneDeep from 'lodash/cloneDeep'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | class Node { 5 | constructor(componentName, props = {}, parent = null, children = [], rules = {}, addition = {}) { 6 | this.componentName = componentName; 7 | this.props = props; 8 | this.parent = parent; 9 | this.children = children; 10 | this.rules = rules; 11 | this.addition = addition; 12 | this.uuid = uuidv4(); 13 | } 14 | 15 | setProps(change) { 16 | this.props = { ...this.props, ...change }; 17 | } 18 | 19 | makeOrphan() { 20 | const { parent } = this; 21 | 22 | if (!parent) { 23 | return; 24 | } 25 | 26 | const index = parent.children.indexOf(this); 27 | parent.children.splice(index, 1); 28 | this.parent = null; 29 | } 30 | 31 | setParent(parent) { 32 | if (!parent.isDroppable(this)) { 33 | throw new Error('Parent node is not droppable.'); 34 | } 35 | 36 | this.makeOrphan(); 37 | 38 | parent.children.push(this); 39 | this.parent = parent; 40 | } 41 | 42 | inCanvas() { 43 | let curentParent = this.parent; 44 | 45 | while (curentParent) { 46 | if (curentParent.isCanvas()) { 47 | return true; 48 | } 49 | curentParent = curentParent.parent; 50 | } 51 | 52 | return false; 53 | } 54 | 55 | isDraggable() { 56 | if (!this.inCanvas()) { 57 | return false; 58 | } 59 | 60 | if (this.rules.canDrag) { 61 | return this.rules.canDrag(this); 62 | } 63 | 64 | return true; 65 | } 66 | 67 | isAncestor(node) { 68 | let curentParent = this.parent; 69 | 70 | while (curentParent) { 71 | if (curentParent === node) { 72 | return true; 73 | } 74 | curentParent = curentParent.parent; 75 | } 76 | 77 | return false; 78 | } 79 | 80 | isDroppable(incommingNode) { 81 | if (!this.isCanvas()) { 82 | return false; 83 | } 84 | 85 | if (incommingNode === this) { 86 | return false; 87 | } 88 | 89 | if (incommingNode.parent 90 | && incommingNode.parent.rules.canMoveOut 91 | && !incommingNode.parent.rules.canMoveOut(incommingNode, incommingNode.parent)) { 92 | return false; 93 | } 94 | 95 | if (this.isAncestor(incommingNode)) { 96 | return false; 97 | } 98 | 99 | if (this.rules.canMoveIn) { 100 | return this.rules.canMoveIn(incommingNode, this); 101 | } 102 | 103 | return true; 104 | } 105 | 106 | isCanvas() { 107 | if (this.componentName === 'Canvas') { 108 | return true; 109 | } 110 | 111 | return false; 112 | } 113 | 114 | append(incommingNode) { 115 | if (!this.isDroppable(incommingNode)) { 116 | throw new Error(`${this.componentName} is not droppable with the incommingNode - ${incommingNode.componentName}.`); 117 | } 118 | 119 | incommingNode.makeOrphan(); 120 | 121 | this.children.push(incommingNode); 122 | // eslint-disable-next-line no-param-reassign 123 | incommingNode.parent = this; 124 | } 125 | 126 | prepend(incommingNode) { 127 | if (!this.isDroppable(incommingNode)) { 128 | throw new Error(`${this.componentName} is not droppable with the incommingNode - ${incommingNode.componentName}.`); 129 | } 130 | 131 | incommingNode.makeOrphan(); 132 | 133 | this.children.splice(0, 0, incommingNode); 134 | // eslint-disable-next-line no-param-reassign 135 | incommingNode.parent = this; 136 | } 137 | 138 | canBeSibling(targetNode) { 139 | if (targetNode === this) { 140 | return false; 141 | } 142 | 143 | if (!targetNode.parent) { 144 | return false; 145 | } 146 | 147 | return targetNode.parent.isDroppable(this); 148 | } 149 | 150 | insertBefore(targetNode) { 151 | if (!this.canBeSibling(targetNode)) { 152 | throw new Error('Can not be the sibling of the target node.'); 153 | } 154 | 155 | this.makeOrphan(); 156 | 157 | const parentOfTargetNode = targetNode.parent; 158 | const indexOfTargetNode = parentOfTargetNode.children.indexOf(targetNode); 159 | parentOfTargetNode.children.splice(indexOfTargetNode, 0, this); 160 | this.parent = parentOfTargetNode; 161 | } 162 | 163 | insertAfter(targetNode) { 164 | if (!this.canBeSibling(targetNode)) { 165 | throw new Error('Can not be the sibling of the target node.'); 166 | } 167 | 168 | this.makeOrphan(); 169 | 170 | const parentOfTargetNode = targetNode.parent; 171 | const indexOfTargetNode = parentOfTargetNode.children.indexOf(targetNode); 172 | parentOfTargetNode.children.splice(indexOfTargetNode + 1, 0, this); 173 | this.parent = parentOfTargetNode; 174 | } 175 | 176 | serialize() { 177 | return { 178 | componentName: this.componentName, 179 | props: this.props, 180 | children: this.children.map((node) => node.serialize()), 181 | addition: this.addition, 182 | uuid: this.uuid, 183 | }; 184 | } 185 | 186 | duplicate() { 187 | return new Node( 188 | this.componentName, 189 | cloneDeep(this.props), 190 | null, 191 | this.children.map((node) => node.duplicate()), 192 | this.rules, 193 | cloneDeep(this.addition), 194 | ); 195 | } 196 | } 197 | 198 | Node.unserialize = (editor, nodeData, parent = null) => { 199 | const node = new Node(); 200 | Object.assign(node, nodeData); 201 | 202 | const craftConfig = editor.getCraftConfig(node); 203 | 204 | if (craftConfig.rules) { 205 | node.rules = craftConfig.rules; 206 | } 207 | 208 | node.parent = parent; 209 | node.children = nodeData.children.map((data) => Node.unserialize(editor, data, node)); 210 | 211 | return node; 212 | }; 213 | 214 | export default Node; 215 | -------------------------------------------------------------------------------- /src/core/services/NodeService.js: -------------------------------------------------------------------------------- 1 | class NodeService { 2 | constructor(vm) { 3 | this.vm = vm; 4 | } 5 | 6 | getElement() { 7 | return this.vm.$el; 8 | } 9 | 10 | getElementBoundingClientRect() { 11 | return this.vm.$el.getBoundingClientRect(); 12 | } 13 | 14 | getEditor() { 15 | return this.vm.editor; 16 | } 17 | 18 | getCurrentNode() { 19 | return this.vm.node; 20 | } 21 | 22 | onLeftHalf({ clientX }) { 23 | const { left, width } = this.getElementBoundingClientRect(); 24 | 25 | if (clientX < (left + (width / 2))) { 26 | return true; 27 | } 28 | 29 | return false; 30 | } 31 | 32 | onTopHalf({ clientY }) { 33 | const { top, height } = this.getElementBoundingClientRect(); 34 | 35 | if (clientY < (top + (height / 2))) { 36 | return true; 37 | } 38 | 39 | return false; 40 | } 41 | 42 | onEdge({ clientX, clientY }, edgeThickness = 8) { 43 | const { 44 | top, left, width, height, 45 | } = this.getElementBoundingClientRect(); 46 | 47 | if ( 48 | clientX < (left + width - edgeThickness) 49 | && clientX > (left + edgeThickness) 50 | && clientY > (top + edgeThickness) 51 | && clientY < (top + height - edgeThickness) 52 | ) { 53 | return false; 54 | } 55 | 56 | return true; 57 | } 58 | 59 | handleElementDragOver(cursor) { 60 | const editor = this.getEditor(); 61 | 62 | editor.indicator.setIsForbidden(!editor.draggedNode.canBeSibling(this.getCurrentNode())); 63 | 64 | if (this.onLeftHalf(cursor)) { 65 | editor.indicator.pointBefore(this.getElement()); 66 | } else { 67 | editor.indicator.pointAfter(this.getElement()); 68 | } 69 | } 70 | 71 | handleCanvasDragOver(cursor) { 72 | const editor = this.getEditor(); 73 | const { clientX, clientY } = cursor; 74 | 75 | if (this.onEdge({ clientX, clientY })) { 76 | this.handleElementDragOver(cursor); 77 | return; 78 | } 79 | 80 | editor.indicator.setIsForbidden(!this.getCurrentNode().isDroppable(editor.draggedNode)); 81 | if (this.onTopHalf(cursor)) { 82 | editor.indicator.pointInsideTop(this.getElement()); 83 | } else { 84 | editor.indicator.pointInside(this.getElement()); 85 | } 86 | } 87 | 88 | handleDragOver(cursor) { 89 | if (this.getCurrentNode().isCanvas()) { 90 | this.handleCanvasDragOver(cursor); 91 | } else { 92 | this.handleElementDragOver(cursor); 93 | } 94 | } 95 | 96 | handleElementDrop(cursor) { 97 | const currentNode = this.getCurrentNode(); 98 | const { draggedNode } = this.getEditor(); 99 | 100 | if (!draggedNode.canBeSibling(currentNode)) { 101 | return; 102 | } 103 | 104 | if (this.onLeftHalf(cursor)) { 105 | draggedNode.insertBefore(currentNode); 106 | } else { 107 | draggedNode.insertAfter(currentNode); 108 | } 109 | } 110 | 111 | handleCanvasDrop(cursor) { 112 | const currentNode = this.getCurrentNode(); 113 | const { draggedNode } = this.getEditor(); 114 | 115 | if (this.onEdge(cursor)) { 116 | this.handleElementDrop(cursor); 117 | return; 118 | } 119 | 120 | if (!currentNode.isDroppable(draggedNode)) { 121 | return; 122 | } 123 | 124 | if (this.onTopHalf(cursor)) { 125 | currentNode.prepend(draggedNode); 126 | } else { 127 | currentNode.append(draggedNode); 128 | } 129 | } 130 | 131 | handleDrop(cursor) { 132 | const currentNode = this.getCurrentNode(); 133 | const editor = this.getEditor(); 134 | 135 | if (currentNode.isCanvas()) { 136 | this.handleCanvasDrop(cursor); 137 | } else { 138 | this.handleElementDrop(cursor); 139 | } 140 | 141 | editor.dragNode(null); 142 | editor.indicator.hide(); 143 | } 144 | } 145 | 146 | export default NodeService; 147 | -------------------------------------------------------------------------------- /src/example/App.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 40 | 41 | 53 | -------------------------------------------------------------------------------- /src/example/app.scss: -------------------------------------------------------------------------------- 1 | $color-black: #171717; 2 | $color-gray: rgb(106, 106, 106); 3 | $color-light-gray: rgb(188, 201, 204); 4 | 5 | $navbar-height: 55px; 6 | $element-sidebar-width: 60px; 7 | $setting-sidebar-width: 280px; 8 | 9 | @mixin scrollbar($width: 13px) { 10 | &::-webkit-scrollbar 11 | { 12 | width: $width; 13 | } 14 | 15 | &::-webkit-scrollbar-thumb 16 | { 17 | border: 4px solid transparent; 18 | border-radius: 8px; 19 | background-clip: padding-box; 20 | background-color: $color-light-gray; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/example/components/ElementBlock.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 16 | 17 | 31 | -------------------------------------------------------------------------------- /src/example/components/ElementSidebar.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 74 | 75 | 97 | -------------------------------------------------------------------------------- /src/example/components/Navbar.vue: -------------------------------------------------------------------------------- 1 |