From 8238c8a73f3fdf99310d03b998de195cd1c626de Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 17:54:14 +0100 Subject: [PATCH] feat(workspace): hashtag page shows the live outliner of each note's tagged branches, edited in place Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 +- Doku/modules/workspace.md | 2 +- README.md | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/components/LazyMount.tsx | 31 +++ .../src/features/workspace/DocumentEditor.tsx | 167 +++++++++++++- .../src/features/workspace/OutlinerTree.tsx | 5 +- .../src/features/workspace/WorkspaceView.tsx | 217 +++++------------- 9 files changed, 262 insertions(+), 176 deletions(-) create mode 100644 frontend/src/components/LazyMount.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index cb98907..0f5cf7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.48.0] - 2026-11-01 + +### Changed +- Workspace, Hashtag-Seite: Die Fundstellen lassen sich direkt bearbeiten. Jede Notiz erscheint als feste Überschrift (Klick öffnet sie), darunter der normale Outliner mit den Punkten, die den Hashtag tragen, samt Unterpunkten. Tippen, Enter, Einrücken, Abhaken und Rückgängig wirken direkt in der Originalnotiz, auch gemeinsam mit anderen. Wer den Hashtag aus einem Punkt entfernt oder per Enter einen neuen Punkt ohne Hashtag anlegt, sieht ihn, bis der Cursor ihn verlässt. Die Rahmen um die einzelnen Notizen sind weg, die Seite sieht aus wie eine normale Notiz; Notizen laden erst beim Hineinscrollen. + ## [0.47.0] - 2026-11-01 ### Added @@ -750,7 +755,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.47.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.48.0...dev +[0.48.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.47.0...v0.48.0 [0.47.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.46.0...v0.47.0 [0.46.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.2...v0.46.0 [0.45.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.1...v0.45.2 diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 4500330..b6a5838 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -44,7 +44,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`). Ordner zeigen ihren Zustand am offenen bzw. geschlossenen Ordnersymbol (kein Pfeil). - **Mehrere Notizen** (PC): Strg/Cmd+Klick markiert einzeln, Shift+Klick einen Bereich in der angezeigten Reihenfolge (eine angeheftete Notiz steht zweimal im Baum; der Bereich beginnt an der Stelle, die angeklickt wurde); ein normaler Klick, Escape oder ein Klick ins Leere hebt die Markierung auf. Wer eine markierte Notiz zieht, nimmt alle markierten mit, in einen Ordner oder auf den Papierkorb (Zusammenführen gibt es dabei nicht). - **Auf den Papierkorb ziehen** (auch eine einzelne Notiz, am Handy per langem Drücken wie beim Verschieben in Ordner) verschiebt in den Papierkorb; der Hinweis bietet „Rückgängig“. -- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`). +- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite trägt eine eigene Freitext-Notiz (`tag_pages`) und zeigt darunter jede Notiz mit Fundstellen als feste Überschrift und darunter den echten Outliner der Punkte mit dem Hashtag samt Unterpunkten (`DocumentEditor` mit `variant="tag"`, je Notiz eine eigene Sync-Verbindung, erst beim Hineinscrollen geladen). Bearbeiten wirkt direkt in der Notiz; ein Punkt ohne Hashtag (entfernt oder neu per Enter) bleibt sichtbar, solange der Cursor darin steht, und verschwindet, sobald er ihn verlässt. Die Reihenfolge der Notizen bleibt, solange die Seite offen ist. - **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz. - **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron. diff --git a/README.md b/README.md index d71eb6e..4154169 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.47.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.48.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 23e6052..e5cace4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 3d50e36..63f3f23 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.47.0", + "version": "0.48.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/components/LazyMount.tsx b/frontend/src/components/LazyMount.tsx new file mode 100644 index 0000000..075a19b --- /dev/null +++ b/frontend/src/components/LazyMount.tsx @@ -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 = ({ children, placeholder }) => { + const ref = useRef(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
{shown ? children : placeholder}
; +}; diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 9adea12..03d6441 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -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 = ({ 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(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); const [blocks, setBlocks] = useState(() => @@ -278,7 +285,7 @@ export const DocumentEditor: React.FC = ({ 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 = ({ ); 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 = ({ [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(null); + const [tagFreshIds, setTagFreshIds] = useState>(() => new Set()); + const tagBlurTimerRef = useRef(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>(new Set()); + const tagGroups = useMemo(() => { + if (!inTagPage || !tagFilter) return [] as DocumentBlock[][]; + const tag = tagFilter.replace(/^#/, '').toLowerCase(); + const keep = new Set(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 = ({ return (
{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 && (
{onBackMobile && ( @@ -1173,7 +1273,7 @@ export const DocumentEditor: React.FC = ({ {/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
{desktopBar && zoomedBlock && breadcrumbs.length > 0 &&
{breadcrumbNav}
} @@ -1289,6 +1389,54 @@ export const DocumentEditor: React.FC = ({ /* ========================================================================= 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 ? ( +
+ + {tagGroups.map((group) => ( + 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} + /> + ))} +
+ ) : null + ) : ( <> {/* Document Icon + Title Header */}
@@ -1386,8 +1534,9 @@ export const DocumentEditor: React.FC = ({ onRedo={handleRedo} /> - {onOpenDocBlock && !inTrash && !inJournal && } + {onOpenDocBlock && !inTrash && !embedded && } + ) )}
diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 64e8fda..9407f50 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -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(({ @@ -71,6 +73,7 @@ export const OutlinerTree = forwardRef(({ onUndo, onRedo, onReplaceRange, + trailingArea = true, }, ref) => { const [focusedBlockId, setFocusedBlockId] = useState(null); @@ -955,7 +958,7 @@ export const OutlinerTree = forwardRef(({ ); })} - {canEdit && ( + {canEdit && trailingArea && (
= ({ 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 = ({
) : subView === 'tag_detail' && selectedTag ? ( -
+
{/* Back to all tags */}
@@ -2435,160 +2450,42 @@ export const WorkspaceView: React.FC = ({ />
- {/* Matched Document Blocks */} -
-

- Verknüpfte Outliner-Blöcke & Unterpunkte ({activeTagTotalMatches}) -

- {activeTagGroups.length === 0 ? ( -
-

Keine Blöcke mit #{selectedTag} gefunden.

-
- ) : ( - activeTagGroups.map((docGroup) => { - const docObj = docs.find((d) => d.id === docGroup.docId); - return ( -
-
- - + {/* 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 ? ( +

Keine Punkte mit #{selectedTag} gefunden.

+ ) : ( +
+ {tagPageGroups.map((docGroup) => ( + + {docGroup.icon && {docGroup.icon}} + {docGroup.title}
- -
- {docGroup.matchedBlocks.map((mb) => ( -
- {/* Root Bullet Point containing the hashtag */} -
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" - > -
- {mb.rootBlock.type === 'todo' ? ( - - ) : ( - - • - - )} - - {mb.rootBlock.content} - -
- -
- - {/* All Children indented */} - {mb.children && mb.children.length > 0 && ( -
- {mb.children.map((ch) => { - const relLevel = Math.max( - 1, - (ch.level || 0) - (mb.rootBlock.level || 0) - ); - return ( -
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" - > -
- {ch.type === 'todo' ? ( - - ) : ( - - ◦ - - )} - - {ch.content} - -
- -
- ); - })} -
- )} -
- ))} -
-
- ); - }) - )} -
+ } + > + d.id === docGroup.docId) || null} + availableFolders={allFolderPaths} + onDocMetadataChange={handleDocMetadataChange} + onDeleteDoc={handleDeleteDocument} + onOpenShareModal={openDocShareModal} + onSelectTag={handleSelectTag} + onWikiLinkClick={handleWikiLinkClick} + onOpenDocBlock={handleJumpToDocBlock} + onOpenTask={onOpenTask} + /> + + ))} +
+ )}
) : subView === 'todos' ? (