feat(ui): browser back and forward step through the app
The app's place (page, open note, workspace view, zoomed bullet, on phones list or note) goes into the browser history (lib/navHistory.ts), so the back button of the mouse, the browser or Android steps back inside the app: out of a zoom, to the previous note, to the previous page. Changes from one action become one entry, the app's own choice of a first note replaces the entry, and following a step adds nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5b711730a7
commit
1eef388fa5
5 files changed
+185
-6
No files matched your search
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { reportNav, onNavStep, restoringNavPlace } from '../navHistory';
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
const stateOf = () => (window.history.state as { mindsiftNav?: Record<string, unknown> } | null)?.mindsiftNav;
|
||||
|
||||
describe('navHistory', () => {
|
||||
it('puts places into the history, one entry per action, and steps back to them', async () => {
|
||||
const pushes = vi.spyOn(window.history, 'pushState');
|
||||
reportNav({ tab: 'triage' }); // first place: replaces the current entry
|
||||
await tick();
|
||||
expect(pushes).not.toHaveBeenCalled();
|
||||
|
||||
// Opening a note from elsewhere: page and note in one go -> one entry
|
||||
reportNav({ tab: 'workspace' });
|
||||
reportNav({ docId: 'doc_1', view: 'editor' });
|
||||
await tick();
|
||||
expect(pushes).toHaveBeenCalledTimes(1);
|
||||
reportNav({ zoom: 'blk_7' });
|
||||
await tick();
|
||||
expect(pushes).toHaveBeenCalledTimes(2);
|
||||
expect(stateOf()).toMatchObject({ tab: 'workspace', docId: 'doc_1', zoom: 'blk_7' });
|
||||
|
||||
// The same place again is no new entry
|
||||
reportNav({ zoom: 'blk_7' });
|
||||
await tick();
|
||||
expect(pushes).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Back: listeners get the earlier place; what the app reports while following it adds nothing
|
||||
const seen: unknown[] = [];
|
||||
const off = onNavStep((p) => seen.push(p));
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: { mindsiftNav: { tab: 'workspace', docId: 'doc_1', view: 'editor', zoom: null } } }));
|
||||
expect(seen).toEqual([{ tab: 'workspace', docId: 'doc_1', view: 'editor', zoom: null }]);
|
||||
expect(restoringNavPlace()).toMatchObject({ docId: 'doc_1', zoom: null });
|
||||
reportNav({ zoom: null });
|
||||
await tick();
|
||||
expect(pushes).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Foreign history entries (no place) are left alone
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
|
||||
expect(seen).toHaveLength(1);
|
||||
off();
|
||||
pushes.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// In-app back and forward: where the user is (page, open note, workspace view, zoomed bullet, on phones
|
||||
// list or note) goes into the browser history, so the browser's back button (mouse button, Android back)
|
||||
// steps back inside the app instead of leaving it. Each part reports its own piece; changes from one
|
||||
// action (e.g. opening a note from a task switches page and note) become one entry. While the app follows
|
||||
// a back/forward step, reports change nothing: the step itself already is the entry.
|
||||
|
||||
export interface NavPlace {
|
||||
tab: string;
|
||||
docId?: string | null;
|
||||
view?: string | null; // workspace view: editor, todos, tags, trash, journal …
|
||||
zoom?: string | null; // bullet the open note is zoomed into
|
||||
detail?: boolean; // phones: the note (true) or the list of notes (false)
|
||||
}
|
||||
|
||||
const RESTORE_MS = 600; // the app needs a moment to follow a step (views mount, notes load)
|
||||
|
||||
let current: NavPlace | null = null;
|
||||
let pending: Partial<NavPlace> | null = null;
|
||||
let pendingReplace = false;
|
||||
let restoringUntil = 0;
|
||||
const listeners = new Set<(place: NavPlace) => void>();
|
||||
|
||||
const same = (a: NavPlace, b: NavPlace) =>
|
||||
a.tab === b.tab &&
|
||||
(a.docId ?? null) === (b.docId ?? null) &&
|
||||
(a.view ?? null) === (b.view ?? null) &&
|
||||
(a.zoom ?? null) === (b.zoom ?? null) &&
|
||||
Boolean(a.detail) === Boolean(b.detail);
|
||||
|
||||
function flush() {
|
||||
if (!pending) return;
|
||||
const part = pending;
|
||||
const replace = pendingReplace;
|
||||
pending = null;
|
||||
pendingReplace = false;
|
||||
if (Date.now() < restoringUntil) return;
|
||||
const next: NavPlace = { ...(current || { tab: '' }), ...part };
|
||||
if (current && same(current, next)) return;
|
||||
const first = !current;
|
||||
current = next;
|
||||
try {
|
||||
if (first || replace) window.history.replaceState({ ...(window.history.state || {}), mindsiftNav: next }, '');
|
||||
else window.history.pushState({ mindsiftNav: next }, '');
|
||||
} catch {
|
||||
// history unavailable (tests, sandboxed frames): back simply leaves the app as before
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports a change of the user's place; changes in the same tick become one history entry. replace: the
|
||||
* app chose it by itself (e.g. the first note after loading), so it updates the current entry instead.
|
||||
*/
|
||||
export function reportNav(part: Partial<NavPlace>, options: { replace?: boolean } = {}) {
|
||||
pending = { ...(pending || {}), ...part };
|
||||
if (options.replace) pendingReplace = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
|
||||
/** The place the app is following right now (back/forward), for views that mount during the step. */
|
||||
export function restoringNavPlace(): NavPlace | null {
|
||||
return Date.now() < restoringUntil ? current : null;
|
||||
}
|
||||
|
||||
/** Called with the place to go to on back/forward. */
|
||||
export function onNavStep(fn: (place: NavPlace) => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('popstate', (e) => {
|
||||
const place = (e.state as { mindsiftNav?: NavPlace } | null)?.mindsiftNav;
|
||||
if (!place) return;
|
||||
current = place;
|
||||
pending = null;
|
||||
restoringUntil = Date.now() + RESTORE_MS;
|
||||
listeners.forEach((fn) => fn(place));
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user