fix: reset capture drag state reliably

This commit is contained in:
mirivlad 2026-06-06 02:30:54 +08:00
parent 6033ccffa9
commit cf770262e5
4 changed files with 136 additions and 16 deletions

View File

@ -19,7 +19,7 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-DLPp-1Ge.js"></script>
<script type="module" crossorigin src="/assets/main-Co5H5J-5.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BW6W9uAx.css">
</head>
<body>

View File

@ -164,6 +164,9 @@
let inboxDropValid = false
let captureDropActive = false
let captureDropLabel = ''
let captureDragDepth = 0
let lastCaptureDragOverAt = 0
let captureDragResetTimer = null
let showConfirm = false
let confirmTitle = ''
@ -384,9 +387,16 @@
window.addEventListener('keydown', handleKeydown)
window.addEventListener('paste', handleGlobalPaste)
window.addEventListener('dragenter', handleGlobalDragEnter)
window.addEventListener('dragover', handleGlobalDragOver)
window.addEventListener('dragleave', handleGlobalDragLeave)
window.addEventListener('dragend', resetCaptureDragState)
window.addEventListener('dragcancel', resetCaptureDragState)
window.addEventListener('drop', handleGlobalDrop)
window.addEventListener('mouseup', resetCaptureDragState)
window.addEventListener('mouseleave', resetCaptureDragState)
window.addEventListener('blur', resetCaptureDragState)
document.addEventListener('visibilitychange', handleVisibilityChange)
window.addEventListener('auxclick', handleMouseNav)
window.addEventListener('mouseup', handleMouseNav)
@ -399,11 +409,19 @@
if (unlistenDrop) unlistenDrop()
window.removeEventListener('keydown', handleKeydown)
window.removeEventListener('paste', handleGlobalPaste)
window.removeEventListener('dragenter', handleGlobalDragEnter)
window.removeEventListener('dragover', handleGlobalDragOver)
window.removeEventListener('dragleave', handleGlobalDragLeave)
window.removeEventListener('dragend', resetCaptureDragState)
window.removeEventListener('dragcancel', resetCaptureDragState)
window.removeEventListener('drop', handleGlobalDrop)
window.removeEventListener('mouseup', resetCaptureDragState)
window.removeEventListener('mouseleave', resetCaptureDragState)
window.removeEventListener('blur', resetCaptureDragState)
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('auxclick', handleMouseNav)
window.removeEventListener('mouseup', handleMouseNav)
clearCaptureDragTimer()
})
// ===== System view / Node selection =====
@ -774,6 +792,11 @@
}
function handleKeydown(e) {
if (e.key === 'Escape' && captureDropActive) {
e.preventDefault()
resetCaptureDragState()
return
}
if (isEditableTarget(e.target)) return
if (e.key === 'Backspace' || (e.altKey && e.key === 'ArrowLeft')) {
@ -1734,8 +1757,12 @@
// ===== Drag-and-drop =====
async function onFilesDropped(paths) {
if (!paths || paths.length === 0) return
await captureDroppedPaths(paths, 'drop')
try {
if (!paths || paths.length === 0) return
await captureDroppedPaths(paths, 'drop')
} finally {
resetCaptureDragState()
}
}
// ===== Helpers =====
@ -2005,33 +2032,84 @@
}
}
function hasExternalCaptureData(dataTransfer) {
if (dragIds.length > 0) return false
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'
function clearCaptureDragTimer() {
if (captureDragResetTimer) {
clearTimeout(captureDragResetTimer)
captureDragResetTimer = null
}
}
function resetCaptureDragState() {
captureDragDepth = 0
captureDropActive = false
captureDropLabel = ''
lastCaptureDragOverAt = 0
inboxDropValid = false
clearCaptureDragTimer()
}
function scheduleCaptureDragReset() {
if (captureDragResetTimer) return
captureDragResetTimer = setTimeout(() => {
captureDragResetTimer = null
if (captureDropActive && Date.now() - lastCaptureDragOverAt > 2000) {
resetCaptureDragState()
} else if (captureDropActive) {
scheduleCaptureDragReset()
}
}, 2500)
}
function activateCaptureDrop(dataTransfer) {
if (!hasExternalCaptureData(dataTransfer)) return false
captureDropLabel = captureContextLabel()
captureDropActive = true
lastCaptureDragOverAt = Date.now()
scheduleCaptureDragReset()
return true
}
function handleGlobalDragEnter(e) {
if (!activateCaptureDrop(e.dataTransfer)) return
captureDragDepth += 1
}
function handleGlobalDragOver(e) {
if (!activateCaptureDrop(e.dataTransfer)) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}
function handleGlobalDragLeave(e) {
if (captureDragDepth > 0) captureDragDepth -= 1
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
captureDropActive = false
resetCaptureDragState()
} else if (captureDragDepth <= 0) {
resetCaptureDragState()
}
}
function handleVisibilityChange() {
if (document.hidden) {
resetCaptureDragState()
}
}
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)
} finally {
resetCaptureDragState()
}
}
function handleInboxDragOver(e) {
@ -2045,9 +2123,14 @@
async function handleInboxDrop(e) {
e.preventDefault()
e.stopPropagation()
inboxDropValid = false
const captured = await captureTransferData(e.dataTransfer, 'drop')
if (captured) inboxCaptureStatus = t('inbox.captured')
try {
const captured = await captureTransferData(e.dataTransfer, 'drop')
if (captured) inboxCaptureStatus = t('inbox.captured')
} catch (err) {
error = String(err)
} finally {
resetCaptureDragState()
}
}
function openAssignInbox(item) {
assignInboxItem = item

View File

@ -203,6 +203,16 @@ async function runReadyScenario(cdp, url) {
await clickInboxItemButton(cdp, 'pasted-smoke.png', 'Удалить')
await clickText(cdp, '.overlay .btn', 'Удалить')
await assertEval(cdp, `!document.querySelector('.inbox-screen')?.innerText.includes('pasted-smoke.png')`, 'inbox: deleted item leaves inbox')
await dispatchExternalDrag(cdp)
await waitForSelector(cdp, '.capture-drop-overlay')
await dispatchBodyKeydown(cdp, 'Escape')
await waitForGone(cdp, '.capture-drop-overlay')
await assertEval(cdp, `document.querySelector('.nav-item')?.click && !document.querySelector('.capture-drop-overlay')`, 'drag: Escape resets capture overlay')
await dispatchExternalDrag(cdp)
await waitForSelector(cdp, '.capture-drop-overlay')
await dispatchWindowBlur(cdp)
await waitForGone(cdp, '.capture-drop-overlay')
await assertEval(cdp, `!document.querySelector('.capture-drop-overlay')`, 'drag: blur resets capture overlay')
await screenshot(cdp, 'inbox.png')
await clickText(cdp, '.inbox-item', 'Inbox Smoke Item')
await assertText(cdp, 'Inbox Smoke Item', 'inbox: item opens from list')
@ -211,12 +221,12 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.nav-item', 'Корзина')
await assertText(cdp, 'Trash Smoke Folder', 'trash: deleted node visible')
await assertEval(cdp, `!document.body.innerText.includes('.verstak/trash') && !document.body.innerText.includes('node-trash_Trash-Smoke-Folder')`, 'trash: physical implementation entries are hidden')
await click(cdp, '.trash-row.folder .trash-row-actions .inbox-icon-btn[title="Открыть"]')
await click(cdp, '.trash-row.folder .trash-row-icon')
await assertText(cdp, 'trash-child.txt', 'trash: opening deleted folder shows children immediately')
await assertText(cdp, 'Корзина / Trash Smoke Folder', 'trash: current folder breadcrumb visible')
await dispatchBodyKeydown(cdp, 'Backspace')
await assertEval(cdp, `document.body.innerText.includes('Trash Smoke Folder') && !document.body.innerText.includes('trash-child.txt')`, 'trash: Backspace returns to trash root')
await click(cdp, '.trash-row.folder .trash-row-actions .inbox-icon-btn[title="Открыть"]')
await click(cdp, '.trash-row.folder .trash-row-icon')
await dispatchMouseBack(cdp)
await assertEval(cdp, `document.body.innerText.includes('Trash Smoke Folder') && !document.body.innerText.includes('trash-child.txt')`, 'trash: mouse Back returns to trash root')
await clickText(cdp, '.nav-item', 'Журнал')
@ -318,6 +328,7 @@ async function runReadyScenario(cdp, url) {
await clickText(cdp, '.nav-item', 'Журнал')
await waitForSelector(cdp, '.journal-screen')
await clickText(cdp, '.journal-tab', 'Журнал работы')
await screenshot(cdp, 'journal.png')
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('фильтры')`, 'journal: filter section visible')
await assertEval(cdp, `document.body.innerText.toLowerCase().includes('экспорт отчёта')`, 'journal: export section visible')
@ -528,6 +539,32 @@ async function emitDroppedFiles(cdp, paths) {
await sleep(300)
}
async function dispatchExternalDrag(cdp) {
await cdp.send('Runtime.evaluate', {
expression: `
(() => {
const data = new DataTransfer();
data.items.add(new File(['smoke'], 'drag-smoke.txt', { type: 'text/plain' }));
const enter = new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer: data });
const over = new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: data });
window.dispatchEvent(enter);
window.dispatchEvent(over);
})()
`,
awaitPromise: true,
returnByValue: true,
})
await sleep(150)
}
async function dispatchWindowBlur(cdp) {
await cdp.send('Runtime.evaluate', {
expression: `window.dispatchEvent(new Event('blur'))`,
returnByValue: true,
})
await sleep(150)
}
async function clickFolderOpenButton(cdp, name) {
const ok = await evalValue(cdp, `
(() => {