Visual drag-and-drop page editor

With the development of the big front end, more and more free development hands, feel to be unemployed (^_^), for some simple template processing, you can directly drag and drop, simple implementation of some nice UI functions. So the emergence of visual drag page editor also complies with the development of The Times.

The final result

For Vue3 version please click

Task list

  • Main page structure: left menu bar optional component list, middle container canvas, right side edit component definition properties;
  • Left menu bar optional component list rendering;
  • Drag components from the menu bar to the container;
  • The selected state of a component in a container;
  • Movable position of components in the container;
  • Command queue and corresponding shortcut keys;
  • The components in the container are single, multiple and all;
  • Operation bar button:
    • Undo, redoDifficult point;
    • Top and bottom;
    • Delete, empty;
    • Preview and close editing mode;
    • Import, export;
  • Right-click menu;
  • Drag adhesive welt;
  • Components can be dragged to adjust the height and width;
  • A component can set predefined properties (props);
  • Component binding value (Model);
  • Set the component identity (soltName), from which you define the behavior of a component (function firing) and the implementation of a slot (custom view);
  • Complete the list of optional components:
    • Input box: bidirectional binding value, adjust width;
    • Button: type, text, size size, drag adjust width and height;
    • Picture: customize picture address, drag and drop to adjust the width and height of the picture
    • Drop-down box: predefined option values, bidirectional binding fields;

I. Project construction and page layout

  1. Project dependencies:
 "dependencies": {
    "@ant-design/icons": "^ 4.5.0." "."antd": "^ 4.15.0"."classnames": "^ 2.2.6." "."deepcopy": "^ 2.1.0." "."react": "^ 17.0.1"."react-color": "^ 2.19.3"."react-dom": "^ 17.0.1"
  },
  "devDependencies": {
    "@types/classnames": "^ 2.2.11." "."@types/node": "^ 14.14.37"."@types/react": "^ 17.0.2"."@types/react-color": "^ 3.0.4"."@types/react-dom": "^ 17.0.1"."@vitejs/plugin-react-refresh": "^ 1.3.1." "."less": "^ 4.4.1"."sass": "^ 1.42.0"."typescript": "^ 4.1.5." "."vite": "^ 2.0.1." "."vite-plugin-babel-import": "^ at 2.0.5." "."vite-plugin-style-import": "^ 1.2.1." "
  }
Copy the code
  1. Vite configuration
const path = require('path');
import reactRefresh from '@vitejs/plugin-react-refresh';
import { defineConfig } from 'vite';
import styleImport from 'vite-plugin-style-import';

