Compare commits

...

6 Commits

Author SHA1 Message Date
Test
90e67adc41 fix: editor background matches vault theme in dark mode
BlockNote's internal CSS sets `.bn-editor { background-color: var(--bn-colors-editor-background); }`
using its own hardcoded variables (#ffffff light, #1f1f1f dark). Our `.bn-container` rule set
`background: var(--bg-primary)` but this didn't cascade to `.bn-editor` which has its own rule.

Override the BlockNote internal CSS variables (`--bn-colors-editor-background` etc.) on
`.bn-container` so BlockNote's own rules pick up our vault theme colors. CSS custom properties
cascade normally, and our selector specificity (2 classes) matches BlockNote's dark theme rule
(1 class + 1 attribute) — source order wins since our CSS loads after BlockNote's.

Fixes: editor content area now seamlessly blends with sidebar and container in any vault theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 19:33:57 +01:00
Test
185feb5a2c feat: raw editor mode — plain textarea with frontmatter + wikilink autocomplete
Adds a third editor mode (alongside WYSIWYG and Diff) that shows the raw
markdown file in a monospaced textarea. Includes:

- useRawMode hook with derived state (no setState-in-effect) and onFlushPending
- RawEditorView: textarea with 500ms debounce, YAML error banner, wikilink
  autocomplete via [[  trigger, Cmd+S save
- BreadcrumbBar Code icon toggles raw mode; mutual exclusion with diff mode
- Command palette: "Toggle Raw Editor" (View group, requires active tab)
- useEditorModeExclusion hook extracted to keep Editor.tsx under complexity threshold
- buildViewCommands extracted from useCommandRegistry for same reason
- RawToggleButton and RawModeEditorSection components extracted for clean CC

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 18:44:47 +01:00
Test
f04dfdbd37 feat: auto-focus editor with H1 title selected on new note creation
When a new note is created (Cmd+N or via command palette), the editor
immediately focuses and selects all text in the H1 title block, so the
user can start typing the note name right away without clicking.

- signalFocusEditor now accepts { selectTitle?: boolean } and passes it
  in the laputa:focus-editor event detail
- handleCreateNoteImmediate dispatches with selectTitle: true
- useEditorFocus reads selectTitle from event and calls selectFirstHeading
- selectFirstHeading walks the ProseMirror document to find the first
  heading node and uses TipTap's chain().setTextSelection() to select it
- Opening existing notes is unaffected (selectTitle defaults to false)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 18:31:53 +01:00
Test
3c27403f86 fix: use globalThis instead of global in test setup (TS build compatibility) 2026-03-02 14:45:39 +01:00
Test
276b3c1a37 feat: responsive tab width — shrink tabs to fit window
Tabs now dynamically reduce their max-width when the total width exceeds
the TabBar container, similar to browser tab behavior. Uses a
ResizeObserver on the tab area to calculate per-tab max-width as
min(360, containerWidth / tabCount), floored at 60px minimum.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:45:39 +01:00
Test
cadb3500f1 feat: dark theme applies to editor content area
Three changes make the editor respect the active theme:

1. useThemeManager now derives app-specific CSS variables (--bg-primary,
   --text-primary, --border-primary, etc.) from the theme's core colors,
   so the editor's EditorTheme.css variables resolve correctly for both
   light and dark themes.

2. BlockNoteView theme prop is now dynamic (isDark ? 'dark' : 'light')
   instead of hardcoded to 'light', so BlockNote's own UI chrome (menus,
   toolbars) matches the active theme.

3. Removed forced light-mode class removal from main.tsx that was
   preventing dark mode from being applied.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 12:35:22 +01:00
28 changed files with 1243 additions and 62 deletions

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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')
})
})

View 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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
View 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 }
}

View File

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

View File

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

View File

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

View File

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

View 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
View 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)))
}