From 1eef388fa5de91e29a6c1b9107f6469dcc3dffe5 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 21:36:41 +0100 Subject: [PATCH 1/2] 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)); + }); +} From 3c23d094ce9dd5947aec3c7fb489b287dffa6b9a Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 2 Nov 2026 21:36:41 +0100 Subject: [PATCH 2/2] chore: release v0.64.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +++++++- README.md | 2 +- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 096f097..2cd839c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.64.0] - 2026-11-02 + +### Added +- Die Zurück-Taste (Maus, Browser, Android) geht innerhalb von MindSift zurück, statt die App zu verlassen: aus einem hineingezoomten Punkt heraus, zur vorher offenen Notiz, zur vorher offenen Ansicht im Workspace, am Handy von der Notiz zur Notizliste und zur vorigen Seite. Vor geht entsprechend wieder vorwärts. + ## [0.63.2] - 2026-11-02 ### Fixed @@ -912,7 +917,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.2...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.64.0...dev +[0.64.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.2...v0.64.0 [0.63.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.1...v0.63.2 [0.63.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.0...v0.63.1 [0.63.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.62.0...v0.63.0 diff --git a/README.md b/README.md index 0bfe134..6810669 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.63.2-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.64.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db2b0a1..3908dda 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.63.2", + "version": "0.64.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.63.2", + "version": "0.64.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 4cbc585..74d1c47 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.63.2", + "version": "0.64.0", "license": "AGPL-3.0-only", "type": "module", "scripts": {