feat(android): a selection bar in the list, and Duplicate everywhere

The floating menu was wrong for a list. It closed on the next tap, so opening
an entry afterwards took two gestures and felt like a button had got stuck.

Android's contextual bar instead: a long press selects, the top bar takes the
place of search, says how many rows are held, and offers duplicate and
delete. It goes away when nothing is held. One does not search while choosing
what to delete, and two rows of controls would fight over the same thumb.

Selection is multiple, because the cost is in the bar, not in the work: the
patch closure loops, and ten deletions become ONE publish. The container goes
up whole on every write - deleting ten entries one by one would pay that trip
ten times, and give nine chances to fail halfway.

A tap during selection picks rather than opens, which is also the way out:
unpicking the last row closes the bar. The hardware back cancels the
selection before locking the vault - locking because someone wanted to cancel
a choice is the opposite of what the gesture means.

Both menus stay, with distinct jobs: the bar acts on rows chosen in the list,
the detail's three dots act on the entry in front of you. Duplicate copies
everything, attachments included, as the desktop does - a copy that silently
lost the attached document would be a surprise discovered too late.
This commit is contained in:
r-zakarya
2026-08-22 00:10:38 +01:00
parent 0619740da1
commit ebd4752ec9
3 changed files with 261 additions and 61 deletions
+117 -27
View File
@@ -215,6 +215,15 @@
padding: 14px 16px; }
.menu button:active { background: rgba(255,255,255,.10); }
.menu button.danger { color: #ffb4b4; }
/* LA BARRE DE SELECTION prend la place de la recherche, comme dans les
applications d'Android : on ne cherche pas pendant qu'on choisit quoi
supprimer, et deux rangees de commandes se disputeraient le pouce. */
#selbar { display: none; }
body.selecting #selbar { display: flex; }
body.selecting .top { display: none; }
#selcount { flex: 1; font-size: 16px; font-weight: 600; }
/* La ligne choisie se voit, sinon on supprime a l'aveugle. */
.row.sel { outline: 2px solid var(--accent); outline-offset: -2px; }
.field .line { display: flex; align-items: center; gap: 8px; }
.field .val {
flex: 1; min-width: 0; word-break: break-all;
@@ -402,9 +411,14 @@
</svg>
</button>
</div>
<div class="top" id="selbar">
<button class="ico" id="selCancel" aria-label="Cancel selection"></button>
<div id="selcount"></div>
<button class="ico" id="selDup" aria-label="Duplicate"></button>
<button class="ico" id="selDel" aria-label="Delete"></button>
</div>
<div class="folders" id="folders"></div>
<div class="scroll"><div id="rows"></div></div>
<div class="menu" id="menuList" hidden></div>
</div>
<div class="screen" id="detail">
@@ -599,9 +613,13 @@ document.getElementById('rows').addEventListener('click', function (ev) {
var btn = ev.target.closest('[data-copy]');
if (btn) { send('copy?i=' + row.getAttribute('data-i')
+ '&f=' + btn.getAttribute('data-copy')); return; }
// Le menu vient de s'ouvrir sur cette ligne : le clic qui suit l'appui long
// ouvrirait l'entree DERRIERE le menu.
if (!document.getElementById('menuList').hidden) return;
// EN SELECTION, UN APPUI CHOISIT. Ouvrir une entree pendant qu'on en
// designe d'autres n'a pas de sens, et c'est aussi la sortie : deselectionner
// la derniere ligne referme la barre.
if (document.body.classList.contains('selecting')) {
toggleSel(row.getAttribute('data-i'));
return;
}
send('open?i=' + row.getAttribute('data-i'));
});
@@ -650,6 +668,8 @@ var ICONS = {
edit: '<path d="M4 20h4L20 8l-4-4L4 16z"/>',
eye: '<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/>'
+ '<circle cx="12" cy="12" r="3"/>',
trash: '<path d="M5 7h14M10 7V4.5h4V7M6.5 7l1 12.5h9L18 7"/>'
+ '<path d="M10.5 10.5v6M13.5 10.5v6"/>',
dots: '<circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/>'
+ '<circle cx="12" cy="19" r="1.4"/>',
check: '<path d="M4 12.5l5.5 5.5L20 7"/>',
@@ -1049,23 +1069,18 @@ document.getElementById('new').addEventListener('click', openNewEntry);
// pierre tombale fait disparaitre l'entree du poste aussi, sans passer par la
// sienne. Le seul filet est la sauvegarde du poste.
function closeMenu() {
['menu', 'menuList'].forEach(function (id) {
var m = document.getElementById(id);
if (!m) return;
m.hidden = true;
m.innerHTML = '';
});
var m = document.getElementById('menu');
m.hidden = true;
m.innerHTML = '';
}
/// AMenu vit dans l'ecran courant : le detail a le sien, la liste le sien.
/// ATop, quand il est donne, ancre le menu a la hauteur de la ligne touchee -
/// un menu qui apparait loin du doigt ne se rattache a rien.
function openMenu(AIndex, ATop) {
var m = document.getElementById(view === 'list' ? 'menuList' : 'menu');
m.innerHTML = '<button class="danger" data-del'
+ (AIndex === undefined ? '' : ' data-i="' + AIndex + '"')
+ '>Delete entry</button>';
if (ATop !== undefined) m.style.top = ATop + 'px';
function openMenu() {
var m = document.getElementById('menu');
m.innerHTML = '<button data-dup>Duplicate entry</button>'
+ '<button class="danger" data-del>Delete entry</button>';
m.hidden = false;
}
@@ -1078,6 +1093,11 @@ document.getElementById('kebab').addEventListener('click', function (ev) {
function onMenuClick(ev) {
var b = ev.target.closest('button');
if (!b) return;
if (b.getAttribute('data-dup') !== null) {
closeMenu();
send('dupentry');
return;
}
if (b.getAttribute('data-del') !== null) {
// Le premier appui n'arme que la confirmation. Ouvrir le menu etait deja
// delibere ; ceci ferme la porte a l'appui qui derape dans la liste.
@@ -1091,19 +1111,54 @@ function onMenuClick(ev) {
b.textContent = 'Deleting...';
// L'index designe l'entree quand on supprime depuis la liste. Absent, c'est
// l'entree ouverte - Delphi tient deja laquelle.
var i = b.getAttribute('data-i');
send('delentry' + (i === null ? '' : '?i=' + i));
send('delentry');
}
document.getElementById('menu').addEventListener('click', onMenuClick);
document.getElementById('menuList').addEventListener('click', onMenuClick);
// APPUI LONG POUR SUPPRIMER DEPUIS LA LISTE.
// SELECTIONNER DANS LA LISTE - appui long, puis la barre du haut change.
//
// Le menu flottant qu'il y avait ici se refermait au premier appui suivant,
// donc ouvrir une entree apres coup demandait deux gestes et donnait
// l'impression qu'un bouton restait accroche. La barre contextuelle est le
// modele d'Android : elle prend la place de la recherche, elle dit combien de
// lignes sont prises, et elle disparait quand on n'en a plus.
//
// Ni une croix par ligne - une action sans retour a un pouce d'ecart sur une
// liste qu'on fait defiler - ni un troisieme bouton par ligne, alors qu'on
// vient justement de les degager. L'appui long ne se voit pas tant qu'on ne
// s'en sert pas, c'est la convention d'Android pour agir sur un element de
// liste, et c'est la que se rangera la selection multiple si elle vient.
// vient de les degager.
var selected = {};
function selCount() {
var n = 0, k;
for (k in selected) if (selected[k]) n++;
return n;
}
function renderSel() {
var n = selCount(), i, all = document.querySelectorAll('.row');
document.body.classList.toggle('selecting', n > 0);
document.getElementById('selcount').textContent =
n + (n === 1 ? ' selected' : ' selected');
for (i = 0; i < all.length; i++)
all[i].classList.toggle('sel', !!selected[all[i].getAttribute('data-i')]);
// Delphi doit le savoir : la touche retour ferme la selection avant de
// verrouiller le coffre, sinon un geste d'annulation le fermerait.
send('state?view=' + (n > 0 ? 'select' : 'list'));
}
function clearSel() { selected = {}; renderSel(); }
function toggleSel(i) {
if (selected[i]) delete selected[i]; else selected[i] = true;
renderSel();
}
function selList() {
var out = [], k;
for (k in selected) if (selected[k]) out.push(k);
return out.join(',');
}
(function () {
var timer = null, moved = false;
var rows = document.getElementById('rows');
@@ -1114,10 +1169,9 @@ document.getElementById('menuList').addEventListener('click', onMenuClick);
moved = false;
timer = setTimeout(function () {
if (moved) return;
var r = row.getBoundingClientRect();
openMenu(row.getAttribute('data-i'), Math.round(r.bottom) - 40);
toggleSel(row.getAttribute('data-i'));
send('touch');
}, 600);
}, 500);
});
rows.addEventListener('touchmove', function () { moved = true; clearTimeout(timer); });
['touchend', 'touchcancel'].forEach(function (e) {
@@ -1125,11 +1179,41 @@ document.getElementById('menuList').addEventListener('click', onMenuClick);
});
})();
document.getElementById('selCancel').innerHTML = ico('close', 'Cancel selection');
document.getElementById('selDup').innerHTML = ico('copy', 'Duplicate');
document.getElementById('selDel').innerHTML = ico('trash', 'Delete');
document.getElementById('selCancel').addEventListener('click', clearSel);
document.getElementById('selDup').addEventListener('click', function () {
var l = selList();
if (!l) return;
clearSel();
send('dupentry?i=' + l);
});
document.getElementById('selDel').addEventListener('click', function (ev) {
var b = ev.currentTarget, l = selList();
if (!l) return;
// Deux temps, comme partout ailleurs pour ce geste : il est sans retour.
if (b.getAttribute('data-armed') === null) {
b.setAttribute('data-armed', '1');
b.innerHTML = ico('check', 'Confirm delete');
setTimeout(function () {
b.removeAttribute('data-armed');
b.innerHTML = ico('trash', 'Delete');
}, 4000);
toast('Tap again to delete ' + selCount());
return;
}
b.removeAttribute('data-armed');
b.innerHTML = ico('trash', 'Delete');
clearSel();
send('delentry?i=' + l);
});
// Un appui n'importe ou ailleurs referme le menu - y compris sur le contenu,
// sinon il resterait ouvert pendant qu'on lit l'entree.
document.addEventListener('click', function (ev) {
if (document.getElementById('menu').hidden) return;
if (ev.target.closest('#menu, #menuList, #kebab')) return;
if (ev.target.closest('#menu, #kebab')) return;
closeMenu();
});
@@ -1155,6 +1239,8 @@ var PM = {
q.value = ''; query = '';
document.body.classList.remove('searching', 'on-detail');
document.body.classList.add('on-list');
selected = {};
document.body.classList.remove('selecting');
// LE FORMULAIRE PART AVEC LA VUE, pas seulement de l'ecran. Il garde le
// mot de passe qu'on vient de taper ; le laisser dans le document apres
// l'enregistrement le laisserait la jusqu'au verrouillage.
@@ -1283,6 +1369,10 @@ var PM = {
t._h = setTimeout(function () { t.classList.remove('on'); }, 2600);
},
back: function () { if (view === 'detail') goList(); },
/// La touche retour annule la selection avant de verrouiller le coffre.
/// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce
/// qu'il veut dire.
clearSelection: function () { clearSel(); },
// Efface tout ce qui est en clair. Appele au passage en arriere-plan et au
// verrouillage : le tas JS n'est pas efface par le masquage de la vue.
wipe: function () {