release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
This commit is contained in:
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Changes 11/10/2025 (v1.9.2)
|
||||||
|
|
||||||
|
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
|
||||||
|
|
||||||
|
- New “Upload file(s)” action in Create menu:
|
||||||
|
- Adds `<li id="uploadOption">` to the dropdown.
|
||||||
|
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
|
||||||
|
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
|
||||||
|
|
||||||
|
- Drag & Drop from file list → Upload:
|
||||||
|
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
|
||||||
|
- On drop, waits until the modal’s #uploadDropArea exists, then relays the drop to it.
|
||||||
|
- Uses a resilient relay: attempts to attach DataTransfer to a synthetic event; falls back to a stash.
|
||||||
|
|
||||||
|
- Synthetic drop fallback:
|
||||||
|
- Introduces window.__pendingDropData (cleared after use).
|
||||||
|
- upload.js now reads e.dataTransfer || window.__pendingDropData to accept relayed drops across browsers.
|
||||||
|
|
||||||
|
- Implementation details:
|
||||||
|
- fileActions.js: adds openUploadModal()/closeUploadModal() with a hidden sentinel to return #uploadCard to its original place on close.
|
||||||
|
- appCore.js: imports openUploadModal, adds waitFor() helper, and wires dragover/leave/drop logic for the relay.
|
||||||
|
- index.html: adds Upload option to the Create menu and the #uploadModal scaffold.
|
||||||
|
|
||||||
|
- UX/Safety:
|
||||||
|
- Defensive checks if modal/card isn’t present.
|
||||||
|
- No backend/API changes; CSRF/auth unchanged.
|
||||||
|
|
||||||
|
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Changes 11/9/2025 (v1.9.1)
|
## Changes 11/9/2025 (v1.9.1)
|
||||||
|
|
||||||
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
|
||||||
|
|||||||
@@ -355,6 +355,10 @@
|
|||||||
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||||
<span data-i18n-key="create_folder">Create folder</span>
|
<span data-i18n-key="create_folder">Create folder</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||||
|
<span data-i18n-key="upload">Upload file(s)</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<!-- Create File Modal -->
|
<!-- Create File Modal -->
|
||||||
@@ -494,6 +498,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div id="uploadModal" class="modal" style="display:none;">
|
||||||
|
<div class="modal-content" style="max-width:900px;width:92vw;">
|
||||||
|
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<h3 style="margin:0;">Upload</h3>
|
||||||
|
<span id="closeUploadModal" class="editor-close-btn" role="button" aria-label="Close">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- we will MOVE #uploadCard into here while open -->
|
||||||
|
<div id="uploadModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -5,10 +5,24 @@ import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
|
|||||||
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
|
||||||
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
|
||||||
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
|
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
|
||||||
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
|
import { initFileActions, openUploadModal } from './fileActions.js?v={{APP_QVER}}';
|
||||||
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
import { initUpload } from './upload.js?v={{APP_QVER}}';
|
||||||
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||||
|
|
||||||
|
window.__pendingDropData = null;
|
||||||
|
|
||||||
|
function waitFor(selector, timeout = 1200) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const t0 = performance.now();
|
||||||
|
(function tick() {
|
||||||
|
const el = document.querySelector(selector);
|
||||||
|
if (el) return resolve(el);
|
||||||
|
if (performance.now() - t0 >= timeout) return resolve(null);
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
|
||||||
const _nativeFetch = window.fetch.bind(window);
|
const _nativeFetch = window.fetch.bind(window);
|
||||||
|
|
||||||
@@ -84,25 +98,53 @@ export function initializeApp() {
|
|||||||
// Enable tag search UI; initial file list load is controlled elsewhere
|
// Enable tag search UI; initial file list load is controlled elsewhere
|
||||||
initTagSearch();
|
initTagSearch();
|
||||||
|
|
||||||
|
|
||||||
// Hook DnD relay from fileList area into upload area
|
// Hook DnD relay from fileList area into upload area
|
||||||
const fileListArea = document.getElementById('fileListContainer');
|
const fileListArea = document.getElementById('fileListContainer');
|
||||||
const uploadArea = document.getElementById('uploadDropArea');
|
|
||||||
if (fileListArea && uploadArea) {
|
if (fileListArea) {
|
||||||
|
let hoverTimer = null;
|
||||||
|
|
||||||
fileListArea.addEventListener('dragover', e => {
|
fileListArea.addEventListener('dragover', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fileListArea.classList.add('drop-hover');
|
fileListArea.classList.add('drop-hover');
|
||||||
|
// (optional) auto-open after brief hover so users see the drop target
|
||||||
|
if (!hoverTimer) {
|
||||||
|
hoverTimer = setTimeout(() => {
|
||||||
|
if (typeof window.openUploadModal === 'function') window.openUploadModal();
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fileListArea.addEventListener('dragleave', () => {
|
fileListArea.addEventListener('dragleave', () => {
|
||||||
fileListArea.classList.remove('drop-hover');
|
fileListArea.classList.remove('drop-hover');
|
||||||
|
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||||
});
|
});
|
||||||
fileListArea.addEventListener('drop', e => {
|
|
||||||
|
fileListArea.addEventListener('drop', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
fileListArea.classList.remove('drop-hover');
|
fileListArea.classList.remove('drop-hover');
|
||||||
uploadArea.dispatchEvent(new DragEvent('drop', {
|
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
|
||||||
dataTransfer: e.dataTransfer,
|
|
||||||
bubbles: true,
|
// 1) open the same modal that the Create menu uses
|
||||||
cancelable: true
|
openUploadModal();
|
||||||
}));
|
// 2) wait until the upload area exists *in the modal*, then relay the drop
|
||||||
|
// Prefer a scoped selector first to avoid duplicate IDs.
|
||||||
|
const uploadArea =
|
||||||
|
(await waitFor('#uploadModal #uploadDropArea')) ||
|
||||||
|
(await waitFor('#uploadDropArea'));
|
||||||
|
if (!uploadArea) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Many browsers make dataTransfer read-only; we try the direct attach first
|
||||||
|
const relay = new DragEvent('drop', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(relay, 'dataTransfer', { value: e.dataTransfer });
|
||||||
|
uploadArea.dispatchEvent(relay);
|
||||||
|
} catch {
|
||||||
|
// Fallback: stash DataTransfer and fire a plain event; handler will read the stash
|
||||||
|
window.__pendingDropData = e.dataTransfer || null;
|
||||||
|
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export function handleDeleteSelected(e) {
|
|||||||
showToast("no_files_selected");
|
showToast("no_files_selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
|
||||||
const count = window.filesToDelete.length;
|
const count = window.filesToDelete.length;
|
||||||
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
|
||||||
@@ -21,6 +20,52 @@ export function handleDeleteSelected(e) {
|
|||||||
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Upload modal "portal" support ---
|
||||||
|
let _uploadCardSentinel = null;
|
||||||
|
|
||||||
|
export function openUploadModal() {
|
||||||
|
const modal = document.getElementById('uploadModal');
|
||||||
|
const body = document.getElementById('uploadModalBody');
|
||||||
|
const card = document.getElementById('uploadCard'); // <-- your existing card
|
||||||
|
window.openUploadModal = openUploadModal;
|
||||||
|
window.__pendingDropData = null;
|
||||||
|
if (!modal || !body || !card) {
|
||||||
|
console.warn('Upload modal or upload card not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a hidden sentinel so we can put the card back in place later
|
||||||
|
if (!_uploadCardSentinel) {
|
||||||
|
_uploadCardSentinel = document.createElement('div');
|
||||||
|
_uploadCardSentinel.id = 'uploadCardSentinel';
|
||||||
|
_uploadCardSentinel.style.display = 'none';
|
||||||
|
card.parentNode.insertBefore(_uploadCardSentinel, card);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move the actual card node into the modal (keeps all existing listeners)
|
||||||
|
body.appendChild(card);
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
modal.style.display = 'block';
|
||||||
|
|
||||||
|
// Focus the chooser for quick keyboard flow
|
||||||
|
setTimeout(() => {
|
||||||
|
const chooseBtn = document.getElementById('customChooseBtn');
|
||||||
|
if (chooseBtn) chooseBtn.focus();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeUploadModal() {
|
||||||
|
const modal = document.getElementById('uploadModal');
|
||||||
|
const card = document.getElementById('uploadCard');
|
||||||
|
|
||||||
|
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
|
||||||
|
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
|
||||||
|
}
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
const cancelDelete = document.getElementById("cancelDeleteFiles");
|
||||||
if (cancelDelete) {
|
if (cancelDelete) {
|
||||||
@@ -829,6 +874,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const menu = document.getElementById('createMenu');
|
const menu = document.getElementById('createMenu');
|
||||||
const fileOpt = document.getElementById('createFileOption');
|
const fileOpt = document.getElementById('createFileOption');
|
||||||
const folderOpt = document.getElementById('createFolderOption');
|
const folderOpt = document.getElementById('createFolderOption');
|
||||||
|
const uploadOpt = document.getElementById('uploadOption'); // NEW
|
||||||
|
|
||||||
// Toggle dropdown on click
|
// Toggle dropdown on click
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@@ -853,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.addEventListener('click', () => {
|
document.addEventListener('click', () => {
|
||||||
menu.style.display = 'none';
|
menu.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
if (uploadOpt) {
|
||||||
|
uploadOpt.addEventListener('click', () => {
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
openUploadModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close buttons / backdrop
|
||||||
|
const upModal = document.getElementById('uploadModal');
|
||||||
|
const closeX = document.getElementById('closeUploadModal');
|
||||||
|
|
||||||
|
if (closeX) closeX.addEventListener('click', closeUploadModal);
|
||||||
|
|
||||||
|
// click outside content to close
|
||||||
|
if (upModal) {
|
||||||
|
upModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === upModal) closeUploadModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && upModal && upModal.style.display === 'block') {
|
||||||
|
closeUploadModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.renameFile = renameFile;
|
window.renameFile = renameFile;
|
||||||
@@ -896,7 +896,8 @@ function initUpload() {
|
|||||||
dropArea.addEventListener("drop", function (e) {
|
dropArea.addEventListener("drop", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
dropArea.style.backgroundColor = "";
|
dropArea.style.backgroundColor = "";
|
||||||
const dt = e.dataTransfer;
|
const dt = e.dataTransfer || window.__pendingDropData || null;
|
||||||
|
window.__pendingDropData = null;
|
||||||
if (dt.items && dt.items.length > 0) {
|
if (dt.items && dt.items.length > 0) {
|
||||||
getFilesFromDataTransferItems(dt.items).then(files => {
|
getFilesFromDataTransferItems(dt.items).then(files => {
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user