1
17
18import type { BlockType } from "@factory/shared/dto";
19
20
21export interface MenuContext {
22
23 readonly blockIds: readonly string[];
24 readonly pageId: string;
25 readonly workspaceId: string;
26 readonly readOnly: boolean;
27}
28
29
30export interface MenuRow {
31 readonly id: string;
32 readonly label: string;
33 readonly shortcut?: string;
34 readonly divider?: boolean;
35 readonly disabled?: boolean;
36 readonly tooltip?: string;
37 readonly submenu?: readonly MenuRow[];
38 readonly run?: (ctx: MenuContext) => void;
39}
40
41
42export const TEXT_COLOR_TOKENS: readonly string[] = [
43 "default",
44 "gray",
45 "brown",
46 "orange",
47 "yellow",
48 "green",
49 "blue",
50 "purple",
51 "pink",
52];
53export const BACKGROUND_COLOR_TOKENS: readonly string[] = [
54 "default",
55 "gray_background",
56 "brown_background",
57 "orange_background",
58 "yellow_background",
59 "green_background",
60 "blue_background",
61 "purple_background",
62 "pink_background",
63];
64
65
66export const TEXT_BLOCK_TYPES: readonly BlockType[] = [
67 "paragraph",
68 "heading_1",
69 "heading_2",
70 "heading_3",
71 "quote",
72 "callout",
73 "bulleted_list",
74 "numbered_list",
75 "todo",
76 "toggle",
77];
78
79
80export function turnIntoRows(currentType: BlockType): readonly MenuRow[] {
81 return TEXT_BLOCK_TYPES.map((type) => ({
82 id: `turn_into:${type}`,
83 label: turnIntoLabel(type),
84
85
86
87 disabled: type === currentType,
88 }));
89}
90
91export function turnIntoId(type: BlockType): string {
92 return `turn_into:${type}`;
93}
94
95function turnIntoLabel(type: BlockType): string {
96 const labels: Record<string, string> = {
97 paragraph: "Text",
98 heading_1: "Heading 1",
99 heading_2: "Heading 2",
100 heading_3: "Heading 3",
101 quote: "Quote",
102 callout: "Callout",
103 bulleted_list: "Bulleted list",
104 numbered_list: "Numbered list",
105 todo: "To-do",
106 toggle: "Toggle",
107 };
108 return labels[type] ?? type;
109}
110
111
112export function convertToRows(): readonly MenuRow[] {
113 return (
114 ["heading_1", "heading_2", "heading_3", "quote", "callout", "todo"] as const
115 ).map((type) => ({
116 id: `convert_to:${type}`,
117 label: turnIntoLabel(type),
118 }));
119}
120
121
122export const ASK_AI_ROWS: readonly MenuRow[] = [
123 { id: "ai:summarize", label: "Summarize", disabled: true, tooltip: "Available in Phase 12" },
124 { id: "ai:rewrite", label: "Rewrite", disabled: true, tooltip: "Available in Phase 12" },
125 { id: "ai:improve", label: "Improve", disabled: true, tooltip: "Available in Phase 12" },
126 { id: "ai:translate", label: "Translate", disabled: true, tooltip: "Available in Phase 12" },
127];
128
129
134export function moreRows(isPageRoot: boolean): readonly MenuRow[] {
135 return [
136 { id: "more:synced", label: "Duplicate as synced block", disabled: true, tooltip: "Available in Phase 5" },
137 { id: "more:template", label: "Save as template", disabled: true, tooltip: "Available in Phase 14" },
138 { id: "more:permissions", label: "Permissions", disabled: !isPageRoot },
139 ];
140}
141
142export interface BuildBlockMenuOptions {
143 readonly blockIds: readonly string[];
144 readonly type: BlockType;
145 readonly isPageRoot?: boolean;
146 readonly readOnly?: boolean;
147
148 readonly onTextColor?: (token: string, ctx: MenuContext) => void;
149 readonly onBackgroundColor?: (token: string, ctx: MenuContext) => void;
150}
151
152
157export function buildBlockMenu(options: BuildBlockMenuOptions): readonly MenuRow[] {
158 const many = options.blockIds.length > 1;
159 const readOnly = options.readOnly ?? false;
160 const type = options.type;
161 const isPageRoot = options.isPageRoot ?? false;
162
163 const rows: MenuRow[] = [
164 {
165 id: "turn_into",
166 label: "Turn into",
167 submenu: turnIntoRows(type),
168 ...(readOnly ? { disabled: true } : {}),
169 },
170 { id: "duplicate", label: many ? `Duplicate ${options.blockIds.length} blocks` : "Duplicate", shortcut: "⌘⇧D", disabled: readOnly },
171 { id: "copy", label: "Copy", shortcut: "⌘C", disabled: options.blockIds.length === 0 },
172 { id: "copy_link", label: "Copy link", shortcut: "⌘⇧C", disabled: options.blockIds.length === 0 },
173 { id: "move_to", label: "Move to", shortcut: "⌘⇧M", disabled: readOnly },
174 { id: "delete", label: "Delete", disabled: readOnly },
175 { id: "sep-1", label: "", divider: true },
176 { id: "comment", label: "Comment" },
177 { id: "ask_ai", label: "Ask AI", submenu: ASK_AI_ROWS },
178 { id: "sep-2", label: "", divider: true },
179 {
180 id: "color",
181 label: "Color",
182 disabled: readOnly,
183 submenu: TEXT_COLOR_TOKENS.map((token) => ({
184 id: `color:${token}`,
185 label: colorLabel(token),
186 run: (c: MenuContext) => options.onTextColor?.(token, c),
187 })),
188 },
189 {
190 id: "background",
191 label: "Background",
192 disabled: readOnly,
193 submenu: BACKGROUND_COLOR_TOKENS.map((token) => ({
194 id: `background:${token}`,
195 label: colorLabel(token),
196 run: (c: MenuContext) => options.onBackgroundColor?.(token, c),
197 })),
198 },
199 { id: "sep-3", label: "", divider: true },
200 { id: "convert_to", label: "Convert to", submenu: convertToRows(), ...(readOnly ? { disabled: true } : {}) },
201 { id: "sep-4", label: "", divider: true },
202 { id: "more", label: "More", submenu: moreRows(isPageRoot) },
203 ];
204
205
206
207
208
209 return rows;
210}
211
212function colorLabel(token: string): string {
213 if (token === "default") return "Default";
214 const name = token.replace(/_background$/, "");
215 return name.charAt(0).toUpperCase() + name.slice(1);
216}
217