Просмотр исходного кода

feat:连线动画新增时间曲线编辑器

Grnetsky 3 недель назад
Родитель
Сommit
5e0e181115
2 измененных файлов с 211 добавлено и 0 удалено
  1. 21 0
      src/views/components/PenAnimates.vue
  2. 190 0
      src/views/components/common/BezierEditor.vue

+ 21 - 0
src/views/components/PenAnimates.vue

@@ -131,6 +131,26 @@
               <label>{{$t('动画颜色')}}</label>
               <t-color-picker class="w-full" format="CSS" :enable-alpha="true" :recent-colors="null" :swatch-colors="defaultPureColor" :color-modes="['monochrome']" :show-primary-color-preview="false" :clearable="true" v-model="item.animateColor" @change="changeValue(i)"></t-color-picker>
             </div>
+
+            <div class="form-item mt-8">
+              <label>动画时长</label>
+              <t-input-number
+                theme="column"
+                :min="1"
+                placeholder="动画运行时长"
+                v-model="item.duration"
+                @change="changeValue(i)"
+              ></t-input-number>
+            </div>
+
+            <div class="form-item mt-8">
+              <label>时间曲线</label>
+              <BezierEditor
+                  style="width: 200px"
+                v-model="item.animateTimingFunction"
+                @change="changeValue(i)"
+              ></BezierEditor>
+            </div>
             <div class="form-item mt-8">
               <label>{{$t('发光效果')}}</label>
               <t-switch class="ml-8 mt-8" size="small" v-model="item.animateShadow" @change="changeValue(i)"></t-switch>
@@ -286,6 +306,7 @@ import { MessagePlugin } from 'tdesign-vue-next';
 import {StopCircleIcon, PlayCircleIcon, EditIcon, DeleteIcon} from 'tdesign-icons-vue-next';
 import CodeEditor from "@/views/components/common/CodeEditor.vue";
 import {cdn} from "@/services/api";
+import BezierEditor from "@/views/components/common/BezierEditor.vue";
 
 const { proxy } = getCurrentInstance();
 const $t = proxy.$t

+ 190 - 0
src/views/components/common/BezierEditor.vue

@@ -0,0 +1,190 @@
+<template>
+  <div class="bezier-editor">
+    <svg
+        ref="svg"
+        class="editor"
+        viewBox="0 0 1 1"
+        preserveAspectRatio="none"
+        @mousedown="onSvgMouseDown"
+    >
+
+      <line x1="0" y1="1" x2="1" y2="0" stroke="#88888893" stroke-width="0.01" />
+
+      <line :x1="0" :y1="1" :x2="p1.x" :y2="p1.y" stroke="#4583FF" stroke-dasharray="2,2" stroke-width="0.015" />
+      <line :x1="p2.x" :y1="p2.y" :x2="1" :y2="0" stroke="#4583FF" stroke-dasharray="2,2" stroke-width="0.015" />
+
+      <path :d="curvePath" fill="none" stroke="#E3E8F4" stroke-width="0.02" />
+
+      <circle
+          class="point"
+          r="0.03"
+          :cx="p1.x"
+          :cy="p1.y"
+          @mousedown.prevent="startDrag('p1')"
+      />
+      <circle
+          class="point"
+          r="0.03"
+          :cx="p2.x"
+          :cy="p2.y"
+          @mousedown.prevent="startDrag('p2')"
+      />
+    </svg>
+
+    <div class="controls">
+      <div class="label">{{ modelValue }}</div>
+<!--      <button @click="reset">重置</button>-->
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
+
+const props = defineProps({
+  modelValue: String,
+  preset:{
+    type: Array,
+    default:()=>{
+      return []
+    }
+  }
+})
+const emit = defineEmits(['update:modelValue', 'change','reset'])
+
+const svg = ref(null)
+const p1 = ref({ x: 0.25, y: 0.75 })
+const p2 = ref({ x: 0.75, y: 0.25 })
+const dragging = ref(null)
+
+function formatVal() {
+  const x1 = Number(p1.value.x.toFixed(3))
+  const y1 = Number((1 - p1.value.y).toFixed(3))
+  const x2 = Number(p2.value.x.toFixed(3))
+  const y2 = Number((1 - p2.value.y).toFixed(3))
+  return `${x1},${y1},${x2},${y2}`
+}
+
+const parseRaw = (str) => {
+  if(!str) return
+  const nums = str.split(',').map(Number)
+  if (nums.length === 4 && nums.every(n => !isNaN(n))) {
+    p1.value = { x: nums[0], y: 1 - nums[1] }
+    p2.value = { x: nums[2], y: 1 - nums[3] }
+  }
+}
+
+watch(() => props.modelValue, (val) => {
+  if (val) parseRaw(val)
+}, { immediate: true })
+
+const curvePath = computed(() => {
+  return `M 0 1 C ${p1.value.x} ${p1.value.y}, ${p2.value.x} ${p2.value.y}, 1 0`
+})
+
+function startDrag(name) {
+  dragging.value = name
+}
+let rafId = null
+
+function onMouseMove(e) {
+  if (!dragging.value || !svg.value) return
+  if (rafId) cancelAnimationFrame(rafId)
+
+  rafId = requestAnimationFrame(() => {
+    const rect = svg.value.getBoundingClientRect()
+    const x = (e.clientX - rect.left) / rect.width
+    const y = (e.clientY - rect.top) / rect.height
+    const unbounded = {
+      x,
+      y,
+    }
+    if (dragging.value === 'p1') p1.value = unbounded
+    if (dragging.value === 'p2') p2.value = unbounded
+    emit('update:modelValue', formatVal())
+    emit('change', formatVal())
+  })
+}
+function onMouseUp() {
+  dragging.value = null
+}
+
+const reset = () => {
+  p1.value = { x: 0.25, y: 0.75 }
+  p2.value = { x: 0.75, y: 0.25 }
+  emit('update:modelValue', formatVal())
+  emit('reset',formatVal())
+}
+function onSvgMouseDown(e) {
+  if (!svg.value) return
+  const rect = svg.value.getBoundingClientRect()
+  const x = (e.clientX - rect.left) / rect.width
+  const y = (e.clientY - rect.top) / rect.height
+  const point = { x, y }
+
+  const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y)
+  const d1 = dist(point, p1.value)
+  const d2 = dist(point, p2.value)
+
+  if (d1 < d2) {
+    p1.value = point
+  } else {
+    p2.value = point
+  }
+
+  emit('update:modelValue', formatVal())
+  emit('change', formatVal())
+}
+
+
+onMounted(() => {
+  window.addEventListener('mousemove', onMouseMove)
+  window.addEventListener('mouseup', onMouseUp)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('mousemove', onMouseMove)
+  window.removeEventListener('mouseup', onMouseUp)
+})
+</script>
+
+<style scoped>
+.bezier-editor {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  user-select: none;
+}
+.editor {
+  width: 100%;
+  aspect-ratio:1;
+  border: 1px solid #303746;
+  background: #303746;
+}
+.point {
+  fill: #4583FF;
+  cursor: pointer;
+}
+.label {
+  font-family: monospace;
+  font-size: 14px;
+}
+.controls {
+  display: flex;
+  gap: 12px;
+  align-items: center;
+}
+
+button {
+  padding: 4px 10px;
+  font-size: 12px;
+  border: 1px solid #aaa;
+  background: #f3f3f3;
+  cursor: pointer;
+  border-radius: 4px;
+}
+
+button:hover {
+  background: #e0e0e0;
+}
+</style>