Skip to content

Commit a06c834

Browse files
committed
refactor: break down large editor component into modular architecture
1 parent cd3e482 commit a06c834

File tree

5 files changed

+346
-82
lines changed

5 files changed

+346
-82
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
"permissions": {
33
"allow": [
44
"Bash(npm run build:*)",
5-
"Bash(npm run lint)"
5+
"Bash(npm run lint)",
6+
"Bash(npm install:*)"
67
],
78
"deny": [],
89
"ask": []
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { Plus, Minus } from 'lucide-react';
2+
3+
interface StatusBarProps {
4+
wordCount: number;
5+
charCount: number;
6+
readingTime: number;
7+
scrollPercentage: number;
8+
zoomLevel: number;
9+
saveStatus: 'saved' | 'saving' | 'error';
10+
onZoomIn: () => void;
11+
onZoomOut: () => void;
12+
onResetZoom: () => void;
13+
}
14+
15+
export function StatusBar({
16+
wordCount,
17+
charCount,
18+
readingTime,
19+
scrollPercentage,
20+
zoomLevel,
21+
saveStatus,
22+
onZoomIn,
23+
onZoomOut,
24+
onResetZoom,
25+
}: StatusBarProps) {
26+
return (
27+
<div className="border-t bg-primary/5 dark:bg-primary/10 px-3 py-0.5 flex items-center justify-between text-[11px] font-sans">
28+
{/* Left side - Word, character count, and reading time */}
29+
<div className="flex items-center gap-3">
30+
{(wordCount > 0 || charCount > 0) && (
31+
<>
32+
<span className="hover:bg-muted px-1.5 py-0.5 rounded cursor-default">
33+
{wordCount} {wordCount === 1 ? 'word' : 'words'}
34+
</span>
35+
<span className="hover:bg-muted px-1.5 py-0.5 rounded cursor-default">
36+
{charCount} {charCount === 1 ? 'character' : 'characters'}
37+
</span>
38+
</>
39+
)}
40+
{readingTime > 0 && (
41+
<span className="hover:bg-muted px-1.5 py-0.5 rounded cursor-default" title="Estimated reading time">
42+
~{readingTime} min read
43+
</span>
44+
)}
45+
</div>
46+
47+
{/* Right side - Scroll percentage, zoom controls, and save status */}
48+
<div className="flex items-center gap-3">
49+
{scrollPercentage > 0 && (
50+
<span className="hover:bg-muted px-1.5 py-0.5 rounded cursor-default" title="Scroll position">
51+
{scrollPercentage}%
52+
</span>
53+
)}
54+
55+
{/* Zoom controls */}
56+
<div className="flex items-center gap-1">
57+
<button
58+
onClick={onZoomOut}
59+
className="hover:bg-muted p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
60+
title="Zoom out"
61+
disabled={zoomLevel <= 50}
62+
>
63+
<Minus className="h-3 w-3" />
64+
</button>
65+
<span
66+
className="hover:bg-muted px-1.5 py-0.5 rounded cursor-pointer min-w-[45px] text-center"
67+
onClick={onResetZoom}
68+
title="Click to reset zoom"
69+
>
70+
{zoomLevel}%
71+
</span>
72+
<button
73+
onClick={onZoomIn}
74+
className="hover:bg-muted p-1 rounded cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
75+
title="Zoom in"
76+
disabled={zoomLevel >= 200}
77+
>
78+
<Plus className="h-3 w-3" />
79+
</button>
80+
</div>
81+
82+
{/* Save status */}
83+
<div className="flex items-center gap-1 px-1.5 py-0.5 hover:bg-muted rounded cursor-default" title={
84+
saveStatus === 'saving' ? 'Saving changes...' :
85+
saveStatus === 'saved' ? 'All changes saved' :
86+
'Error saving changes'
87+
}>
88+
{saveStatus === 'saving' && (
89+
<>
90+
<div className="h-1.5 w-1.5 rounded-full bg-orange-500 animate-pulse" />
91+
<span>Saving</span>
92+
</>
93+
)}
94+
{saveStatus === 'saved' && (
95+
<>
96+
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
97+
<span>Saved</span>
98+
</>
99+
)}
100+
{saveStatus === 'error' && (
101+
<>
102+
<div className="h-1.5 w-1.5 rounded-full bg-red-500" />
103+
<span className="text-red-500">Error</span>
104+
</>
105+
)}
106+
</div>
107+
</div>
108+
</div>
109+
);
110+
}
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { useEffect } from 'react';
2+
import type { Editor } from '@tiptap/react';
3+
import type { Note } from '@/types/note';
4+
5+
interface UseEditorEffectsProps {
6+
editor: Editor | null;
7+
note: Note | null;
8+
updateCounts: (text: string) => void;
9+
setScrollPercentage: (percentage: number) => void;
10+
zoomLevel: number;
11+
baseFontSize: string;
12+
setBaseFontSize: (size: string) => void;
13+
lastContentRef: React.MutableRefObject<string>;
14+
}
15+
16+
export function useEditorEffects({
17+
editor,
18+
note,
19+
updateCounts,
20+
setScrollPercentage,
21+
zoomLevel,
22+
baseFontSize,
23+
setBaseFontSize,
24+
lastContentRef,
25+
}: UseEditorEffectsProps) {
26+
27+
// Sync editor content with note changes
28+
useEffect(() => {
29+
if (!editor || !note) return;
30+
31+
const currentContent = editor.getHTML();
32+
if (note.content !== currentContent) {
33+
const { from, to } = editor.state.selection;
34+
editor.commands.setContent(note.content || '', false);
35+
lastContentRef.current = note.content || '';
36+
37+
// Update word and character counts
38+
const text = editor.state.doc.textContent;
39+
updateCounts(text);
40+
41+
try {
42+
const docSize = editor.state.doc.content.size;
43+
if (from <= docSize && to <= docSize) {
44+
editor.commands.setTextSelection({ from, to });
45+
}
46+
} catch {
47+
// Ignore cursor restoration errors
48+
}
49+
}
50+
}, [note, editor, updateCounts, lastContentRef]);
51+
52+
// Initialize word count when editor is ready
53+
useEffect(() => {
54+
if (!editor) return;
55+
56+
// Calculate initial word count
57+
const text = editor.state.doc.textContent;
58+
updateCounts(text);
59+
}, [editor, updateCounts]);
60+
61+
// Track scroll percentage
62+
useEffect(() => {
63+
if (!editor) return;
64+
65+
const updateScrollPercentage = () => {
66+
const editorView = editor.view;
67+
if (!editorView.dom) return;
68+
69+
const { scrollTop, scrollHeight, clientHeight } = editorView.dom;
70+
const maxScroll = scrollHeight - clientHeight;
71+
72+
if (maxScroll <= 0) {
73+
setScrollPercentage(0);
74+
} else {
75+
const percentage = Math.round((scrollTop / maxScroll) * 100);
76+
setScrollPercentage(Math.min(100, Math.max(0, percentage)));
77+
}
78+
};
79+
80+
const editorElement = editor.view.dom;
81+
editorElement.addEventListener('scroll', updateScrollPercentage);
82+
83+
// Initial calculation
84+
updateScrollPercentage();
85+
86+
return () => {
87+
editorElement.removeEventListener('scroll', updateScrollPercentage);
88+
};
89+
}, [editor, setScrollPercentage]);
90+
91+
// Store the original font size when editor is first created
92+
useEffect(() => {
93+
if (!editor || baseFontSize) return;
94+
95+
const editorElement = editor.view.dom as HTMLElement;
96+
const computedStyle = window.getComputedStyle(editorElement);
97+
const originalFontSize = computedStyle.fontSize;
98+
setBaseFontSize(originalFontSize);
99+
}, [editor, baseFontSize, setBaseFontSize]);
100+
101+
// Apply zoom level to editor
102+
useEffect(() => {
103+
if (!editor || !baseFontSize) return;
104+
105+
const editorElement = editor.view.dom as HTMLElement;
106+
107+
if (zoomLevel === 100) {
108+
// At 100%, use the original font size
109+
editorElement.style.fontSize = baseFontSize;
110+
} else {
111+
// Calculate the new font size based on the original
112+
const baseSize = parseFloat(baseFontSize);
113+
const scaleFactor = zoomLevel / 100;
114+
const newSize = baseSize * scaleFactor;
115+
editorElement.style.fontSize = `${newSize}px`;
116+
}
117+
}, [editor, zoomLevel, baseFontSize]);
118+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { useState, useCallback, useRef } from 'react';
2+
3+
export function useEditorState() {
4+
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved');
5+
const [wordCount, setWordCount] = useState(0);
6+
const [charCount, setCharCount] = useState(0);
7+
const [scrollPercentage, setScrollPercentage] = useState(0);
8+
const [readingTime, setReadingTime] = useState(0);
9+
const [zoomLevel, setZoomLevel] = useState(100);
10+
const [baseFontSize, setBaseFontSize] = useState<string>('');
11+
12+
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
13+
const lastContentRef = useRef<string>('');
14+
15+
// Unified function to calculate and update word/character counts
16+
const updateCounts = useCallback((text: string) => {
17+
const trimmedText = text.trim();
18+
const words = trimmedText ? trimmedText.split(/\s+/).length : 0;
19+
const chars = text.length;
20+
setWordCount(words);
21+
setCharCount(chars);
22+
23+
// Calculate reading time (average 200 words per minute)
24+
const minutes = Math.ceil(words / 200);
25+
setReadingTime(minutes);
26+
}, []);
27+
28+
// Zoom control functions
29+
const handleZoomIn = useCallback(() => {
30+
setZoomLevel(prev => Math.min(200, prev + 10));
31+
}, []);
32+
33+
const handleZoomOut = useCallback(() => {
34+
setZoomLevel(prev => Math.max(50, prev - 10));
35+
}, []);
36+
37+
const resetZoom = useCallback(() => {
38+
setZoomLevel(100);
39+
}, []);
40+
41+
return {
42+
// State
43+
saveStatus,
44+
setSaveStatus,
45+
wordCount,
46+
charCount,
47+
scrollPercentage,
48+
setScrollPercentage,
49+
readingTime,
50+
zoomLevel,
51+
baseFontSize,
52+
setBaseFontSize,
53+
54+
// Refs
55+
saveTimeoutRef,
56+
lastContentRef,
57+
58+
// Functions
59+
updateCounts,
60+
handleZoomIn,
61+
handleZoomOut,
62+
resetZoom,
63+
};
64+
}

0 commit comments

Comments
 (0)