init
This commit is contained in:
27
LICENSE
Normal file
27
LICENSE
Normal file
@@ -0,0 +1,27 @@
|
||||
MIT License
|
||||
|
||||
Original work Copyright (c) 2021 qrai (https://github.com/qrailibs/webbuilder.js)
|
||||
Modified work Copyright (c) 2024
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
This project is based on webbuilder.js by qrai.
|
||||
Original repository: https://github.com/qrailibs/webbuilder.js
|
||||
260
README.md
Normal file
260
README.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# WebBuilder.js
|
||||
|
||||
IoT 可视化编辑器库 - 基于网格布局的拖拽式页面构建器
|
||||
|
||||
中文 | [English](README_EN.md)
|
||||
|
||||
## 特性
|
||||
|
||||
- 📱 固定手机比例画布(375×812)
|
||||
- 📐 网格布局,单元格 1:1 正方形
|
||||
- 🖱️ 拖拽添加、移动组件
|
||||
- ⚙️ 属性面板,实时预览
|
||||
- 💾 JSONB 格式保存/加载
|
||||
- 🎨 自定义组件支持
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
.
|
||||
├── js/lib/webbuilder/ # 核心库(你需要引入的)
|
||||
│ ├── webbuilder.js # 主文件
|
||||
│ └── types/ # 内部模块
|
||||
├── editor.html # 示例编辑器
|
||||
├── js/editor/editor.js # 示例编辑器逻辑
|
||||
├── css/editor.css # 示例编辑器样式
|
||||
├── README.md # 中文文档
|
||||
└── README_EN.md # English
|
||||
```
|
||||
|
||||
> **注意**: `editor.html` 和 `js/editor/` 是示例代码,展示如何使用本库。
|
||||
> 实际使用时只需引入 `js/lib/webbuilder/webbuilder.js`,然后自行实现编辑器逻辑。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 引入文件
|
||||
|
||||
```html
|
||||
<script src="js/lib/webbuilder/webbuilder.js"></script>
|
||||
```
|
||||
|
||||
### 2. 创建容器
|
||||
|
||||
```html
|
||||
<div id="editor" style="width: 100%; height: 600px;"></div>
|
||||
```
|
||||
|
||||
### 3. 初始化编辑器
|
||||
|
||||
```javascript
|
||||
webbuilder.init('#editor');
|
||||
```
|
||||
|
||||
### 4. 注册组件
|
||||
|
||||
```javascript
|
||||
webbuilder.define('gauge', {
|
||||
type: 'gauge',
|
||||
label: '仪表盘',
|
||||
icon: '📊',
|
||||
defaultColumnSpan: 6,
|
||||
defaultRowSpan: 6,
|
||||
defaultProps: {
|
||||
label: '温度',
|
||||
value: 25,
|
||||
unit: '°C'
|
||||
},
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: '标签' },
|
||||
{ type: 'number', name: 'value', label: '值' },
|
||||
{ type: 'text', name: 'unit', label: '单位' }
|
||||
],
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'my-gauge';
|
||||
el.innerHTML = '<div class="value">' + props.value + props.unit + '</div>' +
|
||||
'<div class="label">' + props.label + '</div>';
|
||||
return el;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### 初始化
|
||||
|
||||
```javascript
|
||||
webbuilder.init(containerSelector)
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| containerSelector | string/HTMLElement | 容器选择器或元素 |
|
||||
|
||||
### 注册组件
|
||||
|
||||
```javascript
|
||||
webbuilder.define(name, definition)
|
||||
```
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| name | string | 组件唯一标识 |
|
||||
| definition | object | 组件定义对象 |
|
||||
|
||||
**definition 结构:**
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'component-type', // 组件类型
|
||||
label: '组件名称', // 显示名称
|
||||
icon: '📊', // 工具箱图标
|
||||
defaultColumnSpan: 6, // 默认跨列数
|
||||
defaultRowSpan: 4, // 默认跨行数
|
||||
defaultProps: { ... }, // 默认属性
|
||||
traits: [ ... ], // 属性定义(用于属性面板)
|
||||
render: function(props) { ... } // 渲染函数,返回 DOM 元素
|
||||
}
|
||||
```
|
||||
|
||||
### 导出 JSONB
|
||||
|
||||
```javascript
|
||||
var jsonb = webbuilder.toJSONB();
|
||||
```
|
||||
|
||||
返回格式:
|
||||
|
||||
```javascript
|
||||
{
|
||||
version: '1.0',
|
||||
layouts: {
|
||||
phone: {
|
||||
canvas: {
|
||||
width: 375,
|
||||
height: 812,
|
||||
columns: 24,
|
||||
cellSize: 13.5,
|
||||
rows: 52,
|
||||
gap: 2,
|
||||
background: '#f5f5f5'
|
||||
},
|
||||
components: [
|
||||
{
|
||||
id: 'gauge-1',
|
||||
type: 'gauge',
|
||||
grid: {
|
||||
startCell: 0,
|
||||
colSpan: 6,
|
||||
rowSpan: 6
|
||||
},
|
||||
props: { ... }
|
||||
}
|
||||
]
|
||||
},
|
||||
computer: null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 加载 JSONB
|
||||
|
||||
```javascript
|
||||
webbuilder.fromJSONB(jsonb);
|
||||
```
|
||||
|
||||
### 渲染到容器
|
||||
|
||||
```javascript
|
||||
var components = webbuilder.renderToDiv(container);
|
||||
```
|
||||
|
||||
返回组件列表:
|
||||
|
||||
```javascript
|
||||
[
|
||||
{
|
||||
element: HTMLElement, // 组件 DOM 元素
|
||||
wrapper: HTMLElement, // 包装容器
|
||||
props: { ... }, // 组件属性(深拷贝)
|
||||
id: 'gauge-1',
|
||||
type: 'gauge'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 其他方法
|
||||
|
||||
```javascript
|
||||
// 获取画布配置
|
||||
webbuilder.getCanvasConfig();
|
||||
|
||||
// 获取所有组件
|
||||
webbuilder.getComponents();
|
||||
|
||||
// 清空画布
|
||||
webbuilder.clear();
|
||||
```
|
||||
|
||||
## 组件定义规范
|
||||
|
||||
### traits 属性定义
|
||||
|
||||
用于生成属性面板:
|
||||
|
||||
```javascript
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: '标签', default: '' },
|
||||
{ type: 'number', name: 'value', label: '值', default: 0 },
|
||||
{ type: 'checkbox', name: 'state', label: '状态', default: false }
|
||||
]
|
||||
```
|
||||
|
||||
| type | 说明 |
|
||||
|------|------|
|
||||
| text | 文本输入框 |
|
||||
| number | 数字输入框 |
|
||||
| checkbox | 复选框 |
|
||||
|
||||
### render 函数
|
||||
|
||||
必须返回 DOM 元素:
|
||||
|
||||
```javascript
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.textContent = props.label;
|
||||
return el;
|
||||
}
|
||||
```
|
||||
|
||||
## 网格系统
|
||||
|
||||
- **画布宽度**:375px(固定)
|
||||
- **列数**:24列
|
||||
- **单元格大小**:约 13.5px × 13.5px(正方形)
|
||||
- **组件位置**:通过 `startCell`(起始格子编号)+ `colSpan`(跨列数)+ `rowSpan`(跨行数)定义
|
||||
|
||||
格子编号从 0 开始,按行递增:
|
||||
|
||||
```
|
||||
0 1 2 3 ... 23
|
||||
24 25 26 27 ... 47
|
||||
48 49 50 51 ... 71
|
||||
...
|
||||
```
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
- Chrome 60+
|
||||
- Firefox 55+
|
||||
- Safari 11+
|
||||
- Edge 79+
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
*参考项目:[webbuilder.js](https://github.com/qrailibs/webbuilder.js)*
|
||||
260
README_EN.md
Normal file
260
README_EN.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# WebBuilder.js
|
||||
|
||||
IoT Visual Editor Library - Grid-based Drag & Drop Page Builder
|
||||
|
||||
[中文文档](README.md) | English
|
||||
|
||||
## Features
|
||||
|
||||
- 📱 Fixed mobile canvas (375×812)
|
||||
- 📐 Grid layout with 1:1 square cells
|
||||
- 🖱️ Drag & drop to add and move components
|
||||
- ⚙️ Property panel with real-time preview
|
||||
- 💾 JSONB format save/load
|
||||
- 🎨 Custom component support
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── js/lib/webbuilder/ # Core library (what you need to include)
|
||||
│ ├── webbuilder.js # Main file
|
||||
│ └── types/ # Internal modules
|
||||
├── editor.html # Example editor
|
||||
├── js/editor/editor.js # Example editor logic
|
||||
├── css/editor.css # Example editor styles
|
||||
├── README.md # Chinese
|
||||
└── README_EN.md # English
|
||||
```
|
||||
|
||||
> **Note**: `editor.html` and `js/editor/` are example code showing how to use this library.
|
||||
> For actual use, you only need to include `js/lib/webbuilder/webbuilder.js` and implement your own editor logic.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Include the library
|
||||
|
||||
```html
|
||||
<script src="js/lib/webbuilder/webbuilder.js"></script>
|
||||
```
|
||||
|
||||
### 2. Create container
|
||||
|
||||
```html
|
||||
<div id="editor" style="width: 100%; height: 600px;"></div>
|
||||
```
|
||||
|
||||
### 3. Initialize editor
|
||||
|
||||
```javascript
|
||||
webbuilder.init('#editor');
|
||||
```
|
||||
|
||||
### 4. Register components
|
||||
|
||||
```javascript
|
||||
webbuilder.define('gauge', {
|
||||
type: 'gauge',
|
||||
label: 'Gauge',
|
||||
icon: '📊',
|
||||
defaultColumnSpan: 6,
|
||||
defaultRowSpan: 6,
|
||||
defaultProps: {
|
||||
label: 'Temperature',
|
||||
value: 25,
|
||||
unit: '°C'
|
||||
},
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: 'Label' },
|
||||
{ type: 'number', name: 'value', label: 'Value' },
|
||||
{ type: 'text', name: 'unit', label: 'Unit' }
|
||||
],
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'my-gauge';
|
||||
el.innerHTML = '<div class="value">' + props.value + props.unit + '</div>' +
|
||||
'<div class="label">' + props.label + '</div>';
|
||||
return el;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Initialize
|
||||
|
||||
```javascript
|
||||
webbuilder.init(containerSelector)
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| containerSelector | string/HTMLElement | Container selector or element |
|
||||
|
||||
### Register component
|
||||
|
||||
```javascript
|
||||
webbuilder.define(name, definition)
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| name | string | Unique component identifier |
|
||||
| definition | object | Component definition object |
|
||||
|
||||
**definition structure:**
|
||||
|
||||
```javascript
|
||||
{
|
||||
type: 'component-type', // Component type
|
||||
label: 'Component Name', // Display name
|
||||
icon: '📊', // Toolbox icon
|
||||
defaultColumnSpan: 6, // Default column span
|
||||
defaultRowSpan: 4, // Default row span
|
||||
defaultProps: { ... }, // Default properties
|
||||
traits: [ ... ], // Property definitions (for property panel)
|
||||
render: function(props) { ... } // Render function, returns DOM element
|
||||
}
|
||||
```
|
||||
|
||||
### Export JSONB
|
||||
|
||||
```javascript
|
||||
var jsonb = webbuilder.toJSONB();
|
||||
```
|
||||
|
||||
Return format:
|
||||
|
||||
```javascript
|
||||
{
|
||||
version: '1.0',
|
||||
layouts: {
|
||||
phone: {
|
||||
canvas: {
|
||||
width: 375,
|
||||
height: 812,
|
||||
columns: 24,
|
||||
cellSize: 13.5,
|
||||
rows: 52,
|
||||
gap: 2,
|
||||
background: '#f5f5f5'
|
||||
},
|
||||
components: [
|
||||
{
|
||||
id: 'gauge-1',
|
||||
type: 'gauge',
|
||||
grid: {
|
||||
startCell: 0,
|
||||
colSpan: 6,
|
||||
rowSpan: 6
|
||||
},
|
||||
props: { ... }
|
||||
}
|
||||
]
|
||||
},
|
||||
computer: null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Load JSONB
|
||||
|
||||
```javascript
|
||||
webbuilder.fromJSONB(jsonb);
|
||||
```
|
||||
|
||||
### Render to container
|
||||
|
||||
```javascript
|
||||
var components = webbuilder.renderToDiv(container);
|
||||
```
|
||||
|
||||
Returns component list:
|
||||
|
||||
```javascript
|
||||
[
|
||||
{
|
||||
element: HTMLElement, // Component DOM element
|
||||
wrapper: HTMLElement, // Wrapper container
|
||||
props: { ... }, // Component properties (deep copy)
|
||||
id: 'gauge-1',
|
||||
type: 'gauge'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Other methods
|
||||
|
||||
```javascript
|
||||
// Get canvas config
|
||||
webbuilder.getCanvasConfig();
|
||||
|
||||
// Get all components
|
||||
webbuilder.getComponents();
|
||||
|
||||
// Clear canvas
|
||||
webbuilder.clear();
|
||||
```
|
||||
|
||||
## Component Definition Specification
|
||||
|
||||
### traits - Property definitions
|
||||
|
||||
Used to generate property panel:
|
||||
|
||||
```javascript
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: 'Label', default: '' },
|
||||
{ type: 'number', name: 'value', label: 'Value', default: 0 },
|
||||
{ type: 'checkbox', name: 'state', label: 'State', default: false }
|
||||
]
|
||||
```
|
||||
|
||||
| type | Description |
|
||||
|------|-------------|
|
||||
| text | Text input |
|
||||
| number | Number input |
|
||||
| checkbox | Checkbox |
|
||||
|
||||
### render function
|
||||
|
||||
Must return a DOM element:
|
||||
|
||||
```javascript
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.textContent = props.label;
|
||||
return el;
|
||||
}
|
||||
```
|
||||
|
||||
## Grid System
|
||||
|
||||
- **Canvas width**: 375px (fixed)
|
||||
- **Columns**: 24
|
||||
- **Cell size**: ~13.5px × 13.5px (square)
|
||||
- **Component position**: Defined by `startCell` (starting cell index) + `colSpan` (column span) + `rowSpan` (row span)
|
||||
|
||||
Cell index starts from 0 and increases row by row:
|
||||
|
||||
```
|
||||
0 1 2 3 ... 23
|
||||
24 25 26 27 ... 47
|
||||
48 49 50 51 ... 71
|
||||
...
|
||||
```
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- Chrome 60+
|
||||
- Firefox 55+
|
||||
- Safari 11+
|
||||
- Edge 79+
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
*Based on [webbuilder.js](https://github.com/qrailibs/webbuilder.js) by qrai*
|
||||
311
TODO.md
Normal file
311
TODO.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# IoT 可视化编辑器 TODO
|
||||
|
||||
## 目标
|
||||
|
||||
基于 webbuilder.js 改造,实现支持网格布局的 IoT 可视化编辑器。
|
||||
|
||||
## 技术方案
|
||||
|
||||
- **布局方式**:CSS Grid 网格布局(24列)
|
||||
- **单元格比例**:1:1(正方形)
|
||||
- **画布尺寸**:固定手机宽度(375px),高度自适应
|
||||
- **存储格式**:JSONB,预留多端接口(phone/computer)
|
||||
- **前端技术栈**:纯 HTML + CSS + JavaScript(禁止 npm)
|
||||
|
||||
---
|
||||
|
||||
## 一、核心框架修改
|
||||
|
||||
### 1.1 webbuilder.js 主模块
|
||||
|
||||
- [x] 初始化时接收容器元素(div)
|
||||
- [x] 移除组件类型限制(element/container)
|
||||
- [x] 添加画布配置(列数、行高、间距)
|
||||
- [x] 添加 `getCanvasConfig()` 方法
|
||||
- [x] 添加 `setCanvasConfig(config)` 方法
|
||||
- [x] 添加 `toJSONB()` 方法 - 导出为 JSONB
|
||||
- [x] 添加 `fromJSONB(jsonb)` 方法 - 从 JSONB 加载
|
||||
- [x] 添加 `toHTML()` 方法 - 生成 HTML 代码
|
||||
|
||||
### 1.2 Component.js 组件类
|
||||
|
||||
- [x] 添加 `id` 属性(唯一标识)
|
||||
- [x] 添加 `props` 属性(组件自定义属性)
|
||||
- [x] 添加 `style` 属性(组件样式)
|
||||
- [x] 添加 `gridPosition` 属性(网格位置)
|
||||
|
||||
```javascript
|
||||
gridPosition: {
|
||||
column: 1, // 起始列
|
||||
row: 1, // 起始行
|
||||
columnSpan: 3, // 跨列数
|
||||
rowSpan: 2 // 跨行数
|
||||
}
|
||||
```
|
||||
- [x] 添加 `toJSONB()` 方法
|
||||
- [x] 添加 `fromJSONB(jsonb)` 静态方法
|
||||
- [x] 添加 `toHTML()` 方法
|
||||
|
||||
### 1.3 BuilderContainer.js 容器类
|
||||
|
||||
- [x] 修改为 Grid 容器
|
||||
- [x] 添加网格配置(columns、rowHeight、gap)
|
||||
- [x] 修改拖放逻辑:计算目标行列位置
|
||||
- [x] 添加组件拖拽移动功能(在画布内移动)
|
||||
- [x] 添加组件选中/取消选中功能
|
||||
- [x] 添加组件删除功能
|
||||
- [ ] 添加网格辅助线显示
|
||||
|
||||
### 1.4 BuilderToolbox.js 工具箱类
|
||||
|
||||
- [x] 添加组件分类支持
|
||||
- [x] 添加组件预览图标
|
||||
- [ ] 添加搜索/过滤功能
|
||||
- [ ] 按分类组织:数据展示类、控制输入类、布局容器类
|
||||
|
||||
---
|
||||
|
||||
## 二、编辑器功能
|
||||
|
||||
### 2.1 画布编辑
|
||||
|
||||
- [x] 画布尺寸配置(支持手机/电脑端预览切换)
|
||||
- [x] 手机端:375x812,24列网格
|
||||
- [x] 电脑端:1200x800,24列网格
|
||||
- [ ] 网格线显示/隐藏
|
||||
- [x] 组件吸附到网格
|
||||
- [x] 组件拖拽调整位置
|
||||
- [ ] 组件拖拽调整大小(跨列/跨行)
|
||||
- [ ] 组件右键菜单(删除、复制、置顶/置底)
|
||||
|
||||
### 2.2 组件选中
|
||||
|
||||
- [x] 点击选中组件
|
||||
- [x] 选中高亮边框
|
||||
- [ ] 显示调整大小手柄
|
||||
- [x] 点击空白处取消选中
|
||||
|
||||
### 2.3 属性面板
|
||||
|
||||
- [x] 选中组件时显示属性面板
|
||||
- [x] 显示组件基础属性(位置、大小)
|
||||
- [x] 显示组件自定义属性
|
||||
- [x] 实时更新组件
|
||||
|
||||
### 2.4 工具栏
|
||||
|
||||
- [x] 保存按钮
|
||||
- [x] 预览按钮
|
||||
- [ ] 撤销/重做按钮(可选)
|
||||
- [x] 清空画布按钮
|
||||
|
||||
---
|
||||
|
||||
## 三、JSONB 存储结构
|
||||
|
||||
### 3.1 输出格式
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"layouts": {
|
||||
"phone": {
|
||||
"canvas": {
|
||||
"width": 375,
|
||||
"height": 812,
|
||||
"columns": 24,
|
||||
"rowHeight": 30,
|
||||
"gap": 8,
|
||||
"background": "#f5f5f5"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"id": "gauge-001",
|
||||
"type": "gauge",
|
||||
"grid": {
|
||||
"startCell": 0,
|
||||
"colSpan": 6,
|
||||
"rowSpan": 6
|
||||
},
|
||||
"props": {
|
||||
"label": "温度",
|
||||
"value": 25,
|
||||
"unit": "°C"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"computer": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 加载功能
|
||||
|
||||
- [x] 从 JSONB 恢复画布配置
|
||||
- [x] 从 JSONB 恢复所有组件
|
||||
- [x] 从 JSONB 恢复组件位置和属性
|
||||
|
||||
---
|
||||
|
||||
## 四、HTML 生成
|
||||
|
||||
### 4.1 生成规则
|
||||
|
||||
- [x] 根据 JSONB 生成完整的 HTML 结构
|
||||
- [x] 生成内联 CSS 样式
|
||||
- [ ] 生成组件初始化脚本
|
||||
|
||||
### 4.2 输出格式
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
.iot-canvas {
|
||||
width: 375px;
|
||||
height: 812px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, 1fr);
|
||||
grid-auto-rows: 30px;
|
||||
gap: 8px;
|
||||
background: #f5f5f5;
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="iot-canvas">
|
||||
<div id="gauge-001" class="iot-component" style="grid-column: 1 / span 6; grid-row: 1 / span 6;">
|
||||
<!-- 组件内容 -->
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、组件接口规范
|
||||
|
||||
框架不实现具体组件,只提供接口规范,使用者按规范自定义组件。
|
||||
|
||||
### 5.1 组件注册接口
|
||||
|
||||
```javascript
|
||||
// 注册自定义组件
|
||||
webbuilder.define('my-component', {
|
||||
// 组件唯一标识
|
||||
type: 'my-component',
|
||||
|
||||
// 默认属性
|
||||
defaultProps: {
|
||||
label: '默认文本',
|
||||
value: 0
|
||||
},
|
||||
|
||||
// 属性定义(用于属性面板)
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: '标签' },
|
||||
{ type: 'number', name: 'value', label: '值' },
|
||||
{ type: 'device-selector', name: 'deviceId', label: '绑定设备' }
|
||||
],
|
||||
|
||||
// 编辑器中的渲染函数
|
||||
render: function(props) {
|
||||
return '<div class="my-component">' + props.label + '</div>';
|
||||
},
|
||||
|
||||
// 预览图标(可选)
|
||||
icon: '📊'
|
||||
});
|
||||
```
|
||||
|
||||
### 5.2 组件接口要求
|
||||
|
||||
每个组件必须实现:
|
||||
- `type` - 组件类型标识
|
||||
- `defaultProps` - 默认属性对象
|
||||
- `render(props)` - 返回 HTML 字符串
|
||||
|
||||
可选实现:
|
||||
- `traits` - 属性定义数组,用于属性面板
|
||||
- `icon` - 工具箱中显示的图标
|
||||
- `onSelect()` - 选中时的回调
|
||||
- `onDrop()` - 拖放完成后的回调
|
||||
- `onDestroy()` - 销毁时的回调
|
||||
---
|
||||
|
||||
## 六、显示端适配
|
||||
|
||||
### 6.1 手机端
|
||||
|
||||
- [x] 渲染在固定手机比例的 div 中
|
||||
- [x] 使用 24 列网格
|
||||
- [x] 组件位置和大小按比例显示
|
||||
|
||||
### 6.2 电脑端(预留)
|
||||
|
||||
- [ ] 未来扩展,暂不实现
|
||||
- [ ] JSONB 格式已预留 computer 接口
|
||||
---
|
||||
|
||||
## 七、文件结构
|
||||
|
||||
```
|
||||
.
|
||||
├── editor.html # 编辑器入口
|
||||
├── css/
|
||||
│ └── editor.css # 编辑器样式
|
||||
├── js/
|
||||
│ ├── editor/
|
||||
│ │ └── editor.js # 编辑器主逻辑
|
||||
│ └── lib/
|
||||
│ └── webbuilder/ # 修改后的 webbuilder
|
||||
│ ├── webbuilder.js
|
||||
│ └── LICENSE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、开发顺序
|
||||
|
||||
### 第一阶段:核心框架 ✅
|
||||
1. ✅ 修改 webbuilder.js 核心类
|
||||
2. ✅ 实现网格布局容器
|
||||
3. ✅ 实现组件拖放定位
|
||||
|
||||
### 第二阶段:编辑器功能 ✅
|
||||
4. ✅ 实现组件选中
|
||||
5. ✅ 实现属性面板
|
||||
6. ✅ 实现保存/加载
|
||||
|
||||
### 第三阶段:组件接口 ✅
|
||||
7. ✅ 定义组件接口规范
|
||||
8. ✅ 提供组件注册示例
|
||||
|
||||
### 第四阶段:完善功能
|
||||
9. ✅ 实现 HTML 生成
|
||||
10. ✅ 实现响应式适配
|
||||
11. 实现显示页面
|
||||
---
|
||||
|
||||
## 九、已确认事项
|
||||
|
||||
| 事项 | 决定 |
|
||||
|------|------|
|
||||
| 画布宽度 | 固定 375px |
|
||||
| 画布高度 | 自适应(根据行数计算) |
|
||||
| 网格粒度 | 24列 |
|
||||
| 单元格比例 | 1:1(正方形) |
|
||||
| 多端适配 | 仅支持手机端,电脑端预留接口 |
|
||||
| 组件位置 | 使用 startCell + colSpan + rowSpan |
|
||||
| 组件开发 | 框架只提供接口,使用者自定义实现 |
|
||||
| 折线图 | 使用 Chart.js(使用者自行集成) |
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-03-29*
|
||||
455
css/editor.css
Normal file
455
css/editor.css
Normal file
@@ -0,0 +1,455 @@
|
||||
/* 基础样式 */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: #f0f2f5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 应用容器 */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* 顶部工具栏 */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toolbar-info {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
background: #f5f5f5;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
background: #ff4d4f;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #ff7875;
|
||||
}
|
||||
|
||||
/* 编辑器容器 */
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 工具箱 */
|
||||
.webbuilder-toolbox {
|
||||
width: 200px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.webbuilder-toolbox-title {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.webbuilder-toolbox-list {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.webbuilder-toolbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
cursor: grab;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.webbuilder-toolbox-item:hover {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.webbuilder-toolbox-item .icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.webbuilder-toolbox-item .label {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 画布容器 */
|
||||
.webbuilder-canvas-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
/* 画布 */
|
||||
.webbuilder-canvas {
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.webbuilder-canvas.dropping {
|
||||
outline: 2px dashed #1890ff;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 组件 */
|
||||
.webbuilder-component {
|
||||
background: #fff;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.3s;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.webbuilder-component:hover {
|
||||
border-color: #91d5ff;
|
||||
}
|
||||
|
||||
.webbuilder-component.selected {
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
/* 属性面板 */
|
||||
.webbuilder-property-panel {
|
||||
width: 280px;
|
||||
background: #fff;
|
||||
border-left: 1px solid #e8e8e8;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.property-title {
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.property-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.property-section-title {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.property-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.property-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.property-row label {
|
||||
width: 60px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.property-row input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.property-row input:focus {
|
||||
outline: none;
|
||||
border-color: #1890ff;
|
||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
|
||||
}
|
||||
|
||||
.property-row input[type="checkbox"] {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* 弹窗 */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 80%;
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.modal-body pre {
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
/* 示例组件样式 */
|
||||
.iot-gauge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.iot-gauge .value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.iot-gauge .label {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.iot-switch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.iot-switch .switch {
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
background: #ccc;
|
||||
border-radius: 15px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iot-switch .switch.active {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.iot-switch .switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: left 0.3s;
|
||||
}
|
||||
|
||||
.iot-switch .switch.active::after {
|
||||
left: 32px;
|
||||
}
|
||||
|
||||
.iot-slider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.iot-slider input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.iot-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1890ff;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.iot-button:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.iot-value-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.iot-value-display .value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.iot-value-display .label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
55
editor.html
Normal file
55
editor.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IoT 可视化编辑器</title>
|
||||
<link rel="stylesheet" href="css/editor.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<!-- 顶部工具栏 -->
|
||||
<header class="toolbar">
|
||||
<div class="toolbar-title">IoT 可视化编辑器</div>
|
||||
<div class="toolbar-actions">
|
||||
<button id="btn-save" class="btn btn-primary">保存 JSONB</button>
|
||||
<button id="btn-load" class="btn">加载 JSONB</button>
|
||||
<button id="btn-export" class="btn">导出 HTML</button>
|
||||
<button id="btn-clear" class="btn btn-danger">清空</button>
|
||||
</div>
|
||||
<div class="toolbar-info">
|
||||
<span>画布尺寸: 375 × 812 (手机比例)</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 编辑器容器 -->
|
||||
<div id="editor" class="editor-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- 隐藏的文件输入 -->
|
||||
<input type="file" id="file-input" accept=".json" style="display: none;">
|
||||
|
||||
<!-- 预览弹窗 -->
|
||||
<div id="preview-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span>HTML 预览</span>
|
||||
<button class="modal-close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<pre id="preview-code"></pre>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-copy" class="btn btn-primary">复制代码</button>
|
||||
<button id="btn-download" class="btn">下载文件</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 引入 webbuilder -->
|
||||
<script src="js/lib/webbuilder/webbuilder.js"></script>
|
||||
|
||||
<!-- 编辑器初始化 -->
|
||||
<script src="js/editor/editor.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
310
js/editor/editor.js
Normal file
310
js/editor/editor.js
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* IoT 可视化编辑器初始化脚本
|
||||
* 仅支持手机端布局
|
||||
*/
|
||||
|
||||
// 初始化编辑器
|
||||
function initEditor() {
|
||||
// 初始化 webbuilder
|
||||
webbuilder.init('#editor');
|
||||
|
||||
// 注册示例组件
|
||||
registerComponents();
|
||||
|
||||
// 绑定事件
|
||||
bindEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册示例组件
|
||||
*/
|
||||
function registerComponents() {
|
||||
// 仪表盘组件
|
||||
webbuilder.define('gauge', {
|
||||
type: 'gauge',
|
||||
label: '仪表盘',
|
||||
icon: '📊',
|
||||
defaultColumnSpan: 6,
|
||||
defaultRowSpan: 6,
|
||||
defaultProps: {
|
||||
label: '温度',
|
||||
value: 25,
|
||||
unit: '°C'
|
||||
},
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: '标签', default: '温度' },
|
||||
{ type: 'number', name: 'value', label: '值', default: 25 },
|
||||
{ type: 'text', name: 'unit', label: '单位', default: '°C' }
|
||||
],
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'iot-gauge';
|
||||
|
||||
var valueEl = document.createElement('div');
|
||||
valueEl.className = 'value';
|
||||
valueEl.textContent = props.value + props.unit;
|
||||
|
||||
var labelEl = document.createElement('div');
|
||||
labelEl.className = 'label';
|
||||
labelEl.textContent = props.label;
|
||||
|
||||
el.appendChild(valueEl);
|
||||
el.appendChild(labelEl);
|
||||
|
||||
return el;
|
||||
}
|
||||
});
|
||||
|
||||
// 开关组件
|
||||
webbuilder.define('switch', {
|
||||
type: 'switch',
|
||||
label: '开关',
|
||||
icon: '🔘',
|
||||
defaultColumnSpan: 4,
|
||||
defaultRowSpan: 3,
|
||||
defaultProps: {
|
||||
label: '开关',
|
||||
state: false
|
||||
},
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: '标签', default: '开关' },
|
||||
{ type: 'checkbox', name: 'state', label: '状态', default: false }
|
||||
],
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'iot-switch';
|
||||
|
||||
var switchEl = document.createElement('div');
|
||||
switchEl.className = 'switch' + (props.state ? ' active' : '');
|
||||
|
||||
var labelEl = document.createElement('div');
|
||||
labelEl.className = 'label';
|
||||
labelEl.textContent = props.label;
|
||||
|
||||
el.appendChild(switchEl);
|
||||
el.appendChild(labelEl);
|
||||
|
||||
return el;
|
||||
}
|
||||
});
|
||||
|
||||
// 滑块组件
|
||||
webbuilder.define('slider', {
|
||||
type: 'slider',
|
||||
label: '滑块',
|
||||
icon: '🎚️',
|
||||
defaultColumnSpan: 8,
|
||||
defaultRowSpan: 3,
|
||||
defaultProps: {
|
||||
label: '亮度',
|
||||
value: 50,
|
||||
min: 0,
|
||||
max: 100
|
||||
},
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: '标签', default: '亮度' },
|
||||
{ type: 'number', name: 'value', label: '值', default: 50 },
|
||||
{ type: 'number', name: 'min', label: '最小值', default: 0 },
|
||||
{ type: 'number', name: 'max', label: '最大值', default: 100 }
|
||||
],
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'iot-slider';
|
||||
|
||||
var labelEl = document.createElement('div');
|
||||
labelEl.className = 'label';
|
||||
labelEl.textContent = props.label + ': ' + props.value;
|
||||
|
||||
var inputEl = document.createElement('input');
|
||||
inputEl.type = 'range';
|
||||
inputEl.min = props.min;
|
||||
inputEl.max = props.max;
|
||||
inputEl.value = props.value;
|
||||
|
||||
el.appendChild(labelEl);
|
||||
el.appendChild(inputEl);
|
||||
|
||||
return el;
|
||||
}
|
||||
});
|
||||
|
||||
// 按钮组件
|
||||
webbuilder.define('button', {
|
||||
type: 'button',
|
||||
label: '按钮',
|
||||
icon: '🔘',
|
||||
defaultColumnSpan: 4,
|
||||
defaultRowSpan: 2,
|
||||
defaultProps: {
|
||||
text: '点击'
|
||||
},
|
||||
traits: [
|
||||
{ type: 'text', name: 'text', label: '文本', default: '点击' }
|
||||
],
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'iot-button';
|
||||
el.textContent = props.text;
|
||||
return el;
|
||||
}
|
||||
});
|
||||
|
||||
// 数值显示组件
|
||||
webbuilder.define('value-display', {
|
||||
type: 'value-display',
|
||||
label: '数值显示',
|
||||
icon: '📈',
|
||||
defaultColumnSpan: 4,
|
||||
defaultRowSpan: 3,
|
||||
defaultProps: {
|
||||
label: '湿度',
|
||||
value: 65,
|
||||
unit: '%'
|
||||
},
|
||||
traits: [
|
||||
{ type: 'text', name: 'label', label: '标签', default: '湿度' },
|
||||
{ type: 'number', name: 'value', label: '值', default: 65 },
|
||||
{ type: 'text', name: 'unit', label: '单位', default: '%' }
|
||||
],
|
||||
render: function(props) {
|
||||
var el = document.createElement('div');
|
||||
el.className = 'iot-value-display';
|
||||
|
||||
var valueEl = document.createElement('div');
|
||||
valueEl.className = 'value';
|
||||
valueEl.textContent = props.value + props.unit;
|
||||
|
||||
var labelEl = document.createElement('div');
|
||||
labelEl.className = 'label';
|
||||
labelEl.textContent = props.label;
|
||||
|
||||
el.appendChild(valueEl);
|
||||
el.appendChild(labelEl);
|
||||
|
||||
return el;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 绑定事件
|
||||
*/
|
||||
function bindEvents() {
|
||||
// 保存 JSONB
|
||||
document.getElementById('btn-save').addEventListener('click', function() {
|
||||
var jsonb = webbuilder.toJSONB();
|
||||
var jsonStr = JSON.stringify(jsonb, null, 2);
|
||||
downloadFile('canvas-config.json', jsonStr, 'application/json');
|
||||
});
|
||||
|
||||
// 加载 JSONB
|
||||
document.getElementById('btn-load').addEventListener('click', function() {
|
||||
document.getElementById('file-input').click();
|
||||
});
|
||||
|
||||
document.getElementById('file-input').addEventListener('change', function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
try {
|
||||
var jsonb = JSON.parse(e.target.result);
|
||||
webbuilder.fromJSONB(jsonb);
|
||||
} catch (err) {
|
||||
alert('加载失败:' + err.message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// 清空文件输入
|
||||
this.value = '';
|
||||
});
|
||||
|
||||
// 导出 HTML
|
||||
document.getElementById('btn-export').addEventListener('click', function() {
|
||||
// 创建一个临时 div 来渲染
|
||||
var tempDiv = document.createElement('div');
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
// 调用 renderToDiv 获取组件列表
|
||||
var components = webbuilder.renderToDiv(tempDiv);
|
||||
|
||||
// 生成 HTML
|
||||
var config = webbuilder.getCanvasConfig();
|
||||
var html = '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="UTF-8">\n';
|
||||
html += '<meta name="viewport" content="width=device-width, initial-scale=1.0">\n';
|
||||
html += '<title>IoT 仪表盘</title>\n';
|
||||
html += '<style>\n';
|
||||
html += '* { margin: 0; padding: 0; box-sizing: border-box; }\n';
|
||||
html += 'body { display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #f0f2f5; }\n';
|
||||
html += '.iot-canvas { width: ' + config.width + 'px; height: ' + config.height + 'px; ';
|
||||
html += 'display: grid; grid-template-columns: repeat(' + config.columns + ', ' + config.cellSize + 'px); ';
|
||||
html += 'grid-auto-rows: ' + config.cellSize + 'px; gap: ' + config.gap + 'px; ';
|
||||
html += 'background: ' + config.background + '; padding: ' + config.gap + 'px; box-sizing: border-box; }\n';
|
||||
html += '</style>\n</head>\n<body>\n';
|
||||
html += tempDiv.outerHTML;
|
||||
html += '\n<script>\n';
|
||||
html += '// 组件列表(包含每个组件的元素和属性)\n';
|
||||
html += 'var components = ' + JSON.stringify(components.map(function(c) {
|
||||
return { id: c.id, type: c.type, props: c.props };
|
||||
}), null, 2) + ';\n';
|
||||
html += '</script>\n';
|
||||
html += '</body>\n</html>';
|
||||
|
||||
// 移除临时 div
|
||||
document.body.removeChild(tempDiv);
|
||||
|
||||
showPreview(html);
|
||||
});
|
||||
|
||||
// 清空画布
|
||||
document.getElementById('btn-clear').addEventListener('click', function() {
|
||||
if (confirm('确定要清空画布吗?')) {
|
||||
webbuilder.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// 预览弹窗关闭
|
||||
document.querySelector('.modal-close').addEventListener('click', function() {
|
||||
document.getElementById('preview-modal').style.display = 'none';
|
||||
});
|
||||
|
||||
// 复制代码
|
||||
document.getElementById('btn-copy').addEventListener('click', function() {
|
||||
var code = document.getElementById('preview-code').textContent;
|
||||
navigator.clipboard.writeText(code).then(function() {
|
||||
alert('已复制到剪贴板');
|
||||
});
|
||||
});
|
||||
|
||||
// 下载文件
|
||||
document.getElementById('btn-download').addEventListener('click', function() {
|
||||
var code = document.getElementById('preview-code').textContent;
|
||||
downloadFile('page.html', code, 'text/html');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示预览弹窗
|
||||
*/
|
||||
function showPreview(code) {
|
||||
document.getElementById('preview-code').textContent = code;
|
||||
document.getElementById('preview-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载文件
|
||||
*/
|
||||
function downloadFile(filename, content, type) {
|
||||
var blob = new Blob([content], { type: type });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// 页面加载完成后初始化
|
||||
document.addEventListener('DOMContentLoaded', initEditor);
|
||||
57
js/lib/webbuilder/types/BuilderContainer.js
Normal file
57
js/lib/webbuilder/types/BuilderContainer.js
Normal file
@@ -0,0 +1,57 @@
|
||||
class BuilderContainer {
|
||||
constructor(containerElQuery) {
|
||||
// Set element from query
|
||||
this.el = document.querySelector(containerElQuery);
|
||||
|
||||
// Check if not found
|
||||
if (this.el === undefined) {
|
||||
throw new Error('Container element cannot be undefined');
|
||||
}
|
||||
// Define drop area
|
||||
else {
|
||||
var dropTarget = undefined;
|
||||
|
||||
// When component over
|
||||
this.el.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
dropTarget = e.target;
|
||||
if (dropTarget.hasAttribute('root') || dropTarget.hasAttribute('container'))
|
||||
dropTarget.setAttribute('dropping', true);
|
||||
}, false);
|
||||
|
||||
// When component leaves
|
||||
var dragleave = function (e) {
|
||||
e.preventDefault();
|
||||
if (dropTarget && dropTarget.hasAttribute('dropping'))
|
||||
dropTarget.removeAttribute('dropping');
|
||||
}
|
||||
this.el.addEventListener('dragleave', dragleave, false);
|
||||
this.el.addEventListener('dragend', dragleave, false);
|
||||
|
||||
// When component being dropped
|
||||
this.el.addEventListener('drop', function(e) {
|
||||
dragleave(e);
|
||||
|
||||
// Get dragged
|
||||
var draggedEl = webbuilder.draggingComponent;
|
||||
if (!draggedEl) return;
|
||||
|
||||
var componentName = draggedEl.getAttribute('component-name');
|
||||
var component = webbuilder.findComponent(componentName);
|
||||
|
||||
// Get drop area
|
||||
var dropareaEl = e.target;
|
||||
|
||||
// If drop area == root container
|
||||
// Or == container component
|
||||
if (dropareaEl.hasAttribute('root') || dropareaEl.hasAttribute('container')) {
|
||||
// Produce
|
||||
dropareaEl.appendChild(component.produceRealEl());
|
||||
}
|
||||
|
||||
dropTarget = undefined;
|
||||
}, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
30
js/lib/webbuilder/types/BuilderToolbox.js
Normal file
30
js/lib/webbuilder/types/BuilderToolbox.js
Normal file
@@ -0,0 +1,30 @@
|
||||
class BuilderToolbox {
|
||||
constructor(toolboxElQuery, options) {
|
||||
// Set element from query
|
||||
this.el = document.querySelector(toolboxElQuery);
|
||||
|
||||
// Check if not found
|
||||
if (this.el === undefined) {
|
||||
throw new Error('Toolbox element cannot be undefined');
|
||||
}
|
||||
}
|
||||
|
||||
// Add component to toolbox
|
||||
addItem(component) {
|
||||
// Add draggable component
|
||||
this.el.appendChild(component.produceDraggableEl());
|
||||
}
|
||||
|
||||
// Remove component from toolbox
|
||||
removeItem(component_name) {
|
||||
// Find component
|
||||
let componentEl = this.el.querySelector(`[component-name="${component_name}"]`);
|
||||
// Remove component (if found)
|
||||
if (componentEl !== undefined) componentEl.remove();
|
||||
}
|
||||
|
||||
// Clear all components in toolbox
|
||||
clearItems() {
|
||||
this.el.innerHTML = '';
|
||||
}
|
||||
}
|
||||
71
js/lib/webbuilder/types/Component.js
Normal file
71
js/lib/webbuilder/types/Component.js
Normal file
@@ -0,0 +1,71 @@
|
||||
class ComponentConfig {
|
||||
constructor(canDropOn = {}) {
|
||||
this.canDropOn = canDropOn;
|
||||
}
|
||||
}
|
||||
|
||||
class Component {
|
||||
constructor(name, type, template, config = new ComponentConfig()) {
|
||||
if (name !== undefined && type !== undefined && template !== undefined) {
|
||||
this.name = name;
|
||||
this.type = type;
|
||||
this.template = template;
|
||||
} else {
|
||||
throw new Error('Unspecified component name or type or template');
|
||||
}
|
||||
}
|
||||
|
||||
// Produce draggable element of this component
|
||||
produceDraggableEl() {
|
||||
// Create div
|
||||
let el = document.createElement('div');
|
||||
el.setAttribute('class', 'component-template');
|
||||
el.setAttribute('component-name', this.name);
|
||||
el.setAttribute('component-type', this.type);
|
||||
// Make it draggable
|
||||
el.setAttribute('draggable', true);
|
||||
el.setAttribute('dragging', false);
|
||||
|
||||
el.addEventListener('dragstart', function(e) {
|
||||
if (webbuilder.draggingComponent == undefined) {
|
||||
webbuilder.draggingComponent = this;
|
||||
webbuilder.draggingComponent.setAttribute('dragging', true);
|
||||
}
|
||||
}, false);
|
||||
|
||||
el.addEventListener('dragend', function(e) {
|
||||
e.preventDefault();
|
||||
if (webbuilder.draggingComponent != undefined) {
|
||||
webbuilder.draggingComponent.setAttribute('dragging', false);
|
||||
webbuilder.draggingComponent = undefined;
|
||||
}
|
||||
}, false);
|
||||
|
||||
// Assign html template to inner
|
||||
el.innerHTML = this.template;
|
||||
return el;
|
||||
}
|
||||
|
||||
produceRealEl() {
|
||||
// Create temp element
|
||||
var tempEl = document.createElement('template');
|
||||
// Set template code as inner
|
||||
tempEl.insertAdjacentHTML('afterbegin', this.template);
|
||||
|
||||
if (this.type == 'element') {
|
||||
// Return template code in inner as element
|
||||
return tempEl.firstElementChild;
|
||||
} else if (this.type == 'container') {
|
||||
// Return template code, with container attr set to true
|
||||
tempEl = tempEl.firstElementChild;
|
||||
tempEl.setAttribute('container', '');
|
||||
return tempEl;
|
||||
} else {
|
||||
throw new Error('Unknown component type, unable to produce element');
|
||||
}
|
||||
}
|
||||
|
||||
canDropOn(el) {
|
||||
// 暂未实现
|
||||
}
|
||||
}
|
||||
753
js/lib/webbuilder/webbuilder.js
Normal file
753
js/lib/webbuilder/webbuilder.js
Normal file
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* WebBuilder - IoT 可视化编辑器
|
||||
* 仅支持手机端布局
|
||||
* 单元格比例 1:1(正方形)
|
||||
*/
|
||||
|
||||
var webbuilder = {
|
||||
// 编辑器容器
|
||||
container: null,
|
||||
|
||||
// 手机画布配置
|
||||
phoneConfig: {
|
||||
width: 375,
|
||||
height: 812,
|
||||
columns: 24,
|
||||
gap: 2,
|
||||
background: '#f5f5f5'
|
||||
},
|
||||
|
||||
// 计算得出的单元格大小(1:1比例)
|
||||
cellSize: 0,
|
||||
rows: 0,
|
||||
|
||||
// 已注册的组件类型
|
||||
componentTypes: {},
|
||||
|
||||
// 画布上的组件实例
|
||||
componentInstances: [],
|
||||
|
||||
// 当前选中的组件
|
||||
selectedComponent: null,
|
||||
|
||||
// 拖拽状态
|
||||
draggingComponent: null,
|
||||
draggingType: null,
|
||||
|
||||
// 唯一ID计数器
|
||||
idCounter: 0,
|
||||
|
||||
/**
|
||||
* 计算单元格大小和行数
|
||||
*/
|
||||
_calculateGrid: function() {
|
||||
var config = this.phoneConfig;
|
||||
|
||||
// 计算列宽(单元格大小)
|
||||
// 列宽 = (画布宽度 - (列数 + 1) * gap) / 列数
|
||||
this.cellSize = (config.width - (config.columns + 1) * config.gap) / config.columns;
|
||||
|
||||
// 计算行数
|
||||
// 画布高度 = 行数 * 单元格大小 + (行数 + 1) * gap
|
||||
// 812 = 行数 * cellSize + (行数 + 1) * gap
|
||||
// 812 = 行数 * cellSize + 行数 * gap + gap
|
||||
// 812 - gap = 行数 * (cellSize + gap)
|
||||
// 行数 = (812 - gap) / (cellSize + gap)
|
||||
this.rows = Math.floor((config.height - config.gap) / (this.cellSize + config.gap));
|
||||
|
||||
// 调整画布高度以精确适配行数
|
||||
this.phoneConfig.height = this.rows * this.cellSize + (this.rows + 1) * config.gap;
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化编辑器
|
||||
* @param {string|HTMLElement} containerSelector - 容器选择器或元素
|
||||
*/
|
||||
init: function(containerSelector) {
|
||||
// 获取容器元素
|
||||
if (typeof containerSelector === 'string') {
|
||||
this.container = document.querySelector(containerSelector);
|
||||
} else {
|
||||
this.container = containerSelector;
|
||||
}
|
||||
|
||||
if (!this.container) {
|
||||
throw new Error('Container element not found');
|
||||
}
|
||||
|
||||
// 计算网格
|
||||
this._calculateGrid();
|
||||
|
||||
// 创建编辑器结构
|
||||
this._createEditorStructure();
|
||||
|
||||
// 初始化画布
|
||||
this._initCanvas();
|
||||
|
||||
// 初始化工具箱
|
||||
this._initToolbox();
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建编辑器结构
|
||||
*/
|
||||
_createEditorStructure: function() {
|
||||
this.container.innerHTML = '';
|
||||
this.container.classList.add('webbuilder-editor');
|
||||
|
||||
// 工具箱容器
|
||||
this.toolboxEl = document.createElement('div');
|
||||
this.toolboxEl.className = 'webbuilder-toolbox';
|
||||
this.container.appendChild(this.toolboxEl);
|
||||
|
||||
// 画布容器
|
||||
this.canvasWrapperEl = document.createElement('div');
|
||||
this.canvasWrapperEl.className = 'webbuilder-canvas-wrapper';
|
||||
this.container.appendChild(this.canvasWrapperEl);
|
||||
|
||||
// 属性面板容器
|
||||
this.propertyPanelEl = document.createElement('div');
|
||||
this.propertyPanelEl.className = 'webbuilder-property-panel';
|
||||
this.container.appendChild(this.propertyPanelEl);
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化画布
|
||||
*/
|
||||
_initCanvas: function() {
|
||||
var config = this.phoneConfig;
|
||||
|
||||
// 创建画布
|
||||
this.canvasEl = document.createElement('div');
|
||||
this.canvasEl.className = 'webbuilder-canvas';
|
||||
this.canvasEl.style.width = config.width + 'px';
|
||||
this.canvasEl.style.height = config.height + 'px';
|
||||
this.canvasEl.style.display = 'grid';
|
||||
this.canvasEl.style.gridTemplateColumns = 'repeat(' + config.columns + ', ' + this.cellSize + 'px)';
|
||||
this.canvasEl.style.gridAutoRows = this.cellSize + 'px';
|
||||
this.canvasEl.style.gap = config.gap + 'px';
|
||||
this.canvasEl.style.background = config.background;
|
||||
this.canvasEl.style.position = 'relative';
|
||||
this.canvasEl.style.padding = config.gap + 'px';
|
||||
this.canvasEl.style.boxSizing = 'border-box';
|
||||
this.canvasWrapperEl.appendChild(this.canvasEl);
|
||||
|
||||
// 画布点击取消选中
|
||||
var self = this;
|
||||
this.canvasEl.addEventListener('click', function(e) {
|
||||
if (e.target === self.canvasEl) {
|
||||
self._deselectAll();
|
||||
}
|
||||
});
|
||||
|
||||
// 拖放事件
|
||||
this.canvasEl.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
self.canvasEl.classList.add('dropping');
|
||||
});
|
||||
|
||||
this.canvasEl.addEventListener('dragleave', function(e) {
|
||||
self.canvasEl.classList.remove('dropping');
|
||||
});
|
||||
|
||||
this.canvasEl.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
self.canvasEl.classList.remove('dropping');
|
||||
|
||||
if (self.draggingType) {
|
||||
// 从工具箱拖入新组件
|
||||
var gridPos = self._calculateGridPosition(e);
|
||||
self._addComponentToCanvas(self.draggingType, gridPos);
|
||||
self.draggingType = null;
|
||||
} else if (self.draggingComponent) {
|
||||
// 在画布上移动组件
|
||||
var gridPos = self._calculateGridPosition(e);
|
||||
self._moveComponent(self.draggingComponent, gridPos);
|
||||
self.draggingComponent = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化工具箱
|
||||
*/
|
||||
_initToolbox: function() {
|
||||
this.toolboxEl.innerHTML = '<div class="webbuilder-toolbox-title">组件库</div>';
|
||||
this.toolboxListEl = document.createElement('div');
|
||||
this.toolboxListEl.className = 'webbuilder-toolbox-list';
|
||||
this.toolboxEl.appendChild(this.toolboxListEl);
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算网格位置
|
||||
*/
|
||||
_calculateGridPosition: function(e) {
|
||||
var rect = this.canvasEl.getBoundingClientRect();
|
||||
var config = this.phoneConfig;
|
||||
|
||||
// 计算鼠标相对于画布的位置
|
||||
var x = e.clientX - rect.left - config.gap;
|
||||
var y = e.clientY - rect.top - config.gap;
|
||||
|
||||
// 计算列和行
|
||||
var col = Math.floor(x / (this.cellSize + config.gap)) + 1;
|
||||
var row = Math.floor(y / (this.cellSize + config.gap)) + 1;
|
||||
|
||||
// 边界约束
|
||||
col = Math.max(1, Math.min(config.columns, col));
|
||||
row = Math.max(1, Math.min(this.rows, row));
|
||||
|
||||
return { column: col, row: row };
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加组件到画布
|
||||
*/
|
||||
_addComponentToCanvas: function(componentType, gridPos) {
|
||||
var typeDef = this.componentTypes[componentType];
|
||||
if (!typeDef) {
|
||||
console.error('Component type not found:', componentType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成唯一ID
|
||||
var id = componentType + '-' + (++this.idCounter);
|
||||
|
||||
// 计算 startCell
|
||||
var startCell = (gridPos.row - 1) * this.phoneConfig.columns + (gridPos.column - 1);
|
||||
|
||||
// 创建组件实例
|
||||
var instance = {
|
||||
id: id,
|
||||
type: componentType,
|
||||
grid: {
|
||||
startCell: startCell,
|
||||
colSpan: typeDef.defaultColumnSpan || 6,
|
||||
rowSpan: typeDef.defaultRowSpan || 4
|
||||
},
|
||||
props: JSON.parse(JSON.stringify(typeDef.defaultProps || {}))
|
||||
};
|
||||
|
||||
// 渲染组件
|
||||
this._renderComponent(instance);
|
||||
|
||||
// 添加到实例列表
|
||||
this.componentInstances.push(instance);
|
||||
|
||||
// 选中新添加的组件
|
||||
this._selectComponent(instance.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* 渲染组件到画布(编辑器内部使用)
|
||||
*/
|
||||
_renderComponent: function(instance) {
|
||||
var typeDef = this.componentTypes[instance.type];
|
||||
if (!typeDef) return;
|
||||
|
||||
// 计算行列位置
|
||||
var columns = this.phoneConfig.columns;
|
||||
var startCol = instance.grid.startCell % columns;
|
||||
var startRow = Math.floor(instance.grid.startCell / columns);
|
||||
|
||||
// 边界约束
|
||||
var colSpan = Math.min(instance.grid.colSpan, columns - startCol);
|
||||
var rowSpan = instance.grid.rowSpan;
|
||||
|
||||
// 创建组件容器元素
|
||||
var el = document.createElement('div');
|
||||
el.className = 'webbuilder-component';
|
||||
el.setAttribute('data-id', instance.id);
|
||||
el.setAttribute('data-type', instance.type);
|
||||
el.style.gridColumn = (startCol + 1) + ' / span ' + colSpan;
|
||||
el.style.gridRow = (startRow + 1) + ' / span ' + rowSpan;
|
||||
el.style.position = 'relative';
|
||||
el.style.overflow = 'hidden';
|
||||
|
||||
// 渲染组件内容 - render 函数返回 HTML 元素对象
|
||||
if (typeDef.render) {
|
||||
var content = typeDef.render(instance.props);
|
||||
// 如果返回的是字符串,保持兼容
|
||||
if (typeof content === 'string') {
|
||||
el.innerHTML = content;
|
||||
} else if (content instanceof HTMLElement) {
|
||||
// 如果返回的是 DOM 元素,直接添加
|
||||
el.appendChild(content);
|
||||
}
|
||||
}
|
||||
|
||||
// 使组件可拖拽
|
||||
var self = this;
|
||||
el.setAttribute('draggable', true);
|
||||
el.addEventListener('dragstart', function(e) {
|
||||
e.stopPropagation();
|
||||
self.draggingComponent = instance.id;
|
||||
el.style.opacity = '0.5';
|
||||
});
|
||||
el.addEventListener('dragend', function(e) {
|
||||
el.style.opacity = '1';
|
||||
self.draggingComponent = null;
|
||||
});
|
||||
|
||||
// 点击选中
|
||||
el.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
self._selectComponent(instance.id);
|
||||
});
|
||||
|
||||
// 存储元素引用
|
||||
instance.element = el;
|
||||
|
||||
// 添加到画布
|
||||
this.canvasEl.appendChild(el);
|
||||
},
|
||||
|
||||
/**
|
||||
* 将组件渲染到指定的 div 容器
|
||||
* @param {HTMLElement} container - 目标容器
|
||||
* @returns {Array} 组件列表,每个元素包含 { element, props, id, type }
|
||||
*/
|
||||
renderToDiv: function(container) {
|
||||
var self = this;
|
||||
var result = [];
|
||||
|
||||
// 清空容器
|
||||
container.innerHTML = '';
|
||||
|
||||
// 创建画布样式
|
||||
container.style.width = this.phoneConfig.width + 'px';
|
||||
container.style.height = this.phoneConfig.height + 'px';
|
||||
container.style.display = 'grid';
|
||||
container.style.gridTemplateColumns = 'repeat(' + this.phoneConfig.columns + ', ' + this.cellSize + 'px)';
|
||||
container.style.gridAutoRows = this.cellSize + 'px';
|
||||
container.style.gap = this.phoneConfig.gap + 'px';
|
||||
container.style.background = this.phoneConfig.background;
|
||||
container.style.padding = this.phoneConfig.gap + 'px';
|
||||
container.style.boxSizing = 'border-box';
|
||||
|
||||
var columns = this.phoneConfig.columns;
|
||||
|
||||
// 遍历所有组件实例
|
||||
this.componentInstances.forEach(function(instance) {
|
||||
var typeDef = self.componentTypes[instance.type];
|
||||
if (!typeDef || !typeDef.render) return;
|
||||
|
||||
// 计算行列位置
|
||||
var startCol = instance.grid.startCell % columns;
|
||||
var startRow = Math.floor(instance.grid.startCell / columns);
|
||||
var colSpan = Math.min(instance.grid.colSpan, columns - startCol);
|
||||
var rowSpan = instance.grid.rowSpan;
|
||||
|
||||
// 创建组件容器
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.style.gridColumn = (startCol + 1) + ' / span ' + colSpan;
|
||||
wrapper.style.gridRow = (startRow + 1) + ' / span ' + rowSpan;
|
||||
wrapper.style.overflow = 'hidden';
|
||||
|
||||
// 调用 render 函数获取组件元素
|
||||
var componentElement = typeDef.render(instance.props);
|
||||
|
||||
// 如果返回的是字符串,保持兼容
|
||||
if (typeof componentElement === 'string') {
|
||||
wrapper.innerHTML = componentElement;
|
||||
componentElement = wrapper.firstChild;
|
||||
} else if (componentElement instanceof HTMLElement) {
|
||||
wrapper.appendChild(componentElement);
|
||||
}
|
||||
|
||||
// 添加到容器
|
||||
container.appendChild(wrapper);
|
||||
|
||||
// 添加到结果列表
|
||||
result.push({
|
||||
element: componentElement, // 控件本身的 HTML 元素对象
|
||||
wrapper: wrapper, // 包装容器
|
||||
props: JSON.parse(JSON.stringify(instance.props)), // 自定义组件属性(深拷贝)
|
||||
id: instance.id,
|
||||
type: instance.type
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* 移动组件
|
||||
*/
|
||||
_moveComponent: function(componentId, gridPos) {
|
||||
var instance = this._findInstance(componentId);
|
||||
if (!instance) return;
|
||||
|
||||
// 更新 startCell
|
||||
instance.grid.startCell = (gridPos.row - 1) * this.phoneConfig.columns + (gridPos.column - 1);
|
||||
|
||||
// 重新计算行列位置
|
||||
var columns = this.phoneConfig.columns;
|
||||
var startCol = instance.grid.startCell % columns;
|
||||
var startRow = Math.floor(instance.grid.startCell / columns);
|
||||
var colSpan = Math.min(instance.grid.colSpan, columns - startCol);
|
||||
|
||||
// 更新样式
|
||||
instance.element.style.gridColumn = (startCol + 1) + ' / span ' + colSpan;
|
||||
instance.element.style.gridRow = (startRow + 1) + ' / span ' + instance.grid.rowSpan;
|
||||
|
||||
// 更新属性面板
|
||||
if (this.selectedComponent === componentId) {
|
||||
this._showPropertyPanel(instance);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 选中组件
|
||||
*/
|
||||
_selectComponent: function(componentId) {
|
||||
// 取消之前的选中
|
||||
this._deselectAll();
|
||||
|
||||
var instance = this._findInstance(componentId);
|
||||
if (!instance) return;
|
||||
|
||||
this.selectedComponent = componentId;
|
||||
instance.element.classList.add('selected');
|
||||
|
||||
// 显示属性面板
|
||||
this._showPropertyPanel(instance);
|
||||
},
|
||||
|
||||
/**
|
||||
* 取消所有选中
|
||||
*/
|
||||
_deselectAll: function() {
|
||||
this.selectedComponent = null;
|
||||
var selected = this.canvasEl.querySelectorAll('.selected');
|
||||
selected.forEach(function(el) {
|
||||
el.classList.remove('selected');
|
||||
});
|
||||
this.propertyPanelEl.innerHTML = '';
|
||||
},
|
||||
|
||||
/**
|
||||
* 显示属性面板
|
||||
*/
|
||||
_showPropertyPanel: function(instance) {
|
||||
var typeDef = this.componentTypes[instance.type];
|
||||
var self = this;
|
||||
var columns = this.phoneConfig.columns;
|
||||
|
||||
// 计算当前行列
|
||||
var startCol = instance.grid.startCell % columns;
|
||||
var startRow = Math.floor(instance.grid.startCell / columns);
|
||||
|
||||
var html = '<div class="property-title">' + (typeDef.label || instance.type) + '</div>';
|
||||
|
||||
// 基础属性
|
||||
html += '<div class="property-section">';
|
||||
html += '<div class="property-section-title">位置大小</div>';
|
||||
html += '<div class="property-row"><label>起始列:</label><input type="number" data-prop="startCol" value="' + (startCol + 1) + '" min="1" max="' + columns + '"></div>';
|
||||
html += '<div class="property-row"><label>起始行:</label><input type="number" data-prop="startRow" value="' + (startRow + 1) + '" min="1"></div>';
|
||||
html += '<div class="property-row"><label>跨列数:</label><input type="number" data-prop="colSpan" value="' + instance.grid.colSpan + '" min="1" max="' + columns + '"></div>';
|
||||
html += '<div class="property-row"><label>跨行数:</label><input type="number" data-prop="rowSpan" value="' + instance.grid.rowSpan + '" min="1"></div>';
|
||||
html += '</div>';
|
||||
|
||||
// 自定义属性
|
||||
if (typeDef.traits && typeDef.traits.length > 0) {
|
||||
html += '<div class="property-section">';
|
||||
html += '<div class="property-section-title">组件属性</div>';
|
||||
typeDef.traits.forEach(function(trait) {
|
||||
var value = instance.props[trait.name] !== undefined ? instance.props[trait.name] : (trait.default || '');
|
||||
html += '<div class="property-row">';
|
||||
html += '<label>' + trait.label + ':</label>';
|
||||
if (trait.type === 'number') {
|
||||
html += '<input type="number" data-trait="' + trait.name + '" value="' + value + '">';
|
||||
} else if (trait.type === 'checkbox') {
|
||||
html += '<input type="checkbox" data-trait="' + trait.name + '"' + (value ? ' checked' : '') + '>';
|
||||
} else {
|
||||
html += '<input type="text" data-trait="' + trait.name + '" value="' + value + '">';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// 删除按钮
|
||||
html += '<div class="property-section">';
|
||||
html += '<button class="btn-delete" data-action="delete">删除组件</button>';
|
||||
html += '</div>';
|
||||
|
||||
this.propertyPanelEl.innerHTML = html;
|
||||
|
||||
// 绑定事件
|
||||
this.propertyPanelEl.querySelectorAll('input').forEach(function(input) {
|
||||
input.addEventListener('change', function() {
|
||||
var prop = input.getAttribute('data-prop');
|
||||
var trait = input.getAttribute('data-trait');
|
||||
|
||||
if (prop) {
|
||||
// 更新网格位置
|
||||
var value = parseInt(input.value) || 1;
|
||||
if (prop === 'startCol') {
|
||||
var currentRow = Math.floor(instance.grid.startCell / columns);
|
||||
instance.grid.startCell = currentRow * columns + (value - 1);
|
||||
} else if (prop === 'startRow') {
|
||||
var currentCol = instance.grid.startCell % columns;
|
||||
instance.grid.startCell = (value - 1) * columns + currentCol;
|
||||
} else if (prop === 'colSpan') {
|
||||
instance.grid.colSpan = value;
|
||||
} else if (prop === 'rowSpan') {
|
||||
instance.grid.rowSpan = value;
|
||||
}
|
||||
|
||||
// 重新计算位置
|
||||
var startCol = instance.grid.startCell % columns;
|
||||
var startRow = Math.floor(instance.grid.startCell / columns);
|
||||
var colSpan = Math.min(instance.grid.colSpan, columns - startCol);
|
||||
|
||||
instance.element.style.gridColumn = (startCol + 1) + ' / span ' + colSpan;
|
||||
instance.element.style.gridRow = (startRow + 1) + ' / span ' + instance.grid.rowSpan;
|
||||
} else if (trait) {
|
||||
// 更新组件属性
|
||||
var typeDef = self.componentTypes[instance.type];
|
||||
var traitDef = typeDef.traits.find(function(t) { return t.name === trait; });
|
||||
|
||||
if (input.type === 'checkbox') {
|
||||
instance.props[trait] = input.checked;
|
||||
} else if (traitDef && traitDef.type === 'number') {
|
||||
instance.props[trait] = parseFloat(input.value) || 0;
|
||||
} else {
|
||||
instance.props[trait] = input.value;
|
||||
}
|
||||
|
||||
// 重新渲染组件内容
|
||||
if (typeDef.render) {
|
||||
var content = typeDef.render(instance.props);
|
||||
instance.element.innerHTML = '';
|
||||
if (typeof content === 'string') {
|
||||
instance.element.innerHTML = content;
|
||||
} else if (content instanceof HTMLElement) {
|
||||
instance.element.appendChild(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 删除按钮
|
||||
var deleteBtn = this.propertyPanelEl.querySelector('[data-action="delete"]');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', function() {
|
||||
self._deleteComponent(instance.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除组件
|
||||
*/
|
||||
_deleteComponent: function(componentId) {
|
||||
var index = -1;
|
||||
for (var i = 0; i < this.componentInstances.length; i++) {
|
||||
if (this.componentInstances[i].id === componentId) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (index === -1) return;
|
||||
|
||||
var instance = this.componentInstances[index];
|
||||
|
||||
// 从DOM移除
|
||||
if (instance.element) {
|
||||
instance.element.remove();
|
||||
}
|
||||
|
||||
// 从数组移除
|
||||
this.componentInstances.splice(index, 1);
|
||||
|
||||
// 取消选中
|
||||
this._deselectAll();
|
||||
},
|
||||
|
||||
/**
|
||||
* 查找组件实例
|
||||
*/
|
||||
_findInstance: function(componentId) {
|
||||
for (var i = 0; i < this.componentInstances.length; i++) {
|
||||
if (this.componentInstances[i].id === componentId) {
|
||||
return this.componentInstances[i];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 注册组件类型
|
||||
* @param {string} name - 组件名称
|
||||
* @param {object} definition - 组件定义
|
||||
*/
|
||||
define: function(name, definition) {
|
||||
if (this.componentTypes[name]) {
|
||||
console.warn('Component type already defined:', name);
|
||||
return;
|
||||
}
|
||||
|
||||
this.componentTypes[name] = definition;
|
||||
|
||||
// 添加到工具箱
|
||||
this._addToToolbox(name, definition);
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加组件到工具箱
|
||||
*/
|
||||
_addToToolbox: function(name, definition) {
|
||||
var self = this;
|
||||
|
||||
var item = document.createElement('div');
|
||||
item.className = 'webbuilder-toolbox-item';
|
||||
item.setAttribute('draggable', true);
|
||||
item.setAttribute('data-type', name);
|
||||
|
||||
var icon = definition.icon || '📦';
|
||||
var label = definition.label || name;
|
||||
|
||||
item.innerHTML = '<span class="icon">' + icon + '</span><span class="label">' + label + '</span>';
|
||||
|
||||
// 拖拽事件
|
||||
item.addEventListener('dragstart', function(e) {
|
||||
self.draggingType = name;
|
||||
item.style.opacity = '0.5';
|
||||
});
|
||||
item.addEventListener('dragend', function(e) {
|
||||
item.style.opacity = '1';
|
||||
self.draggingType = null;
|
||||
});
|
||||
|
||||
this.toolboxListEl.appendChild(item);
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出为JSONB
|
||||
* 返回格式: { version, layouts: { phone: {...}, computer: null } }
|
||||
*/
|
||||
toJSONB: function() {
|
||||
var components = this.componentInstances.map(function(instance) {
|
||||
return {
|
||||
id: instance.id,
|
||||
type: instance.type,
|
||||
grid: {
|
||||
startCell: instance.grid.startCell,
|
||||
colSpan: instance.grid.colSpan,
|
||||
rowSpan: instance.grid.rowSpan
|
||||
},
|
||||
props: JSON.parse(JSON.stringify(instance.props))
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
layouts: {
|
||||
phone: {
|
||||
canvas: {
|
||||
width: this.phoneConfig.width,
|
||||
height: this.phoneConfig.height,
|
||||
columns: this.phoneConfig.columns,
|
||||
cellSize: this.cellSize,
|
||||
rows: this.rows,
|
||||
gap: this.phoneConfig.gap,
|
||||
background: this.phoneConfig.background
|
||||
},
|
||||
components: components
|
||||
},
|
||||
computer: null
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 从JSONB加载
|
||||
*/
|
||||
fromJSONB: function(jsonb) {
|
||||
// 清空画布
|
||||
this.canvasEl.innerHTML = '';
|
||||
this.componentInstances = [];
|
||||
this.selectedComponent = null;
|
||||
|
||||
// 获取手机布局
|
||||
var phoneLayout = jsonb.layouts && jsonb.layouts.phone;
|
||||
if (!phoneLayout) {
|
||||
console.warn('No phone layout found');
|
||||
return;
|
||||
}
|
||||
|
||||
// 渲染组件
|
||||
if (phoneLayout.components) {
|
||||
var self = this;
|
||||
phoneLayout.components.forEach(function(comp) {
|
||||
var typeDef = self.componentTypes[comp.type];
|
||||
if (!typeDef) {
|
||||
console.warn('Component type not found:', comp.type);
|
||||
return;
|
||||
}
|
||||
|
||||
var instance = {
|
||||
id: comp.id,
|
||||
type: comp.type,
|
||||
grid: {
|
||||
startCell: comp.grid.startCell,
|
||||
colSpan: comp.grid.colSpan,
|
||||
rowSpan: comp.grid.rowSpan
|
||||
},
|
||||
props: comp.props || {}
|
||||
};
|
||||
|
||||
self._renderComponent(instance);
|
||||
self.componentInstances.push(instance);
|
||||
});
|
||||
|
||||
// 更新ID计数器
|
||||
var maxId = 0;
|
||||
this.componentInstances.forEach(function(inst) {
|
||||
var match = inst.id.match(/-(\d+)$/);
|
||||
if (match) {
|
||||
maxId = Math.max(maxId, parseInt(match[1]));
|
||||
}
|
||||
});
|
||||
this.idCounter = maxId;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取画布配置
|
||||
*/
|
||||
getCanvasConfig: function() {
|
||||
return {
|
||||
width: this.phoneConfig.width,
|
||||
height: this.phoneConfig.height,
|
||||
columns: this.phoneConfig.columns,
|
||||
cellSize: this.cellSize,
|
||||
rows: this.rows,
|
||||
gap: this.phoneConfig.gap,
|
||||
background: this.phoneConfig.background
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取所有组件实例
|
||||
*/
|
||||
getComponents: function() {
|
||||
return JSON.parse(JSON.stringify(this.componentInstances));
|
||||
},
|
||||
|
||||
/**
|
||||
* 清空画布
|
||||
*/
|
||||
clear: function() {
|
||||
this.canvasEl.innerHTML = '';
|
||||
this.componentInstances = [];
|
||||
this.selectedComponent = null;
|
||||
this.propertyPanelEl.innerHTML = '';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user