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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user