fix(android): the tap lands on the drawing, not on the button

Copying a password or a username opened the entry instead - about half the
time, which is the worst kind of failure to diagnose. Since those buttons
carry an icon, the tap target is the <svg> or one of its strokes, so reading
the attribute off the target returned null and the handler fell through to
"open". A finger landing sometimes on the glyph and sometimes on the margin
made it look random. Both handlers now resolve to the button first.

The new-entry form gets a folder, like the editor: an entry filed from the
start saves having to move it.

And a folder can now be created, not only chosen. Filing an entry under a
name is not enough - it is `folders` that fills the desktop's column, and a
name missing from it leaves the entry in a folder nothing displays, looking
lost. The two now go together, in one place, for both editing and creation.
'All' is never registered: it is the default, not a folder.
This commit is contained in:
r-zakarya
2026-08-21 22:11:14 +01:00
parent 0badc5fcfc
commit 1dba5d392f
3 changed files with 96 additions and 11 deletions
+37 -3
View File
@@ -49,7 +49,7 @@ function NewEmptySnapshot(const AUsername: string): string;
/// ///
/// Un uuid neuf, et lui seul : pas de tombstone, on ne supprime pas encore. /// Un uuid neuf, et lui seul : pas de tombstone, on ne supprime pas encore.
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
AKind: string): string; AKind, AFolder: string): string;
/// Retire une entree de l'instantane et laisse une PIERRE TOMBALE a sa place. /// Retire une entree de l'instantane et laisse une PIERRE TOMBALE a sa place.
/// ///
@@ -142,6 +142,37 @@ end;
/// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et /// Copie profonde d'un objet JSON : le reparser est le moyen le plus court, et
/// le seul qui n'oublie aucun champ. /// le seul qui n'oublie aucun champ.
/// Inscrit un dossier dans la liste du coffre s'il n'y figure pas deja.
///
/// Ranger une entree dans un dossier ne suffit pas : c'est `folders` qui peuple
/// la colonne du poste, et un nom qui n'y figure pas donne une entree rangee
/// dans un dossier que rien n'affiche - elle semble disparue. Les deux vont
/// donc ensemble, et 'All' ne s'inscrit jamais : c'est le defaut, pas un
/// dossier.
procedure EnsureFolder(const ARoot: TJSONObject; const AName: string);
var
LFolders: TJSONArray;
LObj: TJSONObject;
I: Integer;
begin
if (AName = '') or (AName = 'All') then Exit;
if not ARoot.TryGetValue<TJSONArray>('folders', LFolders) then
begin
LFolders := TJSONArray.Create;
ARoot.AddPair('folders', LFolders);
end;
for I := 0 to LFolders.Count - 1 do
if (LFolders.Items[I] is TJSONObject) and
((LFolders.Items[I] as TJSONObject).GetValue<string>('name', '') = AName) then
Exit;
LObj := TJSONObject.Create;
LObj.AddPair('name', AName);
// Le poste lit aussi 'color' et 'icon' ; vides, il met ses defauts.
LObj.AddPair('color', '');
LObj.AddPair('icon', '');
LFolders.AddElement(LObj);
end;
function CloneJson(const AObj: TJSONObject): TJSONObject; function CloneJson(const AObj: TJSONObject): TJSONObject;
begin begin
Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject; Result := TJSONObject.ParseJSONValue(AObj.ToJSON) as TJSONObject;
@@ -279,7 +310,7 @@ begin
end; end;
function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword,
AKind: string): string; AKind, AFolder: string): string;
var var
LRoot, LEntry: TJSONObject; LRoot, LEntry: TJSONObject;
LEntries: TJSONArray; LEntries: TJSONArray;
@@ -303,7 +334,9 @@ begin
// Le corps d'une note EST son mot de passe cote stockage - il n'y a pas de // Le corps d'une note EST son mot de passe cote stockage - il n'y a pas de
// colonne separee, ni ici ni sur le poste. // colonne separee, ni ici ni sur le poste.
LEntry.AddPair('password', APassword); LEntry.AddPair('password', APassword);
LEntry.AddPair('folder', 'All'); if AFolder <> '' then LEntry.AddPair('folder', AFolder)
else LEntry.AddPair('folder', 'All');
EnsureFolder(LRoot, AFolder);
LEntry.AddPair('tags', TJSONArray.Create); LEntry.AddPair('tags', TJSONArray.Create);
LEntry.AddPair('totp_secret', ''); LEntry.AddPair('totp_secret', '');
LEntry.AddPair('kind', AKind); LEntry.AddPair('kind', AKind);
@@ -356,6 +389,7 @@ begin
// reste plus ancien que le leur. // reste plus ancien que le leur.
LEntry.RemovePair('updated_at').Free; LEntry.RemovePair('updated_at').Free;
LEntry.AddPair('updated_at', NowUtcIso); LEntry.AddPair('updated_at', NowUtcIso);
if AField = 'folder' then EnsureFolder(LRoot, AValue);
LFound := True; LFound := True;
Break; Break;
+52 -4
View File
@@ -562,8 +562,14 @@ function renderRows() {
document.getElementById('rows').addEventListener('click', function (ev) { document.getElementById('rows').addEventListener('click', function (ev) {
var row = ev.target.closest('.row'); var row = ev.target.closest('.row');
if (!row) return; if (!row) return;
var copy = ev.target.getAttribute('data-copy'); // REMONTER AU BOUTON. Depuis que ces boutons portent un dessin, la cible de
if (copy) send('copy?i=' + row.getAttribute('data-i') + '&f=' + copy); // l'appui est le <svg> ou l'un de ses traits, pas le bouton : lire
// l'attribut sur la cible rendait null, et l'appui ouvrait l'entree au lieu
// de copier. Le doigt tombant tantot sur le glyphe tantot sur la marge, cela
// ne ratait qu'une fois sur deux - la pire des pannes a diagnostiquer.
var btn = ev.target.closest('[data-copy]');
if (btn) send('copy?i=' + row.getAttribute('data-i')
+ '&f=' + btn.getAttribute('data-copy'));
else send('open?i=' + row.getAttribute('data-i')); else send('open?i=' + row.getAttribute('data-i'));
}); });
@@ -677,7 +683,10 @@ function renderDetail(d) {
} }
document.getElementById('fields').addEventListener('click', function (ev) { document.getElementById('fields').addEventListener('click', function (ev) {
var t = ev.target, k = t.getAttribute('data-show'); // Meme raison que dans la liste : le dessin recoit l'appui, le bouton porte
// l'intention. Tout ce qui suit lit et ecrit sur le bouton.
var t = ev.target.closest('button') || ev.target;
var k = t.getAttribute('data-show');
if (t.id === 'del') { if (t.id === 'del') {
if (t.getAttribute('data-armed') === null) { if (t.getAttribute('data-armed') === null) {
t.setAttribute('data-armed', '1'); t.setAttribute('data-armed', '1');
@@ -778,9 +787,31 @@ function editorControl(k) {
html += '<option value="' + esc(names[i]) + '"' html += '<option value="' + esc(names[i]) + '"'
+ (names[i] === val ? ' selected' : '') + '>' + esc(names[i]) + (names[i] === val ? ' selected' : '') + '>' + esc(names[i])
+ '</option>'; + '</option>';
// CHOISIR NE SUFFIT PAS : le premier dossier d'un coffre n'existe pas encore,
// et rien ne permettait de le nommer. Cette option echange la liste contre un
// champ libre, sans quitter l'editeur.
html += '<option value="' + NEW_FOLDER + '">+ new folder</option>';
return html + '</select>'; return html + '</select>';
} }
var NEW_FOLDER = '\u0001new';
// Le choix "+ new folder" remplace la liste par un champ. Delegue sur #fields
// parce que le controle nait et meurt avec l'editeur.
document.getElementById('fields').addEventListener('change', function (ev) {
if (ev.target.id !== 'editval' || ev.target.value !== NEW_FOLDER) return;
var line = ev.target.parentNode;
var input = document.createElement('input');
input.className = 'field-in';
input.id = 'editval';
input.setAttribute('placeholder', 'folder name');
input.setAttribute('autocomplete', 'off');
input.setAttribute('autocapitalize', 'none');
input.style.cssText = 'margin:0;flex:1;min-width:0;width:auto';
line.replaceChild(input, ev.target);
input.focus();
});
// EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au // EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au
// moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors // moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors
// null sans un mot - le bouton semblerait ne rien faire. // null sans un mot - le bouton semblerait ne rien faire.
@@ -895,6 +926,20 @@ addEventListener('keydown', function (ev) {
// place a l'ecran, et c'est exactement ce que cette vue veut dire. // place a l'ecran, et c'est exactement ce que cette vue veut dire.
var newKind = 'login'; var newKind = 'login';
/// Le dossier a la creation. Le meme choix que dans l'editeur, et pour la
/// meme raison : une entree rangee des le depart evite d'avoir a la deplacer.
function newFolderField() {
var names = ['All'], i;
for (i = 0; i < folders.length; i++)
if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]);
var html = '<div class="field"><div class="cap">Folder</div><div class="line">'
+ '<select class="field-in" id="nFolder"'
+ ' style="margin:0;flex:1;min-width:0;width:auto">';
for (i = 0; i < names.length; i++)
html += '<option value="' + esc(names[i]) + '">' + esc(names[i]) + '</option>';
return html + '</select></div></div>';
}
function newField(id, label, type) { function newField(id, label, type) {
return '<div class="field"><div class="cap">' + label + '</div>' return '<div class="field"><div class="cap">' + label + '</div>'
+ '<div class="line"><input class="field-in" id="' + id + '" type="' + '<div class="line"><input class="field-in" id="' + id + '" type="'
@@ -911,6 +956,7 @@ function openNewEntry() {
'<div class="field"><div class="cap">Kind</div><div class="line">' '<div class="field"><div class="cap">Kind</div><div class="line">'
+ '<button class="btn main" data-kind="login">Login</button>' + '<button class="btn main" data-kind="login">Login</button>'
+ '<button class="btn" data-kind="note">Note</button></div></div>' + '<button class="btn" data-kind="note">Note</button></div></div>'
+ newFolderField()
+ newField('nTitle', 'Name', 'text') + newField('nTitle', 'Name', 'text')
+ newField('nSite', 'Site', 'text') + newField('nSite', 'Site', 'text')
+ newField('nUser', 'Username', 'text') + newField('nUser', 'Username', 'text')
@@ -953,7 +999,9 @@ document.getElementById('fields').addEventListener('click', function (ev) {
if (ev.target.id !== 'nSave') return; if (ev.target.id !== 'nSave') return;
var v = function (id) { return document.getElementById(id).value.trim(); }; var v = function (id) { return document.getElementById(id).value.trim(); };
ev.target.disabled = true; ev.target.disabled = true;
send('newentry?t=' + encodeURIComponent(v('nTitle')) var fold = document.getElementById('nFolder');
send('newentry?d=' + encodeURIComponent(fold ? fold.value : 'All')
+ '&t=' + encodeURIComponent(v('nTitle'))
+ '&s=' + encodeURIComponent(v('nSite')) + '&s=' + encodeURIComponent(v('nSite'))
+ '&u=' + encodeURIComponent(v('nUser')) + '&u=' + encodeURIComponent(v('nUser'))
+ '&p=' + encodeURIComponent(document.getElementById('nPwd').value) + '&p=' + encodeURIComponent(document.getElementById('nPwd').value)
+7 -4
View File
@@ -274,7 +274,8 @@ type
procedure SaveEntryField(const AField, ANewValue: string); procedure SaveEntryField(const AField, ANewValue: string);
/// Ajoute une entree au coffre. Le mot de passe voyage par le pont, /// Ajoute une entree au coffre. Le mot de passe voyage par le pont,
/// comme 'setfield' - meme regle, meme journalisation muette. /// comme 'setfield' - meme regle, meme journalisation muette.
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind,
AFolder: string);
/// Supprime l'entree ouverte, pierre tombale comprise. Sans retour. /// Supprime l'entree ouverte, pierre tombale comprise. Sans retour.
procedure DeleteCurrentEntry; procedure DeleteCurrentEntry;
/// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot. /// Fond le coffre ne ici dans le depot, puis passe l'autorite au depot.
@@ -1192,7 +1193,8 @@ begin
end); end);
end; end;
procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind,
AFolder: string);
begin begin
// Une entree sans rien pour la reconnaitre serait une ligne muette dans la // Une entree sans rien pour la reconnaitre serait une ligne muette dans la
// liste, et rien ne permettrait de la retrouver pour la corriger. // liste, et rien ne permettrait de la retrouver pour la corriger.
@@ -1204,7 +1206,7 @@ begin
CommitChange('Added: ' + ATitle, 'new entry from phone', '', CommitChange('Added: ' + ATitle, 'new entry from phone', '',
function (APlain: string): string function (APlain: string): string
begin begin
Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind); Result := AddEntry(APlain, ATitle, ASite, AUser, APwd, AKind, AFolder);
end); end);
end; end;
@@ -1351,7 +1353,8 @@ begin
TNetEncoding.URL.Decode(Param('s')), TNetEncoding.URL.Decode(Param('s')),
TNetEncoding.URL.Decode(Param('u')), TNetEncoding.URL.Decode(Param('u')),
TNetEncoding.URL.Decode(Param('p')), TNetEncoding.URL.Decode(Param('p')),
TNetEncoding.URL.Decode(Param('k'))) TNetEncoding.URL.Decode(Param('k')),
TNetEncoding.URL.Decode(Param('d')))
else if LAction = 'delentry' then else if LAction = 'delentry' then
DeleteCurrentEntry DeleteCurrentEntry