Merge branch 'main' of ssh://18.167.251.121:10002/aidlab/FiDA_Front

This commit is contained in:
X1627315083@163.com
2026-03-10 09:35:43 +08:00
28 changed files with 3461 additions and 1262 deletions

View File

@@ -24,7 +24,7 @@
<script setup lang="ts">
import { ref, inject, computed } from 'vue'
import { TOOLS } from '../manager/ToolManager'
import { OperationType } from '../tools/layerHelper'
const props = defineProps({
zoom: { default: 1, type: Number },
step: { default: 0.1, type: Number }
@@ -32,29 +32,29 @@
const emit = defineEmits(['export', 'import'])
const stateManager = inject('stateManager') as any
const toolManager = inject('toolManager') as any
const tool = computed(() => stateManager.tool.value)
const tool = computed(() => toolManager.currentTool.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) },
{ name: OperationType.SELECT, icon: 'dc-select', iconSize: 16, disabled: ref(false) },
{ name: OperationType.PAN, icon: 'dc-move', iconSize: 18, disabled: ref(false) },
{ name: OperationType.DRAW, icon: 'dc-brush', iconSize: 18, disabled: ref(false) },
{ name: OperationType.ERASER, icon: 'dc-eraser', iconSize: 18, disabled: ref(false) },
{ name: OperationType.IMAGE, icon: 'dc-image', iconSize: 17, disabled: ref(false) },
{ name: OperationType.SELECTBOX, icon: 'dc-selectbox', iconSize: 16, disabled: ref(false) },
{ name: OperationType.RECTANGLE, icon: 'dc-rectangle', iconSize: 16, disabled: ref(false) },
{ type: 'line' },
{
name: TOOLS.UNDO,
name: OperationType.UNDO,
icon: 'dc-undo',
iconSize: 18,
disabled: isUndo,
on: () => stateManager.undoState()
},
{
name: TOOLS.REDO,
name: OperationType.REDO,
icon: 'dc-redo',
iconSize: 18,
disabled: isRedo,

View File

@@ -1,28 +1,64 @@
<template>
<div class="layer-item">
<div class="layer-item" @click="onClickLayer">
<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">{{ layer.name }}</div>
<input type="text" v-model="layer.name" v-else @blur="editName = false" />
<div @dblclick="onClickEditName" v-if="!editName">
{{ layer.info.name || '未命名图层' }}
</div>
<input
v-else
type="text"
ref="nameInputRef"
:value="layer.info.name"
@blur="onChangeName"
@keyup.enter="onChangeName"
/>
</div>
<div class="icons">
<span><svg-icon name="dc-show" size="15" /></span>
<span><svg-icon name="dc-delete" size="13" /></span>
<span @click="onClickShowHide"
><svg-icon :name="layer.visible ? 'dc-show' : 'dc-hide'" size="15"
/></span>
<span @click="onClickDelete"><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'
import { ref, inject, nextTick } from 'vue'
const layerManager = inject('layerManager') as any
const editName = ref(false)
const props = defineProps({
layer: {
type: Object,
default: () => ({})
}
})
const editName = ref(false)
const nameInputRef = ref(null)
const onClickEditName = () => {
editName.value = true
nextTick(() => {
nameInputRef.value.focus()
})
}
const onChangeName = (e) => {
const value = e.target.value
editName.value = false
const name = props.layer.info.name
if (name !== value) {
layerManager.setLayerNameByID(props.layer.info.id, value)
}
}
const onClickShowHide = () => {
layerManager.setLayerVisibleByID(props.layer.info.id, !props.layer.visible)
}
const onClickDelete = () => {
layerManager.deleteLayerByID(props.layer.info.id)
}
const onClickLayer = () => {
layerManager.setActiveID(props.layer.info.id)
}
</script>
<style lang="less" scoped>
@@ -39,11 +75,11 @@
&:last-child {
border-bottom: none;
}
&:not([draging='true']) {
&:hover,
&.active {
background-color: #ededed;
}
&:not([draging='true']):hover {
background-color: #f5f5f5;
}
&.active {
background-color: #ededed !important;
}
&.drag {
opacity: 0;
@@ -51,7 +87,7 @@
&.ghost,
&.chosen {
box-shadow: inset 0 0 5px #aaa;
background-color: #ededed;
background-color: #ededed !important;
}
> .drag {
padding: 0.3rem;

View File

@@ -11,7 +11,7 @@
</div>
<div class="content">
<VueDraggable
:model-value="sortableRootLayers"
:model-value="list"
@start="handleDragStart"
@end="handleDragEnd"
class="sortable-layers"
@@ -36,10 +36,11 @@
:scroll-speed="10"
>
<layer-item
v-for="layer in sortableRootLayers"
:key="layer.id"
v-for="layer in list"
:key="layer.info.id"
:layer="layer"
:draging="draging"
:class="{ active: layer.info.id === layerManager.activeID.value }"
/>
</VueDraggable>
</div>
@@ -51,20 +52,16 @@
import { ref, inject, computed } from 'vue'
import layerItem from './layer-item.vue'
const draging = ref(false)
const sortableRootLayers = ref([
{ id: 1, name: 'layer1' },
{ id: 2, name: 'layer2' },
{ id: 3, name: 'layer3' }
])
const layerManager = inject('layerManager') as any
const list = computed(() => layerManager.layers.value)
const handleDragStart = () => {
draging.value = true
}
const handleDragEnd = (event) => {
draging.value = false
const { from, to, oldIndex, newIndex, item } = event
console.log('🔄 拖拽结束事件:', event)
sortableRootLayers.value.splice(newIndex, 0, sortableRootLayers.value.splice(oldIndex, 1)[0])
const { from, to, oldIndex, newIndex, data } = event
console.log('oldIndex', data)
layerManager.dragSort(data.info.id, newIndex)
}
</script>
@@ -107,6 +104,7 @@
> .content {
flex: 1;
overflow-y: auto;
min-height: 20rem;
&::-webkit-scrollbar {
width: 4px;
}

View File

@@ -1,79 +0,0 @@
<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

@@ -1,52 +0,0 @@
<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

@@ -1,86 +0,0 @@
<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

@@ -1,217 +0,0 @@
<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

@@ -1,69 +0,0 @@
<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

@@ -1,172 +0,0 @@
<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

@@ -1,108 +0,0 @@
<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

@@ -5,8 +5,13 @@
</div>
<layer-panel />
<details-panel />
<header-tools />
<zoom :zoom="1" :step="0.1" is-home />
<header-tools @export="exportCanvas" />
<zoom
:zoom="canvasManager.currentZoom.value / 100"
:step="0.1"
is-home
@home="() => canvasManager.resetZoom()"
/>
</div>
</template>
@@ -22,10 +27,10 @@
// 管理器
import { StateManager } from './manager/StateManager'
import { LayerManager } from './manager/LayerManager'
import { EventManager } from './manager/EventManager'
import { FlowManager } from './manager/FlowManager'
import { NodeManager } from './manager/NodeManager'
import { ToolManager, TOOLS } from './manager/ToolManager'
import { CanvasManager } from './manager/CanvasManager'
import { ToolManager } from './manager/ToolManager'
const canvasContainerRef = ref(null)
const canvasRef = ref(null)
@@ -41,77 +46,59 @@
const stateManager = new StateManager({})
provide('stateManager', stateManager)
// 画布管理器
const canvasManager = new CanvasManager({ stateManager })
stateManager.setManager({ canvasManager, canvasRef })
provide('canvasManager', canvasManager)
// 图层管理器
const layerManager = new LayerManager({ stateManager, canvasManager })
stateManager.setManager({ layerManager })
provide('layerManager', layerManager)
// 事件管理器
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 })
const toolManager = new ToolManager({ stateManager, canvasManager })
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)
}
const observer = ref(null)
onMounted(() => {
initCanvas()
canvasManager.initCanvas({
canvasRef,
canvasViewWidth: canvasContainerRef.value.clientWidth,
canvasViewHeight: canvasContainerRef.value.clientHeight,
canvasWidth: 750,
canvasHeight: 600
})
const trailingTimeout = ref(null)
observer.value = new ResizeObserver((entries) => {
clearTimeout(trailingTimeout.value)
trailingTimeout.value = setTimeout(async () => {
handleWindowResize()
}, 100)
})
observer.value.observe(canvasContainerRef.value)
})
onBeforeMount(() => {
// eventManager.removeEvents() // 移除事件
})
async function handleWindowResize() {
console.log('==========画布窗口大小变化==========')
canvasManager.setCanvasViewSize({
canvasViewWidth: canvasContainerRef.value.clientWidth,
canvasViewHeight: canvasContainerRef.value.clientHeight
})
canvasManager.resetZoom()
}
const exportCanvas = () => {
console.log(canvasManager.getBitObjects())
}
</script>
<style lang="less">
@import '@vue-flow/core/dist/style.css';

View File

@@ -0,0 +1,850 @@
import { gsap } from "gsap";
/**
* 画布动画管理器
* 负责处理画布平移、缩放等动画效果
*/
export class AnimationManager {
/**
* 创建动画管理器
* @param {fabric.Canvas} canvas fabric.js画布实例
* @param {Object} options 配置选项
*/
constructor(canvas, options = {}) {
this.canvasManager = options.canvasManager;
this.canvas = canvas;
this.currentZoom = options.currentZoom;
// 动画相关属性
this._zoomAnimation = null;
this._panAnimation = null;
this._lastWheelTime = 0;
this._lastWheelProcessTime = 0; // 上次处理wheel事件的时间
this._wheelEvents = [];
// 检测设备类型Mac设备使用更短的节流时间确保响应性
this._isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
this._wheelThrottleTime = this._isMac
? options.wheelThrottleTime || 8 // Mac设备使用更短的节流时间
: options.wheelThrottleTime || 30;
this._accumulatedWheelDelta = 0; // 累积滚轮增量
this._wheelAccumulationTimeout = null; // 滚轮累积超时
// Mac设备使用更短的累积时间窗口确保及时响应
this._wheelAccumulationTime = this._isMac ? 60 : 120; // 滚轮累积时间窗口(毫秒)
// 添加新的状态跟踪变量
this._wasPanning = false; // 是否有平移动画正在进行
this._wasZooming = false; // 是否有缩放动画正在进行
this._combinedAnimation = null; // 组合动画引用
// Mac特有的动画优化变量 - 使用最小防抖机制
if (this._isMac) {
this._lastMacAnimationTime = 0; // 上次Mac动画时间
this._macAnimationCooldown = 2; // 最小的动画冷却时间,确保最大响应性
}
// 初始化GSAP默认配置
gsap.defaults({
ease: options.defaultEase || (this._isMac ? "power2.out" : "power2.out"), // Mac使用简单高效的缓动
duration: options.defaultDuration || (this._isMac ? 0.3 : 0.3), // Mac使用标准持续时间
overwrite: "auto", // 自动覆盖同一对象上的动画
});
}
/**
* 使用 GSAP 实现平滑缩放动画
* @param {Object} point 缩放中心点 {x, y}
* @param {Number} targetZoom 目标缩放值
* @param {Object} options 动画选项
*/
animateZoom(point, targetZoom, options = {}) {
if (!this.canvas) return;
// 限制缩放范围
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
// 当前缩放值
const currentZoom = this.canvas.getZoom();
// 如果变化太小,直接应用缩放
if (Math.abs(targetZoom - currentZoom) < 0.01) {
this._applyZoom(point, targetZoom);
return;
}
// 停止任何进行中的缩放动画
if (this._zoomAnimation) {
// 不是直接 kill而是获取当前进度值作为新的起点
const currentProgress = this._zoomAnimation.progress();
const currentZoomValue = this._zoomAnimation.targets()[0].value;
this._zoomAnimation.kill();
this._zoomAnimation = null;
// 从当前过渡中的值开始新动画,而不是从最初的值
const zoomObj = { value: currentZoomValue };
const currentVpt = [...this.canvas.viewportTransform];
// 计算过渡动画持续时间 - 根据当前值到目标值的距离比例
const progressRatio =
Math.abs(targetZoom - currentZoomValue) / Math.abs(targetZoom - currentZoom);
const duration = options.duration || 0.3 * progressRatio;
// 计算缩放后目标位置需要的修正,保持缩放点不变
const animOptions = {
value: targetZoom,
duration: duration,
ease: options.ease || "power2.out",
onUpdate: () => {
// 更新缩放值显示
this.currentZoom.value = Math.round(zoomObj.value * 100);
// 计算过渡中的变换矩阵
const zoom = zoomObj.value;
const scale = zoom / currentZoomValue;
const currentScaleFactor = scale;
// 应用变换
const vpt = this.canvas.viewportTransform;
vpt[0] = currentVpt[0] * scale;
vpt[3] = currentVpt[3] * scale;
// 应用平移修正以保持缩放点
const adjustX = (1 - currentScaleFactor) * point.x;
const adjustY = (1 - currentScaleFactor) * point.y;
vpt[4] = currentVpt[4] * scale + adjustX;
vpt[5] = currentVpt[5] * scale + adjustY;
this.canvas.renderAll();
},
onComplete: () => {
this._zoomAnimation = null;
// 确保最终状态准确
this._applyZoom(point, targetZoom, true);
},
};
// 启动 GSAP 动画
this._zoomAnimation = gsap.to(zoomObj, animOptions);
return;
}
// 如果没有正在进行的动画,创建新的缩放动画
const zoomObj = { value: currentZoom };
const currentVpt = [...this.canvas.viewportTransform];
// 计算缩放后目标位置需要的修正,保持缩放点不变
const scaleFactor = targetZoom / currentZoom;
const invertedScaleFactor = 1 / scaleFactor;
// 这个数学公式确保缩放点在屏幕上的位置保持不变
const dx = point.x - point.x * invertedScaleFactor;
const dy = point.y - point.y * invertedScaleFactor;
// 创建动画配置
const animOptions = {
value: targetZoom,
duration: options.duration || 0.3,
ease: options.ease || (this._isMac ? "expo.out" : "power2.out"), // Mac使用更平滑的缓动
onUpdate: () => {
// 更新缩放值显示
this.currentZoom.value = Math.round(zoomObj.value * 100);
// 计算过渡中的变换矩阵
const zoom = zoomObj.value;
const scale = zoom / currentZoom;
const currentScaleFactor = scale;
// 应用变换
const vpt = this.canvas.viewportTransform;
vpt[0] = currentVpt[0] * scale;
vpt[3] = currentVpt[3] * scale;
// 应用平移修正以保持缩放点
const adjustX = (1 - currentScaleFactor) * point.x;
const adjustY = (1 - currentScaleFactor) * point.y;
vpt[4] = currentVpt[4] * scale + adjustX;
vpt[5] = currentVpt[5] * scale + adjustY;
this.canvas.renderAll();
},
onComplete: () => {
this._zoomAnimation = null;
// 确保最终状态准确
this._applyZoom(point, targetZoom, true);
},
};
// 启动 GSAP 动画
this._zoomAnimation = gsap.to(zoomObj, animOptions);
}
/**
* 应用缩放(内部使用)
* @private
*/
_applyZoom(point, zoom, skipUpdate = false) {
if (!skipUpdate) {
this.currentZoom.value = Math.round(zoom * 100);
}
this.canvas.zoomToPoint(point, zoom);
}
/**
* 使用 GSAP 实现平滑平移动画
* @param {Object} targetPosition 目标位置 {x, y}
* @param {Object} options 动画选项
*/
animatePan(targetPosition, options = {}) {
if (!this.canvas) return;
// 停止任何进行中的平移动画
if (this._panAnimation) {
this._panAnimation.kill();
}
const currentVpt = [...this.canvas.viewportTransform];
const position = {
x: -currentVpt[4],
y: -currentVpt[5],
};
// 计算平移距离
const dx = targetPosition.x - position.x;
const dy = targetPosition.y - position.y;
// 如果距离太小,直接应用平移
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) {
this._applyPan(targetPosition.x, targetPosition.y);
return;
}
// 创建动画配置
const animOptions = {
x: targetPosition.x,
y: targetPosition.y,
duration: options.duration || 0.3,
ease: options.ease || (this._isMac ? "circ.out" : "power2.out"), // Mac使用更柔和的缓动
onUpdate: () => {
this._applyPan(position.x, position.y);
},
onComplete: () => {
this._panAnimation = null;
// 确保最终位置准确
this._applyPan(targetPosition.x, targetPosition.y);
},
};
// 启动 GSAP 动画
this._panAnimation = gsap.to(position, animOptions);
}
/**
* 应用平移(内部使用)
* @private
*/
_applyPan(x, y) {
if (!this.canvas) return;
const vpt = this.canvas.viewportTransform;
vpt[4] = -x;
vpt[5] = -y;
this.canvas.renderAll();
}
/**
* 使用动画平移到指定元素
* @param {Object} elementId 元素ID
*/
panToElement(elementId) {
if (!this.canvas) return;
const obj = this.canvas.getObjects().find((obj) => obj.id === elementId);
if (!obj) return;
const zoom = this.canvas.getZoom();
const center = obj.getCenterPoint();
// 计算目标中心位置
const targetX = center.x * zoom - this.canvas.width / 2;
const targetY = center.y * zoom - this.canvas.height / 2;
// 动画平移
this.animatePan(
{ x: targetX, y: targetY },
{
duration: 0.6,
ease: this._isMac ? "back.out(0.3)" : "power3.out", // Mac使用轻微回弹效果
}
);
}
/**
* 重置缩放(带平滑动画)
* @param {Boolean} animated 是否使用动画
*/
async resetZoom(animated = true) {
const canvasViewWidth = this.canvasManager.canvasViewWidth;
const canvasViewHeight = this.canvasManager.canvasViewHeight;
const canvasWidth = this.canvasManager.canvasWidth;
const canvasHeight = this.canvasManager.canvasHeight;
const panX = canvasViewWidth / 2 - canvasWidth / 2
const panY = canvasViewHeight / 2 - canvasHeight / 2
return new Promise((resolve) => {
if (animated) {
// 停止任何进行中的动画
if (this._zoomAnimation) {
this._zoomAnimation.kill();
}
if (this._panAnimation) {
this._panAnimation.kill();
}
const center = {
x: this.canvas.width / 2,
y: this.canvas.height / 2,
};
// 获取当前变换矩阵
const currentVpt = [...this.canvas.viewportTransform];
const currentZoom = this.canvas.getZoom();
// 创建一个对象来动画整个视图变换
const viewTransform = {
zoom: currentZoom,
panX: currentVpt[4],
panY: currentVpt[5],
};
// 使用GSAP同时动画缩放和平移
gsap.to(viewTransform, {
zoom: 1,
panX: panX,
panY: panY,
duration: 0.5,
ease: this._isMac ? "back.out(0.2)" : "power3.out", // Mac使用轻微回弹效果
onUpdate: () => {
// 更新缩放显示值
this.currentZoom.value = Math.round(viewTransform.zoom * 100);
// 应用新的变换
const vpt = this.canvas.viewportTransform;
vpt[0] = viewTransform.zoom;
vpt[3] = viewTransform.zoom;
vpt[4] = viewTransform.panX;
vpt[5] = viewTransform.panY;
this.canvas.renderAll();
},
onComplete: () => {
// 确保最终状态准确
this.canvas.setViewportTransform([1, 0, 0, 1, panX, panY]);
this.currentZoom.value = 100;
this._zoomAnimation = null;
this._panAnimation = null;
resolve();
},
});
} else {
this.canvas.setViewportTransform([1, 0, 0, 1, panX, panY]);
this.currentZoom.value = 100;
resolve();
}
});
}
/**
* 处理鼠标滚轮缩放
* @param {Object} opt 事件对象
*/
handleMouseWheel(opt) {
const now = Date.now();
let delta = opt.e.deltaY;
// 记录事件用于计算速度和惯性
this._wheelEvents.push({
delta: delta,
point: { x: opt.e.offsetX, y: opt.e.offsetY },
time: now,
hasPanAnimation: this._wasPanning,
hasZoomAnimation: this._wasZooming,
});
// 保留最近的事件记录
if (this._wheelEvents.length > 10) {
this._wheelEvents.shift();
}
// 检查是否是第一个事件或者距离上次处理已经过了足够时间
const isFirstEvent = !this._wheelAccumulationTimeout;
const timeSinceLastProcess = now - (this._lastWheelProcessTime || 0);
if (isFirstEvent || timeSinceLastProcess > this._wheelAccumulationTime) {
// 立即处理第一个事件或长时间没有处理的事件,确保响应性
this._processAccumulatedWheel(opt);
this._lastWheelProcessTime = now;
// 清理之前的累积
this._accumulatedWheelDelta = 0;
// 如果有pending的timeout清除它
if (this._wheelAccumulationTimeout) {
clearTimeout(this._wheelAccumulationTimeout);
this._wheelAccumulationTimeout = null;
}
} else {
// 累积后续事件
this._accumulatedWheelDelta += delta;
// 如果正在累积中,清除之前的定时器
if (this._wheelAccumulationTimeout) {
clearTimeout(this._wheelAccumulationTimeout);
}
// 设置新的定时器,处理累积的事件
this._wheelAccumulationTimeout = setTimeout(() => {
this._processAccumulatedWheel(opt);
this._lastWheelProcessTime = Date.now();
// 清理
this._accumulatedWheelDelta = 0;
this._wheelAccumulationTimeout = null;
}, this._wheelThrottleTime);
}
opt.e.preventDefault();
opt.e.stopPropagation();
}
/**
* 处理累积的滚轮事件并应用缩放
* @private
* @param {Object} lastOpt 最后一个滚轮事件
*/
_processAccumulatedWheel(lastOpt) {
if (!this._wheelEvents.length) return;
const now = Date.now();
// Mac设备的轻量防抖检查 - 进一步减少冷却时间,确保响应性
if (this._isMac && now - this._lastMacAnimationTime < this._macAnimationCooldown) {
// 如果距离上次动画时间太短,只延迟很短时间,不阻塞太久
if (this._wheelAccumulationTimeout) {
clearTimeout(this._wheelAccumulationTimeout);
}
this._wheelAccumulationTimeout = setTimeout(
() => {
this._processAccumulatedWheel(lastOpt);
},
Math.min(this._macAnimationCooldown, 3)
); // 最多延迟3ms
return;
}
const currentZoom = this.canvas.getZoom();
// 分析滚轮事件模式,计算平均增量、速度和加速度
let sumDelta = 0;
let count = 0;
let earliestTime = now;
let latestTime = 0;
let point = {
x: lastOpt.e.offsetX,
y: lastOpt.e.offsetY,
};
// 判断是否在事件收集期间有平移或缩放动画
let hadPanAnimation = false;
let hadZoomAnimation = false;
// 计算平均增量和速度
this._wheelEvents.forEach((event) => {
sumDelta += event.delta;
count++;
earliestTime = Math.min(earliestTime, event.time);
latestTime = Math.max(latestTime, event.time);
// 使用最后记录的点作为缩放中心
if (event.time > latestTime) {
point = event.point;
}
// 检查是否有动画状态
if (event.hasPanAnimation) hadPanAnimation = true;
if (event.hasZoomAnimation) hadZoomAnimation = true;
});
// 计算平均增量
const avgDelta = sumDelta / count;
// 计算滚动速度 - 基于事件频率和时间跨度
const timeSpan = latestTime - earliestTime + 1; // 避免除以零
const eventsPerSecond = (count / timeSpan) * 1000;
// 速度系数: 速度越快,缩放越敏感
let speedFactor = Math.min(3, Math.max(0.5, eventsPerSecond / 10));
// 计算缩放因子,应用速度系数
// 针对Mac设备优化Mac触控板的deltaY值通常较小需要适度增加敏感度
let zoomFactorBase = 0.999;
if (this._isMac) {
// Mac设备的触控板需要适度的敏感度避免过度反应
zoomFactorBase = 0.995; // 适度降低基数,增加缩放敏感度
// 检测是否为触控板滚动(小幅度、高频次的特征)
const avgAbsDelta = Math.abs(avgDelta);
if (avgAbsDelta < 50 && count > 2) {
// 触控板滚动,适度增加敏感度
speedFactor *= 1.6; // 适度增加敏感度倍数
zoomFactorBase = 0.993; // 进一步调整基数
}
}
const zoomFactor = zoomFactorBase ** (avgDelta * speedFactor);
let targetZoom = currentZoom * zoomFactor;
// 限制缩放范围
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
// 根据滚动速度和缩放幅度计算动画持续时间
// 速度快时缩短动画时间,缩放幅度大时延长动画时间
const zoomRatio = Math.abs(targetZoom - currentZoom) / currentZoom;
let duration;
if (this._isMac) {
// Mac设备使用平衡的动画时间控制
if (speedFactor > 2) {
// 快速操作:快速但平滑
duration = Math.min(0.18, Math.max(0.08, (zoomRatio * 0.3) / Math.sqrt(speedFactor)));
} else if (speedFactor > 1.2) {
// 中等速度:标准响应
duration = Math.min(0.25, Math.max(0.1, (zoomRatio * 0.4) / Math.sqrt(speedFactor)));
} else {
// 慢速精确操作:确保平滑
duration = Math.min(0.3, Math.max(0.12, (zoomRatio * 0.5) / Math.sqrt(speedFactor)));
}
} else {
duration = Math.min(0.5, Math.max(0.15, (zoomRatio * 0.8) / Math.sqrt(speedFactor)));
}
// 根据滚动速度选择不同的缓动效果
let easeType;
if (this._isMac) {
// Mac设备使用更简单、性能更好的缓动函数
// 避免复杂的指数和回弹效果,减少计算量
if (speedFactor > 2) {
// 快速滚动:使用简单的缓出效果
easeType = "power2.out";
} else if (speedFactor > 1.2) {
// 中等速度:使用平滑的缓出
easeType = "power1.out";
} else {
// 慢速精确操作:使用线性过渡
easeType = "power1.out";
}
} else {
// 非Mac设备保持原有的缓动
easeType = speedFactor > 1.5 ? "power1.out" : "power2.out";
}
// 根据是否有其他动画正在进行,选择合适的动画方法
if (hadPanAnimation || this._wasPanning) {
// 如果有平移动画,使用组合动画以保持平滑过渡
this.animateCombinedTransform(point, targetZoom, {
duration: duration,
ease: easeType,
});
} else {
// 如果没有其他动画,使用标准缩放动画
this.animateZoom(point, targetZoom, {
duration: duration,
ease: easeType,
});
}
// 更新Mac设备的最后动画时间
if (this._isMac) {
this._lastMacAnimationTime = now;
}
// 清理事件记录
this._wheelEvents = [];
}
/**
* 计算并应用拖动结束后的惯性效果
* @param {Array} positions 拖动过程中记录的位置数组
* @param {Boolean} isTouchDevice 是否是触摸设备
*/
applyInertiaEffect(positions, isTouchDevice) {
if (!positions || positions.length <= 1) return;
const lastPos = positions[positions.length - 1];
const firstPos = positions[0];
const deltaTime = lastPos.time - firstPos.time;
if (deltaTime <= 0) return;
// 计算速度向量 (像素/毫秒)
const velocityX = (lastPos.x - firstPos.x) / deltaTime;
const velocityY = (lastPos.y - firstPos.y) / deltaTime;
const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY);
// 仅当速度足够大时应用惯性效果
if (speed > 0.2) {
// 计算惯性距离,基于速度和衰减因子
const decayFactor = 300; // 调整此值以改变惯性效果的强度
const inertiaDistanceX = velocityX * decayFactor;
const inertiaDistanceY = velocityY * decayFactor;
// 计算目标位置
const vpt = this.canvas.viewportTransform;
const currentPos = {
x: -vpt[4],
y: -vpt[5],
};
const targetPos = {
x: currentPos.x - inertiaDistanceX,
y: currentPos.y - inertiaDistanceY,
};
// 应用惯性动画,速度越大,动画时间越长
const animationDuration = Math.min(1.2, Math.max(0.6, speed * 2));
// 应用惯性动画
this.animatePan(targetPos, {
duration: animationDuration, // 动态计算持续时间
ease: this._isMac ? "quart.out" : "power3.out", // Mac使用更自然的减速效果
});
}
}
/**
* 平滑过渡停止所有动画
* 用于在需要中断当前动画时提供更自然的过渡,而不是硬性中断
* @param {Object} options 过渡选项
*/
smoothStopAnimations(options = {}) {
const duration = options.duration || 0.15; // 默认短暂过渡时间
// 处理缩放动画
if (this._zoomAnimation) {
const zoomObj = this._zoomAnimation.targets()[0];
const currentZoom = this.canvas.getZoom();
// 创建短暂的过渡动画到当前值
gsap.to(zoomObj, {
value: currentZoom,
duration: duration,
ease: this._isMac ? "circ.out" : "power1.out", // Mac使用更平滑的缓动
onUpdate: () => {
this.currentZoom.value = Math.round(zoomObj.value * 100);
this.canvas.renderAll();
},
onComplete: () => {
if (this._zoomAnimation) {
this._zoomAnimation.kill();
this._zoomAnimation = null;
}
},
});
}
// 处理平移动画
if (this._panAnimation) {
const panObj = this._panAnimation.targets()[0];
const vpt = this.canvas.viewportTransform;
const currentPos = { x: -vpt[4], y: -vpt[5] };
// 创建短暂的过渡动画到当前位置
gsap.to(panObj, {
x: currentPos.x,
y: currentPos.y,
duration: duration,
ease: this._isMac ? "circ.out" : "power1.out", // Mac使用更平滑的缓动
onUpdate: () => {
this._applyPan(panObj.x, panObj.y);
},
onComplete: () => {
if (this._panAnimation) {
this._panAnimation.kill();
this._panAnimation = null;
}
},
});
}
}
/**
* 设置画布交互动画
* 为对象交互添加流畅的动画效果
*/
setupInteractionAnimations() {
if (!this.canvas) return;
// 启用对象旋转的流畅动画
this._setupRotationAnimation();
}
/**
* 设置旋转动画
* @private
*/
_setupRotationAnimation() {
if (!fabric) return;
// 保存原始旋转方法
const originalRotate = fabric.Object.prototype.rotate;
const isMac = this._isMac; // 保存Mac检测结果
// 覆盖旋转方法以添加动画
fabric.Object.prototype.rotate = function (angle) {
const currentAngle = this.angle || 0;
if (Math.abs(angle - currentAngle) > 0.1) {
gsap.to(this, {
angle: angle,
duration: 0.3,
ease: isMac ? "back.out(0.3)" : "power2.out", // Mac使用轻微回弹
onUpdate: () => {
this.canvas && this.canvas.renderAll();
},
});
return this;
}
// 如果角度差异很小,使用原始方法
return originalRotate.call(this, angle);
};
}
/**
* 处理滚轮缩放,同时兼容正在进行的平移动画
* @param {Object} point 缩放中心点
* @param {Number} targetZoom 目标缩放值
* @param {Object} options 动画选项
*/
animateCombinedTransform(point, targetZoom, options = {}) {
if (!this.canvas) return;
// 限制缩放范围
targetZoom = Math.min(Math.max(targetZoom, 0.1), 20);
// 当前状态
const currentZoom = this.canvas.getZoom();
const currentVpt = [...this.canvas.viewportTransform];
const currentPos = { x: -currentVpt[4], y: -currentVpt[5] };
// 如果有正在进行的动画,先停止它们
if (this._combinedAnimation) {
this._combinedAnimation.kill();
this._combinedAnimation = null;
}
if (this._zoomAnimation) {
this._zoomAnimation.kill();
this._zoomAnimation = null;
}
if (this._panAnimation) {
this._panAnimation.kill();
this._panAnimation = null;
}
// 创建一个统一的变换对象来动画
const transform = {
zoom: currentZoom,
panX: currentVpt[4],
panY: currentVpt[5],
progress: 0, // 用于动画进度跟踪
};
// 获取平移目标位置(如果有的话)
let panTarget = { x: currentPos.x, y: currentPos.y };
if (this._wasPanning) {
// 如果之前有平移动画,尝试获取平移的目标位置
const vpt = this.canvas.viewportTransform;
panTarget = {
x: currentPos.x,
y: currentPos.y,
};
}
// 计算新的变换矩阵,同时考虑平移和缩放
const scaleFactor = targetZoom / currentZoom;
// 创建动画
this._combinedAnimation = gsap.to(transform, {
zoom: targetZoom,
progress: 1,
duration: options.duration || 0.3,
ease: options.ease || (this._isMac ? "expo.out" : "power2.out"), // Mac使用更平滑的缓动
onUpdate: () => {
// 计算当前动画阶段的混合变换
const currentScaleFactor = transform.zoom / currentZoom;
// 应用缩放
const vpt = this.canvas.viewportTransform;
vpt[0] = currentVpt[0] * (transform.zoom / currentZoom);
vpt[3] = currentVpt[3] * (transform.zoom / currentZoom);
// 平滑混合平移和缩放调整
const adjustX = (1 - currentScaleFactor) * point.x;
const adjustY = (1 - currentScaleFactor) * point.y;
// 如果存在平移目标,进行插值
if (this._wasPanning) {
const t = transform.progress;
const interpolatedX = currentPos.x * (1 - t) + panTarget.x * t;
const interpolatedY = currentPos.y * (1 - t) + panTarget.y * t;
// 结合缩放和平移的调整
vpt[4] = -interpolatedX * currentScaleFactor + adjustX;
vpt[5] = -interpolatedY * currentScaleFactor + adjustY;
} else {
// 只有缩放,保持中心点
vpt[4] = currentVpt[4] * currentScaleFactor + adjustX;
vpt[5] = currentVpt[5] * currentScaleFactor + adjustY;
}
// 更新缩放值显示
this.currentZoom.value = Math.round(transform.zoom * 100);
this.canvas.renderAll();
},
onComplete: () => {
this._combinedAnimation = null;
this._zoomAnimation = null;
this._panAnimation = null;
this._wasPanning = false;
this._wasZooming = false;
// 确保最终状态准确
this._applyZoom(point, targetZoom, true);
},
});
}
/**
* 清理资源
*/
dispose() {
if (this._zoomAnimation) {
this._zoomAnimation.kill();
this._zoomAnimation = null;
}
if (this._panAnimation) {
this._panAnimation.kill();
this._panAnimation = null;
}
this._wheelEvents = [];
this.canvas = null;
this.currentZoom = null;
}
}

