summaryrefslogtreecommitdiff
path: root/frontend/src/components/document/AutoSavePlugin.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components/document/AutoSavePlugin.tsx')
-rw-r--r--frontend/src/components/document/AutoSavePlugin.tsx140
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>
+ );
+}