From 7d94bb26bbd721c4c08a8ad449c73d7bf09d7eb3 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 31 Mar 2026 10:42:38 +0200 Subject: [PATCH] feat: show note title in breadcrumb bar when scrolled past title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use IntersectionObserver on the title section to detect when it scrolls out of view. When hidden, the breadcrumb bar morphs to display " " on the left and gains a subtle shadow to separate it from the content below. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --- src/components/BreadcrumbBar.test.tsx | 32 +++++++++++++++++++++++++++ src/components/BreadcrumbBar.tsx | 25 +++++++++++++++++++-- src/components/EditorContent.tsx | 24 +++++++++++++++++--- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 81d60225..26445de6 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -111,6 +111,38 @@ describe('BreadcrumbBar — archive/unarchive', () => { }) }) +describe('BreadcrumbBar — title in breadcrumb on scroll', () => { + it('does not show title when titleHidden is false', () => { + render(<BreadcrumbBar entry={baseEntry} {...defaultProps} titleHidden={false} />) + expect(screen.queryByText('Test Note')).not.toBeInTheDocument() + }) + + it('shows type and title when titleHidden is true', () => { + render(<BreadcrumbBar entry={baseEntry} {...defaultProps} titleHidden={true} />) + expect(screen.getByText('Note')).toBeInTheDocument() + expect(screen.getByText('›')).toBeInTheDocument() + expect(screen.getByText('Test Note')).toBeInTheDocument() + }) + + it('shows emoji icon when entry has an emoji icon', () => { + const entryWithEmoji = { ...baseEntry, icon: '🚀' } + render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} titleHidden={true} />) + expect(screen.getByText('🚀')).toBeInTheDocument() + }) + + it('does not show icon when entry has a non-emoji icon', () => { + const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' } + render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} titleHidden={true} />) + expect(screen.queryByText('cooking-pot')).not.toBeInTheDocument() + }) + + it('falls back to "Note" when isA is null', () => { + const entryNoType = { ...baseEntry, isA: null } + render(<BreadcrumbBar entry={entryNoType} {...defaultProps} titleHidden={true} />) + expect(screen.getByText('Note')).toBeInTheDocument() + }) +}) + describe('BreadcrumbBar — raw editor toggle', () => { it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => { const onToggleRaw = vi.fn() diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 55b3ca5b..93b76ae4 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -32,6 +32,8 @@ interface BreadcrumbBarProps { onRestore?: () => void onArchive?: () => void onUnarchive?: () => void + /** When true, the note title is scrolled out of view — show it inline. */ + titleHidden?: boolean } const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const @@ -161,19 +163,38 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog ) } +function BreadcrumbTitle({ entry }: { entry: VaultEntry }) { + const typeLabel = entry.isA ?? 'Note' + const icon = entry.icon + const emojiIcon = icon && /^\p{Emoji}/u.test(icon) ? icon : null + return ( + <div className="flex items-center gap-1.5 min-w-0 text-sm text-muted-foreground"> + <span className="shrink-0">{typeLabel}</span> + <span className="shrink-0 text-border">›</span> + {emojiIcon && <span className="shrink-0">{emojiIcon}</span>} + <span className="truncate font-medium text-foreground">{entry.title}</span> + </div> + ) +} + export const BreadcrumbBar = memo(function BreadcrumbBar({ - entry, ...actionProps + entry, titleHidden, ...actionProps }: BreadcrumbBarProps) { return ( <div data-tauri-drag-region - className="flex shrink-0 items-center justify-end" + className="flex shrink-0 items-center" style={{ height: 52, background: 'var(--background)', padding: '6px 16px', + boxShadow: titleHidden ? '0 1px 3px rgba(0,0,0,0.08)' : 'none', + transition: 'box-shadow 0.2s ease', }} > + <div className="flex-1 min-w-0"> + {titleHidden && <BreadcrumbTitle entry={entry} />} + </div> <BreadcrumbActions entry={entry} {...actionProps} /> </div> ) diff --git a/src/components/EditorContent.tsx b/src/components/EditorContent.tsx index c82de615..8fa39b8a 100644 --- a/src/components/EditorContent.tsx +++ b/src/components/EditorContent.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useCallback } from 'react' +import { useCallback, useRef, useState, useEffect } from 'react' import type { VaultEntry, NoteStatus } from '../types' import type { useCreateBlockNote } from '@blocknote/react' import { DiffView } from './DiffView' @@ -120,8 +120,9 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) { return cb ? () => cb(path) : undefined } -function ActiveTabBreadcrumb({ activeTab, props }: { +function ActiveTabBreadcrumb({ activeTab, titleHidden, props }: { activeTab: Tab + titleHidden: boolean props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'> }) { const wordCount = countWords(activeTab.content) @@ -130,6 +131,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: { <BreadcrumbBar entry={activeTab.entry} wordCount={wordCount} + titleHidden={titleHidden} showDiffToggle={props.showDiffToggle} diffMode={props.diffMode} diffLoading={props.diffLoading} @@ -168,6 +170,21 @@ export function EditorContent({ const entryIcon = activeTab?.entry.icon ?? null const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null + const titleSectionRef = useRef<HTMLDivElement | null>(null) + const [titleScrolledAway, setTitleScrolledAway] = useState(false) + const titleHidden = showEditor && titleScrolledAway + + useEffect(() => { + const el = titleSectionRef.current + if (!el) return + const observer = new IntersectionObserver( + ([e]) => setTitleScrolledAway(!e.isIntersecting), + { threshold: 0 }, + ) + observer.observe(el) + return () => observer.disconnect() + }, [activeTab?.entry.path, showEditor]) + const handleSetIcon = useCallback((emoji: string) => { if (activeTab) onSetNoteIcon?.(activeTab.entry.path, emoji) }, [activeTab, onSetNoteIcon]) @@ -181,6 +198,7 @@ export function EditorContent({ {activeTab && ( <ActiveTabBreadcrumb activeTab={activeTab} + titleHidden={titleHidden} props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }} /> )} @@ -203,7 +221,7 @@ export function EditorContent({ <RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} /> {showEditor && activeTab && ( <div className="editor-scroll-area" style={cssVars as React.CSSProperties}> - <div className="title-section"> + <div ref={titleSectionRef} className="title-section"> {!emojiIcon && ( <div className="title-section__add-icon"> <NoteIcon