diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index ab44a4dd..c47a5626 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -3112,7 +3112,7 @@ document.addEventListener("DOMContentLoaded", async function () { let documentSidebarRenderBudget = null; let documentSidebarInitialized = false; let isDocumentSidebarResizing = false; - let draggedSidebarDocumentId = null; + let draggedSidebarDocumentIds = []; let activeDocumentDragPreview = null; let documentTreeDragExpandTimer = null; let documentTreeDragExpandRow = null; @@ -5007,8 +5007,8 @@ document.addEventListener("DOMContentLoaded", async function () { } function clearDocumentDropTargets() { - document.querySelectorAll('#document-tree .document-tree-row.is-drop-target').forEach(function(row) { - row.classList.remove('is-drop-target'); + document.querySelectorAll('#document-tree .is-drop-target').forEach(function(target) { + target.classList.remove('is-drop-target'); }); } @@ -5101,28 +5101,29 @@ document.addEventListener("DOMContentLoaded", async function () { activeDocumentDragPreview = null; } - function createDocumentDragPreview(title) { + function createDocumentDragPreview(title, count) { removeDocumentDragPreview(); + const fileCount = Math.max(1, Number(count) || 1); const normalizedTitle = String(title || 'Untitled').trim() || 'Untitled'; const filename = /\.md$/i.test(normalizedTitle) ? normalizedTitle : normalizedTitle + '.md'; const preview = document.createElement('div'); - preview.className = 'document-drag-preview'; + preview.className = 'document-drag-preview' + (fileCount > 1 ? ' is-multiple' : ''); preview.setAttribute('aria-hidden', 'true'); const page = document.createElement('span'); page.className = 'document-drag-preview-page'; const icon = document.createElement('i'); - icon.className = 'lucide lucide-download'; + icon.className = 'lucide ' + (fileCount > 1 ? 'lucide-files' : 'lucide-file-text'); icon.setAttribute('aria-hidden', 'true'); const badge = document.createElement('span'); badge.className = 'document-drag-preview-badge'; - badge.textContent = 'MD'; + badge.textContent = fileCount > 1 ? String(fileCount) : 'MD'; page.appendChild(icon); page.appendChild(badge); const label = document.createElement('span'); label.className = 'document-drag-preview-label'; - label.textContent = filename; + label.textContent = fileCount > 1 ? fileCount + ' files' : filename; preview.appendChild(page); preview.appendChild(label); document.body.appendChild(preview); @@ -5130,32 +5131,56 @@ document.addEventListener("DOMContentLoaded", async function () { return preview; } - function attachDocumentDropTarget(row, location) { - if (!row || !location) return; - row.setAttribute('data-drop-workspace-id', location.workspaceId); - if (location.folderId) row.setAttribute('data-drop-folder-id', location.folderId); + function getDraggedSidebarDocumentIds(dataTransfer) { + if (draggedSidebarDocumentIds.length) return draggedSidebarDocumentIds.slice(); + if (!dataTransfer) return []; + let documentIds = []; + try { + const encodedIds = dataTransfer.getData('application/x-markdown-viewer-documents'); + const parsedIds = encodedIds ? JSON.parse(encodedIds) : []; + if (Array.isArray(parsedIds)) documentIds = parsedIds; + } catch (_) {} + if (!documentIds.length) { + const legacyId = dataTransfer.getData('application/x-markdown-viewer-document'); + if (legacyId) documentIds = [legacyId]; + } + return Array.from(new Set(documentIds)).filter(function(tabId) { + return tabs.some(function(tab) { return tab.id === tabId && !isTemporaryDocument(tab); }); + }); + } - row.addEventListener('dragover', function(event) { + function attachDocumentDropTarget(target, location) { + if (!target || !location) return; + target.setAttribute('data-drop-workspace-id', location.workspaceId); + if (location.folderId) target.setAttribute('data-drop-folder-id', location.folderId); + + function isClosestDropSurface(event) { + return event.target.closest('[data-drop-workspace-id]') === target; + } + + target.addEventListener('dragover', function(event) { + if (!isClosestDropSurface(event)) return; const transferTypes = event.dataTransfer && event.dataTransfer.types ? Array.from(event.dataTransfer.types) : []; const hasFiles = transferTypes.includes('Files') || Boolean(event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length); - if (!draggedSidebarDocumentId && !hasFiles) return; + if (!draggedSidebarDocumentIds.length && !hasFiles) return; event.preventDefault(); - if (event.dataTransfer) event.dataTransfer.dropEffect = draggedSidebarDocumentId ? 'move' : 'copy'; + if (event.dataTransfer) event.dataTransfer.dropEffect = draggedSidebarDocumentIds.length ? 'move' : 'copy'; clearDocumentDropTargets(); - row.classList.add('is-drop-target'); - scheduleDocumentTreeDragExpansion(row, location); + target.classList.add('is-drop-target'); + scheduleDocumentTreeDragExpansion(target, location); }); - row.addEventListener('dragleave', function(event) { - if (!event.relatedTarget || !row.contains(event.relatedTarget)) { - row.classList.remove('is-drop-target'); - if (documentTreeDragExpandRow === row) clearDocumentTreeDragExpansion(); + target.addEventListener('dragleave', function(event) { + if (!event.relatedTarget || !target.contains(event.relatedTarget)) { + target.classList.remove('is-drop-target'); + if (documentTreeDragExpandRow === target) clearDocumentTreeDragExpansion(); } }); - row.addEventListener('drop', function(event) { + target.addEventListener('drop', function(event) { + if (!isClosestDropSurface(event)) return; event.preventDefault(); event.stopPropagation(); resetDocumentTreeDragFeedback(); @@ -5176,8 +5201,9 @@ document.addEventListener("DOMContentLoaded", async function () { } return; } - const tabId = draggedSidebarDocumentId || (event.dataTransfer && event.dataTransfer.getData('application/x-markdown-viewer-document')); - if (tabId) moveDocumentToLocation(tabId, location.workspaceId, location.folderId || null); + const tabIds = getDraggedSidebarDocumentIds(event.dataTransfer); + if (tabIds.length > 1) moveDocumentsToLocation(tabIds, location.workspaceId, location.folderId || null); + else if (tabIds.length === 1) moveDocumentToLocation(tabIds[0], location.workspaceId, location.folderId || null); }); } @@ -5277,21 +5303,31 @@ document.addEventListener("DOMContentLoaded", async function () { if (options.documentId && !options.temporary) { row.draggable = true; row.addEventListener('dragstart', function(event) { - draggedSidebarDocumentId = options.documentId; - row.classList.add('is-dragging'); + const rowSelectionKey = getDocumentTreeSelectionKey('document', options.documentId); + const selectedDocuments = selectedDocumentTreeIds.has(rowSelectionKey) ? getSelectedDocuments() : []; + draggedSidebarDocumentIds = selectedDocuments.length > 1 + ? selectedDocuments.map(function(tab) { return tab.id; }) + : [options.documentId]; + if (draggedSidebarDocumentIds.length === 1) setSingleDocumentTreeSelection('document', options.documentId); + document.querySelectorAll('#document-tree .document-tree-row[data-document-id]').forEach(function(documentRow) { + documentRow.classList.toggle('is-dragging', draggedSidebarDocumentIds.includes(documentRow.getAttribute('data-document-id'))); + }); if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-markdown-viewer-document', options.documentId); - event.dataTransfer.setData('text/plain', options.documentId); - const preview = createDocumentDragPreview(options.label); + event.dataTransfer.setData('application/x-markdown-viewer-documents', JSON.stringify(draggedSidebarDocumentIds)); + event.dataTransfer.setData('text/plain', draggedSidebarDocumentIds.join('\n')); + const preview = createDocumentDragPreview(options.label, draggedSidebarDocumentIds.length); try { event.dataTransfer.setDragImage(preview, 36, 34); } catch (_) {} } }); row.addEventListener('dragend', function() { - draggedSidebarDocumentId = null; - row.classList.remove('is-dragging'); + draggedSidebarDocumentIds = []; + document.querySelectorAll('#document-tree .document-tree-row.is-dragging').forEach(function(documentRow) { + documentRow.classList.remove('is-dragging'); + }); removeDocumentDragPreview(); resetDocumentTreeDragFeedback(); }); @@ -5448,6 +5484,7 @@ document.addEventListener("DOMContentLoaded", async function () { workspaceGroup.className = 'document-tree-group document-tree-group--workspace'; workspaceGroup.setAttribute('role', 'group'); workspaceGroup.hidden = !expanded; + attachDocumentDropTarget(workspaceGroup, { workspaceId: workspace.id, folderId: null }); const sortedFolders = folders.sort(function(left, right) { return left.createdAt - right.createdAt || left.name.localeCompare(right.name); }); const foldersByParent = new Map(); @@ -5499,6 +5536,7 @@ document.addEventListener("DOMContentLoaded", async function () { folderGroup.className = 'document-tree-group document-tree-group--folder'; folderGroup.setAttribute('role', 'group'); folderGroup.hidden = !folderExpanded; + attachDocumentDropTarget(folderGroup, { workspaceId: workspace.id, folderId: folder.id }); (foldersByParent.get(folder.id) || []).forEach(function(child) { appendFolderBranch(child, folderGroup, depth + 1); }); diff --git a/desktop-app/resources/styles.css b/desktop-app/resources/styles.css index 43712d34..fe3f611f 100644 --- a/desktop-app/resources/styles.css +++ b/desktop-app/resources/styles.css @@ -616,6 +616,10 @@ body { color: #52606f; } +.document-drag-preview.is-multiple .document-drag-preview-page { + box-shadow: -3px 3px 0 -1px var(--toolbar-surface), -3px 3px 0 0 var(--border-color), 0 5px 10px rgba(31, 35, 40, 0.2); +} + .document-drag-preview-page::after { position: absolute; top: -1px; @@ -668,6 +672,16 @@ body { box-shadow: inset 0 0 0 1px var(--accent-color); } +.document-tree-group[data-drop-folder-id]:not([hidden]):empty { + min-height: 12px; +} + +.document-tree-group.is-drop-target { + border-radius: 4px; + background: color-mix(in srgb, var(--accent-color) 9%, transparent); + box-shadow: inset 0 0 0 1px var(--accent-color); +} + .document-tree-row.is-secret-locked .document-tree-main > i, .document-tree-row.is-secret-locked .document-tree-meta { color: var(--text-secondary); diff --git a/script.js b/script.js index ab44a4dd..c47a5626 100644 --- a/script.js +++ b/script.js @@ -3112,7 +3112,7 @@ document.addEventListener("DOMContentLoaded", async function () { let documentSidebarRenderBudget = null; let documentSidebarInitialized = false; let isDocumentSidebarResizing = false; - let draggedSidebarDocumentId = null; + let draggedSidebarDocumentIds = []; let activeDocumentDragPreview = null; let documentTreeDragExpandTimer = null; let documentTreeDragExpandRow = null; @@ -5007,8 +5007,8 @@ document.addEventListener("DOMContentLoaded", async function () { } function clearDocumentDropTargets() { - document.querySelectorAll('#document-tree .document-tree-row.is-drop-target').forEach(function(row) { - row.classList.remove('is-drop-target'); + document.querySelectorAll('#document-tree .is-drop-target').forEach(function(target) { + target.classList.remove('is-drop-target'); }); } @@ -5101,28 +5101,29 @@ document.addEventListener("DOMContentLoaded", async function () { activeDocumentDragPreview = null; } - function createDocumentDragPreview(title) { + function createDocumentDragPreview(title, count) { removeDocumentDragPreview(); + const fileCount = Math.max(1, Number(count) || 1); const normalizedTitle = String(title || 'Untitled').trim() || 'Untitled'; const filename = /\.md$/i.test(normalizedTitle) ? normalizedTitle : normalizedTitle + '.md'; const preview = document.createElement('div'); - preview.className = 'document-drag-preview'; + preview.className = 'document-drag-preview' + (fileCount > 1 ? ' is-multiple' : ''); preview.setAttribute('aria-hidden', 'true'); const page = document.createElement('span'); page.className = 'document-drag-preview-page'; const icon = document.createElement('i'); - icon.className = 'lucide lucide-download'; + icon.className = 'lucide ' + (fileCount > 1 ? 'lucide-files' : 'lucide-file-text'); icon.setAttribute('aria-hidden', 'true'); const badge = document.createElement('span'); badge.className = 'document-drag-preview-badge'; - badge.textContent = 'MD'; + badge.textContent = fileCount > 1 ? String(fileCount) : 'MD'; page.appendChild(icon); page.appendChild(badge); const label = document.createElement('span'); label.className = 'document-drag-preview-label'; - label.textContent = filename; + label.textContent = fileCount > 1 ? fileCount + ' files' : filename; preview.appendChild(page); preview.appendChild(label); document.body.appendChild(preview); @@ -5130,32 +5131,56 @@ document.addEventListener("DOMContentLoaded", async function () { return preview; } - function attachDocumentDropTarget(row, location) { - if (!row || !location) return; - row.setAttribute('data-drop-workspace-id', location.workspaceId); - if (location.folderId) row.setAttribute('data-drop-folder-id', location.folderId); + function getDraggedSidebarDocumentIds(dataTransfer) { + if (draggedSidebarDocumentIds.length) return draggedSidebarDocumentIds.slice(); + if (!dataTransfer) return []; + let documentIds = []; + try { + const encodedIds = dataTransfer.getData('application/x-markdown-viewer-documents'); + const parsedIds = encodedIds ? JSON.parse(encodedIds) : []; + if (Array.isArray(parsedIds)) documentIds = parsedIds; + } catch (_) {} + if (!documentIds.length) { + const legacyId = dataTransfer.getData('application/x-markdown-viewer-document'); + if (legacyId) documentIds = [legacyId]; + } + return Array.from(new Set(documentIds)).filter(function(tabId) { + return tabs.some(function(tab) { return tab.id === tabId && !isTemporaryDocument(tab); }); + }); + } - row.addEventListener('dragover', function(event) { + function attachDocumentDropTarget(target, location) { + if (!target || !location) return; + target.setAttribute('data-drop-workspace-id', location.workspaceId); + if (location.folderId) target.setAttribute('data-drop-folder-id', location.folderId); + + function isClosestDropSurface(event) { + return event.target.closest('[data-drop-workspace-id]') === target; + } + + target.addEventListener('dragover', function(event) { + if (!isClosestDropSurface(event)) return; const transferTypes = event.dataTransfer && event.dataTransfer.types ? Array.from(event.dataTransfer.types) : []; const hasFiles = transferTypes.includes('Files') || Boolean(event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length); - if (!draggedSidebarDocumentId && !hasFiles) return; + if (!draggedSidebarDocumentIds.length && !hasFiles) return; event.preventDefault(); - if (event.dataTransfer) event.dataTransfer.dropEffect = draggedSidebarDocumentId ? 'move' : 'copy'; + if (event.dataTransfer) event.dataTransfer.dropEffect = draggedSidebarDocumentIds.length ? 'move' : 'copy'; clearDocumentDropTargets(); - row.classList.add('is-drop-target'); - scheduleDocumentTreeDragExpansion(row, location); + target.classList.add('is-drop-target'); + scheduleDocumentTreeDragExpansion(target, location); }); - row.addEventListener('dragleave', function(event) { - if (!event.relatedTarget || !row.contains(event.relatedTarget)) { - row.classList.remove('is-drop-target'); - if (documentTreeDragExpandRow === row) clearDocumentTreeDragExpansion(); + target.addEventListener('dragleave', function(event) { + if (!event.relatedTarget || !target.contains(event.relatedTarget)) { + target.classList.remove('is-drop-target'); + if (documentTreeDragExpandRow === target) clearDocumentTreeDragExpansion(); } }); - row.addEventListener('drop', function(event) { + target.addEventListener('drop', function(event) { + if (!isClosestDropSurface(event)) return; event.preventDefault(); event.stopPropagation(); resetDocumentTreeDragFeedback(); @@ -5176,8 +5201,9 @@ document.addEventListener("DOMContentLoaded", async function () { } return; } - const tabId = draggedSidebarDocumentId || (event.dataTransfer && event.dataTransfer.getData('application/x-markdown-viewer-document')); - if (tabId) moveDocumentToLocation(tabId, location.workspaceId, location.folderId || null); + const tabIds = getDraggedSidebarDocumentIds(event.dataTransfer); + if (tabIds.length > 1) moveDocumentsToLocation(tabIds, location.workspaceId, location.folderId || null); + else if (tabIds.length === 1) moveDocumentToLocation(tabIds[0], location.workspaceId, location.folderId || null); }); } @@ -5277,21 +5303,31 @@ document.addEventListener("DOMContentLoaded", async function () { if (options.documentId && !options.temporary) { row.draggable = true; row.addEventListener('dragstart', function(event) { - draggedSidebarDocumentId = options.documentId; - row.classList.add('is-dragging'); + const rowSelectionKey = getDocumentTreeSelectionKey('document', options.documentId); + const selectedDocuments = selectedDocumentTreeIds.has(rowSelectionKey) ? getSelectedDocuments() : []; + draggedSidebarDocumentIds = selectedDocuments.length > 1 + ? selectedDocuments.map(function(tab) { return tab.id; }) + : [options.documentId]; + if (draggedSidebarDocumentIds.length === 1) setSingleDocumentTreeSelection('document', options.documentId); + document.querySelectorAll('#document-tree .document-tree-row[data-document-id]').forEach(function(documentRow) { + documentRow.classList.toggle('is-dragging', draggedSidebarDocumentIds.includes(documentRow.getAttribute('data-document-id'))); + }); if (event.dataTransfer) { event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.setData('application/x-markdown-viewer-document', options.documentId); - event.dataTransfer.setData('text/plain', options.documentId); - const preview = createDocumentDragPreview(options.label); + event.dataTransfer.setData('application/x-markdown-viewer-documents', JSON.stringify(draggedSidebarDocumentIds)); + event.dataTransfer.setData('text/plain', draggedSidebarDocumentIds.join('\n')); + const preview = createDocumentDragPreview(options.label, draggedSidebarDocumentIds.length); try { event.dataTransfer.setDragImage(preview, 36, 34); } catch (_) {} } }); row.addEventListener('dragend', function() { - draggedSidebarDocumentId = null; - row.classList.remove('is-dragging'); + draggedSidebarDocumentIds = []; + document.querySelectorAll('#document-tree .document-tree-row.is-dragging').forEach(function(documentRow) { + documentRow.classList.remove('is-dragging'); + }); removeDocumentDragPreview(); resetDocumentTreeDragFeedback(); }); @@ -5448,6 +5484,7 @@ document.addEventListener("DOMContentLoaded", async function () { workspaceGroup.className = 'document-tree-group document-tree-group--workspace'; workspaceGroup.setAttribute('role', 'group'); workspaceGroup.hidden = !expanded; + attachDocumentDropTarget(workspaceGroup, { workspaceId: workspace.id, folderId: null }); const sortedFolders = folders.sort(function(left, right) { return left.createdAt - right.createdAt || left.name.localeCompare(right.name); }); const foldersByParent = new Map(); @@ -5499,6 +5536,7 @@ document.addEventListener("DOMContentLoaded", async function () { folderGroup.className = 'document-tree-group document-tree-group--folder'; folderGroup.setAttribute('role', 'group'); folderGroup.hidden = !folderExpanded; + attachDocumentDropTarget(folderGroup, { workspaceId: workspace.id, folderId: folder.id }); (foldersByParent.get(folder.id) || []).forEach(function(child) { appendFolderBranch(child, folderGroup, depth + 1); }); diff --git a/styles.css b/styles.css index 43712d34..fe3f611f 100644 --- a/styles.css +++ b/styles.css @@ -616,6 +616,10 @@ body { color: #52606f; } +.document-drag-preview.is-multiple .document-drag-preview-page { + box-shadow: -3px 3px 0 -1px var(--toolbar-surface), -3px 3px 0 0 var(--border-color), 0 5px 10px rgba(31, 35, 40, 0.2); +} + .document-drag-preview-page::after { position: absolute; top: -1px; @@ -668,6 +672,16 @@ body { box-shadow: inset 0 0 0 1px var(--accent-color); } +.document-tree-group[data-drop-folder-id]:not([hidden]):empty { + min-height: 12px; +} + +.document-tree-group.is-drop-target { + border-radius: 4px; + background: color-mix(in srgb, var(--accent-color) 9%, transparent); + box-shadow: inset 0 0 0 1px var(--accent-color); +} + .document-tree-row.is-secret-locked .document-tree-main > i, .document-tree-row.is-secret-locked .document-tree-meta { color: var(--text-secondary); diff --git a/wiki/Usage-Guide.md b/wiki/Usage-Guide.md index 1a668dc8..8753f745 100644 --- a/wiki/Usage-Guide.md +++ b/wiki/Usage-Guide.md @@ -25,6 +25,7 @@ To compare or edit two files, open a file menu and choose **Open in split view** - Select Workspace, Secret Workspace, or a folder, then use the clearly labeled **New file** or **New folder** button. The selected row shows where the item will be created; the plus menu beside an unlocked workspace or folder creates directly inside it. - Workspace roots are fixed. Create nested folders inside **Workspace** or the password-protected **Secret Workspace**. - The first time Secret Workspace is opened, create a password of at least eight characters. Files and folder names are encrypted locally. Use its menu to lock it when finished; the password cannot be recovered. +- When multiple files are selected, dragging any selected file moves the full selection and the drag preview shows the file count. Expanded folders accept drops across their visible contents, not only on the folder name. - Drag a file row onto a folder or workspace to move it. Use **Move to…** from the file menu as the keyboard and touch-friendly alternative. Dropping local Markdown files onto a folder imports them there. - Use **All files** for the hierarchy, **Recent** for recently opened or edited files, and **Favorites** for starred files. - On desktop, one click selects a sidebar document and a double click opens it. On touch layouts, one tap opens it and closes the drawer.