export default defineConfig({
    plugins: [
        reactRefresh(),
        styleImport({
            libs: [{libraryName: 'antd'.esModule: true.resolveStyle: (name) = > {
                        return `antd/es/${name}/style`; },}]}),],css: {
        preprocessorOptions: {
            less: {
                javascriptEnabled: true}}},esbuild: {
        jsxInject: "import React from 'react'".// React for each TSX JSX
    },
    resolve: {
        alias: {
            "@": path.resolve(__dirname, "src"),
            "@assets": path.resolve(__dirname, "src/assets"),
            "@components": path.resolve(__dirname, "src/components")}},server: {
        https: false.// Whether to enable HTTPS
        open: true.// Whether to open automatically in the browser
        port: 3000./ / the port number
        host: "0.0.0.0".hmr: {
            overlay: true.// Whether to turn on the wrong shadow layer}},optimizeDeps: {
        include: [] // Third-party libraries
    },
    build: {
        chunkSizeWarningLimit: 2000.terserOptions: {
            // Remove console from production environment
            compress: {
                drop_console: true.drop_debugger: true,}},rollupOptions: {
            output: {manualChunks: { / / the subcontract
                    react: ['react'.'react-dom'].antd: ['antd']}}}}})Copy the code

Implement a basic three-column layout

  • To the left is the menu bar for the list of components
  • In the middle is the canvas container and a toolbar at the top for editing and previewing pages
  • On the right is a component in the corresponding canvas container, corresponding to the displayed property configuration for that component

This layout is relatively simple. Just imagine for a second

Second, basic data structure design

For the relationship between a canvas container and components, corresponding to a canvas size change, and a canvas corresponding to multiple components, a component configuration information, for drag and drop you must think, positioning, if optimized, You can use CSS3’s property Transform translateX, translateY optimization. I’m not going to do that right now.

  • Defining data structures
    • Container: Indicates the canvas container information
    • Blocks: Component information in the canvas container
    • Block: Blocks stores information about each block component, including the unique identifier, location, width, height, and status of the component
/** * The data type of each element in the container */
export interface VisualEditorBlock {
  componentKey: string.// The component object's key is uniquely identified
  top: number.// Block the top position in the container
  left: number.// Block in the left position of the container
  width: number.// The width of the block component itself
  height: number.// The height of the block component itself
  adjustPosition: boolean.// Does the position need to be adjusted when adding components to the container
  focus: boolean.// Whether the component is selected
  zIndex: number.// The z-index style attribute of the block component element
  hasReasize: boolean.// Whether the block component element has been resizedprops? : Record<string.any> // Attribute configuration information on the right of the block component elementmodel? : Record<string.any> // Custom configuration attribute information on the right side of the component element (binding value)slotName? :string   // Component identification
}
/** * The type of data edited by the editor */
export interface VisualEditorValue {
  container: { // Canvas container
    height: number.width: number,},blocks: VisualEditorBlock[]
}
Copy the code

Code portal

3. Render the list of optional components in the left menu bar

  1. Developing registration components
// visual.config.tsx
import { Button, Input } from "antd";
import { createVisualConfig } from "./editor.utils";

export const visualConfig = createVisualConfig();

visualConfig.registryComponent('text', {
    label: 'text'.prievew: () = > <span>Preview the text</span>,
    render: () = > <span>Rendering text</span>
});

visualConfig.registryComponent('button', {
    label: 'button'.prievew: () = > <Button>Preview button</Button>,
    render: () = > <Button type="primary">The render button</Button>
});

visualConfig.registryComponent('input', {
    label: 'Input field'.prievew: () = > <Input placeholder="Preview input box" />,
    render: () = > <Input placeholder="Render input box" />
});
Copy the code

Register component functions:

/** * Create the editor's default content */
export function createVisualConfig() {
    // For block data, find the Component object with the Component Key, and use the Component object's render property to render the content into the container
    const componentMap: { [k: string]: VisualEditorComponent } = {};
    // List of components predefined by the user in menu
    const componentList: VisualEditorComponent[] = [];

    const registryComponent = (key: string, options: Omit<VisualEditorComponent, 'key'>) = > {
        // Key is unique
        if (componentMap[key]) {
            const index = componentList.indexOf(componentMap[key]);
            componentList.splice(index, 1);
        }
        constnewComponent = { key, ... options } componentList.push(newComponent); componentMap[key] = newComponent; }return {
        componentList,
        componentMap,
        registryComponent
    }
}
Copy the code
  1. Render the registered component
// VisualEditor.tsx

/ /...

export const VisualEditor: React.FC<{
  value: VisualEditorValue,
  config: VisualEditorConfig
}> = (props) = > {

  return (<>
      <div className={styles['visual-editor__container']} >
          <div className={styles['visual-editor__menu']} >
              <div className={styles['visual-editor__menu__title']} >
                  <MenuUnfoldOutlined /> <span>Component list</span>
              </div>
              {
                  props.config.componentList.map((component, index) => {
                      return (
                          <div key={component.key + '_'+index} className={styles['editor-menu__item']} >
                              <span className={styles['menu-item__title']} >{component.label}</span>
                              <div className={styles['menu-item__content']} >
                                  {component.prievew()}
                              </div>
                          </div>)})}</div>
          <div className={styles['visual-editor__head']} >header</div>
          <div className={styles['visual-editor__operator']} >operator</div>
          <div className={styles['visual-editor__body']} >body</div>
      </div>
  </>);
};
Copy the code

The effect

Code portal

Render the canvas container area

Basic rendering

  1. Block component
// EditorBlock.tsx
export const VisualEditorBlock: React.FC<{
    block: VisualEditorBlockData,
    config: VisualEditorConfig,
    editing: boolean} > =(props) = > {
    const style = useMemo(() = > {
        return {
            top: `${props.block.top}px`.left: `${props.block.left}px`,
        }
    }, [props.block.top, props.block.left]);

    const component = props.config.componentMap[props.block.componentKey];

    let render: any;
    if(!!!!! component) { render = component.render({}as any);
    }

    return (() = > {
        const mask = props.editing ? 'mask': ' ';
        return (
            <div className={` ${styles['visual-editor__block']} ${mask} `.trim()} style={style}>
                {render}
            </div>()})})Copy the code
  1. Block component rendering
// VisualEditor.tsx

export const VisualEditor: React.FC<{
  value: VisualEditorValue,
  config: VisualEditorConfig
}> = (props) = > {
    // code omitted.....
​
  const containerStyles = useMemo(() = > {
    return {
      width: `${props.value.container.width}px`.height: `${props.value.container.height}px`,
    }
  }, [props.value.container.height, props.value.container.width]);
  return (<>
      {
        editing ? (
          <div className={styles['visual-editor__container']} >{/* // code omitted..... * /}<div className={styles['visual-editor__head']} >header <button onClick={methods.toggleEditing}>run</button></div>
            <div className={styles['visual-editor__operator']} >operator</div>
            <div className={` ${styles['visual-editor__body']} ${styles['custom-bar__style']} `} >
              <div className={` ${styles['editor-body_container']} ${'editor-block__mask` '}}style={containerStyles}>
                {
                  props.value.blocks.map((block, index) => {
                    return <VisualEditorBlock
                            block={block}
                            config={props.config}
                            editing={editing}
                            key={index}
                          />})}</div>
            </div>
          </div>
        ) : (
          <div className={styles['visual-editor__preview']} >
            <div className={styles['editor-preview__edit']} onClick={methods.toggleEditing}><Button>The editor</Button></div>
            <div className={styles['preview-edit__warpper']} >
              <div className={styles['editor-body_container']} style={containerStyles}>
                {
                  props.value.blocks.map((block, index) => {
                    return <VisualEditorBlock
                            block={block}
                            config={props.config}
                            key={index}
                            editing={editing}
                          />})}</div>
            </div>
          </div>)}</>);
};
Copy the code

rendering

Code portal

5. Drag the left menu component into the canvas container area for rendering

HTML5 drag-and-drop event listening

// VisualEditor.tsx

export const VisualEditor: React.FC<{
  value: VisualEditorValue,
  config: VisualEditorConfig,
  onChange: (val: VisualEditorValue) = > void.// The data has changed from the external function} > =(props) = > {
  // Whether the file is currently being edited
  const [editing, setEditing] = useState(true);
  const methods = {
    // Toggle edit and run statestoggleEditing () { setEditing(! editing); },/** * Update block data to trigger view rerender *@param blocks 
     */
    updateBlocks: (blocks: VisualEditorBlockData[]) = >{ props.onChange({ ... props.value,blocks: [...blocks]
      })
    },
  }

  // Canvas container DOM
  const containerRef = useRef({} as HTMLDivElement);

  const containerStyles = useMemo(() = > {
    return {
      width: `${props.value.container.width}px`.height: `${props.value.container.height}px`,
    }
  }, [props.value.container.height, props.value.container.width]);

  //#region menu drag and drop into the canvas container area
  const menuDraggier = (() = > {

    const dragData = useRef({
      dragComponent: null as null | VisualEditorComponent // Left component list to drag the current component
    });

    const container = {
      dragenter: useCallbackRef((e: DragEvent) = >{ e.dataTransfer! .dropEffect ='move';
      }),
      dragover: useCallbackRef((e: DragEvent) = > {
        e.preventDefault();
      }),
      dragleave: useCallbackRef((e: DragEvent) = >{ e.dataTransfer! .dropEffect ='none';
      }),
      drop: useCallbackRef((e: DragEvent) = > {
        // Add the component to the container canvas
        console.log('add')

        methods.updateBlocks([
          ...props.value.blocks,
          createVisualBlock({
            top: e.offsetY,
            left: e.offsetX,
            component: dragData.current.dragComponent! })]); })};const block = {
      dragstart: useCallbackRef((e: React.DragEvent
       
        , dragComponent: VisualEditorComponent
       ) = > {
        
        containerRef.current.addEventListener('dragenter', container.dragenter);
        containerRef.current.addEventListener('dragover', container.dragover);
        containerRef.current.addEventListener('dragleave', container.dragleave);
        containerRef.current.addEventListener('drop', container.drop);

        dragData.current.dragComponent = dragComponent;

      }),
      dragend: useCallbackRef((e: React.DragEvent<HTMLDivElement>) = > {

        containerRef.current.removeEventListener('dragenter', container.dragenter);
        containerRef.current.removeEventListener('dragover', container.dragover);
        containerRef.current.removeEventListener('dragleave', container.dragleave);
        containerRef.current.removeEventListener('drop', container.drop); })};returnblock; }) ();//#endregion

  return (<>
      {
        editing ? (
          <div className={styles['visual-editor__container']} >{/* // code omitted..... * /}<div className={styles['visual-editor__head']} >header <button onClick={methods.toggleEditing}>run</button></div>
            <div className={styles['visual-editor__operator']} >operator</div>
            <div className={` ${styles['visual-editor__body']} ${styles['custom-bar__style']} `} >
              <div
                className={` ${styles['editor-body_container']} ${'editor-block__mask` '}}style={containerStyles}
                ref={containerRef}
              >
                {
                  props.value.blocks.map((block, index) => {
                    return <VisualEditorBlock
                            block={block}
                            config={props.config}
                            editing={editing}
                            key={index}
                          />})}</div>
            </div>
          </div>) : (// code omitted.....) }</>);
};
Copy the code

Code portal