From f6ab24c5b35f6bd436cde068288f2d90cc5f0b92 Mon Sep 17 00:00:00 2001 From: Gustavo Henrique Santos Souza de Miranda Date: Thu, 22 Jan 2026 18:14:49 -0300 Subject: [PATCH] feat: [US-033] - Auto-save to LocalStorage Co-Authored-By: Claude Opus 4.5 --- .../editor/[projectId]/FlowchartEditor.tsx | 162 +++++++++++++++++- 1 file changed, 160 insertions(+), 2 deletions(-) diff --git a/src/app/editor/[projectId]/FlowchartEditor.tsx b/src/app/editor/[projectId]/FlowchartEditor.tsx index 0df0156..3efa2c1 100644 --- a/src/app/editor/[projectId]/FlowchartEditor.tsx +++ b/src/app/editor/[projectId]/FlowchartEditor.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, BackgroundVariant, @@ -30,6 +30,12 @@ import ContextMenu, { ContextMenuType } from '@/components/editor/ContextMenu' import ConditionEditor from '@/components/editor/ConditionEditor' import type { FlowchartData, FlowchartNode, FlowchartEdge, Condition } from '@/types/flowchart' +// LocalStorage key prefix for draft saves +const DRAFT_KEY_PREFIX = 'vnwrite-draft-' + +// Debounce delay in ms +const AUTOSAVE_DEBOUNCE_MS = 1000 + type ContextMenuState = { x: number y: number @@ -74,8 +80,70 @@ function toReactFlowEdges(edges: FlowchartEdge[]): Edge[] { })) } +// Convert React Flow Node type back to our FlowchartNode type +function fromReactFlowNodes(nodes: Node[]): FlowchartNode[] { + return nodes.map((node) => ({ + id: node.id, + type: node.type as 'dialogue' | 'choice' | 'variable', + position: node.position, + data: node.data, + })) as FlowchartNode[] +} + +// Convert React Flow Edge type back to our FlowchartEdge type +function fromReactFlowEdges(edges: Edge[]): FlowchartEdge[] { + return edges.map((edge) => ({ + id: edge.id, + source: edge.source, + sourceHandle: edge.sourceHandle ?? undefined, + target: edge.target, + targetHandle: edge.targetHandle ?? undefined, + data: edge.data, + })) +} + +// Get LocalStorage key for a project +function getDraftKey(projectId: string): string { + return `${DRAFT_KEY_PREFIX}${projectId}` +} + +// Save draft to LocalStorage +function saveDraft(projectId: string, data: FlowchartData): void { + try { + localStorage.setItem(getDraftKey(projectId), JSON.stringify(data)) + } catch (error) { + console.error('Failed to save draft to LocalStorage:', error) + } +} + +// Load draft from LocalStorage +function loadDraft(projectId: string): FlowchartData | null { + try { + const draft = localStorage.getItem(getDraftKey(projectId)) + if (!draft) return null + return JSON.parse(draft) as FlowchartData + } catch (error) { + console.error('Failed to load draft from LocalStorage:', error) + return null + } +} + +// Clear draft from LocalStorage +export function clearDraft(projectId: string): void { + try { + localStorage.removeItem(getDraftKey(projectId)) + } catch (error) { + console.error('Failed to clear draft from LocalStorage:', error) + } +} + +// Compare two FlowchartData objects for equality +function flowchartDataEquals(a: FlowchartData, b: FlowchartData): boolean { + return JSON.stringify(a) === JSON.stringify(b) +} + // Inner component that uses useReactFlow hook -function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { +function FlowchartEditorInner({ projectId, initialData }: FlowchartEditorProps) { // Define custom node types - memoized to prevent re-renders const nodeTypes: NodeTypes = useMemo( () => ({ @@ -99,6 +167,22 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { const [contextMenu, setContextMenu] = useState(null) const [conditionEditor, setConditionEditor] = useState(null) + // Check for saved draft on initial render (lazy initialization) + const [draftState, setDraftState] = useState<{ + showPrompt: boolean + savedDraft: FlowchartData | null + }>(() => { + // This runs only once on initial render (client-side) + if (typeof window === 'undefined') { + return { showPrompt: false, savedDraft: null } + } + const draft = loadDraft(projectId) + if (draft && !flowchartDataEquals(draft, initialData)) { + return { showPrompt: true, savedDraft: draft } + } + return { showPrompt: false, savedDraft: null } + }) + const [nodes, setNodes, onNodesChange] = useNodesState( toReactFlowNodes(initialData.nodes) ) @@ -106,6 +190,51 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { toReactFlowEdges(initialData.edges) ) + // Track debounce timer + const saveTimerRef = useRef(null) + + // Debounced auto-save to LocalStorage + useEffect(() => { + // Don't save while draft prompt is showing + if (draftState.showPrompt) return + + // Clear existing timer + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + + // Set new timer + saveTimerRef.current = setTimeout(() => { + const currentData: FlowchartData = { + nodes: fromReactFlowNodes(nodes), + edges: fromReactFlowEdges(edges), + } + saveDraft(projectId, currentData) + }, AUTOSAVE_DEBOUNCE_MS) + + // Cleanup on unmount + return () => { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current) + } + } + }, [nodes, edges, projectId, draftState.showPrompt]) + + // Handle restoring draft + const handleRestoreDraft = useCallback(() => { + if (draftState.savedDraft) { + setNodes(toReactFlowNodes(draftState.savedDraft.nodes)) + setEdges(toReactFlowEdges(draftState.savedDraft.edges)) + } + setDraftState({ showPrompt: false, savedDraft: null }) + }, [draftState.savedDraft, setNodes, setEdges]) + + // Handle discarding draft + const handleDiscardDraft = useCallback(() => { + clearDraft(projectId) + setDraftState({ showPrompt: false, savedDraft: null }) + }, [projectId]) + const onConnect = useCallback( (params: Connection) => { if (!params.source || !params.target) return @@ -428,6 +557,35 @@ function FlowchartEditorInner({ initialData }: FlowchartEditorProps) { onCancel={closeConditionEditor} /> )} + + {/* Draft restoration prompt */} + {draftState.showPrompt && ( +
+
+

+ Unsaved Draft Found +

+

+ A local draft was found that differs from the saved version. Would + you like to restore it or discard it? +

+
+ + +
+
+
+ )} ) }