likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 49

前篇

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

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

放大缩小

放大缩小是指:画布中的元素在选中后,可以自由拉拽改变尺寸大小,拉拽的地方可以是元素的 4 个边角,或者上下左右四个方向。

之前我们在元素 focus 选中后为元素添加了边框,现在我们要改造一下这里:在 focus 之后,为元素周围添加可 拉拽 的小圆点(8个),用来放大缩小。

1、添加小圆点

小圆点一共有 8 个,分别代表不同的方向:top、right、bottom、left、leftTop、rightTop、leftBottom、rightBottom。

const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb'];

现在,我们为每个 Block 添加小圆点,当 block 被选中时,显示小圆点:

// src/Block.js
<div
  ...
  className={`editor-block ${block.focus ? 'editor-block-focus' : ''}`}>
  {block.focus ? pointList.map(point => (
    <div key={point} className="shape-point"}></div>
  )) : null}
  ...
</div>

.shape-point {
  position: absolute;
  background: #fff;
  border: 2px solid #59c7f9;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  z-index: 1;
  cursor: pointer;
}

现在,选中 block 后,所有的小圆点都在左上角位置,我们需要将每个小圆点分别放置在 8 个方向位置上。

2、计算小圆点位置

这里我们定义 getPointStyle 方法,根据方向计算 lefttop

{block.focus ? pointList.map(point => (
  <div key={point} className="shape-point" style={getPointStyle(point)}></div>
)) : null}

block 的四个角很好计算:

  • 如果是 leftTopleftBottom,包含 left,则紧贴左侧,left 按照 0 处理;否则紧贴右侧,left 按照 width 处理;
  • 如果是 leftToprightTop,包含 top,则紧贴顶部,top 按照 0 处理;否则紧贴底部,top 按照 height 处理。

对于上下两个点,它们的 left 都是 width 的一半;

对于左右两个点,他们的 top 都是 height 的一半;

最后,只需要平移负的小圆点自身的一半,即可达到位置居中。

const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb'];
const cursors = {
  t: 'n-resize',
  r: 'e-resize',
  b: 's-resize',
  l: 'w-resize',
  lt: 'nw-resize',
  rt: 'ne-resize',
  lb: 'sw-resize',
  rb: 'se-resize',
}

const getPointStyle = point => {
  const { width, height } = block;
  const hasT = /t/.test(point),
    hasB = /b/.test(point),
    hasL = /l/.test(point),
    hasR = /r/.test(point);
  let newLeft = 0, newTop = 0;

  // block 的四个角
  if (point.length === 2) {
    newLeft = hasL ? 0 : width;
    newTop = hasT ? 0 : height;
  } else {
    // 上下两点的点,宽度居中
    if (hasT || hasB) {
      newLeft = width / 2;
      newTop = hasT ? 0 : height;
    }
    // 左右两边的点,高度居中
    if (hasL || hasR) {
      newLeft = hasL ? 0 : width;
      newTop = Math.floor(height / 2);
    }
  }

  const style = {
    marginLeft: '-4px',
    marginTop: '-4px',
    left: `${newLeft}px`,
    top: `${newTop}px`,
    cursor: cursors[point],
  }

  return style;
}

cursors 这里做了方向映射,不同的圆点对应的 cursor 指针样式不同。

有了小圆点,我们就可以通过拖动小圆点改变 block size

3、拖动小圆点进行放大缩小

拖动移动,这里我们依然采用 onMouseDown 事件:

{block.focus ? pointList.map(point => (
  <div key={point} className="shape-point" 
    onMouseDown={event => handleMouseDownOnPoint(event, point)}
    style={getPointStyle(point)}></div>
)) : null}

梳理一下:

  1. 首先要阻止冒泡事件,避免触发 Block 的拖拽移动事件;
  2. 在拖动小圆点前,记录当前鼠标位置信息;
  3. 拖动过程中用最新的位置信息和初始位置信息进行比较,计算差值;
  4. 根据差值分别计算得出 block:widthheightlefttop
  5. 拿底部 button 这个圆点举例:这个点拖动只会改变组件的高度,若差值是正数,说明在往下拉,组件高度增加;若差值是负数,说明在往上拉,组件高度减小。
const handleMouseDownOnPoint = (event, point) => {
  event.stopPropagation();
  const { clientX: startX, clientY: startY } = event;
  const { width, height, top, left } = block;

  const handleMouseMove = event => {
    let { clientX: moveX, clientY: moveY } = event;
    const diffX = moveX - startX;
    const diffY = moveY - startY;
    const hasT = /t/.test(point),
      hasB = /b/.test(point),
      hasL = /l/.test(point),
      hasR = /r/.test(point);
    const newHeight = height + (hasT ? -diffY : hasB ? diffY : 0);
    const newWidth = width + (hasL ? -diffX : hasR ? diffX : 0);
    block.height = Math.max(newHeight, 0);
    block.width = Math.max(newWidth, 0);
    block.left = left + (hasL ? diffX : 0);
    block.top = top + (hasT ? diffY : 0);
    forceUpdate();
  }

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

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

将改变后的 widthheight 作用于 Block 组件:

const blockStyle = {
  top: block.top,
  left: block.left,
  width: block.width,
  height: block.height,
  zIndex: block.zIndex,
};

<div
  ...
  style={blockStyle}
  className={`editor-block ${block.focus ? 'editor-block-focus' : ''}`}>
  ...
</div>

另外,由于我们将 style 信息赋予给了 Block 内的 div 盒子,并非对应的 RenderComponent

因此,RenderComponent 需要设置 width、height 为 100% 才能实现真正的放大缩小。

我们在 config.js 中为注册的组件改造如下:

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

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

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 同步在设置器中。

最后

感谢阅读。

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

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

转载自:https://juejin.cn/post/7097913504828489735
评论
请登录