View File

@@ -0,0 +1,167 @@
import { fabric } from 'fabric-with-all'
import { ref } from 'vue'
import { createCanvas } from '../tools/canvasFactory'
import { AnimationManager } from './AnimationManager'
import { detectDeviceType } from '../tools/index'
import { CanvasEventManager } from "./events/CanvasEventManager";
import { OperationType } from '../tools/layerHelper'
interface CanvasInitOptions {
canvasRef: any
canvasViewWidth?: number
canvasViewHeight?: number
canvasWidth?: number
canvasHeight?: number
}
export class CanvasManager {
stateManager: any
layerManager: any
animationManager: any
eventManager: any
deviceInfo: any
canvas: any
canvasViewWidth: number
canvasViewHeight: number
canvasWidth: number
canvasHeight: number
currentZoom: any
constructor(options) {
this.stateManager = options.stateManager;
this.deviceInfo = detectDeviceType();
this.currentZoom = ref(100)
}
setCanvasViewSize(options) {
this.canvasViewWidth = options.canvasViewWidth || 1920
this.canvasViewHeight = options.canvasViewHeight || 1080
}
initCanvas(options: CanvasInitOptions) {
this.layerManager = this.stateManager.layerManager
this.setCanvasViewSize(options)
this.canvasWidth = options.canvasWidth || 750
this.canvasHeight = options.canvasHeight || 600
this.canvas = createCanvas(options.canvasRef.value, {
width: this.canvasViewWidth,
height: this.canvasViewHeight,
preserveObjectStacking: true,
enableRetinaScaling: true,
stopContextMenu: true,
fireRightClick: true,
backgroundColor: '#fff',
})
this.canvas.clipPath = new fabric.Rect({
left: 0,
top: 0,
width: this.canvasWidth,
height: this.canvasHeight
})
// 画布居中
const canvasX = this.canvasViewWidth / 2 - this.canvasWidth / 2
const canvasY = this.canvasViewHeight / 2 - this.canvasHeight / 2
this.canvas.viewportTransform = [1, 0, 0, 1, canvasX, canvasY]
// 创建矩形
const rect = new fabric.Rect({
left: 20,
top: 20,
width: 100,
height: 100,
fill: '#f00',
info: {
id: 'rect1',
name: 'rect1',
}
})
this.canvas.add(rect)
//创建圆形
const circle = new fabric.Circle({
left: 200,
top: 200,
radius: 50,
fill: '#0f0',
info: {
id: 'circle',
name: 'circle',
}
})
this.canvas.add(circle)
// 文字
const text = new fabric.Text('Hello World', {
left: 300,
top: 300,
fontSize: 24,
fill: '#000',
info: {
id: 'text1',
name: 'text',
}
})
this.canvas.add(text)
// 文字
const text2 = new fabric.Text('Hello World', {
left: 300,
top: 300,
fontSize: 24,
fill: '#000',
info: {
id: 'text2',
name: 'tex2t',
}
})
this.canvas.add(text2)
this.animationManager = new AnimationManager(this.canvas, {
currentZoom: this.currentZoom,
canvasManager: this,
wheelThrottleTime: 15, // 降低滚轮事件节流时间,提高响应性
defaultEase: "power2.lin",
defaultDuration: 0.3, // 缩短默认动画时间
});
this.setupCanvasEvents()
this.stateManager.toolManager.setTool(OperationType.SELECT)
this.layerManager.updateLayers()
this.layerManager.setActiveID(text2.info.id)
}
setupCanvasEvents() {
// 创建画布事件管理器
this.eventManager = new CanvasEventManager(this.canvas, {
canvasManager: this,
animationManager: this.animationManager,
toolManager: this.stateManager.toolManager,
});
// 设置动画交互效果
this.animationManager.setupInteractionAnimations();
}
resetZoom() {
this.animationManager.resetZoom()
}
getObjects() {
return this.canvas.getObjects() || []
}
getObjectById(id: string) {
return this.getObjects().find((item: any) => item.info.id === id)
}
renderAll() {
this.canvas.renderAll()
}
deleteObjectById(id: string) {
const object = this.getObjectById(id)
if (object) {
this.canvas.remove(object)
this.layerManager.updateLayers()
this.renderAll()
}
}
// 拖拽排序
dragSort(id, newIndex) {
this.canvas.moveTo(this.getObjectById(id), newIndex)
this.layerManager.updateLayers()
this.renderAll()
}
getBitObjects() {
return this.getObjects().map(v => {
const object = v.toJSON("info");
return object
})
}
}

