Files
tolaria/src/components/EditorContent.tsx
Test 7d94bb26bb 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>
2026-03-31 10:42:38 +02:00

260 lines
9.8 KiB
TypeScript

import type React 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'
import { BreadcrumbBar } from './BreadcrumbBar'
import { TitleField } from './TitleField'
import { NoteIcon } from './NoteIcon'
import { TrashedNoteBanner } from './TrashedNoteBanner'
import { ArchivedNoteBanner } from './ArchivedNoteBanner'
import { ConflictNoteBanner } from './ConflictNoteBanner'
import { RawEditorView } from './RawEditorView'
import { countWords } from '../utils/wikilinks'
import { SingleEditorView } from './SingleEditorView'
import { isEmoji } from '../utils/emoji'
import { useEditorTheme } from '../hooks/useTheme'
interface Tab {
entry: VaultEntry
content: string
}
interface EditorContentProps {
activeTab: Tab | null
isLoadingNewTab: boolean
entries: VaultEntry[]
editor: ReturnType<typeof useCreateBlockNote>
diffMode: boolean
diffContent: string | null
diffLoading: boolean
onToggleDiff: () => void
rawMode: boolean
onToggleRaw: () => void
onRawContentChange?: (path: string, content: string) => void
onSave?: () => void
activeStatus: NoteStatus
showDiffToggle: boolean
showAIChat?: boolean
onToggleAIChat?: () => void
inspectorCollapsed: boolean
onToggleInspector: () => void
onNavigateWikilink: (target: string) => void
onEditorChange?: () => void
onTrashNote?: (path: string) => void
onRestoreNote?: (path: string) => void
onDeleteNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
/** Ref updated by RawEditorView on every keystroke with the latest doc. */
rawLatestContentRef?: React.MutableRefObject<string | null>
/** Called when the user edits the dedicated title field. */
onTitleChange?: (path: string, newTitle: string) => void
/** Called when user sets or changes an emoji icon via the picker. */
onSetNoteIcon?: (path: string, emoji: string) => void
/** Called when user removes an emoji icon. */
onRemoveNoteIcon?: (path: string) => void
/** Whether the active note has a merge conflict. */
isConflicted?: boolean
/** Resolve conflict by keeping the local version. */
onKeepMine?: (path: string) => void
/** Resolve conflict by keeping the remote version. */
onKeepTheirs?: (path: string) => void
}
function EditorLoadingSkeleton() {
return (
<div className="flex flex-1 flex-col gap-3 p-8 animate-pulse" style={{ minHeight: 0 }}>
<div className="h-6 w-2/5 rounded bg-muted" />
<div className="h-4 w-4/5 rounded bg-muted" />
<div className="h-4 w-3/5 rounded bg-muted" />
<div className="h-4 w-4/5 rounded bg-muted" />
<div className="h-4 w-2/5 rounded bg-muted" />
</div>
)
}
function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | null; onToggleDiff: () => void }) {
return (
<div className="flex-1 overflow-auto">
<button
className="flex items-center gap-1.5 px-4 py-2 text-xs text-primary bg-muted border-b border-border cursor-pointer hover:bg-accent transition-colors w-full border-t-0 border-l-0 border-r-0"
onClick={onToggleDiff}
title="Back to editor"
>
<span style={{ fontSize: 14, lineHeight: 1 }}>&larr;</span>
Back to editor
</button>
<DiffView diff={diffContent ?? ''} />
</div>
)
}
function RawModeEditorSection({
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef,
}: {
rawMode: boolean
activeTab: Tab | null
entries: VaultEntry[]
onContentChange?: (path: string, content: string) => void
onSave?: () => void
latestContentRef?: React.MutableRefObject<string | null>
}) {
if (!rawMode || !activeTab) return null
return (
<RawEditorView
key={activeTab.entry.path}
content={activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
latestContentRef={latestContentRef}
/>
)
}
/** Bind an optional callback to a path, returning undefined if callback is absent */
function bindPath(cb: ((path: string) => void) | undefined, path: string) {
return cb ? () => cb(path) : undefined
}
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)
const path = activeTab.entry.path
return (
<BreadcrumbBar
entry={activeTab.entry}
wordCount={wordCount}
titleHidden={titleHidden}
showDiffToggle={props.showDiffToggle}
diffMode={props.diffMode}
diffLoading={props.diffLoading}
onToggleDiff={props.onToggleDiff}
rawMode={props.rawMode}
onToggleRaw={props.onToggleRaw}
showAIChat={props.showAIChat}
onToggleAIChat={props.onToggleAIChat}
inspectorCollapsed={props.inspectorCollapsed}
onToggleInspector={props.onToggleInspector}
onTrash={bindPath(props.onTrashNote, path)}
onRestore={bindPath(props.onRestoreNote, path)}
onArchive={bindPath(props.onArchiveNote, path)}
onUnarchive={bindPath(props.onUnarchiveNote, path)}
/>
)
}
export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
rawMode, onToggleRaw, onRawContentChange, onSave,
onNavigateWikilink, onEditorChange, vaultPath,
onDeleteNote, rawLatestContentRef, onTitleChange,
onSetNoteIcon, onRemoveNoteIcon,
isConflicted, onKeepMine, onKeepTheirs,
...breadcrumbProps
}: EditorContentProps) {
// Look up trashed/archived from the latest vault entries, not the tab snapshot,
// so the banner appears regardless of navigation context.
const { cssVars } = useEditorTheme()
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
const showEditor = !diffMode && !rawMode
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])
const handleRemoveIcon = useCallback(() => {
if (activeTab) onRemoveNoteIcon?.(activeTab.entry.path)
}, [activeTab, onRemoveNoteIcon])
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
{activeTab && (
<ActiveTabBreadcrumb
activeTab={activeTab}
titleHidden={titleHidden}
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
/>
)}
{activeTab && isTrashed && (
<TrashedNoteBanner
onRestore={() => breadcrumbProps.onRestoreNote?.(activeTab.entry.path)}
onDeletePermanently={() => onDeleteNote?.(activeTab.entry.path)}
/>
)}
{activeTab && isArchived && breadcrumbProps.onUnarchiveNote && (
<ArchivedNoteBanner onUnarchive={() => breadcrumbProps.onUnarchiveNote!(activeTab.entry.path)} />
)}
{activeTab && isConflicted && (
<ConflictNoteBanner
onKeepMine={() => onKeepMine?.(activeTab.entry.path)}
onKeepTheirs={() => onKeepTheirs?.(activeTab.entry.path)}
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<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 ref={titleSectionRef} className="title-section">
{!emojiIcon && (
<div className="title-section__add-icon">
<NoteIcon
icon={null}
editable={!isTrashed}
onSetIcon={handleSetIcon}
onRemoveIcon={handleRemoveIcon}
/>
</div>
)}
<div className={`title-section__row${emojiIcon ? '' : ' title-section__row--no-icon'}`}>
{emojiIcon && (
<NoteIcon
icon={emojiIcon}
editable={!isTrashed}
onSetIcon={handleSetIcon}
onRemoveIcon={handleRemoveIcon}
/>
)}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable={!isTrashed}
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
/>
</div>
<div className="title-section__separator" />
</div>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} editable={!isTrashed} />
</div>
)}
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)
}