使用 Claude-4-Sonnet 这个模型来实现功能,用Vue2实现前端内容,下面给出实现代码和具体功能描述,还有实现效果,做一下简单代码记录
实现效果
实现代码
<template>
<div id="app">
<!-- 顶部工具栏:包含撤销、重做、清空、保存功能以及画布尺寸控制 -->
<div class="toolbar">
<!-- 历史操作按钮 -->
<button @click="undo" :disabled="!canUndo">撤销</button>
<button @click="redo" :disabled="!canRedo">重做</button>
<button @click="clearCanvas">清空</button>
<button @click="saveProject">保存</button>
<button @click="$refs.fileInput.click()">加载</button>
<button @click="resetCanvasView">重置视图</button>
<button @click="clearAllGuidelines" :disabled="guidelines.length === 0">清除参考线</button>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInput"
type="file"
accept=".json"
@change="loadProject"
style="display: none;"
/>
<!-- 画布尺寸调整控件 -->
<div class="canvas-size-controls">
<label>宽度: <input v-model.number="canvasWidth" type="number" min="300" max="2000" /></label>
<label>高度: <input v-model.number="canvasHeight" type="number" min="200" max="1500" /></label>
</div>
<!-- 缩放倍率显示 -->
<div class="zoom-info">
缩放: {{ Math.round(canvasScale * 100) }}%
</div>
<!-- 鼠标位置信息显示 -->
<div class="mouse-position-info">
位置: X{{ mousePosition.x }}, Y{{ mousePosition.y }}
</div>
</div>
<div class="main-content">
<!-- 左侧组件面板:展示可拖拽的组件列表 -->
<div class="component-panel">
<h3>组件列表</h3>
<div class="component-list">
<!-- 遍历所有可用组件类型,每个组件都可以拖拽到画布 -->
<div
v-for="component in componentTypes"
:key="component.type"
class="component-item"
draggable="true"
@dragstart="onDragStart($event, component)"
>
<!-- 组件预览区域,显示组件样式和名称 -->
<div class="component-preview" :style="component.style">
{{ component.name }}
</div>
</div>
</div>
</div>
<!-- 右侧编辑区域:主要的设计画布 -->
<div
class="canvas-container"
@mousedown="onCanvasMouseDown"
@wheel="onCanvasWheel"
@mousemove="updateRulerIndicators"
>
<!-- 水平标尺 -->
<div class="ruler horizontal-ruler" ref="horizontalRuler" @click="onHorizontalRulerClick">
<div class="ruler-indicator horizontal-indicator" :style="{ left: rulerIndicators.x + 'px' }"></div>
<div
v-for="tick in horizontalTicks"
:key="'h-' + tick.position"
class="ruler-tick"
:class="{ 'major-tick': tick.isMajor }"
:style="{ left: tick.position + 'px' }"
>
<span v-if="tick.isMajor" class="tick-label">{{ tick.label }}</span>
</div>
<!-- 水平标尺上的垂直参考线标记 -->
<div
v-for="guideline in verticalGuidelines"
:key="'hr-' + guideline.id"
class="guideline-marker vertical-marker"
:style="{ left: guideline.rulerPosition + 'px' }"
></div>
</div>
<!-- 垂直标尺 -->
<div class="ruler vertical-ruler" ref="verticalRuler" @click="onVerticalRulerClick">
<div class="ruler-indicator vertical-indicator" :style="{ top: rulerIndicators.y + 'px' }"></div>
<div
v-for="tick in verticalTicks"
:key="'v-' + tick.position"
class="ruler-tick"
:class="{ 'major-tick': tick.isMajor }"
:style="{ top: tick.position + 'px' }"
>
<span v-if="tick.isMajor" class="tick-label">{{ tick.label }}</span>
</div>
<!-- 垂直标尺上的水平参考线标记 -->
<div
v-for="guideline in horizontalGuidelines"
:key="'vr-' + guideline.id"
class="guideline-marker horizontal-marker"
:style="{ top: guideline.rulerPosition + 'px' }"
></div>
</div>
<!-- 标尺角落 -->
<div class="ruler-corner"></div>
<div
class="canvas"
:style="{
width: canvasWidth + 'px',
height: canvasHeight + 'px',
transform: `translate(${canvasOffset.x}px, ${canvasOffset.y}px) scale(${canvasScale})`,
cursor: isCanvasDragging ? 'grabbing' : 'grab'
}"
@drop="onDrop"
@dragover="onDragOver"
@click="selectComponent(null)"
>
<!-- 对齐辅助线:拖拽组件时显示的红色对齐线 -->
<div
v-for="line in alignmentLines"
:key="line.id"
class="alignment-line"
:class="line.type"
:style="line.style"
></div>
<!-- 参考线 -->
<div
v-for="guideline in guidelines"
:key="guideline.id"
class="guideline"
:class="guideline.type"
:style="getGuidelineStyle(guideline)"
@mousedown="onGuidelineMouseDown($event, guideline)"
@contextmenu.prevent="showGuidelineContextMenu($event, guideline)"
>
<!-- 参考线位置信息 -->
<div class="guideline-label start" @contextmenu.prevent.stop="deleteGuideline(guideline)">
{{ Math.round(guideline.position) }}
</div>
<div class="guideline-label end" @contextmenu.prevent.stop="deleteGuideline(guideline)">
{{ Math.round(guideline.position) }}
</div>
</div>
<!-- 画布中的所有组件 -->
<div
v-for="component in components"
:key="component.id"
class="canvas-component"
:class="{ selected: selectedComponent && selectedComponent.id === component.id }"
:style="{
left: component.x + 'px',
top: component.y + 'px',
width: component.width + 'px',
height: component.height + 'px',
zIndex: component.zIndex,
...component.style
}"
@mousedown="onComponentMouseDown($event, component)"
@click.stop="selectComponent(component)"
@contextmenu.prevent="showContextMenu($event, component)"
>
<!-- 可以将左侧组件改为自定义组件列表,且左侧组件已经在components中注册,从而根据组件类型渲染不同的组件 -->
<!-- <component :is="component.type" /> -->
{{ component.content }}
<!-- 选中组件时显示的8个调整大小控制点 -->
<div
v-if="selectedComponent && selectedComponent.id === component.id"
class="resize-handles"
>
<div
v-for="handle in resizeHandles"
:key="handle.position"
class="resize-handle"
:class="handle.position"
@mousedown.stop="onResizeStart($event, component, handle.position)"
></div>
</div>
</div>
</div>
</div>
</div>
<!-- 右键菜单:组件右键时显示的操作菜单 -->
<div
v-if="contextMenu.show"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<div @click="deleteComponent(contextMenu.component)">删除组件</div>
<div @click="bringToFront(contextMenu.component)">置顶</div>
<div @click="sendToBack(contextMenu.component)">置底</div>
<div @click="bringForward(contextMenu.component)">上移</div>
<div @click="sendBackward(contextMenu.component)">下移</div>
</div>
</div>
</template>
<script>
/**
* 低代码编辑器主组件
* 功能包括:组件拖拽、调整大小、对齐辅助线、历史记录、右键菜单等
*/
export default {
name: 'App',
data() {
return {
// 画布尺寸
canvasWidth: 1920,
canvasHeight: 1080,
// 组件相关状态
components: [], // 画布中的所有组件
selectedComponent: null, // 当前选中的组件
draggedComponent: null, // 正在拖拽的组件
// 交互状态
isDragging: false, // 是否正在拖拽组件
isResizing: false, // 是否正在调整大小
isCanvasDragging: false, // 是否正在拖拽画布
resizeHandle: '', // 当前调整大小的控制点
// 拖拽和调整大小的辅助数据
dragOffset: { x: 0, y: 0 }, // 拖拽时的偏移量
resizeStartPos: { x: 0, y: 0 }, // 调整大小开始时的鼠标位置
resizeStartSize: { width: 0, height: 0 }, // 调整大小开始时的组件尺寸
// 画布拖拽相关
canvasOffset: { x: 0, y: 0 }, // 画布偏移量
canvasDragStart: { x: 0, y: 0 }, // 画布拖拽开始位置
canvasStartOffset: { x: 0, y: 0 }, // 画布拖拽开始时的偏移量
// 画布缩放相关
canvasScale: 1, // 画布缩放比例
minScale: 0.1, // 最小缩放比例
maxScale: 3, // 最大缩放比例
// 对齐辅助线
alignmentLines: [],
// 右键菜单状态
contextMenu: {
show: false,
x: 0,
y: 0,
component: null
},
// 历史记录
history: [],
historyIndex: -1,
// 标尺相关
rulerUnit: 20, // 标尺单位大小(像素)
rulerSize: 20, // 标尺宽度/高度
horizontalTicks: [], // 水平标尺刻度
verticalTicks: [], // 垂直标尺刻度
rulerIndicators: { x: 0, y: 0 }, // 标尺指示器位置
// 参考线相关
guidelines: [], // 参考线数组
isDraggingGuideline: false, // 是否正在拖拽参考线
draggedGuideline: null, // 正在拖拽的参考线
guidelineOffset: 0, // 拖拽参考线时的偏移量
// 窗口大小变化处理
resizeTimer: null, // 窗口大小变化防抖定时器
// 鼠标位置信息
mousePosition: { x: 0, y: 0 }, // 鼠标在画布中的位置
// 可用的组件类型定义
componentTypes: [
{
type: 'button',
name: '按钮',
style: {
backgroundColor: '#409eff',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '8px 16px',
cursor: 'pointer'
},
defaultProps: {
width: 80,
height: 32,
content: '按钮'
}
},
{
type: 'input',
name: '输入框',
style: {
border: '1px solid #dcdfe6',
borderRadius: '4px',
padding: '8px',
backgroundColor: 'white'
},
defaultProps: {
width: 200,
height: 32,
content: '输入框'
}
},
{
type: 'text',
name: '文本',
style: {
color: '#333',
fontSize: '14px',
lineHeight: '1.5'
},
defaultProps: {
width: 100,
height: 24,
content: '文本内容'
}
},
{
type: 'div',
name: '容器',
style: {
border: '1px solid #ddd',
backgroundColor: '#f5f5f5',
borderRadius: '4px'
},
defaultProps: {
width: 200,
height: 100,
content: '容器'
}
}
],
// 调整大小的8个控制点位置定义
resizeHandles: [
{ position: 'nw' }, // 西北角
{ position: 'n' }, // 北边
{ position: 'ne' }, // 东北角
{ position: 'e' }, // 东边
{ position: 'se' }, // 东南角
{ position: 's' }, // 南边
{ position: 'sw' }, // 西南角
{ position: 'w' } // 西边
]
}
},
computed: {
// 是否可以撤销
canUndo() {
return this.historyIndex > 0
},
// 是否可以重做
canRedo() {
return this.historyIndex < this.history.length - 1
},
// 垂直参考线(在水平标尺上显示)
verticalGuidelines() {
return this.guidelines.filter(g => g.type === 'vertical').map(g => ({
...g,
rulerPosition: this.getGuidelineRulerPosition(g)
}))
},
// 水平参考线(在垂直标尺上显示)
horizontalGuidelines() {
return this.guidelines.filter(g => g.type === 'horizontal').map(g => ({
...g,
rulerPosition: this.getGuidelineRulerPosition(g)
}))
}
},
watch: {
// 监听画布尺寸变化,自动重新初始化视图
canvasWidth() {
this.$nextTick(() => {
this.initializeCanvasView()
})
},
canvasHeight() {
this.$nextTick(() => {
this.initializeCanvasView()
})
}
},
mounted() {
// 添加全局事件监听器
document.addEventListener('mousemove', this.onMouseMove)
document.addEventListener('mouseup', this.onMouseUp)
document.addEventListener('click', this.hideContextMenu)
// 添加窗口大小变化监听器
window.addEventListener('resize', this.onWindowResize)
// 初始化画布视图
this.$nextTick(() => {
this.initializeCanvasView()
})
// 保存初始状态
this.saveState()
},
beforeDestroy() {
// 移除全局事件监听器,防止内存泄漏
document.removeEventListener('mousemove', this.onMouseMove)
document.removeEventListener('mouseup', this.onMouseUp)
document.removeEventListener('click', this.hideContextMenu)
// 移除窗口大小变化监听器
window.removeEventListener('resize', this.onWindowResize)
},
methods: {
// ==================== 拖拽相关方法 ====================
/**
* 开始拖拽组件
* @param {Event} event - 拖拽事件
* @param {Object} componentType - 组件类型数据
*/
onDragStart(event, componentType) {
event.dataTransfer.setData('componentType', JSON.stringify(componentType))
},
/**
* 拖拽经过画布时的处理
* @param {Event} event - 拖拽事件
*/
onDragOver(event) {
event.preventDefault()
},
/**
* 在画布上放置组件
* @param {Event} event - 放置事件
*/
onDrop(event) {
event.preventDefault()
const componentTypeData = event.dataTransfer.getData('componentType')
if (componentTypeData) {
const componentType = JSON.parse(componentTypeData)
const rect = event.currentTarget.getBoundingClientRect()
// 将鼠标坐标转换为画布内的实际坐标,考虑缩放
const x = (event.clientX - rect.left) / this.canvasScale
const y = (event.clientY - rect.top) / this.canvasScale
this.addComponent(componentType, x, y)
}
},
// ==================== 组件管理方法 ====================
/**
* 添加新组件到画布
* @param {Object} componentType - 组件类型
* @param {number} x - X坐标
* @param {number} y - Y坐标
*/
addComponent(componentType, x, y) {
// 计算组件的初始位置(居中放置)
let componentX = x - componentType.defaultProps.width / 2
let componentY = y - componentType.defaultProps.height / 2
// 边界检查:确保组件完全在画布范围内
// 限制X坐标不能小于0,且组件右边不能超出画布宽度
componentX = Math.max(0, Math.min(componentX, this.canvasWidth - componentType.defaultProps.width))
// 限制Y坐标不能小于0,且组件底部不能超出画布高度
componentY = Math.max(0, Math.min(componentY, this.canvasHeight - componentType.defaultProps.height))
const newComponent = {
id: Date.now() + Math.random(), // 生成唯一ID
type: componentType.type,
x: componentX,
y: componentY,
width: componentType.defaultProps.width,
height: componentType.defaultProps.height,
content: componentType.defaultProps.content,
style: { ...componentType.style },
zIndex: this.getMaxZIndex() + 1 // 设置为最高层级
}
this.components.push(newComponent)
this.selectComponent(newComponent)
this.saveState()
},
/**
* 选中组件
* @param {Object|null} component - 要选中的组件,null表示取消选中
*/
selectComponent(component) {
this.selectedComponent = component
this.hideContextMenu()
},
/**
* 删除组件
* @param {Object} component - 要删除的组件
*/
deleteComponent(component) {
const index = this.components.findIndex(c => c.id === component.id)
if (index > -1) {
this.components.splice(index, 1)
if (this.selectedComponent && this.selectedComponent.id === component.id) {
this.selectedComponent = null
}
this.saveState()
}
this.hideContextMenu()
},
// ==================== 层级管理方法 ====================
/**
* 获取当前最大的z-index值
* @returns {number} 最大z-index值
*/
getMaxZIndex() {
return this.components.length > 0 ? Math.max(...this.components.map(c => c.zIndex || 0)) : 0
},
/**
* 将组件置顶
* @param {Object} component - 要置顶的组件
*/
bringToFront(component) {
component.zIndex = this.getMaxZIndex() + 1
this.saveState()
this.hideContextMenu()
},
/**
* 将组件置底
* @param {Object} component - 要置底的组件
*/
sendToBack(component) {
const minZ = Math.min(...this.components.map(c => c.zIndex || 0))
component.zIndex = minZ - 1
this.saveState()
this.hideContextMenu()
},
/**
* 将组件上移一层
* @param {Object} component - 要上移的组件
*/
bringForward(component) {
const currentZ = component.zIndex || 0
const higherComponents = this.components.filter(c => (c.zIndex || 0) > currentZ)
if (higherComponents.length > 0) {
const nextZ = Math.min(...higherComponents.map(c => c.zIndex || 0))
component.zIndex = nextZ + 1
}
this.saveState()
this.hideContextMenu()
},
/**
* 将组件下移一层
* @param {Object} component - 要下移的组件
*/
sendBackward(component) {
const currentZ = component.zIndex || 0
const lowerComponents = this.components.filter(c => (c.zIndex || 0) < currentZ)
if (lowerComponents.length > 0) {
const prevZ = Math.max(...lowerComponents.map(c => c.zIndex || 0))
component.zIndex = prevZ - 1
}
this.saveState()
this.hideContextMenu()
},
// ==================== 鼠标事件处理方法 ====================
/**
* 组件鼠标按下事件处理
* @param {Event} event - 鼠标事件
* @param {Object} component - 被点击的组件
*/
onComponentMouseDown(event, component) {
if (event.button !== 0) return // 只处理左键点击
this.selectComponent(component)
this.isDragging = true
this.draggedComponent = component
// 计算鼠标相对于组件的偏移量,考虑画布缩放
const canvas = document.querySelector('.canvas')
const canvasRect = canvas.getBoundingClientRect()
const mouseXInCanvas = (event.clientX - canvasRect.left) / this.canvasScale
const mouseYInCanvas = (event.clientY - canvasRect.top) / this.canvasScale
this.dragOffset = {
x: mouseXInCanvas - component.x,
y: mouseYInCanvas - component.y
}
event.preventDefault()
},
/**
* 全局鼠标移动事件处理
* @param {Event} event - 鼠标事件
*/
onMouseMove(event) {
if (this.isDragging && this.draggedComponent) {
// 处理组件拖拽,考虑画布缩放
const canvas = document.querySelector('.canvas')
const canvasRect = canvas.getBoundingClientRect()
// 将鼠标坐标转换为画布内的实际坐标
const mouseXInCanvas = (event.clientX - canvasRect.left) / this.canvasScale
const mouseYInCanvas = (event.clientY - canvasRect.top) / this.canvasScale
let newX = mouseXInCanvas - this.dragOffset.x
let newY = mouseYInCanvas - this.dragOffset.y
// 边界检查:确保组件不会超出画布区域
// 限制X坐标不能小于0,且组件右边不能超出画布宽度
newX = Math.max(0, Math.min(newX, this.canvasWidth - this.draggedComponent.width))
// 限制Y坐标不能小于0,且组件底部不能超出画布高度
newY = Math.max(0, Math.min(newY, this.canvasHeight - this.draggedComponent.height))
this.draggedComponent.x = newX
this.draggedComponent.y = newY
// 显示对齐辅助线
this.showAlignmentLines(this.draggedComponent)
// 更新标尺指示器位置
this.updateRulerIndicators(event)
} else if (this.isResizing && this.selectedComponent) {
// 处理组件大小调整
this.handleResize(event)
// 更新标尺指示器位置
this.updateRulerIndicators(event)
} else if (this.isCanvasDragging) {
// 处理画布拖拽
const deltaX = event.clientX - this.canvasDragStart.x
const deltaY = event.clientY - this.canvasDragStart.y
this.canvasOffset.x = this.canvasStartOffset.x + deltaX
this.canvasOffset.y = this.canvasStartOffset.y + deltaY
// 更新标尺刻度
this.updateRulerTicks()
// 更新标尺指示器位置
this.updateRulerIndicators(event)
} else if (this.isDraggingGuideline && this.draggedGuideline) {
// 处理参考线拖拽
this.handleGuidelineDrag(event)
} else {
// 即使没有拖拽操作,也更新标尺指示器位置
this.updateRulerIndicators(event)
}
},
/**
* 全局鼠标释放事件处理
*/
onMouseUp() {
if (this.isDragging) {
this.isDragging = false
this.draggedComponent = null
this.alignmentLines = [] // 清除对齐线
this.saveState()
}
if (this.isResizing) {
this.isResizing = false
this.saveState()
}
if (this.isCanvasDragging) {
this.isCanvasDragging = false
}
if (this.isDraggingGuideline) {
this.isDraggingGuideline = false
this.draggedGuideline = null
}
},
// ==================== 调整大小相关方法 ====================
/**
* 开始调整组件大小
* @param {Event} event - 鼠标事件
* @param {Object} component - 要调整的组件
* @param {string} handle - 调整控制点位置
*/
onResizeStart(event, component, handle) {
this.isResizing = true
this.resizeHandle = handle
// 将鼠标坐标转换为画布内的实际坐标,考虑缩放
const canvas = document.querySelector('.canvas')
const canvasRect = canvas.getBoundingClientRect()
const mouseXInCanvas = (event.clientX - canvasRect.left) / this.canvasScale
const mouseYInCanvas = (event.clientY - canvasRect.top) / this.canvasScale
this.resizeStartPos = { x: mouseXInCanvas, y: mouseYInCanvas }
this.resizeStartSize = { width: component.width, height: component.height }
this.resizeStartPosition = { x: component.x, y: component.y }
event.preventDefault()
},
/**
* 处理组件大小调整
* @param {Event} event - 鼠标事件
*/
handleResize(event) {
// 将当前鼠标坐标转换为画布内的实际坐标,考虑缩放
const canvas = document.querySelector('.canvas')
const canvasRect = canvas.getBoundingClientRect()
const mouseXInCanvas = (event.clientX - canvasRect.left) / this.canvasScale
const mouseYInCanvas = (event.clientY - canvasRect.top) / this.canvasScale
const deltaX = mouseXInCanvas - this.resizeStartPos.x
const deltaY = mouseYInCanvas - this.resizeStartPos.y
const component = this.selectedComponent
let newWidth = this.resizeStartSize.width
let newHeight = this.resizeStartSize.height
let newX = this.resizeStartPosition.x
let newY = this.resizeStartPosition.y
// 根据不同的控制点处理调整逻辑
switch (this.resizeHandle) {
case 'nw': // 西北角:同时调整宽高和位置
newWidth = this.resizeStartSize.width - deltaX
newHeight = this.resizeStartSize.height - deltaY
newX = this.resizeStartPosition.x + deltaX
newY = this.resizeStartPosition.y + deltaY
break
case 'n': // 北边:只调整高度和Y位置
newHeight = this.resizeStartSize.height - deltaY
newY = this.resizeStartPosition.y + deltaY
break
case 'ne': // 东北角:调整宽高,只改变Y位置
newWidth = this.resizeStartSize.width + deltaX
newHeight = this.resizeStartSize.height - deltaY
newY = this.resizeStartPosition.y + deltaY
break
case 'e': // 东边:只调整宽度
newWidth = this.resizeStartSize.width + deltaX
break
case 'se': // 东南角:只调整宽高
newWidth = this.resizeStartSize.width + deltaX
newHeight = this.resizeStartSize.height + deltaY
break
case 's': // 南边:只调整高度
newHeight = this.resizeStartSize.height + deltaY
break
case 'sw': // 西南角:调整宽高,只改变X位置
newWidth = this.resizeStartSize.width - deltaX
newHeight = this.resizeStartSize.height + deltaY
newX = this.resizeStartPosition.x + deltaX
break
case 'w': // 西边:只调整宽度和X位置
newWidth = this.resizeStartSize.width - deltaX
newX = this.resizeStartPosition.x + deltaX
break
}
// 边界检查,确保组件不会超出画布
// 限制位置不能小于0
newX = Math.max(0, newX)
newY = Math.max(0, newY)
// 限制位置+尺寸不能超出画布
if (newX + newWidth > this.canvasWidth) {
if (this.resizeHandle.includes('w')) {
// 如果是从左边调整,限制X位置
newX = this.canvasWidth - newWidth
} else {
// 如果是从右边调整,限制宽度
newWidth = this.canvasWidth - newX
}
}
if (newY + newHeight > this.canvasHeight) {
if (this.resizeHandle.includes('n')) {
// 如果是从上边调整,限制Y位置
newY = this.canvasHeight - newHeight
} else {
// 如果是从下边调整,限制高度
newHeight = this.canvasHeight - newY
}
}
// 最小尺寸限制(在边界检查之后)
newWidth = Math.max(20, newWidth)
newHeight = Math.max(20, newHeight)
// 再次检查边界,确保最小尺寸限制后仍在画布内
if (newX + newWidth > this.canvasWidth) {
newX = this.canvasWidth - newWidth
}
if (newY + newHeight > this.canvasHeight) {
newY = this.canvasHeight - newHeight
}
// 确保位置不为负数
newX = Math.max(0, newX)
newY = Math.max(0, newY)
// 应用新的尺寸和位置
component.width = newWidth
component.height = newHeight
component.x = newX
component.y = newY
},
// 对齐辅助线
showAlignmentLines(draggedComponent) {
this.alignmentLines = []
const threshold = 5 // 对齐阈值
this.components.forEach(component => {
if (component.id === draggedComponent.id) return
const comp = component
const drag = draggedComponent
// 垂直对齐线
if (Math.abs(comp.x - drag.x) < threshold) {
this.alignmentLines.push({
id: `v-${comp.id}-left`,
type: 'vertical',
style: {
left: comp.x + 'px',
top: '0px',
height: this.canvasHeight + 'px'
}
})
}
if (Math.abs((comp.x + comp.width) - (drag.x + drag.width)) < threshold) {
this.alignmentLines.push({
id: `v-${comp.id}-right`,
type: 'vertical',
style: {
left: (comp.x + comp.width) + 'px',
top: '0px',
height: this.canvasHeight + 'px'
}
})
}
if (Math.abs((comp.x + comp.width / 2) - (drag.x + drag.width / 2)) < threshold) {
this.alignmentLines.push({
id: `v-${comp.id}-center`,
type: 'vertical',
style: {
left: (comp.x + comp.width / 2) + 'px',
top: '0px',
height: this.canvasHeight + 'px'
}
})
}
// 水平对齐线
if (Math.abs(comp.y - drag.y) < threshold) {
this.alignmentLines.push({
id: `h-${comp.id}-top`,
type: 'horizontal',
style: {
left: '0px',
top: comp.y + 'px',
width: this.canvasWidth + 'px'
}
})
}
if (Math.abs((comp.y + comp.height) - (drag.y + drag.height)) < threshold) {
this.alignmentLines.push({
id: `h-${comp.id}-bottom`,
type: 'horizontal',
style: {
left: '0px',
top: (comp.y + comp.height) + 'px',
width: this.canvasWidth + 'px'
}
})
}
if (Math.abs((comp.y + comp.height / 2) - (drag.y + drag.height / 2)) < threshold) {
this.alignmentLines.push({
id: `h-${comp.id}-middle`,
type: 'horizontal',
style: {
left: '0px',
top: (comp.y + comp.height / 2) + 'px',
width: this.canvasWidth + 'px'
}
})
}
})
},
// 右键菜单
showContextMenu(event, component) {
this.contextMenu = {
show: true,
x: event.clientX,
y: event.clientY,
component: component
}
},
hideContextMenu() {
this.contextMenu.show = false
},
// 历史记录
saveState() {
const state = JSON.parse(JSON.stringify(this.components))
this.history = this.history.slice(0, this.historyIndex + 1)
this.history.push(state)
this.historyIndex = this.history.length - 1
// 限制历史记录数量
if (this.history.length > 50) {
this.history.shift()
this.historyIndex--
}
},
undo() {
if (this.canUndo) {
this.historyIndex--
this.components = JSON.parse(JSON.stringify(this.history[this.historyIndex]))
this.selectedComponent = null
}
},
redo() {
if (this.canRedo) {
this.historyIndex++
this.components = JSON.parse(JSON.stringify(this.history[this.historyIndex]))
this.selectedComponent = null
}
},
clearCanvas() {
if (confirm('确定要清空画布吗?')) {
this.components = []
this.selectedComponent = null
this.saveState()
}
},
saveProject() {
// 对组件信息进行归一化处理
const normalizedComponents = this.components.map(component => {
return {
...component,
// 保留原始像素值
x: component.x,
y: component.y,
width: component.width,
height: component.height,
// 添加归一化值(相对于画布尺寸的比例,0-1之间)
normalizedX: component.x / this.canvasWidth,
normalizedY: component.y / this.canvasHeight,
normalizedWidth: component.width / this.canvasWidth,
normalizedHeight: component.height / this.canvasHeight
}
})
const projectData = {
components: normalizedComponents,
canvasWidth: this.canvasWidth,
canvasHeight: this.canvasHeight
}
const dataStr = JSON.stringify(projectData, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = 'lowcode-project.json'
link.click()
URL.revokeObjectURL(url)
},
/**
* 加载项目文件
* @param {Event} event - 文件输入事件
*/
loadProject(event) {
const file = event.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const projectData = JSON.parse(e.target.result)
// 恢复画布尺寸
if (projectData.canvasWidth && projectData.canvasHeight) {
this.canvasWidth = projectData.canvasWidth
this.canvasHeight = projectData.canvasHeight
}
// 恢复组件,优先使用归一化数据
this.components = projectData.components.map(component => {
let restoredComponent = { ...component }
// 如果存在归一化数据,根据当前画布尺寸还原位置和大小
if (component.normalizedX !== undefined) {
restoredComponent.x = component.normalizedX * this.canvasWidth
restoredComponent.y = component.normalizedY * this.canvasHeight
restoredComponent.width = component.normalizedWidth * this.canvasWidth
restoredComponent.height = component.normalizedHeight * this.canvasHeight
// 清除归一化数据,避免在运行时保留
delete restoredComponent.normalizedX
delete restoredComponent.normalizedY
delete restoredComponent.normalizedWidth
delete restoredComponent.normalizedHeight
}
return restoredComponent
})
this.selectedComponent = null
this.saveState()
alert('项目加载成功!')
} catch (error) {
alert('项目文件格式错误!')
console.error('加载项目失败:', error)
}
}
reader.readAsText(file)
// 清空文件输入,允许重复选择同一文件
event.target.value = ''
},
// ==================== 画布拖拽相关方法 ====================
/**
* 画布鼠标按下事件处理
* @param {Event} event - 鼠标事件
*/
onCanvasMouseDown(event) {
// 只有在点击画布空白区域或画布容器时才开始拖拽画布
// 排除点击组件、调整大小控制点等元素
if (event.target.classList.contains('canvas') ||
event.target.classList.contains('canvas-container')) {
this.isCanvasDragging = true
this.canvasDragStart = {
x: event.clientX,
y: event.clientY
}
this.canvasStartOffset = {
x: this.canvasOffset.x,
y: this.canvasOffset.y
}
event.preventDefault()
}
},
/**
* 画布鼠标滚轮事件处理(缩放功能)
* @param {Event} event - 滚轮事件
*/
onCanvasWheel(event) {
event.preventDefault()
// 计算缩放增量
const delta = event.deltaY > 0 ? -0.1 : 0.1
const oldScale = this.canvasScale
const newScale = Math.max(this.minScale, Math.min(this.maxScale, oldScale + delta))
// 如果缩放比例没有变化,直接返回
if (newScale === oldScale) return
// 获取鼠标在画布容器中的位置
const container = event.currentTarget
const containerRect = container.getBoundingClientRect()
const mouseX = event.clientX - containerRect.left
const mouseY = event.clientY - containerRect.top
// 计算鼠标相对于画布中心的偏移量
const containerCenterX = container.clientWidth / 2
const containerCenterY = container.clientHeight / 2
// 计算缩放前鼠标在画布坐标系中的位置
const canvasMouseX = (mouseX - containerCenterX - this.canvasOffset.x) / oldScale
const canvasMouseY = (mouseY - containerCenterY - this.canvasOffset.y) / oldScale
// 更新缩放比例
this.canvasScale = newScale
// 计算新的偏移量,使鼠标位置在画布中保持不变
this.canvasOffset.x = mouseX - containerCenterX - canvasMouseX * newScale
this.canvasOffset.y = mouseY - containerCenterY - canvasMouseY * newScale
// 更新标尺刻度
this.updateRulerTicks()
// 更新标尺指示器位置
this.updateRulerIndicators(event)
},
/**
* 重置画布视图(缩放和偏移)
*/
resetCanvasView() {
this.canvasScale = 1
this.canvasOffset.x = 0
this.canvasOffset.y = 0
// 更新标尺刻度
this.updateRulerTicks()
},
/**
* 初始化画布视图,根据画布尺寸自动缩放居中
*/
initializeCanvasView() {
const container = document.querySelector('.canvas-container')
if (!container) return
// 获取容器的可用空间(减去padding和标尺大小)
const containerRect = container.getBoundingClientRect()
const availableWidth = containerRect.width - 40 - this.rulerSize // 减去左右padding和垂直标尺宽度
const availableHeight = containerRect.height - 40 - this.rulerSize // 减去上下padding和水平标尺高度
// 计算缩放比例,使画布能完整显示在容器中
const scaleX = availableWidth / this.canvasWidth
const scaleY = availableHeight / this.canvasHeight
const optimalScale = Math.min(scaleX, scaleY, 1) // 不超过1倍缩放
// 限制缩放比例在允许范围内
this.canvasScale = Math.max(this.minScale, Math.min(this.maxScale, optimalScale))
// 重置画布偏移量为0,让CSS的flex布局来处理居中
this.canvasOffset.x = 0
this.canvasOffset.y = 0
// 延迟初始化标尺,确保DOM更新完成
this.$nextTick(() => {
this.initializeRulers()
})
},
/**
* 初始化标尺刻度
*/
initializeRulers() {
this.updateRulerTicks()
this.$nextTick(() => {
this.updateRulerIndicators()
})
},
/**
* 更新标尺刻度
*/
updateRulerTicks() {
// 清空现有刻度
this.horizontalTicks = []
this.verticalTicks = []
// 计算可见区域的起始和结束位置(考虑缩放和偏移)
const container = document.querySelector('.canvas-container')
if (!container) return
const containerRect = container.getBoundingClientRect()
const availableWidth = containerRect.width - this.rulerSize
const availableHeight = containerRect.height - this.rulerSize
// 计算标尺上的刻度间隔
const tickInterval = this.rulerUnit
// 计算画布在容器中的理论居中位置
const scaledCanvasWidth = this.canvasWidth * this.canvasScale
const scaledCanvasHeight = this.canvasHeight * this.canvasScale
// 计算画布左上角在标尺坐标系中的位置
// 考虑容器padding(20px)、标尺大小、flex居中和transform偏移
const containerCenterX = (containerRect.width - this.rulerSize) / 2
const containerCenterY = (containerRect.height - this.rulerSize) / 2
const canvasCenterX = scaledCanvasWidth / 2
const canvasCenterY = scaledCanvasHeight / 2
// 画布左上角在标尺坐标系中的位置
// 需要减去标尺自身的偏移量:水平标尺left: 20px,垂直标尺top: 20px
const originX = containerCenterX - canvasCenterX + this.canvasOffset.x - this.rulerSize / 2
const originY = containerCenterY - canvasCenterY + this.canvasOffset.y - this.rulerSize / 2
// 计算可见区域内的刻度范围
const startX = Math.floor(-originX / (tickInterval * this.canvasScale)) - 5
const endX = Math.ceil((availableWidth - originX) / (tickInterval * this.canvasScale)) + 5
const startY = Math.floor(-originY / (tickInterval * this.canvasScale)) - 5
const endY = Math.ceil((availableHeight - originY) / (tickInterval * this.canvasScale)) + 5
// 生成水平标尺刻度
for (let i = startX; i <= endX; i++) {
const canvasPos = i * tickInterval // 画布上的位置
const pixelPosition = originX + canvasPos * this.canvasScale // 标尺上的像素位置
// 确保刻度在可见区域内
if (pixelPosition >= 0 && pixelPosition <= availableWidth) {
const isMajor = i % 5 === 0 // 每5个刻度为主刻度
const label = Math.round(i * tickInterval) // 实际像素值
this.horizontalTicks.push({
position: pixelPosition,
isMajor,
label
})
}
}
// 生成垂直标尺刻度
for (let i = startY; i <= endY; i++) {
const canvasPos = i * tickInterval // 画布上的位置
const pixelPosition = originY + canvasPos * this.canvasScale // 标尺上的像素位置
// 确保刻度在可见区域内
if (pixelPosition >= 0 && pixelPosition <= availableHeight) {
const isMajor = i % 5 === 0 // 每5个刻度为主刻度
const label = Math.round(i * tickInterval) // 实际像素值
this.verticalTicks.push({
position: pixelPosition,
isMajor,
label
})
}
}
},
/**
* 更新标尺指示器位置
* @param {Event} event - 鼠标事件(可选)
*/
updateRulerIndicators(event) {
const container = document.querySelector('.canvas-container')
if (!container) return
if (event) {
const containerRect = container.getBoundingClientRect()
// 检查鼠标是否在canvas-container区域内
const mouseInContainer = event.clientX >= containerRect.left &&
event.clientX <= containerRect.right &&
event.clientY >= containerRect.top &&
event.clientY <= containerRect.bottom
// 只有在容器内时才更新标尺指示器
if (mouseInContainer) {
// 计算鼠标相对于容器的位置
const mouseX = event.clientX - containerRect.left
const mouseY = event.clientY - containerRect.top
// 直接使用鼠标在容器中的位置作为指示器位置
// 水平标尺指示器:鼠标的X坐标减去垂直标尺的宽度
const indicatorX = mouseX - this.rulerSize
// 垂直标尺指示器:鼠标的Y坐标减去水平标尺的高度
const indicatorY = mouseY - this.rulerSize
// 确保指示器不会超出标尺范围
const maxX = containerRect.width - this.rulerSize
const maxY = containerRect.height - this.rulerSize
// 更新指示器位置,限制在有效范围内
this.rulerIndicators.x = Math.max(0, Math.min(indicatorX, maxX))
this.rulerIndicators.y = Math.max(0, Math.min(indicatorY, maxY))
}
// 鼠标位置信息只在容器内时更新
if (mouseInContainer) {
const canvas = document.querySelector('.canvas')
if (canvas) {
const canvasRect = canvas.getBoundingClientRect()
const mouseXInCanvas = (event.clientX - canvasRect.left) / this.canvasScale
const mouseYInCanvas = (event.clientY - canvasRect.top) / this.canvasScale
// 更新鼠标位置信息,允许负数和超出画布范围的坐标
this.mousePosition.x = Math.round(mouseXInCanvas)
this.mousePosition.y = Math.round(mouseYInCanvas)
}
}
}
},
// ==================== 窗口大小变化处理 ====================
/**
* 窗口大小变化时的处理
* 重新初始化画布视图以适应新的窗口尺寸
*/
onWindowResize() {
// 使用防抖处理,避免频繁触发
clearTimeout(this.resizeTimer)
this.resizeTimer = setTimeout(() => {
this.$nextTick(() => {
this.initializeCanvasView()
})
}, 100)
},
// ==================== 参考线相关方法 ====================
/**
* 点击水平标尺创建垂直参考线
* @param {Event} event - 点击事件
*/
onHorizontalRulerClick(event) {
const rect = event.currentTarget.getBoundingClientRect()
const clickX = event.clientX - rect.left
// 将点击位置转换为画布坐标
const canvasPosition = this.rulerPositionToCanvasPosition(clickX, 'vertical')
// 只有在画布范围内才创建参考线
if (canvasPosition >= 0 && canvasPosition <= this.canvasWidth) {
this.createGuideline('vertical', canvasPosition)
}
},
/**
* 点击垂直标尺创建水平参考线
* @param {Event} event - 点击事件
*/
onVerticalRulerClick(event) {
const rect = event.currentTarget.getBoundingClientRect()
const clickY = event.clientY - rect.top
// 将点击位置转换为画布坐标
const canvasPosition = this.rulerPositionToCanvasPosition(clickY, 'horizontal')
// 只有在画布范围内才创建参考线
if (canvasPosition >= 0 && canvasPosition <= this.canvasHeight) {
this.createGuideline('horizontal', canvasPosition)
}
},
/**
* 创建参考线
* @param {string} type - 参考线类型:'vertical' 或 'horizontal'
* @param {number} position - 参考线在画布上的位置
*/
createGuideline(type, position) {
const guideline = {
id: Date.now() + Math.random(),
type,
position: Math.max(0, Math.min(position, type === 'vertical' ? this.canvasWidth : this.canvasHeight))
}
this.guidelines.push(guideline)
},
/**
* 将标尺位置转换为画布坐标
* @param {number} rulerPos - 标尺上的位置
* @param {string} type - 参考线类型
* @returns {number} 画布坐标
*/
rulerPositionToCanvasPosition(rulerPos, type) {
const container = document.querySelector('.canvas-container')
if (!container) return 0
const containerRect = container.getBoundingClientRect()
const scaledCanvasWidth = this.canvasWidth * this.canvasScale
const scaledCanvasHeight = this.canvasHeight * this.canvasScale
if (type === 'vertical') {
const containerCenterX = (containerRect.width - this.rulerSize) / 2
const canvasCenterX = scaledCanvasWidth / 2
const originX = containerCenterX - canvasCenterX + this.canvasOffset.x - this.rulerSize / 2
return (rulerPos - originX) / this.canvasScale
} else {
const containerCenterY = (containerRect.height - this.rulerSize) / 2
const canvasCenterY = scaledCanvasHeight / 2
const originY = containerCenterY - canvasCenterY + this.canvasOffset.y - this.rulerSize / 2
return (rulerPos - originY) / this.canvasScale
}
},
/**
* 获取参考线在标尺上的位置
* @param {Object} guideline - 参考线对象
* @returns {number} 标尺位置
*/
getGuidelineRulerPosition(guideline) {
const container = document.querySelector('.canvas-container')
if (!container) return 0
const containerRect = container.getBoundingClientRect()
const scaledCanvasWidth = this.canvasWidth * this.canvasScale
const scaledCanvasHeight = this.canvasHeight * this.canvasScale
if (guideline.type === 'vertical') {
const containerCenterX = (containerRect.width - this.rulerSize) / 2
const canvasCenterX = scaledCanvasWidth / 2
const originX = containerCenterX - canvasCenterX + this.canvasOffset.x - this.rulerSize / 2
return originX + guideline.position * this.canvasScale
} else {
const containerCenterY = (containerRect.height - this.rulerSize) / 2
const canvasCenterY = scaledCanvasHeight / 2
const originY = containerCenterY - canvasCenterY + this.canvasOffset.y - this.rulerSize / 2
return originY + guideline.position * this.canvasScale
}
},
/**
* 获取参考线样式
* @param {Object} guideline - 参考线对象
* @returns {Object} 样式对象
*/
getGuidelineStyle(guideline) {
if (guideline.type === 'vertical') {
return {
left: guideline.position + 'px',
top: '0px',
width: '1px',
height: '100%'
}
} else {
return {
left: '0px',
top: guideline.position + 'px',
width: '100%',
height: '1px'
}
}
},
/**
* 参考线鼠标按下事件
* @param {Event} event - 鼠标事件
* @param {Object} guideline - 参考线对象
*/
onGuidelineMouseDown(event, guideline) {
if (event.button !== 0) return
this.isDraggingGuideline = true
this.draggedGuideline = guideline
// 计算拖拽偏移量
const canvas = document.querySelector('.canvas')
const canvasRect = canvas.getBoundingClientRect()
if (guideline.type === 'vertical') {
const mouseXInCanvas = (event.clientX - canvasRect.left) / this.canvasScale
this.guidelineOffset = mouseXInCanvas - guideline.position
} else {
const mouseYInCanvas = (event.clientY - canvasRect.top) / this.canvasScale
this.guidelineOffset = mouseYInCanvas - guideline.position
}
event.preventDefault()
event.stopPropagation()
},
/**
* 处理参考线拖拽
* @param {Event} event - 鼠标事件
*/
handleGuidelineDrag(event) {
if (!this.draggedGuideline) return
const canvas = document.querySelector('.canvas')
const canvasRect = canvas.getBoundingClientRect()
if (this.draggedGuideline.type === 'vertical') {
const mouseXInCanvas = (event.clientX - canvasRect.left) / this.canvasScale
let newPosition = mouseXInCanvas - this.guidelineOffset
// 限制在画布范围内
newPosition = Math.max(0, Math.min(newPosition, this.canvasWidth))
this.draggedGuideline.position = newPosition
} else {
const mouseYInCanvas = (event.clientY - canvasRect.top) / this.canvasScale
let newPosition = mouseYInCanvas - this.guidelineOffset
// 限制在画布范围内
newPosition = Math.max(0, Math.min(newPosition, this.canvasHeight))
this.draggedGuideline.position = newPosition
}
},
/**
* 显示参考线右键菜单
* @param {Event} event - 右键事件
* @param {Object} guideline - 参考线对象
*/
showGuidelineContextMenu(event, guideline) {
// 直接删除参考线,简化交互
this.deleteGuideline(guideline)
},
/**
* 删除参考线
* @param {Object} guideline - 要删除的参考线
*/
deleteGuideline(guideline) {
const index = this.guidelines.findIndex(g => g.id === guideline.id)
if (index > -1) {
this.guidelines.splice(index, 1)
}
},
/**
* 清除所有参考线
*/
clearAllGuidelines() {
if (this.guidelines.length > 0) {
this.guidelines = []
}
}
}
}
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
}
.toolbar {
height: 50px;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
display: flex;
align-items: center;
padding: 0 16px;
gap: 12px;
}
.toolbar button {
padding: 6px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.toolbar button:hover:not(:disabled) {
background: #f0f0f0;
}
.toolbar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.canvas-size-controls {
margin-left: auto;
display: flex;
gap: 12px;
}
.canvas-size-controls label {
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.canvas-size-controls input {
width: 80px;
padding: 4px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 12px;
}
.zoom-info {
font-size: 12px;
color: #666;
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #ddd;
margin-left: 16px;
min-width: 60px;
text-align: center;
}
.mouse-position-info {
font-size: 12px;
color: #666;
background: #f5f5f5;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #ddd;
margin-left: 16px;
min-width: 120px;
text-align: center;
font-family: monospace;
}
.main-content {
flex: 1;
display: flex;
overflow: hidden;
}
.component-panel {
width: 250px;
background: #fafafa;
border-right: 1px solid #ddd;
padding: 16px;
overflow-y: auto;
flex-shrink: 0;
}
.component-panel h3 {
margin-bottom: 16px;
font-size: 14px;
color: #333;
}
.component-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.component-item {
cursor: grab;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px;
background: white;
transition: all 0.2s;
}
.component-item:hover {
border-color: #409eff;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.component-item:active {
cursor: grabbing;
}
.component-preview {
font-size: 12px;
text-align: center;
min-height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.canvas-container {
flex: 1;
min-width: 0;
padding: 20px;
background: #f0f0f0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.canvas {
background: white;
background-image:
linear-gradient(rgba(0,0,0,0.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.1) 1px, transparent 1px);
background-size: 20px 20px;
border: 1px solid #ddd;
position: relative;
margin: 0 auto;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
flex-shrink: 0;
}
.canvas-component {
position: absolute;
border: 1px solid transparent;
cursor: move;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
user-select: none;
}
.canvas-component:hover {
border-color: #409eff;
}
.canvas-component.selected {
border-color: #409eff;
box-shadow: 0 0 0 1px #409eff;
}
.resize-handles {
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
pointer-events: none;
}
.resize-handle {
position: absolute;
width: 8px;
height: 8px;
background: #409eff;
border: 1px solid white;
border-radius: 50%;
pointer-events: all;
cursor: pointer;
}
.resize-handle.nw {
top: -4px;
left: -4px;
cursor: nw-resize;
}
.resize-handle.n {
top: -4px;
left: 50%;
transform: translateX(-50%);
cursor: n-resize;
}
.resize-handle.ne {
top: -4px;
right: -4px;
cursor: ne-resize;
}
.resize-handle.e {
top: 50%;
right: -4px;
transform: translateY(-50%);
cursor: e-resize;
}
.resize-handle.se {
bottom: -4px;
right: -4px;
cursor: se-resize;
}
.resize-handle.s {
bottom: -4px;
left: 50%;
transform: translateX(-50%);
cursor: s-resize;
}
.resize-handle.sw {
bottom: -4px;
left: -4px;
cursor: sw-resize;
}
.resize-handle.w {
top: 50%;
left: -4px;
transform: translateY(-50%);
cursor: w-resize;
}
.alignment-line {
position: absolute;
pointer-events: none;
z-index: 9999;
}
.alignment-line.vertical {
width: 1px;
background: #ff4757;
box-shadow: 0 0 2px rgba(255, 71, 87, 0.5);
}
.alignment-line.horizontal {
height: 1px;
background: #ff4757;
box-shadow: 0 0 2px rgba(255, 71, 87, 0.5);
}
.context-menu {
position: fixed;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
z-index: 10000;
min-width: 120px;
}
.context-menu div {
padding: 8px 12px;
cursor: pointer;
font-size: 12px;
border-bottom: 1px solid #f0f0f0;
}
.context-menu div:last-child {
border-bottom: none;
}
.context-menu div:hover {
background: #f5f5f5;
}
/* 标尺相关样式 */
.ruler {
position: absolute;
background: #f5f5f5;
border: 1px solid #ddd;
z-index: 100;
/* pointer-events: none; */
pointer-events: auto;
user-select: none;
overflow: hidden; /* 防止刻度溢出 */
}
.horizontal-ruler {
top: 0;
left: 20px; /* 留出垂直标尺的宽度 */
right: 0;
height: 20px;
}
.vertical-ruler {
top: 20px; /* 留出水平标尺的高度 */
left: 0;
bottom: 0;
width: 20px;
}
.ruler-corner {
position: absolute;
top: 0;
left: 0;
width: 20px;
height: 20px;
background: #f5f5f5;
border: 1px solid #ddd;
z-index: 101;
}
.ruler-tick {
position: absolute;
background: #aaa;
}
.horizontal-ruler .ruler-tick {
width: 1px;
height: 5px;
bottom: 0;
}
.vertical-ruler .ruler-tick {
height: 1px;
width: 5px;
right: 0;
}
.ruler-tick.major-tick {
height: 10px;
width: 1px;
background: #666; /* 主刻度颜色更深 */
}
.vertical-ruler .ruler-tick.major-tick {
height: 1px;
width: 10px;
background: #666; /* 主刻度颜色更深 */
}
.tick-label {
position: absolute;
font-size: 8px;
color: #666;
white-space: nowrap;
}
.horizontal-ruler .tick-label {
top: 2px;
left: 4px;
transform: translateX(-20%);
}
.vertical-ruler .tick-label {
right: 2px;
top: -7px;
transform: translateY(-50%);
}
.ruler-indicator {
position: absolute;
background: #409eff;
z-index: 102;
pointer-events: none; /* 确保指示器不会阻挡鼠标事件 */
}
.horizontal-indicator {
width: 1px;
height: 100%;
top: 0;
box-shadow: 0 0 2px rgba(64, 158, 255, 0.5); /* 添加阴影使指示器更明显 */
}
.vertical-indicator {
width: 100%;
height: 1px;
left: 0;
box-shadow: 0 0 2px rgba(64, 158, 255, 0.5); /* 添加阴影使指示器更明显 */
}
/* 参考线样式 */
.guideline {
position: absolute;
cursor: move;
z-index: 10;
opacity: 0.8;
}
.guideline:hover {
opacity: 1;
}
.guideline.vertical {
width: 1px;
height: 100%;
cursor: ew-resize;
background: linear-gradient(to bottom, #00aaff 50%, transparent 50%);
background-size: 1px 8px;
}
.guideline.vertical:hover {
background: linear-gradient(to bottom, #0088cc 50%, transparent 50%);
background-size: 1px 8px;
}
.guideline.horizontal {
width: 100%;
height: 1px;
cursor: ns-resize;
background: linear-gradient(to right, #00aaff 50%, transparent 50%);
background-size: 8px 1px;
}
.guideline.horizontal:hover {
background: linear-gradient(to right, #0088cc 50%, transparent 50%);
background-size: 8px 1px;
}
/* 参考线位置信息 */
.guideline-label {
position: absolute;
background: rgba(0, 170, 255, 0.9);
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-family: monospace;
white-space: nowrap;
z-index: 20;
pointer-events: auto;
cursor: pointer;
}
.guideline-label:hover {
background: rgba(0, 136, 204, 1);
}
/* 垂直参考线的位置信息 */
.guideline.vertical .guideline-label.start {
top: -20px;
left: 50%;
transform: translateX(-50%);
}
.guideline.vertical .guideline-label.end {
bottom: -20px;
left: 50%;
transform: translateX(-50%);
}
/* 水平参考线的位置信息 */
.guideline.horizontal .guideline-label.start {
left: -40px;
top: 50%;
transform: translateY(-50%);
}
.guideline.horizontal .guideline-label.end {
right: -40px;
top: 50%;
transform: translateY(-50%);
}
/* 标尺上的参考线标记 */
/* .ruler-guideline-mark {
position: absolute;
background: #00aaff;
z-index: 12;
}
.horizontal-ruler .ruler-guideline-mark {
width: 1px;
height: 100%;
top: 0;
}
.vertical-ruler .ruler-guideline-mark {
width: 100%;
height: 1px;
left: 0;
} */
/* 标尺上的参考线标记(新样式) */
.guideline-marker {
position: absolute;
background: #00aaff;
z-index: 12;
pointer-events: none;
}
.vertical-marker {
width: 1px;
height: 100%;
top: 0;
}
.horizontal-marker {
width: 100%;
height: 1px;
left: 0;
}
</style>
功能描述
1. 组件管理
1.1 组件类型定义
- 按钮组件:蓝色背景,白色文字,圆角边框
- 输入框组件:带边框的文本输入区域
- 文本组件:纯文本显示元素
- 容器组件:可包含其他元素的容器
1.2 组件操作
- 拖拽添加:从左侧组件面板拖拽到画布
- 选中操作:点击组件进行选中,显示选中状态
- 删除功能:通过右键菜单或快捷键删除组件
- 边界限制:组件添加和移动时自动限制在画布范围内
2. 画布系统
2.1 画布基础功能
- 可调整尺寸:支持自定义画布宽度(300-2000px)和高度(200-1500px)
- 缩放功能:支持0.1x到3x的缩放比例,鼠标滚轮缩放
- 拖拽平移:按住画布空白区域可拖拽移动视图
- 视图重置:一键重置画布位置和缩放
2.2 坐标系统
- 实时坐标显示:工具栏显示鼠标在画布中的精确位置
- 坐标转换:自动处理缩放和偏移的坐标转换
- 边界检测:鼠标位置信息仅在画布编辑区域内更新
3. 标尺和参考线
3.1 标尺功能
- 双向标尺:水平和垂直标尺,显示精确刻度
- 动态指示器:跟随鼠标移动的蓝色指示线
- 刻度标签:主要刻度点显示数值标签
- 区域限制:指示器仅在画布编辑区域内跟随
3.2 参考线系统
- 点击创建:点击标尺在画布范围内创建参考线
- 拖拽调整:可拖拽参考线调整位置
- 位置显示:参考线两端显示精确位置数值
- 右键删除:右键点击位置信息删除参考线
- 批量清除:一键清除所有参考线
- 标尺标记:在对应标尺上显示参考线位置标记
4. 组件编辑
4.1 拖拽移动
- 精确拖拽:支持像素级精确移动
- 边界约束:移动时自动限制在画布范围内
- 实时预览:拖拽过程中实时显示组件位置
4.2 尺寸调整
- 8点控制:提供8个方向的尺寸调整控制点
- 比例调整:支持等比例和自由调整
- 边界检测:调整时防止超出画布边界
4.3 层级管理
- 置顶/置底:快速调整组件到最顶层或最底层
- 上移/下移:精确调整组件层级关系
- 自动层级:新添加组件自动设置为最高层级
5. 对齐辅助
5.1 智能对齐
- 边缘对齐:组件边缘自动对齐到其他组件
- 中心对齐:支持水平和垂直中心对齐
- 辅助线显示:拖拽时显示红色对齐辅助线
- 磁性吸附:接近对齐位置时自动吸附
6. 历史记录
6.1 操作历史
- 撤销功能:支持多步撤销操作
- 重做功能:支持多步重做操作
- 状态保存:自动保存每次操作状态
- 按钮状态:根据历史状态动态启用/禁用按钮
7. 数据管理
7.1 保存和加载
- 项目保存:将整个项目保存为JSON文件
- 项目加载:从JSON文件加载项目
- 清空画布:一键清除所有组件
- 数据完整性:保证保存和加载的数据完整性
8. 交互体验
8.1 右键菜单
- 组件菜单:右键组件显示操作菜单
- 快捷操作:删除、层级调整等快捷操作
- 上下文相关:根据选中组件显示相应选项
8.2 视觉反馈
- 选中状态:选中组件显示蓝色边框
- 悬停效果:鼠标悬停时的视觉反馈
- 操作提示:各种操作的视觉指示
9. 响应式设计
9.1 窗口适配
- 窗口大小监听:自动适配窗口大小变化
- 布局调整:动态调整界面布局
- 防抖处理:窗口大小变化的防抖优化
10.技术特点
10.1 性能优化
- 事件防抖:窗口大小变化等高频事件的防抖处理
- 按需更新:只在必要时更新相关组件
- 内存管理:及时清理事件监听器防止内存泄漏