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