From 5a73b9a3634da128ab69778c59af638d1e779fee Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:10:00 +0100 Subject: [PATCH] refactor: change TTD persistence to iDB (#10662) refactor: change ttd persistence to iDB --- excalidraw-app/app_constants.ts | 1 + excalidraw-app/components/AI.tsx | 3 + excalidraw-app/data/TTDStorage.ts | 51 +++++++ .../components/TTDDialog/TTDDialog.tsx | 9 +- .../components/TTDDialog/TextToDiagram.tsx | 16 ++- .../TTDDialog/hooks/useChatManagement.ts | 123 +++++++++------- .../excalidraw/components/TTDDialog/types.ts | 18 +++ .../components/TTDDialog/useTTDChatStorage.ts | 133 +++++++++++------- packages/excalidraw/index.tsx | 5 + 9 files changed, 256 insertions(+), 103 deletions(-) create mode 100644 excalidraw-app/data/TTDStorage.ts diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 52c1ad7ba2..e4370e7986 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -46,6 +46,7 @@ export const STORAGE_KEYS = { VERSION_FILES: "version-files", IDB_LIBRARY: "excalidraw-library", + IDB_TTD_CHATS: "excalidraw-ttd-chats", // do not use apart from migrations __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", diff --git a/excalidraw-app/components/AI.tsx b/excalidraw-app/components/AI.tsx index e45773f8b4..546cfa18dc 100644 --- a/excalidraw-app/components/AI.tsx +++ b/excalidraw-app/components/AI.tsx @@ -11,6 +11,8 @@ import { safelyParseJSON } from "@excalidraw/common"; import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types"; +import { TTDIndexedDBAdapter } from "../data/TTDStorage"; + export const AIComponents = ({ excalidrawAPI, }: { @@ -116,6 +118,7 @@ export const AIComponents = ({ return result; }} + persistenceAdapter={TTDIndexedDBAdapter} /> ); diff --git a/excalidraw-app/data/TTDStorage.ts b/excalidraw-app/data/TTDStorage.ts new file mode 100644 index 0000000000..d7fe5d89e7 --- /dev/null +++ b/excalidraw-app/data/TTDStorage.ts @@ -0,0 +1,51 @@ +import { createStore, get, set } from "idb-keyval"; + +import type { SavedChats } from "@excalidraw/excalidraw/components/TTDDialog/types"; + +import { STORAGE_KEYS } from "../app_constants"; + +/** + * IndexedDB adapter for TTD chat storage. + * Implements TTDPersistenceAdapter interface. + */ +export class TTDIndexedDBAdapter { + /** IndexedDB database name */ + private static idb_name = STORAGE_KEYS.IDB_TTD_CHATS; + /** Store key for chat data */ + private static key = "ttdChats"; + + private static store = createStore( + `${TTDIndexedDBAdapter.idb_name}-db`, + `${TTDIndexedDBAdapter.idb_name}-store`, + ); + + /** + * Load saved chats from IndexedDB. + * @returns Promise resolving to saved chats array (empty if none found) + */ + static async loadChats(): Promise { + try { + const data = await get( + TTDIndexedDBAdapter.key, + TTDIndexedDBAdapter.store, + ); + return data || []; + } catch (error) { + console.warn("Failed to load TTD chats from IndexedDB:", error); + return []; + } + } + + /** + * Save chats to IndexedDB. + * @param chats - The chats array to persist + */ + static async saveChats(chats: SavedChats): Promise { + try { + await set(TTDIndexedDBAdapter.key, chats, TTDIndexedDBAdapter.store); + } catch (error) { + console.warn("Failed to save TTD chats to IndexedDB:", error); + throw error; + } + } +} diff --git a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx index a6263f2374..a77167613e 100644 --- a/packages/excalidraw/components/TTDDialog/TTDDialog.tsx +++ b/packages/excalidraw/components/TTDDialog/TTDDialog.tsx @@ -15,13 +15,18 @@ import { TTDDialogTab } from "./TTDDialogTab"; import "./TTDDialog.scss"; -import type { MermaidToExcalidrawLibProps, TTTDDialog } from "./types"; +import type { + MermaidToExcalidrawLibProps, + TTDPersistenceAdapter, + TTTDDialog, +} from "./types"; export const TTDDialog = ( props: | { onTextSubmit: TTTDDialog.onTextSubmit; renderWarning?: TTTDDialog.renderWarning; + persistenceAdapter: TTDPersistenceAdapter; } | { __fallback: true }, ) => { @@ -50,6 +55,7 @@ const TTDDialogBase = withInternalFallback( props: TTTDDialog.OnTextSubmitProps, ): Promise; renderWarning?: TTTDDialog.renderWarning; + persistenceAdapter: TTDPersistenceAdapter; } | { __fallback: true } )) => { @@ -105,6 +111,7 @@ const TTDDialogBase = withInternalFallback( mermaidToExcalidrawLib={mermaidToExcalidrawLib} onTextSubmit={rest.onTextSubmit} renderWarning={rest.renderWarning} + persistenceAdapter={rest.persistenceAdapter} /> )} diff --git a/packages/excalidraw/components/TTDDialog/TextToDiagram.tsx b/packages/excalidraw/components/TTDDialog/TextToDiagram.tsx index df34dbceb3..e93775a76b 100644 --- a/packages/excalidraw/components/TTDDialog/TextToDiagram.tsx +++ b/packages/excalidraw/components/TTDDialog/TextToDiagram.tsx @@ -25,18 +25,25 @@ import { TTDPreviewPanel } from "./TTDPreviewPanel"; import { getLastAssistantMessage } from "./utils/chat"; import type { BinaryFiles } from "../../types"; -import type { MermaidToExcalidrawLibProps, TChat, TTTDDialog } from "./types"; +import type { + MermaidToExcalidrawLibProps, + TChat, + TTDPersistenceAdapter, + TTTDDialog, +} from "./types"; const TextToDiagramContent = ({ mermaidToExcalidrawLib, onTextSubmit, renderWarning, + persistenceAdapter, }: { mermaidToExcalidrawLib: MermaidToExcalidrawLibProps; onTextSubmit: ( props: TTTDDialog.OnTextSubmitProps, ) => Promise; renderWarning?: TTTDDialog.renderWarning; + persistenceAdapter: TTDPersistenceAdapter; }) => { const app = useApp(); const setAppState = useExcalidrawSetAppState(); @@ -46,7 +53,7 @@ const TextToDiagramContent = ({ const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom); const showPreview = useAtomValue(showPreviewAtom); - const { savedChats } = useTTDChatStorage(); + const { savedChats } = useTTDChatStorage({ persistenceAdapter }); const lastAssistantMessage = getLastAssistantMessage(chatHistory); @@ -68,7 +75,7 @@ const TextToDiagramContent = ({ handleNewChat, handleMenuToggle, handleMenuClose, - } = useChatManagement(); + } = useChatManagement({ persistenceAdapter }); const onViewAsMermaid = () => { if (typeof lastAssistantMessage?.content === "string") { @@ -231,18 +238,21 @@ export const TextToDiagram = ({ mermaidToExcalidrawLib, onTextSubmit, renderWarning, + persistenceAdapter, }: { mermaidToExcalidrawLib: MermaidToExcalidrawLibProps; onTextSubmit( props: TTTDDialog.OnTextSubmitProps, ): Promise; renderWarning?: TTTDDialog.renderWarning; + persistenceAdapter: TTDPersistenceAdapter; }) => { return ( ); }; diff --git a/packages/excalidraw/components/TTDDialog/hooks/useChatManagement.ts b/packages/excalidraw/components/TTDDialog/hooks/useChatManagement.ts index fb563ce2b2..beba3e30e6 100644 --- a/packages/excalidraw/components/TTDDialog/hooks/useChatManagement.ts +++ b/packages/excalidraw/components/TTDDialog/hooks/useChatManagement.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useAtom, useSetAtom } from "../../../editor-jotai"; @@ -8,81 +8,100 @@ import { useTTDChatStorage } from "../useTTDChatStorage"; import { getLastAssistantMessage } from "../utils/chat"; -import type { SavedChat } from "../types"; +import type { SavedChat, TTDPersistenceAdapter } from "../types"; -export const useChatManagement = () => { +interface UseChatManagementProps { + persistenceAdapter: TTDPersistenceAdapter; +} + +export const useChatManagement = ({ + persistenceAdapter, +}: UseChatManagementProps) => { const setError = useSetAtom(errorAtom); const [chatHistory, setChatHistory] = useAtom(chatHistoryAtom); const [isMenuOpen, setIsMenuOpen] = useState(false); - const { restoreChat, deleteChat, createNewChatId } = useTTDChatStorage(); + const { restoreChat, deleteChat, createNewChatId } = useTTDChatStorage({ + persistenceAdapter, + }); - const resetChatState = () => { - const newSessionId = createNewChatId(); + const applyChatToState = useCallback( + (chat: SavedChat) => { + const restoredMessages = chat.messages.map((msg) => ({ + ...msg, + timestamp: + msg.timestamp instanceof Date + ? msg.timestamp + : new Date(msg.timestamp), + })); + + const history = { + id: chat.id, + messages: restoredMessages, + currentPrompt: "", + }; + + const lastAssistantMsg = getLastAssistantMessage(history); + + setError( + lastAssistantMsg?.error ? new Error(lastAssistantMsg?.error) : null, + ); + setChatHistory(history); + }, + [setError, setChatHistory], + ); + + const resetChatState = useCallback(async () => { + const newSessionId = await createNewChatId(); setChatHistory({ id: newSessionId, messages: [], currentPrompt: "", }); setError(null); - }; + }, [createNewChatId, setChatHistory, setError]); - const applyChatToState = (chat: SavedChat) => { - const restoredMessages = chat.messages.map((msg) => ({ - ...msg, - timestamp: - msg.timestamp instanceof Date ? msg.timestamp : new Date(msg.timestamp), - })); + const onRestoreChat = useCallback( + (chat: SavedChat) => { + const restoredChat = restoreChat(chat); + applyChatToState(restoredChat); - const history = { - id: chat.id, - messages: restoredMessages, - currentPrompt: "", - }; + setIsMenuOpen(false); + }, + [restoreChat, applyChatToState], + ); - const lastAssistantMsg = getLastAssistantMessage(history); + const handleDeleteChat = useCallback( + async (chatId: string, event: React.MouseEvent) => { + event.stopPropagation(); - setError( - lastAssistantMsg?.error ? new Error(lastAssistantMsg?.error) : null, - ); - setChatHistory(history); - }; + const isDeletingActiveChat = chatId === chatHistory.id; + const updatedChats = await deleteChat(chatId); - const onRestoreChat = (chat: SavedChat) => { - const restoredChat = restoreChat(chat); - applyChatToState(restoredChat); - - setIsMenuOpen(false); - }; - - const handleDeleteChat = (chatId: string, event: React.MouseEvent) => { - event.stopPropagation(); - - const isDeletingActiveChat = chatId === chatHistory.id; - const updatedChats = deleteChat(chatId); - - if (isDeletingActiveChat) { - if (updatedChats.length > 0) { - const nextChat = updatedChats[0]; - applyChatToState(nextChat); - } else { - resetChatState(); + if (isDeletingActiveChat) { + if (updatedChats.length > 0) { + const nextChat = updatedChats[0]; + applyChatToState(nextChat); + } else { + await resetChatState(); + } } - } - }; + }, + [chatHistory.id, deleteChat, applyChatToState, resetChatState], + ); - const handleNewChat = () => { - resetChatState(); + const handleNewChat = useCallback(async () => { + await resetChatState(); setIsMenuOpen(false); - }; + }, [resetChatState]); - const handleMenuToggle = () => { + const handleMenuToggle = useCallback(() => { setIsMenuOpen((prev) => !prev); - }; + }, []); - const handleMenuClose = () => { + const handleMenuClose = useCallback(() => { setIsMenuOpen(false); - }; + }, []); return { isMenuOpen, diff --git a/packages/excalidraw/components/TTDDialog/types.ts b/packages/excalidraw/components/TTDDialog/types.ts index c5a9f82027..d3c8e3061d 100644 --- a/packages/excalidraw/components/TTDDialog/types.ts +++ b/packages/excalidraw/components/TTDDialog/types.ts @@ -53,6 +53,24 @@ export interface SavedChat { timestamp: number; } +export type SavedChats = SavedChat[]; + +/** + * Interface for TTD chat persistence. Preferably should be stable + * (e.g. static class/singleton) + */ +export interface TTDPersistenceAdapter { + /** + * Load saved chats from storage. + */ + loadChats(): Promise; + + /** + * Save chats to storage. + */ + saveChats(chats: SavedChats): Promise; +} + export interface MermaidToExcalidrawLibProps { loaded: boolean; api: Promise<{ diff --git a/packages/excalidraw/components/TTDDialog/useTTDChatStorage.ts b/packages/excalidraw/components/TTDDialog/useTTDChatStorage.ts index d2f8990c59..ce55b120a2 100644 --- a/packages/excalidraw/components/TTDDialog/useTTDChatStorage.ts +++ b/packages/excalidraw/components/TTDDialog/useTTDChatStorage.ts @@ -1,44 +1,24 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { randomId } from "@excalidraw/common"; import { atom, useAtom } from "../../editor-jotai"; import { chatHistoryAtom } from "./TTDContext"; -import type { SavedChat } from "./types"; +import type { SavedChat, SavedChats, TTDPersistenceAdapter } from "./types"; -const TTD_CHATS_STORAGE_KEY = "excalidraw-ttd-chats"; +interface UseTTDChatStorageProps { + persistenceAdapter: TTDPersistenceAdapter; +} interface UseTTDChatStorageReturn { savedChats: SavedChats; - saveCurrentChat: () => void; - deleteChat: (chatId: string) => SavedChats; + saveCurrentChat: () => Promise; + deleteChat: (chatId: string) => Promise; restoreChat: (chat: SavedChat) => SavedChat; - createNewChatId: () => string; + createNewChatId: () => Promise; } -type SavedChats = SavedChat[]; - -const saveChatsToStorage = (chats: SavedChats) => { - try { - window.localStorage.setItem(TTD_CHATS_STORAGE_KEY, JSON.stringify(chats)); - } catch (error: any) { - console.warn(`Failed to save chats to localStorage: ${error.message}`); - } -}; - -const loadChatsFromStorage = (): SavedChats => { - try { - const data = window.localStorage.getItem(TTD_CHATS_STORAGE_KEY); - if (data) { - return JSON.parse(data) as SavedChats; - } - } catch (error: any) { - console.warn(`Failed to load chats from localStorage: ${error.message}`); - } - return []; -}; - const generateChatTitle = (firstMessage: string): string => { const trimmed = firstMessage.trim(); if (trimmed.length <= 50) { @@ -47,17 +27,60 @@ const generateChatTitle = (firstMessage: string): string => { return `${trimmed.substring(0, 47)}...`; }; -// Shared atom for saved chats - initialized once from localStorage -export const savedChatsAtom = atom(loadChatsFromStorage()); +// Shared atom for saved chats - starts empty, populated via onLoadChats +export const savedChatsAtom = atom([]); +export const isLoadingChatsAtom = atom(false); +export const chatsLoadedAtom = atom(false); -export const useTTDChatStorage = (): UseTTDChatStorageReturn => { +export const useTTDChatStorage = ({ + persistenceAdapter, +}: UseTTDChatStorageProps): UseTTDChatStorageReturn => { const [chatHistory] = useAtom(chatHistoryAtom); const [savedChats, setSavedChats] = useAtom(savedChatsAtom); + const [isLoading, setIsLoading] = useAtom(isLoadingChatsAtom); + const [chatsLoaded, setChatsLoaded] = useAtom(chatsLoadedAtom); + + // Ref to track latest savedChats for async operations + const savedChatsRef = useRef(savedChats); + savedChatsRef.current = savedChats; const lastMessageInHistory = chatHistory?.messages[chatHistory?.messages.length - 1]; - const saveCurrentChat = () => { + // Load chats on-demand + const loadChats = useCallback(async () => { + if (chatsLoaded || isLoading) { + return; + } + + setIsLoading(true); + try { + const chats = await persistenceAdapter.loadChats(); + setSavedChats(chats); + setChatsLoaded(true); + } catch (error) { + console.warn("Failed to load chats:", error); + setSavedChats([]); + setChatsLoaded(true); + } finally { + setIsLoading(false); + } + }, [ + chatsLoaded, + isLoading, + setSavedChats, + setIsLoading, + setChatsLoaded, + persistenceAdapter, + ]); + + // INITIAL LOAD + useEffect(() => { + loadChats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const saveCurrentChat = useCallback(async () => { if (chatHistory.messages.length === 0) { return; } @@ -71,7 +94,7 @@ export const useTTDChatStorage = (): UseTTDChatStorageReturn => { const title = generateChatTitle(firstUserMessage.content); - const currentSavedChats = loadChatsFromStorage(); + const currentSavedChats = savedChatsRef.current; const existingChat = currentSavedChats.find( (chat) => chat.id === chatHistory.id, ); @@ -111,9 +134,15 @@ export const useTTDChatStorage = (): UseTTDChatStorageReturn => { .slice(0, 10); setSavedChats(updatedChats); - saveChatsToStorage(updatedChats); - }; + try { + await persistenceAdapter.saveChats(updatedChats); + } catch (error) { + console.warn("Failed to save chats:", error); + } + }, [chatHistory, setSavedChats, persistenceAdapter]); + + // Auto-save when generation completes useEffect(() => { if (!lastMessageInHistory?.isGenerating) { saveCurrentChat(); @@ -125,23 +154,33 @@ export const useTTDChatStorage = (): UseTTDChatStorageReturn => { lastMessageInHistory?.isGenerating, ]); - const deleteChat = (chatId: string): SavedChats => { - const updatedChats = savedChats.filter((chat) => chat.id !== chatId); - setSavedChats(updatedChats); - saveChatsToStorage(updatedChats); + const deleteChat = useCallback( + async (chatId: string): Promise => { + const updatedChats = savedChatsRef.current.filter( + (chat) => chat.id !== chatId, + ); + setSavedChats(updatedChats); - return updatedChats; - }; + try { + await persistenceAdapter.saveChats(updatedChats); + } catch (error) { + console.warn("Failed to save after delete:", error); + } - const restoreChat = (chat: SavedChat): SavedChat => { - saveCurrentChat(); + return updatedChats; + }, + [setSavedChats, persistenceAdapter], + ); + + const restoreChat = useCallback((chat: SavedChat): SavedChat => { + // Save is handled by the caller after state update return chat; - }; + }, []); - const createNewChatId = (): string => { - saveCurrentChat(); + const createNewChatId = useCallback(async (): Promise => { + await saveCurrentChat(); return randomId(); - }; + }, [saveCurrentChat]); return { savedChats, diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 19e855f97e..44b83a5dca 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -290,6 +290,11 @@ export { DefaultSidebar } from "./components/DefaultSidebar"; export { TTDDialog } from "./components/TTDDialog/TTDDialog"; export { TTDDialogTrigger } from "./components/TTDDialog/TTDDialogTrigger"; export { TTDStreamFetch } from "./components/TTDDialog/utils/TTDStreamFetch"; +export type { + TTDPersistenceAdapter, + SavedChat, + SavedChats, +} from "./components/TTDDialog/types"; export { zoomToFitBounds } from "./actions/actionCanvas"; export {