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 格式保存/加载
- 🎨 自定义组件支持 - 🎨 自定义组件支持
- 🔔 变化回调通知
## 项目结构 ## 项目结构
@@ -117,6 +119,24 @@ webbuilder.define(name, definition)
} }
``` ```
### 注册变化回调
```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

View File

@@ -3,6 +3,9 @@
* 仅支持手机端布局 * 仅支持手机端布局
*/ */
// 未保存标记
var isDirty = false;
// 初始化编辑器 // 初始化编辑器
function initEditor() { function initEditor() {
// 初始化 webbuilder // 初始化 webbuilder
@@ -13,6 +16,11 @@ function initEditor() {
// 绑定事件 // 绑定事件
bindEvents(); bindEvents();
// 注册变化回调
webbuilder.onChanged(function(action, data) {
isDirty = true;
});
} }
/** /**
@@ -195,10 +203,14 @@ 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();
}); });
@@ -211,6 +223,7 @@ function bindEvents() {
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);
} }
@@ -260,8 +273,12 @@ function bindEvents() {
// 清空画布 // 清空画布
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;
} }
}); });
@@ -283,6 +300,14 @@ function bindEvents() {
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

@@ -34,9 +34,15 @@ var webbuilder = {
draggingComponent: null, draggingComponent: null,
draggingType: null, draggingType: null,
// 拖拽时鼠标相对于组件的偏移
dragOffset: { x: 0, y: 0 },
// 唯一ID计数器 // 唯一ID计数器
idCounter: 0, idCounter: 0,
// 变化回调函数
onChangedCallback: null,
/** /**
* 计算单元格大小和行数 * 计算单元格大小和行数
*/ */
@@ -87,6 +93,9 @@ var webbuilder = {
// 初始化工具箱 // 初始化工具箱
this._initToolbox(); this._initToolbox();
// 初始化键盘事件
this._initKeyboardEvents();
return this; return this;
}, },
@@ -180,6 +189,41 @@ var webbuilder = {
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);
}
},
/** /**
* 计算网格位置 * 计算网格位置
*/ */
@@ -187,9 +231,9 @@ var webbuilder = {
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;
@@ -238,6 +282,9 @@ var webbuilder = {
// 选中新添加的组件 // 选中新添加的组件
this._selectComponent(instance.id); this._selectComponent(instance.id);
// 触发回调
this._notifyChanged('add', instance);
}, },
/** /**
@@ -285,6 +332,10 @@ 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';
@@ -397,6 +448,9 @@ var webbuilder = {
if (this.selectedComponent === componentId) { if (this.selectedComponent === componentId) {
this._showPropertyPanel(instance); this._showPropertyPanel(instance);
} }
// 触发回调
this._notifyChanged('move', instance);
}, },
/** /**
@@ -486,7 +540,7 @@ var webbuilder = {
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);
@@ -530,6 +584,9 @@ var webbuilder = {
} }
} }
} }
// 触发属性更新回调
self._notifyChanged('update', instance);
}); });
}); });
@@ -568,6 +625,9 @@ var webbuilder = {
// 取消选中 // 取消选中
this._deselectAll(); this._deselectAll();
// 触发回调
this._notifyChanged('delete', { id: componentId });
}, },
/** /**
@@ -712,11 +772,14 @@ var webbuilder = {
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 });
}, },
/** /**
@@ -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);
} }
}; };