feat: 完成画布拖拽交互开发

This commit is contained in:
2026-08-15 18:00:49 +08:00
commit a043fd9d8e
38 changed files with 5206 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+39
View File
@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+9
View File
@@ -0,0 +1,9 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
+48
View File
@@ -0,0 +1,48 @@
# my-v0
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
pnpm install
```
### Compile and Hot-Reload for Development
```sh
pnpm dev
```
### Type-Check, Compile and Minify for Production
```sh
pnpm build
```
### Lint with [ESLint](https://eslint.org/)
```sh
pnpm lint
```
+75
View File
@@ -0,0 +1,75 @@
/* eslint-disable */
/* prettier-ignore */
/* oxlint-disable */
/* oxfmt-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const effectScope: typeof import('vue').effectScope
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useModel: typeof import('vue').useModel
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+17
View File
@@ -0,0 +1,17 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
Vendored
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+34
View File
@@ -0,0 +1,34 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
{
name: 'custom/lint',
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
},
},
)
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
{
"name": "my-v0",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s \"lint:*\"",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@tailwindcss/postcss": "^4.3.3",
"element-plus": "^2.14.4",
"pinia": "^4.0.2",
"postcss": "^8.5.26",
"tailwindcss": "^4.3.3",
"vue": "^3.5.40",
"vue-draggable-plus": "^0.6.1",
"vue-router": "^5.2.0",
"vue3-moveable": "^0.28.0",
"vue3-selecto": "^1.12.3",
"vue3-sketch-ruler": "2.4.0"
},
"devDependencies": {
"@iconify/vue": "^5.0.1",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.73.0",
"eslint-plugin-vue": "~10.9.2",
"jiti": "^2.7.0",
"npm-run-all2": "^9.0.2",
"oxlint": "~1.74.0",
"prettier": "3.9.5",
"typescript": "~6.0.0",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vue-eslint-parser": "^10.4.1",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
+4046
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import ScreenEditor from '@/editor/index.vue'
</script>
<template>
<div><ScreenEditor /></div>
</template>
<style scoped></style>
+250
View File
@@ -0,0 +1,250 @@
<!-- 主画布 -->
<script setup lang="ts">
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 { VueSelecto } from 'vue3-selecto'
import SketchRuler from 'vue3-sketch-ruler'
import 'vue3-sketch-ruler/lib/style.css'
defineOptions({
name: 'CanvasRoot',
})
const editorStore = useEditorStore()
const { nodes, selectedNodeIds, 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')
/** 画布样式*/
const canvasStyle = computed(() => {
return {
width: canvasWidth.value,
height: canvasHeight.value,
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 {
width: `${node.layout.width}px`,
height: `${node.layout.height}px`,
left: `${node.layout.x}px`,
top: `${node.layout.y}px`,
zIndex: zIndex + 1,
}
}
// 标尺颜色主题
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')
if (data) {
const node = createNode(JSON.parse(data))
node.layout.x = e.offsetX - node.layout.width / 2
node.layout.y = e.offsetY - node.layout.height / 2
editorStore.addNode(node)
editorStore.selectNode(node.id)
}
}
// 选中节点
const onSelect = (node: MaterialSchema) => {
editorStore.selectNode(node.id)
// 处理第一次选中节点时,moveable组件没有正确获取到target导致无法移动问题
nextTick(() => {
moveableRef.value?.updateTarget()
})
}
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 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">
<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"
:scale="scale"
:lines="lines"
@zoomchange="onZoomChange"
>
<div
ref="stageRef"
class="relative"
:style="{
width: `${canvasStyle.width}px`,
height: `${canvasStyle.height}px`,
backgroundColor: canvasStyle.backgroundColor,
}"
@dragover.prevent
@drop="onDrop"
@mousedown.self="onClearSelect"
>
<div
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)"
>
<component :is="getMaterialComponent(node.type)" :schema="node" />
</div>
</div>
</SketchRuler>
<VueSelecto
v-if="stageRef"
:container="stageRef"
:dragContainer="stageRef"
:select-from-inside="false"
:selectableTargets="['.canvas-node']"
toggle-continue-select="shift"
@selectEnd="onSelectEnd"
/>
<Moveable
ref="moveableRef"
:target="selectNodeTarget"
:draggable="true"
:resizable="true"
:origin="false"
@drag="onDrag"
@drag-group="onDragGroup"
@resize="onResize"
@resize-group="onResizeGroup"
/>
</div>
</template>
<style scoped></style>
+56
View File
@@ -0,0 +1,56 @@
<!-- 编辑器主入口 -->
<script setup lang="ts">
import ToolbarLeft from '@/editor/toolbar/ToolbarLeft.vue'
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 { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
defineOptions({
name: 'ScreenEditor',
})
const editorStore = useEditorStore()
const { panelVisible } = storeToRefs(editorStore)
const materialWidth = computed(() => (panelVisible.value.material ? '260px' : '0'))
const propertyWidth = computed(() => (panelVisible.value.property ? '160px' : '0'))
const layerWidth = computed(() => (panelVisible.value.layer ? '260px' : '0'))
</script>
<template>
<div class="h-screen select-none">
<header class="flex items-center justify-between h-14 border-b border-white/20 px-5">
<div class="w-75"><ToolbarLeft /></div>
<div class="">2</div>
<div class="w-75"><ToolbarRight /></div>
</header>
<main class="h-[calc(100%-56px)] flex">
<!-- 物料 -->
<MaterialPanel
class="w-[256px] overflow-hidden transition-all"
:style="{ width: materialWidth }"
>
</MaterialPanel>
<!-- 图层 -->
<LayerPanel
class="w-38 border-l border-r border-white/20 overflow-hidden transition-all"
:style="{ width: propertyWidth }"
>
图层
</LayerPanel>
<!-- 画布 -->
<CanvasRoot class="canvas flex-1">画布</CanvasRoot>
<!-- 属性 -->
<aside
class="w-[256px] border-l border-white/20 overflow-hidden transition-all"
:style="{ width: layerWidth }"
>
属性
</aside>
</main>
</div>
</template>
<style scoped></style>
+40
View File
@@ -0,0 +1,40 @@
<!-- 图层面板 -->
<script setup lang="ts">
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
import { useDraggable } from 'vue-draggable-plus'
defineOptions({
name: 'LayerPanel',
})
const editorStore = useEditorStore()
const { nodes, selectedNodeIds } = storeToRefs(editorStore)
useDraggable('.layer-panel', nodes, { animation: 150 })
// 节点列表
const nodeList = computed(() => [...nodes.value].reverse())
</script>
<template>
<div class="h-full">
<div class="h-full layer-panel overflow-auto flex flex-col gap-2 p-2.5">
<div
v-for="node in nodeList"
:key="node.id"
class="flex items-center justify-between p-1 border border-white/20"
:class="{
'bg-white/20': selectedNodeIds.includes(node.id),
}"
@click="editorStore.selectNode(node.id)"
>
<span>{{ node.name }}</span>
<span><Icon /></span>
</div>
</div>
</div>
</template>
<style scoped></style>
@@ -0,0 +1,25 @@
<!-- 物料项 -->
<script setup lang="ts">
import type { MaterialDefinition } from '@/schema/materials'
const props = defineProps<{
material: MaterialDefinition
}>()
function onStart(event: DragEvent) {
event.dataTransfer?.setData('schema', JSON.stringify(props.material.schema))
}
</script>
<template>
<div
class="h-30 p-2.5 flex flex-col border border-white/20"
draggable="true"
@dragstart="onStart"
>
<div>{{ props.material.name }}</div>
<div class="border border-white/20 h-full"><Icon :icon="props.material.icon" /></div>
</div>
</template>
<style scoped></style>
+44
View File
@@ -0,0 +1,44 @@
<!-- 物料面板 -->
<script setup lang="ts">
import MaterialItem from '@/editor/panels/material/components/MaterialItem.vue'
import { getMaterialGroups, getMaterialsByGroup } from '@/materials'
defineOptions({
name: 'MaterialPanel',
})
const activeGroup = ref('info')
const groups = getMaterialGroups()
const currentMaterials = computed(() => {
return getMaterialsByGroup(activeGroup.value)
})
</script>
<template>
<div class="flex">
<div class="w-12 border-r border-white/20">
<div
v-for="value in groups"
:key="value.key"
class="cursor-pointer flex flex-col items-center justify-center text-sm h-12"
:class="{ 'bg-white/10 text-white': activeGroup === value.key }"
@click="activeGroup = value.key"
>
<span><Icon :icon="value.icon" width="16" /></span>
<span>{{ value.name }}</span>
</div>
</div>
<div class="flex-1 p-2.5 overflow-auto">
<MaterialItem
class="mt-2.5"
v-for="item in currentMaterials"
:key="item.name"
:material="item"
/>
</div>
</div>
</template>
<style scoped></style>
+39
View File
@@ -0,0 +1,39 @@
<!-- 左侧工具栏 -->
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useEditorStore } from '@/stores/editor'
import { storeToRefs } from 'pinia'
const editorStore = useEditorStore()
const { panelVisible } = storeToRefs(editorStore)
</script>
<template>
<div class="flex items-center gap-5">
<span
class="border p-1 rounded-lg"
:class="{ 'bg-white/20': panelVisible.material }"
@click="panelVisible.material = !panelVisible.material"
>
<Icon icon="mynaui:panel-left"></Icon>
</span>
<span
class="border p-1 rounded-lg"
:class="{ 'bg-white/20': panelVisible.property }"
@click="panelVisible.property = !panelVisible.property"
>
<Icon icon="mynaui:panel-right"></Icon>
</span>
<span
class="border p-1 rounded-lg"
:class="{ 'bg-white/20': panelVisible.layer }"
@click="panelVisible.layer = !panelVisible.layer"
>
<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>
</div>
</template>
<style scoped></style>
+16
View File
@@ -0,0 +1,16 @@
<!-- 右侧工具栏 -->
<script setup lang="ts">
import { Icon } from '@iconify/vue'
</script>
<template>
<div class="flex items-center justify-end gap-5">
<span class="border p-1 rounded-lg"><Icon icon="icon-park-outline:preview-open"></Icon></span>
<span class="border p-1 rounded-lg"><Icon icon="si:json-line"></Icon></span>
<span class="border p-1 rounded-lg"><Icon icon="hugeicons:internet"></Icon></span>
<span class="border p-1 rounded-lg"><Icon icon="lets-icons:import"></Icon></span>
<span class="border p-1 rounded-lg"><Icon icon="lets-icons:export"></Icon></span>
</div>
</template>
<style scoped></style>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { Icon } from '@iconify/vue'
import App from './App.vue'
import router from './router'
import './styles/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.component('Icon', Icon)
app.mount('#app')
+25
View File
@@ -0,0 +1,25 @@
import type { MaterialDefinition } from '@/schema/materials'
export const chartsMaterial: MaterialDefinition = {
name: '柱状图',
icon: '',
group: 'charts',
schema: {
type: 'chart',
name: '柱状图',
layout: {
x: 0,
y: 0,
width: 400,
height: 300,
},
props: {
data: [],
},
},
}
export function install(register: (material: MaterialDefinition) => void) {
// 注册柱状图物料
register(chartsMaterial)
}
+56
View File
@@ -0,0 +1,56 @@
import type { MaterialDefinition, MaterialSchema } from '@/schema/materials'
const materials: MaterialDefinition[] = []
const componentMap = new Map<string, Component>()
export function register(material: MaterialDefinition, component: Component) {
materials.push(material)
componentMap.set(material.schema.type, component)
}
const materialModules = import.meta.glob('./**/index.ts', { eager: true })
// 调用每个物料的 install 函数
Object.values(materialModules).forEach((module) => {
console.log('Registering material module:', module)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
module.install(register)
})
const groups = [
{
name: '图表',
key: 'charts',
icon: 'solar:chart-bold',
},
{
name: '信息',
key: 'info',
icon: 'solar:info-circle-bold',
},
]
// 获取指定分组的素材
export function getMaterialsByGroup(groupKey: string) {
return materials.filter((material) => material.group === groupKey)
}
// 获取所有素材分组
export function getMaterialGroups() {
return groups
}
// 根据物料类型获取对应的组件
export function getMaterialComponent(type: string): Component | undefined {
return componentMap.get(type)
}
// 创建一个新的节点对象,生成唯一的 id
export function createNode(node: MaterialSchema) {
return {
...node,
id: crypto.randomUUID(),
}
}
+17
View File
@@ -0,0 +1,17 @@
<!-- 文本物料 -->
<script setup lang="ts">
import type { MaterialSchema } from '@/schema/materials'
defineOptions({
name: 'TextMaterial',
})
defineProps<{
schema: MaterialSchema
}>()
</script>
<template>
<div :style="schema.style">{{ schema.props.content }}</div>
</template>
<style scoped></style>
+29
View File
@@ -0,0 +1,29 @@
import type { MaterialDefinition } from '../types'
import TextMaterial from './component.vue'
const textMaterial: MaterialDefinition = {
name: '文本',
icon: '',
group: 'info',
schema: {
type: 'text',
name: '文本',
layout: {
x: 0,
y: 0,
width: 200,
height: 50,
},
style: {
color: 'red',
},
props: {
content: '文本内容123123',
},
},
}
export function install(register: (material: MaterialDefinition, component: Component) => void) {
// 注册文本物料
register(textMaterial, TextMaterial)
}
+8
View File
@@ -0,0 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [],
})
export default router
+24
View File
@@ -0,0 +1,24 @@
interface Layout {
x: number
y: number
width: number
height: number
}
// 物料数据结构
export interface MaterialSchema {
type: string
name: string
id: string
layout: Layout
style?: Record<string, any>
props: Record<string, any>
}
// 物料定义结构
export interface MaterialDefinition {
name: string
icon: string
group: string
schema: Omit<MaterialSchema, 'id'>
}
+14
View File
@@ -0,0 +1,14 @@
import type { MaterialSchema } from '@/schema/materials'
// 画布数据结构
interface CanvasSchema {
width: number
height: number
backgroundColor: string
}
// 页面数据结构
export interface PageSchema {
canvas: CanvasSchema
nodes: MaterialSchema[]
}
+76
View File
@@ -0,0 +1,76 @@
import type { MaterialSchema } from '@/schema/materials'
import type { PageSchema } from '@/schema/page'
import { defineStore } from 'pinia'
export const useEditorStore = defineStore('editor', () => {
// 面板显示状态
const panelVisible = reactive({
material: true,
layer: true,
property: true,
})
// 页面数据
const page = ref<PageSchema>({
canvas: {
width: 1920,
height: 1080,
backgroundColor: '#ffffff10',
},
nodes: [],
})
const canvas = toRef(page.value, 'canvas')
// 画布上的节点数据
const nodes = toRef(page.value, 'nodes')
// 当前选中的节点id
const selectedNodeIds = ref<string[]>([])
const selectedNodeId = computed(() => {
return selectedNodeIds.value.length === 1 ? selectedNodeIds.value[0] : null
})
// 当前选中的节点
const selectedNode = computed(() => {
return nodes.value.find((node) => node.id === selectedNodeId.value) || null
})
/* 添加节点 */
function addNode(node: MaterialSchema) {
nodes.value.push(node)
}
/* 选中节点 */
function selectNode(nodeId: string) {
selectedNodeIds.value = [nodeId]
}
// 选中多个节点
function selectNodes(nodeIds: string[]) {
selectedNodeIds.value = nodeIds
}
// 根据节点id查找节点
const findNodeById = (nodeId: string) => {
return nodes.value.find((node) => node.id === nodeId) || null
}
/* 清空选中节点 */
function clearSelect() {
selectedNodeIds.value = []
}
return {
panelVisible,
page,
canvas,
nodes,
selectedNodeId,
selectedNode,
selectedNodeIds,
addNode,
selectNode,
clearSelect,
selectNodes,
findNodeById,
}
})
+1
View File
@@ -0,0 +1 @@
@import 'tailwindcss';
+25
View File
@@ -0,0 +1,25 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue",
"auto-imports.d.ts"
],
"exclude": [
"src/**/__tests__/*"
],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": [
"./src/*"
]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
AutoImport({
imports: ['vue'],
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})