From c1dfc456ec8cebb7affe42f25ac4b8a9bd984391 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 5 Jun 2026 07:41:15 +0800 Subject: [PATCH] feat: unify frontend capture pipeline --- frontend/src/App.svelte | 246 ++++++++++++++++++++++------ frontend/src/lib/i18n/locales/en.js | 4 + frontend/src/lib/i18n/locales/ru.js | 4 + scripts/check-gui-render.mjs | 141 ++++++++++++++-- 4 files changed, 325 insertions(+), 70 deletions(-) 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 @@ {:else}
+ {#if captureDropActive} +
+
{captureDropLabel}
+
+ {/if}