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
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:
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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user