View File

@@ -1,4 +1,4 @@
import { TOOLS } from "./ToolManager"
import { OperationType } from "../tools/layerHelper"
export class EventManager {
stateManager: any
vueFlow: any
@@ -30,14 +30,14 @@ export class EventManager {
handleClick(event: any) {
this.stateManager.setActiveNodeID("")
const tool = this.stateManager.tool.value
if (tool === TOOLS.TEXT) {
if (tool === OperationType.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)
this.stateManager.toolManager.setTool(OperationType.SELECT)
}
}

View File

@@ -1,26 +0,0 @@
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,46 @@
import { ref } from 'vue'
export class LayerManager {
stateManager: any
canvasManager: any
layers: any
activeID: any
constructor(options) {
this.stateManager = options.stateManager;
this.canvasManager = options.canvasManager;
this.layers = ref([])
this.activeID = ref("")
}
setActiveID(id: string) { this.activeID.value = id }
getLayerByID(id) {
return this.layers.value.find((item: any) => item.info.id === id)
}
setLayerNameByID(id, name: string) {
const layer = this.getLayerByID(id)
if (layer) {
layer.info.name = name
this.canvasManager.renderAll()
}
}
setLayerVisibleByID(id, visible: boolean) {
const layer = this.getLayerByID(id)
if (layer) {
layer.set({
visible: visible
})
this.canvasManager.renderAll()
}
}
deleteLayerByID(id) {
this.canvasManager.deleteObjectById(id)
}
// 拖拽排序
dragSort(id, newIndex) {
const index = Math.abs(this.layers.value.length - newIndex - 1)
this.canvasManager.dragSort(id, index)
}
// 更新图层列表
updateLayers() {
this.layers.value = this.canvasManager.getObjects().reverse()
}
}

View File

@@ -1,133 +0,0 @@
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

@@ -4,183 +4,67 @@ 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
export class StateManager {
// 历史记录-撤回/重做
mxHistory: any
historyList: any
historyIndex: any
// 管理器
canvasManager: any
layerManager: 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.canvasManager && (this.canvasManager = options.canvasManager)
options.layerManager && (this.layerManager = options.layerManager)
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
// 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)
// 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)
// 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

@@ -1,52 +1,69 @@
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,
},
]
import { ref } from 'vue'
import { OperationType } from '../tools/layerHelper'
export class ToolManager {
stateManager: any
vueFlow: any
canvasManager: any
currentTool: any
tools: any[]
constructor(options) {
this.stateManager = options.stateManager;
this.vueFlow = options.vueFlow
this.setTool(TOOLS.SELECT)
this.canvasManager = options.canvasManager;
this.currentTool = ref(null)
this.tools = [
/** 选择工具 */
{
name: OperationType.SELECT,
cursor: "default",
setup: this.setupSelectTool.bind(this),
selection: true,
},
/** 移动工具 */
{
name: OperationType.PAN,
cursor: "grab",
setup: this.setupMoveTool.bind(this),
},
/** 画笔工具 */
{
name: OperationType.DRAW,
cursor: "crosshair",
},
/** 橡皮擦工具 */
{
name: OperationType.ERASER,
cursor: "crosshair",
},
/** 智能选框工具 */
{
name: OperationType.SELECTBOX,
cursor: "crosshair",
},
/** 矩形工具 */
{
name: OperationType.RECTANGLE,
cursor: "crosshair",
},
]
}
setTool(value: string) {
const tool = tools.find((t) => t.name === value)
const tool = this.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 || "")
}
this.currentTool.value = tool.name
this.canvasManager.canvas.defaultCursor = tool.cursor
this.setCanvasEvented(!!tool.selection)
this.canvasManager.canvas.isDragging = !!tool.isDragging
if (tool.setup) tool.setup()
}
// 切换工具时,设置画布事件
setCanvasEvented(value: boolean) {
this.canvasManager.canvas.selection = value
this.canvasManager.canvas.getObjects().forEach((v) => v.evented = value)
}
/** 选择工具 */
setupSelectTool() {
}
/** 移动工具 */
setupMoveTool() {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,762 @@
/**
* 键盘管理器
* 负责处理编辑器中的键盘事件和快捷键
* 支持PC、Mac和iPad三端适配
*/
export class KeyboardManager {
/**
* 创建键盘管理器
* @param {Object} options 配置选项
* @param {Object} options.toolManager 工具管理器实例
* @param {Object} options.commandManager 命令管理器实例
* @param {Object} options.layerManager 图层管理器实例
* @param {Object} options.canvasManager 画布管理器实例
* @param {Function} options.pasteText 粘贴文本回调函数
* @param {Function} options.pasteImage 粘贴图片回调函数
* @param {Ref<Boolean>} options.isRedGreenMode 是否为红绿模式
* @param {HTMLElement} options.container 容器元素,用于添加事件监听
*/
constructor(options = {}) {
this.toolManager = options.toolManager;
this.commandManager = options.commandManager;
this.layerManager = options.layerManager;
this.canvasManager = options.canvasManager;
this.container = options.container || document;
this.pasteText = options.pasteText || (() => { });
this.pasteImage = options.pasteImage || (() => { });
this.isRedGreenMode = options.isRedGreenMode;
// 检测平台类型
this.platform = this.detectPlatform();
this.isTouchDevice = this.detectTouchDevice();
// 快捷键的平台特定键名
this.modifierKeys = {
ctrl: this.platform === "mac" ? "meta" : "ctrl",
cmdOrCtrl: this.platform === "mac" ? "meta" : "ctrl",
alt: "alt",
shift: "shift",
option: "alt", // Mac 特有,等同于 alt
cmd: "meta", // Mac 特有,等同于 Command
};
// 快捷键显示的平台特定符号
this.keySymbols = {
ctrl: this.platform === "mac" ? "⌃" : "Ctrl",
meta: this.platform === "mac" ? "⌘" : "Win",
alt: this.platform === "mac" ? "⌥" : "Alt",
shift: this.platform === "mac" ? "⇧" : "Shift",
escape: "Esc",
space: "空格",
};
// 快捷键映射表 - 可通过配置进行扩展
this.shortcuts = this.initShortcuts();
// 触摸相关状态
this.touchState = {
pinchStartDistance: 0,
pinchStartBrushSize: 0,
touchStartX: 0,
touchStartY: 0,
isTwoFingerTouch: false,
};
// 临时工具状态
this.tempToolState = {
active: false,
originalTool: null,
};
// 事件绑定
this._handleKeyDown = this.handleKeyDown.bind(this);
this._handleKeyUp = this.handleKeyUp.bind(this);
this._handlePaste = this.handlePaste.bind(this);
this._handleTouchStart = this.handleTouchStart.bind(this);
this._handleTouchMove = this.handleTouchMove.bind(this);
this._handleTouchEnd = this.handleTouchEnd.bind(this);
// 已注册的自定义事件处理程序
this.customHandlers = {};
}
/**
* 检测当前平台
* @returns {'mac'|'windows'|'ios'|'android'|'other'} 平台类型
*/
detectPlatform() {
const userAgent = navigator.userAgent.toLowerCase();
if (userAgent.indexOf("mac") !== -1) return "mac";
if (userAgent.indexOf("win") !== -1) return "windows";
if (/(iphone|ipad|ipod)/.test(userAgent)) return "ios";
if (userAgent.indexOf("android") !== -1) return "android";
return "other";
}
/**
* 检测是否为触摸设备
* @returns {boolean} 是否为触摸设备
*/
detectTouchDevice() {
return (
"ontouchstart" in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0
);
}
/**
* 初始化快捷键配置
* @returns {Object} 快捷键配置
*/
initShortcuts() {
const cmdOrCtrl = this.modifierKeys.cmdOrCtrl;
// 基本快捷键映射,将在构建时根据平台类型自动调整
return {
// 撤销/重做
[`${cmdOrCtrl}+z`]: { action: "undo", description: "撤销" },
[`${cmdOrCtrl}+shift+z`]: { action: "redo", description: "重做" },
[`${cmdOrCtrl}+y`]: { action: "redo", description: "重做" },
// 复制/粘贴
[`${cmdOrCtrl}+c`]: { action: "copy", description: "复制" },
[`${cmdOrCtrl}+v`]: { action: "paste", description: "粘贴", noStop: true },
[`${cmdOrCtrl}+x`]: { action: "cut", description: "剪切" },
// 删除
delete: { action: "delete", description: "删除" },
backspace: { action: "delete", description: "删除" },
up: { action: "up", description: "上" },
down: { action: "down", description: "下" },
left: { action: "left", description: "左" },
right: { action: "right", description: "右" },
// 选择
[`${cmdOrCtrl}+a`]: { action: "selectAll", description: "全选" },
escape: { action: "clearSelection", description: "取消选择" },
// 保存
[`${cmdOrCtrl}+s`]: { action: "save", description: "保存" },
// 工具切换 (这些会由工具管理器处理)
v: { action: "selectTool", param: "select", description: "选择工具" },
b: { action: "selectTool", param: "draw", description: "画笔工具" },
e: { action: "selectTool", param: "eraser", description: "橡皮擦" },
i: { action: "selectTool", param: "eyedropper", description: "吸色工具" },
h: { action: "selectTool", param: "pan", description: "移动画布" },
l: { action: "selectTool", param: "lasso", description: "套索工具" },
m: {
action: "selectTool",
param: "area_custom",
description: "自由选区工具",
},
w: { action: "selectTool", param: "wave", description: "波浪工具" },
j: { action: "selectTool", param: "liquify", description: "液化工具" },
// 数值调整
"shift+[": {
action: "decreaseTextureScale",
description: "减小材质图片大小",
},
"shift+]": {
action: "increaseTextureScale",
description: "增大材质图片大小",
},
"[": { action: "decreaseBrushSize", param: 1, description: "减小画笔" },
"]": { action: "increaseBrushSize", param: 1, description: "增大画笔" },
",": {
action: "decreaseBrushOpacity",
param: 0.01,
description: "减小透明度",
},
".": {
action: "increaseBrushOpacity",
param: 0.01,
description: "增大透明度",
},
// 空格 - 临时切换到手型工具
space: {
action: "toggleTempTool",
param: "pan",
description: "临时切换到手形工具",
},
// 图层操作
[`${cmdOrCtrl}+shift+n`]: { action: "newLayer", description: "新建图层" },
[`${cmdOrCtrl}+g`]: { action: "groupLayers", description: "组合图层" },
[`${cmdOrCtrl}+o`]: {
action: "addImageToNewLayer",
description: "上传图片到新图层",
},
[`${cmdOrCtrl}+shift+g`]: {
action: "ungroupLayers",
description: "取消组合",
},
[`${cmdOrCtrl}+j`]: { action: "mergeLayers", description: "合并图层" },
// iPad特有的快捷键(当无法使用键盘时)
...(this.platform === "ios" && {
two_finger_tap: {
action: "contextMenu",
description: "显示上下文菜单",
},
three_finger_swipe_left: { action: "undo", description: "撤销" },
three_finger_swipe_right: { action: "redo", description: "重做" },
}),
};
}
/**
* 处理粘贴事件
* @param {ClipboardEvent} event 粘贴事件
*/
handlePaste(event) {
event.preventDefault(); // 阻止默认粘贴行为
if (this.isRedGreenMode.value) return;
const text = event.clipboardData?.getData("text/plain") || "";
if (/^aida_copy_canvas_layer/.test(text)) return;
const items = event.clipboardData?.items || [];
// console.log(this);
for (const item of items) {
if (item.type.indexOf("text/plain") !== -1) {
item.getAsString((text) => {
this.pasteText(text);
});
} else if (item.type.indexOf("image") !== -1) {
const blob = item.getAsFile();
this.pasteImage(blob);
}
}
}
/**
* 初始化并开始监听键盘事件
*/
init() {
// 添加键盘事件监听
this.container.addEventListener("keydown", this._handleKeyDown);
this.container.addEventListener("keyup", this._handleKeyUp);
this.container.addEventListener("paste", this._handlePaste);
// 如果是触摸设备,添加触摸事件监听
if (this.isTouchDevice) {
this.container.addEventListener("touchstart", this._handleTouchStart);
this.container.addEventListener("touchmove", this._handleTouchMove);
this.container.addEventListener("touchend", this._handleTouchEnd);
this.container.addEventListener("touchcancel", this._handleTouchEnd);
}
// console.log(`键盘管理器已初始化,平台: ${this.platform}, 触摸设备: ${this.isTouchDevice}`);
}
/**
* hide模式下关闭快捷键
*/
removeEvents() {
// 移除键盘事件监听
this.container.removeEventListener("keydown", this._handleKeyDown);
this.container.removeEventListener("keyup", this._handleKeyUp);
this.container.removeEventListener("paste", this._handlePaste);
// 如果是触摸设备,移除触摸事件监听
if (this.isTouchDevice) {
this.container.removeEventListener("touchstart", this._handleTouchStart);
this.container.removeEventListener("touchmove", this._handleTouchMove);
this.container.removeEventListener("touchend", this._handleTouchEnd);
this.container.removeEventListener("touchcancel", this._handleTouchEnd);
}
}
/**
* 处理键盘按下事件
* @param {KeyboardEvent} event 键盘事件
*/
handleKeyDown(event) {
// 如果当前焦点在输入框内,不处理大部分快捷键
if (this.isInputActive() && !["Escape", "Tab"].includes(event.key)) {
return;
}
// 构建快捷键标识符
const shortcutKey = this.buildShortcutKey(event);
// 查找并执行快捷键动作
const shortcut = this.shortcuts[shortcutKey];
if (shortcut) {
// 阻止默认行为,例如浏览器的保存对话框等
if (shortcutKey.includes(`${this.modifierKeys.cmdOrCtrl}+`) && !shortcut.noStop) {
event.preventDefault();
}
this.executeAction(shortcut.action, shortcut.param, event);
return;
}
// 工具快捷键处理
if (this.toolManager && !event.ctrlKey && !event.metaKey && !event.altKey) {
this.toolManager.handleKeyboardShortcut(event);
}
}
/**
* 处理键盘释放事件
* @param {KeyboardEvent} event 键盘事件
*/
handleKeyUp(event) {
// 当空格键释放时,如果是临时工具,切回原始工具
if (event.key === " " && this.tempToolState.active) {
this.restoreTempTool();
}
// 调用自定义处理程序
const key = event.key.toLowerCase();
if (this.customHandlers[key] && typeof this.customHandlers[key].onKeyUp === "function") {
this.customHandlers[key].onKeyUp(event);
}
}
/**
* 处理触摸开始事件
* @param {TouchEvent} event 触摸事件
*/
handleTouchStart(event) {
const touches = event.touches;
// 存储初始状态以便后续计算
if (touches.length === 2) {
// 双指触摸 - 可用于缩放或调整画笔大小
this.touchState.isTwoFingerTouch = true;
this.touchState.pinchStartDistance = this.getDistanceBetweenTouches(touches[0], touches[1]);
// 如果有画笔管理器,记录起始画笔大小
if (this.toolManager && this.toolManager.brushManager) {
this.touchState.pinchStartBrushSize = this.toolManager.brushManager.brushSize.value;
}
} else if (touches.length === 3) {
// 三指触摸 - 可用于撤销/重做
this.touchState.touchStartX = touches[0].clientX;
}
}
/**
* 处理触摸移动事件
* @param {TouchEvent} event 触摸事件
*/
handleTouchMove(event) {
const touches = event.touches;
// 阻止默认行为(例如滚动)
if (touches.length >= 2) {
event.preventDefault();
}
// 双指缩放处理 - 调整画笔大小
if (touches.length === 2 && this.touchState.isTwoFingerTouch) {
const currentDistance = this.getDistanceBetweenTouches(touches[0], touches[1]);
const scale = currentDistance / this.touchState.pinchStartDistance;
// 调整画笔大小
if (this.toolManager && this.toolManager.brushManager && scale !== 1) {
const newSize = this.touchState.pinchStartBrushSize * scale;
this.toolManager.brushManager.setBrushSize(newSize);
}
}
// 三指滑动处理 - 撤销/重做
else if (touches.length === 3) {
const deltaX = touches[0].clientX - this.touchState.touchStartX;
// 滑动超过50px认为是有效的手势
if (Math.abs(deltaX) > 50) {
if (deltaX < 0) {
// 向左滑动 - 撤销
this.executeAction("undo");
} else {
// 向右滑动 - 重做
this.executeAction("redo");
}
// 更新起始位置,防止连续触发
this.touchState.touchStartX = touches[0].clientX;
}
}
}
/**
* 处理触摸结束事件
* @param {TouchEvent} event 触摸事件
*/
handleTouchEnd(event) {
// 检测双指轻拍 (两个手指几乎同时按下,又几乎同时抬起)
if (this.touchState.isTwoFingerTouch && event.touches.length === 0) {
if (new Date().getTime() - this.touchState.touchStartTime < 300) {
// 双指轻拍 - 可以触发上下文菜单
this.executeAction("contextMenu");
}
}
// 重置触摸状态
this.touchState.isTwoFingerTouch = false;
}
/**
* 计算两个触摸点之间的距离
* @param {Touch} touch1 第一个触摸点
* @param {Touch} touch2 第二个触摸点
* @returns {number} 两点间距离
*/
getDistanceBetweenTouches(touch1, touch2) {
const dx = touch1.clientX - touch2.clientX;
const dy = touch1.clientY - touch2.clientY;
return Math.sqrt(dx * dx + dy * dy);
}
/**
* 执行快捷键对应的动作
* @param {string} action 动作名称
* @param {*} param 动作参数
* @param {Event} event 原始事件
*/
executeAction(action, param, event) {
switch (action) {
case "undo":
if (this.commandManager) {
this.commandManager.undo();
}
break;
case "redo":
if (this.commandManager) {
this.commandManager.redo();
}
break;
case "copy":
// 复制逻辑
// console.log("复制当前选中图层");
if (this.isRedGreenMode.value) return;
this.layerManager.copyLayer(this.layerManager.activeLayerId.value);
break;
case "paste":
// 粘贴逻辑
// console.log("粘贴");
if (this.isRedGreenMode.value) return;
this.layerManager.pasteLayer();
break;
case "cut":
// 剪切逻辑
// console.log("剪切");
if (this.isRedGreenMode.value) return;
this.layerManager.cutLayer(this.layerManager.activeLayerId.value);
break;
case "delete":
// 删除逻辑
// console.log("删除");
if (this.isRedGreenMode.value) return;
this.layerManager.removeLayer(this.layerManager.activeLayerId.value);
break;
case "selectAll":
// 全选逻辑
// console.log("全选");
if (this.isRedGreenMode.value) return;
// 这里需要实现全选逻辑 TODO: 是否在选择模式下才可以全选?
if (this.layerManager) {
this.layerManager.selectAll();
}
break;
case "clearSelection":
// 清除选择逻辑
// console.log("清除选择");
// 这里需要实现清除选择逻辑
if (this.layerManager) {
this.layerManager.clearSelection();
}
break;
case "save":
// 保存逻辑
// console.log("保存");
break;
case "selectTool":
// 选择工具
if (this.toolManager && param) {
this.toolManager.setToolWithCommand(param);
}
break;
case "up":
case "down":
case "left":
case "right":
// 方向键逻辑
this.canvasManager.moveActiveObject(action);
break;
case "increaseBrushSize":
// 增大画笔尺寸
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 5;
this.toolManager.brushManager.increaseBrushSize(amount);
}
break;
case "decreaseBrushSize":
// 减小画笔尺寸
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 5;
this.toolManager.brushManager.decreaseBrushSize(amount);
}
break;
case "increaseBrushOpacity":
// 增大画笔透明度
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 0.01;
this.toolManager.brushManager.increaseBrushOpacity(amount);
}
break;
case "decreaseTextureScale":
// 减小画笔材质图片大小
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 5;
this.toolManager.brushManager.decreaseBrushSize(amount);
}
break;
case "increaseTextureScale":
// 增大画笔材质图片大小
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 0.01;
this.toolManager.brushManager.increaseTextureScale(amount);
}
break;
case "decreaseBrushOpacity":
// 减小画笔透明度
if (this.toolManager && this.toolManager.brushManager) {
const amount = param || 0.01;
this.toolManager.brushManager.decreaseBrushOpacity(amount);
}
break;
case "toggleTempTool":
// 临时切换工具
if (param && this.toolManager) {
this.setTempTool(param);
}
break;
case "newLayer":
// 创建新图层
if (this.layerManager) {
this.layerManager.createNewLayer();
}
break;
case "addImageToNewLayer":
this.toolManager?.openFile?.();
break;
case "groupLayers":
// 组合图层
if (this.layerManager) {
this.layerManager.groupSelectedLayers();
}
break;
case "ungroupLayers":
// 解组图层
if (this.layerManager) {
this.layerManager.ungroupSelectedLayer();
}
break;
case "mergeLayers":
// 合并图层
if (this.layerManager) {
this.layerManager.mergeSelectedLayers();
}
break;
case "contextMenu":
// 上下文菜单(通常由右击或触控设备上的特定手势触发)
// console.log("显示上下文菜单");
// 这里需要实现显示上下文菜单的逻辑
break;
default:
// 调用自定义注册的动作处理
if (this.customHandlers[action]) {
this.customHandlers[action].execute(param, event);
}
}
}
/**
* 设置临时工具
* @param {string} toolId 临时工具ID
*/
setTempTool(toolId) {
if (!this.toolManager || this.tempToolState.active) return;
// 保存当前工具
this.tempToolState.originalTool = this.toolManager.getCurrentTool();
this.tempToolState.active = true;
// 切换到临时工具
this.toolManager.setTool(toolId);
}
/**
* 恢复临时工具切换前的工具
*/
restoreTempTool() {
if (!this.toolManager || !this.tempToolState.active) return;
// 恢复到原始工具
if (this.tempToolState.originalTool) {
this.toolManager.setTool(this.tempToolState.originalTool);
}
// 重置状态
this.tempToolState.active = false;
this.tempToolState.originalTool = null;
}
/**
* 构建快捷键标识符
* @param {KeyboardEvent} event 键盘事件
* @returns {string} 快捷键标识符
*/
buildShortcutKey(event) {
let shortcutKey = "";
// 统一处理Mac和PC的修饰键
if ((this.platform === "mac" && event.metaKey) || (this.platform !== "mac" && event.ctrlKey)) {
shortcutKey += `${this.modifierKeys.cmdOrCtrl}+`;
} else if (event.ctrlKey) {
shortcutKey += "ctrl+";
}
if (event.shiftKey) shortcutKey += "shift+";
if (event.altKey) shortcutKey += "alt+";
const key = event.key.toLowerCase();
// 特殊键处理
switch (key) {
case " ":
shortcutKey += "space";
break;
case "arrowup":
shortcutKey += "up";
break;
case "arrowdown":
shortcutKey += "down";
break;
case "arrowleft":
shortcutKey += "left";
break;
case "arrowright":
shortcutKey += "right";
break;
default:
shortcutKey += key;
}
return shortcutKey;
}
/**
* 检查当前是否有输入框处于活动状态
* @returns {boolean} 是否有输入框处于活动状态
*/
isInputActive() {
const activeElement = document.activeElement;
const tagName = activeElement.tagName.toLowerCase();
return (
tagName === "input" ||
tagName === "textarea" ||
activeElement.getAttribute("contenteditable") === "true"
);
}
/**
* 获取所有可用的快捷键
* @returns {Array} 快捷键列表
*/
getShortcuts() {
return Object.entries(this.shortcuts).map(([key, value]) => ({
key,
displayKey: this.formatShortcutForDisplay(key),
...value,
}));
}
/**
* 格式化快捷键以便显示
* @param {string} shortcut 快捷键标识符
* @returns {string} 格式化后的快捷键显示
*/
formatShortcutForDisplay(shortcut) {
// 将快捷键格式化为适合当前平台显示的形式
return shortcut
.split("+")
.map((key) => {
// 将键名转换为显示符号
return this.keySymbols[key.toLowerCase()] || key.toUpperCase();
})
.join("+");
}
/**
* 注册自定义快捷键处理程序
* @param {string} action 动作名称
* @param {Object} handler 处理程序对象
* @param {Function} handler.execute 执行函数
* @param {Function} handler.onKeyUp 键释放处理函数(可选)
* @param {string} description 描述
*/
registerCustomHandler(action, handler, description = "") {
if (!action || typeof handler.execute !== "function") {
console.error("无效的自定义处理程序");
return;
}
this.customHandlers[action] = handler;
// 如果提供了快捷键,添加到快捷键映射
if (handler.shortcut) {
this.shortcuts[handler.shortcut] = {
action,
description: description || handler.description || action,
};
}
}
/**
* 清理资源
*/
dispose() {
// 移除事件监听
this.removeEvents();
// 清除引用
this.toolManager = null;
this.commandManager = null;
this.layerManager = null;
this.container = null;
this.customHandlers = {};
this.tempToolState = { active: false, originalTool: null };
this.touchState = {};
}
}

