深度画布框架

This commit is contained in:
lzp
2026-03-06 15:50:05 +08:00
parent a3938662c9
commit d0d8c0ab9b
47 changed files with 3827 additions and 88 deletions

View File

@@ -0,0 +1,58 @@
<template>
<div class="details-panel">
<div class="header" @click="show = !show">
<span class="icon"><svg-icon name="dc-details_edit" size="17" /></span>
<span class="title">Edit Details</span>
<span class="show">
<svg-icon name="dc-down_arrow2" size="10" />
</span>
</div>
<div class="content" v-show="show">
这是一些设置参数...
</div>
</div>
</template>
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
const props = defineProps({})
const show = ref(true)
</script>
<style lang="less" scoped>
.details-panel {
position: absolute;
top: 2.2rem;
right: 3rem;
width: 28.8rem;
max-height: 80%;
display: flex;
flex-direction: column;
gap: 1.6rem;
> div {
border: 0.2rem solid #ebebeb;
background: #ffffff;
border-radius: 0.9rem;
box-shadow: 0 1.66rem 2.33rem 0 rgba(0, 0, 0, 0.05);
}
> .header {
cursor: pointer;
width: 100%;
height: 5rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
padding: 0 1.6rem;
> .title {
flex: 1;
font-family: Semibold;
font-size: 1.6rem;
color: #000;
}
}
> .content {
padding: 1.6rem;
}
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<div class="header-tools">
<template v-for="(v, i) in tools" :key="i">
<span class="line" v-if="v.type === 'line'"></span>
<span
v-else
class="icon"
@click="onClickTool(v)"
:class="{ active: v.name === tool, disabled: v.disabled }"
>
<svg-icon :name="v.icon" :size="v.iconSize" />
</span>
</template>
<button class="export" @click="emit('export')">
<span class="icon"><svg-icon name="export" size="12" /></span>
<span class="text">Export</span>
</button>
<button class="workbench">
<span class="icon"><svg-icon name="dc-workbench" size="20" /></span>
<span class="text">Workbench</span>
</button>
</div>
</template>
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
import { TOOLS } from '../manager/ToolManager'
const props = defineProps({
zoom: { default: 1, type: Number },
step: { default: 0.1, type: Number }
})
const emit = defineEmits(['export', 'import'])
const stateManager = inject('stateManager') as any
const toolManager = inject('toolManager') as any
const tool = computed(() => stateManager.tool.value)
const historyIndex = computed(() => stateManager.historyIndex.value)
const historyList = computed(() => stateManager.historyList.value)
const isUndo = computed(() => !historyList.value[historyIndex.value - 1])
const isRedo = computed(() => !historyList.value[historyIndex.value + 1])
const tools = ref([
{ name: TOOLS.SELECT, icon: 'dc-select', iconSize: 16, disabled: ref(false) },
{ name: TOOLS.MOVE, icon: 'dc-move', iconSize: 18, disabled: ref(false) },
{ name: TOOLS.BRUSH, icon: 'dc-brush', iconSize: 18, disabled: ref(false) },
{ name: TOOLS.ERASER, icon: 'dc-eraser', iconSize: 18, disabled: ref(false) },
{ name: TOOLS.IMAGE, icon: 'dc-image', iconSize: 17, disabled: ref(false) },
{ name: TOOLS.SELECTBOX, icon: 'dc-selectbox', iconSize: 16, disabled: ref(false) },
{ name: TOOLS.RECTANGLE, icon: 'dc-rectangle', iconSize: 16, disabled: ref(false) },
{ type: 'line' },
{
name: TOOLS.UNDO,
icon: 'dc-undo',
iconSize: 18,
disabled: isUndo,
on: () => stateManager.undoState()
},
{
name: TOOLS.REDO,
icon: 'dc-redo',
iconSize: 18,
disabled: isRedo,
on: () => stateManager.redoState()
}
])
const onClickTool = (tool: any) => {
if (tool.disabled?.value) return
if (tool.on) {
tool.on()
} else {
toolManager.setTool(tool.name)
}
}
</script>
<style lang="less" scoped>
.header-tools {
position: absolute;
top: 2.2rem;
left: 50%;
transform: translateX(-50%);
height: 5rem;
padding: 0.7rem 1.8rem;
border: 0.2rem solid #ebebeb;
background: #ffffff;
border-radius: 0.6rem;
box-shadow: 0 1.66rem 2.33rem 0 rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: center;
user-select: none;
gap: 1rem;
> .line {
width: 0;
height: 100%;
border-left: 0.2rem solid #d9d9d9;
border-radius: 0.2rem;
margin: 0 0.6rem;
}
> .icon {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
--svg-icon-color: #000;
border-radius: 0.4rem;
&:not(.disabled).active,
&:not(.disabled):hover {
background-color: #ebebeb;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
> button {
cursor: pointer;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
&:active {
opacity: 0.8;
}
}
> .export {
width: 10rem;
height: 3rem;
background-color: #0d0d0d;
color: #fff;
font-size: 1.2rem;
border-radius: 0.4rem;
}
> .workbench {
position: absolute;
top: 0;
right: -2.4rem;
transform: translateX(100%);
height: 100%;
padding: 0 2.4rem;
border: 0.2rem solid #ebebeb;
background: #ffffff;
border-radius: 0.6rem;
box-shadow: 0 1.66rem 2.33rem 0 rgba(0, 0, 0, 0.05);
font-size: 1.7rem;
}
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="layer-panel">
<div class="icon" @click="showList = !showList">
<svg-icon name="dc-layer" size="20" />
</div>
<layer-list v-show="showList" />
</div>
</template>
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
import layerList from './layer-list.vue'
const props = defineProps({})
const showList = ref(true)
</script>
<style lang="less" scoped>
.layer-panel {
position: absolute;
width: 0;
top: 2.2rem;
left: 3rem;
max-height: 80%;
display: flex;
flex-direction: column;
gap: 1.6rem;
> div {
border: 0.2rem solid #ebebeb;
background: #ffffff;
border-radius: 0.9rem;
box-shadow: 0 1.66rem 2.33rem 0 rgba(0, 0, 0, 0.05);
}
> .icon {
flex-shrink: 0;
width: 5rem;
height: 5rem;
border-radius: 1.2rem;
}
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<div class="layer-item">
<div class="drag"><svg-icon name="dc-drag" size="18" /></div>
<div class="thumb"></div>
<div class="name">
<div @dblclick="editName = true" v-if="!editName">图层 1</div>
<input type="text" value="图层 1" v-else @blur="editName = false" />
</div>
<div class="icons">
<span><svg-icon name="dc-show" size="15" /></span>
<span><svg-icon name="dc-delete" size="13" /></span>
<span><svg-icon name="dc-down_arrow" size="11" /></span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
const props = defineProps({})
const editName = ref(false)
</script>
<style lang="less" scoped>
.layer-item {
width: 100%;
height: 9.5rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0 1.4rem;
background-color: #fafafa;
gap: 1rem;
border-bottom: 0.2rem solid #ededed;
&:last-child {
border-bottom: none;
}
&:hover,
&.active {
background-color: #ededed;
}
> .drag {
padding: 0.3rem;
cursor: move;
}
> .thumb {
width: 5.6rem;
height: 5.6rem;
border-radius: 0.5rem;
overflow: hidden;
border: 0.2rem solid #ebebeb;
background-color: #fff;
> img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
> .name {
flex: 1;
font-size: 1.4rem;
> div {
cursor: pointer;
width: 100%;
height: 3rem;
display: flex;
align-items: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
> input {
width: 100%;
height: 3rem;
}
}
> .icons {
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
> span {
cursor: pointer;
width: 1.6rem;
height: 1.6rem;
display: flex;
align-items: center;
justify-content: center;
}
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<div class="layer-list">
<div class="header">
<div class="left">
<span class="icon"><svg-icon name="dc-layer" size="16" /></span>
<span class="title">Layer</span>
</div>
<div class="right">
<span class="icon"><svg-icon name="add" size="14" /></span>
</div>
</div>
<div class="content">
<layer-item />
<layer-item />
<layer-item />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
import layerItem from './layer-item.vue'
const props = defineProps({})
const emit = defineEmits(['export', 'import'])
</script>
<style lang="less" scoped>
.layer-list {
flex: 1;
width: 36.7rem;
overflow: hidden;
display: flex;
flex-direction: column;
> .header {
display: flex;
align-items: center;
justify-content: space-between;
height: 5.4rem;
padding: 0 2rem;
flex-shrink: 0;
border-bottom: 0.1rem solid #c9c9c9;
> div {
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
}
> .left {
> .title {
font-family: Semibold;
font-size: 1.6rem;
color: #000;
}
}
> .right {
> .icon {
cursor: pointer;
width: 2rem;
height: 2rem;
}
}
}
> .content {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
border-radius: 4px;
background: rgba(0, 0, 0, 0.2);
}
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="my-input">
<span class="decorate"></span>
<span v-show="icon" class="icon">
<svg-icon :name="icon" :size="iconSize" size-unit="px" />
</span>
<span v-show="before" class="before">{{ before }}</span>
<input v-bind="attrs" :value="modelValue" @input="onInput" @copy.stop @keydown.stop />
<span v-show="after" class="after">{{ after }}</span>
</div>
</template>
<script setup lang="ts">
import { ref, useAttrs, watch } from 'vue'
const props = defineProps({
modelValue: { type: [String, Number] },
icon: { default: '', type: String },
iconSize: { default: '10', type: [Number, String] },
before: { default: '', type: String },
after: { default: '', type: String }
})
const attrs = useAttrs()
const emit = defineEmits(['update:modelValue', 'input'])
const onInput = (e) => {
var value = e.target.value
if (attrs.type === 'number') value = Number(value)
emit('update:modelValue', value)
emit('input', value)
}
</script>
<style scoped lang="less">
.my-input {
display: flex;
align-items: center;
width: 100%;
border: 1px solid rgba(230, 230, 231, 1);
border-radius: 1.7px;
height: 17px;
padding: 0 4px 0 2px;
> .decorate {
width: 2px;
background-color: rgba(230, 230, 231, 1);
border-radius: 3px;
height: 85%;
margin-right: 4px;
}
> .iconfont {
font-size: 12px;
color: #000;
margin-right: 2px;
}
> .before {
font-size: 12px;
color: #000;
margin-right: 2px;
}
> .after {
font-size: 12px;
color: #000;
margin-left: 1px;
}
> input {
font-size: 12px;
width: 0;
flex: 1;
text-align: right;
outline: none;
border: none;
background-color: transparent;
padding: 0;
-moz-appearance: textfield; /* Firefox */
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="my-select">
<el-select :model-value="modelValue" @change="onChange" v-bind="attrs">
<el-option v-for="v in list" :key="v.value" :label="v.label" :value="v.value" />
</el-select>
</div>
</template>
<script setup lang="ts">
import { ref, useAttrs, watch } from 'vue'
const props = defineProps({
modelValue: { required: true },
list: { default: () => [], type: [Array, Object] }
})
const attrs = useAttrs()
const emit = defineEmits(['update:modelValue', 'change'])
const onChange = (value) => {
emit('update:modelValue', value)
emit('change', value)
}
</script>
<style scoped lang="less">
.my-select {
&:deep(.el-select) {
--el-select-input-font-size: 12px;
.el-select__wrapper {
font-size: 12px;
min-height: 0;
height: 28px;
padding: 0 8px;
}
.el-select__selected-item,
.el-select__input-wrapper,
.el-select__placeholder {
line-height: normal;
}
.el-select__input {
height: 24px;
}
}
}
.el-popper {
.el-select-dropdown {
li {
padding-left: 8px;
height: 30px;
line-height: 30px;
font-size: 12px;
}
}
}
</style>

View File

@@ -0,0 +1,86 @@
<template>
<div class="my-textarea">
<textarea
:placeholder="placeholder"
:value="modelValue"
@input="onInput"
@change="onChange"
@copy.stop
@keydown.stop
></textarea>
<div class="bths">
<button><svg-icon name="mobang" size="10" size-unit="px" /></button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, markRaw, onMounted } from 'vue'
const emit = defineEmits(['update:modelValue', 'input', 'change'])
const props = defineProps({
modelValue: { type: String },
placeholder: {
type: String,
default: 'Enter the scene you want to describe...'
}
})
const onInput = (e) => {
const value = e.target.value
emit('update:modelValue', value)
emit('input', value)
}
const onChange = (e) => {
emit('change', e.target.value)
}
</script>
<style lang="less" scoped>
.my-textarea {
width: 100%;
height: 115px;
border: 1px solid #e4e4e7;
border-radius: 10px;
padding: 10px 5px;
display: flex;
flex-direction: column;
> textarea {
padding: 0 5px;
width: 100%;
flex: 1;
border: none;
outline: none;
resize: none;
font-family: Medium;
font-size: 10px;
color: #333;
&::placeholder {
color: #c9c9c9;
}
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
border-radius: 4px;
background: rgba(0, 0, 0, 0.2);
}
&::-webkit-scrollbar-track {
border-radius: 4px;
background: rgba(0, 0, 0, 0.1);
}
}
> .bths {
padding: 5px 5px 0;
> button {
width: 20px;
height: 20px;
padding: 0;
border-radius: 4px;
background-color: #f0f0f0;
border: 1px solid #e4e4e7;
&:active {
opacity: 0.5;
}
}
}
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<div class="offset-tool">
<div class="input" v-show="showInput">
<my-input v-model="left" type="number" before="X" after="%" :min="-100" :max="100" />
<my-input v-model="top" type="number" before="Y" after="%" :min="-100" :max="100" />
</div>
<div
class="dish"
@mousedown="mousedown"
@touchstart="mousedown"
ref="dishRef"
v-show="showDish"
>
<img src="/src/assets/images/icon/xyz.png" />
<span class="ball" :style="ballStyle"></span>
<span class="tip x">X: {{ left }}%</span>
<span class="tip y">Y: {{ top }}%</span>
<span class="line x"></span>
<span class="line y"></span>
<span class="line z" :style="lineZStyle"></span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import MyInput from './my-input.vue'
const props = defineProps({
modelValue: { type: Object as () => { x: number; y: number } },
showInput: {
type: Boolean,
default: true
},
showDish: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:modelValue', 'change', 'input'])
// 工具的实际坐标 -100 ~ 100
const top = ref(Math.round(props.modelValue.y))
const left = ref(Math.round(props.modelValue.x))
// 原点的坐标 0 ~ 100
const ballStyle = computed(() => ({
top: 50 + top.value / 2 + '%',
left: 50 + left.value / 2 + '%'
}))
watch(
() => props.modelValue.x,
(v) => (left.value = v)
)
watch(
() => props.modelValue.y,
(v) => (top.value = v)
)
const dishRef = ref<HTMLDivElement>()
const mousedown = (e: MouseEvent | TouchEvent) => {
if (!dishRef.value) return
const mousemove = (e: MouseEvent | TouchEvent) => {
if (!dishRef.value) return
const rect = dishRef.value.getBoundingClientRect()
const X = e.clientX || (e as TouchEvent).touches[0].clientX
const Y = e.clientY || (e as TouchEvent).touches[0].clientY
var x = ((X - rect.left) / rect.width) * 100
var y = ((Y - rect.top) / rect.height) * 100
if (x < 0) x = 0
if (x > 100) x = 100
if (y < 0) y = 0
if (y > 100) y = 100
left.value = Math.round((x - 50) * 2)
top.value = Math.round((y - 50) * 2)
onInput()
}
mousemove(e)
const mouseup = () => {
onChange()
document.removeEventListener('mousemove', mousemove)
document.removeEventListener('touchmove', mousemove)
document.removeEventListener('mouseup', mouseup)
document.removeEventListener('touchend', mouseup)
}
document.addEventListener('mousemove', mousemove)
document.addEventListener('touchmove', mousemove)
document.addEventListener('mouseup', mouseup)
document.addEventListener('touchend', mouseup)
}
const onInput = () => {
const value = {
x: left.value,
y: top.value
}
emit('update:modelValue', value)
emit('input', value)
}
var changeTime: any = null
const onChange = () => {
clearTimeout(changeTime)
changeTime = setTimeout(() => {
const value = {
x: left.value,
y: top.value
}
emit('update:modelValue', value)
emit('change', value)
}, 500)
}
const lineZStyle = computed(() => ({
'--rotateZ': calculateAngle(0, 0, left.value, top.value) + 'deg',
width: calculateDistance(0, 0, left.value, top.value) / 2 + '%'
}))
// 计算角度
function calculateAngle(x1: number, y1: number, x2: number, y2: number) {
const deltaX = x2 - x1
const deltaY = y1 - y2
let angle = Math.atan2(deltaX, deltaY) * (180 / Math.PI) - 90
return angle
}
// 计算距离
function calculateDistance(x1: number, y1: number, x2: number, y2: number) {
const deltaX = x2 - x1
const deltaY = y2 - y1
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
return distance
}
</script>
<style scoped lang="less">
.offset-tool {
position: relative;
> .input {
display: flex;
align-items: center;
justify-content: center;
> * {
flex: 1;
margin-right: 10px;
&:last-child {
margin-right: 0;
}
}
}
> .dish {
width: 115px;
height: 115px;
border: 1px solid #eaeaea;
border-radius: 3.4px;
cursor: pointer;
position: relative;
background-color: #f6f6f6;
margin-top: 20px;
> * {
position: absolute;
pointer-events: none;
user-select: none;
}
> img {
width: 12px;
height: 12px;
bottom: 3.5px;
right: 3.5px;
}
> .ball {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 8.5px;
height: 8.5px;
border: 1px solid #fff;
background-color: #333;
border-radius: 50%;
box-shadow: 0px 0.68px 0.17px 0px rgba(0, 0, 0, 0.26);
}
> .tip {
font-size: 8.5px;
color: #000;
line-height: 24px;
&.x {
top: 50%;
right: 0%;
transform: translate(100%, -50%);
padding-left: 6px;
}
&.y {
top: 0%;
left: 50%;
transform: translate(-50%, -100%);
}
}
> .line {
border-color: #d9d9d9;
border-style: dashed;
border-width: 0;
width: 0;
height: 0;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.x {
width: 100%;
border-top-width: 1px;
}
&.y {
height: 100%;
border-left-width: 1px;
}
&.z {
width: 50%;
border-top-width: 1px;
border-color: #454754;
transform: translate(0%, -50%) rotateZ(var(--rotateZ));
transform-origin: left center;
}
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div class="pixel-ratio-selection">
<div
v-for="v in list"
:key="v"
:class="{ active: v === modelValue }"
@click="onChange(v)"
:style="{ '--w': v.split(':')[0], '--h': v.split(':')[1] }"
>
{{ v }}
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, defineExpose } from 'vue'
const emit = defineEmits(['update:modelValue', 'change'])
const props = defineProps({
modelValue: { type: String },
list: {
type: Array,
default: () => ['1:1', '4:3', '3:4', '16:9']
}
})
const data = reactive({})
const onChange = (v) => {
emit('update:modelValue', v)
emit('change', v)
}
defineExpose({ data })
</script>
<style lang="less" scoped>
.pixel-ratio-selection {
width: 100%;
height: 34px;
border-radius: 6px;
background-color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
padding: 0 17px;
user-select: none;
> div {
text-align: center;
cursor: pointer;
font-size: 12px;
color: #7c7c7c;
height: 21px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
&.active {
border-radius: 4px;
background-color: #fff;
}
&::before {
content: '';
border-radius: 1px;
border: 1px solid #7c7c7c;
width: calc(var(--w) / max(var(--w), var(--h)) * 10px);
height: calc(var(--h) / max(var(--w), var(--h)) * 10px);
margin-right: 4px;
box-sizing: border-box;
}
}
}
</style>

View File

@@ -0,0 +1,172 @@
<template>
<div class="slider" :disabled="disabled">
<div
class="input-range"
:style="{
'--progress': (value - props.min) / (props.max - props.min)
}"
>
<span class="tip">{{ props.tipFormatter(value) }}</span>
<input
type="range"
v-model="value"
v-bind="$attrs"
@input="onInput"
@change="onChange"
:disabled="disabled"
:min="props.min"
:max="props.max"
/>
</div>
<div class="input" v-show="isInput">
<my-input
type="number"
v-model="value"
v-bind="$attrs"
@input="onInput"
@change="onChange"
:disabled="disabled"
:min="props.min"
:max="props.max"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from 'vue'
import MyInput from './my-input.vue'
const props = defineProps({
modelValue: { type: Number },
disabled: {
type: Boolean,
default: false
},
min: {
type: Number,
default: 0
},
max: {
type: Number,
default: 100
},
tipFormatter: {
type: Function,
default: (v) => v
},
isInput: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['update:modelValue', 'change', 'input'])
watch(
() => props.modelValue,
(v) => {
value.value = v
}
)
const value = ref(props.modelValue)
const onInput = () => {
if (props.disabled) return
const v = Number(value.value)
emit('update:modelValue', v)
emit('input', v)
}
const onChange = () => {
if (props.disabled) return
const v = Number(value.value)
emit('update:modelValue', v)
emit('change', v)
}
</script>
<style scoped lang="less">
.slider {
position: relative;
display: flex;
align-items: center;
--input-thumb-size: 8px;
--backcolor1: var(--slider-thumb-color1, #4285f4);
--backcolor2: var(--slider-thumb-color2, rgba(0, 0, 0, 0.1));
&:hover {
> .input-range > .tip {
display: block;
}
}
> .input-range {
position: relative;
flex: 2;
display: flex;
> input {
width: 100%;
-webkit-appearance: none;
appearance: none;
height: 3px;
border-radius: 3px;
outline: none;
background: linear-gradient(
to right,
var(--backcolor1) 0%,
var(--backcolor1) calc(var(--progress) * 100%),
var(--backcolor2) calc(var(--progress) * 100%),
var(--backcolor2) 100%
);
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--input-thumb-size);
height: var(--input-thumb-size);
border-radius: 50%;
background: var(--backcolor1); /* 蓝色滑块 */
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
&::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
}
> .tip {
position: absolute;
font-size: 10px;
pointer-events: none;
user-select: none;
color: #666;
top: calc(var(--input-thumb-size) / -2 - 3.5px);
left: calc(
(100% - var(--input-thumb-size)) * var(--progress) + var(--input-thumb-size) / 2
);
transform: translate(-50%, -100%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 3px 5px;
border-radius: 4px;
font-size: 10px;
white-space: nowrap;
pointer-events: none;
display: none;
&::after {
content: '';
position: absolute;
top: 97%;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid rgba(0, 0, 0, 0.8);
}
}
}
> .input {
flex: 1;
margin-left: 10px;
> input {
border-radius: 3px;
width: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="upload-file">
<div class="preview" v-if="url">
<img :src="url" @error="onChange(null)" />
<div class="close" @click="onChange(null)">
<svg-icon name="close-border" size="16" size-unit="px" />
</div>
</div>
<div class="control" v-else>
<div class="icon"><svg-icon name="upload" size="17" size-unit="px" /></div>
<div class="txt">{{ tip }}</div>
<div class="btn" @click="onSelectFile">Select File</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, computed } from 'vue'
const emit = defineEmits(['update:modelValue', 'change'])
const props = defineProps({
modelValue: { type: [File, Object, String, null] },
tip: { type: String, default: 'Upload your files' }
})
const data = reactive({
file: null
})
const url = computed(() => {
const type = props.modelValue?.constructor
var str = ''
if (type === File) {
str = URL.createObjectURL(props.modelValue as File)
} else if (type === String) {
str = props.modelValue as string
}
return str
})
const onChange = (v) => {
emit('update:modelValue', v)
emit('change', v)
}
const onSelectFile = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/png, image/jpeg, image/jpg'
input.addEventListener('change', (e) => {
const file = e.target.files[0]
if (file) onChange(file)
})
input.click()
}
defineExpose({ data })
</script>
<style lang="less" scoped>
.upload-file {
width: 100%;
height: 99px;
border-radius: 10px;
background-color: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
> .control {
text-align: center;
> .txt {
margin-top: 6px;
margin-bottom: 8px;
font-size: 8px;
color: #7c7c7c;
}
> .btn {
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0px 0.75px 0px 0px rgba(0, 0, 0, 0.02);
min-width: 39px;
height: 13px;
border-radius: 2.3px;
background-color: #fff;
font-size: 6px;
color: #000;
border: 1px solid #d9d9d9;
cursor: pointer;
&:active {
opacity: 0.6;
}
}
}
> .preview {
width: 80px;
height: 80px;
position: relative;
> img {
height: 100%;
width: 100%;
object-fit: contain;
}
> .close {
position: absolute;
top: 0.1px;
right: 0.1px;
border-radius: 50%;
background-color: #fff;
cursor: pointer;
}
}
}
</style>

View File

@@ -0,0 +1,141 @@
<template>
<div class="depth-canvas">
<div class="canvas-container" ref="canvasContainerRef">
<canvas ref="canvasRef"></canvas>
</div>
<layer-panel />
<details-panel />
<header-tools />
<zoom :zoom="1" :step="0.1" is-home />
</div>
</template>
<script setup lang="ts">
import { fabric } from 'fabric-with-all'
import { computed, ref, markRaw, onMounted, nextTick, provide, onBeforeMount } from 'vue'
import { NODE_TYPE, NODE_COMPONENT } from './tools/index.d'
// 组件
import layerPanel from './components/layer-panel/index.vue'
import detailsPanel from './components/details-panel/index.vue'
import headerTools from './components/header-tools.vue'
import zoom from '../components/zoom.vue'
// 管理器
import { StateManager } from './manager/StateManager'
import { EventManager } from './manager/EventManager'
import { FlowManager } from './manager/FlowManager'
import { NodeManager } from './manager/NodeManager'
import { ToolManager, TOOLS } from './manager/ToolManager'
const canvasContainerRef = ref(null)
const canvasRef = ref(null)
const props = defineProps({
config: {
type: Object,
default: () => ({})
}
})
// 状态管理器
const stateManager = new StateManager({})
provide('stateManager', stateManager)
// 事件管理器
const eventManager = new EventManager({ stateManager })
stateManager.setManager({ eventManager })
provide('eventManager', eventManager)
// 流程管理器
const flowManager = new FlowManager({ stateManager })
stateManager.setManager({ flowManager })
provide('flowManager', flowManager)
// 节点管理器
const nodeManager = new NodeManager({ stateManager })
stateManager.setManager({ nodeManager })
provide('nodeManager', nodeManager)
// 工具管理器
const toolManager = new ToolManager({ stateManager })
stateManager.setManager({ toolManager })
provide('toolManager', toolManager)
const initCanvas = () => {
console.log('OverallCanvas: initCanvas')
const canvasViewWidth = canvasContainerRef.value.clientWidth
const canvasViewHeight = canvasContainerRef.value.clientHeight
const canvasWidth = 750
const canvasHeight = 600
const canvas = new fabric.Canvas(canvasRef.value, {
selection: true,
width: canvasViewWidth,
height: canvasViewHeight,
imageSmoothingEnabled: true, // 启用图像平滑 - 抗锯齿
imageSmoothingQuality: 'high', // 设置高质量图像平滑
preserveObjectStacking: true,
enableRetinaScaling: true,
stopContextMenu: true,
fireRightClick: true,
backgroundColor: '#fff'
})
canvas.clipPath = new fabric.Rect({
left: 0,
top: 0,
width: canvasWidth,
height: canvasHeight
})
// 画布居中
const canvasX = canvasViewWidth / 2 - canvasWidth / 2
const canvasY = canvasViewHeight / 2 - canvasHeight / 2
canvas.viewportTransform = [1, 0, 0, 1, canvasX, canvasY]
// 创建矩形
const rect = new fabric.Rect({
left: 20,
top: 20,
width: 100,
height: 100,
fill: '#f00'
})
canvas.add(rect)
//创建圆形
const circle = new fabric.Circle({
left: 200,
top: 200,
radius: 50,
fill: '#0f0'
})
canvas.add(circle)
}
onMounted(() => {
initCanvas()
})
onBeforeMount(() => {
// eventManager.removeEvents() // 移除事件
})
</script>
<style lang="less">
@import '@vue-flow/core/dist/style.css';
@import '@vue-flow/core/dist/theme-default.css';
</style>
<style lang="less" scoped>
.depth-canvas {
position: relative;
width: 100%;
height: 100%;
user-select: none;
> .canvas-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
> canvas {
width: 100%;
height: 100%;
}
&:deep(.canvas-container) {
filter: drop-shadow(0 0 5px rgba(0, 0, 0, 0.3));
}
}
}
</style>

View File

@@ -0,0 +1,33 @@
<template>
<fullscreen-dialog v-model="dialogVisible" hide-destroy>
<div class="canvas-box">
<depth-canvas :config="config" />
</div>
</fullscreen-dialog>
</template>
<script setup lang="ts">
import FullscreenDialog from '../components/fullscreen-dialog.vue'
import depthCanvas from './depth-canvas.vue'
import { ref } from 'vue'
const dialogVisible = ref(false)
const config = ref({})
const open = (options) => {
dialogVisible.value = true
config.value = options || {}
}
const close = () => {
dialogVisible.value = false
}
defineExpose({
open,
close
})
</script>
<style lang="less" scoped>
.canvas-box {
padding-top: 10rem;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,84 @@
import { TOOLS } from "./ToolManager"
export class EventManager {
stateManager: any
vueFlow: any
zoom: any
constructor(options) {
this.stateManager = options.stateManager;
this.vueFlow = options.vueFlow
this.zoom = this.stateManager.zoom
this.registerEvents()
}
/** 处理视口变化 */
handleViewportChange(e: any) {
const { zoom } = e
this.zoom.value = zoom
}
/** 处理节点拖动停止 */
handleNodeDragStop(e: any) {
const { node } = e
const { id, position } = node
this.stateManager.nodes.value.forEach((item) => {
if (item.id === id) {
item.position.x = position.x
item.position.y = position.y
}
})
this.stateManager.recordState()
}
/** 处理点击 */
handleClick(event: any) {
this.stateManager.setActiveNodeID("")
const tool = this.stateManager.tool.value
if (tool === TOOLS.TEXT) {
const { x, y, zoom } = this.vueFlow.value.viewport
const position = {
x: (event.offsetX - x) / zoom,
y: (event.offsetY - y) / zoom
}
this.stateManager.nodeManager.createTextNode({ position })
this.stateManager.toolManager.setTool(TOOLS.SELECT)
}
}
/** 处理复制 */
handleCopy(event: any, activeNodeID: string) {
event.preventDefault()
if (!activeNodeID) return console.warn('没有选中节点')
this.stateManager.nodeManager.copyNodeById(activeNodeID)
}
/** 处理删除 */
handleDelete(event: any, activeNodeID: string) {
event.preventDefault()
if (!activeNodeID) return console.warn('没有选中节点')
this.stateManager.deleteNode(activeNodeID, { isElMessageBox: true })
}
/** 处理键盘事件 */
handleKeyDown(event: any) {
const activeNodeID = this.stateManager.activeNodeID.value;
// const shiftKey
const ctrl = event.ctrlKey ? 'ctrl-' : "";
const shift = event.shiftKey ? 'shift-' : "";
const key = event.key;
const reg = new RegExp(`^${ctrl}${shift}${key}$`, 'i')
const list = [
{ key: "ctrl-c", handler: () => this.handleCopy(event, activeNodeID) },
{ key: "delete", handler: () => this.handleDelete(event, activeNodeID) },
{ key: "ctrl-z", handler: () => this.stateManager.undoState() },
{ key: "ctrl-shift-z", handler: () => this.stateManager.redoState() },
]
list.forEach((v: any) => {
if (reg.test(v.key)) v.handler(event)
})
}
/** 注册事件 */
registerEvents() {
// document.addEventListener('copy', this.handleCopy.bind(this))
document.addEventListener('keydown', this.handleKeyDown.bind(this))
}
/** 删除事件 */
removeEvents() {
// document.removeEventListener('copy', this.handleCopy.bind(this))
document.removeEventListener('keydown', this.handleKeyDown.bind(this))
}
}

View File

@@ -0,0 +1,26 @@
export class FlowManager {
stateManager: any
vueFlow: any
constructor(options) {
this.stateManager = options.stateManager;
this.vueFlow = options.vueFlow
}
setZoom(zoom: number) {
this.stateManager.zoom.value = zoom
this.vueFlow.value.zoomTo(zoom)
}
getNodeById(id: string) {
return this.vueFlow.value.getNode(id)
}
getLastNode() {
const lastNode = this.stateManager.getLastNode()
if (lastNode?.id) {
return this.vueFlow.value.getNode(lastNode.id)
}
return null;
}
getSubordNodeByID(id: string) {
return this.vueFlow.value.getNodes?.find((v) => v.data.superiorID === id)
}
}

View File

@@ -0,0 +1,133 @@
import { createId } from '../../tools/tools'
import { NODE_DATATYPE, NODE_COMPONENT, NODE_DATATIER } from '../tools/index.d'
interface NodeData {
type?: string
component?: any// 节点组件
data?: object// 节点数据
tier?: string// 节点层级
isHeader?: boolean// 是否显示头
superiorID?: string// 上级节点ID
disableDelete?: boolean// 是否禁用删除
disableCopy?: boolean// 是否禁用复制
}
interface NodeOptions {
id?: string
position?: { x: number, y: number }
positionX?: number
positionY?: number
component?: any
data?: NodeData
}// 不可传入type class (内部使用)
export class NodeManager {
stateManager: any
vueFlow: any
nodesep = 100 // 节点间距
ranksep = 100 // 层级间距
constructor(options) {
this.stateManager = options.stateManager;
this.vueFlow = options.vueFlow
}
/** 删除节点 */
deleteNode(id: string) {
this.stateManager.deleteNode(id)
}
/** 添加节点 */
addNode(node: any) {
this.stateManager.addNode(node)
}
/** 创建节点 */
createNode(options: NodeOptions) {
const superiorID = options?.data?.superiorID
const snode = superiorID ? this.stateManager.flowManager.getNodeById(superiorID) : this.stateManager.flowManager.getLastNode();
const id = options.id || createId()
const positionX = options.positionX || 0
const positionY = options.positionY || 0
const position = options.position ||
(!snode ?
{ x: positionX, y: positionY } :
{
x: snode.position.x + snode.dimensions.width + this.nodesep + positionX,
y: snode.position.y + positionY
})
const data = options?.data || {}
data['component'] = options.component
const options_ = {
id,
position,
data
}
this.addNode(options_)
return options_;
}
/** 创建结果节点 */
createResultNode(options?: NodeOptions) {
const options_ = {
...(options ? options : {}),
component: NODE_COMPONENT.RESULT_IMAGE,
data: {
tier: NODE_DATATIER.RESULT_IMAGE,
type: NODE_DATATYPE.RESULT_IMAGE,
isHeader: true,
...(options?.data || {}),
},
}
return this.createNode(options_)
}
/** 创建卡片选择节点 */
createCardsSelect(options?: NodeOptions) {
const options_ = {
...(options ? options : {}),
component: NODE_COMPONENT.CARD,
positionY: 50,
data: {
tier: NODE_DATATIER.CARDS_SELECT,
type: NODE_DATATYPE.CARDS_SELECT,
...(options?.data || {}),
},
}
return this.createNode(options_)
}
/** 创建卡片节点 */
createCardNode(options?: NodeOptions) {
const options_ = {
...(options ? options : {}),
component: NODE_COMPONENT.CARD,
data: {
...(options?.data || {}),
}
}
return this.createNode(options_)
}
/** 创建文本节点 */
createTextNode(options?: NodeOptions) {
const options_ = {
...(options ? options : {}),
component: NODE_COMPONENT.TEXT,
data: {
...(options?.data || {}),
}
}
return this.createNode(options_)
}
copyNodeById(id: string) {
const node = this.stateManager.getNodeById(id)
const flowNode = this.stateManager.flowManager.getNodeById(id)
if (!node) return console.warn(`${id}找不到对应节点`)
if (node.data?.disableCopy) return console.warn(`${id}节点已禁用复制`)
const node_ = {
...JSON.parse(JSON.stringify(node)),
id: createId(),
position: {
x: node.position.x,
y: node.position.y + (flowNode?.dimensions?.height || 0) + this.ranksep,
}
}
delete node_.data?.superiorID
delete node_.data?.disableDelete
this.stateManager.addNode(node_)
}
}

View File

@@ -0,0 +1,186 @@
import { ref, computed } from "vue";
import { NODE_TYPE } from '../tools/index.d'
import { ElMessageBox } from 'element-plus'
import i18n from '@/lang'
const t = i18n.global.t
export interface NodesItem {
id: string
type: string
class: string
position: { x: number, y: number }
data: { component: any, type: string, superiorID?: string }
}
export class StateManager {
vueFlow: any
activeNodeID: any
nodes: any
nodes_: any
edges: any
zoom: any
tool: any
cursor: any
// 节点是否可拖动
nodesDraggable: any
// 拖动时是否可以平移画布
panOnDrag: any
// 历史记录-撤回/重做
mxHistory: any
historyList: any
historyIndex: any
// 管理器
eventManager: any
flowManager: any
nodeManager: any
toolManager: any
// 设置管理器
setManager(options) {
options.eventManager && (this.eventManager = options.eventManager)
options.flowManager && (this.flowManager = options.flowManager)
options.nodeManager && (this.nodeManager = options.nodeManager)
options.toolManager && (this.toolManager = options.toolManager)
}
constructor(options) {
this.vueFlow = options.vueFlow
this.zoom = ref(1)
this.tool = ref("")
this.cursor = ref("")
this.nodesDraggable = ref(false)
this.panOnDrag = ref(false)
this.mxHistory = ref(50)
this.historyList = ref([])
this.historyIndex = ref(0)
this.activeNodeID = ref("")
this.nodes = ref<NodesItem[]>([]);
this.nodes_ = computed(() => {
return this.nodes.value.map((node, index) => {
const obj = node;
const superiorID = node.data.superiorID;
const isSuperior = this.nodes.value.some((v) => v.id === superiorID)
const isSubord = this.nodes.value.some((v) => v.data.superiorID === node.id)
if (!isSuperior && isSubord) {// 没有上级 有下级
obj.type = NODE_TYPE.INPUT;
} else if (isSuperior && isSubord) {// 有上级 有下级
obj.type = NODE_TYPE.SECONDARY;
} else if (isSuperior && !isSubord) {// 有上级 没有下级
obj.type = NODE_TYPE.OUTPUT;
} else {// 其他情况-没有上级 没有下级
obj.type = NODE_TYPE.ALONE;
}
return obj
})
})
this.edges = computed(() => {
const arr = []
this.nodes.value.forEach((node, index) => {
const superiorID = node.data.superiorID;
const isSuperior = this.nodes.value.some((v) => v.id === superiorID)
if (superiorID && isSuperior) {
const source = node.data.superiorID
const target = node.id
arr.push({
id: `el-${source}-${target}`,
source: source,
target: target,
selectable: false,
type: 'default'
})
}
})
return arr
})
}
/** 设置激活节点 */
setActiveNodeID(id: string) { this.activeNodeID.value = id }
/** 添加节点 */
addNode(node: NodesItem) {
this.nodes.value.push(node);
this.recordState()
}
/** 删除节点 */
async deleteNode(id: string, { isElMessageBox } = { isElMessageBox: false }) {
const node = this.getNodeById(id)
if (!node) return console.warn(`没有找到指定id:${id}`)
if (node.data.disableDelete) return console.warn('该节点禁用删除')
let deletePromise: any = true
if (isElMessageBox) {
deletePromise = await new Promise<void>((resolve, reject) => {
ElMessageBox.confirm(
t('flowCanvas.deleteCardConfirm'),
'',
{
confirmButtonText: t('flowCanvas.confirm'),
cancelButtonText: t('flowCanvas.cancel'),
}
).then(() => {
resolve(true)
}).catch(() => {
resolve(false)
})
})
}
if (!deletePromise) return console.log('删除操作被取消')
this.nodes.value = this.nodes.value.filter((node: NodesItem) => node.id !== id)
this.recordState()
}
/** 获取节点 */
getNodeById(id: string) { return this.nodes.value.find((node: NodesItem) => node.id === id) }
/** 获取下级节点 */
getSubordNodeByID(id: string) { return this.nodes.value.find((node: NodesItem) => node.data.superiorID === id) }
getLastNode() { return this.nodes.value[this.nodes.value.length - 1] }
/** 设置工具 */
setTool(tool: string) { this.tool.value = tool }
/** 设置光标 */
setCursor(v: string) { this.cursor.value = v }
/** 设置节点是否可拖动 */
setNodesDraggable(v: boolean) { this.nodesDraggable.value = v }
/** 设置是否可以平移画布 */
setPanOnDrag(v: boolean) { this.panOnDrag.value = v }
/** 设置节点层级至最顶部 */
bringToFont(id) {
const fromIndex = this.nodes.value.findIndex(item => item.id === id)
if (fromIndex === -1) return console.warn(`没有找到指定id:${id}`)
this.nodes.value.splice(this.nodes.value.length - 1, 0, ...this.nodes.value.splice(fromIndex, 1))
}
/** 设置节点层级至最低部 */
sendToBack(id) {
const fromIndex = this.nodes.value.findIndex(item => item.id === id)
if (fromIndex === -1) return console.warn(`没有找到指定id:${id}`)
this.nodes.value.splice(0, 0, ...this.nodes.value.splice(fromIndex, 1))
}
/** 记录状态 */
recordState() {
if (this.historyIndex.value < this.historyList.value.length - 1) {
this.historyList.value.splice(this.historyIndex.value + 1)
}
const state = {
nodes: JSON.stringify(this.nodes.value)
}
this.historyList.value.push(state)
const size = this.historyList.value.length - this.mxHistory.value
if (size > 0) this.historyList.value.splice(0, size)
this.historyIndex.value = this.historyList.value.length - 1
}
/** 撤回状态 */
undoState() {
var index = this.historyIndex.value - 1
const state = this.historyList.value[index]
if (!state) return
this.historyIndex.value = index
this.nodes.value = JSON.parse(state.nodes)
}
/** 重做状态 */
redoState() {
var index = this.historyIndex.value + 1
const state = this.historyList.value[index]
if (!state) return
this.historyIndex.value = index
this.nodes.value = JSON.parse(state.nodes)
}
}

View File

@@ -0,0 +1,52 @@
export const TOOLS = {
SELECT: "SELECT",
MOVE: "MOVE",
BRUSH: "BRUSH",
ERASER: "ERASER",
IMAGE: "IMAGE",
SELECTBOX: "SELECTBOX",
RECTANGLE: "RECTANGLE",
TEXT: "TEXT",
UNDO: "UNDO",
REDO: "REDO",
}
export const tools = [
/** 选择工具 */
{
name: TOOLS.SELECT,
nodesDraggable: true,
panOnDrag: false,
},
/** 移动工具 */
{
name: TOOLS.MOVE,
nodesDraggable: false,
panOnDrag: true,
},
/** 文本工具 */
{
name: TOOLS.TEXT,
cursor: "text",
nodesDraggable: false,
panOnDrag: false,
},
]
export class ToolManager {
stateManager: any
vueFlow: any
constructor(options) {
this.stateManager = options.stateManager;
this.vueFlow = options.vueFlow
this.setTool(TOOLS.SELECT)
}
setTool(value: string) {
const tool = tools.find((t) => t.name === value)
if (!tool) return console.warn(`工具${tool}不存在`)
this.stateManager.tool.value = tool.name
this.stateManager.setNodesDraggable(!!tool.nodesDraggable)
this.stateManager.setPanOnDrag(!!tool.panOnDrag)
this.stateManager.setCursor(tool.cursor || "")
}
}

View File

@@ -0,0 +1,45 @@
/**
* 组件
*/
export const NODE_COMPONENT = {
RESULT_IMAGE: 'result-image',
CARD: 'card',
TEXT: 'text',
}
/**
* 节点类型
*/
export const NODE_TYPE = {
INPUT: 'InputNode',
SECONDARY: 'SecondaryNode',
OUTPUT: 'OutputNode',
ALONE: 'AloneNode',
}
/**
* 节点数据类型
*/
export const NODE_DATATYPE = {
RESULT_IMAGE: 'result-image',
CARDS_SELECT: 'cards-select',
TO_REAL_STYLE: 'to-real-style',
SURFACE_EDIT: 'surface-edit',
SCENE_COMPOSITION: 'scene-composition',
COLOR_PALETTE: 'color-palette',
TO_3D_MODEL: 'to-3d-model',
TO_3VIEW: 'to-3view',
}
/**
* 节点数据层级
*/
export const NODE_DATATIER = {
RESULT_IMAGE: 0,
CARDS_SELECT: 0,
TO_REAL_STYLE: 1,
SURFACE_EDIT: 1,
SCENE_COMPOSITION: 2,
COLOR_PALETTE: 2,
TO_3D_MODEL: 2,
TO_3VIEW: 3,
}