feat(workspace): hashtag page shows the live outliner of each note's tagged branches, edited in place
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m45s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 17:54:14 +01:00
1 parent ca56220e68
commit 8238c8a73f
9 files changed
+262 -176

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.47.0",
"version": "0.48.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.47.0",
"version": "0.48.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.47.0",
"version": "0.48.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+31
View File
@@ -0,0 +1,31 @@
import React, { useEffect, useRef, useState } from 'react';
interface LazyMountProps {
children: React.ReactNode;
// Shown until the place scrolls into view (keeps the page's height roughly right)
placeholder: React.ReactNode;
}
// Renders its children only once they come near the visible area, and keeps them afterwards. For lists
// of heavier parts, e.g. one live note editor per entry on a hashtag page.
export const LazyMount: React.FC<LazyMountProps> = ({ children, placeholder }) => {
const ref = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const el = ref.current;
if (shown || !el) return;
if (typeof IntersectionObserver === 'undefined') {
setShown(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) setShown(true);
},
{ rootMargin: '400px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [shown]);
return <div ref={ref}>{shown ? children : placeholder}</div>;
};
@@ -67,8 +67,11 @@ export interface DocumentEditorProps {
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
onDocCreated?: (doc: Document) => void;
onDocRemoved?: (docId: string) => void;
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */
variant?: 'page' | 'journal';
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title.
* 'tag': inside a hashtag page – the title as a fixed heading, below only the branches with tagFilter */
variant?: 'page' | 'journal' | 'tag';
/** variant 'tag': the hashtag (without #) whose bullets are shown */
tagFilter?: string;
/** Large screens: the action buttons render into this element (the workspace tab bar) */
actionsTarget?: HTMLElement | null;
/** Distraction-free mode: reading width, menu entry to leave it */
@@ -238,13 +241,17 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onDocCreated,
onDocRemoved,
variant = 'page',
tagFilter,
actionsTarget,
focusMode = false,
onToggleFocusMode,
}) => {
const inJournal = variant === 'journal';
const inTagPage = variant === 'tag';
// Embedded in a list of notes (journal, hashtag page): no own scrolling, action bar or backlinks
const embedded = inJournal || inTagPage;
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
const desktopBar = !inJournal && large && Boolean(actionsTarget);
const desktopBar = !embedded && large && Boolean(actionsTarget);
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
const [title, setTitle] = useState<string>(initialDoc?.title || '');
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
@@ -278,7 +285,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
useEffect(() => {
const root = scrollRef.current;
const el = titleInputRef.current;
if (inJournal || !root || !el || typeof IntersectionObserver === 'undefined') {
if (embedded || !root || !el || typeof IntersectionObserver === 'undefined') {
setTitleScrolledAway(false);
return;
}
@@ -289,7 +296,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
);
observer.observe(el);
return () => observer.disconnect();
}, [zoomedBlockId, doc?.id, inJournal]);
}, [zoomedBlockId, doc?.id, embedded]);
useEffect(() => {
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
@@ -789,6 +796,99 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
[docId, canEdit, applyLocalBlocks]
);
// --- Hashtag page (variant 'tag') ---
// Shown are the bullets carrying the hashtag with all their children. The bullet with the cursor (and
// the bullets above it) and bullets just created here stay even without the hashtag, so typing never
// pulls the text away; once the cursor leaves, a bullet without the hashtag disappears.
const [tagFocusId, setTagFocusId] = useState<string | null>(null);
const [tagFreshIds, setTagFreshIds] = useState<Set<string>>(() => new Set());
const tagBlurTimerRef = useRef<number | null>(null);
const handleTagFocusChange = useCallback((id: string | null) => {
engineRef.current?.focusBlock(id);
if (tagBlurTimerRef.current !== null) window.clearTimeout(tagBlurTimerRef.current);
tagBlurTimerRef.current = null;
if (id === null) {
// Moving from one bullet to the next blurs first; only a real leave counts
tagBlurTimerRef.current = window.setTimeout(() => {
setTagFocusId(null);
setTagFreshIds((prev) => (prev.size === 0 ? prev : new Set()));
}, 150);
return;
}
setTagFocusId(id);
setTagFreshIds((prev) => (prev.size === 0 || prev.has(id) ? prev : new Set()));
}, []);
useEffect(() => () => {
if (tagBlurTimerRef.current !== null) window.clearTimeout(tagBlurTimerRef.current);
}, []);
// What was shown last time: of the bullets above the cursor only those stay that were already shown
// (adding a heading above would start the branch anew and lose the cursor)
const tagShownRef = useRef<Set<string>>(new Set());
const tagGroups = useMemo(() => {
if (!inTagPage || !tagFilter) return [] as DocumentBlock[][];
const tag = tagFilter.replace(/^#/, '').toLowerCase();
const keep = new Set<string>(tagFreshIds);
const fIdx = tagFocusId ? blocks.findIndex((b) => b.id === tagFocusId) : -1;
if (fIdx >= 0) {
keep.add(blocks[fIdx].id);
let lvl = Math.max(0, blocks[fIdx].level || 0);
for (let i = fIdx - 1; i >= 0 && lvl > 0; i--) {
const l = Math.max(0, blocks[i].level || 0);
if (l < lvl) {
if (!tagShownRef.current.has(blocks[i].id)) break;
keep.add(blocks[i].id);
lvl = l;
}
}
}
// Runs of shown bullets: a shown bullet brings its children; gaps start a new run
const groups: DocumentBlock[][] = [];
let current: DocumentBlock[] | null = null;
let insideLevel: number | null = null;
for (const b of blocks) {
const lvl = Math.max(0, b.level || 0);
if (insideLevel !== null && lvl <= insideLevel) insideLevel = null;
const shown = insideLevel !== null || keep.has(b.id) || extractHashtags(b.content).includes(tag);
if (!shown) {
current = null;
continue;
}
if (!current) {
current = [];
groups.push(current);
}
current.push(b);
if (insideLevel === null) insideLevel = lvl;
}
tagShownRef.current = new Set(groups.flat().map((b) => b.id));
return groups;
}, [inTagPage, tagFilter, blocks, tagFocusId, tagFreshIds]);
const handleTagCreateBlock = useCallback(
(newBlock: DocumentBlock, afterBlockId?: string) => {
setTagFreshIds((prev) => new Set(prev).add(newBlock.id));
handleCreateBlock(newBlock, afterBlockId);
},
[handleCreateBlock]
);
const handleTagInsertBlocks = useCallback(
(afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => {
setTagFreshIds((prev) => {
const next = new Set(prev);
newBlocks.forEach((b) => next.add(b.id));
return next;
});
handleInsertBlocks(afterBlockId, updatedCurrent, newBlocks);
},
[handleInsertBlocks]
);
// Dragging inside one run: its positions are positions in the whole note
const tagMoveBlock = (group: DocumentBlock[]) => (blockId: string, targetIndex: number, newLevel?: number) => {
const anchor = targetIndex < group.length ? group[targetIndex] : group[group.length - 1];
const idx = currentBlocksRef.current.findIndex((b) => b.id === anchor.id);
if (idx < 0) return;
handleMoveBlock(blockId, targetIndex < group.length ? idx : idx + 1, newLevel);
};
const handleTogglePin = async () => {
if (!doc) return;
try {
@@ -1065,13 +1165,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
return (
<div
ref={scrollRef}
className={inJournal ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
className={embedded ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
data-sync-channel={engineState?.channel}
>
{desktopBar && actionsTarget && createPortal(actionsBlock, actionsTarget)}
{/* Top Document Action Bar (the journal has one for all days; large screens use the tab bar) */}
{!inJournal && !desktopBar && (
{!embedded && !desktopBar && (
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
<div className="flex flex-1 items-center gap-2 min-w-0">
{onBackMobile && (
@@ -1173,7 +1273,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
<div
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
inJournal ? 'py-3' : 'py-5'
inTagPage ? 'py-2' : inJournal ? 'py-3' : 'py-5'
} ${focusMode ? 'max-w-3xl mx-auto' : ''}`}
>
{desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>}
@@ -1289,6 +1389,54 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
/* =========================================================================
STANDARD GESAMTDOKUMENT-ANSICHT
========================================================================= */
inTagPage ? (
/* Hashtag page: the note's title as a fixed heading, below the branches with the hashtag,
edited right in this note */
tagGroups.length > 0 ? (
<div className="space-y-1">
<button
type="button"
onClick={() => onOpenDocBlock?.(docId)}
className="flex items-center gap-2 min-w-0 max-w-full text-left text-lg font-semibold text-main hover:text-accent transition-colors cursor-pointer"
title="Notiz öffnen"
>
{doc?.icon && <span className="shrink-0">{doc.icon}</span>}
<span className="truncate">{title || 'Unbenannte Notiz'}</span>
{doc?.folderPath && <span className="shrink-0 text-[11px] font-normal text-faint">{doc.folderPath}</span>}
</button>
{tagGroups.map((group) => (
<OutlinerTree
key={group[0].id}
compact
trailingArea={false}
docId={docId}
blocks={group}
canEdit={canEdit}
levelOffset={Math.min(...group.map((b) => Math.max(0, b.level || 0)))}
onZoomBlock={(id) => onOpenDocBlock?.(docId, id)}
onUpdateBlock={handleUpdateBlock}
onUpdateBlocks={handleUpdateBlocks}
onCreateBlock={handleTagCreateBlock}
onInsertBlocks={handleTagInsertBlocks}
onDeleteBlock={handleDeleteBlock}
onReplaceRange={handleReplaceRange}
onMoveBlock={tagMoveBlock(group)}
onMoveSibling={handleMoveSibling}
onOpenTask={onOpenTask}
onTagClick={onSelectTag}
onWikiLinkClick={onWikiLinkClick}
onCopyBlockLink={handleCopyBlockLink}
onFocusedBlockChange={handleTagFocusChange}
lockedBlocks={lockedBlocks}
onLockedAction={handleLockedAction}
onResolveConflict={handleResolveConflict}
onUndo={handleUndo}
onRedo={handleRedo}
/>
))}
</div>
) : null
) : (
<>
{/* Document Icon + Title Header */}
<div className="flex items-center gap-3 mb-4 relative">
@@ -1386,8 +1534,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onRedo={handleRedo}
/>
{onOpenDocBlock && !inTrash && !inJournal && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
{onOpenDocBlock && !inTrash && !embedded && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
</>
)
)}
</div>
@@ -42,6 +42,8 @@ export interface OutlinerTreeProps {
/** Deleting a selection over several bullets in one step: the first bullet with its new text, the
* bullets to delete, and children of the last bullet that move under the first */
onReplaceRange?: (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => void;
/** The empty area below the last bullet (click adds one); off where several trees stack (hashtag page) */
trailingArea?: boolean;
}
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
@@ -71,6 +73,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
onUndo,
onRedo,
onReplaceRange,
trailingArea = true,
}, ref) => {
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
@@ -955,7 +958,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
);
})}
{canEdit && (
{canEdit && trailingArea && (
<div
className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
+57 -160
View File
@@ -58,6 +58,7 @@ import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { EmojiIconButton } from '../../components/EmojiIconButton';
import { LazyMount } from '../../components/LazyMount';
import { extractHashtags } from '../../lib/hashtags';
interface WorkspaceViewProps {
@@ -1438,13 +1439,27 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return Array.from(map.values());
}, [docs, selectedTag, tagDetails]);
const activeTagTotalMatches = useMemo(() => {
let count = 0;
for (const g of activeTagGroups) {
count += g.matchedBlocks.length;
// The hashtag page keeps its order while open: a note whose last hashtag was just removed stays in its
// place (its editor then shows nothing), new matches come at the end; leaving the page starts afresh
const tagPageOrderRef = useRef<{ tag: string | null; groups: typeof activeTagGroups }>({ tag: null, groups: [] });
const tagPageGroups = useMemo(() => {
const prev = tagPageOrderRef.current;
const byId = new Map(activeTagGroups.map((g) => [g.docId, g]));
let groups = activeTagGroups;
if (prev.tag === selectedTag) {
const known = new Set(prev.groups.map((g) => g.docId));
groups = [
...prev.groups.map((g) => byId.get(g.docId) || g),
...activeTagGroups.filter((g) => !known.has(g.docId)),
];
}
return count;
}, [activeTagGroups]);
tagPageOrderRef.current = { tag: selectedTag, groups };
return groups;
}, [activeTagGroups, selectedTag]);
useEffect(() => {
if (subView !== 'tag_detail') tagPageOrderRef.current = { tag: null, groups: [] };
}, [subView]);
const selectedDocObj = useMemo(
() => docs.find((d) => d.id === selectedDocId) || null,
@@ -2386,7 +2401,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
</div>
) : subView === 'tag_detail' && selectedTag ? (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-editor">
<div className="w-full space-y-4">
{/* Back to all tags */}
<div className="flex items-center justify-between border-b border-border pb-3">
@@ -2435,160 +2450,42 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
/>
</div>
{/* Matched Document Blocks */}
<div className="space-y-3">
<h3 className="text-[10px] font-bold uppercase tracking-wider text-faint">
Verknüpfte Outliner-Blöcke & Unterpunkte ({activeTagTotalMatches})
</h3>
{activeTagGroups.length === 0 ? (
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-canvas">
<p className="font-semibold text-main">Keine Blöcke mit #{selectedTag} gefunden.</p>
</div>
) : (
activeTagGroups.map((docGroup) => {
const docObj = docs.find((d) => d.id === docGroup.docId);
return (
<div
key={docGroup.docId}
className="p-3 rounded-card border border-border bg-canvas space-y-2"
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
>
{docGroup.icon && <span>{docGroup.icon}</span>}
<span>{docGroup.title}</span>
{docGroup.folderPath && (
<span className="badge-neutral text-[10px] font-normal">
📁 {docGroup.folderPath}
</span>
)}
</button>
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
title="Notiz öffnen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
{/* Matched bullets: each note as a fixed heading with the live outliner of its branches below,
edited right in the note (DocumentEditor variant "tag"); editors load when scrolled near */}
{tagPageGroups.length === 0 ? (
<p className="py-8 text-center text-xs text-muted">Keine Punkte mit #{selectedTag} gefunden.</p>
) : (
<div className="space-y-2 -mx-2 sm:-mx-8">
{tagPageGroups.map((docGroup) => (
<LazyMount
key={docGroup.docId}
placeholder={
<div className="px-2 sm:px-8 py-2 min-h-[6rem] text-lg font-semibold text-muted">
{docGroup.icon && <span className="mr-2">{docGroup.icon}</span>}
{docGroup.title}
</div>
<div className="space-y-2 pl-3 border-l-2 border-border/70">
{docGroup.matchedBlocks.map((mb) => (
<div key={mb.rootBlock.id} className="space-y-1">
{/* Root Bullet Point containing the hashtag */}
<div
onClick={() => handleJumpToDocBlock(docGroup.docId, mb.rootBlock.id)}
className="flex items-start justify-between gap-1.5 text-xs text-main font-medium group cursor-pointer hover:bg-panel-elevated p-1.5 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{mb.rootBlock.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
mb.rootBlock.id,
Boolean(mb.rootBlock.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
mb.rootBlock.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-accent font-bold select-none text-sm leading-none mt-0.5">
•
</span>
)}
<span
className={`flex-1 break-words ${
mb.rootBlock.checked ? 'line-through text-faint' : ''
}`}
>
{mb.rootBlock.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
{/* All Children indented */}
{mb.children && mb.children.length > 0 && (
<div className="space-y-0.5">
{mb.children.map((ch) => {
const relLevel = Math.max(
1,
(ch.level || 0) - (mb.rootBlock.level || 0)
);
return (
<div
key={ch.id}
onClick={() => handleJumpToDocBlock(docGroup.docId, ch.id)}
style={{ paddingLeft: `${relLevel * 16}px` }}
className="flex items-start justify-between gap-1.5 text-xs text-muted hover:text-main group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{ch.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
ch.id,
Boolean(ch.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
ch.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-faint group-hover:text-accent font-bold select-none text-xs leading-none mt-1">
◦
</span>
)}
<span
className={`flex-1 break-words ${
ch.checked ? 'line-through text-faint' : ''
}`}
>
{ch.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
);
})
)}
</div>
}
>
<DocumentEditor
key={`${docGroup.docId}:${selectedTag}`}
variant="tag"
tagFilter={selectedTag}
docId={docGroup.docId}
currentUser={currentUser}
initialDoc={docs.find((d) => d.id === docGroup.docId) || null}
availableFolders={allFolderPaths}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onOpenTask={onOpenTask}
/>
</LazyMount>
))}
</div>
)}
</div>
</div>
) : subView === 'todos' ? (