diff options
Diffstat (limited to 'frontend/src/components/document/AutoSavePlugin.tsx')
| -rw-r--r-- | frontend/src/components/document/AutoSavePlugin.tsx | 140 |
1 files changed, 140 insertions, 0 deletions
diff --git a/frontend/src/components/document/AutoSavePlugin.tsx b/frontend/src/components/document/AutoSavePlugin.tsx new file mode 100644 index 0000000..d3d0eb5 --- /dev/null +++ b/frontend/src/components/document/AutoSavePlugin.tsx @@ -0,0 +1,140 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { UNDO_COMMAND } from 'lexical'; + +const COUNTDOWN_DURATION_MS = 3000; +const TICK_INTERVAL_MS = 50; + +interface AutoSavePluginProps { + onAutoSave: (content: string) => void; + getContent: () => string; + enabled?: boolean; +} + +export default function AutoSavePlugin({ + onAutoSave, + getContent, + enabled = true, +}: AutoSavePluginProps) { + const [editor] = useLexicalComposerContext(); + const [countdown, setCountdown] = useState<number | null>(null); + const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); + const startTimeRef = useRef<number>(0); + const pendingContentRef = useRef<string>(''); + const lastSavedContentRef = useRef<string>(''); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setCountdown(null); + }, []); + + const cancelCountdown = useCallback(() => { + clearTimer(); + }, [clearTimer]); + + const startCountdown = useCallback( + (content: string) => { + pendingContentRef.current = content; + clearTimer(); + + startTimeRef.current = Date.now(); + setCountdown(COUNTDOWN_DURATION_MS); + + timerRef.current = setInterval(() => { + const elapsed = Date.now() - startTimeRef.current; + const remaining = COUNTDOWN_DURATION_MS - elapsed; + + if (remaining <= 0) { + clearTimer(); + lastSavedContentRef.current = pendingContentRef.current; + onAutoSave(pendingContentRef.current); + } else { + setCountdown(remaining); + } + }, TICK_INTERVAL_MS); + }, + [clearTimer, onAutoSave] + ); + + // Listen for editor updates (content changes) + useEffect(() => { + if (!enabled) return; + + const unregister = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => { + // Only trigger on actual content changes + if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return; + + const content = getContent(); + if (content !== lastSavedContentRef.current) { + startCountdown(content); + } + }); + + return unregister; + }, [editor, enabled, getContent, startCountdown]); + + // Listen for undo command to cancel countdown + useEffect(() => { + const unregister = editor.registerCommand( + UNDO_COMMAND, + () => { + cancelCountdown(); + return false; // Don't prevent the undo from executing + }, + 1 // COMMAND_PRIORITY_LOW + ); + + return unregister; + }, [editor, cancelCountdown]); + + // Listen for Escape key to cancel countdown + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && countdown !== null) { + e.preventDefault(); + cancelCountdown(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [countdown, cancelCountdown]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (timerRef.current !== null) { + clearInterval(timerRef.current); + } + }; + }, []); + + if (countdown === null) return null; + + const progressPercent = (countdown / COUNTDOWN_DURATION_MS) * 100; + const secondsLeft = Math.ceil(countdown / 1000); + + return ( + <div className="autosave-bar"> + <span className="autosave-bar-text"> + Saving in {secondsLeft}s... <kbd>Esc</kbd> to cancel + </span> + <div className="autosave-bar-progress-track"> + <div + className="autosave-bar-progress-fill" + style={{ width: `${progressPercent}%` }} + /> + </div> + <button + className="autosave-bar-cancel" + onClick={cancelCountdown} + type="button" + > + Cancel + </button> + </div> + ); +} |
