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