From 1eef388fa5de91e29a6c1b9107f6469dcc3dffe5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 21:36:41 +0100 Subject: [PATCH] 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 --- frontend/src/App.tsx | 10 +++ .../src/features/workspace/DocumentEditor.tsx | 23 +++++- .../src/features/workspace/WorkspaceView.tsx | 34 +++++++- frontend/src/lib/__tests__/navHistory.test.ts | 45 +++++++++++ frontend/src/lib/navHistory.ts | 79 +++++++++++++++++++ 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/__tests__/navHistory.test.ts create mode 100644 frontend/src/lib/navHistory.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aa0e614..f2390a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -81,6 +81,7 @@ import { useUpdateCheck } from './lib/useUpdateCheck'; import { consumePendingShares, formatSharedThought } from './lib/shareTarget'; import { APP_VERSION } from './version'; import { readViewSession, writeViewSession } from './lib/viewSession'; +import { reportNav, onNavStep } from './lib/navHistory'; import { setVoicePreference } from './lib/voice'; import { isPhoneWidth } from './lib/breakpoints'; @@ -188,6 +189,15 @@ export const App: React.FC = () => { return tab && APP_TABS.includes(tab) ? tab : 'triage'; }); useEffect(() => writeViewSession('app', { tab: activeTab }), [activeTab]); + // Browser back/forward steps through the app's pages (lib/navHistory.ts) + useEffect(() => reportNav({ tab: activeTab }), [activeTab]); + useEffect( + () => + onNavStep((place) => { + if ((APP_TABS as readonly string[]).includes(place.tab)) setActiveTab(place.tab as ActiveTab); + }), + [] + ); // Whisper or the browser for voice input: the account's choice (lib/voice.ts) useEffect(() => setVoicePreference(user?.voiceInput), [user?.voiceInput]); useEffect(() => { diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 00fc9b4..4d71995 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -34,6 +34,7 @@ import { Presence } from '../../lib/docSync/transport'; import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree'; import { extractHashtags } from '../../lib/hashtags'; import { moveSibling } from '../../lib/outlineList'; +import { reportNav, onNavStep, restoringNavPlace } from '../../lib/navHistory'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; @@ -260,7 +261,11 @@ export const DocumentEditor: React.FC = ({ const [loading, setLoading] = useState(!initialDoc); const [error, setError] = useState(null); const [showEmojiPicker, setShowEmojiPicker] = useState(false); - const [zoomedBlockId, setZoomedBlockId] = useState(null); + // Zoomed bullet; coming here by the browser's back/forward it is the zoom of that step + const [zoomedBlockId, setZoomedBlockId] = useState(() => { + const place = restoringNavPlace(); + return !inJournal && !inTagPage && place?.docId === docId ? place.zoom ?? null : null; + }); const [showActionsMenu, setShowActionsMenu] = useState(false); const actionsMenuRef = useRef(null); @@ -317,10 +322,22 @@ export const DocumentEditor: React.FC = ({ }, [loading, autoFocusTitle, doc?.id]); useEffect(() => { - if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) { + if (!loading && zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) { setZoomedBlockId(null); } - }, [blocks, zoomedBlockId]); + }, [blocks, zoomedBlockId, loading]); + + // Zooming in and out are steps of the browser history: back zooms out again (lib/navHistory.ts) + useEffect(() => { + if (!embedded) reportNav({ zoom: zoomedBlockId }); + }, [zoomedBlockId, embedded]); + useEffect( + () => + onNavStep((place) => { + if (!embedded && place.docId === docId) setZoomedBlockId(place.zoom ?? null); + }), + [embedded, docId] + ); const { zoomedBlock, childBlocks, levelOffset } = useMemo(() => { return getZoomedSubtree(blocks, zoomedBlockId); diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index f20fd76..3df14a2 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -62,6 +62,7 @@ import { EmojiIconButton } from '../../components/EmojiIconButton'; import { LazyMount } from '../../components/LazyMount'; import { extractHashtags } from '../../lib/hashtags'; import { isPhoneWidth } from '../../lib/breakpoints'; +import { reportNav, onNavStep, restoringNavPlace } from '../../lib/navHistory'; interface WorkspaceViewProps { currentUser?: User | null; @@ -333,14 +334,23 @@ export const WorkspaceView: React.FC = ({ return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); }, [docs, serverTagsSummary]); + // Coming back here by the browser's back/forward: the note and view of that step + const navStep = useMemo(() => { + const place = restoringNavPlace(); + return place && place.tab === 'workspace' ? place : null; + }, []); const [selectedDocId, setSelectedDocId] = useState( - initialDocId || readWorkspaceSession().docId || null + navStep ? navStep.docId ?? null : initialDocId || readWorkspaceSession().docId || null ); const [targetBlockId, setTargetBlockId] = useState(initialBlockId || null); const [newlyCreatedDocId, setNewlyCreatedDocId] = useState(null); // Journal open and the last regular note survive switching to other tabs (the view remounts) const [subView, setSubView] = useState(() => - !initialDocId && isJournalEnabled() && readWorkspaceSession().journal ? 'journal' : 'editor' + navStep?.view + ? (navStep.view as WorkspaceSubView) + : !initialDocId && isJournalEnabled() && readWorkspaceSession().journal + ? 'journal' + : 'editor' ); const [trash, setTrash] = useState([]); // Bumped after a restore so the open editor reloads the note without the trash banner @@ -418,7 +428,24 @@ export const WorkspaceView: React.FC = ({ setShowNewFolderModal(true); }; const [mobileShowDetail, setMobileShowDetail] = useState( - Boolean(initialDocId) || subView === 'journal' + navStep ? Boolean(navStep.detail) : Boolean(initialDocId) || subView === 'journal' + ); + // Browser back/forward: the open note and view (and on phones list or note) are steps of their own + const autoSelectedDocRef = useRef(false); + useEffect(() => { + const replace = autoSelectedDocRef.current; + autoSelectedDocRef.current = false; + reportNav({ docId: selectedDocId, view: subView, detail: isPhoneWidth() ? mobileShowDetail : undefined }, { replace }); + }, [selectedDocId, subView, mobileShowDetail]); + useEffect( + () => + onNavStep((place) => { + if (place.tab !== 'workspace') return; + setSelectedDocId(place.docId ?? null); + if (place.view) setSubView(place.view as WorkspaceSubView); + if (isPhoneWidth()) setMobileShowDetail(Boolean(place.detail)); + }), + [] ); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { try { @@ -525,6 +552,7 @@ export const WorkspaceView: React.FC = ({ if (prevId && docsRes.some((d) => d.id === prevId)) { return prevId; } + autoSelectedDocRef.current = true; // chosen by the app, not a step of its own in the history // Not a journal day: those open in the journal const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id))); return first ? first.id : null; diff --git a/frontend/src/lib/__tests__/navHistory.test.ts b/frontend/src/lib/__tests__/navHistory.test.ts new file mode 100644 index 0000000..3094950 --- /dev/null +++ b/frontend/src/lib/__tests__/navHistory.test.ts @@ -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 } | 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(); + }); +}); diff --git a/frontend/src/lib/navHistory.ts b/frontend/src/lib/navHistory.ts new file mode 100644 index 0000000..73957d0 --- /dev/null +++ b/frontend/src/lib/navHistory.ts @@ -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 | 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, 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)); + }); +}