feat: 属性面板以及简单撤销重做功能
This commit is contained in:
Vendored
+9
@@ -11,6 +11,15 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElCollapse: typeof import('element-plus/es')['ElCollapse']
|
||||
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
|
||||
ElColorPicker: typeof import('element-plus/es')['ElColorPicker']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { setValue, getValue } from '@/utils'
|
||||
|
||||
const undoStack: any[] = shallowReactive([])
|
||||
const redoStack: any[] = shallowReactive([])
|
||||
|
||||
export function useUndoRedo() {
|
||||
// 是否可以撤销
|
||||
const canUndo = computed(() => undoStack.length > 0)
|
||||
// 是否可以重做
|
||||
const canRedo = computed(() => redoStack.length > 0)
|
||||
// 应用更改
|
||||
function applyChange(target: any, key: string, newValue: any) {
|
||||
const oldValue = getValue(target, key)
|
||||
if (oldValue === newValue) return
|
||||
|
||||
const record = {
|
||||
target,
|
||||
key,
|
||||
oldValue,
|
||||
newValue,
|
||||
}
|
||||
undoStack.push(record)
|
||||
setValue(target, key, newValue)
|
||||
// 清空重做栈
|
||||
redoStack.length = 0
|
||||
}
|
||||
|
||||
// 撤销
|
||||
function undo() {
|
||||
const record = undoStack.pop()
|
||||
if (!record) return
|
||||
|
||||
const { target, key, oldValue } = record
|
||||
|
||||
setValue(target, key, oldValue)
|
||||
redoStack.push(record)
|
||||
}
|
||||
|
||||
// 重做
|
||||
function redo() {
|
||||
const record = redoStack.pop()
|
||||
if (!record) return
|
||||
|
||||
const { target, key, newValue } = record
|
||||
|
||||
setValue(target, key, newValue)
|
||||
undoStack.push(record)
|
||||
}
|
||||
|
||||
return {
|
||||
canUndo,
|
||||
canRedo,
|
||||
applyChange,
|
||||
undo,
|
||||
redo,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
type MoveableLike = {
|
||||
updateRect?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 标尺视口量测、工作区冻结、缩放后同步 Moveable 选框
|
||||
*/
|
||||
export const useCanvasRuler = (
|
||||
moveableRef: { value: MoveableLike | null },
|
||||
canvasRootRef: Ref<HTMLElement | null>,
|
||||
) => {
|
||||
const editorStore = useEditorStore()
|
||||
const { canvas } = storeToRefs(editorStore)
|
||||
|
||||
// 标尺厚度
|
||||
const RULER_THICK = 20
|
||||
// 缩放比例
|
||||
const scale = ref(1)
|
||||
// 标尺线条
|
||||
const lines = ref({ h: [], v: [] })
|
||||
/**
|
||||
* 标尺工作区只取初始设计稿尺寸,之后不再跟着改。
|
||||
* vue3-sketch-ruler 会 watch canvasWidth/Height 并 initPanzoom(销毁重建 + 重新居中),跟手改会抖。
|
||||
*/
|
||||
const rulerCanvasSize = {
|
||||
width: canvas.value.width,
|
||||
height: canvas.value.height,
|
||||
}
|
||||
// 外层视口(含标尺),只给 SketchRuler 用
|
||||
const viewportSize = reactive({ width: 0, height: 0 })
|
||||
// 标尺颜色主题
|
||||
const palette = {
|
||||
bgColor: '#1f2937',
|
||||
longfgColor: '#6b7280',
|
||||
fontColor: '#9ca3af',
|
||||
fontShadowColor: '#0e8da7',
|
||||
shadowColor: 'rgba(14, 141, 167, 0.14)',
|
||||
lineColor: '#22c55e',
|
||||
lineType: 'solid',
|
||||
lockLineColor: '#4b5563',
|
||||
borderColor: '#374151',
|
||||
hoverBg: '#111827',
|
||||
hoverColor: '#ffffff',
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
/** 捕获父级容器实际宽高,作为标尺视口 */
|
||||
const updateCanvasSize = () => {
|
||||
const el = canvasRootRef.value
|
||||
if (!el) return
|
||||
const { width, height } = el.getBoundingClientRect()
|
||||
viewportSize.width = Math.floor(width)
|
||||
viewportSize.height = Math.floor(height)
|
||||
}
|
||||
|
||||
/** SketchRuler 缩放变化时同步 Moveable 选框,防止错位 */
|
||||
const onZoomChange = () => {
|
||||
moveableRef.value?.updateRect?.()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateCanvasSize()
|
||||
if (!canvasRootRef.value) return
|
||||
resizeObserver = new ResizeObserver(updateCanvasSize)
|
||||
resizeObserver.observe(canvasRootRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
|
||||
return {
|
||||
RULER_THICK,
|
||||
scale,
|
||||
lines,
|
||||
rulerCanvasSize,
|
||||
viewportSize,
|
||||
palette,
|
||||
onZoomChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import type { OnDrag, OnResize } from 'vue3-moveable'
|
||||
|
||||
/**
|
||||
* Moveable 单选 / 组拖拽与缩放
|
||||
*/
|
||||
export const useMoveable = () => {
|
||||
const editorStore = useEditorStore()
|
||||
|
||||
/** 从节点 DOM 反查 store 节点 */
|
||||
const getNodeByTarget = (target: HTMLElement) => {
|
||||
const nodeId = target.getAttribute('data-node-id')
|
||||
if (!nodeId) return null
|
||||
return editorStore.findNodeById(nodeId)
|
||||
}
|
||||
|
||||
/** 拖拽节点 */
|
||||
const onDrag = (e: OnDrag) => {
|
||||
console.log('拖拽事件', e)
|
||||
e.target.style.setProperty('left', `${e.left}px`)
|
||||
e.target.style.setProperty('top', `${e.top}px`)
|
||||
const node = getNodeByTarget(e.target as HTMLElement)
|
||||
console.log('拖拽节点', node)
|
||||
if (!node) return
|
||||
node.layout.x = e.left
|
||||
node.layout.y = e.top
|
||||
}
|
||||
|
||||
/** 缩放节点 */
|
||||
const onResize = (e: OnResize) => {
|
||||
e.target.style.setProperty('width', `${e.width}px`)
|
||||
e.target.style.setProperty('height', `${e.height}px`)
|
||||
const node = getNodeByTarget(e.target as HTMLElement)
|
||||
if (!node) return
|
||||
node.layout.width = e.width
|
||||
node.layout.height = e.height
|
||||
// 处理左侧圆点的拖拽,改变节点位置
|
||||
onDrag(e.drag)
|
||||
}
|
||||
|
||||
/** 拖拽节点组 */
|
||||
const onDragGroup = (e: { events: OnDrag[] }) => {
|
||||
e.events.forEach(onDrag)
|
||||
}
|
||||
|
||||
/** 缩放节点组 */
|
||||
const onResizeGroup = (e: { events: OnResize[] }) => {
|
||||
e.events.forEach(onResize)
|
||||
}
|
||||
|
||||
return {
|
||||
onDrag,
|
||||
onResize,
|
||||
onDragGroup,
|
||||
onResizeGroup,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { MaterialSchema } from '@/schema/materials'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
type MoveableLike = {
|
||||
updateTarget?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点点选 / 框选,并同步 Moveable target
|
||||
*/
|
||||
export const useSelection = (
|
||||
stageRef: { value: HTMLElement | null },
|
||||
moveableRef: { value: MoveableLike | null },
|
||||
) => {
|
||||
const editorStore = useEditorStore()
|
||||
const { selectedNodeIds } = storeToRefs(editorStore)
|
||||
|
||||
// 当前选中的节点dom元素
|
||||
const selectedTarget = shallowRef<HTMLElement[] | null>(null)
|
||||
|
||||
watch(
|
||||
selectedNodeIds,
|
||||
(newVal) => {
|
||||
console.log('选中的节点id变化', newVal)
|
||||
selectedTarget.value = newVal
|
||||
.map((id) =>
|
||||
stageRef.value?.querySelector<HTMLElement>(
|
||||
`[data-node-id="${id}"]:not([data-node-locked="true"])`,
|
||||
),
|
||||
)
|
||||
.filter((target): target is HTMLElement => target != null)
|
||||
},
|
||||
{ deep: true, flush: 'post' },
|
||||
)
|
||||
|
||||
/** 点选节点 */
|
||||
const onSelect = (node: MaterialSchema) => {
|
||||
editorStore.selectNode(node.id)
|
||||
|
||||
// 处理第一次选中节点时,moveable组件没有正确获取到target导致无法移动问题
|
||||
nextTick(() => {
|
||||
moveableRef.value?.updateTarget?.()
|
||||
})
|
||||
}
|
||||
|
||||
/** 清空选中节点 */
|
||||
const onClearSelect = () => {
|
||||
editorStore.clearSelect()
|
||||
}
|
||||
|
||||
/** 框选结束 */
|
||||
const onSelectEnd = (e: any) => {
|
||||
const ids = e.selected.map((el: HTMLElement) => el.getAttribute('data-node-id'))
|
||||
editorStore.selectNodes(ids)
|
||||
console.log('选中节点id', ids)
|
||||
}
|
||||
|
||||
return {
|
||||
selectedTarget,
|
||||
onSelect,
|
||||
onClearSelect,
|
||||
onSelectEnd,
|
||||
}
|
||||
}
|
||||
+60
-145
@@ -4,56 +4,40 @@ import { createNode, getMaterialComponent } from '@/materials'
|
||||
import type { MaterialSchema } from '@/schema/materials'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Moveable, { type OnDrag, type OnResize } from 'vue3-moveable'
|
||||
import Moveable from 'vue3-moveable'
|
||||
import { VueSelecto } from 'vue3-selecto'
|
||||
import SketchRuler from 'vue3-sketch-ruler'
|
||||
import 'vue3-sketch-ruler/lib/style.css'
|
||||
import { useCanvasRuler } from './composables/useCanvasRuler'
|
||||
import { useMoveable } from './composables/useMoveable'
|
||||
import { useSelection } from './composables/useSelection'
|
||||
|
||||
defineOptions({
|
||||
name: 'CanvasRoot',
|
||||
})
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const { nodes, selectedNodeIds, canvas } = storeToRefs(editorStore)
|
||||
const { nodes, canvas } = storeToRefs(editorStore)
|
||||
|
||||
const moveableRef = useTemplateRef('moveableRef')
|
||||
const stageRef = useTemplateRef('stageRef')
|
||||
// 标尺厚度
|
||||
const RULER_THICK = 20
|
||||
// 缩放比例
|
||||
const scale = ref(1)
|
||||
// 标尺线条
|
||||
const lines = ref({ h: [], v: [] })
|
||||
const canvasWidth = toRef(canvas.value, 'width')
|
||||
const canvasHeight = toRef(canvas.value, 'height')
|
||||
/** 画布样式*/
|
||||
// canvas容器的ref
|
||||
const canvasRootRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const { RULER_THICK, scale, lines, rulerCanvasSize, viewportSize, palette, onZoomChange } =
|
||||
useCanvasRuler(moveableRef, canvasRootRef)
|
||||
const { onDrag, onResize, onDragGroup, onResizeGroup } = useMoveable()
|
||||
const { selectedTarget, onSelect, onClearSelect, onSelectEnd } = useSelection(stageRef, moveableRef)
|
||||
|
||||
/** 设计稿白底:宽高和背景色立刻跟 store */
|
||||
const canvasStyle = computed(() => {
|
||||
return {
|
||||
width: canvasWidth.value,
|
||||
height: canvasHeight.value,
|
||||
width: canvas.value.width,
|
||||
height: canvas.value.height,
|
||||
backgroundColor: canvas.value.backgroundColor,
|
||||
}
|
||||
})
|
||||
|
||||
// canvas容器的ref
|
||||
const wrapRef = ref<HTMLElement | null>(null)
|
||||
// 外层视口(含标尺),只给 SketchRuler 用
|
||||
const viewportSize = reactive({ width: 0, height: 0 })
|
||||
|
||||
// 当前选中的节点dom元素
|
||||
const selectNodeTarget = shallowRef<HTMLElement[] | null>(null)
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
/** 捕获父级容器实际宽高,作为标尺视口 */
|
||||
const updateCanvasSize = () => {
|
||||
const el = wrapRef.value
|
||||
if (!el) return
|
||||
const { width, height } = el.getBoundingClientRect()
|
||||
viewportSize.width = Math.floor(width)
|
||||
viewportSize.height = Math.floor(height)
|
||||
}
|
||||
|
||||
// 获取节点的样式
|
||||
const getNodeStyle = (node: MaterialSchema, zIndex: number) => {
|
||||
return {
|
||||
@@ -65,32 +49,6 @@ const getNodeStyle = (node: MaterialSchema, zIndex: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 标尺颜色主题
|
||||
const palette = {
|
||||
bgColor: '#1f2937',
|
||||
longfgColor: '#6b7280',
|
||||
fontColor: '#9ca3af',
|
||||
fontShadowColor: '#0e8da7',
|
||||
shadowColor: 'rgba(14, 141, 167, 0.14)',
|
||||
lineColor: '#22c55e',
|
||||
lineType: 'solid',
|
||||
lockLineColor: '#4b5563',
|
||||
borderColor: '#374151',
|
||||
hoverBg: '#111827',
|
||||
hoverColor: '#ffffff',
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedNodeIds,
|
||||
(newVal) => {
|
||||
console.log('选中的节点id变化', newVal)
|
||||
selectNodeTarget.value = newVal
|
||||
.map((id) => stageRef.value?.querySelector<HTMLElement>(`[data-node-id="${id}"]`))
|
||||
.filter((target): target is HTMLElement => target != null)
|
||||
},
|
||||
{ deep: true, flush: 'post' },
|
||||
)
|
||||
|
||||
// 拖拽放置节点
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const data = e.dataTransfer?.getData('schema')
|
||||
@@ -104,97 +62,36 @@ const onDrop = (e: DragEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 选中节点
|
||||
const onSelect = (node: MaterialSchema) => {
|
||||
editorStore.selectNode(node.id)
|
||||
|
||||
// 处理第一次选中节点时,moveable组件没有正确获取到target导致无法移动问题
|
||||
nextTick(() => {
|
||||
moveableRef.value?.updateTarget()
|
||||
})
|
||||
// 右键菜单操作映射
|
||||
const commendMap = {
|
||||
copy: () => editorStore.selectedNode && editorStore.copyNode(editorStore.selectedNode),
|
||||
remove: () => editorStore.selectedNode && editorStore.removeNode(editorStore.selectedNode),
|
||||
moveBottom: () => editorStore.selectedNode && editorStore.moveTop(editorStore.selectedNode), // 列表渲染时反转了,这里也反转操作
|
||||
moveTop: () => editorStore.selectedNode && editorStore.moveBottom(editorStore.selectedNode),
|
||||
toggleLock: () => {
|
||||
if (editorStore.selectedNode) {
|
||||
editorStore.toggleLock(editorStore.selectedNode)
|
||||
}
|
||||
selectedTarget.value = []
|
||||
},
|
||||
}
|
||||
|
||||
const getNodeByTarget = (target: HTMLElement) => {
|
||||
const nodeId = target.getAttribute('data-node-id')
|
||||
if (!nodeId) return null
|
||||
return editorStore.findNodeById(nodeId)
|
||||
// 右键菜单操作
|
||||
const onCommand = (command: keyof typeof commendMap) => {
|
||||
commendMap[command]?.()
|
||||
}
|
||||
|
||||
// 拖拽节点
|
||||
const onDrag = (e: OnDrag) => {
|
||||
console.log('拖拽事件', e)
|
||||
e.target.style.setProperty('left', `${e.left}px`)
|
||||
e.target.style.setProperty('top', `${e.top}px`)
|
||||
const node = getNodeByTarget(e.target as HTMLElement)
|
||||
console.log('拖拽节点', node)
|
||||
if (!node) return
|
||||
node.layout.x = e.left
|
||||
node.layout.y = e.top
|
||||
}
|
||||
|
||||
// 缩放节点
|
||||
const onResize = (e: OnResize) => {
|
||||
e.target.style.setProperty('width', `${e.width}px`)
|
||||
e.target.style.setProperty('height', `${e.height}px`)
|
||||
const node = getNodeByTarget(e.target as HTMLElement)
|
||||
if (!node) return
|
||||
node.layout.width = e.width
|
||||
node.layout.height = e.height
|
||||
// 处理左侧圆点的拖拽,改变节点位置
|
||||
onDrag(e.drag)
|
||||
}
|
||||
|
||||
// 清空选中节点
|
||||
const onClearSelect = () => {
|
||||
editorStore.clearSelect()
|
||||
}
|
||||
|
||||
// 选中区域结束
|
||||
const onSelectEnd = (e: any) => {
|
||||
const ids = e.selected.map((el: HTMLElement) => el.getAttribute('data-node-id'))
|
||||
editorStore.selectNodes(ids)
|
||||
console.log('选中节点id', ids)
|
||||
}
|
||||
|
||||
// 拖拽节点组
|
||||
const onDragGroup = (e: any) => {
|
||||
e.events.forEach(onDrag)
|
||||
}
|
||||
|
||||
// 拖动节点组
|
||||
const onResizeGroup = (e: any) => {
|
||||
e.events.forEach(onResize)
|
||||
}
|
||||
|
||||
// SketchRuler缩放变化
|
||||
const onZoomChange = () => {
|
||||
// 更新moveableRef的选框,防止缩放时选框位置错位
|
||||
moveableRef.value?.updateRect()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
updateCanvasSize()
|
||||
if (!wrapRef.value) return
|
||||
resizeObserver = new ResizeObserver(updateCanvasSize)
|
||||
resizeObserver.observe(wrapRef.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapRef" class="h-full w-full min-h-0 min-w-0 overflow-hidden">
|
||||
<div ref="canvasRootRef" class="h-full w-full min-h-0 min-w-0 overflow-hidden">
|
||||
<SketchRuler
|
||||
v-if="viewportSize.width && viewportSize.height"
|
||||
:palette="palette"
|
||||
:thick="RULER_THICK"
|
||||
:width="viewportSize.width"
|
||||
:height="viewportSize.height"
|
||||
:canvas-width="canvasStyle.width"
|
||||
:canvas-height="canvasStyle.height"
|
||||
:canvas-width="rulerCanvasSize.width"
|
||||
:canvas-height="rulerCanvasSize.height"
|
||||
:scale="scale"
|
||||
:lines="lines"
|
||||
@zoomchange="onZoomChange"
|
||||
@@ -211,16 +108,34 @@ onBeforeUnmount(() => {
|
||||
@drop="onDrop"
|
||||
@mousedown.self="onClearSelect"
|
||||
>
|
||||
<div
|
||||
<el-dropdown
|
||||
v-for="(node, index) in nodes"
|
||||
:key="node.id"
|
||||
:data-node-id="node.id"
|
||||
class="canvas-node absolute"
|
||||
:style="getNodeStyle(node, index)"
|
||||
@mousedown="onSelect(node)"
|
||||
trigger="contextmenu"
|
||||
@command="onCommand"
|
||||
>
|
||||
<component :is="getMaterialComponent(node.type)" :schema="node" />
|
||||
</div>
|
||||
<div
|
||||
:data-node-id="node.id"
|
||||
:data-node-locked="node.locked"
|
||||
class="canvas-node absolute"
|
||||
:style="getNodeStyle(node, index)"
|
||||
@mousedown="onSelect(node)"
|
||||
>
|
||||
<component :is="getMaterialComponent(node.type)" :schema="node" />
|
||||
</div>
|
||||
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="copy">复制</el-dropdown-item>
|
||||
<el-dropdown-item command="remove">移除</el-dropdown-item>
|
||||
<el-dropdown-item command="moveTop">置顶</el-dropdown-item>
|
||||
<el-dropdown-item command="moveBottom">置底</el-dropdown-item>
|
||||
<el-dropdown-item command="toggleLock">
|
||||
{{ node.locked ? '解锁' : '锁定' }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</SketchRuler>
|
||||
|
||||
@@ -235,7 +150,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<Moveable
|
||||
ref="moveableRef"
|
||||
:target="selectNodeTarget"
|
||||
:target="selectedTarget"
|
||||
:draggable="true"
|
||||
:resizable="true"
|
||||
:origin="false"
|
||||
|
||||
@@ -5,6 +5,7 @@ import ToolbarRight from '@/editor/toolbar/ToolbarRight.vue'
|
||||
import MaterialPanel from '@/editor/panels/material/index.vue'
|
||||
import LayerPanel from '@/editor/panels/layer/index.vue'
|
||||
import CanvasRoot from '@/editor/canvas/index.vue'
|
||||
import PropertyPanel from '@/editor/panels/property/index.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
@@ -43,12 +44,12 @@ const layerWidth = computed(() => (panelVisible.value.layer ? '260px' : '0'))
|
||||
<!-- 画布 -->
|
||||
<CanvasRoot class="canvas flex-1">画布</CanvasRoot>
|
||||
<!-- 属性 -->
|
||||
<aside
|
||||
<PropertyPanel
|
||||
class="w-[256px] border-l border-white/20 overflow-hidden transition-all"
|
||||
:style="{ width: layerWidth }"
|
||||
>
|
||||
属性
|
||||
</aside>
|
||||
</PropertyPanel>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
defineOptions({
|
||||
name: 'CanvasProperty',
|
||||
})
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const { canvas } = storeToRefs(editorStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-form class="p-5" size="small" label-width="60px">
|
||||
<el-form-item label="宽度">
|
||||
<el-input-number v-model="canvas.width"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="高度">
|
||||
<el-input-number v-model="canvas.height"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item label="背景色">
|
||||
<el-color-picker v-model="canvas.backgroundColor"></el-color-picker>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { getValue } from '@/utils'
|
||||
import { ElInput, ElInputNumber, ElColorPicker } from 'element-plus'
|
||||
import { useUndoRedo } from '@/composables/useUndoRedo'
|
||||
|
||||
defineProps(['setters', 'formData'])
|
||||
|
||||
const { applyChange } = useUndoRedo()
|
||||
|
||||
const componentMap: Record<string, any> = {
|
||||
input: ElInput,
|
||||
number: (props: Record<string, any>) => h(ElInputNumber, { precision: 0, ...props }),
|
||||
color: ElColorPicker,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<el-form class="p-5" size="small" label-width="60px">
|
||||
<el-form-item v-for="setter in setters" :key="setter.key" :label="setter.label">
|
||||
<component
|
||||
:is="componentMap[setter.type]"
|
||||
:model-value="getValue(formData, setter.key)"
|
||||
@update:model-value="(value: any) => applyChange(formData, setter.key, value)"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { getMaterialSetters } from '@/materials'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import FormCreate from './FormCreate.vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'NodeProperty',
|
||||
})
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const { selectedNode } = storeToRefs(editorStore)
|
||||
|
||||
const setters = getMaterialSetters(selectedNode.value?.type || '') ?? []
|
||||
|
||||
// 布局属性
|
||||
const layoutSetters = [
|
||||
{
|
||||
label: '宽度',
|
||||
key: 'layout.width',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
label: '高度',
|
||||
key: 'layout.height',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
label: 'X',
|
||||
key: 'layout.x',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
label: 'Y',
|
||||
key: 'layout.y',
|
||||
type: 'number',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-2">
|
||||
<el-collapse>
|
||||
<el-collapse-item title="布局属性">
|
||||
<FormCreate :setters="layoutSetters" :form-data="selectedNode" />
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="组件属性">
|
||||
<FormCreate :setters="setters" :form-data="selectedNode" />
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import CanvasProperty from './components/CanvasProperty.vue'
|
||||
import NodeProperty from './components/NodeProperty.vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
defineOptions({
|
||||
name: 'PropertyPanel',
|
||||
})
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const { selectedNode } = storeToRefs(editorStore)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<NodeProperty v-if="selectedNode" />
|
||||
<CanvasProperty v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -3,15 +3,17 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUndoRedo } from '@/composables/useUndoRedo'
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const { panelVisible } = storeToRefs(editorStore)
|
||||
const { undo, redo, canUndo, canRedo } = useUndoRedo()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-5">
|
||||
<span
|
||||
class="border p-1 rounded-lg"
|
||||
class="border p-1 rounded-lg cursor-pointer"
|
||||
:class="{ 'bg-white/20': panelVisible.material }"
|
||||
@click="panelVisible.material = !panelVisible.material"
|
||||
>
|
||||
@@ -31,9 +33,21 @@ const { panelVisible } = storeToRefs(editorStore)
|
||||
>
|
||||
<Icon icon="mynaui:layers-three-solid"></Icon>
|
||||
</span>
|
||||
<span class="border p-1 rounded-lg"><Icon icon="mynaui:undo-solid"></Icon></span>
|
||||
<span class="border p-1 rounded-lg"><Icon icon="mynaui:redo-solid"></Icon></span>
|
||||
<!-- 撤销 -->
|
||||
<span
|
||||
class="border p-1 rounded-lg cursor-pointer"
|
||||
@click="undo"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !canUndo }"
|
||||
>
|
||||
<Icon icon="mynaui:undo-solid"> </Icon>
|
||||
</span>
|
||||
<!-- 重做 -->
|
||||
<span
|
||||
class="border p-1 rounded-lg cursor-pointer"
|
||||
@click="redo"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': !canRedo }"
|
||||
>
|
||||
<Icon icon="mynaui:redo-solid"></Icon>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -4,6 +4,7 @@ export const chartsMaterial: MaterialDefinition = {
|
||||
name: '柱状图',
|
||||
icon: '',
|
||||
group: 'charts',
|
||||
setters: [],
|
||||
schema: {
|
||||
type: 'chart',
|
||||
name: '柱状图',
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type { MaterialDefinition, MaterialSchema } from '@/schema/materials'
|
||||
import type { MaterialDefinition, MaterialSchema, SetterSchema } from '@/schema/materials'
|
||||
|
||||
// 物料列表
|
||||
const materials: MaterialDefinition[] = []
|
||||
|
||||
// 已注册的物料列表
|
||||
const componentMap = new Map<string, Component>()
|
||||
|
||||
const settersMap = new Map<string, SetterSchema[]>()
|
||||
export function register(material: MaterialDefinition, component: Component) {
|
||||
materials.push(material)
|
||||
componentMap.set(material.schema.type, component)
|
||||
settersMap.set(material.schema.type, material.setters)
|
||||
}
|
||||
|
||||
const materialModules = import.meta.glob('./**/index.ts', { eager: true })
|
||||
@@ -47,6 +50,10 @@ export function getMaterialComponent(type: string): Component | undefined {
|
||||
return componentMap.get(type)
|
||||
}
|
||||
|
||||
export function getMaterialSetters(type: string): SetterSchema[] | undefined {
|
||||
return settersMap.get(type)
|
||||
}
|
||||
|
||||
// 创建一个新的节点对象,生成唯一的 id
|
||||
export function createNode(node: MaterialSchema) {
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
import type { MaterialDefinition } from '../types'
|
||||
import type { MaterialDefinition } from '@/schema/materials.ts'
|
||||
import TextMaterial from './component.vue'
|
||||
|
||||
const textMaterial: MaterialDefinition = {
|
||||
name: '文本',
|
||||
icon: '',
|
||||
group: 'info',
|
||||
setters: [
|
||||
{
|
||||
type: 'input',
|
||||
label: '内容',
|
||||
key: 'props.content',
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
label: '颜色',
|
||||
key: 'style.color',
|
||||
},
|
||||
],
|
||||
schema: {
|
||||
type: 'text',
|
||||
name: '文本',
|
||||
|
||||
@@ -10,15 +10,24 @@ export interface MaterialSchema {
|
||||
type: string
|
||||
name: string
|
||||
id: string
|
||||
locked?: boolean
|
||||
layout: Layout
|
||||
style?: Record<string, any>
|
||||
props: Record<string, any>
|
||||
}
|
||||
|
||||
export interface SetterSchema {
|
||||
type: string
|
||||
label: string
|
||||
key: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// 物料定义结构
|
||||
export interface MaterialDefinition {
|
||||
name: string
|
||||
icon: string
|
||||
group: string
|
||||
setters: SetterSchema[]
|
||||
schema: Omit<MaterialSchema, 'id'>
|
||||
}
|
||||
|
||||
@@ -59,6 +59,42 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
selectedNodeIds.value = []
|
||||
}
|
||||
|
||||
/* 复制节点 */
|
||||
function copyNode(node: MaterialSchema) {
|
||||
const newNode = JSON.parse(JSON.stringify(node)) as MaterialSchema
|
||||
newNode.id = crypto.randomUUID()
|
||||
// 偏移
|
||||
newNode.layout.x += 20
|
||||
newNode.layout.y += 20
|
||||
addNode(newNode)
|
||||
selectNode(newNode.id)
|
||||
}
|
||||
|
||||
// 删除节点
|
||||
function removeNode(node: MaterialSchema) {
|
||||
nodes.value = nodes.value.filter((n) => n.id !== node.id)
|
||||
selectedNodeIds.value = selectedNodeIds.value.filter((id) => id !== node.id)
|
||||
}
|
||||
|
||||
// 置顶节点
|
||||
function moveTop(node: MaterialSchema) {
|
||||
const index = nodes.value.findIndex((n) => n.id === node.id)
|
||||
nodes.value.splice(index, 1)
|
||||
nodes.value.unshift(node)
|
||||
}
|
||||
|
||||
// 置顶节点
|
||||
function moveBottom(node: MaterialSchema) {
|
||||
const index = nodes.value.findIndex((n) => n.id === node.id)
|
||||
nodes.value.splice(index, 1)
|
||||
nodes.value.push(node)
|
||||
}
|
||||
|
||||
// 锁定节点
|
||||
function toggleLock(node: MaterialSchema) {
|
||||
node.locked = !node.locked
|
||||
}
|
||||
|
||||
return {
|
||||
panelVisible,
|
||||
page,
|
||||
@@ -72,5 +108,10 @@ export const useEditorStore = defineStore('editor', () => {
|
||||
clearSelect,
|
||||
selectNodes,
|
||||
findNodeById,
|
||||
copyNode,
|
||||
removeNode,
|
||||
moveTop,
|
||||
moveBottom,
|
||||
toggleLock,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// 获取对象中指定路径的值
|
||||
export function getValue(target: any, key: string) {
|
||||
// 将 'props.content' 转换为 ['props', 'content']
|
||||
const keys = key.split('.')
|
||||
while (keys.length) {
|
||||
target = target[keys.shift()!]
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
// 设置对象中指定路径的值
|
||||
export function setValue(target: any, key: string, value: any) {
|
||||
const keys = key.split('.')
|
||||
const lastKey = keys.pop()!
|
||||
const _target = getValue(target, keys.join('.'))
|
||||
_target[lastKey] = value
|
||||
}
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"env.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"auto-imports.d.ts"
|
||||
"auto-imports.d.ts",
|
||||
"components.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/*"
|
||||
|
||||
Reference in New Issue
Block a user