Merge branch 'main' of ssh://18.167.251.121:10002/aidlab/FiDA_Front
This commit is contained in:
4
src/assets/icons/dc/hide.svg
Normal file
4
src/assets/icons/dc/hide.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M1.52856 1.52876C1.78891 1.26841 2.21102 1.26841 2.47137 1.52876L14.4714 13.5288C14.7317 13.7891 14.7317 14.2112 14.4714 14.4716C14.211 14.7319 13.7889 14.7319 13.5286 14.4716L11.8523 12.7953C10.8026 13.4938 9.51597 14.0002 7.99997 14.0002C5.89718 14.0002 4.24037 13.0275 3.03903 11.9196C1.84104 10.8148 1.0565 9.5415 0.674858 8.83761C0.390009 8.31223 0.390478 7.68717 0.675086 7.1623C1.06023 6.45201 1.86133 5.15114 3.09055 4.03355L1.52856 2.47157C1.26822 2.21122 1.26822 1.78911 1.52856 1.52876ZM4.03457 4.97757C2.93812 5.95924 2.20475 7.13846 1.8472 7.79786C1.77728 7.92679 1.77735 8.07364 1.84699 8.20209C2.19241 8.83919 2.89458 9.97267 3.94294 10.9395C4.98794 11.9032 6.33712 12.6668 7.99997 12.6668C9.09957 12.6668 10.0599 12.3336 10.8868 11.8298L9.59732 10.5403C8.43827 11.2706 6.8882 11.131 5.87864 10.1215C4.86909 9.11193 4.72948 7.56186 5.45981 6.40281L4.03457 4.97757ZM6.44811 7.39112L8.60901 9.55201C8.01093 9.78639 7.30472 9.66194 6.82145 9.17867C6.33819 8.69541 6.21374 7.9892 6.44811 7.39112Z" fill="#0D0D0D"/>
|
||||
<path d="M6.81556 3.46675C7.18897 3.38117 7.58347 3.3335 7.99997 3.3335C9.66282 3.3335 11.012 4.09716 12.057 5.06087C13.1054 6.02766 13.8075 7.16114 14.153 7.79823C14.2223 7.92613 14.2225 8.07385 14.1523 8.2033C13.9632 8.55189 13.664 9.05544 13.2557 9.60051C13.035 9.89521 13.095 10.313 13.3897 10.5338C13.6844 10.7545 14.1022 10.6945 14.3229 10.3998C14.7784 9.79162 15.1118 9.23078 15.3243 8.83897C15.6087 8.31464 15.6102 7.68865 15.3251 7.16272C14.9434 6.45883 14.1589 5.18548 12.9609 4.0807C11.7596 2.97282 10.1028 2.00016 7.99997 2.00016C7.48067 2.00016 6.98628 2.05972 6.51772 2.16711C6.15883 2.24935 5.93457 2.60696 6.01682 2.96585C6.09907 3.32473 6.45668 3.54899 6.81556 3.46675Z" fill="#0D0D0D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,4 +1,5 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="8" fill="white"/>
|
||||
<path d="M5.03431 5.03431C5.34673 4.7219 5.85327 4.7219 6.16569 5.03431L8 6.86863L9.83432 5.03431C10.1467 4.7219 10.6533 4.7219 10.9657 5.03431C11.2781 5.34673 11.2781 5.85327 10.9657 6.16569L9.13137 8L10.9657 9.83432C11.2781 10.1467 11.2781 10.6533 10.9657 10.9657C10.6533 11.2781 10.1467 11.2781 9.83432 10.9657L8 9.13137L6.16569 10.9657C5.85327 11.2781 5.34673 11.2781 5.03431 10.9657C4.7219 10.6533 4.7219 10.1467 5.03431 9.83432L6.86863 8L5.03431 6.16569C4.7219 5.85327 4.7219 5.34673 5.03431 5.03431Z" fill="#CDCDCD"/>
|
||||
<path d="M8 1.6C4.46538 1.6 1.6 4.46538 1.6 8C1.6 11.5346 4.46538 14.4 8 14.4C11.5346 14.4 14.4 11.5346 14.4 8C14.4 4.46538 11.5346 1.6 8 1.6ZM0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8Z" fill="#CDCDCD"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 896 B After Width: | Height: | Size: 939 B |
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
850
src/components/Canvas/DepthCanvas/manager/AnimationManager.js
Normal file
850
src/components/Canvas/DepthCanvas/manager/AnimationManager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
167
src/components/Canvas/DepthCanvas/manager/CanvasManager.ts
Normal file
167
src/components/Canvas/DepthCanvas/manager/CanvasManager.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
46
src/components/Canvas/DepthCanvas/manager/LayerManager.ts
Normal file
46
src/components/Canvas/DepthCanvas/manager/LayerManager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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_)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 = {};
|
||||
}
|
||||
}
|
||||
34
src/components/Canvas/DepthCanvas/tools/canvasFactory.js
Normal file
34
src/components/Canvas/DepthCanvas/tools/canvasFactory.js
Normal 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;
|
||||
};
|
||||
61
src/components/Canvas/DepthCanvas/tools/index.ts
Normal file
61
src/components/Canvas/DepthCanvas/tools/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
66
src/components/Canvas/DepthCanvas/tools/layerHelper.js
Normal file
66
src/components/Canvas/DepthCanvas/tools/layerHelper.js
Normal 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", // 目标外
|
||||
};
|
||||
72
src/components/Preview/Preview.vue
Normal file
72
src/components/Preview/Preview.vue
Normal 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>
|
||||
@@ -2,41 +2,26 @@
|
||||
<div class="assist-input-wrapper flex flex-col" :class="{ agent: isAgentMode }">
|
||||
<div class="animate-container flex-1 flex flex-col">
|
||||
<div class="scroll-content flex-col">
|
||||
<!-- 图片预览区域 -->
|
||||
<div v-if="uploadedImages.length > 0" class="image-preview-list flex wrap">
|
||||
<div
|
||||
v-for="(image, index) in uploadedImages"
|
||||
:key="index"
|
||||
class="image-preview-item"
|
||||
>
|
||||
<img :src="image.url" :alt="image.name" class="preview-image" />
|
||||
<img
|
||||
:src="image.url"
|
||||
:alt="image.name"
|
||||
class="preview-image"
|
||||
@click="previewImage(image.url)"
|
||||
/>
|
||||
<div class="image-remove-btn" @click="removeImage(index)">
|
||||
<SvgIcon name="delete" size="16" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 编辑区域 -->
|
||||
<div class="editor-wrapper">
|
||||
<!-- 静态占位符 - 当编辑器为空时显示 -->
|
||||
<div
|
||||
v-if="showPlaceholder"
|
||||
class="editor-placeholder"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
padding: '0 1.4rem 1.4rem',
|
||||
fontSize: '2rem',
|
||||
fontFamily: 'InterRegular',
|
||||
fontWeight: 400,
|
||||
color: '#999',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
width: 'calc(100% - 2.8rem)',
|
||||
boxSizing: 'border-box'
|
||||
}"
|
||||
>
|
||||
<div v-if="showPlaceholder" class="editor-placeholder">
|
||||
{{ $t('Input.placeholder') }}
|
||||
</div>
|
||||
<div
|
||||
@@ -205,6 +190,7 @@
|
||||
<SvgIcon class="light-icon" name="light" size="16" />
|
||||
<span>{{ $t('Input.trendingReport') }}</span>
|
||||
</div>
|
||||
<Preview v-model="showPreview" :url="previewUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -222,7 +208,7 @@
|
||||
import { getStyleImage } from './style'
|
||||
import { uploadImage } from '@/api/upload'
|
||||
import MyEvent from '@/utils/myEvent'
|
||||
// import Tag from './Tag.vue'
|
||||
import Preview from '@/components/Preview/Preview.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const agentStore = useAgentStore()
|
||||
@@ -773,6 +759,13 @@
|
||||
uploadedImages.value = []
|
||||
}
|
||||
|
||||
const showPreview = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewImage = (url: string) => {
|
||||
showPreview.value = true
|
||||
previewUrl.value = url
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
addReportTag
|
||||
@@ -840,6 +833,18 @@
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 0 1.4rem 1.4rem;
|
||||
font-size: 2rem;
|
||||
font-family: 'InterRegular';
|
||||
font-weight: 400;
|
||||
color: #999;
|
||||
pointer-events: none;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
width: calc(100% - 2.8rem);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor {
|
||||
@@ -896,29 +901,29 @@
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
border-radius: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-remove-btn {
|
||||
position: absolute;
|
||||
top: 0.2rem;
|
||||
right: 0.2rem;
|
||||
top: 0.6rem;
|
||||
right: 0.6rem;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
// display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover .image-remove-btn {
|
||||
opacity: 1;
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1028,6 +1033,13 @@
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
min-height: 5rem;
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
.editor-placeholder{
|
||||
font-family: 'Regular';
|
||||
font-size: 1.4rem;
|
||||
padding: 0;
|
||||
line-height: 1.4rem;
|
||||
}
|
||||
}
|
||||
.operate {
|
||||
|
||||
Reference in New Issue
Block a user