diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index e61626d..ff3fce6 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -138,6 +138,8 @@
let dragIds = []
let dropRootValid = false
let inboxDropValid = false
+ let captureDropActive = false
+ let captureDropLabel = ''
let showConfirm = false
let confirmTitle = ''
@@ -219,6 +221,10 @@
}
window.addEventListener('keydown', handleKeydown)
+ window.addEventListener('paste', handleGlobalPaste)
+ window.addEventListener('dragover', handleGlobalDragOver)
+ window.addEventListener('dragleave', handleGlobalDragLeave)
+ window.addEventListener('drop', handleGlobalDrop)
loading = false
loadSyncStatus()
@@ -227,6 +233,10 @@
onDestroy(() => {
if (unlistenDrop) unlistenDrop()
window.removeEventListener('keydown', handleKeydown)
+ window.removeEventListener('paste', handleGlobalPaste)
+ window.removeEventListener('dragover', handleGlobalDragOver)
+ window.removeEventListener('dragleave', handleGlobalDragLeave)
+ window.removeEventListener('drop', handleGlobalDrop)
})
// ===== System view / Node selection =====
@@ -544,6 +554,7 @@
// ===== Drag-and-drop =====
function onDragStart(e, id) {
+ e.stopPropagation()
const ids = selectedIds.includes(id) ? selectedIds : [id]
dragIds = ids
e.dataTransfer.effectAllowed = 'move'
@@ -554,12 +565,14 @@
const item = fileItems.find(x => x.id === folderId)
if (item && item.type === 'folder') {
e.preventDefault()
+ e.stopPropagation()
e.dataTransfer.dropEffect = 'move'
}
}
async function onDrop(e, folderId) {
e.preventDefault()
+ e.stopPropagation()
if (dragIds.length === 0) return
for (const id of dragIds) {
try {
@@ -573,14 +586,13 @@
// ===== Keyboard =====
- function handleKeydown(e) {
- if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
+ function isEditableTarget(target) {
+ if (!target || !(target instanceof Element)) return false
+ return !!target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]')
+ }
- if (selectedSection === 'inbox' && (e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) {
- e.preventDefault()
- captureClipboard()
- return
- }
+ function handleKeydown(e) {
+ if (isEditableTarget(e.target)) return
if (activeTab !== 'files') return
@@ -814,6 +826,7 @@
async function handleDropRoot(e) {
e.preventDefault()
+ e.stopPropagation()
try {
const draggedId = e.dataTransfer.getData('text/plain')
if (!draggedId) return
@@ -1342,16 +1355,7 @@
// ===== Drag-and-drop =====
async function onFilesDropped(paths) {
if (!paths || paths.length === 0) return
- if (selectedSection === 'inbox') {
- await captureDroppedPaths(paths)
- return
- }
- if (!selectedNode) {
- error = t('error.selectCaseFirst')
- return
- }
- const path = paths[0]
- await startImport(selectedNode.id, path)
+ await captureDroppedPaths(paths, 'drop')
}
// ===== Helpers =====
@@ -1435,56 +1439,142 @@
reader.readAsDataURL(blob)
})
}
- async function captureClipboardFile() {
- if (!navigator.clipboard || typeof navigator.clipboard.read !== 'function') return false
- const items = await navigator.clipboard.read()
- for (const item of items || []) {
- const type = (item.types || []).find(value => value.startsWith('image/'))
- if (!type) continue
- const blob = await item.getType(type)
- const filename = blob.name || `clipboard.${extensionForMime(type)}`
- const data = await blobToBase64(blob)
- const captured = await wailsCall('CaptureFileData', filename, data)
- addInboxCapture(captured)
- inboxCaptureStatus = t('inbox.captured')
- return true
+
+ function resolveCaptureContext() {
+ if (selectedNode && selectedNode.id) {
+ return {
+ contextType: 'node',
+ nodeId: selectedNode.id,
+ suggestedTargetNodeId: selectedNode.id,
+ }
+ }
+ if (selectedSection) {
+ return {
+ contextType: 'section',
+ section: selectedSection,
+ }
+ }
+ return {
+ contextType: 'global',
+ section: 'root',
}
- return false
}
+
+ function captureContextJSON() {
+ return JSON.stringify(resolveCaptureContext())
+ }
+
+ function captureContextLabel() {
+ if (selectedNode && selectedNode.id) {
+ return t('capture.dropOverlayNode', { title: selectedNode.title })
+ }
+ return t('capture.dropOverlayGlobal')
+ }
+
+ async function refreshInboxAfterCapture(item = null) {
+ if (item) addInboxCapture(item)
+ if (selectedSection === 'inbox') {
+ inboxNodes = await wailsCall('ListInboxNodes') || inboxNodes
+ }
+ }
+
+ async function captureTextPayload(text, source) {
+ const value = String(text || '').trim()
+ if (!value) return null
+ const item = looksLikeURL(value)
+ ? await wailsCall('CaptureURLWithContext', value, '', source, captureContextJSON())
+ : await wailsCall('CaptureTextWithContext', value, source, captureContextJSON())
+ await refreshInboxAfterCapture(item)
+ return item
+ }
+
+ async function captureUrlPayload(url, title, source) {
+ const value = String(url || '').trim()
+ if (!value) return null
+ const item = await wailsCall('CaptureURLWithContext', value, title || '', source, captureContextJSON())
+ await refreshInboxAfterCapture(item)
+ return item
+ }
+
+ async function captureFilePayload(file, source) {
+ if (!file) return null
+ const path = file.path || file.webkitRelativePath || ''
+ if (path) {
+ const item = await wailsCall('CapturePathWithContext', path, source, captureContextJSON())
+ await refreshInboxAfterCapture(item)
+ return item
+ }
+ const data = await blobToBase64(file)
+ const item = await wailsCall('CaptureFileDataWithContext', file.name || `clipboard.${extensionForMime(file.type)}`, data, source, captureContextJSON())
+ await refreshInboxAfterCapture(item)
+ return item
+ }
+
+ function parseMozURL(value) {
+ const lines = String(value || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
+ if (!lines.length) return null
+ return { url: lines[0], title: lines[1] || '' }
+ }
+
+ function parseURIList(value) {
+ const lines = String(value || '').split(/\r?\n/).map(line => line.trim()).filter(line => line && !line.startsWith('#'))
+ return lines[0] || ''
+ }
+
+ async function captureTransferData(dataTransfer, source) {
+ if (!dataTransfer) return false
+ let captured = false
+ const filesList = Array.from(dataTransfer.files || [])
+ for (const file of filesList) {
+ await captureFilePayload(file, source)
+ captured = true
+ }
+ const moz = dataTransfer.getData?.('text/x-moz-url')
+ if (moz) {
+ const parsed = parseMozURL(moz)
+ if (parsed && looksLikeURL(parsed.url)) {
+ await captureUrlPayload(parsed.url, parsed.title, source)
+ return true
+ }
+ }
+ const uri = dataTransfer.getData?.('text/uri-list')
+ if (uri) {
+ const url = parseURIList(uri)
+ if (looksLikeURL(url)) {
+ await captureUrlPayload(url, '', source)
+ return true
+ }
+ }
+ const text = dataTransfer.getData?.('text/plain')
+ if (String(text || '').trim()) {
+ await captureTextPayload(text, source)
+ captured = true
+ }
+ return captured
+ }
+
async function captureClipboard() {
if (inboxCaptureBusy) return
inboxCaptureStatus = ''
- if (!navigator.clipboard || typeof navigator.clipboard.readText !== 'function') {
- error = t('inbox.clipboardUnavailable')
- return
- }
inboxCaptureBusy = true
try {
- if (await captureClipboardFile()) return
- const text = (await navigator.clipboard.readText()).trim()
- if (!text) {
- inboxCaptureStatus = t('inbox.clipboardEmpty')
- return
- }
- const item = looksLikeURL(text)
- ? await wailsCall('CaptureURL', text, '')
- : await wailsCall('CaptureText', text)
- addInboxCapture(item)
+ const item = await wailsCall('CaptureClipboardTextWithContext', captureContextJSON())
+ await refreshInboxAfterCapture(item)
inboxCaptureStatus = t('inbox.captured')
} catch (e) {
- error = String(e)
+ error = String(e).includes('clipboard is empty') ? t('inbox.clipboardEmpty') : t('inbox.clipboardUnavailable')
} finally {
inboxCaptureBusy = false
}
}
- async function captureDroppedPaths(paths) {
+ async function captureDroppedPaths(paths, source = 'drop') {
if (inboxCaptureBusy) return
inboxCaptureBusy = true
inboxCaptureStatus = ''
try {
for (const path of paths) {
- const item = await wailsCall('CapturePath', path)
- addInboxCapture(item)
+ const item = await wailsCall('CapturePathWithContext', path, source, captureContextJSON())
+ await refreshInboxAfterCapture(item)
}
inboxCaptureStatus = t('inbox.captured')
} catch (e) {
@@ -1494,6 +1584,50 @@
inboxDropValid = false
}
}
+ async function handleGlobalPaste(e) {
+ if (showFirstRun || showRecovery) return
+ if (isEditableTarget(e.target)) return
+ if (!e.clipboardData) return
+ try {
+ const captured = await captureTransferData(e.clipboardData, 'paste')
+ if (captured) {
+ e.preventDefault()
+ inboxCaptureStatus = t('inbox.captured')
+ }
+ } catch (err) {
+ error = String(err)
+ }
+ }
+ function hasExternalCaptureData(dataTransfer) {
+ const types = Array.from(dataTransfer?.types || [])
+ return types.includes('Files') ||
+ types.includes('text/uri-list') ||
+ types.includes('text/x-moz-url') ||
+ (types.includes('text/plain') && !types.includes('application/x-verstak-node'))
+ }
+ function handleGlobalDragOver(e) {
+ if (!hasExternalCaptureData(e.dataTransfer)) return
+ e.preventDefault()
+ e.dataTransfer.dropEffect = 'copy'
+ captureDropLabel = captureContextLabel()
+ captureDropActive = true
+ }
+ function handleGlobalDragLeave(e) {
+ if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
+ captureDropActive = false
+ }
+ }
+ async function handleGlobalDrop(e) {
+ if (!hasExternalCaptureData(e.dataTransfer)) return
+ e.preventDefault()
+ captureDropActive = false
+ try {
+ const captured = await captureTransferData(e.dataTransfer, 'drop')
+ if (captured) inboxCaptureStatus = t('inbox.captured')
+ } catch (err) {
+ error = String(err)
+ }
+ }
function handleInboxDragOver(e) {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
@@ -1504,11 +1638,10 @@
}
async function handleInboxDrop(e) {
e.preventDefault()
+ e.stopPropagation()
inboxDropValid = false
- const paths = Array.from(e.dataTransfer?.files || [])
- .map(file => file.path || file.webkitRelativePath || '')
- .filter(Boolean)
- if (paths.length > 0) await captureDroppedPaths(paths)
+ const captured = await captureTransferData(e.dataTransfer, 'drop')
+ if (captured) inboxCaptureStatus = t('inbox.captured')
}
function openAssignInbox(item) {
assignInboxItem = item
@@ -1772,6 +1905,11 @@