Skip to content

Commit 29f0b35

Browse files
committed
feat(paste): ⌘V / Ctrl+V inserts clipboard image or image URL into slide
- `usePasteImage` listens for global `paste` events. When the clipboard has an image / video file (e.g. screenshot, image copied from another app), uploads via the existing `/__slidev/upload` endpoint and appends `![](url)` to the current slide via `appendToSlide`. Same pipeline as `useFileDrop` / the `i`-key picker, so all three converge on identical source-line shape + `slides.coords.yaml` handling. - When the clipboard holds plain text that parses as an `http(s)://…` URL with an image extension (`.png|.jpg|.gif|.webp|.svg|.avif`, optional query), appends `![](url)` without uploading — remote-hosted, hotlinked. - Skipped when an `<input>` / `<textarea>` / contentEditable is focused so paste-into-input still works for chips like `<SlugChip>`. Mounted from `play.vue` alongside the file-drop hook. Verified via synthetic ClipboardEvent dispatch in dev: pasting `https://sli.dev/logo.png` on slide 2 appended `![](https://sli.dev/logo.png)` to the slide source.
1 parent e5068ac commit 29f0b35

2 files changed

Lines changed: 111 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { onMounted, onUnmounted, ref } from 'vue'
2+
import { uploadAndInsert } from './useImageInsert'
3+
import { useNav } from './useNav'
4+
5+
const IMAGE_URL_RE = /\.(?:png|jpe?g|gif|webp|svg|avif)(?:\?.*)?$/i
6+
7+
function isImageOrVideoFile(file: File): boolean {
8+
return file.type.startsWith('image/') || file.type.startsWith('video/')
9+
}
10+
11+
function looksLikeImageUrl(s: string): boolean {
12+
try {
13+
const u = new URL(s.trim())
14+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
15+
return false
16+
return IMAGE_URL_RE.test(u.pathname)
17+
}
18+
catch {
19+
return false
20+
}
21+
}
22+
23+
function inputContextActive(): boolean {
24+
const ae = document.activeElement
25+
if (!ae)
26+
return false
27+
if (ae instanceof HTMLInputElement || ae instanceof HTMLTextAreaElement)
28+
return true
29+
if ((ae as HTMLElement).isContentEditable)
30+
return true
31+
return false
32+
}
33+
34+
// Paste handler — Cmd+V (or Ctrl+V) with an image / video file on the
35+
// clipboard uploads the file via the existing `/__slidev/upload` endpoint and
36+
// appends `![](url)` to the current slide. URL pastes that look like image
37+
// links append the same markdown without uploading (remote-hosted, hotlinked).
38+
//
39+
// Skipped when:
40+
// - An `<input>` / `<textarea>` / contentEditable element is focused (so
41+
// paste-into-input still works for slug-chip-style controls).
42+
// - Clipboard has neither image bytes nor a URL that looks like an image.
43+
//
44+
// Same upload pipeline + append-to-slide-source semantics as `useFileDrop`
45+
// and the `i`-key image picker, so all three converge on the same source-line
46+
// shape and `slides.coords.yaml` handling for the inserted element's eventual
47+
// drag position.
48+
export function usePasteImage() {
49+
const inFlight = ref(false)
50+
const error = ref<string | null>(null)
51+
const { currentSlideNo } = useNav()
52+
53+
async function onPaste(ev: ClipboardEvent) {
54+
if (inputContextActive())
55+
return
56+
const dt = ev.clipboardData
57+
if (!dt)
58+
return
59+
60+
// 1. File paste (screenshot, image from another app, etc.)
61+
const files = Array.from(dt.files ?? []).filter(isImageOrVideoFile)
62+
if (files.length > 0) {
63+
ev.preventDefault()
64+
inFlight.value = true
65+
error.value = null
66+
try {
67+
const no = currentSlideNo.value
68+
for (const file of files)
69+
await uploadAndInsert(file, no)
70+
}
71+
catch (e) {
72+
error.value = e instanceof Error ? e.message : String(e)
73+
74+
console.error('[slidev] paste image failed:', e)
75+
}
76+
finally {
77+
inFlight.value = false
78+
}
79+
return
80+
}
81+
82+
// 2. URL paste — only consume if it parses as a URL and the path ends in
83+
// a common image extension. Otherwise let the paste flow through (e.g.
84+
// pasting plain text into a focusable region that's not an input but
85+
// might still have a paste handler).
86+
const text = dt.getData('text/plain')
87+
if (text && looksLikeImageUrl(text)) {
88+
ev.preventDefault()
89+
const { appendToSlide } = await import('./useImageInsert')
90+
try {
91+
await appendToSlide(currentSlideNo.value, `![](${text.trim()})`)
92+
}
93+
catch (e) {
94+
error.value = e instanceof Error ? e.message : String(e)
95+
96+
console.error('[slidev] paste image URL failed:', e)
97+
}
98+
}
99+
}
100+
101+
onMounted(() => {
102+
window.addEventListener('paste', onPaste)
103+
})
104+
onUnmounted(() => {
105+
window.removeEventListener('paste', onPaste)
106+
})
107+
108+
return { inFlight, error }
109+
}

packages/client/pages/play.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { useDrawings } from '../composables/useDrawings'
55
import { useFileDrop } from '../composables/useFileDrop'
66
import { useHideCursorIdle } from '../composables/useHideCursorIdle'
77
import { useNav } from '../composables/useNav'
8+
import { usePasteImage } from '../composables/usePasteImage'
89
import { usePinchZoomPan } from '../composables/usePinchZoomPan'
910
import { useSwipeControls } from '../composables/useSwipeControls'
1011
import { useWakeLock } from '../composables/useWakeLock'
@@ -44,6 +45,7 @@ usePinchZoomPan(root)
4445
useSwipeControls(root)
4546
registerShortcuts()
4647
const { dropActive: fileDropActive, inFlight: fileDropInFlight } = useFileDrop()
48+
usePasteImage()
4749
if (__SLIDEV_FEATURE_WAKE_LOCK__)
4850
useWakeLock()
4951
useHideCursorIdle(computed(() => isPlaying.value && !isEmbedded.value && !showEditor.value))

0 commit comments

Comments
 (0)