diff --git a/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts index 45402b6e..8c460c9a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/pad-editor.spec.ts @@ -138,4 +138,63 @@ test.describe('Pad Editor', () => { await expect(page.getByText('Hello World Pad 2')).toBeHidden(); await expect(page.getByText('Hello World Pad 1')).toBeVisible(); }); + + test('it saves the doc when we change pages', async ({ + page, + browserName, + }) => { + const [pad] = await createPad(page, 'pad-save-page', browserName, 1); + + const panel = page.getByLabel('Pads panel').first(); + + // Check the first pad + await panel.getByText(pad).click(); + await expect(page.locator('h2').getByText(pad)).toBeVisible(); + await page.locator('.ProseMirror.bn-editor').click(); + await page + .locator('.ProseMirror.bn-editor') + .fill('Hello World Pad persisted 1'); + await expect(page.getByText('Hello World Pad persisted 1')).toBeVisible(); + + await panel + .getByRole('button', { + name: 'Add a pad', + }) + .click(); + + const card = page.getByLabel('Create new pad card').first(); + await expect( + card.getByRole('heading', { + name: 'Name the pad', + level: 3, + }), + ).toBeVisible(); + + await page.goto('/'); + + await panel.getByText(pad).click(); + + await expect(page.getByText('Hello World Pad persisted 1')).toBeVisible(); + }); + + test('it saves the doc when we quit pages', async ({ page, browserName }) => { + const [pad] = await createPad(page, 'pad-save-quit', browserName, 1); + + const panel = page.getByLabel('Pads panel').first(); + + // Check the first pad + await panel.getByText(pad).click(); + await expect(page.locator('h2').getByText(pad)).toBeVisible(); + await page.locator('.ProseMirror.bn-editor').click(); + await page + .locator('.ProseMirror.bn-editor') + .fill('Hello World Pad persisted 2'); + await expect(page.getByText('Hello World Pad persisted 2')).toBeVisible(); + + await page.goto('/'); + + await panel.getByText(pad).click(); + + await expect(page.getByText('Hello World Pad persisted 2')).toBeVisible(); + }); }); diff --git a/src/frontend/apps/impress/src/features/pads/pad/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/pads/pad/components/BlockNoteEditor.tsx index 44f49a84..9b29d88c 100644 --- a/src/frontend/apps/impress/src/features/pads/pad/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/pads/pad/components/BlockNoteEditor.tsx @@ -7,6 +7,7 @@ import { WebrtcProvider } from 'y-webrtc'; import { Box } from '@/components'; import { useAuthStore } from '@/core/auth'; +import useSavePad from '../hook/useSavePad'; import { usePadStore } from '../stores'; import { Pad } from '../types'; import { randomColor } from '../utils'; @@ -37,6 +38,7 @@ interface BlockNoteContentProps { export const BlockNoteContent = ({ pad, provider }: BlockNoteContentProps) => { const { userData } = useAuthStore(); const { setEditor, padsStore } = usePadStore(); + useSavePad(pad.id, provider.doc); const storedEditor = padsStore?.[pad.id]?.editor; const editor = useMemo(() => { diff --git a/src/frontend/apps/impress/src/features/pads/pad/hook/useSavePad.tsx b/src/frontend/apps/impress/src/features/pads/pad/hook/useSavePad.tsx new file mode 100644 index 00000000..c1a5535f --- /dev/null +++ b/src/frontend/apps/impress/src/features/pads/pad/hook/useSavePad.tsx @@ -0,0 +1,83 @@ +import { useRouter } from 'next/router'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import * as Y from 'yjs'; + +import { useUpdatePad } from '../api/useUpdatePad'; +import { toBase64 } from '../utils'; + +const useSavePad = (padId: string, doc: Y.Doc) => { + const { mutate: updatePad } = useUpdatePad(); + const [initialDoc, setInitialDoc] = useState( + toBase64(Y.encodeStateAsUpdate(doc)), + ); + + /** + * Update initial doc when doc is updated by other users, + * so only the user typing will trigger the save. + * This is to avoid saving the same doc multiple time. + */ + useEffect(() => { + const onUpdate = ( + _uintArray: Uint8Array, + _pluginKey: string, + updatedDoc: Y.Doc, + transaction: Y.Transaction, + ) => { + if (!transaction.local) { + setInitialDoc(toBase64(Y.encodeStateAsUpdate(updatedDoc))); + } + }; + + doc.on('update', onUpdate); + + return () => { + doc.off('update', onUpdate); + }; + }, [doc]); + + const savePad = useCallback(() => { + const newDoc = toBase64(Y.encodeStateAsUpdate(doc)); + + /** + * Save only if the doc has changed. + */ + if (initialDoc === newDoc) { + return; + } + + setInitialDoc(newDoc); + + updatePad({ + id: padId, + content: newDoc, + }); + }, [initialDoc, padId, doc, updatePad]); + + const timeout = useRef(); + const router = useRouter(); + + useEffect(() => { + if (timeout.current) { + clearTimeout(timeout.current); + } + + const onSave = () => { + savePad(); + }; + + // Save every minute + timeout.current = setInterval(onSave, 60000); + // Save when the user leaves the page + addEventListener('beforeunload', onSave); + // Save when the user navigates to another page + router.events.on('routeChangeStart', onSave); + + return () => { + clearTimeout(timeout.current); + removeEventListener('beforeunload', onSave); + router.events.off('routeChangeStart', onSave); + }; + }, [router.events, savePad]); +}; + +export default useSavePad; diff --git a/src/frontend/apps/impress/src/features/pads/pad/utils.ts b/src/frontend/apps/impress/src/features/pads/pad/utils.ts index 325a2304..aa0d43ba 100644 --- a/src/frontend/apps/impress/src/features/pads/pad/utils.ts +++ b/src/frontend/apps/impress/src/features/pads/pad/utils.ts @@ -22,3 +22,7 @@ function hslToHex(h: number, s: number, l: number) { }; return `#${f(0)}${f(8)}${f(4)}`; } + +export const toBase64 = ( + str: WithImplicitCoercion, +) => Buffer.from(str).toString('base64');