低代码 - 可视化拖拽技术分析(4)

2,028 阅读6分钟

前篇

上一讲 - 低代码 - 可视化拖拽技术分析(3) 中,我们实现了 撤销重做 操作;本节我们继续完善编辑器的功能,包含以下内容:

  1. 画布尺寸、位置自定义调整;
  2. 保存 Schema;
  3. Schema 的预览和编辑;
  4. 右侧设置区域布局;
  5. 属性设置器;
  6. 样式设置器。

一、画布尺寸、位置自定义调整

1、自定义画布尺寸

画布的默认大小采用 iPhoto 6/7/8 的尺寸:width: 375height: 667

为了能够自定义尺寸大小,我们在顶部操作栏位置扩展两个 input 组件:

<div className="editor-header">
  ...
  <div className="editor-header-canvas">
    <span>画布尺寸</span>
    <input 
      value={wrapperDragState.width} 
      onChange={event => setWrapperDragState({ ...wrapperDragState, width: event.target.value })} 
      onBlur={changeCanvasSize} />
    <span>*</span>
    <input 
      value={wrapperDragState.height} 
      onChange={event => setWrapperDragState({ ...wrapperDragState, height: event.target.value })} 
      onBlur={changeCanvasSize} />
  </div>
</div>

wrapperDragState 是一个 useState,默认读取 schema.container 尺寸信息。

const [wrapperDragState, setWrapperDragState] = useState({ 
  left: 0, 
  top: 0, 
  width: schema.container.width,
  height: schema.container.height,
});

将 input 每次 change 的数据保存在 wrapperDragState 中;当 input 失焦后,同步到 schema.container 中:

const changeCanvasSize = () => {
  schema.container.width = Number(wrapperDragState.width || 0);
  schema.container.height = Number(wrapperDragState.height || 0);
  forceUpdate();
}

2、自定义画布位置

默认,画布位置显示在 editor-container 容器 水平/垂直 居中位置,我们可以给容器添加 mousemove 事件,来调整画布的位置。

const handleWrapperMouseDown = event => {
  // 避免进行右键操作后,出现意外画布移动。(event.button === 0 代表左键)
  if (event.button === 2) return;
  const { clientX: startX, clientY: startY } = event;

  const mousemove = event => {
    const diffX = event.clientX - startX;
    const diffY = event.clientY - startY;
    setWrapperDragState({
      ...wrapperDragState,
      left: wrapperDragState.left + diffX,
      top: wrapperDragState.top + diffY,
    });
  }

  const mouseup = () => {
    document.removeEventListener('mousemove', mousemove);
    document.removeEventListener('mouseup', mouseup);
  }

  document.addEventListener('mousemove', mousemove);
  document.addEventListener('mouseup', mouseup);
}

<div className="editor-container" onMouseDown={handleWrapperMouseDown}>
  ...
</div>

画布应用 wrapperDragState 保存的 left 和 top 信息:

<div 
  id="canvas-container"
  style={{ ...schema.container, transform: `translate(${wrapperDragState.left}px, ${wrapperDragState.top}px)` }}>
  ...
</div>

二、保存 Schema

保存的功能非常简单,我们可以拿到 schema 信息,它包含了画布中所有元素的拖拽信息,因此我们只需保存 schema 即可。

通常会将 schema 保存到数据服务器,这里简单保存至本地 sessionStorage 中:

1、操作栏添加保存按钮:

const saveSchema = () => {
  sessionStorage.setItem('lc-schema', JSON.stringify(schema));
  alert('save success.');
}
  
const buttons = [
  ...
  { label: '保存', handler: saveSchema },
];

2、读取缓存 schema

import SchemaJSON from './schema.json';

const [schema] = useState(JSON.parse(sessionStorage.getItem('lc-schema')) || SchemaJSON);

三、Schema 的预览和编辑

Schema 的在线预览和修改后同步编辑器,给编辑器配置带来了灵活性。例如 Ali 低代码引擎就实现了此功能。

一般需要一个类似于 json editor 的 JSON 编辑器来实现此功能。

这里不做过深介绍,简单使用 alert 弹出 Schema 进行预览。

const seeSchema = () => {
  alert(JSON.stringify(schema, null, 2));
}

const buttons = [
  ...
  { label: 'schema', handler: seeSchema },
];

