feat: 完成画布拖拽交互开发
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
+39
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
Vendored
+75
@@ -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')
|
||||
}
|
||||
Vendored
+17
@@ -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']
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+4046
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import ScreenEditor from '@/editor/index.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div><ScreenEditor /></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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')
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -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'>
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
@import 'tailwindcss';
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user