fix(dnd): drop targets only light up for our own entry drags

They accepted any drag then looked for ids in text/plain: dragging text selected in a cell produced NaN, filtered out, so the target invited a drop that could do nothing. Entry drags now carry application/x-pm-entries, mirroring the folder MIME, and all four targets check it before accepting.
This commit is contained in:
r-zakarya
2026-08-01 23:32:47 +01:00
parent 2027d9b820
commit dc7b18736e
2 changed files with 42 additions and 11 deletions
+9 -3
View File
@@ -267,14 +267,20 @@ Symptôme du bug : après relance, lignes au nom vide / `(no name)`.
## D — Vue corbeille ## D — Vue corbeille
### D1 · menu kebab en table view ### D4 · les cibles de dépôt n'acceptent que nos glissers ✅
- **ACTION** : dans la **corbeille** (vue tableau), sélectionner du texte dans une cellule et le glisser sur un dossier, sur *(no folder)*, sur un tag, sur *Trash*.
- **ATTENDU** : **aucune** cible ne s'allume. Les lignes de corbeille ne sont pas déplaçables (`draggable=false`), mais glisser du **texte** reste possible partout dans le navigateur.
- **Historique** : les cibles s'allumaient sur n'importe quel glisser puis lisaient `text/plain` pour y trouver des identifiants — le texte d'une cellule donnait `NaN`, filtré, donc rien ne se passait : une invitation à déposer suivie d'aucune action. Nos glissers d'entrées portent désormais leur propre type `application/x-pm-entries` (comme les dossiers avec `application/x-pm-folder`), et les quatre cibles le vérifient **avant** de s'allumer.
- **Non-régression** : glisser une carte ou une ligne (hors corbeille) sur un dossier, sur *(no folder)*, sur un tag et sur *Trash* fonctionne toujours, sélection multiple comprise.
### D1 · menu kebab en table view ✅
- **ACTION** : corbeille + vue table → kebab d'une ligne - **ACTION** : corbeille + vue table → kebab d'une ligne
- **ATTENDU** : `Restore` / `Copy…` / `Delete forever` **uniquement**. Pas de Pin / Favorite / Edit / Duplicate / Move to trash. - **ATTENDU** : `Restore` / `Copy…` / `Delete forever` **uniquement**. Pas de Pin / Favorite / Edit / Duplicate / Move to trash.
### D2 · vues cartes et liste ### D2 · vues cartes et liste ✅
- **ATTENDU** : boutons restore + purge (déjà le cas avant), pas de drag possible. - **ATTENDU** : boutons restore + purge (déjà le cas avant), pas de drag possible.
### D3 · clic sur une ligne de corbeille ### D3 · clic sur une ligne de corbeille ✅
- **ATTENDU** : ouvre *Trash actions*, **jamais** l'éditeur (une entrée supprimée ne doit pas pouvoir être sauvegardée). - **ATTENDU** : ouvre *Trash actions*, **jamais** l'éditeur (une entrée supprimée ne doit pas pouvoir être sauvegardée).
--- ---
+33 -8
View File
@@ -2128,10 +2128,15 @@ function renderSidebar() {
// Incoming drag: either an entry being moved here, OR another // Incoming drag: either an entry being moved here, OR another
// folder being reordered above/below this one. // folder being reordered above/below this one.
item.addEventListener('dragover', e => { item.addEventListener('dragover', e => {
e.preventDefault();
// dataTransfer.types is DOMStringList in some engines (no // dataTransfer.types is DOMStringList in some engines (no
// .includes) and frozen string[] in others — normalize first. // .includes) and frozen string[] in others — normalize first.
const types = Array.from(e.dataTransfer.types || []); const types = Array.from(e.dataTransfer.types || []);
// Ignore anything that isn't one of OUR drags. Selecting text in a
// cell and dragging it also carries text/plain, which used to light
// this target up for a drop that could never do anything.
if (types.indexOf('application/x-pm-folder') === -1 &&
types.indexOf(DND_ENTRIES) === -1) return;
e.preventDefault();
if (types.indexOf('application/x-pm-folder') !== -1) { if (types.indexOf('application/x-pm-folder') !== -1) {
// Compute insert position based on mouse Y vs item midline. // Compute insert position based on mouse Y vs item midline.
const r = item.getBoundingClientRect(); const r = item.getBoundingClientRect();
@@ -2155,7 +2160,7 @@ function renderSidebar() {
await reorderFolderTo(draggedFolder, name, before); await reorderFolderTo(draggedFolder, name, before);
return; return;
} }
const raw = e.dataTransfer.getData('text/plain') || ''; const raw = e.dataTransfer.getData(DND_ENTRIES) || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0); const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (ids.length) { if (ids.length) {
await moveEntriesToFolder(ids, name); await moveEntriesToFolder(ids, name);
@@ -2204,12 +2209,15 @@ function renderSidebar() {
item.appendChild(el('span', null, '(no folder)')); item.appendChild(el('span', null, '(no folder)'));
item.appendChild(el('span', { class: 'nav-count' }, String(uncatCount))); item.appendChild(el('span', { class: 'nav-count' }, String(uncatCount)));
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); item.addEventListener('dragover', e => {
if (!dndHasEntries(e)) return;
e.preventDefault(); item.classList.add('drag-over');
});
item.addEventListener('dragleave', () => item.classList.remove('drag-over')); item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => { item.addEventListener('drop', async e => {
e.preventDefault(); e.preventDefault();
item.classList.remove('drag-over'); item.classList.remove('drag-over');
const raw = e.dataTransfer.getData('text/plain') || ''; const raw = e.dataTransfer.getData(DND_ENTRIES) || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0); const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (ids.length) { if (ids.length) {
await moveEntriesToFolder(ids, 'All'); await moveEntriesToFolder(ids, 'All');
@@ -2239,12 +2247,15 @@ function renderSidebar() {
item.appendChild(el('span', { class: 'nav-count' }, String(count))); item.appendChild(el('span', { class: 'nav-count' }, String(count)));
// Drop target: drag a card here to add this tag to that entry // Drop target: drag a card here to add this tag to that entry
item.addEventListener('dragover', e => { e.preventDefault(); item.classList.add('drag-over'); }); item.addEventListener('dragover', e => {
if (!dndHasEntries(e)) return;
e.preventDefault(); item.classList.add('drag-over');
});
item.addEventListener('dragleave', () => item.classList.remove('drag-over')); item.addEventListener('dragleave', () => item.classList.remove('drag-over'));
item.addEventListener('drop', async e => { item.addEventListener('drop', async e => {
e.preventDefault(); e.preventDefault();
item.classList.remove('drag-over'); item.classList.remove('drag-over');
const id = parseInt(e.dataTransfer.getData('text/plain')); const id = parseInt(e.dataTransfer.getData(DND_ENTRIES));
if (id) await addTagToEntry(id, t); if (id) await addTagToEntry(id, t);
}); });
@@ -2288,6 +2299,15 @@ async function addTagToEntry(id, tag) {
} catch (err) { toast(err.message, 'error'); } } catch (err) { toast(err.message, 'error'); }
} }
// Our entry drags carry this MIME, mirroring application/x-pm-folder. Dropping
// text selected in a cell also carries text/plain, so relying on that alone lit
// every drop target up for a drop that could never do anything — the entry ids
// simply weren't there. Targets now check the type before accepting.
const DND_ENTRIES = 'application/x-pm-entries';
function dndHasEntries(ev) {
return Array.from(ev.dataTransfer.types || []).indexOf(DND_ENTRIES) !== -1;
}
// A selection belongs to the view it was made in. Carrying it across is never // A selection belongs to the view it was made in. Carrying it across is never
// what the user meant, and between the list and the trash it is dangerous: the // what the user meant, and between the list and the trash it is dangerous: the
// checked ids are LIVE entries while the trash bar offers "Delete forever", so // checked ids are LIVE entries while the trash bar offers "Delete forever", so
@@ -2904,6 +2924,7 @@ function renderCard(e) {
} else { } else {
ids = String(e.id); ids = String(e.id);
} }
ev.dataTransfer.setData(DND_ENTRIES, ids);
ev.dataTransfer.setData('text/plain', ids); ev.dataTransfer.setData('text/plain', ids);
ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.effectAllowed = 'move';
}); });
@@ -3404,6 +3425,7 @@ function renderTableRow(e) {
} else { } else {
ids = String(e.id); ids = String(e.id);
} }
ev.dataTransfer.setData(DND_ENTRIES, ids);
ev.dataTransfer.setData('text/plain', ids); ev.dataTransfer.setData('text/plain', ids);
ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.effectAllowed = 'move';
}); });
@@ -7377,12 +7399,15 @@ async function init() {
// Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it // Drag-to-trash: dropping an entry onto the Trash nav item soft-deletes it
const trashItem = $('#appShell .nav-item[data-view="trash"]'); const trashItem = $('#appShell .nav-item[data-view="trash"]');
if (trashItem) { if (trashItem) {
trashItem.addEventListener('dragover', ev => { ev.preventDefault(); trashItem.classList.add('drag-over'); }); trashItem.addEventListener('dragover', ev => {
if (!dndHasEntries(ev)) return;
ev.preventDefault(); trashItem.classList.add('drag-over');
});
trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over')); trashItem.addEventListener('dragleave', () => trashItem.classList.remove('drag-over'));
trashItem.addEventListener('drop', async ev => { trashItem.addEventListener('drop', async ev => {
ev.preventDefault(); ev.preventDefault();
trashItem.classList.remove('drag-over'); trashItem.classList.remove('drag-over');
const raw = ev.dataTransfer.getData('text/plain') || ''; const raw = ev.dataTransfer.getData(DND_ENTRIES) || '';
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0); const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
if (!ids.length) return; if (!ids.length) return;
let ok = 0; let ok = 0;