|
|
@@ -0,0 +1,625 @@
|
|
|
+<template>
|
|
|
+ <el-dialog
|
|
|
+ v-model="show"
|
|
|
+ :title="props.title"
|
|
|
+ width="980px"
|
|
|
+ draggable
|
|
|
+ append-to-body
|
|
|
+ :close-on-click-modal="false"
|
|
|
+ align-center
|
|
|
+ >
|
|
|
+ <div class="bezier-editor">
|
|
|
+ <div ref="stageRef" class="bezier-stage" @wheel.prevent="handleWheel">
|
|
|
+ <svg
|
|
|
+ class="bezier-svg"
|
|
|
+ :viewBox="viewBox"
|
|
|
+ @pointerdown="handleStagePointerDown"
|
|
|
+ @pointermove="handlePointerMove"
|
|
|
+ @pointerup="handlePointerUp"
|
|
|
+ @pointerleave="handlePointerUp"
|
|
|
+ >
|
|
|
+ <defs>
|
|
|
+ <pattern
|
|
|
+ :id="gridId"
|
|
|
+ :x="gridStart.x"
|
|
|
+ :y="gridStart.y"
|
|
|
+ :width="gridSize"
|
|
|
+ :height="gridSize"
|
|
|
+ patternUnits="userSpaceOnUse"
|
|
|
+ >
|
|
|
+ <path
|
|
|
+ :d="`M ${gridSize} 0 L 0 0 0 ${gridSize}`"
|
|
|
+ fill="none"
|
|
|
+ stroke="var(--el-border-color-lighter)"
|
|
|
+ :stroke-width="1 / scale"
|
|
|
+ />
|
|
|
+ </pattern>
|
|
|
+ </defs>
|
|
|
+ <rect
|
|
|
+ :x="view.x"
|
|
|
+ :y="view.y"
|
|
|
+ :width="view.width"
|
|
|
+ :height="view.height"
|
|
|
+ :fill="`url(#${gridId})`"
|
|
|
+ />
|
|
|
+ <rect
|
|
|
+ v-if="hasBounds"
|
|
|
+ x="0"
|
|
|
+ y="0"
|
|
|
+ :width="boundsWidth"
|
|
|
+ :height="boundsHeight"
|
|
|
+ fill="transparent"
|
|
|
+ stroke="var(--el-color-primary)"
|
|
|
+ :stroke-width="1.5 / scale"
|
|
|
+ stroke-dasharray="8 6"
|
|
|
+ />
|
|
|
+ <g v-for="(segment, index) in segments" :key="index">
|
|
|
+ <path
|
|
|
+ :d="getSegmentPath(segment)"
|
|
|
+ fill="none"
|
|
|
+ :stroke="
|
|
|
+ selectedIndex === index ? 'var(--el-color-primary)' : 'var(--el-text-color-regular)'
|
|
|
+ "
|
|
|
+ :stroke-width="(selectedIndex === index ? 3 : 2) / scale"
|
|
|
+ stroke-linecap="round"
|
|
|
+ class="cursor-pointer"
|
|
|
+ @pointerdown.stop="selectSegment(index)"
|
|
|
+ />
|
|
|
+ <g v-if="selectedIndex === index">
|
|
|
+ <line
|
|
|
+ :x1="segment.start.x"
|
|
|
+ :y1="segment.start.y"
|
|
|
+ :x2="segment.control1.x"
|
|
|
+ :y2="segment.control1.y"
|
|
|
+ stroke="var(--el-text-color-secondary)"
|
|
|
+ :stroke-width="1 / scale"
|
|
|
+ stroke-dasharray="6 5"
|
|
|
+ />
|
|
|
+ <line
|
|
|
+ :x1="segment.end.x"
|
|
|
+ :y1="segment.end.y"
|
|
|
+ :x2="segment.control2.x"
|
|
|
+ :y2="segment.control2.y"
|
|
|
+ stroke="var(--el-text-color-secondary)"
|
|
|
+ :stroke-width="1 / scale"
|
|
|
+ stroke-dasharray="6 5"
|
|
|
+ />
|
|
|
+ <circle
|
|
|
+ v-for="point in pointKeys"
|
|
|
+ :key="point"
|
|
|
+ :cx="segment[point].x"
|
|
|
+ :cy="segment[point].y"
|
|
|
+ :r="pointRadius(point)"
|
|
|
+ :class="['bezier-point', `bezier-point-${point}`]"
|
|
|
+ @pointerdown.stop="handlePointPointerDown(index, point, $event)"
|
|
|
+ />
|
|
|
+ </g>
|
|
|
+ </g>
|
|
|
+ </svg>
|
|
|
+ <div class="stage-toolbar">
|
|
|
+ <el-tooltip content="适应视图">
|
|
|
+ <el-button size="small" circle @click="fitView">
|
|
|
+ <LuScanLine size="14" />
|
|
|
+ </el-button>
|
|
|
+ </el-tooltip>
|
|
|
+ <el-tooltip content="放大">
|
|
|
+ <el-button size="small" circle @click="zoomAtCenter(1.2)">
|
|
|
+ <LuZoomIn size="14" />
|
|
|
+ </el-button>
|
|
|
+ </el-tooltip>
|
|
|
+ <el-tooltip content="缩小">
|
|
|
+ <el-button size="small" circle @click="zoomAtCenter(1 / 1.2)">
|
|
|
+ <LuZoomOut size="14" />
|
|
|
+ </el-button>
|
|
|
+ </el-tooltip>
|
|
|
+ </div>
|
|
|
+ <div class="stage-status">
|
|
|
+ <span>{{ hasBounds ? `${boundsWidth} x ${boundsHeight}` : '无限画布' }}</span>
|
|
|
+ <span>{{ Math.round(scale * 100) }}%</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="bezier-panel">
|
|
|
+ <el-form label-position="top">
|
|
|
+ <el-form-item label="线段">
|
|
|
+ <el-select v-model="selectedIndex" :disabled="!segments.length">
|
|
|
+ <el-option
|
|
|
+ v-for="(_, index) in segments"
|
|
|
+ :key="index"
|
|
|
+ :label="`线段 ${index + 1}`"
|
|
|
+ :value="index"
|
|
|
+ />
|
|
|
+ </el-select>
|
|
|
+ </el-form-item>
|
|
|
+ <div class="panel-actions">
|
|
|
+ <el-button size="small" :disabled="segments.length === 100" @click="addSegment">
|
|
|
+ <LuPlus size="14" />
|
|
|
+ 新增线段
|
|
|
+ </el-button>
|
|
|
+ <el-button size="small" :disabled="!segments.length" @click="removeSegment">
|
|
|
+ <LuTrash2 size="14" />
|
|
|
+ 删除线段
|
|
|
+ </el-button>
|
|
|
+ </div>
|
|
|
+ <el-alert
|
|
|
+ v-if="lockJoin"
|
|
|
+ class="my-10px"
|
|
|
+ type="info"
|
|
|
+ show-icon
|
|
|
+ :closable="false"
|
|
|
+ title="已启用首尾相连"
|
|
|
+ />
|
|
|
+ <template v-if="currentSegment">
|
|
|
+ <div v-for="point in pointKeys" :key="point" class="point-row">
|
|
|
+ <div class="point-title">{{ pointLabels[point] }}</div>
|
|
|
+ <div class="point-inputs">
|
|
|
+ <input-number
|
|
|
+ v-model="currentSegment[point].x"
|
|
|
+ size="small"
|
|
|
+ @change="handleCoordinateChange(point)"
|
|
|
+ >
|
|
|
+ <template #prefix>X</template>
|
|
|
+ </input-number>
|
|
|
+ <input-number
|
|
|
+ v-model="currentSegment[point].y"
|
|
|
+ size="small"
|
|
|
+ @change="handleCoordinateChange(point)"
|
|
|
+ >
|
|
|
+ <template #prefix>Y</template>
|
|
|
+ </input-number>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ <el-empty v-else description="暂无线段" :image-size="80" />
|
|
|
+ </el-form>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <template #footer>
|
|
|
+ <el-button @click="show = false">取消</el-button>
|
|
|
+ <el-button type="primary" @click="submit">确定</el-button>
|
|
|
+ </template>
|
|
|
+ </el-dialog>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import type { BezierPoint, BezierSegment } from '@/types/resource'
|
|
|
+
|
|
|
+import { computed, nextTick, ref } from 'vue'
|
|
|
+import { klona } from 'klona'
|
|
|
+import { v4 } from 'uuid'
|
|
|
+import { LuPlus, LuScanLine, LuTrash2, LuZoomIn, LuZoomOut } from 'vue-icons-plus/lu'
|
|
|
+
|
|
|
+export type BezierCurveEditorValue = {
|
|
|
+ width?: number
|
|
|
+ height?: number
|
|
|
+ segments: BezierSegment[]
|
|
|
+ lockJoin?: boolean
|
|
|
+}
|
|
|
+
|
|
|
+type PointKey = keyof BezierSegment
|
|
|
+
|
|
|
+const pointKeys: PointKey[] = ['start', 'control1', 'control2', 'end']
|
|
|
+const pointLabels: Record<PointKey, string> = {
|
|
|
+ start: '起点',
|
|
|
+ control1: '控制点 1',
|
|
|
+ control2: '控制点 2',
|
|
|
+ end: '终点'
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ title?: string
|
|
|
+ }>(),
|
|
|
+ {
|
|
|
+ title: '编辑贝塞尔曲线'
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ confirm: [value: BezierCurveEditorValue]
|
|
|
+}>()
|
|
|
+
|
|
|
+const show = ref(false)
|
|
|
+const gridId = `bezier-editor-grid-${v4()}`
|
|
|
+const stageRef = ref<HTMLDivElement>()
|
|
|
+const segments = ref<BezierSegment[]>([])
|
|
|
+const selectedIndex = ref(0)
|
|
|
+const lockJoin = ref(false)
|
|
|
+const boundsWidth = ref<number>()
|
|
|
+const boundsHeight = ref<number>()
|
|
|
+const scale = ref(1)
|
|
|
+const view = ref({
|
|
|
+ x: -40,
|
|
|
+ y: -40,
|
|
|
+ width: 520,
|
|
|
+ height: 360
|
|
|
+})
|
|
|
+
|
|
|
+const dragState = ref<
|
|
|
+ | {
|
|
|
+ type: 'point'
|
|
|
+ segmentIndex: number
|
|
|
+ point: PointKey
|
|
|
+ }
|
|
|
+ | {
|
|
|
+ type: 'pan'
|
|
|
+ x: number
|
|
|
+ y: number
|
|
|
+ viewX: number
|
|
|
+ viewY: number
|
|
|
+ }
|
|
|
+>()
|
|
|
+
|
|
|
+const hasBounds = computed(
|
|
|
+ () => boundsWidth.value !== undefined && boundsHeight.value !== undefined
|
|
|
+)
|
|
|
+const viewBox = computed(
|
|
|
+ () => `${view.value.x} ${view.value.y} ${view.value.width} ${view.value.height}`
|
|
|
+)
|
|
|
+const gridSize = 100
|
|
|
+const gridStart = computed(() => ({
|
|
|
+ x: Math.floor(view.value.x / gridSize) * gridSize,
|
|
|
+ y: Math.floor(view.value.y / gridSize) * gridSize
|
|
|
+}))
|
|
|
+const currentSegment = computed(() => segments.value[selectedIndex.value])
|
|
|
+
|
|
|
+// 初始线段按 80px 宽、40px 高生成,后续新增段会基于上一段继续推导。
|
|
|
+const createDefaultSegment = (start?: BezierPoint): BezierSegment => {
|
|
|
+ const point = start || { x: 0, y: 0 }
|
|
|
+ return {
|
|
|
+ start: { ...point },
|
|
|
+ control1: { x: point.x + 24, y: point.y },
|
|
|
+ control2: { x: point.x + 56, y: point.y - 40 },
|
|
|
+ end: { x: point.x + 80, y: point.y - 40 }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 新增段规则:起点接上、控制点镜像、终点按上一段宽度递增,Y 方向交替。
|
|
|
+const createNextSegment = (prevSegment?: BezierSegment): BezierSegment => {
|
|
|
+ if (!prevSegment) return createDefaultSegment()
|
|
|
+
|
|
|
+ const width = prevSegment.end.x - prevSegment.start.x
|
|
|
+
|
|
|
+ return roundSegment({
|
|
|
+ start: { ...prevSegment.end },
|
|
|
+ control1: {
|
|
|
+ x: prevSegment.end.x * 2 - prevSegment.control2.x,
|
|
|
+ y: prevSegment.end.y * 2 - prevSegment.control2.y
|
|
|
+ },
|
|
|
+ control2: {
|
|
|
+ x: prevSegment.end.x + (prevSegment.end.x - prevSegment.control1.x),
|
|
|
+ y: prevSegment.start.y
|
|
|
+ },
|
|
|
+ end: {
|
|
|
+ x: prevSegment.end.x + width,
|
|
|
+ y: prevSegment.start.y
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const roundPoint = (point: BezierPoint): BezierPoint => ({
|
|
|
+ x: Math.round(point.x),
|
|
|
+ y: Math.round(point.y)
|
|
|
+})
|
|
|
+
|
|
|
+const roundSegment = (segment: BezierSegment): BezierSegment => ({
|
|
|
+ start: roundPoint(segment.start),
|
|
|
+ control1: roundPoint(segment.control1),
|
|
|
+ control2: roundPoint(segment.control2),
|
|
|
+ end: roundPoint(segment.end)
|
|
|
+})
|
|
|
+
|
|
|
+const normalizeSegments = (list: BezierSegment[]): BezierSegment[] => list.map(roundSegment)
|
|
|
+
|
|
|
+const getSegmentPath = (segment: BezierSegment) => {
|
|
|
+ return `M ${segment.start.x} ${segment.start.y} C ${segment.control1.x} ${segment.control1.y}, ${segment.control2.x} ${segment.control2.y}, ${segment.end.x} ${segment.end.y}`
|
|
|
+}
|
|
|
+
|
|
|
+const pointRadius = (point: PointKey) =>
|
|
|
+ (point === 'control1' || point === 'control2' ? 5 : 6) / scale.value
|
|
|
+
|
|
|
+// 将鼠标屏幕坐标换算为 SVG 画布坐标,坐标点统一取整。
|
|
|
+const getCanvasPoint = (clientX: number, clientY: number): BezierPoint => {
|
|
|
+ const rect = stageRef.value?.getBoundingClientRect()
|
|
|
+ if (!rect) return { x: 0, y: 0 }
|
|
|
+
|
|
|
+ return roundPoint({
|
|
|
+ x: view.value.x + ((clientX - rect.left) / rect.width) * view.value.width,
|
|
|
+ y: view.value.y + ((clientY - rect.top) / rect.height) * view.value.height
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 开启首尾相连时,拖拽端点会同步相邻线段的连接点。
|
|
|
+const applyJoinByPoint = (segmentIndex: number, point: PointKey) => {
|
|
|
+ if (!lockJoin.value) return
|
|
|
+
|
|
|
+ const segment = segments.value[segmentIndex]
|
|
|
+ if (!segment) return
|
|
|
+
|
|
|
+ if (point === 'end') {
|
|
|
+ const nextSegment = segments.value[segmentIndex + 1]
|
|
|
+ if (nextSegment) {
|
|
|
+ nextSegment.start = { ...segment.end }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (point === 'start') {
|
|
|
+ const prevSegment = segments.value[segmentIndex - 1]
|
|
|
+ if (prevSegment) {
|
|
|
+ prevSegment.end = { ...segment.start }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const normalizeJoinedSegments = () => {
|
|
|
+ if (!lockJoin.value) return
|
|
|
+
|
|
|
+ for (let i = 1; i < segments.value.length; i += 1) {
|
|
|
+ segments.value[i].start = { ...segments.value[i - 1].end }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const selectSegment = (index: number) => {
|
|
|
+ selectedIndex.value = index
|
|
|
+}
|
|
|
+
|
|
|
+const handlePointPointerDown = (segmentIndex: number, point: PointKey, event: PointerEvent) => {
|
|
|
+ selectSegment(segmentIndex)
|
|
|
+ ;(event.currentTarget as SVGCircleElement).setPointerCapture(event.pointerId)
|
|
|
+ dragState.value = {
|
|
|
+ type: 'point',
|
|
|
+ segmentIndex,
|
|
|
+ point
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleStagePointerDown = (event: PointerEvent) => {
|
|
|
+ dragState.value = {
|
|
|
+ type: 'pan',
|
|
|
+ x: event.clientX,
|
|
|
+ y: event.clientY,
|
|
|
+ viewX: view.value.x,
|
|
|
+ viewY: view.value.y
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handlePointerMove = (event: PointerEvent) => {
|
|
|
+ if (!dragState.value) return
|
|
|
+
|
|
|
+ if (dragState.value.type === 'point') {
|
|
|
+ const segment = segments.value[dragState.value.segmentIndex]
|
|
|
+ if (!segment) return
|
|
|
+
|
|
|
+ segment[dragState.value.point] = getCanvasPoint(event.clientX, event.clientY)
|
|
|
+ applyJoinByPoint(dragState.value.segmentIndex, dragState.value.point)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const rect = stageRef.value?.getBoundingClientRect()
|
|
|
+ if (!rect) return
|
|
|
+
|
|
|
+ const dx = ((event.clientX - dragState.value.x) / rect.width) * view.value.width
|
|
|
+ const dy = ((event.clientY - dragState.value.y) / rect.height) * view.value.height
|
|
|
+ view.value.x = dragState.value.viewX - dx
|
|
|
+ view.value.y = dragState.value.viewY - dy
|
|
|
+}
|
|
|
+
|
|
|
+const handlePointerUp = () => {
|
|
|
+ dragState.value = undefined
|
|
|
+}
|
|
|
+
|
|
|
+const handleCoordinateChange = (point: PointKey) => {
|
|
|
+ const segment = currentSegment.value
|
|
|
+ if (segment) {
|
|
|
+ segment[point] = roundPoint(segment[point])
|
|
|
+ }
|
|
|
+ applyJoinByPoint(selectedIndex.value, point)
|
|
|
+}
|
|
|
+
|
|
|
+const addSegment = () => {
|
|
|
+ segments.value.push(createNextSegment(segments.value.at(-1)))
|
|
|
+ selectedIndex.value = segments.value.length - 1
|
|
|
+ fitView()
|
|
|
+}
|
|
|
+
|
|
|
+const removeSegment = () => {
|
|
|
+ if (!segments.value.length) return
|
|
|
+
|
|
|
+ segments.value.splice(selectedIndex.value, 1)
|
|
|
+ selectedIndex.value = Math.max(0, Math.min(selectedIndex.value, segments.value.length - 1))
|
|
|
+ normalizeJoinedSegments()
|
|
|
+ fitView()
|
|
|
+}
|
|
|
+
|
|
|
+const getContentBounds = () => {
|
|
|
+ const points = segments.value.flatMap((segment) => pointKeys.map((point) => segment[point]))
|
|
|
+
|
|
|
+ if (hasBounds.value) {
|
|
|
+ points.push({ x: 0, y: 0 }, { x: boundsWidth.value!, y: boundsHeight.value! })
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!points.length) {
|
|
|
+ points.push({ x: 0, y: 0 }, { x: 300, y: 180 })
|
|
|
+ }
|
|
|
+
|
|
|
+ const xs = points.map((point) => point.x)
|
|
|
+ const ys = points.map((point) => point.y)
|
|
|
+ return {
|
|
|
+ minX: Math.min(...xs),
|
|
|
+ maxX: Math.max(...xs),
|
|
|
+ minY: Math.min(...ys),
|
|
|
+ maxY: Math.max(...ys)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 根据当前所有控制点和可选边界自适应视口,保证打开弹窗能看到完整曲线。
|
|
|
+const fitView = async () => {
|
|
|
+ await nextTick()
|
|
|
+ const bounds = getContentBounds()
|
|
|
+ const rect = stageRef.value?.getBoundingClientRect()
|
|
|
+ const stageRatio = rect ? rect.width / rect.height : 1.5
|
|
|
+ const padding = 60
|
|
|
+ let width = Math.max(120, bounds.maxX - bounds.minX + padding * 2)
|
|
|
+ let height = Math.max(120, bounds.maxY - bounds.minY + padding * 2)
|
|
|
+ const contentRatio = width / height
|
|
|
+
|
|
|
+ if (contentRatio > stageRatio) {
|
|
|
+ height = width / stageRatio
|
|
|
+ } else {
|
|
|
+ width = height * stageRatio
|
|
|
+ }
|
|
|
+
|
|
|
+ view.value = {
|
|
|
+ x: (bounds.minX + bounds.maxX) / 2 - width / 2,
|
|
|
+ y: (bounds.minY + bounds.maxY) / 2 - height / 2,
|
|
|
+ width,
|
|
|
+ height
|
|
|
+ }
|
|
|
+ scale.value = Math.max(0.1, Math.min(8, 500 / width))
|
|
|
+}
|
|
|
+
|
|
|
+const zoomAt = (factor: number, center: BezierPoint) => {
|
|
|
+ const nextWidth = Math.max(20, Math.min(20000, view.value.width / factor))
|
|
|
+ const nextHeight = Math.max(20, Math.min(20000, view.value.height / factor))
|
|
|
+ const ratioX = (center.x - view.value.x) / view.value.width
|
|
|
+ const ratioY = (center.y - view.value.y) / view.value.height
|
|
|
+
|
|
|
+ view.value = {
|
|
|
+ x: center.x - nextWidth * ratioX,
|
|
|
+ y: center.y - nextHeight * ratioY,
|
|
|
+ width: nextWidth,
|
|
|
+ height: nextHeight
|
|
|
+ }
|
|
|
+ scale.value = Math.max(0.1, Math.min(8, scale.value * factor))
|
|
|
+}
|
|
|
+
|
|
|
+const zoomAtCenter = (factor: number) => {
|
|
|
+ zoomAt(factor, {
|
|
|
+ x: view.value.x + view.value.width / 2,
|
|
|
+ y: view.value.y + view.value.height / 2
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const handleWheel = (event: WheelEvent) => {
|
|
|
+ zoomAt(event.deltaY < 0 ? 1.12 : 1 / 1.12, getCanvasPoint(event.clientX, event.clientY))
|
|
|
+}
|
|
|
+
|
|
|
+defineExpose({
|
|
|
+ edit: (value: BezierCurveEditorValue) => {
|
|
|
+ boundsWidth.value = value.width
|
|
|
+ boundsHeight.value = value.height
|
|
|
+ lockJoin.value = !!value.lockJoin
|
|
|
+ segments.value = normalizeSegments(
|
|
|
+ klona(value.segments?.length ? value.segments : [createDefaultSegment()])
|
|
|
+ )
|
|
|
+ normalizeJoinedSegments()
|
|
|
+ selectedIndex.value = 0
|
|
|
+ show.value = true
|
|
|
+ fitView()
|
|
|
+ }
|
|
|
+})
|
|
|
+
|
|
|
+const submit = () => {
|
|
|
+ segments.value = normalizeSegments(segments.value)
|
|
|
+ show.value = false
|
|
|
+ emit('confirm', {
|
|
|
+ width: boundsWidth.value,
|
|
|
+ height: boundsHeight.value,
|
|
|
+ lockJoin: lockJoin.value,
|
|
|
+ segments: klona(segments.value)
|
|
|
+ })
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.bezier-editor {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: minmax(0, 1fr) 280px;
|
|
|
+ gap: 12px;
|
|
|
+ height: 560px;
|
|
|
+}
|
|
|
+
|
|
|
+.bezier-stage {
|
|
|
+ position: relative;
|
|
|
+ min-width: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ border: 1px solid var(--el-border-color);
|
|
|
+ border-radius: 4px;
|
|
|
+ background: var(--el-bg-color);
|
|
|
+}
|
|
|
+
|
|
|
+.bezier-svg {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ touch-action: none;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+
|
|
|
+.bezier-point {
|
|
|
+ cursor: grab;
|
|
|
+ stroke: var(--el-bg-color);
|
|
|
+ stroke-width: 2;
|
|
|
+}
|
|
|
+
|
|
|
+.bezier-point-start,
|
|
|
+.bezier-point-end {
|
|
|
+ fill: var(--el-color-primary);
|
|
|
+}
|
|
|
+
|
|
|
+.bezier-point-control1,
|
|
|
+.bezier-point-control2 {
|
|
|
+ fill: var(--el-color-warning);
|
|
|
+}
|
|
|
+
|
|
|
+.stage-toolbar {
|
|
|
+ position: absolute;
|
|
|
+ top: 8px;
|
|
|
+ right: 8px;
|
|
|
+ display: flex;
|
|
|
+ gap: 6px;
|
|
|
+}
|
|
|
+
|
|
|
+.stage-status {
|
|
|
+ position: absolute;
|
|
|
+ left: 8px;
|
|
|
+ bottom: 8px;
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ padding: 3px 8px;
|
|
|
+ border-radius: 4px;
|
|
|
+ background: var(--el-bg-color-overlay);
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+ font-size: 12px;
|
|
|
+ box-shadow: var(--el-box-shadow-lighter);
|
|
|
+}
|
|
|
+
|
|
|
+.bezier-panel {
|
|
|
+ min-width: 0;
|
|
|
+ overflow-y: auto;
|
|
|
+ border: 1px solid var(--el-border-color);
|
|
|
+ border-radius: 4px;
|
|
|
+ padding: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-actions {
|
|
|
+ display: flex;
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-row {
|
|
|
+ padding: 10px 0;
|
|
|
+ border-bottom: 1px solid var(--el-border-color-lighter);
|
|
|
+}
|
|
|
+
|
|
|
+.point-title {
|
|
|
+ margin-bottom: 8px;
|
|
|
+ color: var(--el-text-color-secondary);
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.point-inputs {
|
|
|
+ display: grid;
|
|
|
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
|
|
+ gap: 8px;
|
|
|
+}
|
|
|
+</style>
|