feat: add note layout preference

This commit is contained in:
lucaronin
2026-04-26 04:41:18 +02:00
parent 5fd8f8fb40
commit c7edc71a97
19 changed files with 277 additions and 16 deletions

View File

@@ -306,3 +306,26 @@ describe('BreadcrumbBar — raw editor toggle', () => {
expect(screen.getByRole('button', { name: 'Open the raw editor' })).toBeInTheDocument()
})
})
describe('BreadcrumbBar — note layout toggle', () => {
it('shows the left-align layout action while centered', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Switch to left-aligned note layout' })).toBeInTheDocument()
})
it('shows the centered layout action while left-aligned', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="left" onToggleNoteLayout={vi.fn()} />)
expect(screen.getByRole('button', { name: 'Switch to centered note layout' })).toBeInTheDocument()
})
it('calls onToggleNoteLayout when the layout button is clicked', () => {
const onToggleNoteLayout = vi.fn()
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} noteLayout="centered" onToggleNoteLayout={onToggleNoteLayout} />)
fireEvent.click(screen.getByRole('button', { name: 'Switch to left-aligned note layout' }))
expect(onToggleNoteLayout).toHaveBeenCalledOnce()
})
})

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from 'react'
import type { VaultEntry } from '../types'
import type { NoteLayout, VaultEntry } from '../types'
import { cn } from '@/lib/utils'
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'
@@ -17,6 +17,8 @@ import {
Star,
CheckCircle,
ArrowsClockwise,
TextAlignCenter,
TextAlignLeft,
} from '@phosphor-icons/react'
import { NoteTitleIcon } from './NoteTitleIcon'
import { slugify } from '../hooks/useNoteCreation'
@@ -43,6 +45,8 @@ interface BreadcrumbBarProps {
onArchive?: () => void
onUnarchive?: () => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
}
@@ -181,6 +185,29 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
)
}
function NoteLayoutAction({
noteLayout = 'centered',
onToggleNoteLayout,
}: {
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
}) {
if (!onToggleNoteLayout) return null
const isLeftAligned = noteLayout === 'left'
return (
<IconActionButton
copy={{ label: isLeftAligned ? 'Switch to centered note layout' : 'Switch to left-aligned note layout' }}
onClick={onToggleNoteLayout}
className={cn(isLeftAligned ? 'text-foreground' : 'hover:text-foreground')}
>
{isLeftAligned
? <TextAlignLeft size={16} className={BREADCRUMB_ICON_CLASS} />
: <TextAlignCenter size={16} className={BREADCRUMB_ICON_CLASS} />}
</IconActionButton>
)
}
function FavoriteAction({ favorite, onToggleFavorite }: { favorite: boolean; onToggleFavorite?: () => void }) {
return (
<ToggleIconAction
@@ -498,6 +525,8 @@ function BreadcrumbActions({
rawMode,
onToggleRaw,
forceRawMode,
noteLayout,
onToggleNoteLayout,
showAIChat,
onToggleAIChat,
inspectorCollapsed,
@@ -519,6 +548,7 @@ function BreadcrumbActions({
onToggleDiff={onToggleDiff}
/>
{!forceRawMode && <RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />}
<NoteLayoutAction noteLayout={noteLayout} onToggleNoteLayout={onToggleNoteLayout} />
<AIChatAction showAIChat={showAIChat} onToggleAIChat={onToggleAIChat} />
<ArchiveAction archived={entry.archived} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction onDelete={onDelete} />

View File

@@ -204,6 +204,22 @@
width: 100%;
}
.editor-content-layout--left .editor-content-wrapper {
margin-left: clamp(16px, 6%, 96px);
margin-right: auto;
}
.raw-editor-codemirror {
max-width: var(--editor-max-width, 760px);
width: 100%;
margin: 0 auto;
}
.editor-content-layout--left .raw-editor-codemirror {
margin-left: clamp(16px, 6%, 96px);
margin-right: auto;
}
/* --- Note Icon Area --- */
.note-icon-area {
display: flex;
@@ -322,3 +338,11 @@
padding: 12px 0;
}
}
@container editor (max-width: 900px) {
.editor-content-layout--left .editor-content-wrapper,
.editor-content-layout--left .raw-editor-codemirror {
margin-left: auto;
margin-right: auto;
}
}

View File

@@ -5,7 +5,7 @@ import '@blocknote/mantine/style.css'
import 'katex/dist/katex.min.css'
import { uploadImageFile } from '../hooks/useImageDrop'
import { DEFAULT_AI_AGENT, type AiAgentId } from '../lib/aiAgents'
import type { VaultEntry, GitCommit, NoteStatus } from '../types'
import type { VaultEntry, GitCommit, NoteLayout, NoteStatus } from '../types'
import type { NoteListItem } from '../utils/ai-context'
import type { FrontmatterValue } from './Inspector'
import { ResizeHandle } from './ResizeHandle'
@@ -73,6 +73,8 @@ interface EditorProps {
onSave?: () => void
/** Called when the user explicitly renames the filename from the breadcrumb. */
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
canGoBack?: boolean
canGoForward?: boolean
onGoBack?: () => void
@@ -299,6 +301,8 @@ function EditorLayout({
rawModeContent,
rawLatestContentRef,
onRenameFilename,
noteLayout,
onToggleNoteLayout,
isConflicted,
onKeepMine,
onKeepTheirs,
@@ -353,6 +357,8 @@ function EditorLayout({
rawModeContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
@@ -412,6 +418,8 @@ function EditorLayout({
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteLayout={noteLayout}
onToggleNoteLayout={onToggleNoteLayout}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}
@@ -467,6 +475,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
noteLayout, onToggleNoteLayout,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
flushPendingRawContentRef,
@@ -527,6 +536,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
noteLayout={noteLayout}
onToggleNoteLayout={onToggleNoteLayout}
isConflicted={isConflicted}
onKeepMine={onKeepMine}
onKeepTheirs={onKeepTheirs}

View File

@@ -373,7 +373,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
<RawEditorYamlErrorBanner error={pendingChanges.yamlError} />
<div
ref={containerRef}
className="flex flex-1 min-h-0"
className="raw-editor-codemirror flex flex-1 min-h-0"
data-testid="raw-editor-codemirror"
aria-label="Raw editor"
/>

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { EditorContentLayout } from './EditorContentLayout'
vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: () => <div data-testid="breadcrumb-bar" />,
BreadcrumbBar: ({ noteLayout }: { noteLayout?: string }) => <div data-testid="breadcrumb-bar" data-note-layout={noteLayout} />,
}))
vi.mock('../ArchivedNoteBanner', () => ({
@@ -63,6 +63,8 @@ function createModel(overrides: Record<string, unknown> = {}) {
onEditorChange: vi.fn(),
isDeletedPreview: false,
rawLatestContentRef: { current: null },
noteLayout: 'centered',
onToggleNoteLayout: vi.fn(),
forceRawMode: false,
showAIChat: false,
onToggleAIChat: vi.fn(),
@@ -99,4 +101,11 @@ describe('EditorContentLayout', () => {
expect(container.querySelector('.animate-pulse')).not.toBeNull()
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
})
it('marks the editor content root and breadcrumb with the note layout preference', () => {
const { container } = render(<EditorContentLayout {...createModel({ noteLayout: 'left' })} />)
expect(container.firstElementChild).toHaveClass('editor-content-layout--left')
expect(screen.getByTestId('breadcrumb-bar')).toHaveAttribute('data-note-layout', 'left')
})
})

View File

@@ -1,4 +1,5 @@
import type React from 'react'
import { cn } from '@/lib/utils'
import { DiffView } from '../DiffView'
import { BreadcrumbBar } from '../BreadcrumbBar'
import { ArchivedNoteBanner } from '../ArchivedNoteBanner'
@@ -28,6 +29,8 @@ type BreadcrumbActions = Pick<
| 'onArchiveNote'
| 'onUnarchiveNote'
| 'onRenameFilename'
| 'noteLayout'
| 'onToggleNoteLayout'
>
function EditorLoadingSkeleton() {
@@ -128,6 +131,8 @@ function ActiveTabBreadcrumb({
onArchive={bindPath(actions.onArchiveNote, path)}
onUnarchive={bindPath(actions.onUnarchiveNote, path)}
onRenameFilename={actions.onRenameFilename}
noteLayout={actions.noteLayout}
onToggleNoteLayout={actions.onToggleNoteLayout}
/>
)
}
@@ -228,18 +233,23 @@ export function EditorContentLayout(model: EditorContentModel) {
isDeletedPreview,
rawLatestContentRef,
rawModeContent,
noteLayout,
} = model
const rootClassName = cn(
'flex flex-1 flex-col min-w-0 min-h-0',
noteLayout === 'left' ? 'editor-content-layout--left' : 'editor-content-layout--centered',
)
if (!activeTab) {
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
<div className={rootClassName}>
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
</div>
)
}
return (
<div className="flex flex-1 flex-col min-w-0 min-h-0">
<div className={rootClassName}>
<ActiveTabBreadcrumb
activeTab={activeTab}
barRef={breadcrumbBarRef}
@@ -263,6 +273,8 @@ export function EditorContentLayout(model: EditorContentModel) {
onArchiveNote: model.onArchiveNote,
onUnarchiveNote: model.onUnarchiveNote,
onRenameFilename: model.onRenameFilename,
noteLayout: model.noteLayout,
onToggleNoteLayout: model.onToggleNoteLayout,
}}
/>
<EditorChrome

View File

@@ -1,7 +1,7 @@
import type React from 'react'
import { useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { NoteStatus, VaultEntry } from '../../types'
import type { NoteLayout, NoteStatus, VaultEntry } from '../../types'
import { useEditorTheme } from '../../hooks/useTheme'
import { deriveEditorContentState } from './editorContentState'
@@ -40,6 +40,8 @@ export interface EditorContentProps {
rawModeContent?: string | null
rawLatestContentRef?: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
noteLayout?: NoteLayout
onToggleNoteLayout?: () => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void