From 375dda982b9fe70103dfdb91f05447f7045ab6cc Mon Sep 17 00:00:00 2001 From: Sandro Date: Fri, 30 Oct 2026 20:11:09 +0100 Subject: [PATCH] feat(workspace): journal panel toggle, view kept across tabs, sections nest under headings - the journal opens as a framed panel and closes on a second click, returning to the previous note; journal state and the last regular note survive tab switches, the fallback note is never a journal day - pasted and imported markdown nests content under its heading (H2 below H1), Enter on a heading creates a child, '- ' / '* ' / '+ ' start a bullet Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++ .../src/features/workspace/OutlinerBlock.tsx | 4 ++ .../src/features/workspace/OutlinerTree.tsx | 6 +- .../src/features/workspace/WorkspaceView.tsx | 70 +++++++++++++------ .../src/lib/__tests__/pasteOutline.test.ts | 24 +++++-- frontend/src/lib/journal.ts | 20 ++++++ frontend/src/lib/pasteOutline.ts | 16 +++-- internal/storage/import_markdown.go | 21 ++++-- internal/storage/import_markdown_test.go | 16 +++++ 9 files changed, 145 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20f7969..2855b51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +### Changed +- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz). +- Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt. + ## [0.10.0] - 2026-10-30 ### Added diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index a705828..699d99b 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -622,6 +622,10 @@ export const OutlinerBlock: React.FC = ({ val = val.replace(/^\[[xX]\]\s/, ''); typeOverride = 'todo'; checkedOverride = true; + } else if (val === '- ' || val === '* ' || val === '+ ') { + // Markdown list marker typed into an empty block: it already is a bullet, drop the marker + val = ''; + if (!['text', 'bullet', 'paragraph'].includes(block.type || 'text')) typeOverride = 'bullet'; } else if ((val === '1. ' || val === '1) ') && block.type !== 'numbered') { // "1. " typed into an empty bullet starts a numbered list val = ''; diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 3b87473..93d3817 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -247,7 +247,11 @@ export const OutlinerTree = forwardRef(({ if (!canEdit) return; const idx = blocks.findIndex((b) => b.id === afterBlockId); const currentBlock = idx >= 0 ? blocks[idx] : null; - const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0; + const isHeading = + (currentBlock?.type === 'h1' || currentBlock?.type === 'h2' || currentBlock?.type === 'h3') && + !currentBlock.collapsed; + // Enter on an open heading starts its section: the new bullet goes below it as a child + const level = currentBlock ? Math.max(0, currentBlock.level || 0) + (isHeading ? 1 : 0) : 0; // Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock) const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text'; diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index a89a980..af60e1f 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -53,7 +53,7 @@ import { PresenceAvatars } from './PresenceAvatars'; import { TrashView } from './TrashView'; import { JournalView } from './JournalView'; import { JournalCalendar } from './JournalCalendar'; -import { isJournalEnabled, todayISO } from '../../lib/journal'; +import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal'; import { MergeDialog } from './MergeDialog'; import { CountBadge } from '../../components/CountBadge'; import { extractHashtags } from '../../lib/hashtags'; @@ -319,10 +319,15 @@ export const WorkspaceView: React.FC = ({ return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); }, [docs, serverTagsSummary]); - const [selectedDocId, setSelectedDocId] = useState(initialDocId || null); + const [selectedDocId, setSelectedDocId] = useState( + initialDocId || readWorkspaceSession().docId || null + ); const [targetBlockId, setTargetBlockId] = useState(initialBlockId || null); const [newlyCreatedDocId, setNewlyCreatedDocId] = useState(null); - const [subView, setSubView] = useState('editor'); + // 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' + ); const [trash, setTrash] = useState([]); // Bumped after a restore so the open editor reloads the note without the trash banner const [editorNonce, setEditorNonce] = useState(0); @@ -398,7 +403,9 @@ export const WorkspaceView: React.FC = ({ } setShowNewFolderModal(true); }; - const [mobileShowDetail, setMobileShowDetail] = useState(Boolean(initialDocId)); + const [mobileShowDetail, setMobileShowDetail] = useState( + Boolean(initialDocId) || subView === 'journal' + ); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() => { try { return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true'; @@ -473,12 +480,14 @@ export const WorkspaceView: React.FC = ({ const loadWorkspaceData = useCallback(async () => { try { - const [docsRes, foldersRes, sharedFoldersRes, tagsRes] = await Promise.all([ + const [docsRes, foldersRes, sharedFoldersRes, tagsRes, journalRes] = await Promise.all([ api.getDocuments(), api.getFolders(), api.getSharedFolders().catch(() => []), api.getWorkspaceTags().catch(() => []), + isJournalEnabled() ? api.getJournal().catch(() => [] as JournalDay[]) : Promise.resolve([] as JournalDay[]), ]); + const journalIds = new Set(journalRes.map((d) => d.docId)); setDocs(docsRes); setFolders(foldersRes); setSharedFolders(sharedFoldersRes); @@ -488,7 +497,9 @@ export const WorkspaceView: React.FC = ({ if (prevId && docsRes.some((d) => d.id === prevId)) { return prevId; } - return docsRes.length > 0 ? docsRes[0].id : null; + // 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; }); } catch { // ignore @@ -620,6 +631,16 @@ export const WorkspaceView: React.FC = ({ setMobileShowDetail(true); }, [reloadJournal]); + // A second click closes the journal and shows the regular note again + const toggleJournal = useCallback(() => { + if (subView === 'journal') { + setSubView('editor'); + setSelectedDocId((prev) => prev ?? treeDocsRef.current[0]?.id ?? null); + } else { + openJournal(); + } + }, [subView, openJournal]); + const openTrash = useCallback(() => { reloadTrash(); setSubView('trash'); @@ -1004,6 +1025,12 @@ export const WorkspaceView: React.FC = ({ () => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs), [docs, journalDocIds] ); + const treeDocsRef = useRef(treeDocs); + treeDocsRef.current = treeDocs; + + useEffect(() => { + writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId }); + }, [subView, selectedDocId]); const allFolderPaths = useMemo(() => { const set = new Set(folders); @@ -1569,28 +1596,31 @@ export const WorkspaceView: React.FC = ({ {/* Scrollable Navigation Tree */}
- {/* Journal on top; while open, its calendar stays below it */} + {/* Journal on top; open, it becomes a panel with the calendar (click the heading to close) */} {journalEnabled && ( -
+
{subView === 'journal' && ( -
+
!d.empty).map((d) => d.date))} diff --git a/frontend/src/lib/__tests__/pasteOutline.test.ts b/frontend/src/lib/__tests__/pasteOutline.test.ts index b5eca0b..eb7611e 100644 --- a/frontend/src/lib/__tests__/pasteOutline.test.ts +++ b/frontend/src/lib/__tests__/pasteOutline.test.ts @@ -20,13 +20,27 @@ describe('parsePastedLines', () => { ]); }); - it('reads headings, todos, bullets and dividers', () => { - expect(parse('# Titel\nText danach\n- [x] erledigt\n- Punkt\n---')).toEqual([ + it('reads headings, todos, bullets and dividers and nests sections under headings', () => { + expect(parse('# Titel\nText danach\n- [x] erledigt\n* Punkt\n + tiefer\n---\nOhne Abschnitt')).toEqual([ { type: 'h1', content: 'Titel', indent: 0 }, - { type: 'text', content: 'Text danach', indent: 0 }, - { type: 'todo', checked: true, content: 'erledigt', indent: 0 }, - { type: 'text', content: 'Punkt', indent: 0 }, + { type: 'text', content: 'Text danach', indent: 1 }, + { type: 'todo', checked: true, content: 'erledigt', indent: 1 }, + { type: 'text', content: 'Punkt', indent: 1 }, + { type: 'text', content: 'tiefer', indent: 2 }, { type: 'divider', content: '---', indent: 0 }, + { type: 'text', content: 'Ohne Abschnitt', indent: 0 }, + ]); + }); + + it('nests H2 below H1 and closes sections at the same rank', () => { + expect(parse('# A\n## A1\n1. eins\n## A2\nText\n# B\n- b')).toEqual([ + { type: 'h1', content: 'A', indent: 0 }, + { type: 'h2', content: 'A1', indent: 1 }, + { type: 'numbered', content: 'eins', indent: 2 }, + { type: 'h2', content: 'A2', indent: 1 }, + { type: 'text', content: 'Text', indent: 2 }, + { type: 'h1', content: 'B', indent: 0 }, + { type: 'text', content: 'b', indent: 1 }, ]); }); diff --git a/frontend/src/lib/journal.ts b/frontend/src/lib/journal.ts index 3fc77e3..4ac90d7 100644 --- a/frontend/src/lib/journal.ts +++ b/frontend/src/lib/journal.ts @@ -60,3 +60,23 @@ export function monthGrid(year: number, month: number): (string | null)[][] { for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)); return weeks; } + +// Workspace state for this browser tab: is the journal open, which regular note was open last +const WORKSPACE_SESSION_KEY = 'mindsift_workspace_view'; + +export function readWorkspaceSession(): { journal: boolean; docId: string | null } { + try { + const raw = JSON.parse(sessionStorage.getItem(WORKSPACE_SESSION_KEY) || '{}'); + return { journal: raw.journal === true, docId: typeof raw.docId === 'string' ? raw.docId : null }; + } catch { + return { journal: false, docId: null }; + } +} + +export function writeWorkspaceSession(state: { journal: boolean; docId: string | null }) { + try { + sessionStorage.setItem(WORKSPACE_SESSION_KEY, JSON.stringify(state)); + } catch { + // storage blocked: the view simply starts fresh + } +} diff --git a/frontend/src/lib/pasteOutline.ts b/frontend/src/lib/pasteOutline.ts index 3a59900..8f7bc54 100644 --- a/frontend/src/lib/pasteOutline.ts +++ b/frontend/src/lib/pasteOutline.ts @@ -2,6 +2,7 @@ import type { BlockType } from './types'; // Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers, // fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level. +// Content below a heading is nested under it (H2 below H1 one level deeper), so sections fold and zoom. export interface PastedItem { type: BlockType; @@ -31,6 +32,8 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] { let current: PastedItem | null = null; let inCode = false; let afterHeading = false; + const headings: { rank: number; level: number }[] = []; // open headings, innermost last + const sectionLevel = () => (headings.length ? headings[headings.length - 1].level + 1 : 0); const flush = () => { if (current) { @@ -52,13 +55,13 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] { if (rawLine.trim().startsWith('```')) { inCode = true; flush(); - current = { type: 'text', content: rawLine, indent: 0 }; + current = { type: 'text', content: rawLine, indent: sectionLevel() }; afterHeading = false; continue; } const { width, rest } = leadingWidth(rawLine); - const indent = levelOf(width); + const indent = sectionLevel() + levelOf(width); const line = rest.trimEnd(); // A blank line ends the paragraph or list item @@ -71,13 +74,18 @@ export function parsePastedLines(rawLines: string[]): PastedItem[] { if (heading) { flush(); afterHeading = true; - items.push({ type: (['h1', 'h2', 'h3'] as const)[heading[1].length - 1], content: heading[2], indent }); + const rank = heading[1].length; + while (headings.length && headings[headings.length - 1].rank >= rank) headings.pop(); + const level = sectionLevel(); + headings.push({ rank, level }); + items.push({ type: (['h1', 'h2', 'h3'] as const)[rank - 1], content: heading[2], indent: level }); continue; } if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) { flush(); afterHeading = false; - items.push({ type: 'divider', content: '---', indent }); + headings.length = 0; // a divider closes the sections + items.push({ type: 'divider', content: '---', indent: 0 }); continue; } diff --git a/internal/storage/import_markdown.go b/internal/storage/import_markdown.go index 7095ac6..73601ce 100644 --- a/internal/storage/import_markdown.go +++ b/internal/storage/import_markdown.go @@ -740,9 +740,10 @@ func indentWidth(s string) int { func MarkdownToBlocks(md string) []domain.DocumentBlock { lines := strings.Split(md, "\n") var blocks []domain.DocumentBlock - base := 0 // 1 inside a heading section - var indents []int // indent widths of the open list levels - inList := false // the previous line belonged to a list item (for continuation lines) + base := 0 // level of content in the current heading section + var headings []int // ranks (1–3) of the open headings: H2 below H1 nests one level deeper + var indents []int // indent widths of the open list levels + inList := false // the previous line belonged to a list item (for continuation lines) listLevel := func(width int) int { for len(indents) > 0 && indents[len(indents)-1] > width { indents = indents[:len(indents)-1] @@ -789,15 +790,23 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock { if typ == "" { typ = "h3" } - blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), 0)) - base, indents, inList = 1, nil, false + rank := len(m[1]) + if rank > 3 { + rank = 3 + } + for len(headings) > 0 && headings[len(headings)-1] >= rank { + headings = headings[:len(headings)-1] + } + blocks = append(blocks, newBlock(typ, strings.TrimSpace(m[2]), len(headings))) + headings = append(headings, rank) + base, indents, inList = len(headings), nil, false continue } if trimmed == "---" || trimmed == "***" || trimmed == "___" { if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") { blocks = append(blocks, newBlock("divider", "", 0)) - base, indents, inList = 0, nil, false + base, headings, indents, inList = 0, nil, nil, false continue } } diff --git a/internal/storage/import_markdown_test.go b/internal/storage/import_markdown_test.go index ba6482e..6eae56e 100644 --- a/internal/storage/import_markdown_test.go +++ b/internal/storage/import_markdown_test.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "encoding/json" + "fmt" "path/filepath" "strings" "testing" @@ -219,3 +220,18 @@ func TestImportBackupZip(t *testing.T) { } checkRestored(t, store, ben) } + +func TestMarkdownHeadingsNestTheirSections(t *testing.T) { + blocks := MarkdownToBlocks("# Projekt\nEinleitung\n## Teil A\n- Punkt\n - Unterpunkt\n## Teil B\n1. Erstens\n# Anhang\nText") + var got []string + for _, b := range blocks { + got = append(got, fmt.Sprintf("%d:%s:%s", b.Level, b.Type, b.Content)) + } + want := []string{ + "0:h1:Projekt", "1:bullet:Einleitung", "1:h2:Teil A", "2:bullet:Punkt", "3:bullet:Unterpunkt", + "1:h2:Teil B", "2:numbered:Erstens", "0:h1:Anhang", "1:bullet:Text", + } + if strings.Join(got, "|") != strings.Join(want, "|") { + t.Fatalf("got %v\nwant %v", got, want) + } +}