平铺元素ui更改

This commit is contained in:
李志鹏
2026-01-13 14:41:20 +08:00
parent e1ca896764
commit 6eda04a81e
18 changed files with 1544 additions and 233 deletions

View File

@@ -0,0 +1,855 @@
<template>
<transition name="fade">
<div class="part-selector-toolbar" v-if="visible" :class="{active:!closePanel}">
<div class="btn" @click="setClosePanel"><i class="fi fi-br-angle-left"></i></div>
<!-- 顶部选区类型工具栏 -->
<div class="toolbar-section">
<div class="toolbar-header">
<div class="header-title">{{ t("Canvas.GarmentPartSelector") }}</div>
<!-- 移除关闭按钮完全通过工具切换控制显示隐藏 -->
</div>
<div class="tool-types">
<div
:class="[
'tool-btn',
{ active: selectionType === OperationType.LASSO },
]"
@click="setSelectionType(OperationType.LASSO)"
>
<svg-icon name="CFree" size="20" />
<span>{{ $t("Canvas.freehandSketching") }}</span>
</div>
<div
:class="[
'tool-btn',
{ active: selectionType === OperationType.LASSO_RECTANGLE },
]"
@click="setSelectionType(OperationType.LASSO_RECTANGLE)"
>
<svg-icon name="CRectangle" size="26" />
<span>{{ $t("Canvas.rectangle") }}</span>
</div>
<div
:class="[
'tool-btn',
{ active: selectionType === OperationType.LASSO_ELLIPSE },
]"
@click="setSelectionType(OperationType.LASSO_ELLIPSE)"
>
<svg-icon name="CEllipse" size="24" />
<span>{{ $t("Canvas.ellipse") }}</span>
</div>
</div>
<!-- 分割线 -->
<div class="toolbar-divider"></div>
<!-- 底部选区操作工具栏 -->
<div class="tool-actions">
<div class="action-btn" @click="copySelectionToNewLayer">
<svg-icon name="CPaste" size="16" />
<span class="btn-text">{{ $t("Canvas.creation") }}</span>
</div>
<div class="action-btn" @click="cutSelectionToNewLayer">
<svg-icon name="CCut" size="26" />
<span class="btn-text">{{ $t("Canvas.CreateAndCopy") }}</span>
</div>
<div class="action-btn" @click="clearSelectionContent">
<svg-icon name="CClear" size="18" />
<span class="btn-text">{{ $t("Canvas.TheClearlySelectedContent") }}</span>
</div>
<!-- <button
class="action-btn"
@click="addSelection"
:disabled="!hasSelection"
title="添加"
>
<svg-icon name="plus" />
<span class="btn-text">{{ $t("添加") }}</span>
</button>
<button
class="action-btn"
@click="removeSelection"
:disabled="!hasSelection"
title="移除"
>
<svg-icon name="minus" />
<span class="btn-text">{{ $t("移除") }}</span>
</button>
<button
class="action-btn"
@click="invertSelection"
:disabled="!hasSelection"
title="反转"
>
<svg-icon name="flip-horizontal" />
<span class="btn-text">{{ $t("反转") }}</span>
</button> -->
<!-- <button
class="action-btn"
@click="copySelectionToNewLayer"
:disabled="!hasSelection"
title="拷贝并粘贴"
>
<svg-icon name="copy" />
<span class="btn-text">{{ $t("拷贝并粘贴") }}</span>
</button>
<button
class="action-btn"
@click="openFeatherDialog"
:disabled="!hasSelection"
title="羽化"
>
<svg-icon name="feather" />
<span class="btn-text">{{ $t("羽化") }}</span>
</button>
<button
class="action-btn"
@click="fillSelection"
:disabled="!hasSelection"
title="颜色填充"
>
<svg-icon name="fill-color" />
<span class="btn-text">{{ $t("颜色填充") }}</span>
</button>
<button
class="action-btn"
@click="clearSelection"
:disabled="!hasSelection"
title="清除"
>
<svg-icon name="trash" />
<span class="btn-text">{{ $t("清除") }}</span>
</button> -->
</div>
</div>
<!-- 羽化设置弹窗 -->
<div v-if="showFeatherDialog" class="dialog-overlay">
<div class="dialog-container">
<div class="dialog-header">
<h3>{{ $t("羽化") }}</h3>
<button class="close-dialog-btn" @click="cancelFeather">×</button>
</div>
<div class="dialog-content">
<div class="feather-control">
<input
type="range"
min="0"
max="50"
v-model.number="featherAmount"
class="slider-control"
/>
<div class="feather-value">{{ featherAmount }}px</div>
</div>
<div class="dialog-buttons">
<button class="cancel-btn" @click="cancelFeather">
{{ $t("Canvas.close") }}
</button>
<button class="confirm-btn" @click="applyFeather">
{{ $t("Canvas.confirmEdit") }}
</button>
</div>
</div>
</div>
</div>
<!-- 颜色选择器 -->
<div v-if="showColorPicker" class="dialog-overlay">
<div class="dialog-container">
<div class="dialog-header">
<h3>{{ $t("Canvas.SelectFillColor") }}</h3>
<button class="close-dialog-btn" @click="cancelColorPicker">
×
</button>
</div>
<div class="dialog-content">
<input type="color" v-model="fillColor" class="color-picker" />
<div class="dialog-buttons">
<button class="cancel-btn" @click="cancelColorPicker">
{{ $t("Canvas.close") }}
</button>
<button class="confirm-btn" @click="confirmColorPicker">
{{ $t("Canvas.confirmEdit") }}
</button>
</div>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
CreateSelectionCommand,
InvertSelectionCommand,
FeatherSelectionCommand,
FillSelectionCommand,
// CopySelectionToNewLayerCommand,
// ClearSelectionContentCommand,
} from "../commands/SelectionCommands";
import { ToolCommand } from "../commands/ToolCommands";
import {
LassoCutoutCommand,
ClearSelectionCommand,
// CutSelectionToNewLayerCommand,
} from "../commands/LassoCutoutCommand";
import { OperationType } from "../utils/layerHelper";
import { ClearSelectionContentCommand } from "../commands/ClearSelectionContentCommand";
import { CutSelectionToNewLayerCommand } from "../commands/CutSelectionToNewLayerCommand";
const props = defineProps({
canvas: {
type: Object,
required: true,
},
commandManager: {
type: Object,
required: true,
},
selectionManager: {
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 selectionType = ref("rectangle");
const featherAmount = ref(0);
const fillColor = ref("#000000");
const hasSelection = ref(false);
const showFeatherDialog = ref(false);
const showColorPicker = ref(false);
//打开隐藏操作面板
const closePanel = ref(false)
const setClosePanel = ()=>{
closePanel.value = !closePanel.value
}
// 国际化
const { t } = useI18n();
onMounted(() => {
// 为选区管理器添加监听,以便在选区变化时更新状态
if (props.selectionManager) {
// 在选区管理器中添加选区变化的监听
checkSelectionStatus();
// 设置选区状态变化的回调
// eslint-disable-next-line vue/no-mutating-props
props.selectionManager.onSelectionChanged = () => {
checkSelectionStatus();
};
}
});
// 监听 activeTool 变化
watch(
() => props.activeTool,
(newTool) => {
// 当工具为LASSO或AREA类型时显示选区面板
const selectionTools = [
OperationType.PART,
OperationType.PART_RECTANGLE,
OperationType.PART_BRUSH,
OperationType.PART_ERASER
];
if (selectionTools.includes(newTool)) {
show();
// 根据工具类型设置选区类型
selectionType.value = newTool;
// 更新选区管理器的选区类型
if (props.selectionManager) {
props.selectionManager.setSelectionType(selectionType.value);
props.selectionManager.setupSelectionEvents();
}
} else {
close();
}
},
{ immediate: true }
);
/**
* 显示面板
*/
function show() {
visible.value = true;
closePanel.value = true
checkSelectionStatus();
}
/**
* 关闭面板
*/
function close() {
visible.value = false;
}
/**
* 设置选区类型
*/
function setSelectionType(type) {
selectionType.value = type;
// 通过 ToolManager 切换工具,这会自动通知 SelectionManager
if (props.toolManager) {
props.toolManager.setToolWithCommand(type);
}
// 备用方案:如果没有 toolManager直接更新 selectionManager
else if (props.selectionManager) {
props.selectionManager.setSelectionType(type);
props.selectionManager.setupSelectionEvents();
}
}
/**
* 检查选区状态
*/
function checkSelectionStatus() {
hasSelection.value =
props.selectionManager &&
props.selectionManager.getSelectionObject() !== null;
// 同步羽化值
if (hasSelection.value) {
featherAmount.value = props.selectionManager.getFeatherAmount();
}
}
/**
* 添加选区
*/
function addSelection() {
// TODO: 实现添加选区功能
console.log("添加选区功能尚未实现");
}
/**
* 移除选区
*/
function removeSelection() {
// TODO: 实现移除选区功能
console.log("移除选区功能尚未实现");
}
/**
* 反转选区
*/
function invertSelection() {
if (!hasSelection.value) return;
props.commandManager.execute(
new InvertSelectionCommand({
canvas: props.canvas,
selectionManager: props.selectionManager,
})
);
checkSelectionStatus();
}
/**
* 清除选区
*/
function clearSelection() {
if (!hasSelection.value) return;
props.commandManager.execute(
new ClearSelectionCommand({
selectionManager: props.selectionManager,
})
);
checkSelectionStatus();
}
/**
* 应用羽化效果
*/
function applyFeather() {
if (!hasSelection.value) return;
props.commandManager.execute(
new FeatherSelectionCommand({
selectionManager: props.selectionManager,
featherAmount: featherAmount.value,
})
);
}
/**
* 填充选区
*/
function fillSelection() {
if (!hasSelection.value) return;
showColorPicker.value = true;
}
/**
* 套索抠图到新图层
*/
function copySelectionToNewLayer() {
if (!hasSelection.value) return;
props.commandManager.execute(
new LassoCutoutCommand({
canvas: props.canvas,
layerManager: props.layerManager,
selectionManager: props.selectionManager,
toolManager: props.toolManager,
})
);
checkSelectionStatus();
}
/**
* 剪切选区到新图层
*/
function cutSelectionToNewLayer() {
if (!hasSelection.value) return;
props.commandManager.execute(
new CutSelectionToNewLayerCommand({
canvas: props.canvas,
layerManager: props.layerManager,
selectionManager: props.selectionManager,
toolManager: props.toolManager,
})
);
checkSelectionStatus();
}
/**
* 清除选区内容
*/
function clearSelectionContent() {
if (!hasSelection.value) return;
props.commandManager.execute(
new ClearSelectionContentCommand({
canvas: props.canvas,
layerManager: props.layerManager,
selectionManager: props.selectionManager,
})
);
checkSelectionStatus();
}
/**
* 打开羽化设置弹窗
*/
function openFeatherDialog() {
showFeatherDialog.value = true;
}
/**
* 取消羽化设置
*/
function cancelFeather() {
showFeatherDialog.value = false;
}
/**
* 确认羽化设置
*/
function confirmFeather() {
applyFeather();
showFeatherDialog.value = false;
}
/**
* 取消颜色选择
*/
function cancelColorPicker() {
showColorPicker.value = false;
}
/**
* 确认颜色选择
*/
function confirmColorPicker() {
if (!hasSelection.value) return;
props.commandManager.execute(
new FillSelectionCommand({
canvas: props.canvas,
layerManager: props.layerManager,
selectionManager: props.selectionManager,
color: fillColor.value,
})
);
checkSelectionStatus();
showColorPicker.value = false;
}
</script>
<style scoped lang="less">
.part-selector-toolbar {
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%;
text-align: center;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
height: 22px;
> i{
font-size: 1.4rem;
transform: rotate(270deg);
}
}
}
/* 平板和手机适配 */
@media screen and (max-width: 768px) {
.part-selector-toolbar {
bottom: 15px;
left: 15px;
right: 15px;
max-width: calc(100vw - 30px);
border-radius: 6px;
}
}
@media screen and (max-width: 480px) {
.part-selector-toolbar {
bottom: 10px;
left: 10px;
right: 10px;
max-width: calc(100vw - 20px);
}
}
.part-selector-toolbar.is-active {
transform: translateY(0);
}
.toolbar-header {
// display: flex;
// justify-content: center;
// align-items: center;
padding: 8px 0;
// 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;
}
.header-btn {
background: none;
border: none;
color: #333;
font-size: 12px;
cursor: pointer;
padding: 3px 0;
border-radius: 3px;
transition: background-color 0.2s ease;
min-width: 32px;
}
.header-btn:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.close-btn {
color: #666;
}
.toolbar-section {
padding: 0 3rem 1.2rem;
}
.tool-types {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding: 10px 0;
}
.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;
}
.toolbar-divider {
height: 1px;
background-color: rgba(0, 0, 0, 0.05);
margin-bottom: 5px;
}
.tool-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5px;
padding: 0 10px;
}
/* 平板适配 - 每行4个按钮 */
@media screen and (max-width: 768px) {
.tool-actions {
grid-template-columns: repeat(3, 1fr);
gap: 8px 6px;
padding: 0 8px;
}
}
/* 手机适配 - 每行3个按钮 */
@media screen and (max-width: 480px) {
.tool-actions {
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 0;
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

@@ -1,14 +1,15 @@
<template>
<div class="repeat-setting">
<div class="title">{{ t("Canvas.repeatSetting") }}</div>
<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)"
style-type="2"
/>
</div>
<p></p>
<div class="repeat-setting-item">
<span class="label">{{ t("Canvas.scale") }}</span>
<slider
@@ -22,7 +23,6 @@
@change="changeFillScale"
/>
</div>
<p></p>
<div class="repeat-setting-item">
<span class="label">Gap X</span>
<slider
@@ -36,7 +36,6 @@
@change="(e) => emit('changeFill_Gap', e, gapY)"
/>
</div>
<p></p>
<div class="repeat-setting-item">
<span class="label">Gap Y</span>
<slider
@@ -50,14 +49,23 @@
@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"
:left="offsetX"
:top="offsetY"
@input="(e) => emit('inputFillOffset', e)"
@change="(e) => emit('changeFillOffset', e)"
:show-dish="false"
/>
</div>
<div class="repeat-setting-item offset">
<offset-tool
:left="offsetX"
:top="offsetY"
@input="(e) => emit('inputFillOffset', e)"
@change="(e) => emit('changeFillOffset', e)"
:show-input="false"
/>
</div>
</div>
@@ -88,6 +96,12 @@
});
const gapX = computed(() => props.object.fill_?.gapX || 0);
const gapY = computed(() => props.object.fill_?.gapY || 0);
const offsetX = computed(
() => (props.object.fill?.offsetX / props.object.width) * 100
);
const offsetY = computed(
() => (props.object.fill?.offsetY / props.object.height) * 100
);
const emit = defineEmits([
"inputFillAngle",
"changeFillAngle",
@@ -111,23 +125,36 @@
<style scoped lang="less">
.repeat-setting {
user-select: none;
width: 228px;
> .title {
line-height: 35px;
font-size: 14px;
text-align: center;
margin-top: -12px;
margin-bottom: 3px;
}
> .repeat-setting-item {
display: flex;
align-items: center;
//虚线
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
&.offset {
justify-content: center;
}
> .label {
min-width: 50px;
font-size: 14px;
min-width: 68px;
font-size: 12px;
}
> .angle-tool {
&:not(.offset) > div {
width: 120px;
flex: 1;
}
> .slider {
--slider-thumb-color1: #000;
--slider-thumb-color2: #eee;
}
}
> p {
margin: 10px 0;
width: 100%;
height: 0;
border-bottom: 1px dashed #e5e5e5;
}
}
</style>

View File

@@ -132,7 +132,6 @@
v-if="v.type === 'rect'"
trigger="click"
destroyTooltipOnHide
:title="t('Canvas.repeatSetting')"
>
<template #content>
<repeat-setting
@@ -784,6 +783,7 @@
}
> .list {
display: flex;
> div {
display: flex;
align-items: center;

View File

@@ -166,6 +166,19 @@ const normalToolsList = ref([
icon: { name: "CFont", size: "20" },
class: "text-btn",
},
{
id: OperationType.PART,
title: t("Canvas.GarmentPartSelector"),
action: () => selectTool(OperationType.PART),
icon: { name: "CPart", size: "28" },
class: "part-btn",
activeList: [
OperationType.PART,
OperationType.PART_RECTANGLE,
OperationType.PART_BRUSH,
OperationType.PART_ERASER,
],
},
{
id: "help",
title: t("Canvas.help"),

View File

@@ -1,32 +1,53 @@
<template>
<div class="angle-tool" :disabled="disabled">
<div
ref="dishRef"
class="dish"
@mousedown.stop="mousedown"
@touchmove.stop="mousedown"
>
<div class="pointer" :style="{ transform: `rotate(${angle}deg)` }">
<span></span>
<template v-if="styleType === '1'">
<div
ref="dishRef"
class="dish"
@mousedown.stop="mousedown"
@touchmove.stop="mousedown"
>
<div
class="pointer"
:style="{ transform: `rotate(${angle}deg)` }"
>
<span></span>
</div>
</div>
</div>
<div class="input">
<input
type="number"
v-model="angle"
@input="onInput"
@change="onChange"
:disabled="disabled"
/>
</div>
<div class="input">
<input
type="number"
v-model="angle"
@input="onInput"
@change="onChange"
:disabled="disabled"
/>
</div>
</template>
<my-input
v-if="styleType === '2'"
v-model="angle"
@input="onInput"
@change="onChange"
:disabled="disabled"
type="number"
after="°"
icon="icon-angle"
/>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue";
import { calculateAngle } from "../../utils/helper";
import MyInput from "./MyInput.vue";
// Props
const props = defineProps({
styleType: {
type: String,
default: "1",
},
angle: {
type: Number,
default: 0,
@@ -139,5 +160,8 @@
outline: none;
}
}
> .my-input {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,67 @@
<template>
<div class="my-input">
<span class="decorate"></span>
<span v-show="icon" :class="['iconfont', icon]"></span>
<span v-show="before" class="before">{{ before }}</span>
<input v-bind="$attrs" :value="modelValue" @input="onInput" />
<span v-show="after" class="after">{{ after }}</span>
</div>
</template>
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue";
const props = defineProps({
modelValue: { type: Number, default: 0 },
icon: { default: "", type: String },
before: { default: "", type: String },
after: { default: "", type: String },
});
const emit = defineEmits(["update:modelValue", "input"]);
const onInput = (e) => {
const value = e.target.value;
emit("update:modelValue", value);
emit("input", value);
};
</script>
<style scoped lang="less">
.my-input {
display: flex;
align-items: center;
width: 100%;
border: 1px solid rgba(230, 230, 231, 1);
border-radius: 3px;
height: 20px;
padding: 0 4px 0 2px;
> .decorate {
width: 2px;
background-color: rgba(230, 230, 231, 1);
border-radius: 3px;
height: 85%;
margin-right: 4px;
}
> .iconfont {
font-size: 10px;
color: #000;
margin-right: 2px;
}
> .before {
font-size: 12px;
color: #000;
margin-right: 2px;
}
> .after {
font-size: 12px;
color: #000;
}
> input {
font-size: 12px;
width: 0;
flex: 1;
text-align: right;
outline: none;
border: none;
background-color: transparent;
padding: 0;
}
}
</style>

View File

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

View File

@@ -1,13 +1,12 @@
<template>
<div class="slider" :disabled="disabled">
<div class="input-range">
<span
class="tip"
:style="{
'--progress': (value - props.min) / (props.max - props.min),
}"
>{{ props.tipFormatter(value) }}</span
>
<div
class="input-range"
:style="{
'--progress': (value - props.min) / (props.max - props.min),
}"
>
<span class="tip">{{ props.tipFormatter(value) }}</span>
<input
type="range"
v-model="value"
@@ -20,8 +19,7 @@
/>
</div>
<div class="input" v-show="isInput">
<input
type="number"
<my-input
v-model="value"
:min="props.min"
:max="props.max"
@@ -29,6 +27,7 @@
@input="onInput"
@change="onChange"
:disabled="disabled"
type="number"
/>
</div>
</div>
@@ -36,6 +35,7 @@
<script setup lang="ts">
import { ref, defineProps, defineEmits, watch } from "vue";
import MyInput from "./MyInput.vue";
const props = defineProps({
disabled: {
type: Boolean,
@@ -86,9 +86,10 @@
position: relative;
display: flex;
align-items: center;
--input-thumb-size: 12px;
width: 150px;
// &:focus-within,
--input-thumb-size: 10px;
--backcolor1: var(--slider-thumb-color1, #4285f4);
--backcolor2: var(--slider-thumb-color2, rgba(0, 0, 0, 0.1));
&:hover {
> .input-range > .tip {
display: block;
@@ -103,21 +104,26 @@
appearance: none;
height: 5px;
border-radius: 5px;
background: rgba(0, 0, 0, 0.1); /* 更柔和的颜色 */
outline: none;
background: linear-gradient(
to right,
var(--backcolor1) 0%,
var(--backcolor1) calc(var(--progress) * 100%),
var(--backcolor2) calc(var(--progress) * 100%),
var(--backcolor2) 100%
);
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--input-thumb-size);
height: var(--input-thumb-size);
border-radius: 50%;
background: #4285f4; /* 蓝色滑块 */
background: var(--backcolor1); /* 蓝色滑块 */
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);
}
}