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

@@ -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>