返回 slidev
useDragElements.ts
根目录 / packages / client / composables / useDragElements.ts
1 import type { SlidePatch } from '@slidev/types'
2 import type { CSSProperties, DirectiveBinding, InjectionKey, WatchStopHandle } from 'vue'
3 import { debounce, ensureSuffix } from '@antfu/utils'
4 import { injectLocal, onClickOutside, useWindowFocus } from '@vueuse/core'
5 import { computed, ref, watch } from 'vue'
6 import { injectionCurrentPage, injectionFrontmatter, injectionRenderContext, injectionSlideElement, injectionSlideScale, injectionSlideZoom } from '../constants'
7 import { makeId } from '../logic/utils'
8 import { activeDragElement } from '../state'
9 import { directiveInject } from '../utils'
10 import { useNav } from './useNav'
11 import { useSlideBounds } from './useSlideBounds'
12 import { useDynamicSlideInfo } from './useSlideInfo'
13
14 const RE_NEWLINE = /\r?\n/g
15 const RE_POS_ATTR = /pos=".*?"/
16
17 export type DragElementDataSource = 'frontmatter' | 'prop' | 'directive'
18 /**
19 * Markdown source position, injected by markdown-it plugin
20 */
21 export type DragElementMarkdownSource = [startLine: number, endLine: number, index: number]
22
23 export type DragElementsUpdater = (id: string, posStr: string, type: DragElementDataSource, markdownSource?: DragElementMarkdownSource) => void
24
25 const map: Record<number, DragElementsUpdater> = {}
26
27 export function useDragElementsUpdater(no: number) {
28 if (!(__DEV__ && __SLIDEV_FEATURE_EDITOR__))
29 return () => {}
30
31 if (map[no])
32 return map[no]
33
34 const { info, update } = useDynamicSlideInfo(no)
35
36 let newPatch: SlidePatch | null = null
37 async function save() {
38 if (newPatch) {
39 await update({
40 ...newPatch,
41 skipHmr: true,
42 })
43 newPatch = null
44 }
45 }
46 const debouncedSave = debounce(500, save)
47
48 return map[no] = (id, posStr, type, markdownSource) => {
49 if (!info.value)
50 return
51
52 if (type === 'frontmatter') {
53 const frontmatter = info.value.frontmatter
54 frontmatter.dragPos ||= {}
55 if (frontmatter.dragPos[id] === posStr)
56 return
57 frontmatter.dragPos[id] = posStr
58 newPatch = {
59 frontmatter: {
60 dragPos: frontmatter.dragPos,
61 },
62 }
63 }
64 else {
65 if (!markdownSource)
66 throw new Error(`[Slidev] VDrag Element ${id} is missing markdown source`)
67
68 const [startLine, endLine, idx] = markdownSource
69 const lines = info.value.content.split(RE_NEWLINE)
70
71 let section = lines.slice(startLine, endLine).join('\n')
72 let replaced = false
73
74 section = type === 'prop'
75 // eslint-disable-next-line regexp/no-super-linear-backtracking
76 ? section.replace(/<(v-?drag-?\w*)(.*?)(\/)?>/gi, (full, tag, attrs, selfClose = '', index) => {
77 if (index === idx) {
78 replaced = true
79 const posMatch = attrs.match(RE_POS_ATTR)
80 if (!posMatch)
81 return `<${tag}${ensureSuffix(' ', attrs)}pos="${posStr}"${selfClose}>`
82 const start = posMatch.index
83 const end = start + posMatch[0].length
84 return `<${tag}${attrs.slice(0, start)}pos="${posStr}"${attrs.slice(end)}${selfClose}>`
85 }
86 return full
87 })
88 : section.replace(/(?<![</\w])v-drag(?:=".*?")?/gi, (full, index) => {
89 if (index === idx) {
90 replaced = true
91 return `v-drag="${posStr}"`
92 }
93 return full
94 })
95
96 if (!replaced)
97 throw new Error(`[Slidev] VDrag Element ${id} is not found in the markdown source`)
98
99 lines.splice(
100 startLine,
101 endLine - startLine,
102 section,
103 )
104
105 const newContent = lines.join('\n')
106 if (info.value.content === newContent)
107 return
108 newPatch = {
109 content: newContent,
110 }
111 info.value = {
112 ...info.value,
113 content: newContent,
114 }
115 }
116 debouncedSave()
117 }
118 }
119
120 export function useDragElement(directive: DirectiveBinding | null, posRaw?: string | number | number[], markdownSource?: DragElementMarkdownSource, isArrow = false) {
121 function inject<T>(key: InjectionKey<T> | string): T | undefined {
122 return directive
123 ? directiveInject(directive, key)
124 : injectLocal(key)
125 }
126
127 const renderContext = inject(injectionRenderContext)!
128 const frontmatter = inject(injectionFrontmatter) ?? {}
129 const page = inject(injectionCurrentPage)!
130 const updater = computed(() => useDragElementsUpdater(page.value))
131 const scale = inject(injectionSlideScale) ?? ref(1)
132 const zoom = inject(injectionSlideZoom) ?? ref(1)
133 const { left: slideLeft, top: slideTop, stop: stopWatchBounds } = useSlideBounds(inject(injectionSlideElement) ?? ref())
134 const { isPrintMode } = useNav()
135 const enabled = ['slide', 'presenter'].includes(renderContext.value) && !isPrintMode.value
136
137 let dataSource: DragElementDataSource = directive ? 'directive' : 'prop'
138 let dragId: string = makeId()
139 let pos: number[] | undefined
140 if (Array.isArray(posRaw)) {
141 pos = posRaw
142 }
143 else if (typeof posRaw === 'string' && posRaw.includes(',')) {
144 pos = posRaw.split(',').map(Number)
145 }
146 else if (posRaw != null) {
147 dataSource = 'frontmatter'
148 dragId = `${posRaw}`
149 posRaw = frontmatter?.dragPos?.[dragId]
150 pos = (posRaw as string)?.split(',').map(Number)
151 }
152
153 if (dataSource !== 'frontmatter' && !markdownSource)
154 throw new Error('[Slidev] Can not identify the source position of the v-drag element, please provide an explicit `id` prop.')
155
156 const watchStopHandles: WatchStopHandle[] = [stopWatchBounds]
157
158 const autoHeight = !isArrow && posRaw != null && !Number.isFinite(pos?.[3])
159 pos ??= [Number.NaN, Number.NaN, 0]
160 const width = ref(pos[2])
161 const x0 = ref(pos[0] + pos[2] / 2)
162
163 const rotate = ref(isArrow ? 0 : (pos[4] ?? 0))
164 const rotateRad = computed(() => rotate.value * Math.PI / 180)
165 const rotateSin = computed(() => Math.sin(rotateRad.value))
166 const rotateCos = computed(() => Math.cos(rotateRad.value))
167
168 const container = ref<HTMLElement>()
169 const bounds = ref({ left: 0, top: 0, width: 0, height: 0 })
170 const actualHeight = ref(0)
171 function updateBounds() {
172 if (!container.value)
173 return
174 const rect = container.value.getBoundingClientRect()
175 bounds.value = {
176 left: rect.left / zoom.value,
177 top: rect.top / zoom.value,
178 width: rect.width / zoom.value,
179 height: rect.height / zoom.value,
180 }
181 actualHeight.value = ((bounds.value.width + bounds.value.height) / scale.value / (Math.abs(rotateSin.value) + Math.abs(rotateCos.value)) - width.value)
182 }
183 watchStopHandles.push(watch(width, updateBounds, { flush: 'post' }))
184
185 const configuredHeight = ref(pos[3] ?? 0)
186 const height = autoHeight
187 ? computed({
188 get: () => (autoHeight ? actualHeight.value : configuredHeight.value) || 0,
189 set: v => !autoHeight && (configuredHeight.value = v),
190 })
191 : configuredHeight
192 const configuredY0 = autoHeight ? ref(pos[1]) : ref(pos[1] + pos[3] / 2)
193 const y0 = autoHeight
194 ? computed({
195 get: () => configuredY0.value + height.value / 2,
196 set: v => configuredY0.value = v - height.value / 2,
197 })
198 : configuredY0
199
200 const containerStyle = computed(() => {
201 return Number.isFinite(x0.value)
202 ? {
203 position: 'absolute',
204 zIndex: 100,
205 left: `${x0.value - width.value / 2}px`,
206 top: `${y0.value - height.value / 2}px`,
207 width: `${width.value}px`,
208 height: autoHeight ? undefined : `${height.value}px`,
209 transformOrigin: 'center center',
210 transform: `rotate(${rotate.value}deg)`,
211 } satisfies CSSProperties
212 : {
213 position: 'absolute',
214 zIndex: 100,
215 } satisfies CSSProperties
216 })
217
218 watchStopHandles.push(
219 watch(
220 [x0, y0, width, height, rotate],
221 ([x0, y0, w, h, r]) => {
222 let posStr = [x0 - w / 2, y0 - h / 2, w].map(Math.round).join()
223 if (autoHeight)
224 posStr += dataSource === 'directive' ? ',NaN' : ',_'
225 else
226 posStr += `,${Math.round(h)}`
227 if (Math.round(r) !== 0)
228 posStr += `,${Math.round(r)}`
229
230 if (dataSource === 'directive')
231 posStr = `[${posStr}]`
232
233 updater.value(dragId, posStr, dataSource, markdownSource)
234 },
235 ),
236 )
237
238 const state = {
239 dragId,
240 dataSource,
241 markdownSource,
242 isArrow,
243 zoom,
244 autoHeight,
245 x0,
246 y0,
247 width,
248 height,
249 rotate,
250 container,
251 containerStyle,
252 watchStopHandles,
253 dragging: computed((): boolean => activeDragElement.value === state),
254 mounted() {
255 if (!enabled)
256 return
257 updateBounds()
258 if (!posRaw) {
259 setTimeout(() => {
260 updateBounds()
261 x0.value = (bounds.value.left + bounds.value.width / 2 - slideLeft.value) / scale.value
262 y0.value = (bounds.value.top - slideTop.value) / scale.value
263 width.value = bounds.value.width / scale.value
264 height.value = bounds.value.height / scale.value
265 }, 100)
266 }
267 },
268 unmounted() {
269 if (!enabled)
270 return
271 state.stopDragging()
272 },
273 startDragging(): void {
274 if (!enabled)
275 return
276 updateBounds()
277 activeDragElement.value = state
278 },
279 stopDragging(): void {
280 if (!enabled)
281 return
282 if (activeDragElement.value === state)
283 activeDragElement.value = null
284 },
285 }
286
287 watchStopHandles.push(
288 onClickOutside(container, (ev) => {
289 const container = document.querySelector('#drag-control-container')
290 if (container && ev.target && container.contains(ev.target as HTMLElement))
291 return
292 state.stopDragging()
293 }),
294 watch(useWindowFocus(), (focused) => {
295 if (!focused)
296 state.stopDragging()
297 }),
298 )
299
300 return state
301 }
302
303 export type DragElementState = ReturnType<typeof useDragElement>
304
304 lines TYPESCRIPT