Compare commits
6 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90e67adc41 | ||
|
|
185feb5a2c | ||
|
|
f04dfdbd37 | ||
|
|
3c27403f86 | ||
|
|
276b3c1a37 | ||
|
|
cadb3500f1 |
1
design/raw-editor-mode.pen
Normal file
1
design/raw-editor-mode.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
1
design/tab-responsive-width.pen
Normal file
1
design/tab-responsive-width.pen
Normal file
@@ -0,0 +1 @@
|
||||
{"children":[],"variables":{}}
|
||||
@@ -270,6 +270,9 @@ function App() {
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
const rawToggleRef = useRef<() => void>(() => {})
|
||||
|
||||
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode()
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
@@ -290,6 +293,7 @@ function App() {
|
||||
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
||||
onCommitPush: commitFlow.openCommitDialog, onSetViewMode: setViewMode,
|
||||
onToggleInspector: () => layout.setInspectorCollapsed(c => !c),
|
||||
onToggleRawEditor: () => rawToggleRef.current(),
|
||||
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
||||
zoomLevel: zoom.zoomLevel,
|
||||
onSelect: setSelection, onCloseTab: notes.handleCloseTab,
|
||||
@@ -389,12 +393,15 @@ function App() {
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={handleSave}
|
||||
onTitleSync={handleTitleSync}
|
||||
rawToggleRef={rawToggleRef}
|
||||
canGoBack={navHistory.canGoBack}
|
||||
canGoForward={navHistory.canGoForward}
|
||||
onGoBack={handleGoBack}
|
||||
onGoForward={handleGoForward}
|
||||
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
||||
isDarkTheme={themeManager.isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,3 +115,24 @@ describe('BreadcrumbBar — archive/unarchive', () => {
|
||||
expect(onUnarchive).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Raw editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Back to editor" tooltip when rawMode is on', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={true} onToggleRaw={onToggleRaw} />)
|
||||
expect(screen.getByTitle('Back to editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onToggleRaw when raw button is clicked', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode={false} onToggleRaw={onToggleRaw} />)
|
||||
fireEvent.click(screen.getByTitle('Raw editor'))
|
||||
expect(onToggleRaw).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
MagnifyingGlass,
|
||||
GitBranch,
|
||||
Code,
|
||||
CursorText,
|
||||
Sparkle,
|
||||
SlidersHorizontal,
|
||||
@@ -22,6 +23,8 @@ interface BreadcrumbBarProps {
|
||||
diffMode: boolean
|
||||
diffLoading: boolean
|
||||
onToggleDiff: () => void
|
||||
rawMode?: boolean
|
||||
onToggleRaw?: () => void
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
@@ -34,7 +37,23 @@ interface BreadcrumbBarProps {
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
|
||||
function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggleRaw?: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors',
|
||||
rawMode ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
onClick={onToggleRaw}
|
||||
title={rawMode ? 'Back to editor' : 'Raw editor'}
|
||||
>
|
||||
<Code size={16} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount' | 'noteStatus'>) {
|
||||
@@ -68,6 +87,7 @@ function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onTog
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
)}
|
||||
<RawToggleButton rawMode={rawMode} onToggleRaw={onToggleRaw} />
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
|
||||
@@ -67,6 +67,21 @@
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
/* Override BlockNote's internal color variables so .bn-editor background
|
||||
matches our vault theme instead of BlockNote's hardcoded light/dark defaults. */
|
||||
--bn-colors-editor-background: var(--bg-primary);
|
||||
--bn-colors-editor-text: var(--text-primary);
|
||||
--bn-colors-menu-background: var(--bg-card);
|
||||
--bn-colors-menu-text: var(--text-primary);
|
||||
--bn-colors-tooltip-background: var(--bg-hover);
|
||||
--bn-colors-tooltip-text: var(--text-primary);
|
||||
--bn-colors-hovered-background: var(--bg-hover);
|
||||
--bn-colors-hovered-text: var(--text-primary);
|
||||
--bn-colors-selected-background: var(--bg-selected);
|
||||
--bn-colors-selected-text: var(--text-primary);
|
||||
--bn-colors-border: var(--border-primary);
|
||||
--bn-colors-shadow: var(--border-primary);
|
||||
--bn-colors-side-menu: var(--text-muted);
|
||||
}
|
||||
|
||||
.editor__blocknote-container .bn-editor {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { FrontmatterValue } from './Inspector'
|
||||
import { ResizeHandle } from './ResizeHandle'
|
||||
import { TabBar } from './TabBar'
|
||||
import { useDiffMode } from '../hooks/useDiffMode'
|
||||
import { useRawMode } from '../hooks/useRawMode'
|
||||
import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
@@ -53,6 +54,7 @@ interface EditorProps {
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
/** Called when H1→title sync updates the title (debounced). */
|
||||
onTitleSync?: (path: string, newTitle: string) => void
|
||||
canGoBack?: boolean
|
||||
@@ -60,6 +62,35 @@ interface EditorProps {
|
||||
onGoBack?: () => void
|
||||
onGoForward?: () => void
|
||||
leftPanelsCollapsed?: boolean
|
||||
isDarkTheme?: boolean
|
||||
/** Mutable ref that Editor registers its raw-mode toggle into, for command palette access. */
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
|
||||
}: {
|
||||
diffMode: boolean
|
||||
rawMode: boolean
|
||||
handleToggleDiff: () => void | Promise<void>
|
||||
handleToggleRaw: () => void
|
||||
rawToggleRef?: React.MutableRefObject<() => void>
|
||||
}) {
|
||||
const handleToggleDiffExclusive = useCallback(async () => {
|
||||
if (!diffMode && rawMode) handleToggleRaw()
|
||||
await handleToggleDiff()
|
||||
}, [diffMode, rawMode, handleToggleDiff, handleToggleRaw])
|
||||
|
||||
const handleToggleRawExclusive = useCallback(() => {
|
||||
if (!rawMode && diffMode) handleToggleDiff()
|
||||
handleToggleRaw()
|
||||
}, [rawMode, diffMode, handleToggleDiff, handleToggleRaw])
|
||||
|
||||
useEffect(() => {
|
||||
if (rawToggleRef) rawToggleRef.current = handleToggleRawExclusive
|
||||
}, [rawToggleRef, handleToggleRawExclusive])
|
||||
|
||||
return { handleToggleDiffExclusive, handleToggleRawExclusive }
|
||||
}
|
||||
|
||||
function EditorEmptyState() {
|
||||
@@ -80,8 +111,10 @@ export const Editor = memo(function Editor({
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab, onContentChange, onTitleSync,
|
||||
onRenameTab, onContentChange, onSave, onTitleSync,
|
||||
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
|
||||
isDarkTheme,
|
||||
rawToggleRef,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
@@ -114,6 +147,13 @@ export const Editor = memo(function Editor({
|
||||
const { diffMode, diffContent, diffLoading, handleToggleDiff, handleViewCommitDiff } = useDiffMode({
|
||||
activeTabPath, onLoadDiff, onLoadDiffAtCommit,
|
||||
})
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({ activeTabPath })
|
||||
|
||||
const { handleToggleDiffExclusive, handleToggleRawExclusive } = useEditorModeExclusion({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef,
|
||||
})
|
||||
|
||||
const isLoadingNewTab = activeTabPath !== null && !activeTab
|
||||
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
@@ -147,7 +187,11 @@ export const Editor = memo(function Editor({
|
||||
diffMode={diffMode}
|
||||
diffContent={diffContent}
|
||||
diffLoading={diffLoading}
|
||||
onToggleDiff={handleToggleDiff}
|
||||
onToggleDiff={handleToggleDiffExclusive}
|
||||
rawMode={rawMode}
|
||||
onToggleRaw={handleToggleRawExclusive}
|
||||
onRawContentChange={onContentChange}
|
||||
onSave={onSave}
|
||||
activeStatus={activeStatus}
|
||||
showDiffToggle={showDiffToggle}
|
||||
showAIChat={showAIChat}
|
||||
@@ -161,6 +205,7 @@ export const Editor = memo(function Editor({
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
}
|
||||
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { VaultEntry, NoteStatus } from '../types'
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import { DiffView } from './DiffView'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import { SingleEditorView } from './SingleEditorView'
|
||||
|
||||
@@ -19,6 +20,10 @@ interface EditorContentProps {
|
||||
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
|
||||
@@ -32,6 +37,7 @@ interface EditorContentProps {
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
}
|
||||
|
||||
function EditorLoadingSkeleton() {
|
||||
@@ -62,6 +68,28 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
|
||||
)
|
||||
}
|
||||
|
||||
function RawModeEditorSection({
|
||||
rawMode, activeTab, entries, onContentChange, onSave,
|
||||
}: {
|
||||
rawMode: boolean
|
||||
activeTab: Tab | null
|
||||
entries: VaultEntry[]
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
onSave?: () => void
|
||||
}) {
|
||||
if (!rawMode || !activeTab) return null
|
||||
return (
|
||||
<RawEditorView
|
||||
key={activeTab.entry.path}
|
||||
content={activeTab.content}
|
||||
path={activeTab.entry.path}
|
||||
entries={entries}
|
||||
onContentChange={onContentChange ?? (() => {})}
|
||||
onSave={onSave ?? (() => {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -69,7 +97,7 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
|
||||
|
||||
function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
activeTab: Tab
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange'>
|
||||
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave'>
|
||||
}) {
|
||||
const wordCount = countWords(activeTab.content)
|
||||
const path = activeTab.entry.path
|
||||
@@ -82,6 +110,8 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
diffMode={props.diffMode}
|
||||
diffLoading={props.diffLoading}
|
||||
onToggleDiff={props.onToggleDiff}
|
||||
rawMode={props.rawMode}
|
||||
onToggleRaw={props.onToggleRaw}
|
||||
showAIChat={props.showAIChat}
|
||||
onToggleAIChat={props.onToggleAIChat}
|
||||
inspectorCollapsed={props.inspectorCollapsed}
|
||||
@@ -97,19 +127,28 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
|
||||
export function EditorContent({
|
||||
activeTab, isLoadingNewTab, entries, editor,
|
||||
diffMode, diffContent, onToggleDiff,
|
||||
onNavigateWikilink, onEditorChange, vaultPath,
|
||||
rawMode, onToggleRaw, onRawContentChange, onSave,
|
||||
onNavigateWikilink, onEditorChange, vaultPath, isDarkTheme,
|
||||
...breadcrumbProps
|
||||
}: EditorContentProps) {
|
||||
const showEditor = !diffMode && !rawMode
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-w-0 min-h-0">
|
||||
{activeTab && <ActiveTabBreadcrumb activeTab={activeTab} props={{ diffMode, diffContent, onToggleDiff, ...breadcrumbProps }} />}
|
||||
{activeTab && (
|
||||
<ActiveTabBreadcrumb
|
||||
activeTab={activeTab}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
/>
|
||||
)}
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
{!diffMode && activeTab && (
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} />
|
||||
{showEditor && activeTab && (
|
||||
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} />
|
||||
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} isDarkTheme={isDarkTheme} />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}
|
||||
{isLoadingNewTab && showEditor && <EditorLoadingSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
143
src/components/RawEditorView.test.tsx
Normal file
143
src/components/RawEditorView.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react'
|
||||
import { RawEditorView } from './RawEditorView'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
|
||||
// Minimal VaultEntry factory
|
||||
function entry(title: string, path = `/vault/note/${title}.md`) {
|
||||
return {
|
||||
path, filename: `${title}.md`, title, isA: 'Note',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: null, createdAt: null, fileSize: 0, snippet: '', wordCount: 0,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, outgoingLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
content: '---\ntitle: My Note\n---\n\n# My Note\n\nSome content.',
|
||||
path: '/vault/note/my-note.md',
|
||||
entries: [entry('Project Alpha'), entry('Meeting Notes')],
|
||||
onContentChange: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
}
|
||||
|
||||
describe('extractWikilinkQuery', () => {
|
||||
it('returns null when no [[ trigger', () => {
|
||||
expect(extractWikilinkQuery('hello world', 5)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns empty string immediately after [[', () => {
|
||||
const text = 'see [['
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('')
|
||||
})
|
||||
|
||||
it('returns query after [[', () => {
|
||||
const text = 'see [[Proj'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBe('Proj')
|
||||
})
|
||||
|
||||
it('returns null when ]] closes the link', () => {
|
||||
const text = '[[Proj]]'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when newline is in query', () => {
|
||||
const text = '[[Proj\ncontinued'
|
||||
expect(extractWikilinkQuery(text, text.length)).toBeNull()
|
||||
})
|
||||
|
||||
it('handles cursor before end of text', () => {
|
||||
// cursor at 6 = after "[[Proj" (before the space)
|
||||
const text = '[[Proj after'
|
||||
expect(extractWikilinkQuery(text, 6)).toBe('Proj')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectYamlError', () => {
|
||||
it('returns null for content without frontmatter', () => {
|
||||
expect(detectYamlError('# Title\n\nSome content.')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid frontmatter', () => {
|
||||
expect(detectYamlError('---\ntitle: My Note\n---\n\n# Title')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns error for unclosed frontmatter', () => {
|
||||
const error = detectYamlError('---\ntitle: My Note\n\n# Title')
|
||||
expect(error).toContain('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('returns error for tab indentation in frontmatter', () => {
|
||||
const error = detectYamlError('---\n\ttitle: My Note\n---\n')
|
||||
expect(error).toContain('tab indentation')
|
||||
})
|
||||
|
||||
it('returns null for content not starting with ---', () => {
|
||||
expect(detectYamlError('Not frontmatter')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RawEditorView', () => {
|
||||
it('renders textarea with the provided content', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe(defaultProps.content)
|
||||
})
|
||||
|
||||
it('calls onContentChange when user types (debounced)', async () => {
|
||||
vi.useFakeTimers()
|
||||
const onContentChange = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onContentChange={onContentChange} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
|
||||
fireEvent.change(textarea, { target: { value: '---\ntitle: Changed\n---\n\n# Changed' } })
|
||||
|
||||
// Should not be called immediately
|
||||
expect(onContentChange).not.toHaveBeenCalled()
|
||||
|
||||
// After debounce
|
||||
await act(async () => { vi.advanceTimersByTime(600) })
|
||||
expect(onContentChange).toHaveBeenCalledWith(
|
||||
defaultProps.path,
|
||||
'---\ntitle: Changed\n---\n\n# Changed'
|
||||
)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows YAML error banner for unclosed frontmatter', () => {
|
||||
render(<RawEditorView {...defaultProps} content="---\ntitle: Bad\n\n# Title" />)
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('raw-editor-yaml-error')).toHaveTextContent('Unclosed frontmatter')
|
||||
})
|
||||
|
||||
it('does not show YAML error for valid content', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
expect(screen.queryByTestId('raw-editor-yaml-error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSave when Cmd+S is pressed', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onSave={onSave} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
fireEvent.keyDown(textarea, { key: 's', metaKey: true })
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onSave when Ctrl+S is pressed', () => {
|
||||
const onSave = vi.fn()
|
||||
render(<RawEditorView {...defaultProps} onSave={onSave} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea')
|
||||
fireEvent.keyDown(textarea, { key: 's', ctrlKey: true })
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('has monospaced font family applied', () => {
|
||||
render(<RawEditorView {...defaultProps} />)
|
||||
const textarea = screen.getByTestId('raw-editor-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.style.fontFamily).toContain('monospace')
|
||||
})
|
||||
})
|
||||
276
src/components/RawEditorView.tsx
Normal file
276
src/components/RawEditorView.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { NoteSearchList } from './NoteSearchList'
|
||||
import { extractWikilinkQuery, detectYamlError } from '../utils/rawEditorUtils'
|
||||
import type { WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
/** Get approximate pixel coordinates of the cursor in a textarea. */
|
||||
function getCaretCoordinates(
|
||||
textarea: HTMLTextAreaElement,
|
||||
position: number,
|
||||
): { top: number; left: number } {
|
||||
const mirror = document.createElement('div')
|
||||
const style = getComputedStyle(textarea)
|
||||
|
||||
const props = [
|
||||
'boxSizing', 'width', 'borderTopWidth', 'borderRightWidth',
|
||||
'borderBottomWidth', 'borderLeftWidth',
|
||||
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
||||
'fontStyle', 'fontVariant', 'fontWeight', 'fontSize', 'lineHeight',
|
||||
'fontFamily', 'textTransform', 'letterSpacing', 'wordSpacing',
|
||||
] as const
|
||||
for (const prop of props) {
|
||||
(mirror.style as unknown as Record<string, string>)[prop] = style[prop] as string
|
||||
}
|
||||
mirror.style.position = 'absolute'
|
||||
mirror.style.visibility = 'hidden'
|
||||
mirror.style.top = '0'
|
||||
mirror.style.left = '-9999px'
|
||||
mirror.style.whiteSpace = 'pre-wrap'
|
||||
mirror.style.wordWrap = 'break-word'
|
||||
mirror.style.overflow = 'hidden'
|
||||
|
||||
mirror.textContent = textarea.value.slice(0, position)
|
||||
const caret = document.createElement('span')
|
||||
caret.textContent = '\u200B'
|
||||
mirror.appendChild(caret)
|
||||
document.body.appendChild(mirror)
|
||||
|
||||
const caretRect = caret.getBoundingClientRect()
|
||||
const mirrorRect = mirror.getBoundingClientRect()
|
||||
document.body.removeChild(mirror)
|
||||
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
return {
|
||||
top: caretRect.top - mirrorRect.top + textareaRect.top - textarea.scrollTop,
|
||||
left: caretRect.left - mirrorRect.left + textareaRect.left,
|
||||
}
|
||||
}
|
||||
|
||||
interface AutocompleteState {
|
||||
caretTop: number
|
||||
caretLeft: number
|
||||
selectedIndex: number
|
||||
items: WikilinkSuggestionItem[]
|
||||
}
|
||||
|
||||
interface RawEditorViewProps {
|
||||
content: string
|
||||
path: string
|
||||
entries: VaultEntry[]
|
||||
onContentChange: (path: string, content: string) => void
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
const FONT_FAMILY = '"Berkeley Mono", "JetBrains Mono", "Fira Mono", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const DEBOUNCE_MS = 500
|
||||
const DROPDOWN_MAX_HEIGHT = 200
|
||||
|
||||
export function RawEditorView({ content, path, entries, onContentChange, onSave }: RawEditorViewProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pathRef = useRef(path)
|
||||
const onContentChangeRef = useRef(onContentChange)
|
||||
const onSaveRef = useRef(onSave)
|
||||
useEffect(() => { pathRef.current = path }, [path])
|
||||
useEffect(() => { onContentChangeRef.current = onContentChange }, [onContentChange])
|
||||
useEffect(() => { onSaveRef.current = onSave }, [onSave])
|
||||
|
||||
const [value, setValue] = useState(content)
|
||||
const [autocomplete, setAutocomplete] = useState<AutocompleteState | null>(null)
|
||||
const [yamlError, setYamlError] = useState<string | null>(() => detectYamlError(content))
|
||||
// NOTE: tab-switch reset is handled via key={path} in the parent (EditorContent)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
() => deduplicateByPath(entries.map(entry => ({
|
||||
title: entry.title,
|
||||
aliases: [...new Set([entry.filename.replace(/\.md$/, ''), ...entry.aliases])],
|
||||
group: entry.isA || 'Note',
|
||||
entryTitle: entry.title,
|
||||
path: entry.path,
|
||||
}))),
|
||||
[entries],
|
||||
)
|
||||
|
||||
/** Insert [[entryTitle]] at the current [[ trigger position */
|
||||
const insertWikilink = useCallback((entryTitle: string) => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const cursor = textarea.selectionStart
|
||||
const text = textarea.value
|
||||
const before = text.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return
|
||||
|
||||
const after = text.slice(cursor)
|
||||
const newText = `${text.slice(0, triggerIdx)}[[${entryTitle}]]${after}`
|
||||
const newCursor = triggerIdx + entryTitle.length + 4
|
||||
|
||||
setValue(newText)
|
||||
setAutocomplete(null)
|
||||
|
||||
// Flush immediately — autocomplete inserts should not be debounced
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
onContentChangeRef.current(pathRef.current, newText)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.selectionStart = newCursor
|
||||
textareaRef.current.selectionEnd = newCursor
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value
|
||||
const cursor = e.target.selectionStart ?? 0
|
||||
|
||||
setValue(newValue)
|
||||
setYamlError(detectYamlError(newValue))
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onContentChangeRef.current(pathRef.current, newValue)
|
||||
}, DEBOUNCE_MS)
|
||||
|
||||
const query = extractWikilinkQuery(newValue, cursor)
|
||||
if (query === null || query.length < MIN_QUERY_LENGTH) {
|
||||
setAutocomplete(null)
|
||||
return
|
||||
}
|
||||
|
||||
const textarea = e.target
|
||||
const coords = getCaretCoordinates(textarea, cursor)
|
||||
const candidates = preFilterWikilinks(baseItems, query)
|
||||
const withHandlers = attachClickHandlers(candidates, insertWikilink)
|
||||
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
|
||||
|
||||
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
|
||||
}, [baseItems, typeEntryMap, insertWikilink])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Save shortcut
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current)
|
||||
debounceRef.current = null
|
||||
onContentChangeRef.current(pathRef.current, textareaRef.current?.value ?? '')
|
||||
}
|
||||
onSaveRef.current()
|
||||
return
|
||||
}
|
||||
|
||||
if (!autocomplete) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(prev => prev
|
||||
? { ...prev, selectedIndex: Math.min(prev.selectedIndex + 1, prev.items.length - 1) }
|
||||
: null)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(prev => prev
|
||||
? { ...prev, selectedIndex: Math.max(prev.selectedIndex - 1, 0) }
|
||||
: null)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const item = autocomplete.items[autocomplete.selectedIndex]
|
||||
if (item) insertWikilink(item.entryTitle ?? item.title)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setAutocomplete(null)
|
||||
}
|
||||
}, [autocomplete, insertWikilink])
|
||||
|
||||
const closeAutocomplete = useCallback(() => setAutocomplete(null), [])
|
||||
|
||||
// Flush pending debounce on unmount (e.g. switching tabs while in raw mode)
|
||||
useEffect(() => {
|
||||
const pendingPath = pathRef
|
||||
const pendingChange = onContentChangeRef
|
||||
const pendingDebounce = debounceRef
|
||||
const pendingTextarea = textareaRef
|
||||
return () => {
|
||||
if (pendingDebounce.current) {
|
||||
clearTimeout(pendingDebounce.current)
|
||||
pendingChange.current(pendingPath.current, pendingTextarea.current?.value ?? '')
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const dropdownBelow = autocomplete
|
||||
? autocomplete.caretTop + 20 + DROPDOWN_MAX_HEIGHT <= window.innerHeight
|
||||
: true
|
||||
const dropdownTop = autocomplete
|
||||
? (dropdownBelow ? autocomplete.caretTop + 20 : autocomplete.caretTop - DROPDOWN_MAX_HEIGHT - 4)
|
||||
: 0
|
||||
const dropdownLeft = autocomplete
|
||||
? Math.min(autocomplete.caretLeft, window.innerWidth - 260)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 relative" style={{ background: 'var(--background)' }}>
|
||||
{yamlError && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 text-xs border-b shrink-0"
|
||||
style={{ background: '#fef3c7', borderColor: '#d97706', color: '#92400e' }}
|
||||
role="alert"
|
||||
data-testid="raw-editor-yaml-error"
|
||||
>
|
||||
<span style={{ fontWeight: 600 }}>YAML error:</span>
|
||||
<span>{yamlError}</span>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={closeAutocomplete}
|
||||
className="flex-1 resize-none border-none outline-none p-8"
|
||||
style={{
|
||||
fontFamily: FONT_FAMILY,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
background: 'var(--background)',
|
||||
color: 'var(--foreground)',
|
||||
tabSize: 2,
|
||||
minHeight: 0,
|
||||
}}
|
||||
spellCheck={false}
|
||||
aria-label="Raw editor"
|
||||
data-testid="raw-editor-textarea"
|
||||
/>
|
||||
{autocomplete && autocomplete.items.length > 0 && (
|
||||
<div
|
||||
className="fixed z-50 min-w-64 max-w-xs rounded-md border shadow-lg overflow-auto"
|
||||
style={{
|
||||
top: dropdownTop,
|
||||
left: dropdownLeft,
|
||||
maxHeight: DROPDOWN_MAX_HEIGHT,
|
||||
background: 'var(--popover)',
|
||||
borderColor: 'var(--border)',
|
||||
}}
|
||||
data-testid="raw-editor-wikilink-dropdown"
|
||||
>
|
||||
<NoteSearchList
|
||||
items={autocomplete.items}
|
||||
selectedIndex={autocomplete.selectedIndex}
|
||||
getItemKey={(item, i) => `${item.title}-${item.path ?? i}`}
|
||||
onItemClick={(item) => insertWikilink(item.entryTitle ?? item.title)}
|
||||
onItemHover={(i) => setAutocomplete(prev => prev ? { ...prev, selectedIndex: i } : null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const mockThemeManager: ThemeManager = {
|
||||
themes: [],
|
||||
activeThemeId: null,
|
||||
activeTheme: null,
|
||||
isDark: false,
|
||||
switchTheme: vi.fn(),
|
||||
createTheme: vi.fn().mockResolvedValue('untitled'),
|
||||
reloadThemes: vi.fn(),
|
||||
|
||||
@@ -23,12 +23,13 @@ function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
}
|
||||
|
||||
/** Single BlockNote editor view — content is swapped via replaceBlocks */
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: {
|
||||
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath, isDarkTheme }: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
entries: VaultEntry[]
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onChange?: () => void
|
||||
vaultPath?: string
|
||||
isDarkTheme?: boolean
|
||||
}) {
|
||||
const navigateRef = useRef(onNavigateWikilink)
|
||||
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
|
||||
@@ -100,7 +101,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
)}
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
theme="light"
|
||||
theme={isDarkTheme ? 'dark' : 'light'}
|
||||
onChange={onChange}
|
||||
>
|
||||
<SuggestionMenuController
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { TabBar } from './TabBar'
|
||||
import { computeTabMaxWidth } from '../utils/tabLayout'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
function makeEntry(path: string, title: string): VaultEntry {
|
||||
@@ -257,4 +258,59 @@ describe('TabBar', () => {
|
||||
fireEvent.click(screen.getByText('Beta'))
|
||||
expect(onSwitchTab).toHaveBeenCalledWith(tabs[1].entry.path)
|
||||
})
|
||||
|
||||
describe('responsive tab width', () => {
|
||||
it('wraps tabs in an overflow-hidden flex container', () => {
|
||||
const tabs = makeTabs(['Alpha'])
|
||||
const { container } = render(
|
||||
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
|
||||
)
|
||||
const tabArea = container.querySelector('.overflow-hidden')
|
||||
expect(tabArea).toBeInTheDocument()
|
||||
expect(tabArea?.classList.contains('flex')).toBe(true)
|
||||
expect(tabArea?.classList.contains('min-w-0')).toBe(true)
|
||||
expect(tabArea?.classList.contains('flex-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('tab elements are shrinkable with min-w-0', () => {
|
||||
const tabs = makeTabs(['Alpha', 'Beta'])
|
||||
const { container } = render(
|
||||
<TabBar tabs={tabs} activeTabPath={tabs[0].entry.path} {...defaultProps} />
|
||||
)
|
||||
const tabEls = container.querySelectorAll('[draggable="true"]')
|
||||
expect(tabEls).toHaveLength(2)
|
||||
for (const el of tabEls) {
|
||||
expect(el.classList.contains('shrink-0')).toBe(false)
|
||||
expect(el.classList.contains('min-w-0')).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTabMaxWidth', () => {
|
||||
it('caps at 360px when container is wide', () => {
|
||||
expect(computeTabMaxWidth(1200, 2)).toBe(360)
|
||||
})
|
||||
|
||||
it('divides space equally among tabs', () => {
|
||||
expect(computeTabMaxWidth(500, 5)).toBe(100)
|
||||
})
|
||||
|
||||
it('enforces minimum of 60px', () => {
|
||||
expect(computeTabMaxWidth(300, 10)).toBe(60)
|
||||
})
|
||||
|
||||
it('returns 360 for zero tabs', () => {
|
||||
expect(computeTabMaxWidth(800, 0)).toBe(360)
|
||||
})
|
||||
|
||||
it('floors the result to integer pixels', () => {
|
||||
// 1000 / 3 = 333.33 → 333
|
||||
expect(computeTabMaxWidth(1000, 3)).toBe(333)
|
||||
})
|
||||
|
||||
it('handles single tab', () => {
|
||||
expect(computeTabMaxWidth(200, 1)).toBe(200)
|
||||
expect(computeTabMaxWidth(500, 1)).toBe(360)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { VaultEntry, NoteStatus } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
import { Plus, Columns, ArrowsOutSimple, ArrowLeft, ArrowRight } from '@phosphor-icons/react'
|
||||
import { computeTabMaxWidth } from '@/utils/tabLayout'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
@@ -199,7 +200,7 @@ function StatusDot({ status }: { status: NoteStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBefore, showDropAfter, tabMaxWidth, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
tab: Tab
|
||||
isActive: boolean
|
||||
isEditing: boolean
|
||||
@@ -207,6 +208,7 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
isDragging: boolean
|
||||
showDropBefore: boolean
|
||||
showDropAfter: boolean
|
||||
tabMaxWidth: number
|
||||
onSwitch: () => void
|
||||
onClose: () => void
|
||||
onDoubleClick: () => void
|
||||
@@ -219,10 +221,11 @@ function TabItem({ tab, isActive, isEditing, noteStatus, isDragging, showDropBef
|
||||
draggable={!isEditing}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[360px] transition-all relative",
|
||||
"group flex min-w-0 items-center gap-1.5 whitespace-nowrap transition-all relative",
|
||||
isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground"
|
||||
)}
|
||||
style={{
|
||||
maxWidth: tabMaxWidth,
|
||||
background: isActive ? 'var(--background)' : 'transparent',
|
||||
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
|
||||
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
|
||||
@@ -331,8 +334,20 @@ export const TabBar = memo(function TabBar({
|
||||
}: TabBarProps) {
|
||||
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null)
|
||||
const tabAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [tabMaxWidth, setTabMaxWidth] = useState(360)
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
|
||||
useEffect(() => {
|
||||
const el = tabAreaRef.current
|
||||
if (!el) return
|
||||
const recalc = () => setTabMaxWidth(computeTabMaxWidth(el.clientWidth, tabs.length))
|
||||
recalc()
|
||||
const observer = new ResizeObserver(recalc)
|
||||
observer.observe(el)
|
||||
return () => observer.disconnect()
|
||||
}, [tabs.length])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-stretch"
|
||||
@@ -340,30 +355,33 @@ export const TabBar = memo(function TabBar({
|
||||
onDragLeave={handleBarDragLeave}
|
||||
>
|
||||
<NavButtons canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={onGoBack} onGoForward={onGoForward} />
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
|
||||
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
|
||||
onRenameCancel={() => setEditingPath(null)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragOver: (e) => handleDragOver(e, index),
|
||||
onDrop: handleDrop,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
|
||||
<div ref={tabAreaRef} className="flex flex-1 min-w-0 items-stretch overflow-hidden">
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
noteStatus={getNoteStatus?.(tab.entry.path) ?? 'clean'}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
tabMaxWidth={tabMaxWidth}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
|
||||
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
|
||||
onRenameCancel={() => setEditingPath(null)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragOver: (e) => handleDragOver(e, index),
|
||||
onDrop: handleDrop,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1 shrink-0" style={{ borderBottom: '1px solid var(--border)', cursor: 'default' }} onMouseDown={onDragMouseDown} />
|
||||
</div>
|
||||
<TabBarActions onCreateNote={onCreateNote} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ interface AppCommandsConfig {
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onZoomReset: () => void
|
||||
@@ -129,6 +130,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onCommitPush: config.onCommitPush,
|
||||
onSetViewMode: config.onSetViewMode,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onZoomIn: config.onZoomIn,
|
||||
onZoomOut: config.onZoomOut,
|
||||
onZoomReset: config.onZoomReset,
|
||||
|
||||
@@ -156,6 +156,36 @@ describe('useCommandRegistry', () => {
|
||||
expect(onTrashNote).toHaveBeenCalledWith('/vault/note/test.md')
|
||||
})
|
||||
|
||||
it('has toggle-raw-editor command in View group', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig()))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('View')
|
||||
})
|
||||
|
||||
it('disables toggle-raw-editor when no note is open', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ activeTabPath: null })))
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('enables toggle-raw-editor when a note is open', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md' })),
|
||||
)
|
||||
const cmd = result.current.find(c => c.id === 'toggle-raw-editor')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('calls onToggleRawEditor when toggle-raw-editor executes', () => {
|
||||
const onToggleRawEditor = vi.fn()
|
||||
const { result } = renderHook(() =>
|
||||
useCommandRegistry(makeConfig({ activeTabPath: '/vault/note/test.md', onToggleRawEditor })),
|
||||
)
|
||||
result.current.find(c => c.id === 'toggle-raw-editor')!.execute()
|
||||
expect(onToggleRawEditor).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables commit when no modified files', () => {
|
||||
const { result } = renderHook(() => useCommandRegistry(makeConfig({ modifiedCount: 0 })))
|
||||
expect(result.current.find(c => c.id === 'commit-push')!.enabled).toBe(false)
|
||||
|
||||
@@ -32,6 +32,7 @@ interface CommandRegistryConfig {
|
||||
onCommitPush: () => void
|
||||
onSetViewMode: (mode: ViewMode) => void
|
||||
onToggleInspector: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleAIChat?: () => void
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
@@ -102,6 +103,30 @@ export function buildTypeCommands(
|
||||
})
|
||||
}
|
||||
|
||||
export function buildViewCommands(
|
||||
hasActiveNote: boolean,
|
||||
onSetViewMode: (mode: ViewMode) => void,
|
||||
onToggleInspector: () => void,
|
||||
onToggleRawEditor: (() => void) | undefined,
|
||||
onToggleAIChat: (() => void) | undefined,
|
||||
zoomLevel: number,
|
||||
onZoomIn: () => void,
|
||||
onZoomOut: () => void,
|
||||
onZoomReset: () => void,
|
||||
): CommandAction[] {
|
||||
return [
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
]
|
||||
}
|
||||
|
||||
export function buildThemeCommands(
|
||||
themes: ThemeFile[] | undefined,
|
||||
activeThemeId: string | null | undefined,
|
||||
@@ -130,7 +155,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
activeTabPath, entries, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
@@ -181,14 +206,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ id: 'view-changes', label: 'View Pending Changes', group: 'Git', keywords: ['modified', 'diff'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
|
||||
// View
|
||||
{ id: 'view-editor', label: 'Editor Only', group: 'View', shortcut: '⌘1', keywords: ['layout', 'focus'], enabled: true, execute: () => onSetViewMode('editor-only') },
|
||||
{ id: 'view-editor-list', label: 'Editor + Note List', group: 'View', shortcut: '⌘2', keywords: ['layout'], enabled: true, execute: () => onSetViewMode('editor-list') },
|
||||
{ id: 'view-all', label: 'Full Layout', group: 'View', shortcut: '⌘3', keywords: ['layout', 'sidebar'], enabled: true, execute: () => onSetViewMode('all') },
|
||||
{ id: 'toggle-inspector', label: 'Toggle Inspector', group: 'View', keywords: ['properties', 'panel', 'right'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
{ id: 'zoom-reset', label: 'Reset Zoom', group: 'View', shortcut: '⌘0', keywords: ['zoom', 'actual', 'default', '100'], enabled: zoomLevel !== 100, execute: onZoomReset },
|
||||
...buildViewCommands(hasActiveNote, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, zoomLevel, onZoomIn, onZoomOut, onZoomReset),
|
||||
|
||||
// Appearance
|
||||
...buildThemeCommands(themes, activeThemeId, onSwitchTheme, onCreateTheme),
|
||||
@@ -206,7 +224,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed, modifiedCount,
|
||||
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleAIChat, onOpenVault,
|
||||
onCommitPush, onSetViewMode, onToggleInspector, onToggleRawEditor, onToggleAIChat, onOpenVault,
|
||||
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
|
||||
onSelect, onOpenDailyNote, onCloseTab,
|
||||
onGoBack, onGoForward, canGoBack, canGoForward,
|
||||
|
||||
@@ -2,19 +2,33 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useEditorFocus } from './useEditorFocus'
|
||||
|
||||
function makeTiptapMock(hasHeading = true) {
|
||||
const chainResult = { setTextSelection: vi.fn().mockReturnThis(), run: vi.fn() }
|
||||
const descendantsMock = vi.fn().mockImplementation((cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => {
|
||||
if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: 15 }, 2)
|
||||
})
|
||||
return {
|
||||
state: { doc: { descendants: descendantsMock } },
|
||||
chain: vi.fn(() => chainResult),
|
||||
_chainResult: chainResult,
|
||||
_descendantsMock: descendantsMock,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useEditorFocus', () => {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
function setup(isMounted: boolean) {
|
||||
const editor = { focus: vi.fn() }
|
||||
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn(), _tiptapEditor: tiptap } as any
|
||||
const mountedRef = { current: isMounted }
|
||||
renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
return editor
|
||||
return { editor, tiptap }
|
||||
}
|
||||
|
||||
it('focuses editor via rAF when already mounted', async () => {
|
||||
const rAF = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const editor = setup(true)
|
||||
const { editor } = setup(true)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
@@ -24,7 +38,7 @@ describe('useEditorFocus', () => {
|
||||
|
||||
it('focuses editor via setTimeout when not yet mounted', () => {
|
||||
vi.useFakeTimers()
|
||||
const editor = setup(false)
|
||||
const { editor } = setup(false)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
@@ -35,7 +49,8 @@ describe('useEditorFocus', () => {
|
||||
})
|
||||
|
||||
it('cleans up event listener on unmount', () => {
|
||||
const editor = { focus: vi.fn() }
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
|
||||
const editor = { focus: vi.fn() } as any
|
||||
const mountedRef = { current: true }
|
||||
const { unmount } = renderHook(() => useEditorFocus(editor, mountedRef))
|
||||
|
||||
@@ -45,4 +60,67 @@ describe('useEditorFocus', () => {
|
||||
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('selectTitle behavior', () => {
|
||||
it('selects H1 text when selectTitle is true and editor is mounted', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).toHaveBeenCalled()
|
||||
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
|
||||
expect(tiptap._chainResult.run).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not select title when selectTitle is false (default)', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: false } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not select title when selectTitle is absent from event detail', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips selection when no heading found in document', () => {
|
||||
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
|
||||
const tiptap = makeTiptapMock(false)
|
||||
const { editor } = setup(true, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('selects H1 text after timeout when editor not yet mounted', () => {
|
||||
vi.useFakeTimers()
|
||||
const tiptap = makeTiptapMock(true)
|
||||
const { editor } = setup(false, tiptap)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(80)
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
expect(tiptap.chain).toHaveBeenCalled()
|
||||
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,54 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface TiptapChain {
|
||||
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
|
||||
run: () => void
|
||||
}
|
||||
|
||||
interface TiptapEditor {
|
||||
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
|
||||
chain: () => TiptapChain
|
||||
}
|
||||
|
||||
/** Select all text in the first heading block via the TipTap chain API. */
|
||||
function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
|
||||
const tiptap = editor._tiptapEditor
|
||||
if (!tiptap?.state?.doc) return
|
||||
|
||||
let from = -1
|
||||
let to = -1
|
||||
|
||||
tiptap.state.doc.descendants((node, pos) => {
|
||||
if (from !== -1) return false
|
||||
if (node.type.name === 'heading') {
|
||||
from = pos + 1
|
||||
to = pos + node.nodeSize - 1
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (from === -1 || from >= to) return
|
||||
tiptap.chain().setTextSelection({ from, to }).run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus editor when a new note is created (signaled via custom event).
|
||||
* Uses adaptive timing: fast rAF path when editor is already mounted,
|
||||
* short timeout when waiting for first mount.
|
||||
* When selectTitle is true, also selects all text in the first H1 block.
|
||||
*/
|
||||
export function useEditorFocus(
|
||||
editor: { focus: () => void },
|
||||
editor: { focus: () => void; _tiptapEditor?: TiptapEditor },
|
||||
editorMountedRef: React.RefObject<boolean>,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const t0 = (e as CustomEvent).detail?.t0 as number | undefined
|
||||
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean } | undefined
|
||||
const t0 = detail?.t0
|
||||
const selectTitle = detail?.selectTitle ?? false
|
||||
const doFocus = () => {
|
||||
editor.focus()
|
||||
if (selectTitle) selectFirstHeading(editor)
|
||||
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
|
||||
}
|
||||
if (editorMountedRef.current) {
|
||||
|
||||
@@ -241,8 +241,10 @@ function navigateWikilink(entries: VaultEntry[], target: string, selectNote: (e:
|
||||
}
|
||||
|
||||
/** Dispatch focus-editor event with perf timing marker. */
|
||||
function signalFocusEditor(): void {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { t0: performance.now() } }))
|
||||
function signalFocusEditor(opts?: { selectTitle?: boolean }): void {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', {
|
||||
detail: { t0: performance.now(), selectTitle: opts?.selectTitle ?? false },
|
||||
}))
|
||||
}
|
||||
|
||||
interface PersistCallbacks {
|
||||
@@ -372,7 +374,7 @@ export function useNoteActions(config: NoteActionsConfig) {
|
||||
addEntryWithMock(resolved.entry, resolved.content, addEntry)
|
||||
config.trackUnsaved?.(resolved.entry.path)
|
||||
config.markContentPending?.(resolved.entry.path, resolved.content)
|
||||
signalFocusEditor()
|
||||
signalFocusEditor({ selectTitle: true })
|
||||
setTimeout(() => pendingNamesRef.current.delete(title), 500)
|
||||
}, [entries, openTabWithContent, addEntry, config.trackUnsaved, config.markContentPending]) // eslint-disable-line react-hooks/exhaustive-deps -- config callbacks are stable
|
||||
|
||||
|
||||
87
src/hooks/useRawMode.test.ts
Normal file
87
src/hooks/useRawMode.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useRawMode } from './useRawMode'
|
||||
|
||||
describe('useRawMode', () => {
|
||||
let onFlushPending: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
onFlushPending = vi.fn().mockResolvedValue(true)
|
||||
})
|
||||
|
||||
function renderRawHook(activeTabPath: string | null = '/note.md') {
|
||||
return renderHook(
|
||||
({ path }) => useRawMode({ activeTabPath: path, onFlushPending }),
|
||||
{ initialProps: { path: activeTabPath } },
|
||||
)
|
||||
}
|
||||
|
||||
it('starts with raw mode off', () => {
|
||||
const { result } = renderRawHook()
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('toggles raw mode on', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
})
|
||||
|
||||
it('flushes pending edits when activating raw mode', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onFlushPending).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not flush pending edits when deactivating raw mode', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
onFlushPending.mockClear()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(onFlushPending).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles raw mode off when already on', async () => {
|
||||
const { result } = renderRawHook()
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('resets raw mode when activeTabPath changes', async () => {
|
||||
const { result, rerender } = renderRawHook('/note-a.md')
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
|
||||
rerender({ path: '/note-b.md' })
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
|
||||
it('works without onFlushPending callback', async () => {
|
||||
const { result } = renderHook(() => useRawMode({ activeTabPath: '/note.md' }))
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
expect(result.current.rawMode).toBe(true)
|
||||
})
|
||||
|
||||
it('does not activate raw mode when activeTabPath is null', async () => {
|
||||
const { result } = renderRawHook(null)
|
||||
|
||||
await act(async () => { await result.current.handleToggleRaw() })
|
||||
|
||||
// Cannot activate raw mode without an active tab path
|
||||
expect(result.current.rawMode).toBe(false)
|
||||
})
|
||||
})
|
||||
29
src/hooks/useRawMode.ts
Normal file
29
src/hooks/useRawMode.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
interface UseRawModeParams {
|
||||
activeTabPath: string | null
|
||||
/** Flush pending WYSIWYG edits to disk before entering raw mode. */
|
||||
onFlushPending?: () => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages raw editor mode state.
|
||||
* Raw mode is automatically inactive when the active tab changes,
|
||||
* because rawMode is derived from whether the stored path matches the current tab.
|
||||
*/
|
||||
export function useRawMode({ activeTabPath, onFlushPending }: UseRawModeParams) {
|
||||
// Track which path has raw mode active — automatically deactivates on tab switch
|
||||
const [rawActivePath, setRawActivePath] = useState<string | null>(null)
|
||||
const rawMode = rawActivePath !== null && rawActivePath === activeTabPath
|
||||
|
||||
const handleToggleRaw = useCallback(async () => {
|
||||
if (rawMode) {
|
||||
setRawActivePath(null)
|
||||
} else {
|
||||
await onFlushPending?.()
|
||||
setRawActivePath(activeTabPath)
|
||||
}
|
||||
}, [rawMode, activeTabPath, onFlushPending])
|
||||
|
||||
return { rawMode, handleToggleRaw }
|
||||
}
|
||||
@@ -37,7 +37,22 @@ vi.mock('../mock-tauri', () => ({
|
||||
}))
|
||||
|
||||
// Must import after mocks
|
||||
const { useThemeManager } = await import('./useThemeManager')
|
||||
const { useThemeManager, isColorDark } = await import('./useThemeManager')
|
||||
|
||||
describe('isColorDark', () => {
|
||||
it('identifies dark colors', () => {
|
||||
expect(isColorDark('#000000')).toBe(true)
|
||||
expect(isColorDark('#0F0F23')).toBe(true)
|
||||
expect(isColorDark('#1a1a2e')).toBe(true)
|
||||
expect(isColorDark('#0f0f1a')).toBe(true)
|
||||
})
|
||||
|
||||
it('identifies light colors', () => {
|
||||
expect(isColorDark('#FFFFFF')).toBe(false)
|
||||
expect(isColorDark('#F7F6F3')).toBe(false)
|
||||
expect(isColorDark('#E0E0E0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useThemeManager', () => {
|
||||
beforeEach(() => {
|
||||
@@ -228,6 +243,101 @@ describe('useThemeManager', () => {
|
||||
expect(newId).toBe('')
|
||||
})
|
||||
|
||||
it('isDark is false for light theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme?.id).toBe('default')
|
||||
})
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('isDark is true for dark theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
expect(result.current.isDark).toBe(true)
|
||||
})
|
||||
|
||||
it('isDark is false when no active theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager(null))
|
||||
expect(result.current.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('derives app-specific CSS variables from theme colors', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme).not.toBeNull()
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
|
||||
expect(root.style.getPropertyValue('--text-primary')).toBe('#1A1A2E')
|
||||
expect(root.style.getPropertyValue('--text-heading')).toBe('#1A1A2E')
|
||||
expect(root.style.getPropertyValue('--border-primary')).toBe('#E2E8F0')
|
||||
})
|
||||
|
||||
it('sets color-scheme and data-theme-mode for dark theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('color-scheme')).toBe('dark')
|
||||
})
|
||||
expect(root.dataset.themeMode).toBe('dark')
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
|
||||
expect(root.style.getPropertyValue('--text-primary')).toBe('#E2E8F0')
|
||||
})
|
||||
|
||||
it('sets color-scheme to light for light theme', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeTheme).not.toBeNull()
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
|
||||
expect(root.dataset.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
it('updates derived variables when switching between themes', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
expect(result.current.themes).toHaveLength(2)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('dark')
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#0F0F23')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchTheme('default')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(root.style.getPropertyValue('--bg-primary')).toBe('#FFFFFF')
|
||||
})
|
||||
expect(root.style.getPropertyValue('color-scheme')).toBe('light')
|
||||
expect(root.dataset.themeMode).toBe('light')
|
||||
})
|
||||
|
||||
it('reloadThemes re-fetches theme list', async () => {
|
||||
const { result } = renderHook(() => useThemeManager('/vault'))
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -7,6 +7,118 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
|
||||
// --- Color utilities for theme variable derivation ---
|
||||
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
const h = hex.replace('#', '')
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
function toHex(r: number, g: number, b: number): string {
|
||||
return '#' + [r, g, b].map(c => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Blend two hex colors. ratio=0 → color1, ratio=1 → color2. */
|
||||
function mixColors(hex1: string, hex2: string, ratio: number): string {
|
||||
const [r1, g1, b1] = parseHex(hex1)
|
||||
const [r2, g2, b2] = parseHex(hex2)
|
||||
return toHex(r1 + (r2 - r1) * ratio, g1 + (g2 - g1) * ratio, b1 + (b2 - b1) * ratio)
|
||||
}
|
||||
|
||||
/** Check if a hex color is perceptually dark (luminance < 0.5). */
|
||||
export function isColorDark(hex: string): boolean {
|
||||
const [r, g, b] = parseHex(hex)
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255 < 0.5
|
||||
}
|
||||
|
||||
// Variables derived from theme core colors (not present in theme.colors directly)
|
||||
const DERIVED_VAR_NAMES = [
|
||||
'bg-primary', 'bg-sidebar', 'bg-card', 'bg-hover', 'bg-hover-subtle', 'bg-selected',
|
||||
'bg-input', 'bg-button', 'bg-dialog',
|
||||
'text-primary', 'text-heading', 'text-secondary', 'text-tertiary', 'text-muted', 'text-faint',
|
||||
'border-primary', 'border-subtle', 'border-input', 'border-dialog',
|
||||
'link-color', 'link-hover',
|
||||
// shadcn variables that may not be in the theme
|
||||
'card', 'card-foreground', 'popover', 'popover-foreground',
|
||||
'secondary', 'secondary-foreground', 'muted-foreground',
|
||||
'accent', 'accent-foreground', 'input', 'ring',
|
||||
'sidebar-foreground', 'sidebar-primary', 'sidebar-primary-foreground',
|
||||
'sidebar-accent', 'sidebar-accent-foreground', 'sidebar-border', 'sidebar-ring',
|
||||
]
|
||||
|
||||
/** Derive app-specific and missing shadcn CSS variables from core theme colors. */
|
||||
function deriveThemeVariables(root: HTMLElement, colors: Record<string, string>): void {
|
||||
const bg = colors.background
|
||||
const fg = colors.foreground
|
||||
if (!bg || !fg) return
|
||||
|
||||
const isDark = isColorDark(bg)
|
||||
root.style.setProperty('color-scheme', isDark ? 'dark' : 'light')
|
||||
root.dataset.themeMode = isDark ? 'dark' : 'light'
|
||||
|
||||
const primary = colors.primary ?? (isDark ? '#5C9CFF' : '#155DFF')
|
||||
const border = colors.border ?? mixColors(bg, fg, isDark ? 0.15 : 0.08)
|
||||
const muted = colors.muted ?? mixColors(bg, fg, isDark ? 0.08 : 0.05)
|
||||
const sidebarBg = colors['sidebar-background'] ?? mixColors(bg, fg, 0.04)
|
||||
|
||||
// App-specific variables
|
||||
root.style.setProperty('--bg-primary', bg)
|
||||
root.style.setProperty('--bg-sidebar', sidebarBg)
|
||||
root.style.setProperty('--bg-card', mixColors(bg, fg, 0.03))
|
||||
root.style.setProperty('--bg-hover', mixColors(bg, fg, 0.1))
|
||||
root.style.setProperty('--bg-hover-subtle', muted)
|
||||
root.style.setProperty('--bg-selected', `${primary}25`)
|
||||
root.style.setProperty('--bg-input', bg)
|
||||
root.style.setProperty('--bg-button', mixColors(bg, fg, 0.1))
|
||||
root.style.setProperty('--bg-dialog', mixColors(bg, fg, 0.02))
|
||||
|
||||
root.style.setProperty('--text-primary', fg)
|
||||
root.style.setProperty('--text-heading', fg)
|
||||
root.style.setProperty('--text-secondary', mixColors(fg, bg, 0.25))
|
||||
root.style.setProperty('--text-tertiary', mixColors(fg, bg, 0.35))
|
||||
root.style.setProperty('--text-muted', mixColors(fg, bg, 0.5))
|
||||
root.style.setProperty('--text-faint', mixColors(fg, bg, 0.6))
|
||||
|
||||
root.style.setProperty('--border-primary', border)
|
||||
root.style.setProperty('--border-subtle', border)
|
||||
root.style.setProperty('--border-input', border)
|
||||
root.style.setProperty('--border-dialog', border)
|
||||
|
||||
root.style.setProperty('--link-color', primary)
|
||||
root.style.setProperty('--link-hover', mixColors(primary, fg, 0.2))
|
||||
|
||||
// Shadcn variables — only set if not already provided by the theme
|
||||
const setIfMissing = (name: string, value: string) => {
|
||||
if (!(name in colors)) root.style.setProperty(`--${name}`, value)
|
||||
}
|
||||
setIfMissing('card', mixColors(bg, fg, 0.03))
|
||||
setIfMissing('card-foreground', fg)
|
||||
setIfMissing('popover', mixColors(bg, fg, 0.04))
|
||||
setIfMissing('popover-foreground', fg)
|
||||
setIfMissing('secondary', mixColors(bg, fg, 0.08))
|
||||
setIfMissing('secondary-foreground', fg)
|
||||
setIfMissing('muted-foreground', mixColors(fg, bg, 0.3))
|
||||
setIfMissing('accent', mixColors(bg, fg, 0.08))
|
||||
setIfMissing('accent-foreground', fg)
|
||||
setIfMissing('input', border)
|
||||
setIfMissing('ring', primary)
|
||||
setIfMissing('sidebar-foreground', fg)
|
||||
setIfMissing('sidebar-accent', mixColors(sidebarBg, fg, 0.1))
|
||||
setIfMissing('sidebar-accent-foreground', fg)
|
||||
setIfMissing('sidebar-border', border)
|
||||
setIfMissing('sidebar-primary', primary)
|
||||
setIfMissing('sidebar-primary-foreground', '#FFFFFF')
|
||||
setIfMissing('sidebar-ring', primary)
|
||||
}
|
||||
|
||||
function clearDerivedVariables(root: HTMLElement): void {
|
||||
for (const name of DERIVED_VAR_NAMES) {
|
||||
root.style.removeProperty(`--${name}`)
|
||||
}
|
||||
root.style.removeProperty('color-scheme')
|
||||
delete root.dataset.themeMode
|
||||
}
|
||||
|
||||
/** Map theme colors/typography/spacing to CSS custom properties on :root. */
|
||||
function applyThemeToDom(theme: ThemeFile): void {
|
||||
const root = document.documentElement
|
||||
@@ -23,6 +135,7 @@ function applyThemeToDom(theme: ThemeFile): void {
|
||||
if (theme.colors['sidebar-background']) {
|
||||
root.style.setProperty('--sidebar', theme.colors['sidebar-background'])
|
||||
}
|
||||
deriveThemeVariables(root, theme.colors)
|
||||
}
|
||||
|
||||
function clearThemeFromDom(theme: ThemeFile): void {
|
||||
@@ -38,12 +151,14 @@ function clearThemeFromDom(theme: ThemeFile): void {
|
||||
root.style.removeProperty(`--theme-${key}`)
|
||||
}
|
||||
root.style.removeProperty('--sidebar')
|
||||
clearDerivedVariables(root)
|
||||
}
|
||||
|
||||
export interface ThemeManager {
|
||||
themes: ThemeFile[]
|
||||
activeThemeId: string | null
|
||||
activeTheme: ThemeFile | null
|
||||
isDark: boolean
|
||||
switchTheme: (themeId: string) => Promise<void>
|
||||
createTheme: (sourceId?: string) => Promise<string>
|
||||
reloadThemes: () => Promise<void>
|
||||
@@ -69,6 +184,7 @@ export function useThemeManager(vaultPath: string | null): ThemeManager {
|
||||
const prevThemeRef = useRef<ThemeFile | null>(null)
|
||||
|
||||
const activeTheme = themes.find(t => t.id === activeThemeId) ?? null
|
||||
const isDark = activeTheme?.colors.background ? isColorDark(activeTheme.colors.background) : false
|
||||
|
||||
const loadThemes = useCallback(async () => {
|
||||
if (!vaultPath) return
|
||||
@@ -120,5 +236,5 @@ export function useThemeManager(vaultPath: string | null): ThemeManager {
|
||||
}
|
||||
}, [vaultPath, loadThemes, switchTheme])
|
||||
|
||||
return { themes, activeThemeId, activeTheme, switchTheme, createTheme, reloadThemes: loadThemes }
|
||||
return { themes, activeThemeId, activeTheme, isDark, switchTheme, createTheme, reloadThemes: loadThemes }
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Force light mode — dark mode removed for now
|
||||
document.documentElement.classList.remove('dark')
|
||||
|
||||
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
|
||||
// at native level before React's synthetic events can call preventDefault).
|
||||
// Capture phase fires first → prevents native menu; React bubble phase still fires
|
||||
|
||||
@@ -5,6 +5,13 @@ import { createElement, type ReactNode, type ComponentType } from 'react'
|
||||
// Mock scrollIntoView for jsdom (not implemented)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
|
||||
// Mock ResizeObserver for jsdom (not implemented)
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver
|
||||
|
||||
// Mock @tauri-apps/plugin-opener for test environment
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({
|
||||
openUrl: vi.fn(),
|
||||
|
||||
21
src/utils/rawEditorUtils.ts
Normal file
21
src/utils/rawEditorUtils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** Extract the wikilink query that the user is currently typing after [[ */
|
||||
export function extractWikilinkQuery(text: string, cursor: number): string | null {
|
||||
const before = text.slice(0, cursor)
|
||||
const triggerIdx = before.lastIndexOf('[[')
|
||||
if (triggerIdx === -1) return null
|
||||
const afterTrigger = before.slice(triggerIdx + 2)
|
||||
// Don't trigger if the query contains ] (already closed) or a newline
|
||||
if (afterTrigger.includes(']') || afterTrigger.includes('\n')) return null
|
||||
return afterTrigger
|
||||
}
|
||||
|
||||
/** Basic YAML frontmatter structural checks. */
|
||||
export function detectYamlError(content: string): string | null {
|
||||
if (!content.startsWith('---')) return null
|
||||
const rest = content.slice(3)
|
||||
const closeIdx = rest.search(/\n---(\n|$)/)
|
||||
if (closeIdx === -1) return 'Unclosed frontmatter block — add a closing --- line'
|
||||
const block = rest.slice(0, closeIdx)
|
||||
if (/^\t/m.test(block)) return 'YAML frontmatter contains tab indentation — use spaces'
|
||||
return null
|
||||
}
|
||||
5
src/utils/tabLayout.ts
Normal file
5
src/utils/tabLayout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Compute per-tab max-width so all tabs fit within the container. */
|
||||
export function computeTabMaxWidth(containerWidth: number, tabCount: number): number {
|
||||
if (tabCount === 0) return 360
|
||||
return Math.max(60, Math.min(360, Math.floor(containerWidth / tabCount)))
|
||||
}
|
||||
Reference in New Issue
Block a user