diff options
| author | soryu <soryu@soryu.co> | 2026-04-28 00:18:40 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-28 00:18:40 +0100 |
| commit | c8b169da8cb7eae0957e0ab5e7370b071093a224 (patch) | |
| tree | c3f9720a8acfe863ac0b65df9439abf9a941323a /frontend/src/components/document/AutoSavePlugin.tsx | |
| parent | 3679ceb3325033faa2f889ef3dfee5668ef7aeea (diff) | |
| download | soryu-c8b169da8cb7eae0957e0ab5e7370b071093a224.tar.gz soryu-c8b169da8cb7eae0957e0ab5e7370b071093a224.zip | |
feat: Document UI for directive orchestration with Lexical editor (#93)
* WIP: heartbeat checkpoint
* feat: soryu-co/soryu - makima: Save previous goal on update and include history in re-planning prompt
* feat: soryu-co/soryu - makima: Install Lexical and create base document editor component
* feat: soryu-co/soryu - makima: Create directive file system sidebar and document layout
* feat: soryu-co/soryu - makima: Create custom Lexical step diagram block
* feat: soryu-co/soryu - makima: Add context menu and goal auto-update integration
* WIP: heartbeat checkpoint
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> + ); +} |
