feat(entries): Precedent / Suivant parmi les entrees ouvertes

Fleches dans l'en-tete du panneau et Alt+fleches, comme un navigateur :
un clic par erreur dans une liste filtree ne force plus a refaire la
recherche. Historique en memoire (50), vide au verrouillage ; la navigation
repasse par openSlideOver et sa confirmation de modifications non
enregistrees. Logique pure testee (history.test.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-10-10 17:39:24 +01:00
parent 28f8a42846
commit 63e532d5bb
6 changed files with 120 additions and 0 deletions
+11
View File
@@ -1561,6 +1561,17 @@ après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
clic extérieur est déjà câblée). Basculer le flag secret se fait **en
place**, jamais via `rerender()` : reconstruire la liste fait flasher le
panneau et perd le focus + la hauteur auto du multiligne.
- **Precedent / Suivant des entrees ouvertes** (2026-10-10) : fleches ‹ › de
l'en-tete du panneau + **Alt+←/→** (le `preventDefault` empeche aussi la page
de « reculer »). `state.entryHistory = {ids, pos}`, 50 au plus, memoire
seulement, vide par `lockVault`. Alimente par `openSlideOver` (sauf
`opts.fromHistory`) ; la navigation repasse par `openSlideOver`, donc par la
confirmation « modifications non enregistrees » — la position ne bouge
qu'une fois l'entree vraiment ouverte. Entree supprimee sautee ; panneau
ferme, Alt+← rouvre celle qu'on vient de quitter. Logique pure
(`entryHistoryPush` / `entryHistoryTarget`) testee dans
`js/tests/history.test.js`. Raison : un clic par erreur dans une liste
filtree obligeait a refaire la recherche.
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
texte ; match sur le label de section garde toute la section. Esc dans
la search avec query = clear ; Esc vide = ferme Settings.
+5
View File
@@ -2285,6 +2285,11 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.slideover-body { padding: 20px; overflow-y: auto; flex: 1; }
/* Precedent / Suivant : le chevron du sprite, tourne. Le titre prend la place. */
#slideover .slideover-header h3 { flex: 1; min-width: 0; margin-left: 6px; }
.so-hist:disabled { opacity: .3; cursor: default; }
.so-hist-back { transform: rotate(90deg); }
.so-hist-fwd { transform: rotate(-90deg); }
.slideover-field { margin-bottom: 16px; }
/* Settings panel search — sits below the header, above the body. Only
present inside #settingsPanel. */
+2
View File
@@ -421,6 +421,8 @@
<!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
<aside class="slideover" id="slideover">
<header class="slideover-header">
<button class="icon-btn so-hist" id="slideoverBack" title="Previous entry (Alt+Left)" disabled><svg class="so-hist-back"><use href="#i-chevron-down"/></svg></button>
<button class="icon-btn so-hist" id="slideoverFwd" title="Next entry (Alt+Right)" disabled><svg class="so-hist-fwd"><use href="#i-chevron-down"/></svg></button>
<h3 id="slideoverTitle">Entry</h3>
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
</header>
+54
View File
@@ -733,6 +733,9 @@ const state = {
selectedId: null,
theme: localStorage.getItem('theme') || 'dark',
locked: false, // true after user clicks Lock (token still valid server-side)
// Entrees ouvertes, pour Precedent / Suivant (fleches du panneau, Alt+fleches).
// En memoire seulement, videe au verrouillage.
entryHistory: { ids: [], pos: -1 },
// POURQUOI LE COFFRE S'EST FERME, et depuis quand la page vit. Un
// verrouillage automatique etait muet ; quand le remplissage repondait
// "Vault is locked", rien ne disait si c'etait l'inactivite, une veille de
@@ -1437,6 +1440,7 @@ function lockVault(reason) {
state.cryptoKey = null;
state.entries = [];
state.trashed = [];
state.entryHistory = { ids: [], pos: -1 };
state.locked = true;
state.justRecovered = false;
state.avatarDataUri = ''; // reloaded from server on next unlock
@@ -4025,6 +4029,45 @@ const ENTRY_TEMPLATES = {
// Open the slideover for either an existing entry (id = number) or a
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
// pre-fill the corresponding fields for the new-entry path.
// HISTORIQUE DES ENTREES OUVERTES, comme celui d'un navigateur. Fonctions
// pures sur {ids, pos} : testables sans DOM (tests/history.test.js).
const ENTRY_HISTORY_MAX = 50;
function entryHistoryPush(h, id) {
if (h.ids[h.pos] === id) return; // meme entree : pas de doublon
h.ids = h.ids.slice(0, h.pos + 1); // revenir puis ouvrir ailleurs oublie les suivants
h.ids.push(id);
if (h.ids.length > ENTRY_HISTORY_MAX) h.ids.shift();
h.pos = h.ids.length - 1;
}
// Indice de la prochaine entree qui existe encore dans le sens dir (-1 / +1),
// ou -1. Ne bouge rien : l'ouverture peut etre refusee (modifications non
// enregistrees), la position ne change qu'une fois l'entree vraiment ouverte.
function entryHistoryTarget(h, dir, exists) {
let p = h.pos + dir;
while (p >= 0 && p < h.ids.length && !exists(h.ids[p])) p += dir;
return (p >= 0 && p < h.ids.length) ? p : -1;
}
async function entryHistoryGo(dir) {
const h = state.entryHistory;
const exists = id => state.entries.some(x => x.id === id);
// Panneau ferme : Precedent rouvre d'abord l'entree qu'on vient de quitter.
let p = (dir < 0 && !$('#slideover').classList.contains('is-open') &&
exists(h.ids[h.pos])) ? h.pos : entryHistoryTarget(h, dir, exists);
if (p < 0) return;
if (await openSlideOver(h.ids[p], { fromHistory: true }) === false) return;
h.pos = p;
renderEntryHistoryNav();
}
function renderEntryHistoryNav() {
const h = state.entryHistory;
const exists = id => state.entries.some(x => x.id === id);
const back = $('#slideoverBack'), fwd = $('#slideoverFwd');
if (back) back.disabled = entryHistoryTarget(h, -1, exists) < 0;
if (fwd) fwd.disabled = entryHistoryTarget(h, +1, exists) < 0;
}
async function openSlideOver(id, opts) {
opts = opts || {};
// A blocking operation owns the screen (export, sync, rotation). The busy
@@ -4062,6 +4105,8 @@ async function openSlideOver(id, opts) {
if (!ok) return false; // caller must not act on a panel that didn't switch
}
if (!isNew && !e) return false;
if (!isNew && !opts.fromHistory) entryHistoryPush(state.entryHistory, id);
renderEntryHistoryNav();
// The editor must be visible when it opens: Settings sits ABOVE the
// slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left
@@ -7593,6 +7638,13 @@ async function init() {
if (mod && /^[uUjJhHsSpPtTnNrR]$/.test(e.key)) return e.preventDefault(); // View source / Downloads / History / Save / Print / New tab+win / Reload
if (mod && e.shiftKey && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data
// Alt+fleches : Precedent / Suivant parmi les entrees ouvertes. Le
// preventDefault empeche aussi la page elle-meme de "reculer".
if (e.altKey && !mod && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
e.preventDefault();
if (!state.locked && state.cryptoKey && !document.querySelector('.modal:not(.is-hidden)'))
entryHistoryGo(e.key === 'ArrowLeft' ? -1 : +1);
}
});
document.addEventListener('keydown', handleCardCursorKey);
@@ -7798,6 +7850,8 @@ async function init() {
// Slide-over
$('#slideoverClose').addEventListener('click', () => requestCloseSlideOver());
$('#slideoverBack').addEventListener('click', () => entryHistoryGo(-1));
$('#slideoverFwd').addEventListener('click', () => entryHistoryGo(+1));
// Click outside the slide-over closes it. Clicks on cards re-open it for
// another entry (so we don't close in that case; the card's own handler
// will switch state.selectedId).
+2
View File
@@ -156,6 +156,8 @@ function loadApp(overrides = {}) {
syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
// metadata-at-rest
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
// historique des entrees ouvertes
entryHistoryPush, entryHistoryTarget, ENTRY_HISTORY_MAX,
};
`;
+46
View File
@@ -0,0 +1,46 @@
// Historique Precedent / Suivant des entrees ouvertes (js/app.js).
const test = require('node:test');
const assert = require('node:assert/strict');
const { loadApp } = require('./harness.js');
const T = loadApp().__test;
const fresh = () => ({ ids: [], pos: -1 });
const all = () => true;
test('ouvrir A puis B : Precedent vise A, Suivant rien', () => {
const h = fresh();
T.entryHistoryPush(h, 1); T.entryHistoryPush(h, 2);
assert.deepEqual(h.ids, [1, 2]);
assert.equal(h.ids[T.entryHistoryTarget(h, -1, all)], 1);
assert.equal(T.entryHistoryTarget(h, +1, all), -1);
});
test('meme entree deux fois : pas de doublon', () => {
const h = fresh();
T.entryHistoryPush(h, 1); T.entryHistoryPush(h, 1);
assert.deepEqual(h.ids, [1]);
});
test('revenir puis ouvrir ailleurs oublie les suivants', () => {
const h = fresh();
[1, 2, 3].forEach(id => T.entryHistoryPush(h, id));
h.pos = 0; // revenu sur 1
T.entryHistoryPush(h, 4);
assert.deepEqual(h.ids, [1, 4]);
assert.equal(h.pos, 1);
});
test('une entree supprimee est sautee', () => {
const h = fresh();
[1, 2, 3].forEach(id => T.entryHistoryPush(h, id));
const exists = id => id !== 2;
assert.equal(h.ids[T.entryHistoryTarget(h, -1, exists)], 1);
assert.equal(T.entryHistoryTarget(h, -1, id => id === 3), -1);
});
test('historique borne', () => {
const h = fresh();
for (let i = 0; i < T.ENTRY_HISTORY_MAX + 10; i++) T.entryHistoryPush(h, i);
assert.equal(h.ids.length, T.ENTRY_HISTORY_MAX);
assert.equal(h.pos, T.ENTRY_HISTORY_MAX - 1);
});