返回 slidev
DragControl.vue
根目录 / packages / client / internals / DragControl.vue
1 <script setup lang="ts">
2 import type { Pausable } from '@vueuse/core'
3 import type { DragElementState } from '../composables/useDragElements'
4 import { clamp } from '@antfu/utils'
5 import { useIntervalFn } from '@vueuse/core'
6 import { computed, inject, ref, watchEffect } from 'vue'
7 import { useSlideBounds } from '../composables/useSlideBounds'
8 import { injectionSlideScale } from '../constants'
9 import { slideHeight, slideWidth } from '../env'
10 import { magicKeys } from '../state'
11
12 const { data } = defineProps<{ data: DragElementState }>()
13 const { dragId, zoom, autoHeight, x0, y0, width, height, rotate, isArrow } = data
14
15 const slideScale = inject(injectionSlideScale, ref(1))
16 const scale = computed(() => slideScale.value * zoom.value)
17 const { left: slideLeft, top: slideTop } = useSlideBounds()
18
19 const ctrlSize = 10
20 const minSize = isArrow ? Number.NEGATIVE_INFINITY : 40
21 const minRemain = 10
22
23 const rotateRad = computed(() => rotate.value * Math.PI / 180)
24 const rotateSin = computed(() => Math.sin(rotateRad.value))
25 const rotateCos = computed(() => Math.cos(rotateRad.value))
26
27 const boundingWidth = computed(() => width.value * rotateCos.value + height.value * rotateSin.value)
28 const boundingHeight = computed(() => width.value * rotateSin.value + height.value * rotateCos.value)
29
30 const boundingLeft = computed(() => x0.value - boundingWidth.value / 2)
31 const boundingTop = computed(() => y0.value - boundingHeight.value / 2)
32 const boundingRight = computed(() => x0.value + boundingWidth.value / 2)
33 const boundingBottom = computed(() => y0.value + boundingHeight.value / 2)
34
35 const arrowRevX = computed(() => isArrow && width.value < 0)
36 const arrowRevY = computed(() => isArrow && height.value < 0)
37
38 let currentDrag: {
39 x0: number
40 y0: number
41 width: number
42 height: number
43 rotate: number
44 dx0: number
45 dy0: number
46 ltx: number
47 lty: number
48 rtx: number
49 rty: number
50 lbx: number
51 lby: number
52 rbx: number
53 rby: number
54 } | null = null
55
56 function onPointerdown(ev: PointerEvent) {
57 if (ev.buttons !== 1)
58 return
59
60 ev.preventDefault()
61 ev.stopPropagation()
62 const el = ev.target as HTMLElement
63 const elBounds = el.getBoundingClientRect()
64
65 const cross1x = width.value * rotateCos.value - height.value * rotateSin.value
66 const cross1y = width.value * rotateSin.value + height.value * rotateCos.value
67 const cross2x = width.value * rotateCos.value + height.value * rotateSin.value
68 const cross2y = -width.value * rotateSin.value + height.value * rotateCos.value
69
70 currentDrag = {
71 x0: x0.value,
72 y0: y0.value,
73 width: width.value,
74 height: height.value,
75 rotate: rotate.value,
76 dx0: ev.clientX - (elBounds.left + elBounds.right) / 2,
77 dy0: ev.clientY - (elBounds.top + elBounds.bottom) / 2,
78 ltx: x0.value - cross1x / 2,
79 lty: y0.value - cross1y / 2,
80 rtx: x0.value + cross2x / 2,
81 rty: y0.value - cross2y / 2,
82 lbx: x0.value - cross2x / 2,
83 lby: y0.value + cross2y / 2,
84 rbx: x0.value + cross1x / 2,
85 rby: y0.value + cross1y / 2,
86 };
87
88 (ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId)
89 }
90
91 function onPointermove(ev: PointerEvent) {
92 if (!currentDrag || ev.buttons !== 1)
93 return
94
95 ev.preventDefault()
96 ev.stopPropagation()
97
98 const x = (ev.clientX - slideLeft.value - currentDrag.dx0) / scale.value
99 const y = (ev.clientY - slideTop.value - currentDrag.dy0) / scale.value
100
101 x0.value = clamp(x, -boundingWidth.value / 2 + minRemain, slideWidth.value + boundingWidth.value / 2 - minRemain)
102 y0.value = clamp(y, -boundingHeight.value / 2 + minRemain, slideHeight.value + boundingHeight.value / 2 - minRemain)
103 }
104
105 function onPointerup(ev: PointerEvent) {
106 if (!currentDrag)
107 return
108
109 ev.preventDefault()
110 ev.stopPropagation()
111
112 currentDrag = null
113 }
114
115 const ctrlClasses = `absolute border border-gray bg-gray dark:border-gray-500 dark:bg-gray-800 bg-opacity-30 `
116
117 function getCornerProps(isLeft: boolean, isTop: boolean) {
118 return {
119 onPointerdown,
120 onPointermove: (ev: PointerEvent) => {
121 if (!currentDrag || ev.buttons !== 1)
122 return
123
124 ev.preventDefault()
125 ev.stopPropagation()
126
127 let x = (ev.clientX - slideLeft.value) / scale.value
128 let y = (ev.clientY - slideTop.value) / scale.value
129
130 const { ltx, lty, rtx, rty, lbx, lby, rbx, rby } = currentDrag
131
132 const ratio = currentDrag.width / currentDrag.height
133 const wMin = Math.max(minSize, minSize * ratio)
134 function getSize(w1: number, h1: number) {
135 if (ev.shiftKey) {
136 const w = Math.max(w1, h1 * ratio, wMin)
137 const h = w / ratio
138 return { w, h }
139 }
140 else {
141 return { w: Math.max(w1, minSize), h: Math.max(h1, minSize) }
142 }
143 }
144
145 if (isLeft) {
146 if (isTop) {
147 const w1 = (rbx - x) * rotateCos.value + (rby - y) * rotateSin.value
148 const h1 = -(rbx - x) * rotateSin.value + (rby - y) * rotateCos.value
149 const { w, h } = getSize(w1, h1)
150 x = rbx - w * rotateCos.value + h * rotateSin.value
151 y = rby - w * rotateSin.value - h * rotateCos.value
152 }
153 else {
154 const w1 = (rtx - x) * rotateCos.value - (y - rty) * rotateSin.value
155 const h1 = (rtx - x) * rotateSin.value + (y - rty) * rotateCos.value
156 const { w, h } = getSize(w1, h1)
157 x = rtx - w * rotateCos.value - h * rotateSin.value
158 y = rty - w * rotateSin.value + h * rotateCos.value
159 }
160 }
161 else {
162 if (isTop) {
163 const w1 = (x - lbx) * rotateCos.value - (lby - y) * rotateSin.value
164 const h1 = (x - lbx) * rotateSin.value + (lby - y) * rotateCos.value
165 const { w, h } = getSize(w1, h1)
166 x = lbx + w * rotateCos.value + h * rotateSin.value
167 y = lby + w * rotateSin.value - h * rotateCos.value
168 }
169 else {
170 const w1 = (x - ltx) * rotateCos.value + (y - lty) * rotateSin.value
171 const h1 = -(x - ltx) * rotateSin.value + (y - lty) * rotateCos.value
172 const { w, h } = getSize(w1, h1)
173 x = ltx + w * rotateCos.value - h * rotateSin.value
174 y = lty + w * rotateSin.value + h * rotateCos.value
175 }
176 }
177
178 if (isLeft) {
179 if (isTop) {
180 x0.value = (x + rbx) / 2
181 y0.value = (y + rby) / 2
182 width.value = (rbx - x) * rotateCos.value + (rby - y) * rotateSin.value
183 height.value = -(rbx - x) * rotateSin.value + (rby - y) * rotateCos.value
184 }
185 else {
186 x0.value = (x + rtx) / 2
187 y0.value = (y + rty) / 2
188 width.value = (rtx - x) * rotateCos.value - (y - rty) * rotateSin.value
189 height.value = (rtx - x) * rotateSin.value + (y - rty) * rotateCos.value
190 }
191 }
192 else {
193 if (isTop) {
194 x0.value = (x + lbx) / 2
195 y0.value = (y + lby) / 2
196 width.value = (x - lbx) * rotateCos.value - (lby - y) * rotateSin.value
197 height.value = (x - lbx) * rotateSin.value + (lby - y) * rotateCos.value
198 }
199 else {
200 x0.value = (x + ltx) / 2
201 y0.value = (y + lty) / 2
202 width.value = (x - ltx) * rotateCos.value + (y - lty) * rotateSin.value
203 height.value = -(x - ltx) * rotateSin.value + (y - lty) * rotateCos.value
204 }
205 }
206 },
207 onPointerup,
208 style: {
209 width: `${ctrlSize}px`,
210 height: `${ctrlSize}px`,
211 margin: `-${ctrlSize / 2}px`,
212 left: isLeft !== arrowRevX.value ? '0' : undefined,
213 right: isLeft !== arrowRevX.value ? undefined : '0',
214 top: isTop !== arrowRevY.value ? '0' : undefined,
215 bottom: isTop !== arrowRevY.value ? undefined : '0',
216 cursor: isArrow ? 'move' : +isLeft + +isTop === 1 ? 'nesw-resize' : 'nwse-resize',
217 borderRadius: isArrow ? '50%' : undefined,
218 },
219 class: ctrlClasses,
220 }
221 }
222
223 function getBorderProps(dir: 'l' | 'r' | 't' | 'b') {
224 return {
225 onPointerdown,
226 onPointermove: (ev: PointerEvent) => {
227 if (!currentDrag || ev.buttons !== 1)
228 return
229
230 ev.preventDefault()
231 ev.stopPropagation()
232
233 const x = (ev.clientX - slideLeft.value) / scale.value
234 const y = (ev.clientY - slideTop.value) / scale.value
235
236 const { ltx, lty, rtx, rty, lbx, lby, rbx, rby } = currentDrag
237
238 if (dir === 'l') {
239 const rx = (rtx + rbx) / 2
240 const ry = (rty + rby) / 2
241 width.value = Math.max((rx - x) * rotateCos.value + (ry - y) * rotateSin.value, minSize)
242 x0.value = rx - width.value * rotateCos.value / 2
243 y0.value = ry - width.value * rotateSin.value / 2
244 }
245 else if (dir === 'r') {
246 const lx = (ltx + lbx) / 2
247 const ly = (lty + lby) / 2
248 width.value = Math.max((x - lx) * rotateCos.value + (y - ly) * rotateSin.value, minSize)
249 x0.value = lx + width.value * rotateCos.value / 2
250 y0.value = ly + width.value * rotateSin.value / 2
251 }
252 else if (dir === 't') {
253 const bx = (lbx + rbx) / 2
254 const by = (lby + rby) / 2
255 height.value = Math.max((by - y) * rotateCos.value - (bx - x) * rotateSin.value, minSize)
256 x0.value = bx + height.value * rotateSin.value / 2
257 y0.value = by - height.value * rotateCos.value / 2
258 }
259 else if (dir === 'b') {
260 const tx = (ltx + rtx) / 2
261 const ty = (lty + rty) / 2
262 height.value = Math.max((y - ty) * rotateCos.value - (x - tx) * rotateSin.value, minSize)
263 x0.value = tx - height.value * rotateSin.value / 2
264 y0.value = ty + height.value * rotateCos.value / 2
265 }
266 },
267 onPointerup,
268 style: {
269 width: `${ctrlSize}px`,
270 height: `${ctrlSize}px`,
271 margin: `-${ctrlSize / 2}px`,
272 left: dir === 'l' ? '0' : dir === 'r' ? `100%` : `50%`,
273 top: dir === 't' ? '0' : dir === 'b' ? `100%` : `50%`,
274 cursor: 'lr'.includes(dir) ? 'ew-resize' : 'ns-resize',
275 borderRadius: '50%',
276 },
277 class: ctrlClasses,
278 }
279 }
280
281 function getRotateProps() {
282 return {
283 onPointerdown,
284 onPointermove: (ev: PointerEvent) => {
285 if (!currentDrag || ev.buttons !== 1)
286 return
287
288 ev.preventDefault()
289 ev.stopPropagation()
290
291 const x = (ev.clientX - slideLeft.value - currentDrag.dx0) / scale.value - ctrlSize / 4
292 const y = (ev.clientY - slideTop.value - currentDrag.dy0) / scale.value - ctrlSize / 4
293
294 let angle = Math.atan2(y - y0.value, x - x0.value) * 180 / Math.PI + 90
295
296 const commonAngles = [0, 90, 180, 270, 360]
297 for (const a of commonAngles) {
298 if (Math.abs(angle - a) < 5) {
299 angle = a % 360
300 break
301 }
302 }
303
304 rotate.value = angle
305 },
306 onPointerup,
307 style: {
308 width: `${ctrlSize}px`,
309 height: `${ctrlSize}px`,
310 margin: `-${ctrlSize / 2}px`,
311 left: '50%',
312 top: '-20px',
313 cursor: 'grab',
314 borderRadius: '50%',
315 },
316 class: ctrlClasses,
317 }
318 }
319
320 const moveInterval = 20
321 const intervalFnOptions = {
322 immediate: false,
323 immediateCallback: false,
324 }
325 const moveLeft = useIntervalFn(() => {
326 if (boundingRight.value <= minRemain)
327 return
328 x0.value--
329 }, moveInterval, intervalFnOptions)
330 const moveRight = useIntervalFn(() => {
331 if (boundingLeft.value >= slideWidth.value - minRemain)
332 return
333 x0.value++
334 }, moveInterval, intervalFnOptions)
335 const moveUp = useIntervalFn(() => {
336 if (boundingBottom.value <= minRemain)
337 return
338 y0.value--
339 }, moveInterval, intervalFnOptions)
340 const moveDown = useIntervalFn(() => {
341 if (boundingTop.value >= slideHeight.value - minRemain)
342 return
343 y0.value++
344 }, moveInterval, intervalFnOptions)
345
346 watchEffect(() => {
347 function shortcut(key: string, fn: Pausable) {
348 if (magicKeys[key].value)
349 fn.resume()
350 else fn.pause()
351 }
352 shortcut('left', moveLeft)
353 shortcut('right', moveRight)
354 shortcut('up', moveUp)
355 shortcut('down', moveDown)
356 })
357 </script>
358
359 <template>
360 <div
361 v-if="Number.isFinite(x0)"
362 id="drag-control-container"
363 :data-drag-id="dragId"
364 :style="{
365 position: 'absolute',
366 zIndex: 100,
367 left: `${zoom * (x0 - Math.abs(width) / 2)}px`,
368 top: `${zoom * (y0 - Math.abs(height) / 2)}px`,
369 width: `${zoom * Math.abs(width)}px`,
370 height: `${zoom * Math.abs(height)}px`,
371 transformOrigin: 'center center',
372 transform: `rotate(${rotate}deg)`,
373 }"
374 @pointerdown="onPointerdown"
375 @pointermove="onPointermove"
376 @pointerup="onPointerup"
377 >
378 <div class="absolute inset-0 z-nav dark:b-gray-400" :class="isArrow ? '' : 'b b-dark'">
379 <template v-if="!autoHeight">
380 <div v-bind="getCornerProps(true, true)" />
381 <div v-bind="getCornerProps(false, false)" />
382 <template v-if="!isArrow">
383 <div v-bind="getCornerProps(true, false)" />
384 <div v-bind="getCornerProps(false, true)" />
385 </template>
386 </template>
387 <template v-if="!isArrow">
388 <div v-bind="getBorderProps('l')" />
389 <div v-bind="getBorderProps('r')" />
390 <template v-if="!autoHeight">
391 <div v-bind="getBorderProps('t')" />
392 <div v-bind="getBorderProps('b')" />
393 </template>
394 <div v-bind="getRotateProps()" />
395 <div
396 class="absolute -top-15px w-0 b b-dashed b-dark dark:b-gray-400"
397 :style="{
398 left: 'calc(50% - 1px)',
399 height: autoHeight ? '14px' : '10px',
400 }"
401 />
402 </template>
403 </div>
404 </div>
405 </template>
406
406 lines Plain Text