View File

@@ -0,0 +1,34 @@
import { fabric } from "fabric-with-all";
/**
* Factory for creating optimized fabric canvas instances
*/
export const createCanvas = (elementId, options = {}) => {
// Create the canvas instance
const canvas = new fabric.Canvas(elementId, {
enableRetinaScaling: true,
renderOnAddRemove: false,
preserveObjectStacking: true, // 保持对象堆叠顺序
// skipOffscreen: true, // 跳过离屏渲染
imageSmoothingEnabled: true, // 启用图像平滑 - 抗锯齿
imageSmoothingQuality: "high", // 设置高质量图像平滑
...options,
});
return canvas;
};
/**
* Utility to create a static canvas (for improved performance when interaction is not needed)
*/
export const createStaticCanvas = (elementId, options = {}) => {
const canvas = new fabric.StaticCanvas(elementId, {
enableRetinaScaling: true,
imageSmoothingEnabled: true, // 启用图像平滑 - 抗锯齿
imageSmoothingQuality: "high", // 设置高质量图像平滑
skipOffscreen: false, // 不跳过离屏渲染
...options,
});
return canvas;
};

View File

@@ -0,0 +1,61 @@
/**
* 精确检测设备类型,区分 PC、Mac、平板和移动设备
* @private
* @returns {Object} 设备信息对象
*/
export const detectDeviceType = () => {
const userAgent = navigator.userAgent.toLowerCase();
const platform = navigator.platform.toLowerCase();
const hasTouchSupport =
"ontouchstart" in window || navigator.maxTouchPoints > 0;
// 检测操作系统
const isMac = /mac|darwin/.test(platform) || /macintosh/.test(userAgent);
const isWindows = /win/.test(platform);
const isLinux = /linux/.test(platform) && !/android/.test(userAgent);
// 检测设备类型 - 修复iPad检测逻辑
const isMobile = /mobile|phone|android.*mobile|iphone/.test(userAgent);
// 修复iPad检测包括iOS iPad和Android平板
const isTablet =
/tablet|ipad|android(?!.*mobile)/.test(userAgent) ||
/ipad/.test(userAgent) ||
(navigator.maxTouchPoints &&
navigator.maxTouchPoints > 1 &&
/mac/.test(userAgent));
const isDesktop = !isMobile && !isTablet;
// 检测浏览器类型(用于特定优化)
const isSafari = /safari/.test(userAgent) && !/chrome/.test(userAgent);
const isChrome = /chrome/.test(userAgent);
const isFirefox = /firefox/.test(userAgent);
// 调试日志 - 仅在开发环境输出
if (process.env.NODE_ENV === "development") {
// console.log("设备检测结果:", {
// userAgent,
// platform,
// isMobile,
// isTablet,
// isDesktop,
// hasTouchSupport,
// maxTouchPoints: navigator.maxTouchPoints,
// });
}
return {
isMac,
isWindows,
isLinux,
isMobile,
isTablet,
isDesktop,
isSafari,
isChrome,
isFirefox,
hasTouchSupport,
// 判断是否应该使用触摸事件作为主要交互方式
preferTouchEvents: (isMobile || isTablet) && !isDesktop,
// 判断是否需要特殊的 Mac 触控板处理
needsMacTrackpadOptimization: isMac && isDesktop && hasTouchSupport,
};
}

