模板编辑工具

## 背景目的

互联网医院商城移动端需要运营人员动态配置展示内容，从展示哪些内容，内容的展示顺序，内容的数据几个维度上都需要是灵活可变的。当然运营不可能 coding 页面，所以需要一套动态的可视化编辑器来帮助他们完成这些工作。

现阶段目标:

- [x] 图形化编辑，拖拽排序等功能
- [x] 可扩展控件
- [x] 可扩展数据类型
- [x] 效果预览
- [x] JSON 格式数据，支持 SSR
- [ ] 撤销，前进功能
- [ ] ...

## 概念

### 控件-Control

控件是客户端可见的界面，一个控件包含一套完成的页面和内部的功能逻辑。比如`轮播`，`推荐商品`等都是控件。控件表面和组件类似，但控件是组件和一系列配置的集合，因为控件除了展示页面，还需要告诉编辑器 `我是谁（组件的身份）`、`我有哪些数据类型和可编辑`，`页面和编辑的数据是如何关联的`等问题。

> 现阶段控件会铺满容器。

#### 结构

一个基础的控件文件夹包含以下文件

```bash
├── ControlName.tsx // 组件实现
├── config.json // 配置文件
├── index.ts // 入口文件
└── 其他依赖文件
```

#### 配置文件

下面是配置的属性

| 属性     | 类型   | 说明                                             | 必填 | 默认值 |
| -------- | ------ | ------------------------------------------------ | ---- | ------ |
| name     | string | 控件的唯一名称，相当于 id                        | 是   | -      |
| showName | string | 控件的显示名称，不传则取 name                    | 否   | -      |
| group    | string | 控件所在的分类，相同分类的控件会归纳在同一个分组 | 否   | 默认   |
| thumb    | string | 控件展示的缩略图                                 | 是   | -      |
| props    | array  | 定义控件的依赖的属性，是控件配置的核心           | 是   | -      |

##### 配置 props

`props`声明了控件 UI 依赖的属性值，声明的类型的转化成值会以 props 的形式传递给 React UI 组件，也就是说，这里定义了组件的每一个 prop 的类型。

每一个 prop 至少包含`key`和`schema`两个属性。

- `key` 属性的键，比如 key 是`url`，那么 UI 组件会接受到一个`url`的 prop
- `schema` 属性的类型，比如`SchemaTypes.Text`声明了这个值是一个字符串。schema 不仅决定了值的类型，还决定了在编辑这个值时页面上是如何展示的，如文本框，选择器等等。此外我们还可以自定义 schema，这个后面会提到。

基本的配置

```typescript
{
  props: [
    {
      key: 'url',
      schema: SchemaTypes.Text,
    },
  ]
}
```

当类型有扩展属性时，可以将 schema 以 `type` 字段声明，其他字段作为扩展属性，如定义`inputProps`传递给输入框控件，内部有`maxLength`为最大输入长度。

```typescript
{
  props: [
    {
      key: 'url',
      schema: {
        type: SchemaTypes.Text,
        inputProps: {
          maxLength: 4, // 最大长度为4
        },
      },
    },
  ]
}
```

`schema`还支持嵌套声明，不过由于多层嵌套在 UI 层面不好展示层级关系，因此目前只支持一层嵌套。如**分类 1**有`name`和`link`2 个属性。

```typescript
{
  props: [
    {
      key: 'imageLink',
      schema: {
        name: {
          type: SchemaTypes.Text,
          inputProps: {
            maxLength: 4, // 最大长度为4
          },
        },
        link: {
          type: SchemaTypes.Link,
        },
      },
    },
  ]
}
```

我们默认支持了数组类型的`schema`，当类型为数组时，会有个额外的`item`属性，`item`的配置和`schema`配置完全一致。此外还可以通过`max`属性来限制最大长度。

如一个可编辑长度的分类控件配置如下

```typescript
{
  props: [
    {
      key: 'categories',
      schema: {
        type: SchemaTypes.Array,
        max: 8,
        item: {
          name: {
            type: SchemaTypes.Text,
            inputProps: {
              maxLength: 4, // 最大长度为4
            },
          },
          link: {
            type: SchemaTypes.Link,
          },
        },
      },
    },
  ]
}
```

> 当数组类型在界面上初始化时，编辑器会生成一个默认项，当长度为 1 时最后一项将无法删除，也就是说数组值的长度永远不会为 0，除非删除整个控件。

除了 type 外还有一些通用的属性

| 属性     | 类型   | 说明            | 必填 | 默认值 |
| -------- | ------ | --------------- | ---- | ------ |
| default  | any    | schema 的默认值 | 否   | -      |
| showName | string | schema 的标签   | 否   | -      |
| tips     | string | 控件的提示文案  | 否   | -      |

#### 控件组件

控件组件实际上就是一个 React 组件，上面已经提到组件的`props`就是 config 中配置的`props`属性。

例如上面的**链接图片**控件的组件实现大概是这样的

```tsx
import React from 'react'
import './style.css'

interface Props {
  url: string
  link: string
}

export default (props: Props) => {
  const { url, link } = props

  return (
    <Link href={link} className="mt-full-image-box">
      <img src={url} className="mt-full-image" alt="" />
    </Link>
  )
}
```

