feat: show note title in breadcrumb bar when scrolled past title

Use IntersectionObserver on the title section to detect when it
scrolls out of view. When hidden, the breadcrumb bar morphs to
display "<type> › <emoji> <title>" 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>
This commit is contained in:
Test
2026-03-31 10:42:38 +02:00
parent b78e42272e
commit 7d94bb26bb
3 changed files with 76 additions and 5 deletions

View File

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

View File

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

View File

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