画布增加的新功能

This commit is contained in:
李志鹏
2026-01-02 11:24:11 +08:00
parent 1ae365b1f3
commit f8e4ab8cdb
59 changed files with 4401 additions and 1213 deletions

View File

@@ -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;

View File

@@ -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"
>

View File

@@ -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">
<!-- 遍历固定层 -->

View File

@@ -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;

View File

@@ -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(() => {
// 计算按压持续时间

View File

@@ -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">&times;</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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:"变暗:重叠部分颜色减淡" },
]);

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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;

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>