feat: add note layout preference
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user