1"use client";
2
3
13import { useCallback, useEffect, useMemo } from "react";
14import { useMutation, useQueryClient } from "@tanstack/react-query";
15import { useShallow } from "zustand/react/shallow";
16import { blockApi, type BatchBlockOp, type InsertBlockInput } from "@/lib/api";
17import { getBlockBatchQueue } from "@/lib/batch-queue";
18import { sortByPosition } from "@/lib/utils";
19import type { Block, BlockPatch, BlockType } from "@/lib/types";
20import { useBlockPatchStore } from "@/stores/block-patch-store";
21import { useEditorStore } from "@/stores/editor-store";
22
23export function useInvalidatePageBlocks(pageId?: string | null) {
24 const qc = useQueryClient();
25 return useCallback(
26 (id?: string) => {
27 const target = id ?? pageId;
28 if (!target) return;
29 qc.invalidateQueries({ queryKey: ["pages", target, "blocks"] });
30 },
31 [qc, pageId]
32 );
33}
34
35
39export function useBatchSave(pageId: string | null) {
40 const qc = useQueryClient();
41
42 const queue = useCallback(
43 (ops: BatchBlockOp | BatchBlockOp[]) => {
44 if (!pageId) return;
45 getBlockBatchQueue(pageId).queue(ops);
46 },
47 [pageId]
48 );
49
50 const flush = useCallback(async () => {
51 if (!pageId) return;
52 await getBlockBatchQueue(pageId).flush();
53 qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] });
54 }, [pageId, qc]);
55
56
57 useEffect(() => {
58 if (!pageId) return;
59 const q = getBlockBatchQueue(pageId);
60 return () => {
61 void q.flush().then(() =>
62 qc.invalidateQueries({ queryKey: ["pages", pageId, "blocks"] })
63 );
64 };
65 }, [pageId, qc]);
66
67 return { queue, flush };
68}
69
70export function useInsertBlock(pageId: string | null) {
71 const invalidate = useInvalidatePageBlocks(pageId);
72
73 return useMutation({
74 mutationFn: (input: {
75 parentId: string | null;
76
79 afterBlockId?: string | null;
80
81 beforeBlockId?: string | null;
82 type: BlockType;
83 content?: unknown;
84 properties?: Record<string, unknown>;
85 }) => {
86
87
88
89
90
91 const body: InsertBlockInput = {
92 pageId: pageId as string,
93 parentId: input.parentId,
94 type: input.type,
95 ...(input.afterBlockId ? { afterBlockId: input.afterBlockId } : {}),
96 ...(input.beforeBlockId ? { beforeBlockId: input.beforeBlockId } : {}),
97 ...(input.content !== undefined ? { content: input.content } : {}),
98 ...(input.properties !== undefined ? { properties: input.properties } : {})
99 };
100 return blockApi.insert(body);
101 },
102 onSuccess: (block) => {
103 invalidate();
104 if (useEditorStore.getState().activePageId === pageId) {
105 useEditorStore.getState().setSelection(block.id);
106 }
107 }
108 });
109}
110
111export function useUpdateBlock(pageId: string | null) {
112 const invalidate = useInvalidatePageBlocks(pageId);
113 const { queue } = useBatchSave(pageId);
114
115
119 const updateBlock = useCallback(
120 async (blockId: string, patch: BlockPatch, immediate = true) => {
121 if (immediate) {
122 const block = await blockApi.update(blockId, patch);
123 invalidate();
124 return block;
125 }
126 queue({ op: "update", blockId, patch });
127 return undefined;
128 },
129 [invalidate, queue]
130 );
131
132 return { updateBlock };
133}
134
135export function useMoveBlock(pageId: string | null) {
136 const invalidate = useInvalidatePageBlocks(pageId);
137
138 return useMutation({
139 mutationFn: (input: {
140 id: string;
141 parentId: string | null;
142
149 position?: string;
150 beforeBlockId?: string | null;
151 afterBlockId?: string | null;
152 }) => {
153
154
155
156 const body: {
157 parentId: string | null;
158 position?: string;
159 beforeBlockId?: string;
160 afterBlockId?: string;
161 } = { parentId: input.parentId };
162 if (input.position) body.position = input.position;
163 if (input.beforeBlockId) body.beforeBlockId = input.beforeBlockId;
164 if (input.afterBlockId) body.afterBlockId = input.afterBlockId;
165 return blockApi.move(input.id, body);
166 },
167 onSuccess: () => invalidate()
168 });
169}
170
171export function useDeleteBlock(pageId: string | null) {
172 const invalidate = useInvalidatePageBlocks(pageId);
173
174 return useMutation({
175 mutationFn: (id: string) => blockApi.remove(id),
176 onSuccess: () => {
177 invalidate();
178 useEditorStore.getState().setSelection(null);
179 }
180 });
181}
182
183export function useDuplicateBlock(pageId: string | null) {
184 const invalidate = useInvalidatePageBlocks(pageId);
185
186 return useMutation({
187
188
189 mutationFn: (input: { id: string; afterBlockId?: string; parentId?: string | null }) =>
190 blockApi.duplicate(input.id, {
191 ...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
192 ...(input.afterBlockId ? { afterBlockId: input.afterBlockId } : {})
193 }),
194 onSuccess: (block) => {
195 invalidate();
196 useEditorStore.getState().setSelection(block.id);
197 }
198 });
199}
200
201
206export function useMergedBlocks(pageId: string, serverBlocks: Block[]): Block[] {
207 const overlay = useBlockPatchStore(
208 useShallow((s) => ({
209 patches: s.patches[pageId] ?? EMPTY_PATCHES,
210 upserts: s.upserts[pageId] ?? EMPTY_UPSERTS,
211 deleted: s.deleted[pageId] ?? EMPTY_DELETED
212 }))
213 );
214 const server = serverBlocks;
215 return useMemo(
216 () => {
217 const removed = new Set(overlay.deleted);
218 const out: Block[] = [];
219 for (const b of server) {
220 if (removed.has(b.id)) continue;
221 const p = overlay.patches[b.id];
222 out.push(p ? applyOverlay(b, p) : b);
223 }
224 for (const b of Object.values(overlay.upserts)) {
225 if (removed.has(b.id)) continue;
226 const p = overlay.patches[b.id];
227 out.push(p ? applyOverlay(b, p) : b);
228 }
229 return sortByPosition(out);
230 },
231
232 [server, overlay.patches, overlay.upserts, overlay.deleted]
233 );
234}
235
236const EMPTY_PATCHES: Record<string, BlockPatch> = {};
237const EMPTY_UPSERTS: Record<string, Block> = {};
238const EMPTY_DELETED: string[] = [];
239
240function applyOverlay(block: Block, p: BlockPatch): Block {
241 return { ...block, ...p, properties: { ...block.properties, ...(p.properties ?? {}) } };
242}
243