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:
sandrodachandClaude Opus 5.5 committed 2026-11-02 21:36:41 +01:00
1 parent 5b711730a7
commit 1eef388fa5
5 files changed
+185 -6

No files matched your search

@@ -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<DocumentEditorProps> = ({
const [loading, setLoading] = useState<boolean>(!initialDoc);
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
// Zoomed bullet; coming here by the browser's back/forward it is the zoom of that step
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(() => {
const place = restoringNavPlace();
return !inJournal && !inTagPage && place?.docId === docId ? place.zoom ?? null : null;
});
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
@@ -317,10 +322,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
}, [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);
@@ -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<WorkspaceViewProps> = ({
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<string | null>(
initialDocId || readWorkspaceSession().docId || null
navStep ? navStep.docId ?? null : initialDocId || readWorkspaceSession().docId || null
);
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
// Journal open and the last regular note survive switching to other tabs (the view remounts)
const [subView, setSubView] = useState<WorkspaceSubView>(() =>
!initialDocId && isJournalEnabled() && readWorkspaceSession().journal ? 'journal' : 'editor'
navStep?.view
? (navStep.view as WorkspaceSubView)
: !initialDocId && isJournalEnabled() && readWorkspaceSession().journal
? 'journal'
: 'editor'
);
const [trash, setTrash] = useState<TrashedDocument[]>([]);
// Bumped after a restore so the open editor reloads the note without the trash banner
@@ -418,7 +428,24 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setShowNewFolderModal(true);
};
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(
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<boolean>(() => {
try {
@@ -525,6 +552,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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;