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
Generated
+2
-2
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.47.0",
|
||||
"version": "0.48.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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' : ''
|
||||
|
||||
@@ -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' ? (
|
||||
|
||||
Reference in new issue
Block a user