Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 140 additions & 5 deletions src/renderer/components/editor/CodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { useRef, useEffect } from 'react'
import { useCodeMirror } from '@/hooks/use-codemirror'
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import type { ImperativePanelGroupHandle, PanelOnResize } from 'react-resizable-panels'
import { useCodeMirror, type VisibleLineRange } from '@/hooks/use-codemirror'
import { TocPanel } from './TocPanel'
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable'
import { useTocSettingsStore } from '@/stores/toc-settings-store'
import { TOC_MAX_WIDTH, TOC_MIN_WIDTH } from '@/types/settings'

interface CodeEditorProps {
filePath: string
Expand All @@ -12,6 +17,16 @@ interface CodeEditorProps {
onScrollChange: (scrollTop: number) => void
}

function getTocPercentBounds(panelWidth: number): { minPercent: number; maxPercent: number } {
const minPercent = (TOC_MIN_WIDTH / panelWidth) * 100
const maxPercent = (TOC_MAX_WIDTH / panelWidth) * 100

return {
minPercent,
maxPercent: Math.max(minPercent, maxPercent)
}
}

export function CodeEditor({
content,
language,
Expand All @@ -22,15 +37,57 @@ export function CodeEditor({
onScrollChange
}: CodeEditorProps): React.JSX.Element {
const containerRef = useRef<HTMLDivElement>(null)
const { view, setContent } = useCodeMirror(containerRef, {
const layoutRef = useRef<HTMLDivElement>(null)
const panelGroupRef = useRef<ImperativePanelGroupHandle>(null)
const [visibleRange, setVisibleRange] = useState<VisibleLineRange | undefined>()
const [layoutWidth, setLayoutWidth] = useState(0)
const { isTocHydrated, isTocVisible, tocWidth, setTocWidth } = useTocSettingsStore((state) => ({
isTocHydrated: state.isLoaded || state.loadFailed,
isTocVisible: state.settings.isVisible,
tocWidth: state.settings.width,
setTocWidth: state.setWidth
}))

const { view, setContent, scrollToLine } = useCodeMirror(containerRef, {
content,
language,
readOnly,
onChange,
onCursorChange,
onScrollChange
onScrollChange,
onVisibleRangeChange: setVisibleRange
})

const getPanelWidth = useCallback((): number => {
return layoutWidth || layoutRef.current?.clientWidth || 1000
}, [layoutWidth])

const getTocPanelSizePercent = useCallback((): number => {
const panelWidth = getPanelWidth()
const { minPercent, maxPercent } = getTocPercentBounds(panelWidth)
const widthRatio = panelWidth > 0 ? tocWidth / panelWidth : 0

return Math.min(maxPercent, Math.max(minPercent, widthRatio * 100))
}, [getPanelWidth, tocWidth])

const tocPanelBounds = useMemo(() => getTocPercentBounds(getPanelWidth()), [getPanelWidth])
const tocPanelDefaultSize = useMemo(() => getTocPanelSizePercent(), [getTocPanelSizePercent])
const canRenderToc = isTocHydrated && isTocVisible && language === 'markdown'

const handleTocResize = useCallback<PanelOnResize>(
(size, prevSize): void => {
const panelWidth = getPanelWidth()
const { minPercent, maxPercent } = getTocPercentBounds(panelWidth)
const clampedSize = Math.min(maxPercent, Math.max(minPercent, size))
const nextPixels = Math.round((clampedSize / 100) * panelWidth)

if (prevSize !== size) {
setTocWidth(nextPixels)
}
},
[getPanelWidth, setTocWidth]
)

// Update content when it changes from external source (file reload)
const prevContentRef = useRef(content)
useEffect(() => {
Expand All @@ -40,14 +97,92 @@ export function CodeEditor({
}
}, [content, setContent])

useEffect(() => {
const element = layoutRef.current
if (!element) {
return
}

const updateLayoutWidth = (): void => {
setLayoutWidth(element.clientWidth)
}

updateLayoutWidth()

const observer = new ResizeObserver(() => {
updateLayoutWidth()
})

observer.observe(element)

return () => observer.disconnect()
}, [])

// Focus when becoming visible
useEffect(() => {
if (isVisible && view) {
view.focus()
}
}, [isVisible, view])

useEffect(() => {
if (!canRenderToc) {
return
}

const group = panelGroupRef.current
if (!group) {
return
}

const tocSize = getTocPanelSizePercent()
const currentTocSize = group.getLayout()[1]

if (currentTocSize !== undefined && Math.abs(currentTocSize - tocSize) < 0.5) {
return
}

group.setLayout([100 - tocSize, tocSize])
}, [canRenderToc, getTocPanelSizePercent])

return (
<div ref={containerRef} className="w-full h-full overflow-hidden" />
<div className="h-full w-full" style={{ display: isVisible ? 'block' : 'none' }}>
<div ref={layoutRef} className="h-full w-full">
<ResizablePanelGroup ref={panelGroupRef} direction="horizontal">
<ResizablePanel
defaultSize={canRenderToc ? 100 - tocPanelDefaultSize : 100}
minSize={60}
>
<div ref={containerRef} className="w-full h-full overflow-hidden" />
</ResizablePanel>

{canRenderToc && (
<>
<ResizableHandle />
<ResizablePanel
defaultSize={tocPanelDefaultSize}
minSize={tocPanelBounds.minPercent}
maxSize={tocPanelBounds.maxPercent}
onResize={handleTocResize}
>
<div
className="h-full"
style={{ minWidth: TOC_MIN_WIDTH, maxWidth: TOC_MAX_WIDTH, width: '100%' }}
>
<TocPanel
editorMode="codemirror"
codemirror={{
content,
scrollToLine,
visibleRange
}}
/>
</div>
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
</div>
)
}
12 changes: 8 additions & 4 deletions src/renderer/components/editor/EditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MarkdownEditor } from './MarkdownEditor'
import { EditorToolbar } from './EditorToolbar'
import { useEditorStore } from '@/stores/editor-store'
import type { EditorFileState } from '@/stores/editor-store'
import { useTocSettings } from '@/hooks/use-toc-settings'

interface EditorPanelProps {
filePath: string
Expand All @@ -14,6 +15,9 @@ export function EditorPanel({
filePath,
isVisible
}: EditorPanelProps): React.JSX.Element {
// Intentionally invoked for side effects: loads and persists shared TOC settings.
useTocSettings()

const fileState = useEditorStore(
(state) => state.openFiles.get(filePath)
) as EditorFileState | undefined
Expand All @@ -25,28 +29,28 @@ export function EditorPanel({
(content: string) => {
updateContent(filePath, content)
},
[filePath]
[filePath, updateContent]
)

const handleCursorChange = useCallback(
(line: number, col: number) => {
updateCursorPosition(filePath, line, col)
},
[filePath]
[filePath, updateCursorPosition]
)

const handleScrollChange = useCallback(
(scrollTop: number) => {
updateScrollTop(filePath, scrollTop)
},
[filePath]
[filePath, updateScrollTop]
)

const handleToggleViewMode = useCallback(() => {
if (!fileState) return
const newMode = fileState.viewMode === 'markdown' ? 'code' : 'markdown'
setViewMode(filePath, newMode)
}, [filePath, fileState?.viewMode])
}, [filePath, fileState, setViewMode])

if (!fileState) {
return (
Expand Down
66 changes: 44 additions & 22 deletions src/renderer/components/editor/EditorToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Code2, Eye } from 'lucide-react'
import { Code2, Eye, List } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { useTocIsVisible, useTocSettingsStore } from '@/stores/toc-settings-store'

interface EditorToolbarProps {
viewMode: 'code' | 'markdown'
Expand All @@ -12,31 +14,51 @@ export function EditorToolbar({
onToggleViewMode,
filePath
}: EditorToolbarProps): React.JSX.Element {
const fileName = filePath.split('/').pop() || filePath
const fileName = filePath.split(/[\\/]/).pop() || filePath
const isTocVisible = useTocIsVisible()
const toggleTocVisibility = useTocSettingsStore((state) => state.toggleVisibility)

return (
<div className="flex items-center justify-between px-3 h-8 border-b border-border bg-card flex-shrink-0">
<span className="text-xs text-muted-foreground truncate">{fileName}</span>
<button
onClick={onToggleViewMode}
className={cn(
'flex items-center gap-1 px-2 py-0.5 text-xs rounded transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-secondary'
)}
title={viewMode === 'markdown' ? 'Switch to source mode' : 'Switch to WYSIWYG mode'}
>
{viewMode === 'markdown' ? (
<>
<Code2 size={12} />
<span>Source</span>
</>
) : (
<>
<Eye size={12} />
<span>Preview</span>
</>
)}
</button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className={cn(
'h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground',
isTocVisible && 'bg-accent text-accent-foreground'
)}
onClick={toggleTocVisibility}
title="Toggle Table of Contents"
aria-pressed={isTocVisible}
>
<List size={12} />
<span>TOC</span>
</Button>

<Button
variant="ghost"
size="sm"
onClick={onToggleViewMode}
className={cn(
'h-6 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary'
)}
title={viewMode === 'markdown' ? 'Switch to source mode' : 'Switch to WYSIWYG mode'}
>
{viewMode === 'markdown' ? (
<>
<Code2 size={12} />
<span>Source</span>
</>
) : (
<>
<Eye size={12} />
<span>Preview</span>
</>
)}
</Button>
</div>
</div>
)
}
Loading
Loading