View File

@@ -0,0 +1,66 @@
/**
* 图层类型枚举
*/
export const LayerType = {
EMPTY: "empty", // 空图层
BITMAP: "bitmap", // 位图图层
VECTOR: "vector", // 矢量图层
TEXT: "text", // 文字图层
GROUP: "group", // 组图层
ADJUSTMENT: "adjustment", // 调整图层
SMART_OBJECT: "smartObject", // 智能对象
SHAPE: "shape", // 形状图层
VIDEO: "video", // 视频图层 (预留)
AUDIO: "audio", // 音频图层 (预留)
FIXED: "fixed", // 固定图层 - 位于背景图层之上,普通图层之下
BACKGROUND: "background", // 背景图层 - 位于固定图层之、普通图层之下
};
/**
* 画布操作模式枚举draw(绘画)、select(选择)、pan(拖拽)....
*/
export const OperationType = {
// 编辑器模式
DISABLED: "disabled", // 禁用
SELECT: "select",// 选择工具模式
PAN: "pan", // 拖拽模式
DRAW: "draw", // 绘画模式
ERASER: "eraser", // 橡皮擦模式
IMAGE: "image",// 图片工具模式
SELECTBOX: "selectbox",// 选择框工具模式
RECTANGLE: "rectangle",// 矩形工具模式
TEXT: "text",// 文字工具模式
UNDO: "undo",// 撤销工具模式
REDO: "redo",// 重做工具模式
};
// 所有操作模式类型列表
export const OperationTypes = Object.values(OperationType);
/**
* 混合模式枚举
* 与 fabricjs 和 CSS3 的 globalCompositeOperation 对应
*/
export const BlendMode = {
NORMAL: "source-over", // 正常模式
MULTIPLY: "multiply", // 正片叠底
SCREEN: "screen", // 滤色
OVERLAY: "overlay", // 叠加
DARKEN: "darken", // 变暗
LIGHTEN: "lighten", // 变亮
COLOR_DODGE: "color-dodge", // 颜色减淡
COLOR_BURN: "color-burn", // 颜色加深
HARD_LIGHT: "hard-light", // 强光
SOFT_LIGHT: "soft-light", // 柔光
DIFFERENCE: "difference", // 差值
EXCLUSION: "exclusion", // 排除
HUE: "hue", // 色相
SATURATION: "saturation", // 饱和度
COLOR: "color", // 颜色
LUMINOSITY: "luminosity", // 明度
DESTINATION_IN: "destination-in", // 目标内
DESTINATION_OUT: "destination-out", // 目标外
};

View File

@@ -0,0 +1,72 @@
<!-- ImagePreview.vue -->
<template>
<div v-if="modelValue" class="image-preview-modal" @click="closePreview">
<div class="image-preview-container" @click.stop>
<img :src="url" alt="Preview Image" class="preview-image" />
<button class="close-btn" @click="closePreview">×</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
url: string
modelValue: boolean
}>()
const emits = defineEmits(['update:modelValue'])
const closePreview = () => {
emits('update:modelValue', false)
}
</script>
<style lang="less" scoped>
.image-preview-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.image-preview-container {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
justify-content: center;
align-items: center;
}
.preview-image {
max-width: 100%;
max-height: 90vh;
border-radius: 0.8rem;
display: block;
}
.close-btn {
position: absolute;
top: -2rem;
right: -2rem;
background: white;
border: none;
font-size: 3rem;
cursor: pointer;
color: #606266;
width: 4rem;
height: 4rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
</style>