1
26
27export interface PastedBlockSpec {
28 readonly id: string;
29 readonly type:
30 | "paragraph"
31 | "heading_1"
32 | "heading_2"
33 | "heading_3"
34 | "quote"
35 | "bulleted_list"
36 | "numbered_list"
37 | "todo"
38 | "toggle"
39 | "divider";
40
42 readonly content: unknown;
43 readonly properties?: Record<string, unknown>;
44
45 readonly parentId: string | null;
46}
47
48
49export function generateClipboardBlockId(): string {
50 let suffix = "";
51 for (let i = 0; i < 32; i += 1) {
52 suffix += Math.floor(Math.random() * 16).toString(16);
53 }
54 return `blk_${suffix}`;
55}
56
57
60function inlineHtml(el: Element): string {
61 const clone = el.cloneNode(true) as Element;
62 const BLOCK_TAGS = "div, p, ul, ol, li, h1, h2, h3, h4, h5, h6, table, section, article, header, footer";
63 for (const b of Array.from(clone.querySelectorAll(BLOCK_TAGS))) {
64 b.replaceWith(...Array.from(b.childNodes));
65 }
66 const s = (clone.innerHTML ?? "").replace(/\s+[\s]*<br\s*\/?>/gi, " ");
67 return s.trim();
68}
69
70
71function firstChildList(li: Element): Element | null {
72 for (const child of Array.from(li.children)) {
73 if (child.tagName === "UL" || child.tagName === "OL") return child;
74 }
75 return null;
76}
77
78
85function parseListLi(li: Element, isOrdered: boolean, parentSpecId: string | null, out: PastedBlockSpec[]): void {
86 const inner = firstChildList(li);
87 const isNotionToggle = inner !== null && li.getAttribute("data-notion") === "toggle";
88 const id = generateClipboardBlockId();
89 const checkbox = li.querySelector<HTMLInputElement>(":scope > input[type=checkbox]");
90
91
92 const textClone = li.cloneNode(true) as Element;
93 for (const c of Array.from(textClone.children)) {
94 if (c.tagName === "UL" || c.tagName === "OL") c.remove();
95 }
96 const ownText = inlineHtml(textClone);
97
98 const type: PastedBlockSpec["type"] = isNotionToggle
99 ? "toggle"
100 : checkbox
101 ? "todo"
102 : isOrdered
103 ? "numbered_list"
104 : "bulleted_list";
105
106 const spec: PastedBlockSpec = {
107 id,
108 type,
109 content: ownText,
110 ...(checkbox && !isNotionToggle ? { properties: { checked: checkbox.checked } } : {}),
111 ...(isNotionToggle ? { properties: { collapsed: false } } : {}),
112 parentId: parentSpecId,
113 };
114 out.push(spec);
115
116 if (inner) {
117 for (const sub of Array.from(inner.children)) {
118 if (sub.tagName === "LI") parseListLi(sub, inner.tagName === "OL", id, out);
119 }
120 }
121}
122
123
124function parseListEl(list: Element, parentSpecId: string | null, out: PastedBlockSpec[]): void {
125 const isOrdered = list.tagName === "OL";
126 for (const li of Array.from(list.children)) {
127 if (li.tagName === "LI") parseListLi(li, isOrdered, parentSpecId, out);
128 }
129}
130
131function blockTypeFor(tag: string): PastedBlockSpec["type"] | null {
132 switch (tag) {
133 case "H1": return "heading_1";
134 case "H2": return "heading_2";
135 case "H3": return "heading_3";
136 case "BLOCKQUOTE": return "quote";
137 case "HR": return "divider";
138 case "P": return "paragraph";
139 default: return null;
140 }
141}
142
143
149export function parseNotionHtml(root: Element): PastedBlockSpec[] {
150 const out: PastedBlockSpec[] = [];
151
152 const walk = (container: Element) => {
153 for (const node of Array.from(container.children)) {
154 const tag = node.tagName;
155 if (tag === "UL" || tag === "OL") {
156 parseListEl(node, null, out);
157 continue;
158 }
159 if (tag === "LI") {
160 parseListLi(node, false, null, out);
161 continue;
162 }
163 if (tag === "TABLE") {
164
165 out.push({ id: generateClipboardBlockId(), type: "paragraph", content: inlineHtml(node) || "", parentId: null });
166 continue;
167 }
168 const type = blockTypeFor(tag);
169 if (type) {
170 out.push({
171 id: generateClipboardBlockId(),
172 type,
173 content: type === "divider" ? "" : inlineHtml(node) || "",
174 parentId: null,
175 });
176 continue;
177 }
178
179
180
181 const innerList = Array.from(node.children).find((c) => c.tagName === "UL" || c.tagName === "OL");
182 if (innerList) {
183 parseListEl(innerList, null, out);
184 continue;
185 }
186 walk(node);
187 }
188 };
189
190 walk(root);
191 return out;
192}
193
194
196export function pasteHtmlToBlocks(html: string): PastedBlockSpec[] {
197 const doc = new DOMParser().parseFromString(html, "text/html");
198 const specs = parseNotionHtml(doc.body);
199 return specs;
200}
201
202function escapeHtml(s: string): string {
203 return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
204}
205
206
208export function pasteTextToBlocks(text: string): PastedBlockSpec[] {
209 const lines = text.replace(/\r\n?/g, "\n").split("\n");
210
211 if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
212 return lines.map((line) => ({
213 id: generateClipboardBlockId(),
214 type: "paragraph" as const,
215 content: line.trim() === "" ? "" : escapeHtml(line),
216 parentId: null,
217 }));
218}
219
220
223
224
225export const CLIPBOARD_APP_MARKER = "ai-software-factory";
226
227export interface ClipboardPayload {
228 readonly app: typeof CLIPBOARD_APP_MARKER;
229 readonly v: 1;
230 readonly blocks: PastedBlockSpec[];
231}
232
233
239export function copySubtreeToClipboardPayload(
240 rootId: string,
241 allBlocks: ReadonlyArray<Pick<BlockLike, "id" | "parentId" | "type" | "content" | "properties">>
242): PastedBlockSpec[] {
243 const byParent = new Map<string | null, string[]>();
244 for (const b of allBlocks) {
245 const list = byParent.get(b.parentId) ?? [];
246 list.push(b.id);
247 byParent.set(b.parentId, list);
248 }
249 const subtreeIds: string[] = [];
250 const queue = [rootId];
251 while (queue.length > 0) {
252 const id = queue.shift()!;
253 subtreeIds.push(id);
254 queue.push(...(byParent.get(id) ?? []));
255 }
256 const idMap = new Map<string, string>();
257 for (const id of subtreeIds) idMap.set(id, generateClipboardBlockId());
258 const specs: PastedBlockSpec[] = [];
259
260 const byId = new Map(allBlocks.map((b) => [b.id, b]));
261 const emit = (id: string, parentSpecId: string | null) => {
262 const b = byId.get(id);
263 if (!b) return;
264 specs.push({
265 id: idMap.get(id)!,
266 type: b.type as PastedBlockSpec["type"],
267 content: b.content,
268 properties: (b.properties ?? undefined) as Record<string, unknown> | undefined,
269 parentId: parentSpecId,
270 });
271 for (const childId of byParent.get(id) ?? []) emit(childId, idMap.get(id)!);
272 };
273 emit(rootId, null);
274 return specs;
275}
276
277interface BlockLike {
278 id: string;
279 parentId: string | null;
280 type: string;
281 content: unknown;
282 properties: unknown;
283}
284
285
291export function parseClipboardText(text: string): PastedBlockSpec[] | null {
292 if (!text || !text.trim().startsWith("{")) return null;
293 let obj: unknown;
294 try {
295 obj = JSON.parse(text);
296 } catch {
297 return null;
298 }
299 if (!obj || typeof obj !== "object") return null;
300 const o = obj as Record<string, unknown>;
301 if (o.app === CLIPBOARD_APP_MARKER && Array.isArray(o.blocks)) {
302 const blocks = o.blocks.filter(
303 (b): b is PastedBlockSpec =>
304 !!b && typeof b === "object" && typeof (b as PastedBlockSpec).id === "string" && typeof (b as PastedBlockSpec).type === "string"
305 );
306 if (blocks.length === 0) return null;
307 return blocks;
308 }
309
310
311 if (typeof o.type === "string" && "parentId" in o) {
312 return [
313 {
314 id: generateClipboardBlockId(),
315 type: o.type as PastedBlockSpec["type"],
316 content: o.content ?? "",
317 properties: (o.properties ?? undefined) as Record<string, unknown> | undefined,
318 parentId: null,
319 }
320 ];
321 }
322 return null;
323}
324
325
328export function htmlHasBlockStructure(html: string): boolean {
329 return /<(ul|ol|li|details|summary|blockquote|hr|table|h[1-6])[\s>/]/i.test(html);
330}
331