diff --git a/README.md b/README.md index 75386dc..08025f5 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,11 @@ IoT 可视化编辑器库 - 基于网格布局的拖拽式页面构建器 - 📱 固定手机比例画布(375×812) - 📐 网格布局,单元格 1:1 正方形 - 🖱️ 拖拽添加、移动组件 +- ⌨️ Delete/Backspace键删除选中组件 - ⚙️ 属性面板,实时预览 - 💾 JSONB 格式保存/加载 - 🎨 自定义组件支持 +- 🔔 变化回调通知 ## 项目结构 @@ -21,10 +23,10 @@ IoT 可视化编辑器库 - 基于网格布局的拖拽式页面构建器 │ ├── webbuilder.js # 主文件 │ └── types/ # 内部模块 ├── editor.html # 示例编辑器 -├── js/editor/editor.js # 示例编辑器逻辑 -├── css/editor.css # 示例编辑器样式 -├── README.md # 中文文档 -└── README_EN.md # English +├── js/editor/editor.js # 示例编辑器逻辑 +├── css/editor.css # 示例编辑器样式 +├── README.md # 中文文档 +└── README_EN.md # English ``` > **注意**: `editor.html` 和 `js/editor/` 是示例代码,展示如何使用本库。 @@ -112,11 +114,29 @@ webbuilder.define(name, definition) defaultColumnSpan: 6, // 默认跨列数 defaultRowSpan: 4, // 默认跨行数 defaultProps: { ... }, // 默认属性 - traits: [ ... ], // 属性定义(用于属性面板) + traits: [ ... ], // 属性定义(用于属性面板) 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 ```javascript @@ -176,7 +196,7 @@ var components = webbuilder.renderToDiv(container); { element: HTMLElement, // 组件 DOM 元素 wrapper: HTMLElement, // 包装容器 - props: { ... }, // 组件属性(深拷贝) + props: { ... }, // 组件属性(深拷贝) id: 'gauge-1', type: 'gauge' } @@ -257,4 +277,4 @@ MIT --- -*参考项目:[webbuilder.js](https://github.com/qrailibs/webbuilder.js)* \ No newline at end of file +*参考项目:[webbuilder.js](https://github.com/qrailibs/webbuilder.js)* diff --git a/js/editor/editor.js b/js/editor/editor.js index 357e2b0..25db27a 100644 --- a/js/editor/editor.js +++ b/js/editor/editor.js @@ -3,16 +3,24 @@ * 仅支持手机端布局 */ +// 未保存标记 +var isDirty = false; + // 初始化编辑器 function initEditor() { // 初始化 webbuilder webbuilder.init('#editor'); - + // 注册示例组件 registerComponents(); - + // 绑定事件 bindEvents(); + + // 注册变化回调 + webbuilder.onChanged(function(action, data) { + isDirty = true; + }); } /** @@ -39,22 +47,22 @@ function registerComponents() { 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', @@ -73,21 +81,21 @@ function registerComponents() { 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', @@ -110,24 +118,24 @@ function registerComponents() { 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', @@ -148,7 +156,7 @@ function registerComponents() { return el; } }); - + // 数值显示组件 webbuilder.define('value-display', { type: 'value-display', @@ -169,18 +177,18 @@ function registerComponents() { 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; } }); @@ -195,41 +203,46 @@ function bindEvents() { var jsonb = webbuilder.toJSONB(); var jsonStr = JSON.stringify(jsonb, null, 2); downloadFile('canvas-config.json', jsonStr, 'application/json'); + isDirty = false; }); - + // 加载 JSONB document.getElementById('btn-load').addEventListener('click', function() { + if (isDirty && !confirm('当前有未保存的更改,确定要加载新文件吗?')) { + return; + } 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); + isDirty = false; } 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'; @@ -251,25 +264,29 @@ function bindEvents() { }), null, 2) + ';\n'; html += '\n'; html += '\n'; - + // 移除临时 div document.body.removeChild(tempDiv); - + showPreview(html); }); - + // 清空画布 document.getElementById('btn-clear').addEventListener('click', function() { + if (isDirty && !confirm('当前有未保存的更改,确定要清空画布吗?')) { + return; + } if (confirm('确定要清空画布吗?')) { webbuilder.clear(); + isDirty = true; } }); - + // 预览弹窗关闭 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; @@ -277,12 +294,20 @@ function bindEvents() { alert('已复制到剪贴板'); }); }); - + // 下载文件 document.getElementById('btn-download').addEventListener('click', function() { var code = document.getElementById('preview-code').textContent; downloadFile('page.html', code, 'text/html'); }); + + // 页面离开提示 + window.addEventListener('beforeunload', function(e) { + if (isDirty) { + e.preventDefault(); + e.returnValue = ''; + } + }); } /** diff --git a/js/lib/webbuilder/webbuilder.js b/js/lib/webbuilder/webbuilder.js index 962c7c5..74160f3 100644 --- a/js/lib/webbuilder/webbuilder.js +++ b/js/lib/webbuilder/webbuilder.js @@ -7,7 +7,7 @@ var webbuilder = { // 编辑器容器 container: null, - + // 手机画布配置 phoneConfig: { width: 375, @@ -16,37 +16,43 @@ var webbuilder = { gap: 2, background: '#f5f5f5' }, - + // 计算得出的单元格大小(1:1比例) cellSize: 0, rows: 0, - + // 已注册的组件类型 componentTypes: {}, - + // 画布上的组件实例 componentInstances: [], - + // 当前选中的组件 selectedComponent: null, - + // 拖拽状态 draggingComponent: null, draggingType: null, - + + // 拖拽时鼠标相对于组件的偏移 + dragOffset: { x: 0, y: 0 }, + // 唯一ID计数器 idCounter: 0, - + + // 变化回调函数 + onChangedCallback: null, + /** * 计算单元格大小和行数 */ _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 @@ -54,11 +60,11 @@ var webbuilder = { // 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 - 容器选择器或元素 @@ -70,55 +76,58 @@ var webbuilder = { } else { this.container = containerSelector; } - + if (!this.container) { throw new Error('Container element not found'); } - + // 计算网格 this._calculateGrid(); - + // 创建编辑器结构 this._createEditorStructure(); - + // 初始化画布 this._initCanvas(); - + // 初始化工具箱 this._initToolbox(); - + + // 初始化键盘事件 + this._initKeyboardEvents(); + 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'; @@ -133,7 +142,7 @@ var webbuilder = { 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) { @@ -141,21 +150,21 @@ var webbuilder = { 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); @@ -169,7 +178,7 @@ var webbuilder = { } }); }, - + /** * 初始化工具箱 */ @@ -179,29 +188,64 @@ var webbuilder = { this.toolboxListEl.className = 'webbuilder-toolbox-list'; 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) { 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 x = e.clientX - rect.left - config.gap - this.dragOffset.x; + var y = e.clientY - rect.top - config.gap - this.dragOffset.y; + // 计算列和行 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 }; }, - + /** * 添加组件到画布 */ @@ -211,13 +255,13 @@ var webbuilder = { 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, @@ -229,33 +273,36 @@ var webbuilder = { }, props: JSON.parse(JSON.stringify(typeDef.defaultProps || {})) }; - + // 渲染组件 this._renderComponent(instance); - + // 添加到实例列表 this.componentInstances.push(instance); - + // 选中新添加的组件 this._selectComponent(instance.id); + + // 触发回调 + this._notifyChanged('add', instance); }, - + /** * 渲染组件到画布(编辑器内部使用) */ _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'; @@ -265,7 +312,7 @@ var webbuilder = { 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); @@ -277,7 +324,7 @@ var webbuilder = { el.appendChild(content); } } - + // 使组件可拖拽 var self = this; el.setAttribute('draggable', true); @@ -285,25 +332,29 @@ var webbuilder = { e.stopPropagation(); self.draggingComponent = instance.id; 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.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 - 目标容器 @@ -312,10 +363,10 @@ var webbuilder = { renderToDiv: function(container) { var self = this; var result = []; - + // 清空容器 container.innerHTML = ''; - + // 创建画布样式 container.style.width = this.phoneConfig.width + 'px'; container.style.height = this.phoneConfig.height + 'px'; @@ -326,29 +377,29 @@ var webbuilder = { 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; @@ -356,10 +407,10 @@ var webbuilder = { } else if (componentElement instanceof HTMLElement) { wrapper.appendChild(componentElement); } - + // 添加到容器 container.appendChild(wrapper); - + // 添加到结果列表 result.push({ element: componentElement, // 控件本身的 HTML 元素对象 @@ -369,53 +420,56 @@ var webbuilder = { 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); } + + // 触发回调 + this._notifyChanged('move', 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); }, - + /** * 取消所有选中 */ @@ -427,7 +481,7 @@ var webbuilder = { }); this.propertyPanelEl.innerHTML = ''; }, - + /** * 显示属性面板 */ @@ -435,13 +489,13 @@ var webbuilder = { 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 = '