From e4fe6aac1d6ece744fde989e68b08fb341ebce11 Mon Sep 17 00:00:00 2001 From: wtz Date: Sun, 29 Mar 2026 11:50:51 +0800 Subject: [PATCH] init --- LICENSE | 27 + README.md | 260 +++++++ README_EN.md | 260 +++++++ TODO.md | 311 ++++++++ css/editor.css | 455 ++++++++++++ editor.html | 55 ++ js/editor/editor.js | 310 ++++++++ js/lib/webbuilder/types/BuilderContainer.js | 57 ++ js/lib/webbuilder/types/BuilderToolbox.js | 30 + js/lib/webbuilder/types/Component.js | 71 ++ js/lib/webbuilder/webbuilder.js | 753 ++++++++++++++++++++ 11 files changed, 2589 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README_EN.md create mode 100644 TODO.md create mode 100644 css/editor.css create mode 100644 editor.html create mode 100644 js/editor/editor.js create mode 100644 js/lib/webbuilder/types/BuilderContainer.js create mode 100644 js/lib/webbuilder/types/BuilderToolbox.js create mode 100644 js/lib/webbuilder/types/Component.js create mode 100644 js/lib/webbuilder/webbuilder.js diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4f0f224 --- /dev/null +++ b/LICENSE @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..75386dc --- /dev/null +++ b/README.md @@ -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 + +``` + +### 2. 创建容器 + +```html +
+``` + +### 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 = '
' + props.value + props.unit + '
' + + '
' + props.label + '
'; + 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)* \ No newline at end of file diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..df762b7 --- /dev/null +++ b/README_EN.md @@ -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 + +``` + +### 2. Create container + +```html +
+``` + +### 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 = '
' + props.value + props.unit + '
' + + '
' + props.label + '
'; + 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* \ No newline at end of file diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..38688f2 --- /dev/null +++ b/TODO.md @@ -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 + + + + + + + + +
+
+ +
+
+ + +``` + +--- + +## 五、组件接口规范 + +框架不实现具体组件,只提供接口规范,使用者按规范自定义组件。 + +### 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 '
' + props.label + '
'; + }, + + // 预览图标(可选) + 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* \ No newline at end of file diff --git a/css/editor.css b/css/editor.css new file mode 100644 index 0000000..0370453 --- /dev/null +++ b/css/editor.css @@ -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; +} \ No newline at end of file diff --git a/editor.html b/editor.html new file mode 100644 index 0000000..673f67b --- /dev/null +++ b/editor.html @@ -0,0 +1,55 @@ + + + + + + IoT 可视化编辑器 + + + +
+ +
+
IoT 可视化编辑器
+
+ + + + +
+
+ 画布尺寸: 375 × 812 (手机比例) +
+
+ + +
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/editor/editor.js b/js/editor/editor.js new file mode 100644 index 0000000..357e2b0 --- /dev/null +++ b/js/editor/editor.js @@ -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 = '\n\n\n\n'; + html += '\n'; + html += 'IoT 仪表盘\n'; + html += '\n\n\n'; + html += tempDiv.outerHTML; + html += '\n\n'; + html += '\n'; + + // 移除临时 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); \ No newline at end of file diff --git a/js/lib/webbuilder/types/BuilderContainer.js b/js/lib/webbuilder/types/BuilderContainer.js new file mode 100644 index 0000000..41081b3 --- /dev/null +++ b/js/lib/webbuilder/types/BuilderContainer.js @@ -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); + } + } +} \ No newline at end of file diff --git a/js/lib/webbuilder/types/BuilderToolbox.js b/js/lib/webbuilder/types/BuilderToolbox.js new file mode 100644 index 0000000..94ee413 --- /dev/null +++ b/js/lib/webbuilder/types/BuilderToolbox.js @@ -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 = ''; + } +} \ No newline at end of file diff --git a/js/lib/webbuilder/types/Component.js b/js/lib/webbuilder/types/Component.js new file mode 100644 index 0000000..1abfe41 --- /dev/null +++ b/js/lib/webbuilder/types/Component.js @@ -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) { + // 暂未实现 + } +} \ No newline at end of file diff --git a/js/lib/webbuilder/webbuilder.js b/js/lib/webbuilder/webbuilder.js new file mode 100644 index 0000000..962c7c5 --- /dev/null +++ b/js/lib/webbuilder/webbuilder.js @@ -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 = '
组件库
'; + 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 = '
' + (typeDef.label || instance.type) + '
'; + + // 基础属性 + html += '
'; + html += '
位置大小
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
'; + + // 自定义属性 + if (typeDef.traits && typeDef.traits.length > 0) { + html += '
'; + html += '
组件属性
'; + typeDef.traits.forEach(function(trait) { + var value = instance.props[trait.name] !== undefined ? instance.props[trait.name] : (trait.default || ''); + html += '
'; + html += ''; + if (trait.type === 'number') { + html += ''; + } else if (trait.type === 'checkbox') { + html += ''; + } else { + html += ''; + } + html += '
'; + }); + html += '
'; + } + + // 删除按钮 + html += '
'; + html += ''; + html += '
'; + + 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 = '' + icon + '' + label + ''; + + // 拖拽事件 + 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 = ''; + } +}; \ No newline at end of file