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
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:
1 parent
bc7bdc481e
commit
375dda982b
9 files changed
+145
-36
No files matched your search
@@ -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
|
||||
|
||||
@@ -622,6 +622,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
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 = '';
|
||||
|
||||
@@ -247,7 +247,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
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';
|
||||
|
||||
|
||||
@@ -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<WorkspaceViewProps> = ({
|
||||
return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
}, [docs, serverTagsSummary]);
|
||||
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(
|
||||
initialDocId || readWorkspaceSession().docId || null
|
||||
);
|
||||
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
||||
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
||||
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
|
||||
// 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'
|
||||
);
|
||||
const [trash, setTrash] = useState<TrashedDocument[]>([]);
|
||||
// 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<WorkspaceViewProps> = ({
|
||||
}
|
||||
setShowNewFolderModal(true);
|
||||
};
|
||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(initialDocId));
|
||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(
|
||||
Boolean(initialDocId) || subView === 'journal'
|
||||
);
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true';
|
||||
@@ -473,12 +480,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
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<WorkspaceViewProps> = ({
|
||||
() => (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<string>(folders);
|
||||
@@ -1569,28 +1596,31 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{/* Scrollable Navigation Tree */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||
{/* 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 && (
|
||||
<div className="border-b border-border pb-1.5 -mb-1.5">
|
||||
<div
|
||||
className={
|
||||
subView === 'journal'
|
||||
? 'rounded-card border-2 border-accent/40 bg-panel-elevated p-2.5'
|
||||
: 'border-b border-border pb-1.5 -mb-1.5'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openJournal}
|
||||
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
|
||||
onClick={toggleJournal}
|
||||
aria-expanded={subView === 'journal'}
|
||||
title={subView === 'journal' ? 'Journal schließen' : 'Journal öffnen'}
|
||||
className={`w-full flex items-center gap-2 text-left cursor-pointer transition-colors ${
|
||||
subView === 'journal'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
? 'h-11 sm:h-7 px-0.5 text-sm font-bold text-main'
|
||||
: 'h-11 sm:h-8 px-2 rounded-btn text-xs text-main border border-transparent hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="flex-1 text-left">Journal</span>
|
||||
{subView === 'journal' ? (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted" />
|
||||
) : (
|
||||
<ChevronRight className="w-3.5 h-3.5 text-muted" />
|
||||
)}
|
||||
<CalendarDays className={subView === 'journal' ? 'w-4 h-4 text-accent' : 'w-3.5 h-3.5 text-accent'} />
|
||||
<span>Journal</span>
|
||||
</button>
|
||||
{subView === 'journal' && (
|
||||
<div className="hidden lg:block px-1 pt-2 pb-1">
|
||||
<div className="hidden lg:block pt-2">
|
||||
<JournalCalendar
|
||||
activeDate={journalActive}
|
||||
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user