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:
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user