diff --git a/resources/js/visual-editor/editor/components/EditorBoot.tsx b/resources/js/visual-editor/editor/components/EditorBoot.tsx new file mode 100644 index 000000000..5c386348b --- /dev/null +++ b/resources/js/visual-editor/editor/components/EditorBoot.tsx @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { EditorShell } from './EditorShell'; +import { + fetchPost, + useAutosave, + type AutosaveState, + type PostRestClientOptions, +} from '../rest'; +import { createEditorStore, type Block, type EditorStore } from '../store'; + +export interface EditorBootProps { + postId: string; + postType: string; + apiBase: string; +} + +type LoadState = + | { status: 'loading' } + | { status: 'error'; error: Error } + | { status: 'ready'; store: EditorStore }; + +export function EditorBoot({ postId, apiBase }: EditorBootProps) { + const clientOptions = useMemo( + () => ({ apiBase }), + [apiBase] + ); + + const [loadState, setLoadState] = useState({ status: 'loading' }); + const [loadAttempt, setLoadAttempt] = useState(0); + const abortRef = useRef(null); + + useEffect(() => { + const controller = new AbortController(); + abortRef.current = controller; + + setLoadState({ status: 'loading' }); + + fetchPost(postId, clientOptions, { signal: controller.signal }) + .then((payload) => { + if (controller.signal.aborted) { + return; + } + + const store = createEditorStore(payload.blocks as Block[]); + setLoadState({ status: 'ready', store }); + }) + .catch((error: unknown) => { + if (controller.signal.aborted) { + return; + } + + const wrapped = + error instanceof Error ? error : new Error('Failed to load post.'); + setLoadState({ status: 'error', error: wrapped }); + }); + + return () => { + controller.abort(); + abortRef.current = null; + }; + }, [postId, clientOptions, loadAttempt]); + + const retry = useCallback(() => { + setLoadAttempt((attempt) => attempt + 1); + }, []); + + if (loadState.status === 'loading') { + return ( +
+ Loading editor… +
+ ); + } + + if (loadState.status === 'error') { + return ( +
+

+ Failed to load the post: {loadState.error.message} +

+ +
+ ); + } + + return ( + + ); +} + +interface EditorBootReadyProps { + store: EditorStore; + postId: string; + clientOptions: PostRestClientOptions; +} + +function EditorBootReady({ store, postId, clientOptions }: EditorBootReadyProps) { + const autosave = useAutosave({ store, postId, clientOptions }); + + return ( + <> + + + + ); +} + +function AutosaveAnnouncer({ state }: { state: AutosaveState }) { + if (state.status !== 'error' || !state.lastError) { + return null; + } + + return ( +
+ Failed to save: {state.lastError.message} +
+ ); +} diff --git a/resources/js/visual-editor/editor/components/EditorShell.tsx b/resources/js/visual-editor/editor/components/EditorShell.tsx index f691da76a..425eaf8ac 100644 --- a/resources/js/visual-editor/editor/components/EditorShell.tsx +++ b/resources/js/visual-editor/editor/components/EditorShell.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { useStore } from 'zustand'; import type { EditorStore } from '../store'; +import type { AutosaveState } from '../rest'; import { EditorStoreProvider, useEditorStore } from '../primitives'; import { Canvas } from './Canvas'; import { BlockList } from './BlockList'; @@ -9,20 +10,21 @@ import { InserterPanel } from './InserterPanel'; export interface EditorShellProps { store: EditorStore; + saveStatus?: AutosaveState; } -export function EditorShell({ store }: EditorShellProps) { +export function EditorShell({ store, saveStatus }: EditorShellProps) { return ( - + ); } -function EditorShellChrome() { +function EditorShellChrome({ saveStatus }: { saveStatus?: AutosaveState }) { return (
- +
@@ -35,11 +37,19 @@ function EditorShellChrome() { ); } -function Statusbar() { +function Statusbar({ saveStatus }: { saveStatus?: AutosaveState }) { const store = useEditorStore(); const isDirty = useStore(store, (state) => state.isDirty); const selectedClientId = useStore(store, (state) => state.selection.clientId); + const statusLabel = resolveStatusLabel(saveStatus, isDirty); + const statusToken = + saveStatus && saveStatus.status !== 'idle' + ? saveStatus.status + : isDirty + ? 'dirty' + : 'saved'; + return (
- {isDirty ? 'Unsaved changes' : 'Saved'} + {statusLabel} {String(attributes.content ?? '')}

; +} + +const originalFetch = globalThis.fetch; + +beforeEach(() => { + clearRegistry(); + registerBlock({ name: 'core/paragraph', edit: Paragraph }); +}); + +afterEach(() => { + clearRegistry(); + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('EditorBoot', () => { + it('fetches the post on mount and renders the editor', async () => { + const payload = { + id: 1, + title: 'Test', + blocks: [ + { + clientId: 'loaded-1', + name: 'core/paragraph', + attributes: { content: 'Loaded from REST' }, + innerBlocks: [], + }, + ], + updated_at: '2026-04-14T12:00:00+00:00', + }; + + globalThis.fetch = vi + .fn() + .mockResolvedValue(jsonResponse(payload)) as unknown as typeof fetch; + + render( + + ); + + expect(screen.getByText('Loading editor…')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Loaded from REST')).toBeInTheDocument(); + }); + + expect(document.querySelector('[data-ve-editor-shell]')).not.toBeNull(); + expect(globalThis.fetch).toHaveBeenCalledWith( + '/visual-editor/api/posts/1', + expect.objectContaining({ method: 'GET' }) + ); + }); + + it('renders the error state with a retry button on load failure', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'Nope' }), { status: 500 }) + ) + .mockResolvedValueOnce( + jsonResponse({ + id: 1, + title: 'Test', + blocks: [], + updated_at: '2026-04-14T12:00:00+00:00', + }) + ); + + globalThis.fetch = fetchMock as unknown as typeof fetch; + + render( + + ); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + expect(screen.getByText(/Failed to load the post/)).toBeInTheDocument(); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /retry/i })); + + await waitFor(() => { + expect(document.querySelector('[data-ve-editor-shell]')).not.toBeNull(); + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/resources/js/visual-editor/editor/components/editor.css b/resources/js/visual-editor/editor/components/editor.css index 7972849db..02abb7936 100644 --- a/resources/js/visual-editor/editor/components/editor.css +++ b/resources/js/visual-editor/editor/components/editor.css @@ -351,3 +351,91 @@ color: #888; font-style: italic; } + +.ve-block-heading h1, +.ve-block-heading h2, +.ve-block-heading h3, +.ve-block-heading h4, +.ve-block-heading h5, +.ve-block-heading h6 { + margin: 0; + font-weight: 700; + line-height: 1.2; + color: #1d1d1f; +} + +.ve-block-heading h1 { + font-size: 2.25rem; +} + +.ve-block-heading h2 { + font-size: 1.875rem; +} + +.ve-block-heading h3 { + font-size: 1.5rem; +} + +.ve-block-heading h4 { + font-size: 1.25rem; +} + +.ve-block-heading h5 { + font-size: 1.125rem; +} + +.ve-block-heading h6 { + font-size: 1rem; +} + +.ve-editor-boot { + display: flex; + flex-direction: column; + gap: 0.75rem; + align-items: center; + justify-content: center; + min-height: 100vh; + padding: 2rem; + background: #f5f5f7; + color: #1d1d1f; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 0.875rem; + text-align: center; +} + +.ve-editor-boot--error { + color: #bf1d1d; +} + +.ve-editor-boot__message { + margin: 0; + max-width: 32rem; +} + +.ve-editor-boot__retry { + appearance: none; + padding: 0.5rem 1rem; + border: 1px solid #1d1d1f; + border-radius: 0.375rem; + background: #1d1d1f; + color: #fff; + font: inherit; + cursor: pointer; +} + +.ve-editor-boot__retry:hover { + background: #333; +} + +.ve-editor-boot__toast { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background: #bf1d1d; + color: #fff; + font-size: 0.8125rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); + z-index: 100; +} diff --git a/resources/js/visual-editor/editor/components/index.ts b/resources/js/visual-editor/editor/components/index.ts index d4c14a841..242d8f6cf 100644 --- a/resources/js/visual-editor/editor/components/index.ts +++ b/resources/js/visual-editor/editor/components/index.ts @@ -1,4 +1,5 @@ export { EditorShell, type EditorShellProps } from './EditorShell'; +export { EditorBoot, type EditorBootProps } from './EditorBoot'; export { Canvas, type CanvasProps } from './Canvas'; export { BlockList, type BlockListProps } from './BlockList'; export { BlockWrapper, type BlockWrapperProps } from './BlockWrapper'; diff --git a/resources/js/visual-editor/editor/main.tsx b/resources/js/visual-editor/editor/main.tsx index 1aeb1f1f6..36befa49e 100644 --- a/resources/js/visual-editor/editor/main.tsx +++ b/resources/js/visual-editor/editor/main.tsx @@ -1,11 +1,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { EditorShell } from './components'; +import { EditorBoot } from './components'; import './components/editor.css'; import { registerBlock, type BlockEditProps } from './registry'; -import { registerCoreBlocks, PARAGRAPH_BLOCK_NAME, HEADING_BLOCK_NAME } from './blocks'; +import { registerCoreBlocks } from './blocks'; import { loadInserterBlocks } from './inserter'; -import { createEditorStore, createClientId, type Block, type EditorStore } from './store'; const MOUNT_ID = 've-root'; @@ -38,25 +37,6 @@ function registerPlaceholderBlock(): void { registerBlock({ name: 've/placeholder', edit: PlaceholderEdit }); } -function createInitialStore(): EditorStore { - const initialBlocks: Block[] = [ - { - clientId: createClientId(), - name: HEADING_BLOCK_NAME, - attributes: { level: 2, content: '

Heading

' }, - innerBlocks: [], - }, - { - clientId: createClientId(), - name: PARAGRAPH_BLOCK_NAME, - attributes: { content: '

Start writing…

' }, - innerBlocks: [], - }, - ]; - - return createEditorStore(initialBlocks); -} - function bootEditor(): void { const container = document.getElementById(MOUNT_ID); @@ -81,14 +61,15 @@ function bootEditor(): void { void loadInserterBlocks({ apiBase: config.apiBase }); - const store = createInitialStore(); - createRoot(container).render( - + ); - } bootEditor(); diff --git a/resources/js/visual-editor/editor/rest/__tests__/postRestClient.test.ts b/resources/js/visual-editor/editor/rest/__tests__/postRestClient.test.ts new file mode 100644 index 000000000..59580fff1 --- /dev/null +++ b/resources/js/visual-editor/editor/rest/__tests__/postRestClient.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fetchPost, PostRestError, savePost } from '../postRestClient'; +import type { Block } from '../../store'; + +const samplePayload = { + id: 1, + title: 'Test Post', + blocks: [ + { + clientId: 'abc-123', + name: 'core/paragraph', + attributes: { content: 'Hello' }, + innerBlocks: [], + }, + ], + updated_at: '2026-04-14T12:34:56+00:00', +}; + +function mockJsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('fetchPost', () => { + it('returns the parsed payload on success', async () => { + const fetchImpl = vi.fn().mockResolvedValue(mockJsonResponse(samplePayload)); + + const result = await fetchPost('1', { + apiBase: '/visual-editor/api', + fetchImpl, + }); + + expect(result).toEqual(samplePayload); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('/visual-editor/api/posts/1'); + expect(init.method).toBe('GET'); + expect((init.headers as Headers).get('Accept')).toBe('application/json'); + expect((init.headers as Headers).get('X-Requested-With')).toBe('XMLHttpRequest'); + }); + + it('throws PostRestError on non-2xx status', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValue(mockJsonResponse({ message: 'Nope' }, 403)); + + await expect( + fetchPost('1', { apiBase: '/visual-editor/api', fetchImpl }) + ).rejects.toMatchObject({ + name: 'PostRestError', + status: 403, + }); + }); + + it('throws PostRestError when the payload shape is wrong', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + mockJsonResponse({ id: 'not-a-number', title: 'x', blocks: [], updated_at: 'x' }) + ); + + await expect( + fetchPost('1', { apiBase: '/visual-editor/api', fetchImpl }) + ).rejects.toBeInstanceOf(PostRestError); + }); + + it('normalizes trailing slashes in apiBase', async () => { + const fetchImpl = vi.fn().mockResolvedValue(mockJsonResponse(samplePayload)); + + await fetchPost('42', { + apiBase: '/visual-editor/api/', + fetchImpl, + }); + + expect(fetchImpl.mock.calls[0][0]).toBe('/visual-editor/api/posts/42'); + }); + + it('attaches a CSRF token when provided', async () => { + const fetchImpl = vi.fn().mockResolvedValue(mockJsonResponse(samplePayload)); + + await fetchPost('1', { + apiBase: '/visual-editor/api', + fetchImpl, + csrfToken: 'token-xyz', + }); + + const headers = fetchImpl.mock.calls[0][1].headers as Headers; + expect(headers.get('X-CSRF-TOKEN')).toBe('token-xyz'); + }); +}); + +describe('savePost', () => { + const blocks: Block[] = [ + { + clientId: 'abc', + name: 'core/paragraph', + attributes: { content: 'Updated' }, + innerBlocks: [], + }, + ]; + + it('PUTs the block tree and returns the persisted payload', async () => { + const fetchImpl = vi.fn().mockResolvedValue(mockJsonResponse(samplePayload)); + + const result = await savePost('1', blocks, { + apiBase: '/visual-editor/api', + fetchImpl, + }); + + expect(result).toEqual(samplePayload); + + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('/visual-editor/api/posts/1'); + expect(init.method).toBe('PUT'); + expect((init.headers as Headers).get('Content-Type')).toBe('application/json'); + expect(init.body).toBe(JSON.stringify({ blocks })); + }); + + it('throws PostRestError on validation failure', async () => { + const fetchImpl = vi.fn().mockResolvedValue( + mockJsonResponse({ message: 'The blocks.0.name field is required.' }, 422) + ); + + await expect( + savePost('1', blocks, { apiBase: '/visual-editor/api', fetchImpl }) + ).rejects.toMatchObject({ + name: 'PostRestError', + status: 422, + }); + }); +}); diff --git a/resources/js/visual-editor/editor/rest/__tests__/useAutosave.test.tsx b/resources/js/visual-editor/editor/rest/__tests__/useAutosave.test.tsx new file mode 100644 index 000000000..a76f37d4c --- /dev/null +++ b/resources/js/visual-editor/editor/rest/__tests__/useAutosave.test.tsx @@ -0,0 +1,257 @@ +import { act, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +async function flushMicrotasks(): Promise { + // `vi.advanceTimersByTimeAsync` resolves timers but pending fetch + // promises (and the state updates that follow them) still need a tick + // to settle. Looping over a handful of microtasks is enough to let + // React flush the resulting state updates. + for (let i = 0; i < 20; i += 1) { + await Promise.resolve(); + } +} +import { useAutosave, type AutosaveState } from '../useAutosave'; +import { createEditorStore, type Block, type EditorStore } from '../../store'; + +function makeBlock(clientId: string, content: string): Block { + return { + clientId, + name: 'core/paragraph', + attributes: { content }, + innerBlocks: [], + }; +} + +interface HarnessProps { + store: EditorStore; + fetchImpl: typeof fetch; + onState?: (state: ReturnType) => void; + debounceMs?: number; + retryBaseMs?: number; + maxRetries?: number; +} + +function AutosaveHarness({ + store, + fetchImpl, + onState, + debounceMs = 1000, + retryBaseMs = 100, + maxRetries = 3, +}: HarnessProps) { + const state = useAutosave({ + store, + postId: '1', + clientOptions: { apiBase: '/visual-editor/api', fetchImpl }, + debounceMs, + retryBaseMs, + maxRetries, + }); + + if (onState) { + onState(state); + } + + return
{state.status}
; +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function successResponse(): Response { + return new Response( + JSON.stringify({ + id: 1, + title: 'Test', + blocks: [], + updated_at: '2026-04-14T12:00:00+00:00', + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ); +} + +describe('useAutosave', () => { + it('debounces saves and clears the dirty flag on success', async () => { + const store = createEditorStore([makeBlock('a', 'alpha')]); + const fetchImpl = vi.fn().mockResolvedValue(successResponse()); + + render(); + + act(() => { + store.getState().insertBlock(makeBlock('b', 'beta')); + }); + + expect(store.getState().isDirty).toBe(true); + expect(fetchImpl).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + const [url, init] = fetchImpl.mock.calls[0]; + expect(url).toBe('/visual-editor/api/posts/1'); + expect(init.method).toBe('PUT'); + + const body = JSON.parse(init.body as string); + expect(body.blocks).toHaveLength(2); + + await act(async () => { + await flushMicrotasks(); + }); + + expect(store.getState().isDirty).toBe(false); + }); + + it('does not save when nothing is dirty', async () => { + const store = createEditorStore([makeBlock('a', 'alpha')]); + const fetchImpl = vi.fn().mockResolvedValue(successResponse()); + + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + }); + + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('reports error status and keeps dirty flag when save fails', async () => { + const store = createEditorStore([makeBlock('a', 'alpha')]); + const fetchImpl = vi + .fn() + .mockResolvedValue( + new Response(JSON.stringify({ message: 'boom' }), { status: 500 }) + ); + + const stateRef: { current: AutosaveState | null } = { current: null }; + + render( + { + stateRef.current = state; + }} + /> + ); + + act(() => { + store.getState().insertBlock(makeBlock('b', 'beta')); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + await act(async () => { + await flushMicrotasks(); + }); + + expect(stateRef.current?.status).toBe('error'); + expect(store.getState().isDirty).toBe(true); + expect(stateRef.current?.retryCount).toBe(1); + }); + + it('saves every time the store becomes dirty, not just the first edit', async () => { + const store = createEditorStore([makeBlock('a', 'alpha')]); + const fetchImpl = vi.fn().mockImplementation(() => Promise.resolve(successResponse())); + + render( + + ); + + // First edit + save cycle. + act(() => { + store.getState().insertBlock(makeBlock('b', 'beta')); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + await flushMicrotasks(); + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(store.getState().isDirty).toBe(false); + + // Second edit should trigger a brand new debounce + save cycle. + act(() => { + store.getState().insertBlock(makeBlock('c', 'gamma')); + }); + + expect(store.getState().isDirty).toBe(true); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + await flushMicrotasks(); + }); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(store.getState().isDirty).toBe(false); + + // And a third edit, for good measure. + act(() => { + store.getState().insertBlock(makeBlock('d', 'delta')); + }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + await flushMicrotasks(); + }); + + expect(fetchImpl).toHaveBeenCalledTimes(3); + expect(store.getState().isDirty).toBe(false); + }); + + it('retries with exponential backoff up to maxRetries', async () => { + const store = createEditorStore([makeBlock('a', 'alpha')]); + + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response('', { status: 500 })) + .mockResolvedValueOnce(new Response('', { status: 500 })) + .mockResolvedValue(successResponse()); + + render( + + ); + + act(() => { + store.getState().insertBlock(makeBlock('b', 'beta')); + }); + + // First attempt after initial debounce. + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + await flushMicrotasks(); + }); + expect(fetchImpl).toHaveBeenCalledTimes(1); + + // Retry 1 scheduled at retryBaseMs * 2^0 = 50ms. + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + await flushMicrotasks(); + }); + expect(fetchImpl).toHaveBeenCalledTimes(2); + + // Retry 2 scheduled at retryBaseMs * 2^1 = 100ms. + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + await flushMicrotasks(); + }); + expect(fetchImpl).toHaveBeenCalledTimes(3); + + expect(store.getState().isDirty).toBe(false); + }); +}); diff --git a/resources/js/visual-editor/editor/rest/index.ts b/resources/js/visual-editor/editor/rest/index.ts new file mode 100644 index 000000000..1920f5acc --- /dev/null +++ b/resources/js/visual-editor/editor/rest/index.ts @@ -0,0 +1,15 @@ +export { + fetchPost, + savePost, + PostRestError, + type PostPayload, + type PostRestClientOptions, + type FetchPostOptions, + type SavePostOptions, +} from './postRestClient'; +export { + useAutosave, + type AutosaveState, + type AutosaveStatus, + type UseAutosaveOptions, +} from './useAutosave'; diff --git a/resources/js/visual-editor/editor/rest/postRestClient.ts b/resources/js/visual-editor/editor/rest/postRestClient.ts new file mode 100644 index 000000000..6ec48b3f1 --- /dev/null +++ b/resources/js/visual-editor/editor/rest/postRestClient.ts @@ -0,0 +1,174 @@ +import type { Block } from '../store'; + +export interface PostPayload { + id: number; + title: string; + blocks: Block[]; + updated_at: string; +} + +export interface PostRestClientOptions { + apiBase: string; + fetchImpl?: typeof fetch; + csrfToken?: string | null; +} + +export interface FetchPostOptions { + signal?: AbortSignal; +} + +export interface SavePostOptions { + signal?: AbortSignal; +} + +export class PostRestError extends Error { + public readonly status: number; + public readonly body: unknown; + + constructor(message: string, status: number, body: unknown = null) { + super(message); + this.name = 'PostRestError'; + this.status = status; + this.body = body; + } +} + +function joinUrl(base: string, path: string): string { + const trimmedBase = base.replace(/\/+$/, ''); + const trimmedPath = path.replace(/^\/+/, ''); + return `${trimmedBase}/${trimmedPath}`; +} + +function resolveCsrfToken(override: string | null | undefined): string | null { + if (override !== undefined) { + return override; + } + + if (typeof document === 'undefined') { + return null; + } + + const meta = document.querySelector('meta[name="csrf-token"]'); + + if (!meta) { + return null; + } + + const token = meta.getAttribute('content'); + + return token && token.length > 0 ? token : null; +} + +function buildHeaders(csrfToken: string | null, includeContentType: boolean): Headers { + const headers = new Headers({ + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }); + + if (includeContentType) { + headers.set('Content-Type', 'application/json'); + } + + if (csrfToken) { + headers.set('X-CSRF-TOKEN', csrfToken); + } + + return headers; +} + +function resolveFetch(override: typeof fetch | undefined): typeof fetch { + if (override) { + return override; + } + + if (typeof fetch === 'function') { + return fetch.bind(globalThis); + } + + throw new PostRestError('No fetch implementation available.', 0); +} + +async function parseJsonSafe(response: Response): Promise { + try { + return await response.json(); + } catch { + return null; + } +} + +function isPostPayload(value: unknown): value is PostPayload { + if (!value || typeof value !== 'object') { + return false; + } + + const source = value as Record; + + return ( + typeof source.id === 'number' && + typeof source.title === 'string' && + Array.isArray(source.blocks) && + typeof source.updated_at === 'string' + ); +} + +async function handlePostResponse(response: Response): Promise { + const body = await parseJsonSafe(response); + + if (!response.ok) { + throw new PostRestError( + `Visual editor REST request failed with status ${response.status}.`, + response.status, + body + ); + } + + if (!isPostPayload(body)) { + throw new PostRestError( + 'Visual editor REST response did not match the expected post shape.', + response.status, + body + ); + } + + return body; +} + +export async function fetchPost( + postId: string, + clientOptions: PostRestClientOptions, + options: FetchPostOptions = {} +): Promise { + const fetcher = resolveFetch(clientOptions.fetchImpl); + const csrfToken = resolveCsrfToken(clientOptions.csrfToken); + const url = joinUrl(clientOptions.apiBase, `posts/${encodeURIComponent(postId)}`); + + const response = await fetcher(url, { + method: 'GET', + credentials: 'same-origin', + headers: buildHeaders(csrfToken, false), + signal: options.signal, + }); + + return handlePostResponse(response); +} + +export async function savePost( + postId: string, + blocks: Block[], + clientOptions: PostRestClientOptions, + options: SavePostOptions = {} +): Promise { + const fetcher = resolveFetch(clientOptions.fetchImpl); + const csrfToken = resolveCsrfToken(clientOptions.csrfToken); + const url = joinUrl(clientOptions.apiBase, `posts/${encodeURIComponent(postId)}`); + + const response = await fetcher(url, { + method: 'PUT', + credentials: 'same-origin', + headers: buildHeaders(csrfToken, true), + body: JSON.stringify({ blocks }), + signal: options.signal, + }); + + return handlePostResponse(response); +} diff --git a/resources/js/visual-editor/editor/rest/useAutosave.ts b/resources/js/visual-editor/editor/rest/useAutosave.ts new file mode 100644 index 000000000..20f32eb11 --- /dev/null +++ b/resources/js/visual-editor/editor/rest/useAutosave.ts @@ -0,0 +1,257 @@ +import { useEffect, useRef, useState } from 'react'; +import type { EditorStore } from '../store'; +import { PostRestError, savePost, type PostRestClientOptions } from './postRestClient'; + +export type AutosaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +export interface AutosaveState { + status: AutosaveStatus; + lastSavedAt: number | null; + lastError: Error | null; + retryCount: number; +} + +export interface UseAutosaveOptions { + store: EditorStore; + postId: string; + clientOptions: PostRestClientOptions; + debounceMs?: number; + maxRetries?: number; + retryBaseMs?: number; + enabled?: boolean; + now?: () => number; + setTimeoutImpl?: typeof setTimeout; + clearTimeoutImpl?: typeof clearTimeout; +} + +const DEFAULT_DEBOUNCE_MS = 1500; +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_RETRY_BASE_MS = 1000; + +const INITIAL_STATE: AutosaveState = { + status: 'idle', + lastSavedAt: null, + lastError: null, + retryCount: 0, +}; + +/** + * Subscribes directly to the editor store's `isDirty` flag and debounces + * `savePost` calls through it. React's rendering cycle is intentionally + * bypassed for scheduling — re-rendering the owning component must not + * cancel an in-flight debounce timer. + */ +export function useAutosave(options: UseAutosaveOptions): AutosaveState { + const { + store, + postId, + clientOptions, + debounceMs = DEFAULT_DEBOUNCE_MS, + maxRetries = DEFAULT_MAX_RETRIES, + retryBaseMs = DEFAULT_RETRY_BASE_MS, + enabled = true, + now = Date.now, + setTimeoutImpl, + clearTimeoutImpl, + } = options; + + const [state, setState] = useState(INITIAL_STATE); + + // Keep the most recent values in refs so the subscription effect can + // read them without needing to resubscribe on every render. + const configRef = useRef({ + store, + postId, + clientOptions, + debounceMs, + maxRetries, + retryBaseMs, + now, + setTimeoutImpl, + clearTimeoutImpl, + }); + + configRef.current = { + store, + postId, + clientOptions, + debounceMs, + maxRetries, + retryBaseMs, + now, + setTimeoutImpl, + clearTimeoutImpl, + }; + + useEffect(() => { + if (!enabled) { + return; + } + + let timerId: ReturnType | null = null; + let abortController: AbortController | null = null; + let retryCount = 0; + let disposed = false; + let inFlight = false; + + function schedule(delay: number): void { + const setter = configRef.current.setTimeoutImpl ?? setTimeout; + const clearer = configRef.current.clearTimeoutImpl ?? clearTimeout; + + if (timerId !== null) { + clearer(timerId); + } + + timerId = setter(() => { + timerId = null; + void runSave(); + }, delay); + } + + async function runSave(): Promise { + if (disposed) { + return; + } + + const { + store: currentStore, + postId: currentPostId, + clientOptions: currentClient, + retryBaseMs: currentRetryBase, + maxRetries: currentMaxRetries, + now: currentNow, + } = configRef.current; + + const snapshot = currentStore.getState().blocks; + + if (abortController) { + abortController.abort(); + } + + abortController = new AbortController(); + const controller = abortController; + + inFlight = true; + + setState((previous) => ({ + ...previous, + status: 'saving', + lastError: null, + })); + + try { + await savePost(currentPostId, snapshot, currentClient, { + signal: controller.signal, + }); + } catch (error) { + inFlight = false; + + if (controller.signal.aborted || disposed) { + return; + } + + const wrapped = normalizeError(error); + retryCount += 1; + + const exhausted = retryCount > currentMaxRetries; + + setState((previous) => ({ + ...previous, + status: 'error', + lastError: wrapped, + retryCount: exhausted ? 0 : retryCount, + })); + + if (exhausted) { + retryCount = 0; + return; + } + + schedule(currentRetryBase * Math.pow(2, retryCount - 1)); + return; + } + + inFlight = false; + + if (disposed) { + return; + } + + retryCount = 0; + + // Only clear the dirty flag when the snapshot we saved still + // matches the current blocks — otherwise the user kept typing + // mid-save and we need another pass. + const latestBlocks = currentStore.getState().blocks; + + if (latestBlocks === snapshot) { + currentStore.getState().markClean(); + } + + setState({ + status: 'saved', + lastSavedAt: currentNow(), + lastError: null, + retryCount: 0, + }); + + // If the store drifted during the save, schedule another pass. + if (latestBlocks !== snapshot && currentStore.getState().isDirty) { + schedule(configRef.current.debounceMs); + } + } + + const unsubscribe = store.subscribe((next, previous) => { + if (disposed) { + return; + } + + if (next.isDirty && !inFlight && !previous.isDirty) { + schedule(configRef.current.debounceMs); + return; + } + + if (next.isDirty && !inFlight && timerId === null) { + schedule(configRef.current.debounceMs); + } + }); + + // If we mount with a dirty store, schedule immediately. + if (store.getState().isDirty) { + schedule(debounceMs); + } + + return () => { + disposed = true; + const clearer = configRef.current.clearTimeoutImpl ?? clearTimeout; + + if (timerId !== null) { + clearer(timerId); + timerId = null; + } + + if (abortController) { + abortController.abort(); + abortController = null; + } + + unsubscribe(); + }; + // Intentionally subscribe only once per store / enabled change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [store, enabled]); + + return state; +} + +function normalizeError(error: unknown): Error { + if (error instanceof PostRestError) { + return error; + } + + if (error instanceof Error) { + return error; + } + + return new Error(typeof error === 'string' ? error : 'Unknown autosave error'); +}