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