"use client"; /** * Block mutations. * * - `useBatchSave` exposes the shared per-page BlockBatchQueue * (lib/batch-queue.ts): `queue(...ops)` appends FIFO ops, flushed as ONE * POST /blocks/batch at most 500 ms after the last one (debounced); * `flush()` forces an immediate ordered flush (navigation/unmount). * - Structural mutations (insert/move/delete/duplicate) invalidate the * page's block queries so the tree stays in sync. */ import { useCallback, useEffect, useMemo } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useShallow } from "zustand/react/shallow"; import { blockApi, type BatchBlockOp, type InsertBlockInput } from "@/lib/api"; import { getBlockBatchQueue } from "@/lib/batch-queue"; import { sortByPosition } from "@/lib/utils"; import type { Block, BlockPatch, BlockType } from "@/lib/types"; import { useBlockPatchStore } from "@/stores/block-patch-store"; import { useEditorStore } from "@/stores/editor-store"; export function useInvalidatePageBlocks(pageId?: string | null) { const qc = useQueryClient(); return useCallback( (id?: string) => { const target = id ?? pageId; if (!target) return; qc.invalidateQueries({ queryKey: ["pages", target, "blocks"] }); }, [qc, pageId] ); } /** * Debounced batch queue for one page (shared with the editor's save queue — * same instance, so both never double-flush). */ export function useBatchSave(pageId: string | null) { const qc = useQueryClient(); const queue = useCallback( (ops: BatchBlockOp | BatchBlockOp[]) => { if (!pageId) return; getBlockBatchQueue(pageId).queue(ops); }, [pageId] ); const flush = useCallback(async () => { if (!pageId) return; await getBlockBatchQueue(pageId).flush(); qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] }); }, [pageId, qc]); // Flush pending ops on unmount so no edit is lost. useEffect(() => { if (!pageId) return; const q = getBlockBatchQueue(pageId); return () => { void q.flush().then(() => qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] }) ); }; }, [pageId, qc]); return { queue, flush }; } export function useInsertBlock(pageId: string | null) { const invalidate = useInvalidatePageBlocks(pageId); return useMutation({ mutationFn: (input: { parentId: string | null; /** Anchor the new block AFTER (below) this block's id — server * `resolvePosition` computes the fractional position. Omit/null → * the server appends after the last sibling in `parentId`. */ afterBlockId?: string | null; /** Anchor the new block BEFORE (above) this block's id. */ beforeBlockId?: string | null; type: BlockType; content?: unknown; properties?: Record; }) => { // Position is computed SERVER-side (resolvePosition). The client used to // run `positionForInsert` (the shared base-36 engine), which THREW on // every server-assigned base-62 position — silently killing all UI block // creation (no request ever left the browser). Anchors are ids; falsy // anchors are OMITTED (the DTO requires `.min(1)` strings, never null). const body: InsertBlockInput = { pageId: pageId as string, parentId: input.parentId, type: input.type, ...(input.afterBlockId ? { afterBlockId: input.afterBlockId } : {}), ...(input.beforeBlockId ? { beforeBlockId: input.beforeBlockId } : {}), ...(input.content !== undefined ? { content: input.content } : {}), ...(input.properties !== undefined ? { properties: input.properties } : {}) }; return blockApi.insert(body); }, onSuccess: (block) => { invalidate(); if (useEditorStore.getState().activePageId === pageId) { useEditorStore.getState().setSelection(block.id); } } }); } export function useUpdateBlock(pageId: string | null) { const invalidate = useInvalidatePageBlocks(pageId); const { queue } = useBatchSave(pageId); /** * Save a block patch. `immediate: true` (default) persists one block now; * `false` joins the debounced batch queue. */ const updateBlock = useCallback( async (blockId: string, patch: BlockPatch, immediate = true) => { if (immediate) { const block = await blockApi.update(blockId, patch); invalidate(); return block; } queue({ op: "update", blockId, patch }); return undefined; }, [invalidate, queue] ); return { updateBlock }; } export function useMoveBlock(pageId: string | null) { const invalidate = useInvalidatePageBlocks(pageId); return useMutation({ mutationFn: (input: { id: string; parentId: string | null; /** * Explicit position (legacy) OR neighbor block ids. Prefer passing * `beforeBlockId`/`afterBlockId` and letting the SERVER compute the * position with its canonical base-62 engine — the web's base-36 shared * engine rejects the server's base-62 positions (uppercase) and threw on * every reorder (TASK-0135). */ position?: string; beforeBlockId?: string | null; afterBlockId?: string | null; }) => { // The API's MoveBlockDto declares `afterBlockId`/`beforeBlockId` as // optional strings — sending `null` fails validation (400), so the // fields must be OMITTED when there is no neighbor. const body: { parentId: string | null; position?: string; beforeBlockId?: string; afterBlockId?: string; } = { parentId: input.parentId }; if (input.position) body.position = input.position; if (input.beforeBlockId) body.beforeBlockId = input.beforeBlockId; if (input.afterBlockId) body.afterBlockId = input.afterBlockId; return blockApi.move(input.id, body); }, onSuccess: () => invalidate() }); } export function useDeleteBlock(pageId: string | null) { const invalidate = useInvalidatePageBlocks(pageId); return useMutation({ mutationFn: (id: string) => blockApi.remove(id), onSuccess: () => { invalidate(); useEditorStore.getState().setSelection(null); } }); } export function useDuplicateBlock(pageId: string | null) { const invalidate = useInvalidatePageBlocks(pageId); return useMutation({ // Id-anchored: the server computes the fractional position (client // base-36 math threw on the API's base-62 positions). mutationFn: (input: { id: string; afterBlockId?: string; parentId?: string | null }) => blockApi.duplicate(input.id, { ...(input.parentId !== undefined ? { parentId: input.parentId } : {}), ...(input.afterBlockId ? { afterBlockId: input.afterBlockId } : {}) }), onSuccess: (block) => { invalidate(); useEditorStore.getState().setSelection(block.id); } }); } /** * Server blocks merged with the realtime overlay (block-patch-store) and * re-sorted by fractional position. Re-renders only when this page's * overlay slice changes (shallow equality). */ export function useMergedBlocks(pageId: string, serverBlocks: Block[]): Block[] { const overlay = useBlockPatchStore( useShallow((s) => ({ patches: s.patches[pageId] ?? EMPTY_PATCHES, upserts: s.upserts[pageId] ?? EMPTY_UPSERTS, deleted: s.deleted[pageId] ?? EMPTY_DELETED })) ); const server = serverBlocks; return useMemo( () => { const removed = new Set(overlay.deleted); const out: Block[] = []; for (const b of server) { if (removed.has(b.id)) continue; const p = overlay.patches[b.id]; out.push(p ? applyOverlay(b, p) : b); } for (const b of Object.values(overlay.upserts)) { if (removed.has(b.id)) continue; const p = overlay.patches[b.id]; out.push(p ? applyOverlay(b, p) : b); } return sortByPosition(out); }, // eslint-disable-next-line react-hooks/exhaustive-deps [server, overlay.patches, overlay.upserts, overlay.deleted] ); } const EMPTY_PATCHES: Record = {}; const EMPTY_UPSERTS: Record = {}; const EMPTY_DELETED: string[] = []; function applyOverlay(block: Block, p: BlockPatch): Block { return { ...block, ...p, properties: { ...block.properties, ...(p.properties ?? {}) } }; }