ClipboardAndDropHandler.ts raw
1 import { isSupportedImage } from '@/lib/image-scaler'
2 import mediaUpload from '@/services/media-upload.service'
3 import { Extension } from '@tiptap/core'
4 import { EditorView } from '@tiptap/pm/view'
5 import { Plugin, TextSelection } from 'prosemirror-state'
6
7 const DRAGOVER_CLASS_LIST = [
8 'outline-2',
9 'outline-offset-4',
10 'outline-dashed',
11 'outline-border',
12 'rounded-md'
13 ]
14
15 export interface ClipboardAndDropHandlerOptions {
16 onUploadStart?: (file: File, cancel: () => void) => void
17 onUploadEnd?: (file: File) => void
18 onUploadProgress?: (file: File, progress: number) => void
19 }
20
21 export const ClipboardAndDropHandler = Extension.create<ClipboardAndDropHandlerOptions>({
22 name: 'clipboardAndDropHandler',
23
24 addOptions() {
25 return {
26 onUploadStart: undefined,
27 onUploadSuccess: undefined,
28 onUploadError: undefined,
29 onUploadEnd: undefined,
30 onUploadProgress: undefined,
31 onProvideCancel: undefined
32 }
33 },
34
35 addProseMirrorPlugins() {
36 const options = this.options
37
38 return [
39 new Plugin({
40 props: {
41 handleDOMEvents: {
42 dragenter(view, event) {
43 event.preventDefault()
44 view.dom.classList.add(...DRAGOVER_CLASS_LIST)
45 return true
46 },
47 dragover(view, event) {
48 event.preventDefault()
49 view.dom.classList.add(...DRAGOVER_CLASS_LIST)
50 return true
51 },
52 dragleave(view) {
53 view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
54 return true
55 }
56 },
57 handleDrop(view: EditorView, event: DragEvent) {
58 event.preventDefault()
59 event.stopPropagation()
60 view.dom.classList.remove(...DRAGOVER_CLASS_LIST)
61
62 const items = Array.from(event.dataTransfer?.files ?? [])
63 const mediaFiles = items.filter(
64 (item) => item.type.includes('image') || item.type.includes('video')
65 )
66 if (!mediaFiles.length) return false
67
68 uploadFiles(view, mediaFiles, options)
69 return true
70 },
71 handlePaste(view, event) {
72 const items = Array.from(event.clipboardData?.items ?? [])
73 let handled = false
74
75 for (const item of items) {
76 if (
77 item.kind === 'file' &&
78 (item.type.includes('image') || item.type.includes('video'))
79 ) {
80 const file = item.getAsFile()
81 if (file) {
82 uploadFiles(view, [file], options)
83 handled = true
84 }
85 } else if (item.kind === 'string' && item.type === 'text/plain') {
86 item.getAsString((text) => {
87 const { schema } = view.state
88 const parts = text.split('\n')
89 const nodes = []
90 for (let i = 0; i < parts.length; i++) {
91 if (i > 0) nodes.push(schema.nodes.hardBreak.create())
92 if (parts[i]) nodes.push(schema.text(parts[i]))
93 }
94 if (nodes.length > 0) {
95 const tr = view.state.tr.replaceSelectionWith(nodes[0])
96 for (let i = 1; i < nodes.length; i++) {
97 tr.insert(tr.selection.from, nodes[i])
98 }
99 view.dispatch(tr)
100 }
101 })
102 handled = true
103 }
104
105 // Only handle the first file/string item
106 if (handled) break
107 }
108 return handled
109 }
110 }
111 })
112 ]
113 }
114 })
115
116 async function uploadFiles(
117 view: EditorView,
118 files: File[],
119 options: ClipboardAndDropHandlerOptions
120 ) {
121 const abortControllers = new Map<File, AbortController>()
122 files.forEach((file) => {
123 const abortController = new AbortController()
124 abortControllers.set(file, abortController)
125 options.onUploadStart?.(file, () => abortController.abort())
126 })
127
128 for (const file of files) {
129 const name = file.name
130
131 const placeholder = `[Uploading "${name}"...]`
132 const uploadingNode = view.state.schema.text(placeholder)
133 const hardBreakNode = view.state.schema.nodes.hardBreak.create()
134 let tr = view.state.tr.replaceSelectionWith(uploadingNode)
135 tr = tr.insert(tr.selection.from, hardBreakNode)
136 view.dispatch(tr)
137
138 const abortController = abortControllers.get(file)
139
140 // Use responsive upload for supported images, regular upload for other media
141 const uploadPromise = isSupportedImage(file)
142 ? mediaUpload.uploadResponsiveImage(file, {
143 onProgress: (p) => options.onUploadProgress?.(file, p),
144 signal: abortController?.signal
145 }).then((result) => {
146 // For responsive uploads, use the desktop-sm or original variant URL
147 const primaryVariant =
148 result.variants.find((v) => v.variant === 'desktop-sm') ||
149 result.variants.find((v) => v.variant === 'original') ||
150 result.variants[result.variants.length - 1]
151 return { url: primaryVariant?.url ?? result.variants[0]?.url }
152 })
153 : mediaUpload.upload(file, {
154 onProgress: (p) => options.onUploadProgress?.(file, p),
155 signal: abortController?.signal
156 })
157
158 uploadPromise
159 .then((result) => {
160 options.onUploadEnd?.(file)
161 const urlNode = view.state.schema.text(result.url)
162
163 const tr = view.state.tr
164 let didReplace = false
165
166 view.state.doc.descendants((node, pos) => {
167 if (node.isText && node.text && node.text.includes(placeholder) && !didReplace) {
168 const startPos = node.text.indexOf(placeholder)
169 const from = pos + startPos
170 const to = from + placeholder.length
171 tr.replaceWith(from, to, urlNode)
172 didReplace = true
173 return false
174 }
175 return true
176 })
177
178 if (didReplace) {
179 view.dispatch(tr)
180 } else {
181 const endPos = view.state.doc.content.size
182
183 const paragraphNode = view.state.schema.nodes.paragraph.create(
184 null,
185 view.state.schema.text(result.url)
186 )
187
188 const insertTr = view.state.tr.insert(endPos, paragraphNode)
189 const newPos = endPos + 1 + result.url.length
190 insertTr.setSelection(TextSelection.near(insertTr.doc.resolve(newPos)))
191 view.dispatch(insertTr)
192 }
193 })
194 .catch((error) => {
195 console.error('Upload failed:', error)
196 options.onUploadEnd?.(file)
197
198 const tr = view.state.tr
199 let didReplace = false
200
201 view.state.doc.descendants((node, pos) => {
202 if (node.isText && node.text && node.text.includes(placeholder) && !didReplace) {
203 const startPos = node.text.indexOf(placeholder)
204 const from = pos + startPos
205 const to = from + placeholder.length
206 const errorNode = view.state.schema.text(`[Error uploading "${name}"]`)
207 tr.replaceWith(from, to, errorNode)
208 didReplace = true
209 return false
210 }
211 return true
212 })
213
214 if (didReplace) {
215 view.dispatch(tr)
216 }
217 throw error
218 })
219 }
220 }
221