画布增加的新功能
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<!-- 图片列表面板 -->
|
||||
<div v-if="showPanel" class="crop-image-overlay" @click.self="close">
|
||||
<div class="crop-image-modal">
|
||||
<div class="modal-header">
|
||||
@@ -392,7 +391,7 @@
|
||||
<style scoped lang="less">
|
||||
/* 弹窗遮罩层 */
|
||||
.crop-image-overlay {
|
||||
position: fixed;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -420,8 +419,8 @@
|
||||
.crop-image-modal {
|
||||
background-color: #fff;
|
||||
border-radius: 12px;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
import { ref, nextTick, computed, inject } from "vue";
|
||||
import { Checkbox } from "ant-design-vue";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import { isGroupLayer } from "../../utils/layerHelper";
|
||||
import { isGroupLayer, SpecialLayerId } from "../../utils/layerHelper";
|
||||
import { fillToCssStyle, palletToFill, fillToPallet } from "../../utils/helper";
|
||||
import { SetColorLayerFillCommand } from "../../commands/LayerCommands";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const {t} = useI18n()
|
||||
// 设置组件名称,用于递归渲染
|
||||
@@ -183,6 +185,9 @@ function handleToggleVisibility() {
|
||||
}
|
||||
|
||||
function handleToggleLock() {
|
||||
// 禁用解锁的图层不能操作
|
||||
if (props.layer.isDisableUnlock) return;
|
||||
|
||||
if (props.isChild) {
|
||||
// 子图层需要传递父图层ID - 从父级组件获取
|
||||
const parentId = props.layer.parentId || findParentLayerId();
|
||||
@@ -348,6 +353,30 @@ function findParentLayerId() {
|
||||
console.warn("无法找到图层的父图层:", props.layer.id);
|
||||
return null;
|
||||
}
|
||||
const canvasManager = inject('canvasManager');
|
||||
const layerObject = computed(() => {
|
||||
const layer = props.layer;
|
||||
const id = layer.fabricObject?.id || layer.fabricObjects?.[0]?.id || layer.id;
|
||||
return canvasManager.getLayerObjectById(id);
|
||||
});
|
||||
const palletPanel = inject("palletPanel");
|
||||
const clickColor = () => {
|
||||
const fill = layerObject.value.fill;
|
||||
if (fill) {
|
||||
const obj = fillToPallet(fill);
|
||||
console.log("===========:", obj);
|
||||
palletPanel(obj).then((res) => {
|
||||
console.log("===========:", res);
|
||||
const cmd = new SetColorLayerFillCommand({
|
||||
canvas: canvasManager.canvas,
|
||||
layerManager: layerManager,
|
||||
object: layerObject.value,
|
||||
newFill: palletToFill(res),
|
||||
});
|
||||
layerManager.commandManager.execute(cmd);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -377,8 +406,8 @@ function findParentLayerId() {
|
||||
@contextmenu.prevent="handleContextMenu"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<div class="layer-drag-handle" :title="$t('拖拽排序')">
|
||||
<SvgIcon v-if="!isHidenDragHandle" :name="isChild ? 'CSort' : 'CSort'" :size="32"></SvgIcon>
|
||||
<div class="layer-drag-handle" :title="$t('拖拽排序')" v-if="!isHidenDragHandle">
|
||||
<SvgIcon :name="isChild ? 'CSort' : 'CSort'" :size="32"></SvgIcon>
|
||||
</div>
|
||||
|
||||
<!-- 图层头部 -->
|
||||
@@ -417,9 +446,18 @@ function findParentLayerId() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 颜色图层按钮 -->
|
||||
<div
|
||||
class="layer-color-btn"
|
||||
v-if="layer.id === SpecialLayerId.COLOR"
|
||||
@click.stop="clickColor"
|
||||
:style="{
|
||||
background: fillToCssStyle(layerObject.fill),
|
||||
}"
|
||||
></div>
|
||||
|
||||
<!-- 图层操作按钮 -->
|
||||
<div class="layer-actions" v-if="!(isGroupLayerType && !isChild)">
|
||||
<div class="layer-actions" >
|
||||
<!-- 可见性切换 -->
|
||||
<div
|
||||
class="visibility-btn"
|
||||
@@ -434,7 +472,7 @@ function findParentLayerId() {
|
||||
<span
|
||||
v-if="layer.locked"
|
||||
class="status-icon locked"
|
||||
:class="{ disabled: layer.isBackground || layer.isFixed }"
|
||||
:class="{ disabled: layer.isBackground || layer.isFixed || layer.isDisableUnlock || layer.isFixedOther }"
|
||||
:title="$t('锁定')"
|
||||
@click.stop="handleToggleLock"
|
||||
>
|
||||
|
||||
@@ -81,14 +81,14 @@ const fillColorRef = ref(null);
|
||||
// 计算属性:可排序的根级图层(排除背景层和固定层)
|
||||
const sortableRootLayers = computed(() => {
|
||||
if (!layers) return [];
|
||||
return layers.value.filter((layer) => !layer.parentId && !layer.isFixed && !layer.isBackground);
|
||||
return layers.value.filter((layer) => !layer.parentId && !layer.isFixed && !layer.isBackground && !layer.isFixedOther);
|
||||
});
|
||||
|
||||
// 计算属性:不可排序的固定图层(背景层和固定层)
|
||||
const fixedLayers = computed(() => {
|
||||
if (!layers) return [];
|
||||
return layers.value.filter((layer) => {
|
||||
if (props.showFixedLayer) return !layer.parentId && (layer.isFixed || layer.isBackground);
|
||||
if (props.showFixedLayer) return !layer.parentId && (layer.isFixed || layer.isBackground || layer.isFixedOther);
|
||||
return !layer.parentId && layer.isBackground; // 只显示背景层,不显示固定层 - 固定层用来做红绿图模式 和 放模特
|
||||
});
|
||||
});
|
||||
@@ -576,7 +576,7 @@ function handleLayerClick(layer, event) {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey || isMultiSelectMode.value) {
|
||||
toggleLayerSelection(layer, event);
|
||||
} else {
|
||||
lastSelectLayerId.value = layer.id; // 更新最后选中的图层ID
|
||||
if(!layer.isFixedClipMask) lastSelectLayerId.value = layer.id; // 更新最后选中的图层ID
|
||||
// 普通点击:进入单选模式
|
||||
// selectedLayerIds.value = [layer.id];
|
||||
// isMultiSelectMode.value = false;
|
||||
@@ -596,7 +596,7 @@ function handleLayerClick(layer, event) {
|
||||
layerManager?.updateLayersObjectsInteractivity();
|
||||
}
|
||||
}
|
||||
lastSelectedIndex.value = sortableRootLayers.value.findIndex((l) => l.id === layer.id);
|
||||
if(!layer.isFixedClipMask) lastSelectedIndex.value = sortableRootLayers.value.findIndex((l) => l.id === layer.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -999,7 +999,7 @@ function buildChildLayerContextMenuItems(childLayer) {
|
||||
{
|
||||
label: childLayer.locked ? "解锁图层" : "锁定图层",
|
||||
icon: childLayer.locked ? "CUnLock" : "CLock",
|
||||
disabled: childLayer.isBackground || childLayer.isFixed,
|
||||
disabled: childLayer.isBackground || childLayer.isFixed || childLayer.isDisableUnlock,
|
||||
action: () => toggleChildLayerLock(childLayer.id),
|
||||
},
|
||||
// 显示/隐藏
|
||||
@@ -1633,7 +1633,6 @@ async function moveGroupToGroup(draggedLayer, fromParentId, toParentId, newIndex
|
||||
@delete-child="deleteChildLayer"
|
||||
@rename-child="renameChildLayer"
|
||||
/>
|
||||
|
||||
<!-- 固定层(背景层和固定层) -->
|
||||
<div v-if="fixedLayers.length > 0" class="fixed-layers">
|
||||
<!-- 遍历固定层 -->
|
||||
|
||||
@@ -340,6 +340,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.layer-color-btn{
|
||||
width: 30px;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
// 图层操作
|
||||
.layer-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -384,7 +384,7 @@ async function prepareForLiquify(targetObj) {
|
||||
}
|
||||
updateAllParams();
|
||||
|
||||
console.log("液化环境准备完成");
|
||||
console.log("液化环境准备完成",compositeCommand);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("准备液化环境失败:", error);
|
||||
@@ -1614,6 +1614,7 @@ function close() {
|
||||
*/
|
||||
function startPressTimer() {
|
||||
if (pressTimer.value) return;
|
||||
if (currentMode.value === compositeCommand.value.liquifyManager.enhancedManager.modes.PUSH) return;
|
||||
|
||||
pressTimer.value = setInterval(() => {
|
||||
// 计算按压持续时间
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<!-- 颜色选择器模板 -->
|
||||
<div v-show="showPanel" class="pallet-overlay" @click.self="close">
|
||||
<div class="pallet-modal">
|
||||
<!-- <div class="modal-header">
|
||||
<h3></h3>
|
||||
<button class="close-btn" @click="close">×</button>
|
||||
</div> -->
|
||||
|
||||
<div class="modal-content">
|
||||
<pallet
|
||||
v-if="showPanel"
|
||||
:selectColor="selectColor"
|
||||
@selectUplpadColor="selectUplpadColor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div class="image-count" @click="close">
|
||||
{{ $t("Canvas.close") }}
|
||||
</div>
|
||||
<div class="image-submit gallery_btn" @click="confirm">
|
||||
{{ $t("Canvas.confirm") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref,
|
||||
watch,
|
||||
computed,
|
||||
defineProps,
|
||||
onDeactivated,
|
||||
reactive,
|
||||
onMounted,
|
||||
defineExpose,
|
||||
nextTick,
|
||||
onUnmounted,
|
||||
} from "vue";
|
||||
import pallet from "./pallet.vue";
|
||||
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
// Props
|
||||
const props = defineProps([]);
|
||||
const { t } = useI18n();
|
||||
var resolveFn: (value: any) => void;
|
||||
const showPanel = ref(false);
|
||||
const open = (obj = {}) => {
|
||||
selectColor.value = JSON.parse(JSON.stringify(obj));
|
||||
showPanel.value = true;
|
||||
return new Promise((resolve) => (resolveFn = resolve));
|
||||
};
|
||||
const close = () => {
|
||||
showPanel.value = false;
|
||||
};
|
||||
//提交选中的T图片
|
||||
const confirm = () => {
|
||||
close();
|
||||
resolveFn && resolveFn(JSON.parse(JSON.stringify(selectColor.value)));
|
||||
};
|
||||
const selectColor = ref({});
|
||||
const selectUplpadColor = (item: any) => {
|
||||
selectColor.value = JSON.parse(JSON.stringify(item));
|
||||
};
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
/* 弹窗遮罩层 */
|
||||
.pallet-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1001;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗主体 */
|
||||
.pallet-modal {
|
||||
background-color: #fff;
|
||||
border-radius: 12px;
|
||||
max-width: 90%;
|
||||
max-height: 95%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
animation: modalSlideUp 0.3s ease;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗头部 */
|
||||
.modal-header {
|
||||
padding: 16px 20px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
transition: all 0.2s;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: #333;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* 弹窗内容 */
|
||||
.modal-content {
|
||||
width: 35rem;
|
||||
// max-width: 240px;
|
||||
margin: 10px 20px;
|
||||
background-color: #fff;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 弹窗底部 */
|
||||
.modal-footer {
|
||||
padding: 10px 20px;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
// border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
> .image-submit {
|
||||
font-size: 1.2rem;
|
||||
line-height: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.image-count {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,666 @@
|
||||
<template>
|
||||
<div class="pallet" ref="palletRef">
|
||||
<div class="palletColo" @click="openPallet">
|
||||
<div v-show="!selectColor.gradient" class="palletBackColor" :title="selectColor.name" :style="{'background-color':selectColor.hex}">
|
||||
{{ selectColor.hex }}
|
||||
</div>
|
||||
<div v-show="selectColor.gradient" class="palletBackColor" :style="{'background-image':`linear-gradient(${selectColor.gradient?.angle}deg,${setGradient(selectColor.gradient)})`}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="palletBox">
|
||||
<div class="color_setting_block" @click.stop>
|
||||
<Chrome class="chrome_color" v-model="color_"></Chrome>
|
||||
<div class="color_setting_operateSingle">
|
||||
<div class="color_setting_btn" :class="{active:!color?.gradient?.gradientShow}">{{ $t('ColorboardUpload.Single') }}</div>
|
||||
<a-switch :checked="color?.gradient?.gradientShow" @click="setOperate"/>
|
||||
<div class="color_setting_btn" :class="{active:color?.gradient?.gradientShow}">{{ $t('ColorboardUpload.Gradual') }}</div>
|
||||
</div>
|
||||
<div class="color_setting_operate" v-if="color?.gradient?.gradientShow">
|
||||
<div class="color_setting_operate_item color_setting_operate_control">
|
||||
<div class="operate_item_box">
|
||||
<div>{{ $t('ColorboardUpload.Alignment') }}</div>
|
||||
</div>
|
||||
<div class="operate_item_box operate_item_angle">
|
||||
<div class="operate_item_angle_box" @mousedown="mousedownGradientAngle(getMousePosition($event,false))" @touchstart="mousedownGradientAngle(getMousePosition($event,true))">
|
||||
<div :style="{'transform':`rotate(${color.gradient.angle}deg)`}"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operate_item_box operate_item_delete">
|
||||
<i class="fi fi-rr-trash" @click="deleteGradientItem"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color_setting_operate_item color_setting_operate_input">
|
||||
<div class="color_setting_operate_bg" @click="addGradient($event)" :style="{'background-image':color?.gradient?`linear-gradient(90deg,${setGradient(color.gradient)})`:'none'}">
|
||||
</div>
|
||||
<div v-for="item,index in color.gradient.gradientList" :key="item" class="color_setting_operate_btn" :class="{'active':index == color.gradient.selectIndex}" :style="{'left':item.left,'background-color':`rgba(${item.rgba.r},${item.rgba.g},${item.rgba.b},${item.rgba.a})`}" @mousedown="mousedownGradient(getMousePosition($event,false),item,index,)" @touchstart="mousedownGradient(getMousePosition($event,true),item,index,)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { defineComponent,computed,ref,watch,nextTick,onMounted,onUnmounted,toRefs, reactive} from 'vue'
|
||||
import { useStore } from "vuex";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { message,Upload} from 'ant-design-vue';
|
||||
import { Sketch, Chrome} from '@ans1998/vue3-color'
|
||||
import { getMousePosition } from "@/tool/mdEvent";
|
||||
import { rgbaToHex } from "@/tool/util"
|
||||
import { color } from 'echarts/core';
|
||||
export default defineComponent({
|
||||
components:{
|
||||
Chrome,
|
||||
},
|
||||
props:{
|
||||
selectColor:{
|
||||
type:Object,
|
||||
default:()=>{}
|
||||
},
|
||||
},
|
||||
emits:['selectUplpadColor'],
|
||||
setup(props,{emit}) {
|
||||
const {t} = useI18n()
|
||||
const store = useStore();
|
||||
const palletData = reactive({
|
||||
palletShow: true,
|
||||
palletList:[],
|
||||
color_:{} as any,
|
||||
color:{} as any,
|
||||
updataSelectColorTime:null as any,
|
||||
gradient:{
|
||||
gradientList:[
|
||||
{
|
||||
rgba:{
|
||||
r:117,
|
||||
g:119,
|
||||
b:255,
|
||||
a:1,
|
||||
},
|
||||
left:'0%'
|
||||
},{
|
||||
rgba:{
|
||||
r:0,
|
||||
g:222,
|
||||
b:152,
|
||||
a:1,
|
||||
},
|
||||
left:'100%'
|
||||
},
|
||||
],
|
||||
angle:45,
|
||||
selectIndex:-1,
|
||||
gradientShow:false,
|
||||
},
|
||||
setGradient:computed(()=>{
|
||||
return (gradient:any)=>{
|
||||
let gradientStr = ''
|
||||
if(!gradient?.gradientList)return
|
||||
gradient.gradientList.sort((a:any, b:any) => {
|
||||
let aArr = a.left.split('%')[0]
|
||||
let bArr = b.left.split('%')[0]
|
||||
return aArr - bArr;
|
||||
});
|
||||
gradient.gradientList.forEach((item:any,index:any)=>{
|
||||
let str = ','
|
||||
if(gradient.gradientList.length == index+1)str = ''
|
||||
let rgba = item.rgba?item.rgba:{r:255,g:255,b:255}
|
||||
gradientStr += `rgba(${rgba.r},${rgba.g},${rgba.b},${rgba.a}) ${item.left}${str}`
|
||||
|
||||
})
|
||||
return `${gradientStr}`
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
const getpalletListDom = reactive({
|
||||
})
|
||||
const palletRef = ref(null)
|
||||
watch(()=>palletData.color_,(newVal:any)=>{
|
||||
if(!newVal?.rgba?.r)return
|
||||
if(palletData.color?.gradient?.gradientShow){
|
||||
palletData.color.gradient.gradientList[palletData.color.gradient.selectIndex].rgba = {
|
||||
r:newVal.rgba.r,
|
||||
g:newVal.rgba.g,
|
||||
b:newVal.rgba.b,
|
||||
a:newVal.rgba.a,
|
||||
}
|
||||
}else{
|
||||
palletData.color = newVal
|
||||
}
|
||||
})
|
||||
watch(()=>palletData.color,(newVal:any)=>{
|
||||
if(JSON.stringify(props.selectColor) != JSON.stringify(newVal)){
|
||||
newVal.name = ''
|
||||
newVal.tcx = ''
|
||||
let rgba = [newVal.rgba.r,newVal.rgba.g,newVal.rgba.b]
|
||||
let hex = rgbaToHex(rgba)
|
||||
newVal.hex = hex
|
||||
emit('selectUplpadColor',newVal)
|
||||
}
|
||||
},{deep: true })
|
||||
const setOperate = ()=>{
|
||||
if(!palletData.color.rgba)return message.info(t('DesignDetailAlter.jsContent7'))
|
||||
palletData.color.rgba = palletData.color?.rgba?.r?palletData.color.rgba:{r:0,g:0,b:0,a:1}
|
||||
palletData.gradient.selectIndex = 0
|
||||
palletData.gradient.gradientShow = true
|
||||
if(!palletData.color.gradient){
|
||||
if(palletData.color.rgba.r){
|
||||
palletData.gradient.gradientList[palletData.gradient.selectIndex].rgba = {
|
||||
r:palletData.color.rgba.r,
|
||||
g:palletData.color.rgba.g,
|
||||
b:palletData.color.rgba.b,
|
||||
a:1,
|
||||
}
|
||||
}
|
||||
palletData.color.gradient = JSON.parse(JSON.stringify(palletData.gradient))
|
||||
}else{
|
||||
palletData.color.rgba = palletData.color.gradient.gradientList[0].rgba
|
||||
palletData.color.gradient = null
|
||||
}
|
||||
}
|
||||
const deleteGradientItem = ()=>{
|
||||
if(palletData.color.gradient.gradientList.length <= 2)return
|
||||
palletData.color.gradient.gradientList.splice(palletData.color.gradient.selectIndex,1)
|
||||
}
|
||||
const addGradient = (event:any)=>{
|
||||
let gradientWidth = event.target.clientWidth
|
||||
let left:any = event.offsetX/gradientWidth
|
||||
palletData.color.gradient.gradientList.push({
|
||||
rgba:palletData.color_.rgba,
|
||||
left:left.toFixed(2)*100+'%'
|
||||
})
|
||||
}
|
||||
const mousedownGradientAngle = (event:any)=>{
|
||||
// isMoible() true为移动端
|
||||
let domPosition = event.target.getBoundingClientRect()
|
||||
let position = {
|
||||
x:domPosition.x+domPosition.width/2,
|
||||
y:domPosition.y+domPosition.height/2,
|
||||
}
|
||||
let angle
|
||||
let mousedown = function(event:any){
|
||||
let e = getMousePosition(event,false)
|
||||
mouseDownOperation(e)
|
||||
}
|
||||
|
||||
let touchstart = function(event:any){
|
||||
let e = getMousePosition(event,true)
|
||||
mouseDownOperation(e)
|
||||
}
|
||||
let mouseDownOperation = (e:any)=>{
|
||||
let X = position.x
|
||||
let Y = position.y
|
||||
let x = (e.clientX) - X
|
||||
let y = Y -( e.clientY)
|
||||
angle = Math.atan2(x,y)*(180 / Math.PI)
|
||||
// this.colorList[this.selectIndex].gradient = JSON.parse(JSON.stringify(this.gradient))
|
||||
palletData.color.gradient.angle = angle
|
||||
|
||||
}
|
||||
let mouseupGradientAngle = ()=>{
|
||||
window.removeEventListener('touchmove',touchstart)
|
||||
window.removeEventListener('touchend',mouseupGradientAngle)
|
||||
|
||||
window.removeEventListener('mousemove',mousedown)
|
||||
window.removeEventListener('mouseup',mouseupGradientAngle)
|
||||
}
|
||||
window.addEventListener('touchmove',touchstart)
|
||||
window.addEventListener('touchend',mouseupGradientAngle)
|
||||
|
||||
window.addEventListener('mousemove',mousedown)
|
||||
window.addEventListener('mouseup',mouseupGradientAngle)
|
||||
}
|
||||
|
||||
const mousedownGradient = (event:any,item:any,index:number)=>{
|
||||
palletData.color.gradient.selectIndex = index
|
||||
|
||||
|
||||
// this.selectColor = {rgba:gradientRgba,hex:hex} //顔色选择器默认颜色
|
||||
let gradientWidth = (palletRef.value.querySelector('.color_setting_operate_bg') as any).clientWidth
|
||||
let position = {
|
||||
x:event.clientX,
|
||||
left:event.target.style.left?event.target.style.left.split('%')[0]:0
|
||||
}
|
||||
let mousedown = function(event:any){
|
||||
let e = getMousePosition(event,false)
|
||||
mousedownGradient(e)
|
||||
}
|
||||
|
||||
let touchstart = function(event:any){
|
||||
let e = getMousePosition(event,true)
|
||||
mousedownGradient(e)
|
||||
}
|
||||
let mousedownGradient = (e:any)=>{
|
||||
let left = ((e.clientX) - position.x)/gradientWidth*100+Number(position.left)
|
||||
left = (left<0?0:left>100?100:left)
|
||||
item.left = left+'%'
|
||||
}
|
||||
|
||||
let mouseupGradientAngle = ()=>{
|
||||
window.removeEventListener('touchmove',touchstart)
|
||||
window.removeEventListener('touchend',mouseupGradientAngle)
|
||||
window.removeEventListener('mousemove',mousedown)
|
||||
window.removeEventListener('mouseup',mouseupGradientAngle)
|
||||
}
|
||||
window.addEventListener('touchmove',touchstart)
|
||||
window.addEventListener('touchend',mouseupGradientAngle)
|
||||
|
||||
window.addEventListener('mousemove',mousedown)
|
||||
window.addEventListener('mouseup',mouseupGradientAngle)
|
||||
|
||||
|
||||
}
|
||||
const selectImgItem = ()=>{
|
||||
|
||||
}
|
||||
const openPallet = ()=>{
|
||||
if(palletData.palletShow && props.selectColor?.rgba?.r){
|
||||
if(props.selectColor.gradient){
|
||||
palletData.color_.rgba = props.selectColor.gradient.gradientList[0].rgba
|
||||
}else{
|
||||
palletData.color_ = JSON.parse(JSON.stringify(props.selectColor))
|
||||
palletData.gradient.gradientShow = false
|
||||
}
|
||||
|
||||
palletData.color = JSON.parse(JSON.stringify(props.selectColor))
|
||||
}else{
|
||||
}
|
||||
}
|
||||
// 点击外部区域关闭颜色选择器
|
||||
const handleClickOutside = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const colorSettingBlock = palletRef.value.querySelector('.color_setting_block');
|
||||
const palletColo = palletRef.value.querySelector('.palletColo');
|
||||
|
||||
// 如果点击的是 .palletColo 或 .color_setting_block 内部,则不关闭
|
||||
if (palletData.palletShow && colorSettingBlock &&
|
||||
!colorSettingBlock.contains(target) &&
|
||||
!palletColo?.contains(target)) {
|
||||
palletData.palletShow = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(()=>{
|
||||
// 添加点击外部区域监听器
|
||||
// document.addEventListener('click', handleClickOutside);
|
||||
|
||||
nextTick().then(()=>{
|
||||
const backIcon = document.createElement('div');
|
||||
backIcon.classList.add('vc-sketch-color-wrap')
|
||||
let dropperDom = palletRef.value.getElementsByClassName('vc-chrome-fields-wrap')[0]
|
||||
dropperDom.appendChild(backIcon);
|
||||
backIcon.addEventListener('click',async ()=>{
|
||||
try {
|
||||
const dropper = new EyeDropper();
|
||||
const result = await dropper.open();
|
||||
let hex = result.sRGBHex.replace("#", "");
|
||||
// 将十六进制颜色码拆分成红、绿、蓝三个部分
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
palletData.color = {rgba:{r:r,g:g,b:b,a:1},hex:result.sRGBHex}
|
||||
// return `rgb(${r}, ${g}, ${b})`;
|
||||
// box.style.backgroundColor = label.textContent = result.sRGBHex;
|
||||
} catch (e) {
|
||||
message.info(t('DesignDetailAlter.jsContent1'))
|
||||
}
|
||||
})
|
||||
openPallet();
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(()=>{
|
||||
// 清理事件监听器
|
||||
// document.removeEventListener('click', handleClickOutside);
|
||||
})
|
||||
return{
|
||||
...toRefs(palletData),
|
||||
...toRefs(getpalletListDom),
|
||||
palletRef,
|
||||
openPallet,
|
||||
selectImgItem,
|
||||
setOperate,
|
||||
deleteGradientItem,
|
||||
addGradient,
|
||||
mousedownGradientAngle,
|
||||
mousedownGradient,
|
||||
getMousePosition,
|
||||
}
|
||||
},
|
||||
|
||||
provide() {
|
||||
return {
|
||||
}
|
||||
},
|
||||
|
||||
})
|
||||
</script>
|
||||
<style lang="less" scoped>
|
||||
.pallet{
|
||||
// position: absolute;
|
||||
width: 100%;
|
||||
user-select: none;
|
||||
> .palletColo{
|
||||
width: 100%;
|
||||
height: 7rem;
|
||||
border-radius: .5rem;
|
||||
border: 1px solid #000;
|
||||
padding: .5rem .6rem;
|
||||
cursor: pointer;
|
||||
> .palletBackColor{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
> .palletBox{
|
||||
margin-top: 1.5rem;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
> .color_setting_block{
|
||||
margin: auto;
|
||||
background: linear-gradient(70deg, #eee4f3, #f3f4e6);
|
||||
width: 100%;
|
||||
// border-radius: calc(1rem*1.2);
|
||||
overflow: hidden;
|
||||
box-shadow: 2px 2px 8px rgba(0,0,0,.3);
|
||||
.vc-chrome{
|
||||
background: rgba(0,0,0,0);
|
||||
box-shadow:none;
|
||||
}
|
||||
:deep(.chrome_color){
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.vc-chrome-saturation-wrap{
|
||||
width: 30rem;
|
||||
height: 30rem;
|
||||
margin: 2rem auto;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.vc-saturation-pointer{
|
||||
pointer-events: none;
|
||||
}
|
||||
.vc-chrome-body{
|
||||
padding: 0;
|
||||
width: 90%;
|
||||
margin: 2 auto;
|
||||
margin: 0 auto;
|
||||
background: rgba(0,0,0,0);
|
||||
margin-bottom: 3rem;
|
||||
// display: none;
|
||||
.vc-chrome-fields-wrap{
|
||||
margin-top: 5%;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
.vc-chrome-toggle-btn{
|
||||
width: calc(3.2rem*1.2);
|
||||
.vc-chrome-toggle-icon{
|
||||
height: auto;
|
||||
margin-right: calc(-0.4rem*1.2);
|
||||
margin-top: calc(0rem*1.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
svg{
|
||||
width: calc(2.4rem*1.2) !important;
|
||||
height: calc(2.4rem*1.2) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.vc-chrome-fields{
|
||||
.vc-chrome-field{
|
||||
padding-left: calc(.6rem*1.2);
|
||||
}
|
||||
.vc-input__label{
|
||||
font-size: calc(1.6rem*1.2);
|
||||
}
|
||||
.vc-input__input{
|
||||
font-size: 2rem;
|
||||
height: 4rem;
|
||||
}
|
||||
}
|
||||
.ant-upload-list{
|
||||
|
||||
}
|
||||
.vc-sketch-color-wrap{
|
||||
background-image: url(@/assets/images/homePage/dropper.png);
|
||||
background-size: 3rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: 50%;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
padding: calc(.7rem*1.2);
|
||||
border: 1px solid;
|
||||
position: absolute;
|
||||
bottom: -2rem;
|
||||
right: 0rem;
|
||||
border-radius: calc(.5rem*1.2);
|
||||
|
||||
}
|
||||
.vc-chrome-fields{
|
||||
.vc-input__label{
|
||||
margin-top: calc(1rem*1.2);
|
||||
}
|
||||
}
|
||||
.vc-chrome-fields:nth-child(2){
|
||||
>:last-of-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.vc-chrome-fields:nth-child(3){
|
||||
>:last-of-type {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.vc-chrome-controls{
|
||||
align-items: center;
|
||||
flex-direction: row-reverse;
|
||||
.vc-chrome-color-wrap{
|
||||
// width: 3.6rem*1.2);
|
||||
margin-left: calc(2rem*1.2);
|
||||
width: auto;
|
||||
.vc-chrome-active-color{
|
||||
border-radius: 50%;
|
||||
}
|
||||
.vc-chrome-active-color,.vc-checkerboard{
|
||||
width: calc(3rem*1.2);
|
||||
height: calc(3rem*1.2);
|
||||
}
|
||||
}
|
||||
.vc-chrome-hue-wrap,.vc-chrome-alpha-wrap{
|
||||
.vc-hue{
|
||||
border-radius: 15px;
|
||||
}
|
||||
.vc-alpha{
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
height: 2rem;
|
||||
margin: 0;
|
||||
.vc-hue-pointer{
|
||||
transform: translateX(-1.25rem);
|
||||
}
|
||||
.vc-hue-picker{
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
transform: translate(0px,-3px);
|
||||
}
|
||||
}
|
||||
.vc-chrome-alpha-wrap{
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
.vc-chrome-saturation-wrap .vc-saturation-circle{
|
||||
width: calc(1rem*1.2);
|
||||
height: calc(1rem*1.2);
|
||||
}
|
||||
}
|
||||
.color_block{
|
||||
// margin-top: calc(1rem;
|
||||
// display: flex;
|
||||
// justify-content: space-between;
|
||||
// font-size: calc(1.6rem;
|
||||
width: 100%;
|
||||
padding: 0 5%;
|
||||
padding-bottom: 5%;
|
||||
margin: calc(0.5rem*1.2) auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
.color_right{
|
||||
width: 13rem;
|
||||
font-size: calc(1.2rem*1.2);
|
||||
color: #666666;
|
||||
.color_rgb_block{
|
||||
display: flex;
|
||||
.rgb_item{
|
||||
margin-left: calc(.2rem*1.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
.color_left{
|
||||
cursor: pointer;
|
||||
color: rgb(153, 153, 153);
|
||||
}
|
||||
.color_right,.color_left{
|
||||
>div{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.color_HEX_block,.color_rgb_block{
|
||||
padding: .25rem .6rem;
|
||||
box-shadow: inset 0 0 0 1px #ccc;
|
||||
border-radius: .5rem;
|
||||
justify-content: space-around;
|
||||
text-transform:uppercase;
|
||||
.color_block_bg{
|
||||
width: 1.8rem;
|
||||
height: 2.5rem;
|
||||
// margin-right: .5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
.color_block_bg{
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.color_setting_operateSingle{
|
||||
text-align: center;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.color_setting_btn{
|
||||
margin: 0 1rem;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
&.active{
|
||||
color: rgba(0, 0, 0, 0.7);
|
||||
font-weight: 900;
|
||||
}
|
||||
}
|
||||
}
|
||||
.color_setting_operate{
|
||||
*{
|
||||
-webkit-touch-callout: none; /* iOS Safari */
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-moz-user-select: none; /* Firefox */
|
||||
-ms-user-select: none; /* Internet Explorer/Edge */
|
||||
user-select: none;
|
||||
}
|
||||
.color_setting_operate_item{
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
.operate_item_box{
|
||||
}
|
||||
}
|
||||
.color_setting_operate_control{
|
||||
.operate_item_delete,.operate_item_angle{
|
||||
cursor: pointer;
|
||||
}
|
||||
.operate_item_delete{
|
||||
i{
|
||||
display: flex;
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
.operate_item_angle{
|
||||
.operate_item_angle_box{
|
||||
border-radius: 50%;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border: solid 2px #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
>div{
|
||||
height: 100%;
|
||||
width: 1rem;
|
||||
position: relative;
|
||||
pointer-events:none;
|
||||
}
|
||||
>div::before{
|
||||
position: absolute;
|
||||
content: "";
|
||||
top: 0.2rem;
|
||||
left: 0;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.color_setting_operate_input{
|
||||
width: 80%;
|
||||
// padding: 0 10%;
|
||||
margin: 1.2rem 10%;
|
||||
border-radius: 10%;
|
||||
position: relative;
|
||||
height: 2.5rem;
|
||||
.color_setting_operate_bg{
|
||||
border-radius: .5rem;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
.color_setting_operate_btn{
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%,-50%);
|
||||
left: 0;
|
||||
width: 1rem;
|
||||
height: 110%;
|
||||
border: .2rem solid;
|
||||
border-radius: .5rem;
|
||||
cursor: pointer;
|
||||
box-sizing: content-box;
|
||||
z-index: 2;
|
||||
&.active{
|
||||
border: .3rem solid;
|
||||
}
|
||||
}
|
||||
.color_setting_operate_btn:hover{
|
||||
border: .3rem solid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,744 +0,0 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
class="select-menu-panel"
|
||||
v-if="visible"
|
||||
:class="{ active: !closePanel }"
|
||||
>
|
||||
<div class="btn" @click="setClosePanel">
|
||||
<i class="fi fi-br-angle-left"></i>
|
||||
</div>
|
||||
<!-- 变换工具顶部 -->
|
||||
<div class="panel-select">
|
||||
<!-- <div class="panel-header">
|
||||
<div class="header-title">变换工具</div>
|
||||
</div> -->
|
||||
<!-- 分割线 -->
|
||||
<!-- <div class="panel-divider"></div> -->
|
||||
<!-- 变换工具内容 -->
|
||||
<div class="tool-content">
|
||||
<div
|
||||
class="object-item"
|
||||
v-for="v in activeObjects"
|
||||
:key="v.id"
|
||||
>
|
||||
<div class="title">{{ v.layer?.name }}</div>
|
||||
<div class="list">
|
||||
<div>
|
||||
<span class="label">W</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="v.width"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">H</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="v.height"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<!-- <div>
|
||||
<span class="label">X</span>
|
||||
<input type="number" :value="v.left" disabled />
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Y</span>
|
||||
<input type="number" :value="v.top" disabled />
|
||||
</div> -->
|
||||
<div>
|
||||
<span class="label iconfont icon-angle"></span>
|
||||
<input
|
||||
type="number"
|
||||
:value="Number(Number(v.angle).toFixed(3))"
|
||||
@change="(e) => changeAngle(e, v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="btn" @click="clickflipHorizontal(v)">
|
||||
<i class="iconfont icon-flip-horizontal"></i>
|
||||
<p class="tip">
|
||||
{{ t("Canvas.flipHorizontal") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="btn" @click="clickflipVertical(v)">
|
||||
<i class="iconfont icon-flip-vertical"></i>
|
||||
<p class="tip">
|
||||
{{ t("Canvas.flipVertical") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="btn" @click="clickCropImage(v)">
|
||||
<i class="iconfont icon-caijian"></i>
|
||||
<p class="tip">
|
||||
{{ t("Canvas.cropAndAdd") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import showViewVideo from "@/tool/mount";
|
||||
import { ref, onMounted, watch, onUnmounted } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { ToolCommand } from "../commands/ToolCommands";
|
||||
import { OperationType } from "../utils/layerHelper";
|
||||
import { loadImageUrlToLayer } from "../utils/imageHelper";
|
||||
import { TransformCommand } from "../commands/StateCommands";
|
||||
const props = defineProps({
|
||||
canvas: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
commandManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
selectManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
layerManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
toolManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
activeTool: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref(false);
|
||||
//打开隐藏操作面板
|
||||
const closePanel = ref(false);
|
||||
const setClosePanel = () => {
|
||||
closePanel.value = !closePanel.value;
|
||||
};
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n();
|
||||
|
||||
onMounted(() => {
|
||||
setupCanvasListeners();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
removeCanvasListeners();
|
||||
});
|
||||
|
||||
// 监听 activeTool 变化
|
||||
watch(
|
||||
() => props.activeTool,
|
||||
(newTool) => {
|
||||
if (newTool === OperationType.SELECT) {
|
||||
show();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 显示面板
|
||||
*/
|
||||
function show() {
|
||||
if (activeObjects.value.length === 0) return;
|
||||
visible.value = true;
|
||||
closePanel.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭面板
|
||||
*/
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
// 获取当前选中的对象
|
||||
const activeObjects = ref([]);
|
||||
const getActiveObject = (e) => {
|
||||
console.log("==========切换激活对象", e);
|
||||
activeObjects.value = e.selected.map((v) => v);
|
||||
activeObjects.value.forEach((v) => {
|
||||
v.layer = props.layerManager.getLayerById(v.layerId);
|
||||
});
|
||||
if (activeObjects.value.length === 0) {
|
||||
close();
|
||||
} else {
|
||||
show(false);
|
||||
}
|
||||
};
|
||||
const lastSelectLayerId = inject("lastSelectLayerId");
|
||||
const layers = inject("layers");
|
||||
const transformObject = (activeObj, initialState, finalState) => {
|
||||
const transformCmd = new TransformCommand({
|
||||
canvas: props.canvas,
|
||||
objectId: activeObj.id,
|
||||
initialState,
|
||||
finalState,
|
||||
objectType: activeObj.type,
|
||||
name: `变换 ${activeObj.type || "对象"}`,
|
||||
layerManager: props.layerManager,
|
||||
layers: layers,
|
||||
lastSelectLayerId: lastSelectLayerId,
|
||||
});
|
||||
props.layerManager.commandManager.execute(transformCmd, {
|
||||
name: "对象修改",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据左上角坐标计算旋转后的新坐标
|
||||
* @param {number} W - 宽度
|
||||
* @param {number} H - 高度
|
||||
* @param {number} currentX - 当前左上角x坐标
|
||||
* @param {number} currentY - 当前左上角y坐标
|
||||
* @param {number} currentAngleDeg - 当前角度(度)
|
||||
* @param {number} newAngleDeg - 新角度(度)
|
||||
* @returns {Object} 旋转后的左上角坐标 {x, y}
|
||||
*/
|
||||
function calculateRotatedTopLeftDeg(
|
||||
W,
|
||||
H,
|
||||
currentX,
|
||||
currentY,
|
||||
currentAngleDeg,
|
||||
newAngleDeg
|
||||
) {
|
||||
const currentAngle = (currentAngleDeg * Math.PI) / 180;
|
||||
const newAngle = (newAngleDeg * Math.PI) / 180;
|
||||
// 1. 用当前角度计算中心点位置
|
||||
const cosCurrent = Math.cos(currentAngle);
|
||||
const sinCurrent = Math.sin(currentAngle);
|
||||
const Cx = currentX + (W / 2) * cosCurrent - (H / 2) * sinCurrent;
|
||||
const Cy = currentY + (W / 2) * sinCurrent + (H / 2) * cosCurrent;
|
||||
|
||||
// 2. 用新角度计算旋转后的左上角位置
|
||||
const cosNew = Math.cos(newAngle);
|
||||
const sinNew = Math.sin(newAngle);
|
||||
const newX = Cx + (-W / 2) * cosNew - (-H / 2) * sinNew;
|
||||
const newY = Cy + (-W / 2) * sinNew + (-H / 2) * cosNew;
|
||||
|
||||
return { x: newX, y: newY };
|
||||
}
|
||||
// 改变角度
|
||||
const changeAngle = (e, obj) => {
|
||||
const initialState = TransformCommand.captureTransformState(obj);
|
||||
const finalState = { ...initialState };
|
||||
const angle = e.target.value;
|
||||
if (obj.originX === "left" && obj.originY === "top") {
|
||||
const width = obj.width * obj.scaleX;
|
||||
const height = obj.height * obj.scaleY;
|
||||
const left = obj.left;
|
||||
const top = obj.top;
|
||||
const { x, y } = calculateRotatedTopLeftDeg(
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
obj.angle,
|
||||
angle
|
||||
);
|
||||
finalState.left = x;
|
||||
finalState.top = y;
|
||||
}
|
||||
finalState.angle = angle;
|
||||
transformObject(obj, initialState, finalState);
|
||||
};
|
||||
// 水平翻转
|
||||
const clickflipHorizontal = (obj) => {
|
||||
const initialState = TransformCommand.captureTransformState(obj);
|
||||
const finalState = { ...initialState };
|
||||
finalState.flipX = !finalState.flipX;
|
||||
transformObject(obj, initialState, finalState);
|
||||
};
|
||||
// 垂直翻转
|
||||
const clickflipVertical = (obj) => {
|
||||
const initialState = TransformCommand.captureTransformState(obj);
|
||||
const finalState = { ...initialState };
|
||||
finalState.flipY = !finalState.flipY;
|
||||
transformObject(obj, initialState, finalState);
|
||||
};
|
||||
// 裁剪图片
|
||||
const cropImage = inject("cropImage");
|
||||
const clickCropImage = async (obj) => {
|
||||
const base64 = await props.layerManager.getLayerToBase64(obj.layerId);
|
||||
if(base64) cropImage(base64).then((res) => {
|
||||
loadImageUrlToLayer({
|
||||
imageUrl: res,
|
||||
layerManager: props.layerManager,
|
||||
canvas: props.canvas,
|
||||
toolManager: props.toolManager,
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const updateActiveObjects = (arrs, keys) => {
|
||||
arrs.forEach((v) => {
|
||||
activeObjects.value.forEach((item) => {
|
||||
if (item.id === v.id) {
|
||||
keys.forEach((key) => (item[key] = v[key]));
|
||||
}
|
||||
});
|
||||
activeObjects.value = [...activeObjects.value];
|
||||
});
|
||||
};
|
||||
|
||||
const objectRotatingChange = (e) => {
|
||||
const arrs = [];
|
||||
if (e.target._objects) {
|
||||
e.target._objects.forEach((v) => arrs.push(v));
|
||||
} else {
|
||||
arrs.push(e.target);
|
||||
}
|
||||
updateActiveObjects(arrs, ["angle"]);
|
||||
};
|
||||
|
||||
/**
|
||||
* 设置画布事件监听
|
||||
*/
|
||||
function setupCanvasListeners() {
|
||||
if (!props.canvas) return;
|
||||
// 鼠标事件
|
||||
props.canvas.on("selection:created", getActiveObject);
|
||||
props.canvas.on("selection:updated", getActiveObject);
|
||||
props.canvas.on("selection:cleared", close);
|
||||
props.canvas.on("object:rotating", objectRotatingChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除画布事件监听
|
||||
*/
|
||||
function removeCanvasListeners() {
|
||||
if (!props.canvas) return;
|
||||
|
||||
// 移除鼠标事件
|
||||
props.canvas.off("selection:created", getActiveObject);
|
||||
props.canvas.off("selection:updated", getActiveObject);
|
||||
props.canvas.off("selection:cleared", close);
|
||||
props.canvas.off("object:rotating", objectRotatingChange);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.select-menu-panel {
|
||||
position: absolute;
|
||||
bottom: 22px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
max-width: min(90vw, 640px);
|
||||
margin: 0 auto;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
color: #333;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
user-select: none;
|
||||
&.active {
|
||||
transform: translateY(100%);
|
||||
> .btn {
|
||||
> i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
> .btn {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
> i {
|
||||
font-size: 1.4rem;
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板和手机适配 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.select-menu-panel {
|
||||
bottom: 15px;
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
max-width: calc(100vw - 30px);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.select-menu-panel {
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
max-width: calc(100vw - 20px);
|
||||
}
|
||||
}
|
||||
|
||||
.select-menu-panel.is-active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 8px 15px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.panel-select {
|
||||
// padding: 0 0 10px;
|
||||
}
|
||||
|
||||
/* 平板适配 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.panel-header {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机适配 */
|
||||
@media screen and (max-width: 480px) {
|
||||
.panel-header {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-btn span {
|
||||
margin-top: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background-color: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
height: 1px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
margin: 0 10px 5px 10px;
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
overflow-y: auto;
|
||||
max-height: 220px;
|
||||
margin-top: 10px;
|
||||
padding: 0 10px;
|
||||
> .object-item {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
padding: 10px 0;
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
> .title {
|
||||
text-align: left;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
> .list {
|
||||
display: flex;
|
||||
> div {
|
||||
margin-right: 15px;
|
||||
font-size: 14px;
|
||||
color: #474747;
|
||||
> .label {
|
||||
margin-right: 5px;
|
||||
}
|
||||
> input {
|
||||
width: 65px;
|
||||
}
|
||||
.iconfont {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
> div.btn {
|
||||
position: relative;
|
||||
min-width: 24px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
> .tip {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -100%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 0.4rem;
|
||||
margin-left: 0.8rem;
|
||||
font-size: 1.2rem;
|
||||
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);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
> .tip {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板适配 - 每行4个按钮 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.tool-content {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机适配 - 每行3个按钮 */
|
||||
@media screen and (max-width: 480px) {
|
||||
.tool-content {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
min-width: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
// flex-direction: column;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
gap: 4px;
|
||||
.c-svg {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: #007aff;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 对话框样式 */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.dialog-container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
width: 280px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 15px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dialog-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.close-dialog-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.feather-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.slider-control {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.slider-control::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #007aff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.feather-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background-color: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s, transform 0.3s;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div class="repeat-setting">
|
||||
<div class="repeat-setting-item">
|
||||
<span class="label">{{ t("Canvas.angle") }}</span>
|
||||
<angle-tool
|
||||
:angle="angle"
|
||||
@input="(e) => emit('inputFillAngle', e)"
|
||||
@change="(e) => emit('changeFillAngle', e)"
|
||||
/>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="repeat-setting-item">
|
||||
<span class="label">{{ t("Canvas.scale") }}</span>
|
||||
<slider
|
||||
:min="1"
|
||||
:max="500"
|
||||
:step="1"
|
||||
is-input
|
||||
:tipFormatter="(v) => `${scale}%`"
|
||||
:value="scale"
|
||||
@input="inputFillScale"
|
||||
@change="changeFillScale"
|
||||
/>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="repeat-setting-item">
|
||||
<span class="label">Gap X</span>
|
||||
<slider
|
||||
:min="0"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
is-input
|
||||
:tipFormatter="(v) => `${v}px`"
|
||||
:value="gapX"
|
||||
@input="(e) => emit('inputFill_Gap', e, gapY)"
|
||||
@change="(e) => emit('changeFill_Gap', e, gapY)"
|
||||
/>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="repeat-setting-item">
|
||||
<span class="label">Gap Y</span>
|
||||
<slider
|
||||
:min="0"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
is-input
|
||||
:tipFormatter="(v) => `${v}px`"
|
||||
:value="gapY"
|
||||
@input="(e) => emit('inputFill_Gap', gapX, e)"
|
||||
@change="(e) => emit('changeFill_Gap', gapX, e)"
|
||||
/>
|
||||
</div>
|
||||
<p></p>
|
||||
<div class="repeat-setting-item">
|
||||
<span class="label">{{ t("Canvas.offset") }}</span>
|
||||
<offset-tool
|
||||
:top="(props.object.fill?.offsetY / props.object.height) * 100"
|
||||
:left="(props.object.fill?.offsetX / props.object.width) * 100"
|
||||
@input="(e) => emit('inputFillOffset', e)"
|
||||
@change="(e) => emit('changeFillOffset', e)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, defineEmits, computed } from "vue";
|
||||
import { getTransformScaleAngle } from "../../utils/helper";
|
||||
import AngleTool from "../tools/AngleTool.vue";
|
||||
import OffsetTool from "../tools/OffsetTool.vue";
|
||||
import Slider from "../tools/Slider.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
object: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
});
|
||||
const angle = computed(
|
||||
() => getTransformScaleAngle(props.object.fill?.patternTransform).angle
|
||||
);
|
||||
const scale = computed(() => {
|
||||
const patternTransform = props.object.fill?.patternTransform;
|
||||
const scaleValue = getTransformScaleAngle(patternTransform).scale * 100;
|
||||
return Number(Number(scaleValue).toFixed(2));
|
||||
});
|
||||
const gapX = computed(() => props.object.fill_?.gapX || 0);
|
||||
const gapY = computed(() => props.object.fill_?.gapY || 0);
|
||||
const emit = defineEmits([
|
||||
"inputFillAngle",
|
||||
"changeFillAngle",
|
||||
"inputFillOffset",
|
||||
"changeFillOffset",
|
||||
"inputFillScale",
|
||||
"changeFillScale",
|
||||
"inputFill_Gap",
|
||||
"changeFill_Gap",
|
||||
]);
|
||||
const inputFillScale = (e) => {
|
||||
const scale = e / 100;
|
||||
emit("inputFillScale", scale);
|
||||
};
|
||||
const changeFillScale = (e) => {
|
||||
const scale = e / 100;
|
||||
emit("changeFillScale", scale);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.repeat-setting {
|
||||
user-select: none;
|
||||
> .repeat-setting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
//虚线
|
||||
> .label {
|
||||
min-width: 50px;
|
||||
font-size: 14px;
|
||||
}
|
||||
> .angle-tool {
|
||||
width: 120px;
|
||||
}
|
||||
}
|
||||
> p {
|
||||
margin: 10px 0;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
border-bottom: 1px dashed #e5e5e5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ref } from "vue";
|
||||
import i18n from "@/lang/index.ts";
|
||||
const { t } = i18n.global;
|
||||
|
||||
/** 填充重复模式 */
|
||||
export const getSelectOptions = () => ref([
|
||||
{ value: "no-repeat", label: t("Canvas.noRepeat") },
|
||||
{ value: "repeat", label: t("Canvas.repeat") },
|
||||
{ value: "repeat-x", label: t("Canvas.repeatX") },
|
||||
{ value: "repeat-y", label: t("Canvas.repeatY") },
|
||||
]);
|
||||
|
||||
/** 图层混合模式 */
|
||||
export const getLayerCompositeOptions = () => ref([
|
||||
{ value: "source-over", label: t("Canvas.CompositeNormal"), tip: t("Canvas.CompositeNormalTip") },// 正常
|
||||
{ value: "darken", label: t("Canvas.CompositeDarken"), tip: t("Canvas.CompositeDarkenTip") },// 变暗
|
||||
{ value: "multiply", label: t("Canvas.CompositeMultiply"), tip: t("Canvas.CompositeMultiplyTip") },// 正片叠底
|
||||
{ value: "color-burn", label: t("Canvas.CompositeColorBurn"), tip: t("Canvas.CompositeColorBurnTip") },// 颜色加深
|
||||
|
||||
{ value: "lighten", label: t("Canvas.CompositeLighten"), tip: t("Canvas.CompositeLightenTip") },// 颜色减淡
|
||||
{ value: "screen", label: t("Canvas.CompositeScreen"), tip: t("Canvas.CompositeScreenTip") },// 滤色
|
||||
{ value: "color-dodge", label: t("Canvas.CompositeColorDodge"), tip: t("Canvas.CompositeColorDodgeTip") },// 颜色减淡
|
||||
{ value: "lighter", label: t("Canvas.CompositeLighter"), tip: t("Canvas.CompositeLighterTip") },// 颜色减淡
|
||||
|
||||
{ value: "overlay", label: t("Canvas.CompositeOverlay"), tip: t("Canvas.CompositeOverlayTip") },// 叠加
|
||||
{ value: "soft-light", label: t("Canvas.CompositeSoftLight"), tip: t("Canvas.CompositeSoftLightTip") },// 柔光
|
||||
{ value: "hard-light", label: t("Canvas.CompositeHardLight"), tip: t("Canvas.CompositeHardLightTip") },// 强光
|
||||
|
||||
{ value: "difference", label: t("Canvas.CompositeDifference"), tip: t("Canvas.CompositeDifferenceTip") },// 差值
|
||||
{ value: "exclusion", label: t("Canvas.CompositeExclusion"), tip: t("Canvas.CompositeExclusionTip") },// 排除
|
||||
|
||||
{ value: "hue", label: t("Canvas.CompositeHue"), tip: t("Canvas.CompositeHueTip") },// 色相
|
||||
{ value: "saturation", label: t("Canvas.CompositeSaturation"), tip: t("Canvas.CompositeSaturationTip") },// 饱和度
|
||||
{ value: "color", label: t("Canvas.CompositeColor"), tip: t("Canvas.CompositeColorTip") },// 颜色
|
||||
{ value: "luminosity", label: t("Canvas.CompositeLuminosity"), tip: t("Canvas.CompositeLuminosityTip") },// 亮度
|
||||
|
||||
// { value: "destination-over", label: "背后", tip:"背后:新图形绘制到原内容下方" },
|
||||
// { value: "source-in", label: "颜色加深", tip:"颜色加深:只显示重叠部分,其他透明" },
|
||||
// { value: "destination-in", label: "颜色减淡", tip:"颜色减淡:只显示原内容与新图形重叠部分" },
|
||||
// { value: "source-out", label: "排除", tip:"排除:只显示新图形中不重叠部分" },
|
||||
// { value: "destination-out", label: "差值", tip:"差值:只清除原内容中与新图形重叠部分" },
|
||||
// { value: "xor", label: "排除", tip:"排除:重叠部分透明" },
|
||||
// { value: "copy", label: "正常", tip:"正常:完全忽略原内容,只显示新图形" },
|
||||
// { value: "source-atop", label: "叠加", tip:"叠加:只在与现有内容重叠处绘制新图形" },
|
||||
// { value: "destination-atop", label: "柔光", tip:"柔光:仅保留重叠部分,新图形在原内容后绘制" },
|
||||
// { value: "darker", label: "变暗", tip:"变暗:重叠部分颜色减淡" },
|
||||
]);
|
||||
@@ -0,0 +1,899 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div
|
||||
class="select-menu-panel"
|
||||
v-if="visible"
|
||||
:class="{ active: !closePanel }"
|
||||
>
|
||||
<div class="btn" @click="setClosePanel">
|
||||
<i class="fi fi-br-angle-left"></i>
|
||||
</div>
|
||||
<!-- 变换工具顶部 -->
|
||||
<div class="panel-select">
|
||||
<!-- <div class="panel-header">
|
||||
<div class="header-title">变换工具</div>
|
||||
</div> -->
|
||||
<!-- 分割线 -->
|
||||
<!-- <div class="panel-divider"></div> -->
|
||||
<!-- 变换工具内容 -->
|
||||
<div class="tool-content">
|
||||
<div
|
||||
class="object-item"
|
||||
v-for="v in activeObjects"
|
||||
:key="v.id"
|
||||
>
|
||||
<div class="title">{{ v.layer?.name }}</div>
|
||||
<div class="list">
|
||||
<div
|
||||
class="input"
|
||||
v-if="v.layerId !== SpecialLayerId.COLOR"
|
||||
>
|
||||
<angle-tool
|
||||
:angle="Number(Number(v.angle).toFixed(3))"
|
||||
@input="(e) => inputAngle(e, v)"
|
||||
@change="(e) => changeAngle(e, v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="input">
|
||||
<span class="label"
|
||||
>{{ t("Canvas.opacity") }}:</span
|
||||
>
|
||||
<slider
|
||||
:tipFormatter="
|
||||
(v) => `${Math.round(v * 100)}%`
|
||||
"
|
||||
:value="v.opacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
@change="(e) => changeOpacity(e, v)"
|
||||
@input="(e) => inputOpacity(e, v)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="btn"
|
||||
@click="clickflipHorizontal(v)"
|
||||
v-if="v.layerId !== SpecialLayerId.COLOR"
|
||||
>
|
||||
<i class="iconfont icon-flip-horizontal"></i>
|
||||
<p class="tip">
|
||||
{{ t("Canvas.flipHorizontal") }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="btn"
|
||||
@click="clickflipVertical(v)"
|
||||
v-if="v.layerId !== SpecialLayerId.COLOR"
|
||||
>
|
||||
<i class="iconfont icon-flip-vertical"></i>
|
||||
<p class="tip">
|
||||
{{ t("Canvas.flipVertical") }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="btn"
|
||||
@click="clickCropImage(v)"
|
||||
v-if="v.layerId !== SpecialLayerId.COLOR"
|
||||
>
|
||||
<i class="iconfont icon-caijian"></i>
|
||||
<p class="tip">
|
||||
{{ t("Canvas.cropAndAdd") }}
|
||||
</p>
|
||||
</div> -->
|
||||
<!-- <div
|
||||
class="btn"
|
||||
@click="clickRasterizeLayer(v)"
|
||||
v-if="v.type !== 'image'"
|
||||
>
|
||||
<span class="label">{{ t("Canvas.RasterizedLayer") }}</span>
|
||||
</div> -->
|
||||
<div class="select">
|
||||
<!-- 混合模式 -->
|
||||
<i class="iconfont icon-hunhemoshi"></i>
|
||||
<my-select
|
||||
:defaultValue="
|
||||
v.layer?.blendMode ||
|
||||
v.globalCompositeOperation
|
||||
"
|
||||
:list="layerCompositeOptions"
|
||||
@change="
|
||||
(n, o) => setLayerComposite(n, o, v, 1)
|
||||
"
|
||||
@active="
|
||||
(n, o) => setLayerComposite(n, o, v, 0)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="btn"
|
||||
@click="clickTest(v)"
|
||||
>
|
||||
<span class="label">测试</span>
|
||||
</div> -->
|
||||
<div
|
||||
class="select"
|
||||
v-if="v.type === 'rect' || v.type === 'image'"
|
||||
>
|
||||
<!-- 平铺 -->
|
||||
<i class="iconfont icon-repeat"></i>
|
||||
<a-select
|
||||
size="small"
|
||||
:defaultValue="
|
||||
typeof v.fill === 'object'
|
||||
? v.fill?.repeat || 'no-repeat'
|
||||
: 'no-repeat'
|
||||
"
|
||||
:options="selectOptions"
|
||||
@change="(e) => changeFillRepeat(e, v)"
|
||||
/>
|
||||
</div>
|
||||
<!-- 平铺设置 -->
|
||||
<a-popover
|
||||
v-if="v.type === 'rect'"
|
||||
trigger="click"
|
||||
destroyTooltipOnHide
|
||||
:title="t('Canvas.repeatSetting')"
|
||||
>
|
||||
<template #content>
|
||||
<repeat-setting
|
||||
:object="v"
|
||||
@inputFillAngle="
|
||||
(e) => inputFillAngle(e, v)
|
||||
"
|
||||
@changeFillAngle="
|
||||
(e) => changeFillAngle(e, v)
|
||||
"
|
||||
@inputFillOffset="
|
||||
(e) => inputFillOffset(e, v)
|
||||
"
|
||||
@changeFillOffset="
|
||||
(e) => changeFillOffset(e, v)
|
||||
"
|
||||
@inputFillScale="
|
||||
(e) => inputFillScale(e, v)
|
||||
"
|
||||
@changeFillScale="
|
||||
(e) => changeFillScale(e, v)
|
||||
"
|
||||
@inputFill_Gap="
|
||||
(x, y) => inputFill_Gap(x, y, v)
|
||||
"
|
||||
@changeFill_Gap="
|
||||
(x, y) => changeFill_Gap(x, y, v)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<div class="btn">
|
||||
<i class="iconfont icon-gengduo"></i>
|
||||
</div>
|
||||
</a-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, onUnmounted, reactive } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
const { t } = useI18n();
|
||||
import { OperationType, SpecialLayerId } from "../../utils/layerHelper";
|
||||
import { loadImageUrlToLayer } from "../../utils/imageHelper";
|
||||
import {
|
||||
calculateRotatedTopLeftDeg,
|
||||
createPatternTransform,
|
||||
getTransformScaleAngle,
|
||||
} from "../../utils/helper";
|
||||
import { TransformCommand } from "../../commands/StateCommands";
|
||||
import {
|
||||
FillRepeatCommand,
|
||||
FillRepeatChangeCommand,
|
||||
FillRepeatGapChangeCommand,
|
||||
} from "../../commands/FillRepeatCommand";
|
||||
import { SetLayerCompositeCommand } from "../../commands/LayerCommands.js";
|
||||
import RepeatSetting from "./RepeatSetting.vue";
|
||||
import Slider from "../tools/Slider.vue";
|
||||
import AngleTool from "../tools/AngleTool.vue";
|
||||
import MySelect from "../tools/MySelect.vue";
|
||||
import EventManager from "../../utils/event.js";
|
||||
import { getSelectOptions, getLayerCompositeOptions } from "./data.js";
|
||||
const selectOptions = getSelectOptions();
|
||||
const layerCompositeOptions = getLayerCompositeOptions();
|
||||
const props = defineProps({
|
||||
canvas: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
commandManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
selectManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
layerManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
canvasManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
toolManager: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
activeTool: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
// 响应式数据
|
||||
const visible = ref(false);
|
||||
//打开隐藏操作面板
|
||||
const closePanel = ref(false);
|
||||
const setClosePanel = () => {
|
||||
closePanel.value = !closePanel.value;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setupCanvasListeners();
|
||||
});
|
||||
onUnmounted(() => {
|
||||
removeCanvasListeners();
|
||||
});
|
||||
|
||||
// 监听 activeTool 变化
|
||||
watch(
|
||||
() => props.activeTool,
|
||||
(newTool) => {
|
||||
if (newTool === OperationType.SELECT) {
|
||||
show();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 显示面板
|
||||
*/
|
||||
function show() {
|
||||
if (activeObjects.length === 0) return;
|
||||
visible.value = true;
|
||||
closePanel.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭面板
|
||||
*/
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
// 获取当前选中的对象
|
||||
const activeObjects = reactive([]);
|
||||
const getActiveObject = (e) => {
|
||||
console.log("==========切换激活对象", e, activeObjects);
|
||||
activeObjects.splice(0, activeObjects.length, ...e.selected);
|
||||
activeObjects.forEach((v) => {
|
||||
v.layer = props.layerManager.getLayerById(v.layerId);
|
||||
});
|
||||
if (activeObjects.length === 0) {
|
||||
close();
|
||||
} else {
|
||||
show();
|
||||
}
|
||||
};
|
||||
//取消当前选中
|
||||
const cancelSelect = () => {
|
||||
activeObjects.splice(0, activeObjects.length);
|
||||
close();
|
||||
};
|
||||
const lastSelectLayerId = inject("lastSelectLayerId");
|
||||
const layers = inject("layers");
|
||||
const transformObject = (
|
||||
activeObj,
|
||||
initialState,
|
||||
finalState,
|
||||
isCommand = true
|
||||
) => {
|
||||
const cmd = new TransformCommand({
|
||||
canvas: props.canvas,
|
||||
objectId: activeObj.id,
|
||||
initialState,
|
||||
finalState,
|
||||
objectType: activeObj.type,
|
||||
name: `变换 ${activeObj.type || "对象"}`,
|
||||
layerManager: props.layerManager,
|
||||
layers: layers,
|
||||
lastSelectLayerId: lastSelectLayerId,
|
||||
});
|
||||
if (isCommand) {
|
||||
props.commandManager.execute(cmd);
|
||||
} else {
|
||||
cmd.execute();
|
||||
}
|
||||
};
|
||||
// 改变不透明度
|
||||
const changeOpacity = (opacity, obj) => {
|
||||
props.layerManager?.setLayerOpacity(obj.layerId, opacity);
|
||||
};
|
||||
const inputOpacity = (opacity, obj) => {
|
||||
obj.opacity = opacity;
|
||||
props.canvas.renderAll();
|
||||
};
|
||||
// 改变角度
|
||||
const inputAngle = (angle, obj) => {
|
||||
const initialState = TransformCommand.captureTransformState(obj);
|
||||
const finalState = computeAngleState(angle, obj, initialState);
|
||||
transformObject(obj, initialState, finalState, false);
|
||||
if (!obj.hasOwnProperty("oldState")) obj.oldState = initialState;
|
||||
};
|
||||
const changeAngle = (angle, obj) => {
|
||||
var initialState;
|
||||
if (obj.hasOwnProperty("oldState")) {
|
||||
initialState = obj.oldState;
|
||||
delete obj.oldState;
|
||||
} else {
|
||||
initialState = TransformCommand.captureTransformState(obj);
|
||||
}
|
||||
const finalState = computeAngleState(angle, obj, initialState);
|
||||
transformObject(obj, initialState, finalState);
|
||||
};
|
||||
const computeAngleState = (angle, obj, initialState) => {
|
||||
const finalState = { ...initialState };
|
||||
if (obj.originX === "left" && obj.originY === "top") {
|
||||
const width = obj.width * obj.scaleX;
|
||||
const height = obj.height * obj.scaleY;
|
||||
const left = obj.left;
|
||||
const top = obj.top;
|
||||
const { x, y } = calculateRotatedTopLeftDeg(
|
||||
width,
|
||||
height,
|
||||
left,
|
||||
top,
|
||||
obj.angle,
|
||||
angle
|
||||
);
|
||||
finalState.left = x;
|
||||
finalState.top = y;
|
||||
}
|
||||
finalState.angle = angle;
|
||||
return finalState;
|
||||
};
|
||||
|
||||
// 水平翻转
|
||||
const clickflipHorizontal = (obj) => {
|
||||
const initialState = TransformCommand.captureTransformState(obj);
|
||||
const finalState = { ...initialState };
|
||||
finalState.flipX = !finalState.flipX;
|
||||
transformObject(obj, initialState, finalState);
|
||||
};
|
||||
// 垂直翻转
|
||||
const clickflipVertical = (obj) => {
|
||||
const initialState = TransformCommand.captureTransformState(obj);
|
||||
const finalState = { ...initialState };
|
||||
finalState.flipY = !finalState.flipY;
|
||||
transformObject(obj, initialState, finalState);
|
||||
};
|
||||
// 裁剪图片
|
||||
const cropImage = inject("cropImage");
|
||||
const clickCropImage = async (obj) => {
|
||||
const base64 = await props.layerManager.getLayerToBase64(obj.layerId);
|
||||
if (base64)
|
||||
cropImage(base64).then((res) => {
|
||||
loadImageUrlToLayer({
|
||||
imageUrl: res,
|
||||
layerManager: props.layerManager,
|
||||
canvas: props.canvas,
|
||||
toolManager: props.toolManager,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 栅格化图层
|
||||
const clickRasterizeLayer = (obj) => {
|
||||
props.layerManager.rasterizeLayer(obj.layerId);
|
||||
};
|
||||
|
||||
// 改变填充重复
|
||||
const changeFillRepeat = async (value, obj) => {
|
||||
console.log("==========改变填充重复", obj.type);
|
||||
const cmd = new FillRepeatCommand({
|
||||
canvas: props.canvas,
|
||||
layers: layers,
|
||||
canvasManager: props.canvasManager,
|
||||
layerManager: props.layerManager,
|
||||
layerId: obj.layerId,
|
||||
fillRepeat: value,
|
||||
});
|
||||
props.commandManager.execute(cmd);
|
||||
};
|
||||
// 改变填充角度
|
||||
const inputFillAngle = (angle, obj) => {
|
||||
if (!obj.oldPattern) obj.oldPattern = obj.get("fill");
|
||||
const fill = obj.get("fill");
|
||||
const scale = getTransformScaleAngle(fill?.patternTransform).scale;
|
||||
const pattern = new fabric.Pattern({
|
||||
...fill,
|
||||
patternTransform: createPatternTransform(scale, angle),
|
||||
});
|
||||
obj.set("fill", pattern);
|
||||
props.canvas.renderAll();
|
||||
};
|
||||
const changeFillAngle = (angle, obj) => {
|
||||
const fill = obj.get("fill");
|
||||
const scale = getTransformScaleAngle(fill?.patternTransform).scale;
|
||||
const pattern = {
|
||||
patternTransform: createPatternTransform(scale, angle),
|
||||
};
|
||||
changeFill(obj, pattern);
|
||||
};
|
||||
// 改变填充便宜
|
||||
const inputFillOffset = (value, obj) => {
|
||||
if (!obj.oldPattern) obj.oldPattern = obj.get("fill");
|
||||
const pattern = new fabric.Pattern({
|
||||
...obj.get("fill"),
|
||||
offsetX: (value.left / 100) * obj.width,
|
||||
offsetY: (value.top / 100) * obj.height,
|
||||
});
|
||||
obj.set("fill", pattern);
|
||||
props.canvas.renderAll();
|
||||
};
|
||||
const changeFillOffset = (value, obj) => {
|
||||
const pattern = new fabric.Pattern({
|
||||
offsetX: (value.left / 100) * obj.width,
|
||||
offsetY: (value.top / 100) * obj.height,
|
||||
});
|
||||
changeFill(obj, pattern);
|
||||
};
|
||||
// 改变填充缩放
|
||||
const inputFillScale = (scale, obj) => {
|
||||
if (!obj.oldPattern) obj.oldPattern = obj.get("fill");
|
||||
const fill = obj.get("fill");
|
||||
const angle = getTransformScaleAngle(fill?.patternTransform).angle;
|
||||
const pattern = new fabric.Pattern({
|
||||
...fill,
|
||||
patternTransform: createPatternTransform(scale, angle),
|
||||
});
|
||||
obj.set("fill", pattern);
|
||||
props.canvas.renderAll();
|
||||
};
|
||||
const changeFillScale = (scale, obj) => {
|
||||
const fill = obj.get("fill");
|
||||
const angle = getTransformScaleAngle(fill?.patternTransform).angle;
|
||||
const pattern = {
|
||||
patternTransform: createPatternTransform(scale, angle),
|
||||
};
|
||||
changeFill(obj, pattern);
|
||||
};
|
||||
const changeFill = (obj, pattern) => {
|
||||
const cmd = new FillRepeatChangeCommand({
|
||||
canvas: props.canvas,
|
||||
layers: layers,
|
||||
canvasManager: props.canvasManager,
|
||||
layerManager: props.layerManager,
|
||||
layerId: obj.layerId,
|
||||
newPattern: pattern,
|
||||
});
|
||||
props.commandManager.execute(cmd);
|
||||
};
|
||||
// 改变填充间隙
|
||||
const inputFill_Gap = (gapX, gapY, obj) => {
|
||||
const cmd = new FillRepeatGapChangeCommand({
|
||||
canvas: props.canvas,
|
||||
layers: layers,
|
||||
canvasManager: props.canvasManager,
|
||||
layerManager: props.layerManager,
|
||||
layerId: obj.layerId,
|
||||
newGapX: gapX,
|
||||
newGapY: gapY,
|
||||
record: true,
|
||||
});
|
||||
cmd.execute();
|
||||
};
|
||||
const changeFill_Gap = (gapX, gapY, obj) => {
|
||||
if (obj.oldFill_) {
|
||||
obj.fill_ = { ...obj.oldFill_ };
|
||||
delete obj.oldFill_;
|
||||
}
|
||||
const cmd = new FillRepeatGapChangeCommand({
|
||||
canvas: props.canvas,
|
||||
layers: layers,
|
||||
canvasManager: props.canvasManager,
|
||||
layerManager: props.layerManager,
|
||||
layerId: obj.layerId,
|
||||
newGapX: gapX,
|
||||
newGapY: gapY,
|
||||
});
|
||||
props.commandManager.execute(cmd);
|
||||
};
|
||||
|
||||
const setLayerComposite = (newValue, oldValue, obj, isCmd) => {
|
||||
const cmd = new SetLayerCompositeCommand({
|
||||
canvas: props.canvas,
|
||||
layers: layers,
|
||||
layerManager: props.layerManager,
|
||||
layerId: obj.layerId,
|
||||
newValue: newValue,
|
||||
oldValue: oldValue,
|
||||
});
|
||||
if (isCmd) {
|
||||
props.commandManager.execute(cmd);
|
||||
} else {
|
||||
cmd.execute();
|
||||
}
|
||||
};
|
||||
|
||||
const clickTest = (obj) => {
|
||||
console.log("==========点击测试", obj);
|
||||
};
|
||||
|
||||
// 更新选中对象属性
|
||||
const updateActiveObjects = (arrs, keys, isNumber = true) => {
|
||||
arrs.forEach((v) => {
|
||||
activeObjects.forEach((item) => {
|
||||
if (item.id === v.id) {
|
||||
keys.forEach(
|
||||
(key) => (item[key] = isNumber ? Number(v[key]) : v[key])
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 旋转对象时更新角度
|
||||
const objectRotatingChange = (e) => {
|
||||
const arrs = [];
|
||||
if (e.target._objects) {
|
||||
e.target._objects.forEach((v) => arrs.push(v));
|
||||
} else {
|
||||
arrs.push(e.target);
|
||||
}
|
||||
updateActiveObjects(arrs, ["angle"]);
|
||||
};
|
||||
|
||||
// 对象属性修改后触发
|
||||
const objectModifiedChange = (e) => {
|
||||
console.log("==========object:modified", e.target);
|
||||
};
|
||||
// 不透明度撤销时触发
|
||||
const objectOpacityUndo = (layerId, opacity) => {
|
||||
const layerObjects = props.canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.layerId === layerId);
|
||||
updateActiveObjects(layerObjects, ["opacity"]);
|
||||
};
|
||||
// 对象属性修改撤销时触发
|
||||
const objectModifiedUndo = (object) => {
|
||||
updateActiveObjects([object], ["angle"]);
|
||||
};
|
||||
// 组合操作撤销时触发
|
||||
const objectCompositeChange = (object) => {
|
||||
updateActiveObjects([object], ["globalCompositeOperation"], false);
|
||||
};
|
||||
/**
|
||||
* 设置画布事件监听
|
||||
*/
|
||||
function setupCanvasListeners() {
|
||||
if (!props.canvas) return;
|
||||
// 注册事件
|
||||
props.canvas.on("selection:created", getActiveObject);
|
||||
props.canvas.on("selection:updated", getActiveObject);
|
||||
props.canvas.on("selection:cleared", cancelSelect);
|
||||
props.canvas.on("object:rotating", objectRotatingChange);
|
||||
props.canvas.on("object:modified", objectModifiedChange);
|
||||
EventManager.on("object:opacity:execute", objectOpacityUndo);
|
||||
EventManager.on("object:opacity:undo", objectOpacityUndo);
|
||||
EventManager.on("object:modified:execute", objectModifiedUndo);
|
||||
EventManager.on("object:modified:undo", objectModifiedUndo);
|
||||
EventManager.on("object:composite:execute", objectCompositeChange);
|
||||
EventManager.on("object:composite:undo", objectCompositeChange);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除画布事件监听
|
||||
*/
|
||||
function removeCanvasListeners() {
|
||||
if (!props.canvas) return;
|
||||
// 移除事件
|
||||
props.canvas.off("selection:created", getActiveObject);
|
||||
props.canvas.off("selection:updated", getActiveObject);
|
||||
props.canvas.off("selection:cleared", cancelSelect);
|
||||
props.canvas.off("object:rotating", objectRotatingChange);
|
||||
props.canvas.off("object:modified", objectModifiedChange);
|
||||
EventManager.off("object:opacity:execute", objectOpacityUndo);
|
||||
EventManager.off("object:opacity:undo", objectOpacityUndo);
|
||||
EventManager.off("object:modified:execute", objectModifiedUndo);
|
||||
EventManager.off("object:modified:undo", objectModifiedUndo);
|
||||
EventManager.off("object:composite:execute", objectCompositeChange);
|
||||
EventManager.off("object:composite:undo", objectCompositeChange);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.select-menu-panel {
|
||||
position: absolute;
|
||||
bottom: 22px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
// max-width: min(90vw, 640px);
|
||||
max-width: 95%;
|
||||
width: 80rem;
|
||||
margin: 0 auto;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(15px);
|
||||
-webkit-backdrop-filter: blur(15px);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
color: #333;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
user-select: none;
|
||||
&.active {
|
||||
transform: translateY(100%);
|
||||
> .btn {
|
||||
> i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
> .btn {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
> i {
|
||||
font-size: 1.4rem;
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板和手机适配 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.select-menu-panel {
|
||||
bottom: 15px;
|
||||
left: 15px;
|
||||
right: 15px;
|
||||
max-width: calc(100vw - 30px);
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.select-menu-panel {
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
max-width: calc(100vw - 20px);
|
||||
}
|
||||
}
|
||||
|
||||
.select-menu-panel.is-active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 8px 15px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.panel-select {
|
||||
// padding: 0 0 10px;
|
||||
}
|
||||
|
||||
/* 平板适配 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.panel-header {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机适配 */
|
||||
@media screen and (max-width: 480px) {
|
||||
.panel-header {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-btn span {
|
||||
margin-top: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tool-btn svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background-color: #007aff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.panel-divider {
|
||||
height: 1px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
margin: 0 10px 5px 10px;
|
||||
}
|
||||
|
||||
.tool-content {
|
||||
overflow-y: auto;
|
||||
max-height: 20rem;
|
||||
margin-top: 1rem;
|
||||
padding: 0 1.5rem;
|
||||
> .object-item {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
padding: 1rem 0;
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
> .title {
|
||||
text-align: left;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
> .list {
|
||||
display: flex;
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 1.5rem;
|
||||
position: relative;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
> .iconfont {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
> .label {
|
||||
font-size: 1.3rem;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
> .angle-tool {
|
||||
width: 9rem;
|
||||
}
|
||||
> .tip {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -100%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 0.4rem;
|
||||
// margin-left: 0.8rem;
|
||||
font-size: 1.2rem;
|
||||
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);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
> .tip {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
> div.input {
|
||||
font-size: 1.4rem;
|
||||
color: #474747;
|
||||
> .label {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
> .iconfont {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
> .slider {
|
||||
width: 8rem;
|
||||
}
|
||||
}
|
||||
> div.select {
|
||||
> .iconfont {
|
||||
margin-right: 4px;
|
||||
}
|
||||
> .my-select,
|
||||
> .ant-select {
|
||||
width: 12rem;
|
||||
text-align: left;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
> div.btn {
|
||||
min-width: 2.8rem;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
> div.color {
|
||||
width: 4rem;
|
||||
height: 2.5rem;
|
||||
cursor: pointer;
|
||||
background-image: linear-gradient(to bottom, #ff0000, #ffff00);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 平板适配 - 每行4个按钮 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.tool-content {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px 6px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机适配 - 每行3个按钮 */
|
||||
@media screen and (max-width: 480px) {
|
||||
.tool-content {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 6px 4px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.header-btn {
|
||||
font-size: 11px;
|
||||
padding: 2px 4px;
|
||||
min-width: 28px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -412,8 +412,12 @@ const handleToolClick = (tool) => {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.tools-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.red-green-mode {
|
||||
background-color: #fff4f4;
|
||||
background-color: #060505;
|
||||
}
|
||||
|
||||
.mode-indicator {
|
||||
|
||||
@@ -270,6 +270,13 @@
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.layer-color-btn {
|
||||
width: 30px;
|
||||
height: 20px;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
.layer-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
|
||||
121
src/component/Canvas/CanvasEditor/components/tools/AngleTool.vue
Normal file
121
src/component/Canvas/CanvasEditor/components/tools/AngleTool.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="angle-tool">
|
||||
<div
|
||||
ref="dishRef"
|
||||
class="dish"
|
||||
@mousedown.stop="mousedown"
|
||||
@touchmove.stop="mousedown"
|
||||
>
|
||||
<div class="pointer" :style="{ transform: `rotate(${angle}deg)` }">
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input">
|
||||
<input type="number" v-model="angle" @input="onInput" @change="onChange" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, defineEmits, watch } from "vue";
|
||||
import { calculateAngle } from "../../utils/helper";
|
||||
// Props
|
||||
const props = defineProps({
|
||||
angle: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["change", "input"]);
|
||||
const angle = ref(props.angle);
|
||||
watch(() => props.angle, (value) => {
|
||||
angle.value = value;
|
||||
});
|
||||
const dishRef = ref<HTMLDivElement>();
|
||||
const mousedown = (e: MouseEvent | TouchEvent) => {
|
||||
const mousemove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!dishRef.value) return;
|
||||
const { left, top, width, height } =
|
||||
dishRef.value.getBoundingClientRect();
|
||||
const centerX = left + width / 2;
|
||||
const centerY = top + height / 2;
|
||||
const { clientX, clientY } = e?.touches?.[0] || e;
|
||||
angle.value = calculateAngle(centerX, centerY, clientX, clientY, true);
|
||||
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 = () => emit("input", angle.value);
|
||||
var changeTime: any = null;
|
||||
const onChange = () => {
|
||||
clearTimeout(changeTime);
|
||||
changeTime = setTimeout(() => emit("change", angle.value), 500);
|
||||
};
|
||||
// var angleTime = null;
|
||||
// watch(angle, (value) => {
|
||||
// emit("input", value);
|
||||
// clearTimeout(angleTime);
|
||||
// angleTime = setTimeout(() => emit("change", value), 50);
|
||||
// });
|
||||
// defineExpose({
|
||||
// open,
|
||||
// close,
|
||||
// });
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.angle-tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
> .dish {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid #000;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
> .pointer {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
> span {
|
||||
position: absolute;
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
width: 35%;
|
||||
height: 35%;
|
||||
background-color: #000;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
> .input {
|
||||
margin-left: 5px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
flex: 1;
|
||||
// min-width: 45px;
|
||||
// max-width: 80px;
|
||||
// width: 50px;
|
||||
> input {
|
||||
width: 100%;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<a-select
|
||||
class="my-select"
|
||||
:size="size"
|
||||
@change="change"
|
||||
:defaultValue="defaultValue"
|
||||
@dropdownVisibleChange="dropdownVisibleChange"
|
||||
>
|
||||
<a-select-option
|
||||
v-for="v in list"
|
||||
:key="v.value"
|
||||
:value="v.value"
|
||||
:title="v.tip"
|
||||
@mouseover.stop.prevent="mouseover(v)"
|
||||
@mouseleave="mouseleave(v)"
|
||||
>{{ v.label }}</a-select-option
|
||||
>
|
||||
</a-select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, defineEmits, watch } from "vue";
|
||||
const props = defineProps({
|
||||
defaultValue: {
|
||||
default: "",
|
||||
},
|
||||
list: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "small",
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["change", "active"]);
|
||||
const isChange = ref(false);
|
||||
const initValue = ref(props.defaultValue);
|
||||
const activeValue = ref(props.defaultValue);
|
||||
const timeout = ref(null);
|
||||
const mouseover = (v) => {
|
||||
clearTimeout(timeout.value);
|
||||
if (v.value === activeValue.value) return;
|
||||
emit("active", v.value, activeValue.value);
|
||||
activeValue.value = v.value;
|
||||
};
|
||||
const mouseleave = () => {
|
||||
clearTimeout(timeout.value);
|
||||
timeout.value = setTimeout(() => {
|
||||
dropdownVisibleChange(false);
|
||||
}, 100);
|
||||
};
|
||||
const change = (v) => {
|
||||
isChange.value = true;
|
||||
emit("change", v, initValue.value);
|
||||
};
|
||||
const dropdownVisibleChange = (v) => {
|
||||
if (v) {
|
||||
isChange.value = false;
|
||||
initValue.value = props.defaultValue;
|
||||
} else if (!isChange.value) {
|
||||
emit("active", initValue.value, activeValue.value);
|
||||
activeValue.value = initValue.value;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<div class="offset-tool">
|
||||
<div
|
||||
class="dish"
|
||||
@mousedown="mousedown"
|
||||
@touchstart="mousedown"
|
||||
ref="dishRef"
|
||||
>
|
||||
<span
|
||||
:style="{ top: data.top + '%', left: data.left + '%' }"
|
||||
></span>
|
||||
</div>
|
||||
<input
|
||||
class="top"
|
||||
type="range"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="0.1"
|
||||
v-model="data.top"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
/>
|
||||
<input
|
||||
class="left"
|
||||
type="range"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="0.1"
|
||||
v-model="data.left"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
/>
|
||||
<span class="tip"
|
||||
>x:{{ tofix(data.left) }}% y:{{ tofix(data.top) }}%</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, defineEmits, watch } from "vue";
|
||||
const props = defineProps({
|
||||
top: {
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
left: {
|
||||
type: Number,
|
||||
default: 50,
|
||||
},
|
||||
});
|
||||
const tofix = (v: number | string) => Number(Number(v).toFixed(1));
|
||||
const emit = defineEmits(["change", "input"]);
|
||||
const data = reactive({
|
||||
top: tofix(props.top),
|
||||
left: tofix(props.left),
|
||||
});
|
||||
watch(
|
||||
() => props.top,
|
||||
(v) => (data.top = tofix(v))
|
||||
);
|
||||
watch(
|
||||
() => props.left,
|
||||
(v) => (data.left = tofix(v))
|
||||
);
|
||||
const dishRef = ref<HTMLDivElement>();
|
||||
const mousedown = (e: MouseEvent | TouchEvent) => {
|
||||
if (!dishRef.value) return;
|
||||
const mousemove = (e: MouseEvent | TouchEvent) => {
|
||||
if (!dishRef.value) return;
|
||||
const { left, top, width, height } =
|
||||
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 - left) / width) * 100;
|
||||
var y = ((Y - top) / height) * 100;
|
||||
if (x < 0) x = 0;
|
||||
if (x > 100) x = 100;
|
||||
if (y < 0) y = 0;
|
||||
if (y > 100) y = 100;
|
||||
data.left = tofix(x);
|
||||
data.top = tofix(y);
|
||||
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 = () => emit("input", { ...data });
|
||||
var changeTime: any = null;
|
||||
const onChange = () => {
|
||||
clearTimeout(changeTime);
|
||||
changeTime = setTimeout(() => emit("change", { ...data }), 500);
|
||||
};
|
||||
// var offsetTime = null;
|
||||
// watch(data, (v) => {
|
||||
// const obj = { ...v };
|
||||
// emit("input", obj);
|
||||
// clearTimeout(offsetTime);
|
||||
// offsetTime = setTimeout(() => emit("change", obj), 50);
|
||||
// });
|
||||
|
||||
// defineExpose({
|
||||
// open,
|
||||
// close,
|
||||
// });
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.offset-tool {
|
||||
width: 125px;
|
||||
height: 125px;
|
||||
display: flex;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
--gap: 15px;
|
||||
> .dish {
|
||||
margin: var(--gap) 0 0 var(--gap);
|
||||
flex: 1;
|
||||
border: 1px solid #000;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
> span {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
position: absolute;
|
||||
top: 0%;
|
||||
left: 0%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background-color: #000;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
> .tip {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
bottom: 0;
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
color: #666;
|
||||
}
|
||||
> input.left {
|
||||
right: 0;
|
||||
}
|
||||
> input.top {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
transform-origin: left bottom;
|
||||
transform: rotate(90deg) translateX(-100%);
|
||||
}
|
||||
> input {
|
||||
position: absolute;
|
||||
width: calc(100% - var(--gap));
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
|
||||
// outline: none;
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #4285f4; /* 蓝色滑块 */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
&::-webkit-slider-thumb:hover {
|
||||
background: #3b77db;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
160
src/component/Canvas/CanvasEditor/components/tools/Slider.vue
Normal file
160
src/component/Canvas/CanvasEditor/components/tools/Slider.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="slider">
|
||||
<div class="input-range">
|
||||
<span
|
||||
class="tip"
|
||||
:style="{
|
||||
'--progress': (value - props.min) / (props.max - props.min),
|
||||
}"
|
||||
>{{ props.tipFormatter(value) }}</span
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
v-model="value"
|
||||
:min="props.min"
|
||||
:max="props.max"
|
||||
:step="props.step"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="input" v-show="isInput">
|
||||
<input
|
||||
type="number"
|
||||
v-model="value"
|
||||
:min="props.min"
|
||||
:max="props.max"
|
||||
:step="props.step"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps, defineEmits, watch } from "vue";
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
min: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
max: {
|
||||
type: Number,
|
||||
default: 100,
|
||||
},
|
||||
step: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
tipFormatter: {
|
||||
type: Function,
|
||||
default: (v) => v,
|
||||
},
|
||||
isInput: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["change", "input"]);
|
||||
const value = ref(props.value);
|
||||
watch(
|
||||
() => props.value,
|
||||
(v) => (value.value = v)
|
||||
);
|
||||
const onInput = () => emit("input", Number(value.value));
|
||||
var changeTime: any = null;
|
||||
const onChange = () => {
|
||||
clearTimeout(changeTime);
|
||||
changeTime = setTimeout(() => emit("change", Number(value.value)), 500);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.slider {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
--input-thumb-size: 12px;
|
||||
width: 150px;
|
||||
// &:focus-within,
|
||||
&:hover {
|
||||
> .input-range > .tip {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
> .input-range {
|
||||
position: relative;
|
||||
flex: 2;
|
||||
> input {
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
height: 5px;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
|
||||
outline: none;
|
||||
&::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: var(--input-thumb-size);
|
||||
height: var(--input-thumb-size);
|
||||
border-radius: 50%;
|
||||
background: #4285f4; /* 蓝色滑块 */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
&::-webkit-slider-thumb:hover {
|
||||
background: #3b77db;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
> .tip {
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
color: #666;
|
||||
top: 0;
|
||||
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: 0.4rem 0.8rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 1.2rem;
|
||||
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>
|
||||
Reference in New Issue
Block a user