feat: add onChanged callback, Delete key support, drag offset fix

- Add onChanged(callback) API for tracking page changes
- Support Delete/Backspace key to delete selected component
- Fix drag offset issue when moving components
- Add unsaved changes tracking in editor demo
- Update README documentation
This commit is contained in:
wtz
2026-03-29 13:01:53 +08:00
parent e4fe6aac1d
commit 1d58d40185
3 changed files with 272 additions and 161 deletions

View File

@@ -9,9 +9,11 @@ IoT 可视化编辑器库 - 基于网格布局的拖拽式页面构建器
- 📱 固定手机比例画布375×812 - 📱 固定手机比例画布375×812
- 📐 网格布局,单元格 1:1 正方形 - 📐 网格布局,单元格 1:1 正方形
- 🖱️ 拖拽添加、移动组件 - 🖱️ 拖拽添加、移动组件
- ⌨️ Delete/Backspace键删除选中组件
- ⚙️ 属性面板,实时预览 - ⚙️ 属性面板,实时预览
- 💾 JSONB 格式保存/加载 - 💾 JSONB 格式保存/加载
- 🎨 自定义组件支持 - 🎨 自定义组件支持
- 🔔 变化回调通知
## 项目结构 ## 项目结构
@@ -21,10 +23,10 @@ IoT 可视化编辑器库 - 基于网格布局的拖拽式页面构建器
│ ├── webbuilder.js # 主文件 │ ├── webbuilder.js # 主文件
│ └── types/ # 内部模块 │ └── types/ # 内部模块
├── editor.html # 示例编辑器 ├── editor.html # 示例编辑器
├── js/editor/editor.js # 示例编辑器逻辑 ├── js/editor/editor.js # 示例编辑器逻辑
├── css/editor.css # 示例编辑器样式 ├── css/editor.css # 示例编辑器样式
├── README.md # 中文文档 ├── README.md # 中文文档
└── README_EN.md # English └── README_EN.md # English
``` ```
> **注意**: `editor.html` 和 `js/editor/` 是示例代码,展示如何使用本库。 > **注意**: `editor.html` 和 `js/editor/` 是示例代码,展示如何使用本库。
@@ -112,11 +114,29 @@ webbuilder.define(name, definition)
defaultColumnSpan: 6, // 默认跨列数 defaultColumnSpan: 6, // 默认跨列数
defaultRowSpan: 4, // 默认跨行数 defaultRowSpan: 4, // 默认跨行数
defaultProps: { ... }, // 默认属性 defaultProps: { ... }, // 默认属性
traits: [ ... ], // 属性定义(用于属性面板) traits: [ ... ], // 属性定义(用于属性面板)
render: function(props) { ... } // 渲染函数,返回 DOM 元素 render: function(props) { ... } // 渲染函数,返回 DOM 元素
} }
``` ```
### 注册变化回调
```javascript
webbuilder.onChanged(function(action, data) {
// action: 'add' | 'delete' | 'move' | 'update' | 'load' | 'clear'
// data: 相关数据组件实例、组件ID等
});
```
| action | 说明 | data |
|--------|------|------|
| add | 添加组件 | 组件实例对象 |
| delete | 删除组件 | { id: 组件ID } |
| move | 移动组件 | 组件实例对象 |
| update | 属性面板修改属性 | 组件实例对象 |
| load | 加载JSONB | { count: 组件数量 } |
| clear | 清空画布 | null |
### 导出 JSONB ### 导出 JSONB
```javascript ```javascript
@@ -176,7 +196,7 @@ var components = webbuilder.renderToDiv(container);
{ {
element: HTMLElement, // 组件 DOM 元素 element: HTMLElement, // 组件 DOM 元素
wrapper: HTMLElement, // 包装容器 wrapper: HTMLElement, // 包装容器
props: { ... }, // 组件属性(深拷贝) props: { ... }, // 组件属性(深拷贝)
id: 'gauge-1', id: 'gauge-1',
type: 'gauge' type: 'gauge'
} }
@@ -257,4 +277,4 @@ MIT
--- ---
*参考项目:[webbuilder.js](https://github.com/qrailibs/webbuilder.js)* *参考项目:[webbuilder.js](https://github.com/qrailibs/webbuilder.js)*

View File

@@ -3,16 +3,24 @@
* 仅支持手机端布局 * 仅支持手机端布局
*/ */
// 未保存标记
var isDirty = false;
// 初始化编辑器 // 初始化编辑器
function initEditor() { function initEditor() {
// 初始化 webbuilder // 初始化 webbuilder
webbuilder.init('#editor'); webbuilder.init('#editor');
// 注册示例组件 // 注册示例组件
registerComponents(); registerComponents();
// 绑定事件 // 绑定事件
bindEvents(); bindEvents();
// 注册变化回调
webbuilder.onChanged(function(action, data) {
isDirty = true;
});
} }
/** /**
@@ -39,22 +47,22 @@ function registerComponents() {
render: function(props) { render: function(props) {
var el = document.createElement('div'); var el = document.createElement('div');
el.className = 'iot-gauge'; el.className = 'iot-gauge';
var valueEl = document.createElement('div'); var valueEl = document.createElement('div');
valueEl.className = 'value'; valueEl.className = 'value';
valueEl.textContent = props.value + props.unit; valueEl.textContent = props.value + props.unit;
var labelEl = document.createElement('div'); var labelEl = document.createElement('div');
labelEl.className = 'label'; labelEl.className = 'label';
labelEl.textContent = props.label; labelEl.textContent = props.label;
el.appendChild(valueEl); el.appendChild(valueEl);
el.appendChild(labelEl); el.appendChild(labelEl);
return el; return el;
} }
}); });
// 开关组件 // 开关组件
webbuilder.define('switch', { webbuilder.define('switch', {
type: 'switch', type: 'switch',
@@ -73,21 +81,21 @@ function registerComponents() {
render: function(props) { render: function(props) {
var el = document.createElement('div'); var el = document.createElement('div');
el.className = 'iot-switch'; el.className = 'iot-switch';
var switchEl = document.createElement('div'); var switchEl = document.createElement('div');
switchEl.className = 'switch' + (props.state ? ' active' : ''); switchEl.className = 'switch' + (props.state ? ' active' : '');
var labelEl = document.createElement('div'); var labelEl = document.createElement('div');
labelEl.className = 'label'; labelEl.className = 'label';
labelEl.textContent = props.label; labelEl.textContent = props.label;
el.appendChild(switchEl); el.appendChild(switchEl);
el.appendChild(labelEl); el.appendChild(labelEl);
return el; return el;
} }
}); });
// 滑块组件 // 滑块组件
webbuilder.define('slider', { webbuilder.define('slider', {
type: 'slider', type: 'slider',
@@ -110,24 +118,24 @@ function registerComponents() {
render: function(props) { render: function(props) {
var el = document.createElement('div'); var el = document.createElement('div');
el.className = 'iot-slider'; el.className = 'iot-slider';
var labelEl = document.createElement('div'); var labelEl = document.createElement('div');
labelEl.className = 'label'; labelEl.className = 'label';
labelEl.textContent = props.label + ': ' + props.value; labelEl.textContent = props.label + ': ' + props.value;
var inputEl = document.createElement('input'); var inputEl = document.createElement('input');
inputEl.type = 'range'; inputEl.type = 'range';
inputEl.min = props.min; inputEl.min = props.min;
inputEl.max = props.max; inputEl.max = props.max;
inputEl.value = props.value; inputEl.value = props.value;
el.appendChild(labelEl); el.appendChild(labelEl);
el.appendChild(inputEl); el.appendChild(inputEl);
return el; return el;
} }
}); });
// 按钮组件 // 按钮组件
webbuilder.define('button', { webbuilder.define('button', {
type: 'button', type: 'button',
@@ -148,7 +156,7 @@ function registerComponents() {
return el; return el;
} }
}); });
// 数值显示组件 // 数值显示组件
webbuilder.define('value-display', { webbuilder.define('value-display', {
type: 'value-display', type: 'value-display',
@@ -169,18 +177,18 @@ function registerComponents() {
render: function(props) { render: function(props) {
var el = document.createElement('div'); var el = document.createElement('div');
el.className = 'iot-value-display'; el.className = 'iot-value-display';
var valueEl = document.createElement('div'); var valueEl = document.createElement('div');
valueEl.className = 'value'; valueEl.className = 'value';
valueEl.textContent = props.value + props.unit; valueEl.textContent = props.value + props.unit;
var labelEl = document.createElement('div'); var labelEl = document.createElement('div');
labelEl.className = 'label'; labelEl.className = 'label';
labelEl.textContent = props.label; labelEl.textContent = props.label;
el.appendChild(valueEl); el.appendChild(valueEl);
el.appendChild(labelEl); el.appendChild(labelEl);
return el; return el;
} }
}); });
@@ -195,41 +203,46 @@ function bindEvents() {
var jsonb = webbuilder.toJSONB(); var jsonb = webbuilder.toJSONB();
var jsonStr = JSON.stringify(jsonb, null, 2); var jsonStr = JSON.stringify(jsonb, null, 2);
downloadFile('canvas-config.json', jsonStr, 'application/json'); downloadFile('canvas-config.json', jsonStr, 'application/json');
isDirty = false;
}); });
// 加载 JSONB // 加载 JSONB
document.getElementById('btn-load').addEventListener('click', function() { document.getElementById('btn-load').addEventListener('click', function() {
if (isDirty && !confirm('当前有未保存的更改,确定要加载新文件吗?')) {
return;
}
document.getElementById('file-input').click(); document.getElementById('file-input').click();
}); });
document.getElementById('file-input').addEventListener('change', function(e) { document.getElementById('file-input').addEventListener('change', function(e) {
var file = e.target.files[0]; var file = e.target.files[0];
if (!file) return; if (!file) return;
var reader = new FileReader(); var reader = new FileReader();
reader.onload = function(e) { reader.onload = function(e) {
try { try {
var jsonb = JSON.parse(e.target.result); var jsonb = JSON.parse(e.target.result);
webbuilder.fromJSONB(jsonb); webbuilder.fromJSONB(jsonb);
isDirty = false;
} catch (err) { } catch (err) {
alert('加载失败:' + err.message); alert('加载失败:' + err.message);
} }
}; };
reader.readAsText(file); reader.readAsText(file);
// 清空文件输入 // 清空文件输入
this.value = ''; this.value = '';
}); });
// 导出 HTML // 导出 HTML
document.getElementById('btn-export').addEventListener('click', function() { document.getElementById('btn-export').addEventListener('click', function() {
// 创建一个临时 div 来渲染 // 创建一个临时 div 来渲染
var tempDiv = document.createElement('div'); var tempDiv = document.createElement('div');
document.body.appendChild(tempDiv); document.body.appendChild(tempDiv);
// 调用 renderToDiv 获取组件列表 // 调用 renderToDiv 获取组件列表
var components = webbuilder.renderToDiv(tempDiv); var components = webbuilder.renderToDiv(tempDiv);
// 生成 HTML // 生成 HTML
var config = webbuilder.getCanvasConfig(); var config = webbuilder.getCanvasConfig();
var html = '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="UTF-8">\n'; var html = '<!DOCTYPE html>\n<html>\n<head>\n<meta charset="UTF-8">\n';
@@ -251,25 +264,29 @@ function bindEvents() {
}), null, 2) + ';\n'; }), null, 2) + ';\n';
html += '</script>\n'; html += '</script>\n';
html += '</body>\n</html>'; html += '</body>\n</html>';
// 移除临时 div // 移除临时 div
document.body.removeChild(tempDiv); document.body.removeChild(tempDiv);
showPreview(html); showPreview(html);
}); });
// 清空画布 // 清空画布
document.getElementById('btn-clear').addEventListener('click', function() { document.getElementById('btn-clear').addEventListener('click', function() {
if (isDirty && !confirm('当前有未保存的更改,确定要清空画布吗?')) {
return;
}
if (confirm('确定要清空画布吗?')) { if (confirm('确定要清空画布吗?')) {
webbuilder.clear(); webbuilder.clear();
isDirty = true;
} }
}); });
// 预览弹窗关闭 // 预览弹窗关闭
document.querySelector('.modal-close').addEventListener('click', function() { document.querySelector('.modal-close').addEventListener('click', function() {
document.getElementById('preview-modal').style.display = 'none'; document.getElementById('preview-modal').style.display = 'none';
}); });
// 复制代码 // 复制代码
document.getElementById('btn-copy').addEventListener('click', function() { document.getElementById('btn-copy').addEventListener('click', function() {
var code = document.getElementById('preview-code').textContent; var code = document.getElementById('preview-code').textContent;
@@ -277,12 +294,20 @@ function bindEvents() {
alert('已复制到剪贴板'); alert('已复制到剪贴板');
}); });
}); });
// 下载文件 // 下载文件
document.getElementById('btn-download').addEventListener('click', function() { document.getElementById('btn-download').addEventListener('click', function() {
var code = document.getElementById('preview-code').textContent; var code = document.getElementById('preview-code').textContent;
downloadFile('page.html', code, 'text/html'); downloadFile('page.html', code, 'text/html');
}); });
// 页面离开提示
window.addEventListener('beforeunload', function(e) {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
}
});
} }
/** /**

View File

@@ -7,7 +7,7 @@
var webbuilder = { var webbuilder = {
// 编辑器容器 // 编辑器容器
container: null, container: null,
// 手机画布配置 // 手机画布配置
phoneConfig: { phoneConfig: {
width: 375, width: 375,
@@ -16,37 +16,43 @@ var webbuilder = {
gap: 2, gap: 2,
background: '#f5f5f5' background: '#f5f5f5'
}, },
// 计算得出的单元格大小1:1比例 // 计算得出的单元格大小1:1比例
cellSize: 0, cellSize: 0,
rows: 0, rows: 0,
// 已注册的组件类型 // 已注册的组件类型
componentTypes: {}, componentTypes: {},
// 画布上的组件实例 // 画布上的组件实例
componentInstances: [], componentInstances: [],
// 当前选中的组件 // 当前选中的组件
selectedComponent: null, selectedComponent: null,
// 拖拽状态 // 拖拽状态
draggingComponent: null, draggingComponent: null,
draggingType: null, draggingType: null,
// 拖拽时鼠标相对于组件的偏移
dragOffset: { x: 0, y: 0 },
// 唯一ID计数器 // 唯一ID计数器
idCounter: 0, idCounter: 0,
// 变化回调函数
onChangedCallback: null,
/** /**
* 计算单元格大小和行数 * 计算单元格大小和行数
*/ */
_calculateGrid: function() { _calculateGrid: function() {
var config = this.phoneConfig; var config = this.phoneConfig;
// 计算列宽(单元格大小) // 计算列宽(单元格大小)
// 列宽 = (画布宽度 - (列数 + 1) * gap) / 列数 // 列宽 = (画布宽度 - (列数 + 1) * gap) / 列数
this.cellSize = (config.width - (config.columns + 1) * config.gap) / config.columns; this.cellSize = (config.width - (config.columns + 1) * config.gap) / config.columns;
// 计算行数 // 计算行数
// 画布高度 = 行数 * 单元格大小 + (行数 + 1) * gap // 画布高度 = 行数 * 单元格大小 + (行数 + 1) * gap
// 812 = 行数 * cellSize + (行数 + 1) * gap // 812 = 行数 * cellSize + (行数 + 1) * gap
@@ -54,11 +60,11 @@ var webbuilder = {
// 812 - gap = 行数 * (cellSize + gap) // 812 - gap = 行数 * (cellSize + gap)
// 行数 = (812 - gap) / (cellSize + gap) // 行数 = (812 - gap) / (cellSize + gap)
this.rows = Math.floor((config.height - config.gap) / (this.cellSize + config.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; this.phoneConfig.height = this.rows * this.cellSize + (this.rows + 1) * config.gap;
}, },
/** /**
* 初始化编辑器 * 初始化编辑器
* @param {string|HTMLElement} containerSelector - 容器选择器或元素 * @param {string|HTMLElement} containerSelector - 容器选择器或元素
@@ -70,55 +76,58 @@ var webbuilder = {
} else { } else {
this.container = containerSelector; this.container = containerSelector;
} }
if (!this.container) { if (!this.container) {
throw new Error('Container element not found'); throw new Error('Container element not found');
} }
// 计算网格 // 计算网格
this._calculateGrid(); this._calculateGrid();
// 创建编辑器结构 // 创建编辑器结构
this._createEditorStructure(); this._createEditorStructure();
// 初始化画布 // 初始化画布
this._initCanvas(); this._initCanvas();
// 初始化工具箱 // 初始化工具箱
this._initToolbox(); this._initToolbox();
// 初始化键盘事件
this._initKeyboardEvents();
return this; return this;
}, },
/** /**
* 创建编辑器结构 * 创建编辑器结构
*/ */
_createEditorStructure: function() { _createEditorStructure: function() {
this.container.innerHTML = ''; this.container.innerHTML = '';
this.container.classList.add('webbuilder-editor'); this.container.classList.add('webbuilder-editor');
// 工具箱容器 // 工具箱容器
this.toolboxEl = document.createElement('div'); this.toolboxEl = document.createElement('div');
this.toolboxEl.className = 'webbuilder-toolbox'; this.toolboxEl.className = 'webbuilder-toolbox';
this.container.appendChild(this.toolboxEl); this.container.appendChild(this.toolboxEl);
// 画布容器 // 画布容器
this.canvasWrapperEl = document.createElement('div'); this.canvasWrapperEl = document.createElement('div');
this.canvasWrapperEl.className = 'webbuilder-canvas-wrapper'; this.canvasWrapperEl.className = 'webbuilder-canvas-wrapper';
this.container.appendChild(this.canvasWrapperEl); this.container.appendChild(this.canvasWrapperEl);
// 属性面板容器 // 属性面板容器
this.propertyPanelEl = document.createElement('div'); this.propertyPanelEl = document.createElement('div');
this.propertyPanelEl.className = 'webbuilder-property-panel'; this.propertyPanelEl.className = 'webbuilder-property-panel';
this.container.appendChild(this.propertyPanelEl); this.container.appendChild(this.propertyPanelEl);
}, },
/** /**
* 初始化画布 * 初始化画布
*/ */
_initCanvas: function() { _initCanvas: function() {
var config = this.phoneConfig; var config = this.phoneConfig;
// 创建画布 // 创建画布
this.canvasEl = document.createElement('div'); this.canvasEl = document.createElement('div');
this.canvasEl.className = 'webbuilder-canvas'; this.canvasEl.className = 'webbuilder-canvas';
@@ -133,7 +142,7 @@ var webbuilder = {
this.canvasEl.style.padding = config.gap + 'px'; this.canvasEl.style.padding = config.gap + 'px';
this.canvasEl.style.boxSizing = 'border-box'; this.canvasEl.style.boxSizing = 'border-box';
this.canvasWrapperEl.appendChild(this.canvasEl); this.canvasWrapperEl.appendChild(this.canvasEl);
// 画布点击取消选中 // 画布点击取消选中
var self = this; var self = this;
this.canvasEl.addEventListener('click', function(e) { this.canvasEl.addEventListener('click', function(e) {
@@ -141,21 +150,21 @@ var webbuilder = {
self._deselectAll(); self._deselectAll();
} }
}); });
// 拖放事件 // 拖放事件
this.canvasEl.addEventListener('dragover', function(e) { this.canvasEl.addEventListener('dragover', function(e) {
e.preventDefault(); e.preventDefault();
self.canvasEl.classList.add('dropping'); self.canvasEl.classList.add('dropping');
}); });
this.canvasEl.addEventListener('dragleave', function(e) { this.canvasEl.addEventListener('dragleave', function(e) {
self.canvasEl.classList.remove('dropping'); self.canvasEl.classList.remove('dropping');
}); });
this.canvasEl.addEventListener('drop', function(e) { this.canvasEl.addEventListener('drop', function(e) {
e.preventDefault(); e.preventDefault();
self.canvasEl.classList.remove('dropping'); self.canvasEl.classList.remove('dropping');
if (self.draggingType) { if (self.draggingType) {
// 从工具箱拖入新组件 // 从工具箱拖入新组件
var gridPos = self._calculateGridPosition(e); var gridPos = self._calculateGridPosition(e);
@@ -169,7 +178,7 @@ var webbuilder = {
} }
}); });
}, },
/** /**
* 初始化工具箱 * 初始化工具箱
*/ */
@@ -179,29 +188,64 @@ var webbuilder = {
this.toolboxListEl.className = 'webbuilder-toolbox-list'; this.toolboxListEl.className = 'webbuilder-toolbox-list';
this.toolboxEl.appendChild(this.toolboxListEl); this.toolboxEl.appendChild(this.toolboxListEl);
}, },
/**
* 初始化键盘事件
*/
_initKeyboardEvents: function() {
var self = this;
document.addEventListener('keydown', function(e) {
// Delete键删除选中的组件
if (e.key === 'Delete' || e.key === 'Backspace') {
if (self.selectedComponent) {
e.preventDefault();
self._deleteComponent(self.selectedComponent);
}
}
});
},
/**
* 注册变化回调
* @param {function} callback - 回调函数签名callback(action, data)
* action: 'add' | 'delete' | 'move' | 'update' | 'load' | 'clear'
* data: 相关数据组件实例、组件ID等
*/
onChanged: function(callback) {
this.onChangedCallback = callback;
},
/**
* 触发变化回调
*/
_notifyChanged: function(action, data) {
if (typeof this.onChangedCallback === 'function') {
this.onChangedCallback(action, data);
}
},
/** /**
* 计算网格位置 * 计算网格位置
*/ */
_calculateGridPosition: function(e) { _calculateGridPosition: function(e) {
var rect = this.canvasEl.getBoundingClientRect(); var rect = this.canvasEl.getBoundingClientRect();
var config = this.phoneConfig; var config = this.phoneConfig;
// 计算鼠标相对于画布的位置 // 计算鼠标相对于画布的位置,减去偏移量
var x = e.clientX - rect.left - config.gap; var x = e.clientX - rect.left - config.gap - this.dragOffset.x;
var y = e.clientY - rect.top - config.gap; var y = e.clientY - rect.top - config.gap - this.dragOffset.y;
// 计算列和行 // 计算列和行
var col = Math.floor(x / (this.cellSize + config.gap)) + 1; var col = Math.floor(x / (this.cellSize + config.gap)) + 1;
var row = Math.floor(y / (this.cellSize + config.gap)) + 1; var row = Math.floor(y / (this.cellSize + config.gap)) + 1;
// 边界约束 // 边界约束
col = Math.max(1, Math.min(config.columns, col)); col = Math.max(1, Math.min(config.columns, col));
row = Math.max(1, Math.min(this.rows, row)); row = Math.max(1, Math.min(this.rows, row));
return { column: col, row: row }; return { column: col, row: row };
}, },
/** /**
* 添加组件到画布 * 添加组件到画布
*/ */
@@ -211,13 +255,13 @@ var webbuilder = {
console.error('Component type not found:', componentType); console.error('Component type not found:', componentType);
return; return;
} }
// 生成唯一ID // 生成唯一ID
var id = componentType + '-' + (++this.idCounter); var id = componentType + '-' + (++this.idCounter);
// 计算 startCell // 计算 startCell
var startCell = (gridPos.row - 1) * this.phoneConfig.columns + (gridPos.column - 1); var startCell = (gridPos.row - 1) * this.phoneConfig.columns + (gridPos.column - 1);
// 创建组件实例 // 创建组件实例
var instance = { var instance = {
id: id, id: id,
@@ -229,33 +273,36 @@ var webbuilder = {
}, },
props: JSON.parse(JSON.stringify(typeDef.defaultProps || {})) props: JSON.parse(JSON.stringify(typeDef.defaultProps || {}))
}; };
// 渲染组件 // 渲染组件
this._renderComponent(instance); this._renderComponent(instance);
// 添加到实例列表 // 添加到实例列表
this.componentInstances.push(instance); this.componentInstances.push(instance);
// 选中新添加的组件 // 选中新添加的组件
this._selectComponent(instance.id); this._selectComponent(instance.id);
// 触发回调
this._notifyChanged('add', instance);
}, },
/** /**
* 渲染组件到画布(编辑器内部使用) * 渲染组件到画布(编辑器内部使用)
*/ */
_renderComponent: function(instance) { _renderComponent: function(instance) {
var typeDef = this.componentTypes[instance.type]; var typeDef = this.componentTypes[instance.type];
if (!typeDef) return; if (!typeDef) return;
// 计算行列位置 // 计算行列位置
var columns = this.phoneConfig.columns; var columns = this.phoneConfig.columns;
var startCol = instance.grid.startCell % columns; var startCol = instance.grid.startCell % columns;
var startRow = Math.floor(instance.grid.startCell / columns); var startRow = Math.floor(instance.grid.startCell / columns);
// 边界约束 // 边界约束
var colSpan = Math.min(instance.grid.colSpan, columns - startCol); var colSpan = Math.min(instance.grid.colSpan, columns - startCol);
var rowSpan = instance.grid.rowSpan; var rowSpan = instance.grid.rowSpan;
// 创建组件容器元素 // 创建组件容器元素
var el = document.createElement('div'); var el = document.createElement('div');
el.className = 'webbuilder-component'; el.className = 'webbuilder-component';
@@ -265,7 +312,7 @@ var webbuilder = {
el.style.gridRow = (startRow + 1) + ' / span ' + rowSpan; el.style.gridRow = (startRow + 1) + ' / span ' + rowSpan;
el.style.position = 'relative'; el.style.position = 'relative';
el.style.overflow = 'hidden'; el.style.overflow = 'hidden';
// 渲染组件内容 - render 函数返回 HTML 元素对象 // 渲染组件内容 - render 函数返回 HTML 元素对象
if (typeDef.render) { if (typeDef.render) {
var content = typeDef.render(instance.props); var content = typeDef.render(instance.props);
@@ -277,7 +324,7 @@ var webbuilder = {
el.appendChild(content); el.appendChild(content);
} }
} }
// 使组件可拖拽 // 使组件可拖拽
var self = this; var self = this;
el.setAttribute('draggable', true); el.setAttribute('draggable', true);
@@ -285,25 +332,29 @@ var webbuilder = {
e.stopPropagation(); e.stopPropagation();
self.draggingComponent = instance.id; self.draggingComponent = instance.id;
el.style.opacity = '0.5'; el.style.opacity = '0.5';
// 计算鼠标相对于组件的偏移
var rect = el.getBoundingClientRect();
self.dragOffset.x = e.clientX - rect.left;
self.dragOffset.y = e.clientY - rect.top;
}); });
el.addEventListener('dragend', function(e) { el.addEventListener('dragend', function(e) {
el.style.opacity = '1'; el.style.opacity = '1';
self.draggingComponent = null; self.draggingComponent = null;
}); });
// 点击选中 // 点击选中
el.addEventListener('click', function(e) { el.addEventListener('click', function(e) {
e.stopPropagation(); e.stopPropagation();
self._selectComponent(instance.id); self._selectComponent(instance.id);
}); });
// 存储元素引用 // 存储元素引用
instance.element = el; instance.element = el;
// 添加到画布 // 添加到画布
this.canvasEl.appendChild(el); this.canvasEl.appendChild(el);
}, },
/** /**
* 将组件渲染到指定的 div 容器 * 将组件渲染到指定的 div 容器
* @param {HTMLElement} container - 目标容器 * @param {HTMLElement} container - 目标容器
@@ -312,10 +363,10 @@ var webbuilder = {
renderToDiv: function(container) { renderToDiv: function(container) {
var self = this; var self = this;
var result = []; var result = [];
// 清空容器 // 清空容器
container.innerHTML = ''; container.innerHTML = '';
// 创建画布样式 // 创建画布样式
container.style.width = this.phoneConfig.width + 'px'; container.style.width = this.phoneConfig.width + 'px';
container.style.height = this.phoneConfig.height + 'px'; container.style.height = this.phoneConfig.height + 'px';
@@ -326,29 +377,29 @@ var webbuilder = {
container.style.background = this.phoneConfig.background; container.style.background = this.phoneConfig.background;
container.style.padding = this.phoneConfig.gap + 'px'; container.style.padding = this.phoneConfig.gap + 'px';
container.style.boxSizing = 'border-box'; container.style.boxSizing = 'border-box';
var columns = this.phoneConfig.columns; var columns = this.phoneConfig.columns;
// 遍历所有组件实例 // 遍历所有组件实例
this.componentInstances.forEach(function(instance) { this.componentInstances.forEach(function(instance) {
var typeDef = self.componentTypes[instance.type]; var typeDef = self.componentTypes[instance.type];
if (!typeDef || !typeDef.render) return; if (!typeDef || !typeDef.render) return;
// 计算行列位置 // 计算行列位置
var startCol = instance.grid.startCell % columns; var startCol = instance.grid.startCell % columns;
var startRow = Math.floor(instance.grid.startCell / columns); var startRow = Math.floor(instance.grid.startCell / columns);
var colSpan = Math.min(instance.grid.colSpan, columns - startCol); var colSpan = Math.min(instance.grid.colSpan, columns - startCol);
var rowSpan = instance.grid.rowSpan; var rowSpan = instance.grid.rowSpan;
// 创建组件容器 // 创建组件容器
var wrapper = document.createElement('div'); var wrapper = document.createElement('div');
wrapper.style.gridColumn = (startCol + 1) + ' / span ' + colSpan; wrapper.style.gridColumn = (startCol + 1) + ' / span ' + colSpan;
wrapper.style.gridRow = (startRow + 1) + ' / span ' + rowSpan; wrapper.style.gridRow = (startRow + 1) + ' / span ' + rowSpan;
wrapper.style.overflow = 'hidden'; wrapper.style.overflow = 'hidden';
// 调用 render 函数获取组件元素 // 调用 render 函数获取组件元素
var componentElement = typeDef.render(instance.props); var componentElement = typeDef.render(instance.props);
// 如果返回的是字符串,保持兼容 // 如果返回的是字符串,保持兼容
if (typeof componentElement === 'string') { if (typeof componentElement === 'string') {
wrapper.innerHTML = componentElement; wrapper.innerHTML = componentElement;
@@ -356,10 +407,10 @@ var webbuilder = {
} else if (componentElement instanceof HTMLElement) { } else if (componentElement instanceof HTMLElement) {
wrapper.appendChild(componentElement); wrapper.appendChild(componentElement);
} }
// 添加到容器 // 添加到容器
container.appendChild(wrapper); container.appendChild(wrapper);
// 添加到结果列表 // 添加到结果列表
result.push({ result.push({
element: componentElement, // 控件本身的 HTML 元素对象 element: componentElement, // 控件本身的 HTML 元素对象
@@ -369,53 +420,56 @@ var webbuilder = {
type: instance.type type: instance.type
}); });
}); });
return result; return result;
}, },
/** /**
* 移动组件 * 移动组件
*/ */
_moveComponent: function(componentId, gridPos) { _moveComponent: function(componentId, gridPos) {
var instance = this._findInstance(componentId); var instance = this._findInstance(componentId);
if (!instance) return; if (!instance) return;
// 更新 startCell // 更新 startCell
instance.grid.startCell = (gridPos.row - 1) * this.phoneConfig.columns + (gridPos.column - 1); instance.grid.startCell = (gridPos.row - 1) * this.phoneConfig.columns + (gridPos.column - 1);
// 重新计算行列位置 // 重新计算行列位置
var columns = this.phoneConfig.columns; var columns = this.phoneConfig.columns;
var startCol = instance.grid.startCell % columns; var startCol = instance.grid.startCell % columns;
var startRow = Math.floor(instance.grid.startCell / columns); var startRow = Math.floor(instance.grid.startCell / columns);
var colSpan = Math.min(instance.grid.colSpan, columns - startCol); var colSpan = Math.min(instance.grid.colSpan, columns - startCol);
// 更新样式 // 更新样式
instance.element.style.gridColumn = (startCol + 1) + ' / span ' + colSpan; instance.element.style.gridColumn = (startCol + 1) + ' / span ' + colSpan;
instance.element.style.gridRow = (startRow + 1) + ' / span ' + instance.grid.rowSpan; instance.element.style.gridRow = (startRow + 1) + ' / span ' + instance.grid.rowSpan;
// 更新属性面板 // 更新属性面板
if (this.selectedComponent === componentId) { if (this.selectedComponent === componentId) {
this._showPropertyPanel(instance); this._showPropertyPanel(instance);
} }
// 触发回调
this._notifyChanged('move', instance);
}, },
/** /**
* 选中组件 * 选中组件
*/ */
_selectComponent: function(componentId) { _selectComponent: function(componentId) {
// 取消之前的选中 // 取消之前的选中
this._deselectAll(); this._deselectAll();
var instance = this._findInstance(componentId); var instance = this._findInstance(componentId);
if (!instance) return; if (!instance) return;
this.selectedComponent = componentId; this.selectedComponent = componentId;
instance.element.classList.add('selected'); instance.element.classList.add('selected');
// 显示属性面板 // 显示属性面板
this._showPropertyPanel(instance); this._showPropertyPanel(instance);
}, },
/** /**
* 取消所有选中 * 取消所有选中
*/ */
@@ -427,7 +481,7 @@ var webbuilder = {
}); });
this.propertyPanelEl.innerHTML = ''; this.propertyPanelEl.innerHTML = '';
}, },
/** /**
* 显示属性面板 * 显示属性面板
*/ */
@@ -435,13 +489,13 @@ var webbuilder = {
var typeDef = this.componentTypes[instance.type]; var typeDef = this.componentTypes[instance.type];
var self = this; var self = this;
var columns = this.phoneConfig.columns; var columns = this.phoneConfig.columns;
// 计算当前行列 // 计算当前行列
var startCol = instance.grid.startCell % columns; var startCol = instance.grid.startCell % columns;
var startRow = Math.floor(instance.grid.startCell / columns); var startRow = Math.floor(instance.grid.startCell / columns);
var html = '<div class="property-title">' + (typeDef.label || instance.type) + '</div>'; var html = '<div class="property-title">' + (typeDef.label || instance.type) + '</div>';
// 基础属性 // 基础属性
html += '<div class="property-section">'; html += '<div class="property-section">';
html += '<div class="property-section-title">位置大小</div>'; html += '<div class="property-section-title">位置大小</div>';
@@ -450,7 +504,7 @@ var webbuilder = {
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="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 class="property-row"><label>跨行数:</label><input type="number" data-prop="rowSpan" value="' + instance.grid.rowSpan + '" min="1"></div>';
html += '</div>'; html += '</div>';
// 自定义属性 // 自定义属性
if (typeDef.traits && typeDef.traits.length > 0) { if (typeDef.traits && typeDef.traits.length > 0) {
html += '<div class="property-section">'; html += '<div class="property-section">';
@@ -470,23 +524,23 @@ var webbuilder = {
}); });
html += '</div>'; html += '</div>';
} }
// 删除按钮 // 删除按钮
html += '<div class="property-section">'; html += '<div class="property-section">';
html += '<button class="btn-delete" data-action="delete">删除组件</button>'; html += '<button class="btn-delete" data-action="delete">删除组件</button>';
html += '</div>'; html += '</div>';
this.propertyPanelEl.innerHTML = html; this.propertyPanelEl.innerHTML = html;
// 绑定事件 // 绑定事件
this.propertyPanelEl.querySelectorAll('input').forEach(function(input) { this.propertyPanelEl.querySelectorAll('input').forEach(function(input) {
input.addEventListener('change', function() { input.addEventListener('change', function() {
var prop = input.getAttribute('data-prop'); var prop = input.getAttribute('data-prop');
var trait = input.getAttribute('data-trait'); var trait = input.getAttribute('data-trait');
if (prop) { if (prop) {
// 更新网格位置 // 更新网格位置
var value = parseInt(input.value) || 1; var value = parseInt(input.value, 10) || 1;
if (prop === 'startCol') { if (prop === 'startCol') {
var currentRow = Math.floor(instance.grid.startCell / columns); var currentRow = Math.floor(instance.grid.startCell / columns);
instance.grid.startCell = currentRow * columns + (value - 1); instance.grid.startCell = currentRow * columns + (value - 1);
@@ -498,19 +552,19 @@ var webbuilder = {
} else if (prop === 'rowSpan') { } else if (prop === 'rowSpan') {
instance.grid.rowSpan = value; instance.grid.rowSpan = value;
} }
// 重新计算位置 // 重新计算位置
var startCol = instance.grid.startCell % columns; var startCol = instance.grid.startCell % columns;
var startRow = Math.floor(instance.grid.startCell / columns); var startRow = Math.floor(instance.grid.startCell / columns);
var colSpan = Math.min(instance.grid.colSpan, columns - startCol); var colSpan = Math.min(instance.grid.colSpan, columns - startCol);
instance.element.style.gridColumn = (startCol + 1) + ' / span ' + colSpan; instance.element.style.gridColumn = (startCol + 1) + ' / span ' + colSpan;
instance.element.style.gridRow = (startRow + 1) + ' / span ' + instance.grid.rowSpan; instance.element.style.gridRow = (startRow + 1) + ' / span ' + instance.grid.rowSpan;
} else if (trait) { } else if (trait) {
// 更新组件属性 // 更新组件属性
var typeDef = self.componentTypes[instance.type]; var typeDef = self.componentTypes[instance.type];
var traitDef = typeDef.traits.find(function(t) { return t.name === trait; }); var traitDef = typeDef.traits.find(function(t) { return t.name === trait; });
if (input.type === 'checkbox') { if (input.type === 'checkbox') {
instance.props[trait] = input.checked; instance.props[trait] = input.checked;
} else if (traitDef && traitDef.type === 'number') { } else if (traitDef && traitDef.type === 'number') {
@@ -518,7 +572,7 @@ var webbuilder = {
} else { } else {
instance.props[trait] = input.value; instance.props[trait] = input.value;
} }
// 重新渲染组件内容 // 重新渲染组件内容
if (typeDef.render) { if (typeDef.render) {
var content = typeDef.render(instance.props); var content = typeDef.render(instance.props);
@@ -530,9 +584,12 @@ var webbuilder = {
} }
} }
} }
// 触发属性更新回调
self._notifyChanged('update', instance);
}); });
}); });
// 删除按钮 // 删除按钮
var deleteBtn = this.propertyPanelEl.querySelector('[data-action="delete"]'); var deleteBtn = this.propertyPanelEl.querySelector('[data-action="delete"]');
if (deleteBtn) { if (deleteBtn) {
@@ -541,7 +598,7 @@ var webbuilder = {
}); });
} }
}, },
/** /**
* 删除组件 * 删除组件
*/ */
@@ -553,23 +610,26 @@ var webbuilder = {
break; break;
} }
} }
if (index === -1) return; if (index === -1) return;
var instance = this.componentInstances[index]; var instance = this.componentInstances[index];
// 从DOM移除 // 从DOM移除
if (instance.element) { if (instance.element) {
instance.element.remove(); instance.element.remove();
} }
// 从数组移除 // 从数组移除
this.componentInstances.splice(index, 1); this.componentInstances.splice(index, 1);
// 取消选中 // 取消选中
this._deselectAll(); this._deselectAll();
// 触发回调
this._notifyChanged('delete', { id: componentId });
}, },
/** /**
* 查找组件实例 * 查找组件实例
*/ */
@@ -581,7 +641,7 @@ var webbuilder = {
} }
return null; return null;
}, },
/** /**
* 注册组件类型 * 注册组件类型
* @param {string} name - 组件名称 * @param {string} name - 组件名称
@@ -592,29 +652,29 @@ var webbuilder = {
console.warn('Component type already defined:', name); console.warn('Component type already defined:', name);
return; return;
} }
this.componentTypes[name] = definition; this.componentTypes[name] = definition;
// 添加到工具箱 // 添加到工具箱
this._addToToolbox(name, definition); this._addToToolbox(name, definition);
}, },
/** /**
* 添加组件到工具箱 * 添加组件到工具箱
*/ */
_addToToolbox: function(name, definition) { _addToToolbox: function(name, definition) {
var self = this; var self = this;
var item = document.createElement('div'); var item = document.createElement('div');
item.className = 'webbuilder-toolbox-item'; item.className = 'webbuilder-toolbox-item';
item.setAttribute('draggable', true); item.setAttribute('draggable', true);
item.setAttribute('data-type', name); item.setAttribute('data-type', name);
var icon = definition.icon || '📦'; var icon = definition.icon || '📦';
var label = definition.label || name; var label = definition.label || name;
item.innerHTML = '<span class="icon">' + icon + '</span><span class="label">' + label + '</span>'; item.innerHTML = '<span class="icon">' + icon + '</span><span class="label">' + label + '</span>';
// 拖拽事件 // 拖拽事件
item.addEventListener('dragstart', function(e) { item.addEventListener('dragstart', function(e) {
self.draggingType = name; self.draggingType = name;
@@ -624,10 +684,10 @@ var webbuilder = {
item.style.opacity = '1'; item.style.opacity = '1';
self.draggingType = null; self.draggingType = null;
}); });
this.toolboxListEl.appendChild(item); this.toolboxListEl.appendChild(item);
}, },
/** /**
* 导出为JSONB * 导出为JSONB
* 返回格式: { version, layouts: { phone: {...}, computer: null } } * 返回格式: { version, layouts: { phone: {...}, computer: null } }
@@ -645,7 +705,7 @@ var webbuilder = {
props: JSON.parse(JSON.stringify(instance.props)) props: JSON.parse(JSON.stringify(instance.props))
}; };
}); });
return { return {
version: '1.0', version: '1.0',
layouts: { layouts: {
@@ -665,7 +725,7 @@ var webbuilder = {
} }
}; };
}, },
/** /**
* 从JSONB加载 * 从JSONB加载
*/ */
@@ -674,14 +734,14 @@ var webbuilder = {
this.canvasEl.innerHTML = ''; this.canvasEl.innerHTML = '';
this.componentInstances = []; this.componentInstances = [];
this.selectedComponent = null; this.selectedComponent = null;
// 获取手机布局 // 获取手机布局
var phoneLayout = jsonb.layouts && jsonb.layouts.phone; var phoneLayout = jsonb.layouts && jsonb.layouts.phone;
if (!phoneLayout) { if (!phoneLayout) {
console.warn('No phone layout found'); console.warn('No phone layout found');
return; return;
} }
// 渲染组件 // 渲染组件
if (phoneLayout.components) { if (phoneLayout.components) {
var self = this; var self = this;
@@ -691,7 +751,7 @@ var webbuilder = {
console.warn('Component type not found:', comp.type); console.warn('Component type not found:', comp.type);
return; return;
} }
var instance = { var instance = {
id: comp.id, id: comp.id,
type: comp.type, type: comp.type,
@@ -702,23 +762,26 @@ var webbuilder = {
}, },
props: comp.props || {} props: comp.props || {}
}; };
self._renderComponent(instance); self._renderComponent(instance);
self.componentInstances.push(instance); self.componentInstances.push(instance);
}); });
// 更新ID计数器 // 更新ID计数器
var maxId = 0; var maxId = 0;
this.componentInstances.forEach(function(inst) { this.componentInstances.forEach(function(inst) {
var match = inst.id.match(/-(\d+)$/); var match = inst.id.match(/-(\d+)$/);
if (match) { if (match) {
maxId = Math.max(maxId, parseInt(match[1])); maxId = Math.max(maxId, parseInt(match[1], 10));
} }
}); });
this.idCounter = maxId; this.idCounter = maxId;
} }
// 触发回调
this._notifyChanged('load', { count: this.componentInstances.length });
}, },
/** /**
* 获取画布配置 * 获取画布配置
*/ */
@@ -733,14 +796,14 @@ var webbuilder = {
background: this.phoneConfig.background background: this.phoneConfig.background
}; };
}, },
/** /**
* 获取所有组件实例 * 获取所有组件实例
*/ */
getComponents: function() { getComponents: function() {
return JSON.parse(JSON.stringify(this.componentInstances)); return JSON.parse(JSON.stringify(this.componentInstances));
}, },
/** /**
* 清空画布 * 清空画布
*/ */
@@ -749,5 +812,8 @@ var webbuilder = {
this.componentInstances = []; this.componentInstances = [];
this.selectedComponent = null; this.selectedComponent = null;
this.propertyPanelEl.innerHTML = ''; this.propertyPanelEl.innerHTML = '';
// 触发回调
this._notifyChanged('clear', null);
} }
}; };