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>
This commit is contained in:
Test
2026-03-02 18:38:25 +01:00
parent f04dfdbd37
commit 185feb5a2c
14 changed files with 750 additions and 16 deletions

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

@@ -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
@@ -61,6 +63,34 @@ interface EditorProps {
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() {
@@ -81,9 +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])
@@ -116,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'))
@@ -149,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}

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
@@ -63,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
@@ -70,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
@@ -83,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}
@@ -98,19 +127,28 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
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} 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>
)
}