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
@@ -81,6 +81,7 @@ import { useUpdateCheck } from './lib/useUpdateCheck';
|
|||||||
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
import { readViewSession, writeViewSession } from './lib/viewSession';
|
import { readViewSession, writeViewSession } from './lib/viewSession';
|
||||||
|
import { reportNav, onNavStep } from './lib/navHistory';
|
||||||
import { setVoicePreference } from './lib/voice';
|
import { setVoicePreference } from './lib/voice';
|
||||||
import { isPhoneWidth } from './lib/breakpoints';
|
import { isPhoneWidth } from './lib/breakpoints';
|
||||||
|
|
||||||
@@ -188,6 +189,15 @@ export const App: React.FC = () => {
|
|||||||
return tab && APP_TABS.includes(tab) ? tab : 'triage';
|
return tab && APP_TABS.includes(tab) ? tab : 'triage';
|
||||||
});
|
});
|
||||||
useEffect(() => writeViewSession('app', { tab: activeTab }), [activeTab]);
|
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)
|
// Whisper or the browser for voice input: the account's choice (lib/voice.ts)
|
||||||
useEffect(() => setVoicePreference(user?.voiceInput), [user?.voiceInput]);
|
useEffect(() => setVoicePreference(user?.voiceInput), [user?.voiceInput]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { Presence } from '../../lib/docSync/transport';
|
|||||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
import { moveSibling } from '../../lib/outlineList';
|
import { moveSibling } from '../../lib/outlineList';
|
||||||
|
import { reportNav, onNavStep, restoringNavPlace } from '../../lib/navHistory';
|
||||||
|
|
||||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
|
|
||||||
@@ -260,7 +261,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
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 [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
|
||||||
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
|
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -317,10 +322,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}, [loading, autoFocusTitle, doc?.id]);
|
}, [loading, autoFocusTitle, doc?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) {
|
if (!loading && zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) {
|
||||||
setZoomedBlockId(null);
|
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(() => {
|
const { zoomedBlock, childBlocks, levelOffset } = useMemo(() => {
|
||||||
return getZoomedSubtree(blocks, zoomedBlockId);
|
return getZoomedSubtree(blocks, zoomedBlockId);
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { EmojiIconButton } from '../../components/EmojiIconButton';
|
|||||||
import { LazyMount } from '../../components/LazyMount';
|
import { LazyMount } from '../../components/LazyMount';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
import { isPhoneWidth } from '../../lib/breakpoints';
|
import { isPhoneWidth } from '../../lib/breakpoints';
|
||||||
|
import { reportNav, onNavStep, restoringNavPlace } from '../../lib/navHistory';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
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));
|
return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||||
}, [docs, serverTagsSummary]);
|
}, [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>(
|
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 [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
||||||
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
||||||
// Journal open and the last regular note survive switching to other tabs (the view remounts)
|
// Journal open and the last regular note survive switching to other tabs (the view remounts)
|
||||||
const [subView, setSubView] = useState<WorkspaceSubView>(() =>
|
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[]>([]);
|
const [trash, setTrash] = useState<TrashedDocument[]>([]);
|
||||||
// Bumped after a restore so the open editor reloads the note without the trash banner
|
// 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);
|
setShowNewFolderModal(true);
|
||||||
};
|
};
|
||||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(
|
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>(() => {
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
@@ -525,6 +552,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (prevId && docsRes.some((d) => d.id === prevId)) {
|
if (prevId && docsRes.some((d) => d.id === prevId)) {
|
||||||
return 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
|
// Not a journal day: those open in the journal
|
||||||
const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id)));
|
const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id)));
|
||||||
return first ? first.id : null;
|
return first ? first.id : null;
|
||||||
|
|||||||
@@ -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