当编辑者选中控件到**视窗面板（vision panel）**后，页面会渲染这个组件的内容。如果点击控件，**编辑视图（editor panel）**会出现控件定义的 props 的编辑控件用于编辑。

##### Interactive

默认支持控件的整体选中进行编辑，但是有时候我们的控件上可定义的元素会比较多，这时让使用者判断当前编辑的哪一部分内容会比较困难。为了解决这个问题，我们提供了一个`Interactive`组件来解决这个问题。它有以下功能

- 被包裹的组件会成为一个可交互的元素，当点击时会定位到元素对应的编辑控件
- 当元素对应的编辑控件获取焦点时，元素也会高亮
- 元素属性更改时，高亮部分也会随属性变化而变化

`Interactive`组件并不会对组件本身的渲染造成影响，在实际运行时相当于直接返回了被包裹的组件本身。

它的使用也非常的简单，下面以分类控件来演示用法。

```tsx
categories.map((item, index) => {
  return (
    <Interactive path={`categories[${index}].link`} key={index}>
      <div className="mt-category-auto-item">
        <Interactive path={`categories[${index}].icon`}>
          <img className="mt-category-auto-item-icon" src={item.icon} alt="" />
        </Interactive>
        <Interactive path={`categories[${index}].name`}>
          <div className="mt-category-auto-item-text">{item.name}</div>
        </Interactive>
      </div>
    </Interactive>
  )
})
```

将需要显示的组件用 Interactive 进行包裹，并传入`path`属性，`path`是当前组件依赖的属性路径。如第三个分类的图标的`path`为`categories[2].icon`。因为链接是针对于整个数组有效，所以在最外层包裹并设置`path`为`categories[2].link`。

#### 导出和注册

从`index.ts`导出控件的组件和配置

```typescript
import Component from './Component'
import config from './config'

export default {
  Component,
  config,
}
```

当在页面入口通过`regsitControls`注册组件后，在**控件面板（`control panel`）**便能看到所有控件。

```typescript
// App.tsx

mt.regsitControls([fullImage])
```

### 数据定义-Schema

Schema 定义控件某个属性的类型，它确定了编辑时的 UI 和产生的数据。

#### Schema 组件

Schema 通过一个 React 组件实现功能，下面是文本输入框的 schema 实现

```typescript
import React, { useCallback, useRef } from 'react'
import './style.css'
import { useSchemaFocus, SchemaTypes } from '../../core/schema'

export default (props: SchemaCompPropsWithConfig) => {
  const { schemaDefinition, value, onChange } = props
  const { inputProps, tips, showName } = schemaDefinition
  const inputRef = useRef<HTMLInputElement>(null)
  const handleFocus = useCallback(() => {
    if (inputRef.current) {
      inputRef.current.focus()
    }
  }, [])

  const IS_NUMBER = schemaDefinition.type === SchemaTypes.Number

  const { focus, blur } = useSchemaFocus(handleFocus)

  return (
    <div className="mt-form-el">
      <label>
        {showName && <span className="mt-form-el-label">{showName}: </span>}
        <input
          ref={inputRef}
          value={value === undefined || value === null ? '' : value}
          className="mt-input-text"
          type="text"
          placeholder={`请输入${schemaDefinition.showName || ''}`}
          {...inputProps}
          onChange={e => {
            const value = Number(e.target.value)
            if (IS_NUMBER) {
              console.log(value)
              onChange(isNaN(value) ? 0 : value)
            } else {
              onChange(e.target.value)
            }
          }}
          onBlur={blur}
          onFocus={focus}
        />
        {tips && <div className="mt-form-el-tips">* {tips}</div>}
      </label>
    </div>
  )
}
```

Schema 的实现内容没有强制要求，每一个 Schema 组件会被传入一下属性

| 属性             | 类型              | 说明            | 必填 | 默认值 |
| ---------------- | ----------------- | --------------- | ---- | ------ |
| schemaDefinition | any               | schema 的默认值 | 否   | -      |
| value            | any               | 值              | 否   | -      |
| onChange         | (value:any)=>void | 值更改的回调    | 否   | -      |

Schema 组件会有 2 种控制的状态

1. 获取焦点 - 当编辑视窗的控件中的某个可交互的（Interactive）控件元素进行点击时，schema 组件会获取焦点
2. 失去焦点 - 当其他 schema 控件获取焦点或者对应控件元素失去焦点时，当前 schema 组件会失去焦点。

我们通过`useSchemaFocus` hook 来控制 Schema 的获焦和失焦行为。它接受一个回调函数，当控件元素获取焦点时会触发。同时返回`focus`和`blur`方法，如果 schema 组件在操作时获取和失去焦点时（如输入框获取了焦点），执行对应的方法能时让编辑视窗的控件获取焦点。

> 建议使用浏览器原生获取焦点的能力来避免怪异行为，例如`tabindex`等。。

#### Schema 注册

schema 需要注册后才能在控件中使用，也就是说必须在控件注册之前注册 schema。

注册后的 schema 可以在`SchemaTypes`获取。

```typescript
registSchemaComp('DrugList', DrugList)
registSchemaComp('DrugCategory', DrugCategory)

// 使用
{
   type: SchemaTypes.DrugList,
}
```

## 其他组件

### Link

Link 组件用于解决编辑过程时 a 标签点击问题

### useEnv

获取当前的环境上下文，如是编辑模式(edit)还是渲染模式(render)。
