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:
@@ -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
|
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
|
place**, jamais via `rerender()` : reconstruire la liste fait flasher le
|
||||||
panneau et perd le focus + la hauteur auto du multiligne.
|
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
|
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
|
||||||
texte ; match sur le label de section garde toute la section. Esc dans
|
texte ; match sur le label de section garde toute la section. Esc dans
|
||||||
la search avec query = clear ; Esc vide = ferme Settings.
|
la search avec query = clear ; Esc vide = ferme Settings.
|
||||||
|
|||||||
@@ -2285,6 +2285,11 @@ body[data-editor-position="center"]:has(#settingsPanel.is-open)::before {
|
|||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.slideover-body { padding: 20px; overflow-y: auto; flex: 1; }
|
.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; }
|
.slideover-field { margin-bottom: 16px; }
|
||||||
/* Settings panel search — sits below the header, above the body. Only
|
/* Settings panel search — sits below the header, above the body. Only
|
||||||
present inside #settingsPanel. */
|
present inside #settingsPanel. */
|
||||||
|
|||||||
@@ -421,6 +421,8 @@
|
|||||||
<!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
|
<!-- ---- SLIDE-OVER DETAIL ------------------------------- -->
|
||||||
<aside class="slideover" id="slideover">
|
<aside class="slideover" id="slideover">
|
||||||
<header class="slideover-header">
|
<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>
|
<h3 id="slideoverTitle">Entry</h3>
|
||||||
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
|
<button class="icon-btn" id="slideoverClose"><svg><use href="#i-x"/></svg></button>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -733,6 +733,9 @@ const state = {
|
|||||||
selectedId: null,
|
selectedId: null,
|
||||||
theme: localStorage.getItem('theme') || 'dark',
|
theme: localStorage.getItem('theme') || 'dark',
|
||||||
locked: false, // true after user clicks Lock (token still valid server-side)
|
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
|
// POURQUOI LE COFFRE S'EST FERME, et depuis quand la page vit. Un
|
||||||
// verrouillage automatique etait muet ; quand le remplissage repondait
|
// verrouillage automatique etait muet ; quand le remplissage repondait
|
||||||
// "Vault is locked", rien ne disait si c'etait l'inactivite, une veille de
|
// "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.cryptoKey = null;
|
||||||
state.entries = [];
|
state.entries = [];
|
||||||
state.trashed = [];
|
state.trashed = [];
|
||||||
|
state.entryHistory = { ids: [], pos: -1 };
|
||||||
state.locked = true;
|
state.locked = true;
|
||||||
state.justRecovered = false;
|
state.justRecovered = false;
|
||||||
state.avatarDataUri = ''; // reloaded from server on next unlock
|
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
|
// Open the slideover for either an existing entry (id = number) or a
|
||||||
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
|
// brand-new one (id = null). opts: { presetTitle, presetSite, template }
|
||||||
// pre-fill the corresponding fields for the new-entry path.
|
// 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) {
|
async function openSlideOver(id, opts) {
|
||||||
opts = opts || {};
|
opts = opts || {};
|
||||||
// A blocking operation owns the screen (export, sync, rotation). The busy
|
// 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 (!ok) return false; // caller must not act on a panel that didn't switch
|
||||||
}
|
}
|
||||||
if (!isNew && !e) return false;
|
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
|
// The editor must be visible when it opens: Settings sits ABOVE the
|
||||||
// slideover, so a hotkey (Ctrl+Shift+A) fired with Settings open left
|
// 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 && /^[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 && /^[nNwW]$/.test(e.key)) return e.preventDefault(); // New incognito / Close window
|
||||||
if (mod && e.shiftKey && e.key === 'Delete') return e.preventDefault(); // Clear browsing data
|
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);
|
document.addEventListener('keydown', handleCardCursorKey);
|
||||||
|
|
||||||
@@ -7798,6 +7850,8 @@ async function init() {
|
|||||||
|
|
||||||
// Slide-over
|
// Slide-over
|
||||||
$('#slideoverClose').addEventListener('click', () => requestCloseSlideOver());
|
$('#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
|
// 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
|
// another entry (so we don't close in that case; the card's own handler
|
||||||
// will switch state.selectedId).
|
// will switch state.selectedId).
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ function loadApp(overrides = {}) {
|
|||||||
syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
|
syncIsGitea, giteaUnwrap, syncSnapshotsEqual,
|
||||||
// metadata-at-rest
|
// metadata-at-rest
|
||||||
withEncryptedMeta, decryptEntryMeta, ENCRYPTED_META_FIELDS,
|
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