1
23
24import { comparePositions, type Position } from "@factory/shared/positions";
25import type { BlockContentByType, BlockType } from "@factory/shared/dto";
26
27import type { BlockDto, EditorBlockOperation, NewBlock } from "./types.js";
28
29
30export interface FactoryDocNode {
31 readonly type: string;
32 readonly attrs?: Readonly<Record<string, unknown>>;
33 readonly content?: readonly FactoryDocNode[];
34}
35
36
37export interface FactoryDoc {
38 readonly type: "doc";
39 readonly content: readonly FactoryDocNode[];
40}
41
42
43export const BLOCK_NODE_TYPE = "factoryBlock";
44
45
50export function generateBlockId(): string {
51
52
53
54 let suffix = "";
55 for (let i = 0; i < 32; i += 1) {
56 suffix += Math.floor(Math.random() * 16).toString(16);
57 }
58 return `blk_${suffix}`;
59}
60
61
62function byPosition(a: { position: Position }, b: { position: Position }): number {
63 return comparePositions(a.position, b.position);
64}
65
66function isDoc(node: unknown): node is FactoryDoc {
67 return (
68 typeof node === "object" &&
69 node !== null &&
70 (node as { type?: unknown }).type === "doc" &&
71 Array.isArray((node as { content?: unknown }).content)
72 );
73}
74
75function isBlockNode(node: unknown): node is FactoryDocNode {
76 return (
77 typeof node === "object" &&
78 node !== null &&
79 (node as { type?: unknown }).type === BLOCK_NODE_TYPE
80 );
81}
82
83function attrsOf(node: FactoryDocNode): Record<string, unknown> {
84 return (node.attrs ?? {}) as Record<string, unknown>;
85}
86
87
94export function blocksToDoc(blocks: readonly BlockDto[]): FactoryDoc {
95 const seen = new Set<string>();
96 for (const block of blocks) {
97 if (seen.has(block.id)) {
98 throw new Error(`blocksToDoc: duplicate block id "${block.id}"`);
99 }
100 seen.add(block.id);
101 }
102
103 const childrenByParent = new Map<string | null, BlockDto[]>();
104 for (const block of blocks) {
105 const parent = block.parentId;
106 if (parent !== null && !seen.has(parent)) {
107 throw new Error(
108 `blocksToDoc: block "${block.id}" references missing parent "${parent}"`,
109 );
110 }
111 const list = childrenByParent.get(parent) ?? [];
112 list.push(block);
113 childrenByParent.set(parent, list);
114 }
115
116 const toNode = (block: BlockDto): FactoryDocNode => {
117 const attrs: Record<string, unknown> = {
118 id: block.id,
119 position: block.position,
120 type: block.type,
121 content: block.content,
122 };
123 if (block.properties !== undefined) {
124 attrs.properties = block.properties;
125 }
126 const children = (childrenByParent.get(block.id) ?? []).slice().sort(byPosition);
127
128
129 const node: FactoryDocNode = {
130 type: BLOCK_NODE_TYPE,
131 attrs,
132 ...(children.length > 0 ? { content: children.map(toNode) } : {}),
133 };
134 return node;
135 };
136
137 const roots = (childrenByParent.get(null) ?? []).slice().sort(byPosition);
138 return { type: "doc", content: roots.map(toNode) };
139}
140
141
146export function docToBlocks(doc: unknown): BlockLike[] {
147 if (!isDoc(doc)) {
148 throw new Error("docToBlocks: expected a {type: 'doc', content: []} document");
149 }
150 const out: BlockLike[] = [];
151 const walk = (nodes: readonly FactoryDocNode[] | undefined, parentId: string | null): void => {
152 for (const node of nodes ?? []) {
153 if (!isBlockNode(node)) {
154 throw new Error(`docToBlocks: unexpected node type "${String((node as { type?: unknown }).type)}"`);
155 }
156 const attrs = attrsOf(node);
157 const type = attrs.type as BlockType | undefined;
158 const position = attrs.position as Position | undefined;
159 const content = attrs.content as BlockContentByType[BlockType] | undefined;
160 const id = attrs.id as string | undefined;
161 if (type === undefined || position === undefined || content === undefined || id === undefined) {
162 throw new Error(
163 `docToBlocks: block node "${String(attrs.id)}" is missing id/type/position/content attrs`,
164 );
165 }
166 const properties = attrs.properties as Record<string, unknown> | undefined;
167
168
169
170 const block: BlockLike = {
171 id,
172 type,
173 content,
174 parentId,
175 position,
176 ...(properties !== undefined ? { properties } : {}),
177 };
178 out.push(block);
179 walk(node.content, id);
180 }
181 };
182 walk(doc.content, null);
183 return out;
184}
185
186
187export function jsonEqual(a: unknown, b: unknown): boolean {
188 if (a === b) return true;
189 if (typeof a !== typeof b || a === null || b === null || typeof a !== "object") {
190 return false;
191 }
192 const ka = Object.keys(a as Record<string, unknown>).sort();
193 const kb = Object.keys(b as Record<string, unknown>).sort();
194 if (ka.length !== kb.length || ka.some((k, i) => k !== kb[i])) return false;
195 return ka.every((k) =>
196 jsonEqual(
197 (a as Record<string, unknown>)[k],
198 (b as Record<string, unknown>)[k],
199 ),
200 );
201}
202
203export interface BlockDiff {
204 readonly deletes: readonly string[];
205 readonly updates: readonly {
206 readonly id: string;
207 readonly patch: {
208 readonly content?: Record<string, unknown>;
209 readonly properties?: Record<string, unknown>;
210 };
211 }[];
212 readonly moves: readonly {
213 readonly id: string;
214 readonly before?: string;
215 readonly after?: string;
216 readonly toPageId?: string;
217 }[];
218 readonly inserts: readonly BlockLike[];
219
220 readonly operations: EditorBlockOperation[];
221}
222
223type BlockLike = Pick<BlockDto, "id" | "type" | "content" | "properties" | "parentId" | "position">;
224
225
241export function diffBlocks(
242 previous: readonly BlockLike[],
243 next: readonly BlockLike[],
244): BlockDiff {
245 const prevById = new Map<string, BlockLike>();
246 for (const b of previous) prevById.set(b.id, b);
247 const nextById = new Map<string, BlockLike>();
248 for (const b of next) nextById.set(b.id, b);
249
250 const deletes = previous.filter((b) => !nextById.has(b.id)).map((b) => b.id);
251 const inserts = next.filter((b) => !prevById.has(b.id));
252
253 const updates: {
254 id: string;
255 patch: { content?: Record<string, unknown>; properties?: Record<string, unknown> };
256 }[] = [];
257 const moves: { id: string; before?: string; after?: string; toPageId?: string }[] = [];
258
259
260 const byParent = new Map<string | null, BlockLike[]>();
261 for (const b of next) {
262 const list = byParent.get(b.parentId) ?? [];
263 list.push(b);
264 byParent.set(b.parentId, list);
265 }
266 for (const list of byParent.values()) list.sort(byPosition);
267
268 for (const [id, nb] of nextById) {
269 const pb = prevById.get(id);
270 if (pb === undefined) continue;
271 const contentChanged = !jsonEqual(pb.content, nb.content);
272 const propertiesChanged =
273 (pb.properties === undefined) !== (nb.properties === undefined) ||
274 (pb.properties !== undefined &&
275 nb.properties !== undefined &&
276 !jsonEqual(pb.properties, nb.properties));
277 if (contentChanged || propertiesChanged) {
278 const patch: { content?: Record<string, unknown>; properties?: Record<string, unknown> } = {};
279 if (contentChanged) patch.content = nb.content as Record<string, unknown>;
280 if (propertiesChanged && nb.properties !== undefined) patch.properties = nb.properties;
281 updates.push({ id, patch });
282 }
283 const moved = pb.position !== nb.position || pb.parentId !== nb.parentId;
284 if (moved) {
285 const siblings = byParent.get(nb.parentId) ?? [];
286 const idx = siblings.findIndex((s) => s.id === id);
287 const before = idx > 0 ? siblings[idx - 1]?.id : undefined;
288 const after = idx >= 0 && idx < siblings.length - 1 ? siblings[idx + 1]?.id : undefined;
289
290
291 if (before !== undefined || after !== undefined || pb.parentId !== nb.parentId) {
292 const move: { id: string; before?: string; after?: string } = { id };
293 if (before !== undefined) move.before = before;
294 if (after !== undefined) move.after = after;
295 moves.push(move);
296 }
297 }
298 }
299
300 return { deletes, updates, moves, inserts, operations: toOperations(deletes, updates, moves, inserts) };
301}
302
303
304export function toOperations(
305 deletes: readonly string[],
306 updates: readonly {
307 readonly id: string;
308 readonly patch: {
309 readonly content?: Record<string, unknown>;
310 readonly properties?: Record<string, unknown>;
311 };
312 }[],
313 moves: readonly {
314 readonly id: string;
315 readonly before?: string;
316 readonly after?: string;
317 readonly toPageId?: string;
318 }[],
319
320
321 inserts: readonly (NewBlock | BlockLike)[],
322): EditorBlockOperation[] {
323 const ops: EditorBlockOperation[] = [];
324 for (const id of deletes) ops.push({ op: "delete", id });
325 for (const u of updates) ops.push({ op: "update", id: u.id, patch: u.patch });
326 for (const m of moves) {
327
328
329
330 const move: { op: "move"; id: string; before?: string; after?: string } = { op: "move", id: m.id };
331 if (m.before !== undefined) move.before = m.before;
332 else if (m.after !== undefined) move.after = m.after;
333 ops.push(move);
334 }
335 for (const b of inserts) {
336 const block: {
337 op: "insert";
338 block: {
339 type: BlockType;
340 content: Record<string, unknown>;
341 properties?: Record<string, unknown>;
342 parentId?: string;
343 position: Position;
344 };
345 } = {
346 op: "insert",
347 block: {
348 type: b.type,
349 content: b.content as Record<string, unknown>,
350 position: b.position,
351 },
352 };
353 if (b.properties !== undefined) block.block.properties = b.properties;
354 if (b.parentId !== null) block.block.parentId = b.parentId;
355 ops.push(block);
356 }
357 return ops;
358}
359