1266 lines
46 KiB
JavaScript
1266 lines
46 KiB
JavaScript
/**
|
||
* WebBuilder - IoT 可视化编辑器
|
||
* 支持手机端和电脑端布局
|
||
* 单元格比例 1:1(正方形)
|
||
*/
|
||
|
||
var webbuilder = {
|
||
container: null,
|
||
phoneConfig: {
|
||
width: 375,
|
||
height: 812,
|
||
columns: 24,
|
||
gap: 2,
|
||
background: '#f5f5f5'
|
||
},
|
||
cellSize: 0,
|
||
rows: 0,
|
||
componentTypes: {},
|
||
componentInstances: [],
|
||
selectedComponent: null,
|
||
draggingComponent: null,
|
||
draggingType: null,
|
||
dragOffset: { x: 0, y: 0 },
|
||
idCounter: 0,
|
||
onChangedCallback: null,
|
||
resizeHandles: null,
|
||
isResizing: false,
|
||
resizeDirection: null,
|
||
resizeStartPos: { x: 0, y: 0 },
|
||
resizeStartSize: { colSpan: 0, rowSpan: 0 },
|
||
isMobile: false,
|
||
mobilePanelMode: 'toolbox',
|
||
touchDragType: null,
|
||
touchDragComponent: null,
|
||
touchDragOffset: { x: 0, y: 0 },
|
||
mobileStyleEl: null,
|
||
|
||
_applyHandleBaseStyle: function(handle) {
|
||
handle.style.position = 'absolute';
|
||
handle.style.width = '8px';
|
||
handle.style.height = '8px';
|
||
handle.style.background = '#1890ff';
|
||
handle.style.border = '1px solid #fff';
|
||
handle.style.borderRadius = '2px';
|
||
handle.style.pointerEvents = 'auto';
|
||
handle.style.zIndex = '11';
|
||
handle.style.boxShadow = '0 0 2px rgba(0, 0, 0, 0.3)';
|
||
},
|
||
|
||
_calculateGrid: function() {
|
||
var config = this.phoneConfig;
|
||
this.cellSize = (config.width - (config.columns + 1) * config.gap) / config.columns;
|
||
this.rows = Math.floor((config.height - config.gap) / (this.cellSize + config.gap));
|
||
this.phoneConfig.height = this.rows * this.cellSize + (this.rows + 1) * config.gap;
|
||
},
|
||
|
||
_detectMobile: function() {
|
||
return window.innerWidth <= 768 ||
|
||
'ontouchstart' in window ||
|
||
navigator.maxTouchPoints > 0;
|
||
},
|
||
|
||
_injectMobileStyles: function() {
|
||
if (this.mobileStyleEl) return;
|
||
var style = document.createElement('style');
|
||
style.textContent = [
|
||
'.wb-mobile {',
|
||
' display: flex !important;',
|
||
' flex-direction: column !important;',
|
||
' height: 100vh !important;',
|
||
'}',
|
||
'.wb-mobile .webbuilder-canvas-wrapper {',
|
||
' flex: 1 !important;',
|
||
' overflow: auto !important;',
|
||
' min-height: 0 !important;',
|
||
'}',
|
||
'.wb-mobile .webbuilder-toolbox {',
|
||
' width: 100% !important;',
|
||
' height: 80px !important;',
|
||
' flex-shrink: 0 !important;',
|
||
' border-right: none !important;',
|
||
' border-top: 1px solid #e8e8e8 !important;',
|
||
'}',
|
||
'.wb-mobile .webbuilder-toolbox-title {',
|
||
' display: none !important;',
|
||
'}',
|
||
'.wb-mobile .webbuilder-toolbox-list {',
|
||
' display: flex !important;',
|
||
' flex-direction: row !important;',
|
||
' overflow-x: auto !important;',
|
||
' overflow-y: hidden !important;',
|
||
' padding: 8px !important;',
|
||
' gap: 8px !important;',
|
||
' height: 100% !important;',
|
||
' -webkit-overflow-scrolling: touch !important;',
|
||
'}',
|
||
'.wb-mobile .webbuilder-toolbox-item {',
|
||
' flex-direction: column !important;',
|
||
' min-width: 64px !important;',
|
||
' padding: 8px !important;',
|
||
' margin-bottom: 0 !important;',
|
||
' justify-content: center !important;',
|
||
' align-items: center !important;',
|
||
'}',
|
||
'.wb-mobile .webbuilder-toolbox-item .icon {',
|
||
' font-size: 24px !important;',
|
||
'}',
|
||
'.wb-mobile .webbuilder-toolbox-item .label {',
|
||
' font-size: 11px !important;',
|
||
' text-align: center !important;',
|
||
'}',
|
||
'.wb-mobile .webbuilder-property-panel {',
|
||
' width: 100% !important;',
|
||
' height: 200px !important;',
|
||
' flex-shrink: 0 !important;',
|
||
' border-left: none !important;',
|
||
' border-top: 1px solid #e8e8e8 !important;',
|
||
' overflow-y: auto !important;',
|
||
' background: #fff !important;',
|
||
'}',
|
||
'.wb-mobile-hidden {',
|
||
' display: none !important;',
|
||
'}',
|
||
'.wb-mobile-close-panel {',
|
||
' width: 100% !important;',
|
||
' padding: 10px !important;',
|
||
' margin: 8px 0 !important;',
|
||
' background: #1890ff !important;',
|
||
' border: none !important;',
|
||
' border-radius: 4px !important;',
|
||
' color: #fff !important;',
|
||
' font-size: 14px !important;',
|
||
' cursor: pointer !important;',
|
||
'}'
|
||
].join('\n');
|
||
document.head.appendChild(style);
|
||
this.mobileStyleEl = style;
|
||
},
|
||
|
||
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.isMobile = this._detectMobile();
|
||
|
||
if (this.isMobile) {
|
||
this._injectMobileStyles();
|
||
}
|
||
|
||
this._calculateGrid();
|
||
this._createEditorStructure();
|
||
this._initCanvas();
|
||
this._initToolbox();
|
||
this._initKeyboardEvents();
|
||
this._initResizeEvents();
|
||
this._initTouchEvents();
|
||
|
||
if (this.isMobile) {
|
||
this._updateMobilePanelVisibility();
|
||
}
|
||
|
||
return this;
|
||
},
|
||
|
||
_createEditorStructure: function() {
|
||
this.container.innerHTML = '';
|
||
this.container.classList.add('webbuilder-editor');
|
||
if (this.isMobile) {
|
||
this.container.classList.add('wb-mobile');
|
||
}
|
||
|
||
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);
|
||
|
||
this.toolboxEl = document.createElement('div');
|
||
this.toolboxEl.className = 'webbuilder-toolbox';
|
||
this.container.appendChild(this.toolboxEl);
|
||
},
|
||
|
||
_initCanvas: function() {
|
||
var config = this.phoneConfig;
|
||
var self = this;
|
||
|
||
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);
|
||
|
||
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() {
|
||
self.canvasEl.classList.remove('dropping');
|
||
});
|
||
|
||
this.canvasEl.addEventListener('drop', function(e) {
|
||
var gridPos;
|
||
var gridPos2;
|
||
e.preventDefault();
|
||
self.canvasEl.classList.remove('dropping');
|
||
|
||
if (self.draggingType) {
|
||
gridPos = self._calculateGridPosition(e);
|
||
self._addComponentToCanvas(self.draggingType, gridPos);
|
||
self.draggingType = null;
|
||
} else if (self.draggingComponent) {
|
||
gridPos2 = self._calculateGridPosition(e);
|
||
self._moveComponent(self.draggingComponent, gridPos2);
|
||
self.draggingComponent = null;
|
||
}
|
||
});
|
||
},
|
||
|
||
_initToolbox: function() {
|
||
if (this.isMobile) {
|
||
this._initMobileToolbox();
|
||
} else {
|
||
this._initDesktopToolbox();
|
||
}
|
||
},
|
||
|
||
_initDesktopToolbox: function() {
|
||
this.toolboxEl.innerHTML = '<div class="webbuilder-toolbox-title">组件库</div>';
|
||
this.toolboxListEl = document.createElement('div');
|
||
this.toolboxListEl.className = 'webbuilder-toolbox-list';
|
||
this.toolboxEl.appendChild(this.toolboxListEl);
|
||
},
|
||
|
||
_initMobileToolbox: function() {
|
||
this.toolboxListEl = document.createElement('div');
|
||
this.toolboxListEl.className = 'webbuilder-toolbox-list wb-mobile-toolbox-list';
|
||
this.toolboxEl.appendChild(this.toolboxListEl);
|
||
},
|
||
|
||
_initKeyboardEvents: function() {
|
||
var self = this;
|
||
document.addEventListener('keydown', function(e) {
|
||
var target = e.target;
|
||
var isInputElement = target.tagName === 'INPUT' ||
|
||
target.tagName === 'TEXTAREA' ||
|
||
target.tagName === 'SELECT' ||
|
||
target.isContentEditable;
|
||
|
||
if (isInputElement) {
|
||
return;
|
||
}
|
||
|
||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||
if (self.selectedComponent) {
|
||
e.preventDefault();
|
||
self._deleteComponent(self.selectedComponent);
|
||
}
|
||
}
|
||
});
|
||
},
|
||
|
||
_initResizeEvents: function() {
|
||
var self = this;
|
||
|
||
document.addEventListener('mousemove', function(e) {
|
||
if (self.isResizing && self.selectedComponent) {
|
||
e.preventDefault();
|
||
self._handleResizeMove(e);
|
||
}
|
||
});
|
||
|
||
document.addEventListener('mouseup', function(e) {
|
||
if (self.isResizing) {
|
||
e.preventDefault();
|
||
self._handleResizeEnd(e);
|
||
}
|
||
});
|
||
},
|
||
|
||
_initTouchEvents: function() {
|
||
var self = this;
|
||
if (!this.isMobile) return;
|
||
|
||
this.toolboxListEl.addEventListener('touchstart', function(e) {
|
||
var item = e.target.closest('.webbuilder-toolbox-item');
|
||
if (!item) return;
|
||
self.touchDragType = item.getAttribute('data-type');
|
||
}, { passive: true });
|
||
|
||
this.canvasEl.addEventListener('touchmove', function(e) {
|
||
if (self.touchDragType || self.touchDragComponent) {
|
||
e.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
|
||
this.canvasEl.addEventListener('touchend', function(e) {
|
||
var touch;
|
||
var gridPos;
|
||
var touch2;
|
||
var gridPos2;
|
||
if (self.touchDragType) {
|
||
touch = e.changedTouches[0];
|
||
gridPos = self._calculateGridPositionFromTouch(touch);
|
||
if (gridPos) {
|
||
self._addComponentToCanvas(self.touchDragType, gridPos);
|
||
}
|
||
self.touchDragType = null;
|
||
} else if (self.touchDragComponent) {
|
||
touch2 = e.changedTouches[0];
|
||
gridPos2 = self._calculateGridPositionFromTouch(touch2);
|
||
if (gridPos2) {
|
||
self._moveComponent(self.touchDragComponent, gridPos2);
|
||
}
|
||
self.touchDragComponent = null;
|
||
}
|
||
});
|
||
},
|
||
|
||
_calculateGridPositionFromTouch: function(touch) {
|
||
var rect = this.canvasEl.getBoundingClientRect();
|
||
var config = this.phoneConfig;
|
||
var x = touch.clientX - rect.left - config.gap;
|
||
var y = touch.clientY - rect.top - config.gap;
|
||
var col;
|
||
var row;
|
||
|
||
if (x < 0 || y < 0 || x > config.width || y > config.height) {
|
||
return null;
|
||
}
|
||
|
||
col = Math.floor(x / (this.cellSize + config.gap)) + 1;
|
||
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 };
|
||
},
|
||
|
||
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 - 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 };
|
||
},
|
||
|
||
_addComponentToCanvas: function(componentType, gridPos) {
|
||
var typeDef = this.componentTypes[componentType];
|
||
var id;
|
||
var startCell;
|
||
var instance;
|
||
|
||
if (!typeDef) {
|
||
console.error('Component type not found:', componentType);
|
||
return;
|
||
}
|
||
|
||
id = componentType + '-' + (++this.idCounter);
|
||
startCell = (gridPos.row - 1) * this.phoneConfig.columns + (gridPos.column - 1);
|
||
|
||
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);
|
||
this._notifyChanged('add', instance);
|
||
},
|
||
|
||
_renderComponent: function(instance) {
|
||
var typeDef = this.componentTypes[instance.type];
|
||
var columns;
|
||
var startCol;
|
||
var startRow;
|
||
var colSpan;
|
||
var rowSpan;
|
||
var el;
|
||
var content;
|
||
var handlesContainer;
|
||
var directions;
|
||
var self = this;
|
||
|
||
if (!typeDef) return;
|
||
|
||
columns = this.phoneConfig.columns;
|
||
startCol = instance.grid.startCell % columns;
|
||
startRow = Math.floor(instance.grid.startCell / columns);
|
||
colSpan = Math.min(instance.grid.colSpan, columns - startCol);
|
||
rowSpan = instance.grid.rowSpan;
|
||
|
||
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 = 'visible';
|
||
|
||
if (typeDef.render) {
|
||
content = typeDef.render(instance.props);
|
||
if (typeof content === 'string') {
|
||
el.innerHTML = content;
|
||
} else if (content instanceof HTMLElement) {
|
||
el.appendChild(content);
|
||
}
|
||
}
|
||
|
||
handlesContainer = document.createElement('div');
|
||
handlesContainer.className = 'resize-handles-container';
|
||
handlesContainer.style.display = 'none';
|
||
handlesContainer.style.position = 'absolute';
|
||
handlesContainer.style.top = '0';
|
||
handlesContainer.style.left = '0';
|
||
handlesContainer.style.width = '100%';
|
||
handlesContainer.style.height = '100%';
|
||
handlesContainer.style.pointerEvents = 'none';
|
||
handlesContainer.style.zIndex = '10';
|
||
el.appendChild(handlesContainer);
|
||
|
||
directions = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'];
|
||
|
||
directions.forEach(function(direction) {
|
||
var handle = document.createElement('div');
|
||
handle.className = 'resize-handle resize-handle-' + direction;
|
||
handle.setAttribute('data-direction', direction);
|
||
self._applyHandleBaseStyle(handle);
|
||
|
||
switch(direction) {
|
||
case 'nw':
|
||
handle.style.top = '-4px';
|
||
handle.style.left = '-4px';
|
||
handle.style.cursor = 'nwse-resize';
|
||
break;
|
||
case 'n':
|
||
handle.style.top = '-4px';
|
||
handle.style.left = '50%';
|
||
handle.style.transform = 'translateX(-50%)';
|
||
handle.style.cursor = 'ns-resize';
|
||
break;
|
||
case 'ne':
|
||
handle.style.top = '-4px';
|
||
handle.style.right = '-4px';
|
||
handle.style.cursor = 'nesw-resize';
|
||
break;
|
||
case 'w':
|
||
handle.style.top = '50%';
|
||
handle.style.left = '-4px';
|
||
handle.style.transform = 'translateY(-50%)';
|
||
handle.style.cursor = 'ew-resize';
|
||
break;
|
||
case 'e':
|
||
handle.style.top = '50%';
|
||
handle.style.right = '-4px';
|
||
handle.style.transform = 'translateY(-50%)';
|
||
handle.style.cursor = 'ew-resize';
|
||
break;
|
||
case 'sw':
|
||
handle.style.bottom = '-4px';
|
||
handle.style.left = '-4px';
|
||
handle.style.cursor = 'nesw-resize';
|
||
break;
|
||
case 's':
|
||
handle.style.bottom = '-4px';
|
||
handle.style.left = '50%';
|
||
handle.style.transform = 'translateX(-50%)';
|
||
handle.style.cursor = 'ns-resize';
|
||
break;
|
||
case 'se':
|
||
handle.style.bottom = '-4px';
|
||
handle.style.right = '-4px';
|
||
handle.style.cursor = 'nwse-resize';
|
||
break;
|
||
}
|
||
|
||
handle.addEventListener('mousedown', function(e) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
self._handleResizeStart(e, instance.id, direction);
|
||
});
|
||
|
||
handlesContainer.appendChild(handle);
|
||
});
|
||
|
||
el.setAttribute('draggable', true);
|
||
el.addEventListener('dragstart', function(e) {
|
||
var rect;
|
||
if (self.isResizing) {
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
e.stopPropagation();
|
||
self.draggingComponent = instance.id;
|
||
el.style.opacity = '0.5';
|
||
rect = el.getBoundingClientRect();
|
||
self.dragOffset.x = e.clientX - rect.left;
|
||
self.dragOffset.y = e.clientY - rect.top;
|
||
});
|
||
el.addEventListener('dragend', function() {
|
||
el.style.opacity = '1';
|
||
self.draggingComponent = null;
|
||
});
|
||
|
||
el.addEventListener('click', function(e) {
|
||
e.stopPropagation();
|
||
self._selectComponent(instance.id);
|
||
});
|
||
|
||
if (this.isMobile) {
|
||
el.addEventListener('touchstart', function(e) {
|
||
var touch;
|
||
var rect;
|
||
e.stopPropagation();
|
||
self.touchDragComponent = instance.id;
|
||
touch = e.touches[0];
|
||
rect = el.getBoundingClientRect();
|
||
self.touchDragOffset.x = touch.clientX - rect.left;
|
||
self.touchDragOffset.y = touch.clientY - rect.top;
|
||
}, { passive: true });
|
||
}
|
||
|
||
instance.element = el;
|
||
instance.handlesContainer = handlesContainer;
|
||
this.canvasEl.appendChild(el);
|
||
},
|
||
|
||
_handleResizeStart: function(e, componentId, direction) {
|
||
var instance = this._findInstance(componentId);
|
||
if (!instance) return;
|
||
|
||
this.isResizing = true;
|
||
this.resizeDirection = direction;
|
||
this.resizeStartPos = { x: e.clientX, y: e.clientY };
|
||
this.resizeStartSize = {
|
||
colSpan: instance.grid.colSpan,
|
||
rowSpan: instance.grid.rowSpan,
|
||
startCell: instance.grid.startCell
|
||
};
|
||
this.canvasEl.style.pointerEvents = 'none';
|
||
},
|
||
|
||
_handleResizeMove: function(e) {
|
||
var instance = this._findInstance(this.selectedComponent);
|
||
var config;
|
||
var cellSize;
|
||
var gap;
|
||
var deltaX;
|
||
var deltaY;
|
||
var colDelta;
|
||
var rowDelta;
|
||
var startCol;
|
||
var startRow;
|
||
var origColSpan;
|
||
var origRowSpan;
|
||
var newColSpan;
|
||
var newRowSpan;
|
||
var newStartCol;
|
||
var newStartRow;
|
||
|
||
if (!instance) return;
|
||
|
||
config = this.phoneConfig;
|
||
cellSize = this.cellSize;
|
||
gap = config.gap;
|
||
deltaX = e.clientX - this.resizeStartPos.x;
|
||
deltaY = e.clientY - this.resizeStartPos.y;
|
||
colDelta = Math.round(deltaX / (cellSize + gap));
|
||
rowDelta = Math.round(deltaY / (cellSize + gap));
|
||
startCol = this.resizeStartSize.startCell % config.columns;
|
||
startRow = Math.floor(this.resizeStartSize.startCell / config.columns);
|
||
origColSpan = this.resizeStartSize.colSpan;
|
||
origRowSpan = this.resizeStartSize.rowSpan;
|
||
newColSpan = origColSpan;
|
||
newRowSpan = origRowSpan;
|
||
newStartCol = startCol;
|
||
newStartRow = startRow;
|
||
|
||
switch(this.resizeDirection) {
|
||
case 'se':
|
||
newColSpan = Math.max(1, origColSpan + colDelta);
|
||
newRowSpan = Math.max(1, origRowSpan + rowDelta);
|
||
break;
|
||
case 'e':
|
||
newColSpan = Math.max(1, origColSpan + colDelta);
|
||
break;
|
||
case 's':
|
||
newRowSpan = Math.max(1, origRowSpan + rowDelta);
|
||
break;
|
||
case 'w':
|
||
newColSpan = Math.max(1, origColSpan - colDelta);
|
||
newStartCol = startCol + (origColSpan - newColSpan);
|
||
if (newStartCol < 0) {
|
||
newStartCol = 0;
|
||
newColSpan = origColSpan + startCol;
|
||
}
|
||
break;
|
||
case 'sw':
|
||
newColSpan = Math.max(1, origColSpan - colDelta);
|
||
newStartCol = startCol + (origColSpan - newColSpan);
|
||
newRowSpan = Math.max(1, origRowSpan + rowDelta);
|
||
if (newStartCol < 0) {
|
||
newStartCol = 0;
|
||
newColSpan = origColSpan + startCol;
|
||
}
|
||
break;
|
||
case 'n':
|
||
newRowSpan = Math.max(1, origRowSpan - rowDelta);
|
||
newStartRow = startRow + (origRowSpan - newRowSpan);
|
||
if (newStartRow < 0) {
|
||
newStartRow = 0;
|
||
newRowSpan = origRowSpan + startRow;
|
||
}
|
||
break;
|
||
case 'ne':
|
||
newColSpan = Math.max(1, origColSpan + colDelta);
|
||
newRowSpan = Math.max(1, origRowSpan - rowDelta);
|
||
newStartRow = startRow + (origRowSpan - newRowSpan);
|
||
if (newStartRow < 0) {
|
||
newStartRow = 0;
|
||
newRowSpan = origRowSpan + startRow;
|
||
}
|
||
break;
|
||
case 'nw':
|
||
newColSpan = Math.max(1, origColSpan - colDelta);
|
||
newStartCol = startCol + (origColSpan - newColSpan);
|
||
newRowSpan = Math.max(1, origRowSpan - rowDelta);
|
||
newStartRow = startRow + (origRowSpan - newRowSpan);
|
||
if (newStartCol < 0) {
|
||
newStartCol = 0;
|
||
newColSpan = origColSpan + startCol;
|
||
}
|
||
if (newStartRow < 0) {
|
||
newStartRow = 0;
|
||
newRowSpan = origRowSpan + startRow;
|
||
}
|
||
break;
|
||
}
|
||
|
||
newColSpan = Math.min(newColSpan, config.columns - newStartCol);
|
||
newRowSpan = Math.min(newRowSpan, this.rows - newStartRow);
|
||
newColSpan = Math.max(1, newColSpan);
|
||
newRowSpan = Math.max(1, newRowSpan);
|
||
|
||
instance.grid.startCell = newStartRow * config.columns + newStartCol;
|
||
instance.grid.colSpan = newColSpan;
|
||
instance.grid.rowSpan = newRowSpan;
|
||
instance.element.style.gridColumn = (newStartCol + 1) + ' / span ' + newColSpan;
|
||
instance.element.style.gridRow = (newStartRow + 1) + ' / span ' + newRowSpan;
|
||
|
||
if (this.selectedComponent === instance.id) {
|
||
this._showPropertyPanel(instance);
|
||
}
|
||
},
|
||
|
||
_handleResizeEnd: function() {
|
||
var instance = this._findInstance(this.selectedComponent);
|
||
this.isResizing = false;
|
||
this.resizeDirection = null;
|
||
this.canvasEl.style.pointerEvents = 'auto';
|
||
|
||
if (instance) {
|
||
this._notifyChanged('update', instance);
|
||
}
|
||
},
|
||
|
||
_showResizeHandles: function(instance) {
|
||
if (!instance || !instance.handlesContainer) return;
|
||
instance.handlesContainer.style.display = 'block';
|
||
},
|
||
|
||
_hideResizeHandles: function(instance) {
|
||
if (!instance || !instance.handlesContainer) return;
|
||
instance.handlesContainer.style.display = 'none';
|
||
},
|
||
|
||
renderToDiv: function(container) {
|
||
var self = this;
|
||
var result = [];
|
||
var columns;
|
||
|
||
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';
|
||
|
||
columns = this.phoneConfig.columns;
|
||
|
||
this.componentInstances.forEach(function(instance) {
|
||
var typeDef = self.componentTypes[instance.type];
|
||
var startCol;
|
||
var startRow;
|
||
var colSpan;
|
||
var rowSpan;
|
||
var wrapper;
|
||
var componentElement;
|
||
|
||
if (!typeDef || !typeDef.render) return;
|
||
|
||
startCol = instance.grid.startCell % columns;
|
||
startRow = Math.floor(instance.grid.startCell / columns);
|
||
colSpan = Math.min(instance.grid.colSpan, columns - startCol);
|
||
rowSpan = instance.grid.rowSpan;
|
||
|
||
wrapper = document.createElement('div');
|
||
wrapper.style.gridColumn = (startCol + 1) + ' / span ' + colSpan;
|
||
wrapper.style.gridRow = (startRow + 1) + ' / span ' + rowSpan;
|
||
wrapper.style.overflow = 'hidden';
|
||
|
||
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,
|
||
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);
|
||
var columns;
|
||
var startCol;
|
||
var startRow;
|
||
var colSpan;
|
||
|
||
if (!instance) return;
|
||
|
||
instance.grid.startCell = (gridPos.row - 1) * this.phoneConfig.columns + (gridPos.column - 1);
|
||
|
||
columns = this.phoneConfig.columns;
|
||
startCol = instance.grid.startCell % columns;
|
||
startRow = Math.floor(instance.grid.startCell / columns);
|
||
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) {
|
||
var instance;
|
||
this._deselectAll();
|
||
|
||
instance = this._findInstance(componentId);
|
||
if (!instance) return;
|
||
|
||
this.selectedComponent = componentId;
|
||
instance.element.classList.add('selected');
|
||
this._showResizeHandles(instance);
|
||
this._showPropertyPanel(instance);
|
||
|
||
if (this.isMobile) {
|
||
this.mobilePanelMode = 'property';
|
||
this._updateMobilePanelVisibility();
|
||
}
|
||
},
|
||
|
||
_deselectAll: function() {
|
||
var selected = this.canvasEl.querySelectorAll('.selected');
|
||
var self = this;
|
||
|
||
this.selectedComponent = null;
|
||
selected.forEach(function(el) {
|
||
el.classList.remove('selected');
|
||
});
|
||
|
||
this.componentInstances.forEach(function(instance) {
|
||
if (instance.handlesContainer) {
|
||
instance.handlesContainer.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
this.propertyPanelEl.innerHTML = '';
|
||
|
||
if (this.isMobile) {
|
||
this.mobilePanelMode = 'toolbox';
|
||
this._updateMobilePanelVisibility();
|
||
}
|
||
},
|
||
|
||
_updateMobilePanelVisibility: function() {
|
||
if (!this.isMobile) return;
|
||
if (this.mobilePanelMode === 'property') {
|
||
this.toolboxEl.classList.add('wb-mobile-hidden');
|
||
this.propertyPanelEl.classList.remove('wb-mobile-hidden');
|
||
} else {
|
||
this.toolboxEl.classList.remove('wb-mobile-hidden');
|
||
this.propertyPanelEl.classList.add('wb-mobile-hidden');
|
||
}
|
||
},
|
||
|
||
_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;
|
||
var deleteBtn;
|
||
var closeBtn;
|
||
|
||
html = '<div class="property-title">' + (typeDef.label || instance.type) + '</div>';
|
||
|
||
html += '<div class="property-section">';
|
||
html += '<div class="property-section-title">位置大小</div>';
|
||
html += '<div class="property-row"><label>起始列:</label><input type="number" data-prop="startCol" value="' + (startCol + 1) + '" min="1" max="' + columns + '"></div>';
|
||
html += '<div class="property-row"><label>起始行:</label><input type="number" data-prop="startRow" value="' + (startRow + 1) + '" min="1"></div>';
|
||
html += '<div class="property-row"><label>跨列数:</label><input type="number" data-prop="colSpan" value="' + instance.grid.colSpan + '" min="1" max="' + columns + '"></div>';
|
||
html += '<div class="property-row"><label>跨行数:</label><input type="number" data-prop="rowSpan" value="' + instance.grid.rowSpan + '" min="1"></div>';
|
||
html += '</div>';
|
||
|
||
if (typeDef.traits && typeDef.traits.length > 0) {
|
||
html += '<div class="property-section">';
|
||
html += '<div class="property-section-title">组件属性</div>';
|
||
typeDef.traits.forEach(function(trait) {
|
||
var value = instance.props[trait.name] !== undefined ? instance.props[trait.name] : (trait.default || '');
|
||
html += '<div class="property-row">';
|
||
html += '<label>' + trait.label + ':</label>';
|
||
if (trait.type === 'number') {
|
||
html += '<input type="number" data-trait="' + trait.name + '" value="' + value + '">';
|
||
} else if (trait.type === 'checkbox') {
|
||
html += '<input type="checkbox" data-trait="' + trait.name + '"' + (value ? ' checked' : '') + '>';
|
||
} else {
|
||
html += '<input type="text" data-trait="' + trait.name + '" value="' + value + '">';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '<div class="property-section">';
|
||
html += '<button class="btn-delete" data-action="delete">删除组件</button>';
|
||
html += '</div>';
|
||
|
||
this.propertyPanelEl.innerHTML = html;
|
||
|
||
this.propertyPanelEl.querySelectorAll('input').forEach(function(input) {
|
||
input.addEventListener('change', function() {
|
||
var prop = input.getAttribute('data-prop');
|
||
var trait = input.getAttribute('data-trait');
|
||
var value;
|
||
var currentRow;
|
||
var currentCol;
|
||
var typeDef2;
|
||
var traitDef;
|
||
var content;
|
||
var handlesContainer;
|
||
var sCol;
|
||
var sRow;
|
||
var cSpan;
|
||
|
||
if (prop) {
|
||
value = parseInt(input.value, 10) || 1;
|
||
if (prop === 'startCol') {
|
||
currentRow = Math.floor(instance.grid.startCell / columns);
|
||
instance.grid.startCell = currentRow * columns + (value - 1);
|
||
} else if (prop === 'startRow') {
|
||
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;
|
||
}
|
||
|
||
sCol = instance.grid.startCell % columns;
|
||
sRow = Math.floor(instance.grid.startCell / columns);
|
||
cSpan = Math.min(instance.grid.colSpan, columns - sCol);
|
||
|
||
instance.element.style.gridColumn = (sCol + 1) + ' / span ' + cSpan;
|
||
instance.element.style.gridRow = (sRow + 1) + ' / span ' + instance.grid.rowSpan;
|
||
} else if (trait) {
|
||
typeDef2 = self.componentTypes[instance.type];
|
||
traitDef = typeDef2.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 (typeDef2.render) {
|
||
content = typeDef2.render(instance.props);
|
||
instance.element.innerHTML = '';
|
||
if (typeof content === 'string') {
|
||
instance.element.innerHTML = content;
|
||
} else if (content instanceof HTMLElement) {
|
||
instance.element.appendChild(content);
|
||
}
|
||
|
||
handlesContainer = document.createElement('div');
|
||
handlesContainer.className = 'resize-handles-container';
|
||
handlesContainer.style.display = 'block';
|
||
handlesContainer.style.position = 'absolute';
|
||
handlesContainer.style.top = '0';
|
||
handlesContainer.style.left = '0';
|
||
handlesContainer.style.width = '100%';
|
||
handlesContainer.style.height = '100%';
|
||
handlesContainer.style.pointerEvents = 'none';
|
||
handlesContainer.style.zIndex = '10';
|
||
instance.element.appendChild(handlesContainer);
|
||
instance.handlesContainer = handlesContainer;
|
||
self._recreateResizeHandles(instance);
|
||
}
|
||
}
|
||
|
||
self._notifyChanged('update', instance);
|
||
});
|
||
});
|
||
|
||
deleteBtn = this.propertyPanelEl.querySelector('[data-action="delete"]');
|
||
if (deleteBtn) {
|
||
deleteBtn.addEventListener('click', function() {
|
||
self._deleteComponent(instance.id);
|
||
});
|
||
}
|
||
|
||
if (this.isMobile) {
|
||
closeBtn = document.createElement('button');
|
||
closeBtn.className = 'btn wb-mobile-close-panel';
|
||
closeBtn.textContent = '返回组件库';
|
||
closeBtn.addEventListener('click', function() {
|
||
self._deselectAll();
|
||
});
|
||
this.propertyPanelEl.appendChild(closeBtn);
|
||
}
|
||
},
|
||
|
||
_recreateResizeHandles: function(instance) {
|
||
var self = this;
|
||
var directions;
|
||
|
||
if (!instance || !instance.handlesContainer) return;
|
||
|
||
directions = ['nw', 'n', 'ne', 'w', 'e', 'sw', 's', 'se'];
|
||
instance.handlesContainer.innerHTML = '';
|
||
|
||
directions.forEach(function(direction) {
|
||
var handle = document.createElement('div');
|
||
handle.className = 'resize-handle resize-handle-' + direction;
|
||
handle.setAttribute('data-direction', direction);
|
||
self._applyHandleBaseStyle(handle);
|
||
|
||
switch(direction) {
|
||
case 'nw':
|
||
handle.style.top = '-4px';
|
||
handle.style.left = '-4px';
|
||
handle.style.cursor = 'nwse-resize';
|
||
break;
|
||
case 'n':
|
||
handle.style.top = '-4px';
|
||
handle.style.left = '50%';
|
||
handle.style.transform = 'translateX(-50%)';
|
||
handle.style.cursor = 'ns-resize';
|
||
break;
|
||
case 'ne':
|
||
handle.style.top = '-4px';
|
||
handle.style.right = '-4px';
|
||
handle.style.cursor = 'nesw-resize';
|
||
break;
|
||
case 'w':
|
||
handle.style.top = '50%';
|
||
handle.style.left = '-4px';
|
||
handle.style.transform = 'translateY(-50%)';
|
||
handle.style.cursor = 'ew-resize';
|
||
break;
|
||
case 'e':
|
||
handle.style.top = '50%';
|
||
handle.style.right = '-4px';
|
||
handle.style.transform = 'translateY(-50%)';
|
||
handle.style.cursor = 'ew-resize';
|
||
break;
|
||
case 'sw':
|
||
handle.style.bottom = '-4px';
|
||
handle.style.left = '-4px';
|
||
handle.style.cursor = 'nesw-resize';
|
||
break;
|
||
case 's':
|
||
handle.style.bottom = '-4px';
|
||
handle.style.left = '50%';
|
||
handle.style.transform = 'translateX(-50%)';
|
||
handle.style.cursor = 'ns-resize';
|
||
break;
|
||
case 'se':
|
||
handle.style.bottom = '-4px';
|
||
handle.style.right = '-4px';
|
||
handle.style.cursor = 'nwse-resize';
|
||
break;
|
||
}
|
||
|
||
handle.addEventListener('mousedown', function(e) {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
self._handleResizeStart(e, instance.id, direction);
|
||
});
|
||
|
||
instance.handlesContainer.appendChild(handle);
|
||
});
|
||
},
|
||
|
||
_deleteComponent: function(componentId) {
|
||
var index = -1;
|
||
var i;
|
||
var instance;
|
||
|
||
for (i = 0; i < this.componentInstances.length; i++) {
|
||
if (this.componentInstances[i].id === componentId) {
|
||
index = i;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (index === -1) return;
|
||
|
||
instance = this.componentInstances[index];
|
||
if (instance.element) {
|
||
instance.element.remove();
|
||
}
|
||
this.componentInstances.splice(index, 1);
|
||
this._deselectAll();
|
||
this._notifyChanged('delete', { id: componentId });
|
||
},
|
||
|
||
_findInstance: function(componentId) {
|
||
var i;
|
||
for (i = 0; i < this.componentInstances.length; i++) {
|
||
if (this.componentInstances[i].id === componentId) {
|
||
return this.componentInstances[i];
|
||
}
|
||
}
|
||
return null;
|
||
},
|
||
|
||
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;
|
||
var icon;
|
||
var label;
|
||
|
||
item = document.createElement('div');
|
||
item.className = 'webbuilder-toolbox-item';
|
||
if (this.isMobile) {
|
||
item.classList.add('wb-mobile-toolbox-item');
|
||
}
|
||
item.setAttribute('draggable', true);
|
||
item.setAttribute('data-type', name);
|
||
|
||
icon = definition.icon || '\uD83D\uDCE6';
|
||
label = definition.label || name;
|
||
|
||
item.innerHTML = '<span class="icon">' + icon + '</span><span class="label">' + label + '</span>';
|
||
|
||
item.addEventListener('dragstart', function() {
|
||
self.draggingType = name;
|
||
item.style.opacity = '0.5';
|
||
});
|
||
item.addEventListener('dragend', function() {
|
||
item.style.opacity = '1';
|
||
self.draggingType = null;
|
||
});
|
||
|
||
if (this.isMobile) {
|
||
item.addEventListener('click', function() {
|
||
self.touchDragType = name;
|
||
});
|
||
}
|
||
|
||
this.toolboxListEl.appendChild(item);
|
||
},
|
||
|
||
toJSONB: function() {
|
||
var self = this;
|
||
var components;
|
||
|
||
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
|
||
}
|
||
};
|
||
},
|
||
|
||
fromJSONB: function(jsonb) {
|
||
var phoneLayout;
|
||
var self = this;
|
||
var maxId;
|
||
|
||
this.canvasEl.innerHTML = '';
|
||
this.componentInstances = [];
|
||
this.selectedComponent = null;
|
||
|
||
phoneLayout = jsonb.layouts && jsonb.layouts.phone;
|
||
if (!phoneLayout) {
|
||
console.warn('No phone layout found');
|
||
return;
|
||
}
|
||
|
||
if (phoneLayout.components) {
|
||
phoneLayout.components.forEach(function(comp) {
|
||
var typeDef = self.componentTypes[comp.type];
|
||
var instance;
|
||
|
||
if (!typeDef) {
|
||
console.warn('Component type not found:', comp.type);
|
||
return;
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
maxId = 0;
|
||
this.componentInstances.forEach(function(inst) {
|
||
var match = inst.id.match(/-(\d+)$/);
|
||
if (match) {
|
||
maxId = Math.max(maxId, parseInt(match[1], 10));
|
||
}
|
||
});
|
||
this.idCounter = maxId;
|
||
}
|
||
|
||
this._notifyChanged('load', { count: this.componentInstances.length });
|
||
},
|
||
|
||
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 = '';
|
||
this._notifyChanged('clear', null);
|
||
}
|
||
};
|