This commit is contained in:
wtz
2026-03-29 11:50:51 +08:00
commit e4fe6aac1d
11 changed files with 2589 additions and 0 deletions

27
LICENSE Normal file
View 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
View 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
View 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
View 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] 手机端375x81224列网格
- [x] 电脑端1200x80024列网格
- [ ] 网格线显示/隐藏
- [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
View 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
View 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">&times;</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
View 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);

View 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);
}
}
}

View 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 = '';
}
}

View 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) {
// 暂未实现
}
}

View 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 = '';
}
};