feat(workspace): journal panel toggle, view kept across tabs, sections nest under headings
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m9s

- 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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 20:11:09 +01:00
1 parent bc7bdc481e
commit 375dda982b
9 files changed
+145 -36

No files matched your search

@@ -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 },
]);
});
+20
View File
@@ -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
}
}
+12 -4
View File
@@ -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;
}