diff --git a/cypress/e2e/examples/basic.spec.ts b/cypress/e2e/examples/basic.spec.ts index 03fe5b75cb..c87d6c211d 100644 --- a/cypress/e2e/examples/basic.spec.ts +++ b/cypress/e2e/examples/basic.spec.ts @@ -48,7 +48,7 @@ context('Basic', () => { cy.contains('Global Footer') .should('not.exist') - cy.get('#page-root > #slide-container > #slide-content > #slideshow .slidev-page-2 > p') + cy.get('#page-root > #slide-container > #slide-content > #slideshow .slidev-page-2 > div > p') .should('have.css', 'border-color', 'rgb(0, 128, 0)') .should('not.have.css', 'color', 'rgb(128, 0, 0)') @@ -56,7 +56,7 @@ context('Basic', () => { cy.get('#page-root > #slide-container > #slide-content > #slideshow .slidev-page-5 .slidev-code') .should('have.text', '
{{$slidev.nav.currentPage}}
') - .get('#page-root > #slide-container > #slide-content > #slideshow .slidev-page-5 > p') + .get('#page-root > #slide-container > #slide-content > #slideshow .slidev-page-5 > div > p') .should('have.text', 'Current Page: 5') }) diff --git a/demo/starter/slides.md b/demo/starter/slides.md index dbc604d3a1..ecb9643a0c 100644 --- a/demo/starter/slides.md +++ b/demo/starter/slides.md @@ -559,6 +559,43 @@ database "MySql" { [Learn More](https://sli.dev/guide/syntax.html#diagrams) +--- +foo: bar +dragPos: + square: 691,33,167,_,-16 +--- + +# Draggable Elements + +Double-click on the draggable elements to edit their positions. + +
+ +###### Directive Usage + +```md + +``` + +
+ +###### Component Usage + +```md + + + Use the `v-drag` component to have a draggable container! + +``` + + +
+ Double-click me! +
+
+ + + --- src: ./pages/multiple-entries.md hide: false diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 89f6a7e61c..0712cfd2b5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -28,6 +28,10 @@ const Guide: DefaultTheme.NavItemWithLink[] = [ text: 'Animations', link: '/guide/animations', }, + { + text: 'Draggable Elements', + link: '/guide/draggable', + }, { text: 'Presenter Mode', link: '/guide/presenter-mode', diff --git a/docs/builtin/components.md b/docs/builtin/components.md index e71ee46eda..817fadf5a0 100644 --- a/docs/builtin/components.md +++ b/docs/builtin/components.md @@ -272,6 +272,10 @@ Parameters: See https://sli.dev/guide/animations.html +### `VDrag` + +See https://sli.dev/guide/draggable.html + ### `SlidevVideo` Embed a video. diff --git a/docs/custom/index.md b/docs/custom/index.md index 11eff0f19f..7cf2af9329 100644 --- a/docs/custom/index.md +++ b/docs/custom/index.md @@ -118,6 +118,7 @@ In addition, every slide accepts the following configuration in the Frontmatter - `title` (`string`): Override the title for the `` and `` components (learn more [here](/builtin/components.html#titlerenderer)). - `transition` (`string | TransitionProps`): Defines the transition between the slide and the next one (learn more [here](/guide/animations.html#slide-transitions)). - `zoom` (`number`): Custom zoom scale. Useful for slides with a lot of content. +- `dragPos` (`Record`): Used as positions of draggable elements (learn more [here](/guide/draggable.html). ## Directory Structure diff --git a/docs/guide/draggable.md b/docs/guide/draggable.md new file mode 100644 index 0000000000..02a2164c59 --- /dev/null +++ b/docs/guide/draggable.md @@ -0,0 +1,66 @@ +# Draggable Elements + +Draggable elements give you the ability to move, resize and rotate elements by dragging them with the mouse. This is useful for creating floating elements in your slides. + +## Directive Usage + +### Data from the frontmatter + +```md +--- +dragPos: + square: Left,Top,Width,Height,Rotate +--- + + +``` + +### Data from the directive value + +::: warning +Slidev use regex to update the position value in the slide content. If you meet problems, please use the frontmatter to define the values instead. +::: + +```md + +``` + +## Component Usage + +### Data from the frontmatter + +```md +--- +dragPos: + foo: Left,Top,Width,Height,Rotate +--- + + + + Use the `v-drag` component to have a draggable container! + +``` + +### Data from props + +```md + + + Use the `v-drag` component to have a draggable container! + +``` + +## Automatic Height + +You can set `Height` to `NaN` (if you use the directive) or `_` (if you use the component) to make the height of the draggable element automatically adjust to its content. + +## Create a Draggable Element + +When you first create a draggable element, you don't need to specify the position value (but you need to specify the position name if you want to use the frontmatter). Slidev will automatically generate the initial position value for you. + +## Controls + +- Double click the draggable element to start dragging it. +- You can also use the arrow keys to move the element. +- Hold `Shift` while dragging to preserve its aspect ratio. +- Click outside the draggable element to stop dragging it. diff --git a/packages/client/builtin/VDrag.vue b/packages/client/builtin/VDrag.vue new file mode 100644 index 0000000000..0d31461a6e --- /dev/null +++ b/packages/client/builtin/VDrag.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/client/composables/useDragElements.ts b/packages/client/composables/useDragElements.ts new file mode 100644 index 0000000000..cbbcc8256b --- /dev/null +++ b/packages/client/composables/useDragElements.ts @@ -0,0 +1,282 @@ +import { debounce, ensureSuffix } from '@antfu/utils' +import type { SlidePatch } from '@slidev/types' +import { injectLocal, onClickOutside, useWindowFocus } from '@vueuse/core' +import type { CSSProperties, DirectiveBinding, InjectionKey, WatchStopHandle } from 'vue' +import { computed, ref, watch } from 'vue' +import { injectionCurrentPage, injectionFrontmatter, injectionRenderContext, injectionSlideElement, injectionSlideScale, injectionSlideZoom } from '../constants' +import { makeId } from '../logic/utils' +import { activeDragElement } from '../state' +import { directiveInject } from '../utils' +import { useSlideBounds } from './useSlideBounds' +import { useDynamicSlideInfo } from './useSlideInfo' + +export type DragElementDataSource = 'frontmatter' | 'prop' | 'directive' +/** + * Markdown source position, injected by markdown-it plugin + */ +export type DragElementMarkdownSource = [startLine: number, endLine: number, index: number] + +export type DragElementsUpdater = (id: string, posStr: string, type: DragElementDataSource, markdownSource?: DragElementMarkdownSource) => void + +const map: Record = {} + +export function useDragElementsUpdater(no: number) { + if (!(__DEV__ && __SLIDEV_FEATURE_EDITOR__)) + return () => {} + + if (map[no]) + return map[no] + + const { info, update } = useDynamicSlideInfo(no) + + let newPatch: SlidePatch | null = null + async function save() { + if (newPatch) { + await update({ + ...newPatch, + skipHmr: true, + }) + newPatch = null + } + } + const debouncedSave = debounce(500, save) + + return map[no] = (id, posStr, type, markdownSource) => { + if (!info.value) + return + + if (type === 'frontmatter') { + const frontmatter = info.value.frontmatter + frontmatter.dragPos ||= {} + if (frontmatter.dragPos[id] === posStr) + return + frontmatter.dragPos[id] = posStr + newPatch = { + frontmatter, + } + } + else { + if (!markdownSource) + throw new Error(`[Slidev] VDrag Element ${id} is missing markdown source`) + + const [startLine, endLine, idx] = markdownSource + const lines = info.value.content.split(/\r?\n/g) + + let section = lines.slice(startLine, endLine).join('\n') + let replaced = false + + section = type === 'prop' + ? section.replace(/<(v-?drag)(.*?)>/ig, (full, tag, attrs, index) => { + if (index === idx) { + replaced = true + const posMatch = attrs.match(/pos=".*?"/) + if (!posMatch) + return `<${tag}${ensureSuffix(' ', attrs)}pos="${posStr}">` + const start = posMatch.index + const end = start + posMatch[0].length + return `<${tag}${attrs.slice(0, start)}pos="${posStr}"${attrs.slice(end)}>` + } + return full + }) + : section.replace(/(? { + if (index === idx) { + replaced = true + return `v-drag="${posStr}"` + } + return full + }) + + if (!replaced) + throw new Error(`[Slidev] VDrag Element ${id} is not found in the markdown source`) + + lines.splice( + startLine, + endLine - startLine, + section, + ) + + const newContent = lines.join('\n') + if (info.value.content === newContent) + return + newPatch = { + content: newContent, + } + info.value = { + ...info.value, + content: newContent, + } + } + debouncedSave() + } +} + +export function useDragElement(directive: DirectiveBinding | null, posRaw?: string | number | number[], markdownSource?: DragElementMarkdownSource) { + function inject(key: InjectionKey | string): T | undefined { + return directive + ? directiveInject(directive, key) + : injectLocal(key) + } + + const renderContext = inject(injectionRenderContext)! + const frontmatter = inject(injectionFrontmatter) ?? {} + const page = inject(injectionCurrentPage)! + const updater = computed(() => useDragElementsUpdater(page.value)) + const scale = inject(injectionSlideScale) ?? ref(1) + const zoom = inject(injectionSlideZoom) ?? ref(1) + const { left: slideLeft, top: slideTop, stop: stopWatchBounds } = useSlideBounds(inject(injectionSlideElement) ?? ref()) + const enabled = ['slide', 'presenter'].includes(renderContext.value) + + let dataSource: DragElementDataSource = directive ? 'directive' : 'prop' + let id: string = makeId() + let pos: number[] | undefined + if (Array.isArray(posRaw)) { + pos = posRaw + } + else if (typeof posRaw === 'string' && posRaw.includes(',')) { + pos = posRaw.split(',').map(Number) + } + else if (posRaw != null) { + dataSource = 'frontmatter' + id = `${posRaw}` + posRaw = frontmatter?.dragPos?.[id] + pos = (posRaw as string)?.split(',').map(Number) + } + + if (dataSource !== 'frontmatter' && !markdownSource) + throw new Error('[Slidev] Can not identify the source position of the v-drag element, please provide an explicit `id` prop.') + + const watchStopHandles: WatchStopHandle[] = [stopWatchBounds] + + const autoHeight = posRaw != null && !Number.isFinite(pos?.[3]) + pos ??= [Number.NaN, Number.NaN, 0] + const width = ref(pos[2]) + const x0 = ref(pos[0] + pos[2] / 2) + + const rotate = ref(pos[4] ?? 0) + const rotateRad = computed(() => rotate.value * Math.PI / 180) + const rotateSin = computed(() => Math.sin(rotateRad.value)) + const rotateCos = computed(() => Math.cos(rotateRad.value)) + + const container = ref() + const bounds = ref({ left: 0, top: 0, width: 0, height: 0 }) + const actualHeight = ref(0) + function updateBounds() { + const rect = container.value!.getBoundingClientRect() + bounds.value = { + left: rect.left / zoom.value, + top: rect.top / zoom.value, + width: rect.width / zoom.value, + height: rect.height / zoom.value, + } + actualHeight.value = ((bounds.value.width + bounds.value.height) / scale.value / (Math.abs(rotateSin.value) + Math.abs(rotateCos.value)) - width.value) + } + watchStopHandles.push(watch(width, updateBounds, { flush: 'post' })) + + const configuredHeight = ref(pos[3] ?? 0) + const height = computed({ + get: () => (autoHeight ? actualHeight.value : configuredHeight.value) || 0, + set: v => !autoHeight && (configuredHeight.value = v), + }) + const configuredY0 = ref(pos[1]) + const y0 = computed({ + get: () => configuredY0.value + height.value / 2, + set: v => configuredY0.value = v - height.value / 2, + }) + + const containerStyle = computed(() => { + return Number.isFinite(x0.value) + ? { + position: 'absolute', + zIndex: 100, + left: `${x0.value - width.value / 2}px`, + top: `${y0.value - height.value / 2}px`, + width: `${width.value}px`, + height: autoHeight ? undefined : `${height.value}px`, + transformOrigin: 'center center', + transform: `rotate(${rotate.value}deg)`, + } + : { + position: 'absolute', + zIndex: 100, + } + }) + + watchStopHandles.push( + watch( + [x0, y0, width, height, rotate], + ([x0, y0, w, h, r]) => { + let posStr = [x0 - w / 2, y0 - h / 2, w].map(Math.round).join() + if (autoHeight) + posStr += dataSource === 'directive' ? ',NaN' : ',_' + else + posStr += `,${Math.round(h)}` + if (Math.round(r) !== 0) + posStr += `,${Math.round(r)}` + + if (dataSource === 'directive') + posStr = `[${posStr}]` + + updater.value(id, posStr, dataSource, markdownSource) + }, + ), + ) + + const state = { + id, + dataSource, + markdownSource, + zoom, + autoHeight, + x0, + y0, + width, + height, + rotate, + container, + containerStyle, + watchStopHandles, + dragging: computed((): boolean => activeDragElement.value === state), + mounted() { + if (!enabled) + return + updateBounds() + if (!posRaw) { + setTimeout(() => { + updateBounds() + x0.value = (bounds.value.left + bounds.value.width / 2 - slideLeft.value) / scale.value + y0.value = (bounds.value.top - slideTop.value) / scale.value + width.value = bounds.value.width / scale.value + height.value = bounds.value.height / scale.value + }, 100) + } + }, + unmounted() { + if (!enabled) + return + state.stopDragging() + }, + startDragging(): void { + updateBounds() + activeDragElement.value = state + }, + stopDragging(): void { + if (activeDragElement.value === state) + activeDragElement.value = null + }, + } + + watchStopHandles.push( + onClickOutside(container, (ev) => { + if ((ev.target as HTMLElement | null)?.dataset?.dragId !== id) + state.stopDragging() + }), + watch(useWindowFocus(), (focused) => { + if (!focused) + state.stopDragging() + }), + ) + + return state +} + +export type DragElementState = ReturnType diff --git a/packages/client/composables/useSlideBounds.ts b/packages/client/composables/useSlideBounds.ts new file mode 100644 index 0000000000..31ccacb331 --- /dev/null +++ b/packages/client/composables/useSlideBounds.ts @@ -0,0 +1,30 @@ +import { useElementBounding } from '@vueuse/core' +import { inject, ref, watch } from 'vue' +import { injectionSlideElement } from '../constants' +import { editorHeight, editorWidth, isEditorVertical, showEditor, slideScale, windowSize } from '../state' + +export function useSlideBounds(slideElement = inject(injectionSlideElement, ref())) { + const bounding = useElementBounding(slideElement) + const stop = watch( + [ + showEditor, + isEditorVertical, + editorWidth, + editorHeight, + slideScale, + windowSize.width, + windowSize.height, + ], + () => { + setTimeout(bounding.update, 300) + }, + { + flush: 'post', + immediate: true, + }, + ) + return { + ...bounding, + stop, + } +} diff --git a/packages/client/composables/useSlideInfo.ts b/packages/client/composables/useSlideInfo.ts index 7ffaee7d10..26c79beea0 100644 --- a/packages/client/composables/useSlideInfo.ts +++ b/packages/client/composables/useSlideInfo.ts @@ -6,19 +6,19 @@ import type { SlideInfo, SlidePatch } from '@slidev/types' import { getSlide } from '../logic/slides' export interface UseSlideInfo { - info: Ref + info: Ref update: (data: SlidePatch) => Promise } export function useSlideInfo(no: number): UseSlideInfo { if (!__SLIDEV_HAS_SERVER__) { return { - info: ref(getSlide(no)?.meta.slide) as Ref, + info: ref(getSlide(no)?.meta.slide ?? null) as Ref, update: async () => {}, } } const url = `/@slidev/slide/${no}.json` - const { data: info, execute } = useFetch(url).json().get() + const { data: info, execute } = useFetch(url).json().get() execute() @@ -42,7 +42,7 @@ export function useSlideInfo(no: number): UseSlideInfo { info.value = payload.data }) import.meta.hot?.on('slidev:update-note', (payload) => { - if (payload.no === no && info.value.note?.trim() !== payload.note?.trim()) + if (payload.no === no && info.value && info.value.note?.trim() !== payload.note?.trim()) info.value = { ...info.value, ...payload } }) } @@ -61,7 +61,14 @@ export function useDynamicSlideInfo(no: MaybeRef) { } return { - info: computed(() => get(unref(no)).info.value), + info: computed({ + get() { + return get(unref(no)).info.value + }, + set(newInfo) { + get(unref(no)).info.value = newInfo + }, + }), update: async (data: SlidePatch, newId?: number) => { const info = get(newId ?? unref(no)) const newData = await info.update(data) diff --git a/packages/client/constants.ts b/packages/client/constants.ts index 742e79b296..997c5d7451 100644 --- a/packages/client/constants.ts +++ b/packages/client/constants.ts @@ -6,6 +6,7 @@ import type { SlidevContext } from './modules/context' // The value of the injections keys are implementation details, you should always use them with the reference to the constant instead of the value export const injectionClicksContext = '$$slidev-clicks-context' as unknown as InjectionKey> export const injectionCurrentPage = '$$slidev-page' as unknown as InjectionKey> +export const injectionSlideElement = '$$slidev-slide-element' as unknown as InjectionKey> export const injectionSlideScale = '$$slidev-slide-scale' as unknown as InjectionKey> export const injectionSlidevContext = '$$slidev-context' as unknown as InjectionKey> export const injectionRoute = '$$slidev-route' as unknown as InjectionKey @@ -44,6 +45,7 @@ export const FRONTMATTER_FIELDS = [ 'title', 'transition', 'zoom', + 'dragPos', ] export const HEADMATTER_FIELDS = [ diff --git a/packages/client/internals/DragControl.vue b/packages/client/internals/DragControl.vue new file mode 100644 index 0000000000..2332734b0a --- /dev/null +++ b/packages/client/internals/DragControl.vue @@ -0,0 +1,396 @@ + + + diff --git a/packages/client/internals/SlideContainer.vue b/packages/client/internals/SlideContainer.vue index 5a1ff3da86..9ee65faf6c 100644 --- a/packages/client/internals/SlideContainer.vue +++ b/packages/client/internals/SlideContainer.vue @@ -2,7 +2,7 @@ import { provideLocal, useElementSize, useStyleTag } from '@vueuse/core' import { computed, ref, watchEffect } from 'vue' import { configs, slideAspect, slideHeight, slideWidth } from '../env' -import { injectionSlideScale } from '../constants' +import { injectionSlideElement, injectionSlideScale } from '../constants' import { useNav } from '../composables/useNav' const props = defineProps({ @@ -23,11 +23,12 @@ const props = defineProps({ const { clicksDirection, isPrintMode } = useNav() -const root = ref() -const element = useElementSize(root) +const root = ref(null) +const rootSize = useElementSize(root) +const slideElement = ref(null) -const width = computed(() => props.width ? props.width : element.width.value) -const height = computed(() => props.width ? props.width / slideAspect.value : element.height.value) +const width = computed(() => props.width ? props.width : rootSize.width.value) +const height = computed(() => props.width ? props.width / slideAspect.value : rootSize.height.value) if (props.width) { watchEffect(() => { @@ -42,7 +43,7 @@ const screenAspect = computed(() => width.value / height.value) const scale = computed(() => { if (props.scale && !isPrintMode.value) - return props.scale + return +props.scale if (screenAspect.value < slideAspect.value) return width.value / slideWidth.value return height.value * slideAspect.value / slideWidth.value @@ -69,12 +70,13 @@ if (props.isMain) { `)) } -provideLocal(injectionSlideScale, scale as any) +provideLocal(injectionSlideScale, scale) +provideLocal(injectionSlideElement, slideElement)