1
20
21import type { Position } from "@factory/shared/positions";
22import type { BlockType } from "@factory/shared/dto";
23import { betweenPosition } from "./positions.js";
24import { generateBlockId } from "./codec.js";
25
26
27export interface CopiedBlock {
28 readonly id: string;
29 readonly type: BlockType;
30 readonly content: Record<string, unknown>;
31 readonly properties?: Record<string, unknown>;
32
33 readonly parentId: string | null;
34
35 readonly position: Position;
36}
37
38
39export interface PastedBlock {
40 readonly id: string;
41 readonly type: BlockType;
42 readonly content: Record<string, unknown>;
43 readonly properties?: Record<string, unknown>;
44
45 readonly parentId: string | null;
46 readonly position: Position;
47}
48
49
55export function copyBlocks(
56 selection: readonly CopiedBlock[],
57 options?: { readonly regenerateIds?: boolean },
58): { readonly blocks: readonly CopiedBlock[]; readonly idsRegenerated: boolean } {
59 const regenerate = options?.regenerateIds ?? true;
60 const idMap = new Map<string, string>();
61 const remap = (id: string | null): string | null => {
62 if (id === null) return null;
63 const existing = idMap.get(id);
64 if (existing !== undefined) return existing;
65 const next = regenerate ? generateBlockId() : id;
66 idMap.set(id, next);
67 return next;
68 };
69 const blocks = selection.map((b) => {
70 const block: CopiedBlock = {
71 id: remap(b.id) ?? b.id,
72 type: b.type,
73 content: b.content,
74 parentId: remap(b.parentId),
75 position: b.position,
76 ...(b.properties !== undefined ? { properties: b.properties } : {}),
77 };
78 return block;
79 });
80 return { blocks, idsRegenerated: regenerate };
81}
82
83
84
85
86export function blockToMarkdown(block: Pick<CopiedBlock, "type" | "content" | "properties">): string {
87 const text =
88 typeof (block.content as { text?: unknown }).text === "string"
89 ? ((block.content as { text: string }).text)
90 : "";
91 const checked = block.properties?.checked === true ? "[x]" : "[ ]";
92 switch (block.type) {
93 case "heading_1":
94 return `# ${text}`;
95 case "heading_2":
96 return `## ${text}`;
97 case "heading_3":
98 return `### ${text}`;
99 case "quote":
100 return `> ${text}`;
101 case "bulleted_list":
102 return `- ${text}`;
103 case "numbered_list":
104 return `1. ${text}`;
105 case "todo":
106 return `- ${checked} ${text}`;
107 case "toggle":
108 return `### ${text}`;
109 case "code": {
110 const code = (block.content as { text?: string }).text ?? "";
111 const lang = (block.content as { language?: string }).language ?? "";
112 return "```" + lang + "\n" + code + "\n```";
113 }
114 case "divider":
115 return "---";
116 case "spacer": {
117 const height = (block.content as { height?: number }).height ?? 0;
118 return `<!-- spacer:${height}px -->`;
119 }
120 case "image":
121 case "file":
122 case "bookmark":
123 case "embed":
124 case "video":
125 case "audio":
126 return referenceMarkdown(block.type, block.content);
127 case "table": {
128 const rows = (block.content as { rows?: string[][] }).rows ?? [];
129 return rows.map((r) => `| ${r.join(" | ")} |`).join("\n");
130 }
131 case "equation":
132 return `$${text}$`;
133 default:
134 return text;
135 }
136}
137
138function referenceMarkdown(type: string, content: Record<string, unknown>): string {
139 const url = typeof content.url === "string" ? content.url : "";
140 const fileId = typeof content.fileId === "string" ? content.fileId : "";
141 if (url.length > 0) return `[${type}](${url})`;
142 if (fileId.length > 0) return `[${type}:${fileId}]`;
143 return `[${type}]`;
144}
145
146
147export function blocksToMarkdown(
148 blocks: readonly Pick<CopiedBlock, "type" | "content" | "properties">[],
149): string {
150 return blocks.map(blockToMarkdown).join("\n\n");
151}
152
153
154
155
161export function markdownToBlocks(input: string): PastedBlock[] {
162 const lines = input.split(/\r?\n/);
163 const blocks: PastedBlock[] = [];
164 let position: Position = "0";
165
166 const push = (
167 type: BlockType,
168 content: Record<string, unknown>,
169 properties?: Record<string, unknown>,
170 ): void => {
171 const block: PastedBlock = {
172 id: generateBlockId(),
173 type,
174 content,
175 parentId: null,
176 position,
177 ...(properties !== undefined ? { properties } : {}),
178 };
179 blocks.push(block);
180 position = betweenPosition(position, "zzzz");
181 };
182
183 let i = 0;
184 while (i < lines.length) {
185 const line = lines[i] ?? "";
186 const fence = line.match(/^```(\w*)\s*$/);
187 if (fence !== null) {
188 const language = fence[1] ?? "";
189 const codeLines: string[] = [];
190 i += 1;
191 while (i < lines.length && (lines[i] ?? "") !== "```") {
192 codeLines.push(lines[i] ?? "");
193 i += 1;
194 }
195 i += 1;
196 const codeContent: Record<string, unknown> = { text: codeLines.join("\n") };
197 if (language.length > 0) codeContent.language = language;
198 push("code", codeContent);
199 continue;
200 }
201 const heading = line.match(/^(#{1,3})\s+(.*)$/);
202 if (heading !== null) {
203 const level = (heading[1] ?? "#").length;
204 push(
205 level === 1 ? "heading_1" : level === 2 ? "heading_2" : "heading_3",
206 { text: heading[2] ?? "" },
207 );
208 i += 1;
209 continue;
210 }
211 const quote = line.match(/^>\s?(.*)$/);
212 if (quote !== null) {
213 push("quote", { text: quote[1] ?? "" });
214 i += 1;
215 continue;
216 }
217 const todo = line.match(/^[-*]\s+\[( |x|X)\]\s+(.*)$/);
218 if (todo !== null) {
219 push("todo", { text: todo[2] ?? "" }, { checked: todo[1] !== " " });
220 i += 1;
221 continue;
222 }
223 const bullet = line.match(/^[-*]\s+(.*)$/);
224 if (bullet !== null) {
225 push("bulleted_list", { text: bullet[1] ?? "" });
226 i += 1;
227 continue;
228 }
229 const numbered = line.match(/^\d+\.\s+(.*)$/);
230 if (numbered !== null) {
231 push("numbered_list", { text: numbered[1] ?? "" });
232 i += 1;
233 continue;
234 }
235 if (line.trim() === "---") {
236 push("divider", {});
237 i += 1;
238 continue;
239 }
240 const spacer = line.match(/^<!--\s*spacer:(\d+)px\s*-->$/);
241 if (spacer !== null) {
242 push("spacer", { height: Number(spacer[1]) || 0 });
243 i += 1;
244 continue;
245 }
246 const eq = line.match(/^\$(.*)\$$/);
247 if (eq !== null) {
248 push("equation", { text: eq[1] ?? "" });
249 i += 1;
250 continue;
251 }
252 const ref = line.match(/^\[([a-z_]+)\]\((.+)\)$/);
253 if (ref !== null) {
254 push("bookmark", { url: ref[2] ?? "" });
255 i += 1;
256 continue;
257 }
258 if (line.trim() === "") {
259 i += 1;
260 continue;
261 }
262 push("paragraph", { text: line });
263 i += 1;
264 }
265 return blocks;
266}
267
268
273export function parsePasted(raw: string, source: "internal" | "markdown" | "text"): PastedBlock[] {
274 if (source === "internal") {
275 const parsed = JSON.parse(raw) as unknown;
276 if (Array.isArray(parsed)) {
277 return parsed as PastedBlock[];
278 }
279 throw new Error("internal clipboard payload must be a block array");
280 }
281 return markdownToBlocks(raw);
282}
283