四、右侧设置区域布局

页面上有了右侧区域的布局渲染,才能继续下面的设置器内容。布局这里我们简单实现一下。

假设我们右侧设置区域,可供设置的画布元素信息类型有以下四类:

const setterTabs = [
  { type: 'props', name: '属性' },
  { type: 'style', name: '样式' },
  { type: 'events', name: '事件' },
  { type: 'animations', name: '动画' },
]

Editor .editor-right 位置添加 DOM 结构和样式布局,得到如下代码:

import PropsSetter from './setter/PropsSetter'; // 属性设置器(下面讲)
import StyleSetter from './setter/StyleSetter'; // 样式设置器(下面讲)

<div className="editor-right">
  <div className="setter-tabs-list">
    // 1、渲染 Tab
    {setterTabs.map(tab => (
      <div
        key={tab.type}
        data-type={tab.type}
        className={`setter-tab ${activeSetter === tab.type ? 'setter-tab-active' : ''}`}
        onClick={() => setSetter(tab.type)}>{tab.name}</div>
    ))}
  </div>
  // 2、渲染 activeTab 对应的 View
  <div className="setter-content">
    {currentBlockIndex.current !== -1 ? (() => {
      const block = schema.blocks[currentBlockIndex.current];
      if (!block) return null;
      switch (activeSetter) {
        case 'props':
          return <PropsSetter block={block} />;
        case 'style':
          return <StyleSetter block={block} />
        default:
          return null;
      }
    })() : null}
  </div>
</div>

同一时间只能显示一个 Tab,因此需要一个 state 变量来标记当前显示的 Tab - activeSetter

// src/Editor.js
const [activeSetter, setSetter] = useState('');

另外,为了每次添加到物料组件到画布上时,右侧区域可以默认显示当前 block 的属性设置器内容,在添加到画布上时,记录 currentBlockIndex 即可:

// src/Editor.js
const handleDrop = (event) => {
  // ...
  currentBlockIndex.current = schema.blocks.length - 1;
  setSetter('props');
}

到这里,右侧的设置区域布局完成,下面我们将 属性设置 逻辑加入进来。

五、属性设置器

设置器,可以理解为修改某一属性的控件组件,比如可以是 Input、Radio、ColorPicker 等;画布元素中,每一个支持修改的属性都会对应一类设置器。

第一步,我们先改造一下 config.js 中注册的组件,为其添加 children prop

// src/config.js
registerConfig.register({
  label: '文本',
  preview: () => '预览文本',
  render: ({ children }) => <span>{children}</span>,
  type: 'text',
  props: {
    children: '渲染文本',
  }
});

registerConfig.register({
  label: '按钮',
  preview: () => <button>预览按钮</button>,
  render: ({ children }) => <button style={{ display: 'block', width: '100%', height: '100%' }}>{children}</button>,
  type: 'button',
  props: {
    children: '渲染按钮',
  }
});

文本和按钮分别接收 props.children 作为渲染文本,第二步,我们改造一下 Block.js 中的逻辑,将 block.props 传递给 RenderComponent

// src/Block.js
const { block, ...otherProps } = props;
const component = config.componentMap[block.type];
const RenderComponent = component.render(block.props);

接下来,就需要在将物料组件添加到画布上时,将 第一步第二步props 进行关联:

// src/Editor.js
const handleDrop = (event) => {
  const { offsetX, offsetY } = event.nativeEvent;
  // 1、取出注册组件时定义的 props
  const { type, props, style } = currentMaterial.current;
  schema.blocks.forEach(block => block.focus = false);
  schema.blocks.push({
    type,
    alignCenter: true,
    focus: true,
    style: {...},
    // 2、传递给 block props
    props,
  });
  ...
}

在上面 右侧属性设置区域布局 已为设置器提供了视图环境,接下来我们编写 PropsSetter 呈现逻辑。

在这里,我们需要做的一件事情就是:将 component.props 和设置器进行关联,去呈现 prop 对应的的设置器。

在这里,组件的 props.children 属于文本类型属性,对应的设置器组件为 Input

// src/setter/PropsSetter.js
import { useContext } from 'react';
import EditorContext from '../context';

