2026-02-28 21:31:47 +01:00
|
|
|
import { useEffect, useRef, useState, type RefObject } from 'react'
|
2026-02-23 20:29:34 +01:00
|
|
|
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
|
|
|
|
|
import { isTauri } from '../mock-tauri'
|
|
|
|
|
|
|
|
|
|
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
2026-02-28 21:31:47 +01:00
|
|
|
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff']
|
2026-02-23 20:29:34 +01:00
|
|
|
|
|
|
|
|
function hasImageFiles(dt: DataTransfer): boolean {
|
|
|
|
|
for (let i = 0; i < dt.items.length; i++) {
|
|
|
|
|
if (dt.items[i].kind === 'file' && IMAGE_MIME_TYPES.includes(dt.items[i].type)) return true
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 21:31:47 +01:00
|
|
|
function isImagePath(path: string): boolean {
|
|
|
|
|
const ext = path.split('.').pop()?.toLowerCase() ?? ''
|
|
|
|
|
return IMAGE_EXTENSIONS.includes(ext)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 20:29:34 +01:00
|
|
|
/** Upload an image file — saves to vault/attachments in Tauri, returns data URL in browser */
|
|
|
|
|
export async function uploadImageFile(file: File, vaultPath?: string): Promise<string> {
|
|
|
|
|
if (isTauri() && vaultPath) {
|
|
|
|
|
const buf = await file.arrayBuffer()
|
|
|
|
|
const bytes = new Uint8Array(buf)
|
|
|
|
|
let binary = ''
|
|
|
|
|
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i])
|
|
|
|
|
const base64 = btoa(binary)
|
|
|
|
|
const savedPath = await invoke<string>('save_image', {
|
|
|
|
|
vaultPath,
|
|
|
|
|
filename: file.name,
|
|
|
|
|
data: base64,
|
|
|
|
|
})
|
|
|
|
|
return convertFileSrc(savedPath)
|
|
|
|
|
}
|
|
|
|
|
return new Promise<string>((resolve, reject) => {
|
|
|
|
|
const reader = new FileReader()
|
|
|
|
|
reader.onload = () => resolve(reader.result as string)
|
|
|
|
|
reader.onerror = () => reject(reader.error)
|
|
|
|
|
reader.readAsDataURL(file)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-28 21:31:47 +01:00
|
|
|
/** Copy a dropped file (by OS path) into vault/attachments and return its asset URL. */
|
|
|
|
|
async function copyImageToVault(sourcePath: string, vaultPath: string): Promise<string> {
|
|
|
|
|
const savedPath = await invoke<string>('copy_image_to_vault', { vaultPath, sourcePath })
|
|
|
|
|
return convertFileSrc(savedPath)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 20:29:34 +01:00
|
|
|
interface UseImageDropOptions {
|
|
|
|
|
containerRef: RefObject<HTMLDivElement | null>
|
2026-02-28 21:31:47 +01:00
|
|
|
/** Called with an asset URL for each image dropped via Tauri native drag-drop. */
|
|
|
|
|
onImageUrl?: (url: string) => void
|
|
|
|
|
vaultPath?: string
|
2026-02-23 20:29:34 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-28 21:31:47 +01:00
|
|
|
export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) {
|
2026-02-23 20:29:34 +01:00
|
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
2026-02-28 21:31:47 +01:00
|
|
|
const onImageUrlRef = useRef(onImageUrl)
|
|
|
|
|
useEffect(() => { onImageUrlRef.current = onImageUrl }, [onImageUrl])
|
|
|
|
|
const vaultPathRef = useRef(vaultPath)
|
|
|
|
|
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
2026-02-23 20:29:34 +01:00
|
|
|
|
2026-02-28 21:31:47 +01:00
|
|
|
// HTML5 DnD visual feedback (works in browser mode; BlockNote handles the actual upload)
|
2026-02-23 20:29:34 +01:00
|
|
|
useEffect(() => {
|
|
|
|
|
const container = containerRef.current
|
|
|
|
|
if (!container) return
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (e: DragEvent) => {
|
|
|
|
|
if (!e.dataTransfer || !hasImageFiles(e.dataTransfer)) return
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
e.dataTransfer.dropEffect = 'copy'
|
|
|
|
|
setIsDragOver(true)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleDragLeave = (e: DragEvent) => {
|
|
|
|
|
if (!container.contains(e.relatedTarget as Node)) {
|
|
|
|
|
setIsDragOver(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-23 22:00:31 +01:00
|
|
|
const handleDrop = () => {
|
|
|
|
|
// Only reset visual state; BlockNote's native dropFile plugin handles
|
|
|
|
|
// the actual upload (via editor.uploadFile) and block insertion.
|
2026-02-23 20:29:34 +01:00
|
|
|
setIsDragOver(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
container.addEventListener('dragover', handleDragOver)
|
|
|
|
|
container.addEventListener('dragleave', handleDragLeave)
|
|
|
|
|
container.addEventListener('drop', handleDrop)
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
container.removeEventListener('dragover', handleDragOver)
|
|
|
|
|
container.removeEventListener('dragleave', handleDragLeave)
|
|
|
|
|
container.removeEventListener('drop', handleDrop)
|
|
|
|
|
}
|
2026-02-23 22:00:31 +01:00
|
|
|
}, [containerRef])
|
2026-02-23 20:29:34 +01:00
|
|
|
|
2026-02-28 21:31:47 +01:00
|
|
|
// Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isTauri()) return
|
|
|
|
|
|
|
|
|
|
let unlisten: (() => void) | null = null
|
|
|
|
|
let mounted = true
|
|
|
|
|
|
|
|
|
|
void (async () => {
|
|
|
|
|
try {
|
|
|
|
|
const { getCurrentWebview } = await import('@tauri-apps/api/webview')
|
|
|
|
|
if (!mounted) return
|
|
|
|
|
unlisten = await getCurrentWebview().onDragDropEvent((event) => {
|
|
|
|
|
const payload = event.payload
|
|
|
|
|
if (payload.type === 'over') {
|
2026-03-11 18:15:40 +01:00
|
|
|
// Tauri 'over' events don't include paths and can't distinguish
|
|
|
|
|
// OS file drags from internal drags (tabs, blocks). Let the HTML5
|
|
|
|
|
// dragover handler drive isDragOver — it checks hasImageFiles().
|
2026-02-28 21:31:47 +01:00
|
|
|
} else if (payload.type === 'drop') {
|
|
|
|
|
setIsDragOver(false)
|
|
|
|
|
const imagePaths = payload.paths.filter(isImagePath)
|
|
|
|
|
const vault = vaultPathRef.current
|
|
|
|
|
const callback = onImageUrlRef.current
|
|
|
|
|
if (imagePaths.length > 0 && vault && callback) {
|
|
|
|
|
for (const sourcePath of imagePaths) {
|
|
|
|
|
void copyImageToVault(sourcePath, vault).then(callback)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
setIsDragOver(false)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
} catch {
|
|
|
|
|
// Tauri webview API not available (e.g. older Tauri version)
|
|
|
|
|
}
|
|
|
|
|
})()
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
mounted = false
|
|
|
|
|
unlisten?.()
|
|
|
|
|
}
|
|
|
|
|
}, [])
|
|
|
|
|
|
2026-02-23 20:29:34 +01:00
|
|
|
return { isDragOver }
|
|
|
|
|
}
|