const PropsSetter = ({ block }) => {
  const { forceUpdate } = useContext(EditorContext);

  const onChange = (key, value) => {
    block.props[key] = value;
    forceUpdate(); // 更新画布视图
  }

  // 根据 block 类型,映射渲染列表,
  const propsList = (() => {
    switch (block.type) {
      case 'button':
        // 映射组件属性对应的 Setter 属性设置器类型
        return Object.keys(block.props).map(prop => ({ label: prop, value: block.props[prop], setter: 'input' }));
      case 'text':
        return Object.keys(block.props).map(prop => ({ label: prop, value: block.props[prop], setter: 'input' }));

      default:
        return [];
    }
  })();

  return (
    <div className="setter-props-wrapper">
      {propsList.map(prop => (
        <div key={prop} className="setter-item">
          <span className="setter-item-label">{prop.label}:</span>
          <div className="setter-item-control">
            {(() => {
              switch (prop.setter) {
                case 'input':
                  return <input value={prop.value} onChange={event => onChange(prop.label, event.target.value)} />
                default:
                  return null;
              }
            })()}
            
          </div>
        </div>
      ))}
    </div>
  )
}

export default PropsSetter;

上面代码中有一点很关键:设置 block.props 后如何触发视图更新?

const { forceUpdate } = useContext(EditorContext);

const onChange = (key, value) => {
  block.props[key] = value;
  forceUpdate(); // 更新画布视图
}

这里涉及到一个交互:更改属性设置器后,画布中这个元素视图应该同步更新。

画布视图所在的组件为 Editor,画布的视图更新可以由 Editor 提供,代码如下:

// src/Editor.js
function Editor() {
  const [, forceUpdate] = useReducer(v => v + 1, 0);
  
  return (
    <Provider value={{ config, forceUpdate }}>
      ...
    <Provider/>
  )
}

现在,我们就可以通过右侧属性设置器,来修改画布中按钮文本。

六、样式设置器

理解了属性设置器的流程,相信 灵机一动,就可想出样式设置器的实现步骤了。

同样的,我们先从注册组件入手,在 config.js 注册组件时,可以配置 RenderComponent 默认样式:

// src/config.js
registerConfig.register({
  type: 'button',
  label: '按钮',
  render: ({ children }) => <button style={{ display: 'block', width: '100%', height: '100%' }}>{children}</button>,
  style: {
    width: 100,
    height: 34,
    zIndex: 1,
  },
  ...
});

接着,在拖动组件添加到画布上时,将提供的默认样式赋值给 block.style

const handleDrop = (event) => {
  const { type, props, style } = currentMaterial.current;
  schema.blocks.push({
    style: {
      width: undefined,
      height: undefined,
      left: offsetX,
      top: offsetY,
      zIndex: 1,
      ...style
    },
    ...
  });
  ...
}

下面我们实现 StyleSetter,根据 block.style 遍历渲染设置器,这里目前统一使用了 Input

// src/setter/StyleSetter.js
import { useContext } from 'react';
import EditorContext from '../context';

const StyleSetter = ({ block }) => {
  const { forceUpdate } = useContext(EditorContext);

  const onChange = (key, value) => {
    block.style[key] = Number(value);
    forceUpdate(); // 更新画布视图
  }

  return (
    <div className="style-setter-wrapper">
      {Object.keys(block.style).map(key => (
        <div key={key} className="setter-item">
          <span className="setter-item-label">{key}:</span>
          <div className="setter-item-control">
            <input value={block.style[key]} onChange={event => onChange(key, event.target.value)} />
          </div>
        </div>
      ))}
    </div>
  )
}

export default StyleSetter;

关于样式这里,也涉及到一个和画布的交互:当拖动画布元素进行放大缩小时,样式设置器这里的信息也应该同步。

由于右侧设置区域也是在 Editor 组件中,因此 Block 组件可以使用 Editor 提供的视图更新方法 forceUpdate

// src/Block.js
- const [, forceUpdate] = useReducer(v => v + 1, 0);
+ const { config, forceUpdate } = useContext(EditorContext);

现在,我们可以去通过设置器去修改属性,并且画布元素的放大缩小,也会将 widht、height 同步在设置器中。

最后

感谢阅读。

如有不足之处,欢迎指正 👏 。

借鉴于:谭志光 - 可视化拖拽组件库一些技术要点原理分析