Skip to content
File

Blob: src/client/components/editor/controllers/slash/items.ts

typescript294 lines
1import type { Editor, Range } from "@tiptap/core";
2import {
3 Heading1,
4 Heading2,
5 Heading3,
6 ChevronDown,
7 Info,
8 List,
9 ListOrdered,
10 ListChecks,
11 Pilcrow,
12 Quote,
13 Code2,
14 Minus,
15 ImageIcon,
16 Table,
17 FileText,
18 SmilePlus,
19 Sparkles,
20 PenLine,
21 Lightbulb,
22} from "lucide-react";
23import { insertCalloutBlock } from "../callout";
24import { insertDetailsBlock } from "../details-block";
25import { canInsertPageMentionAtRange } from "../../lib/page-mention/can-insert";
26import type { AiGenerateIntent } from "@/shared/types";
27 
28export interface SlashMenuItem {
29 title: string;
30 group: string;
31 icon: React.FC<{ size?: number; className?: string }>;
32 aliases?: string[];
33 description?: string;
34 isAvailable?: (props: { editor: Editor }) => boolean;
35 isBlocked?: (props: { editor: Editor }) => string | null;
36 command: (props: { editor: Editor; range: Range }) => void;
37}
38 
39export interface ResolvedSlashMenuItem extends SlashMenuItem {
40 blockedReason: string | null;
41}
42 
43export interface SlashMenuPageMentionConfig {
44 isAvailable?: (props: { editor: Editor }) => boolean;
45 openPicker: (props: { editor: Editor; range: Range }) => void;
46}
47 
48export interface SlashMenuImageConfig {
49 isAvailable?: (props: { editor: Editor }) => boolean;
50 insertImage: (props: { editor: Editor; range: Range }) => void;
51}
52 
53export interface SlashMenuEmojiConfig {
54 openPicker: (props: { editor: Editor; range: Range }) => void;
55}
56 
57export interface SlashMenuAiConfig {
58 isAvailable: (props: { editor: Editor }) => boolean;
59 isBlocked?: (props: { editor: Editor }) => string | null;
60 startGenerate: (props: { editor: Editor; range: Range; intent: AiGenerateIntent }) => void;
61}
62 
63export interface SlashMenuItemsOpts {
64 pageMention: SlashMenuPageMentionConfig | null;
65 image: SlashMenuImageConfig;
66 emoji: SlashMenuEmojiConfig;
67 ai: SlashMenuAiConfig | null;
68}
69 
70export function getSlashMenuItems(opts: SlashMenuItemsOpts): SlashMenuItem[] {
71 const items: SlashMenuItem[] = [];
72 
73 if (opts.ai) {
74 const aiConfig = opts.ai;
75 const aiIsAvailable = ({ editor }: { editor: Editor }) => aiConfig.isAvailable({ editor });
76 const aiIsBlocked = aiConfig.isBlocked
77 ? ({ editor }: { editor: Editor }) => aiConfig.isBlocked!({ editor })
78 : undefined;
79 items.push(
80 {
81 title: "Continue writing",
82 description: "Keep going from the cursor",
83 group: "AI",
84 icon: Sparkles,
85 aliases: ["ai", "continue", "write"],
86 isAvailable: aiIsAvailable,
87 isBlocked: aiIsBlocked,
88 command: ({ editor, range }) => aiConfig.startGenerate({ editor, range, intent: "continue" }),
89 },
90 {
91 title: "Explain",
92 description: "Expand on what's above",
93 group: "AI",
94 icon: PenLine,
95 aliases: ["ai", "explain"],
96 isAvailable: aiIsAvailable,
97 isBlocked: aiIsBlocked,
98 command: ({ editor, range }) => aiConfig.startGenerate({ editor, range, intent: "explain" }),
99 },
100 {
101 title: "Brainstorm",
102 description: "Draft related ideas",
103 group: "AI",
104 icon: Lightbulb,
105 aliases: ["ai", "brainstorm", "ideas"],
106 isAvailable: aiIsAvailable,
107 isBlocked: aiIsBlocked,
108 command: ({ editor, range }) => aiConfig.startGenerate({ editor, range, intent: "brainstorm" }),
109 },
110 );
111 }
112 
113 items.push(
114 {
115 title: "Heading 1",
116 group: "Headings",
117 icon: Heading1,
118 aliases: ["h1"],
119 command: ({ editor, range }) => {
120 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).setHeading({ level: 1 }).run();
121 },
122 },
123 {
124 title: "Heading 2",
125 group: "Headings",
126 icon: Heading2,
127 aliases: ["h2"],
128 command: ({ editor, range }) => {
129 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).setHeading({ level: 2 }).run();
130 },
131 },
132 {
133 title: "Heading 3",
134 group: "Headings",
135 icon: Heading3,
136 aliases: ["h3"],
137 command: ({ editor, range }) => {
138 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).setHeading({ level: 3 }).run();
139 },
140 },
141 {
142 title: "Bullet List",
143 group: "Lists",
144 icon: List,
145 aliases: ["ul", "unordered"],
146 command: ({ editor, range }) => {
147 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).toggleBulletList().run();
148 },
149 },
150 {
151 title: "Ordered List",
152 group: "Lists",
153 icon: ListOrdered,
154 aliases: ["ol", "numbered"],
155 command: ({ editor, range }) => {
156 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).toggleOrderedList().run();
157 },
158 },
159 {
160 title: "Task List",
161 group: "Lists",
162 icon: ListChecks,
163 aliases: ["todo", "checklist"],
164 command: ({ editor, range }) => {
165 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).toggleTaskList().run();
166 },
167 },
168 {
169 title: "Details",
170 group: "Basic blocks",
171 icon: ChevronDown,
172 aliases: ["details", "spoiler", "collapse"],
173 command: ({ editor, range }) => {
174 insertDetailsBlock(editor, range);
175 },
176 },
177 {
178 title: "Callout",
179 group: "Basic blocks",
180 icon: Info,
181 aliases: ["callout", "note", "tip", "warning"],
182 command: ({ editor, range }) => {
183 insertCalloutBlock(editor, range);
184 },
185 },
186 {
187 title: "Paragraph",
188 group: "Basic blocks",
189 icon: Pilcrow,
190 aliases: ["text", "plain"],
191 command: ({ editor, range }) => {
192 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).setParagraph().run();
193 },
194 },
195 {
196 title: "Blockquote",
197 group: "Basic blocks",
198 icon: Quote,
199 aliases: ["quote"],
200 command: ({ editor, range }) => {
201 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).setBlockquote().run();
202 },
203 },
204 {
205 title: "Code Block",
206 group: "Basic blocks",
207 icon: Code2,
208 aliases: ["code", "codeblock"],
209 command: ({ editor, range }) => {
210 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).setCodeBlock().run();
211 },
212 },
213 {
214 title: "Horizontal Rule",
215 group: "Basic blocks",
216 icon: Minus,
217 aliases: ["hr", "divider", "separator"],
218 command: ({ editor, range }) => {
219 editor.chain().focus(null, { scrollIntoView: false }).deleteRange(range).setHorizontalRule().run();
220 },
221 },
222 {
223 title: "Table",
224 group: "Basic blocks",
225 icon: Table,
226 aliases: ["tbl", "grid"],
227 command: ({ editor, range }) => {
228 editor
229 .chain()
230 .focus(null, { scrollIntoView: false })
231 .deleteRange(range)
232 .insertTable({ rows: 3, cols: 3, withHeaderRow: true })
233 .run();
234 },
235 },
236 {
237 title: "Image",
238 group: "Media",
239 icon: ImageIcon,
240 aliases: ["img", "picture", "photo"],
241 isAvailable: ({ editor }) => (opts.image.isAvailable ? opts.image.isAvailable({ editor }) : true),
242 command: ({ editor, range }) => {
243 opts.image.insertImage({ editor, range });
244 },
245 },
246 {
247 title: "Emoji",
248 group: "Insert",
249 icon: SmilePlus,
250 aliases: ["smile", "smiley", "face"],
251 command: ({ editor, range }) => {
252 opts.emoji.openPicker({ editor, range });
253 },
254 },
255 );
256 
257 if (opts.pageMention) {
258 const pageMentionConfig = opts.pageMention;
259 const { openPicker } = pageMentionConfig;
260 items.push({
261 title: "Link page",
262 group: "Insert",
263 icon: FileText,
264 aliases: ["mention", "page", "reference"],
265 isAvailable: ({ editor }) =>
266 (pageMentionConfig.isAvailable ? pageMentionConfig.isAvailable({ editor }) : true) &&
267 canInsertPageMentionAtRange(editor),
268 command: ({ editor, range }) => {
269 openPicker({ editor, range });
270 },
271 });
272 }
273 
274 return items;
275}
276 
277export function filterItems(
278 items: SlashMenuItem[],
279 query: string,
280 props?: { editor: Editor },
281): ResolvedSlashMenuItem[] {
282 const q = query.toLowerCase();
283 const editor = props?.editor;
284 const visible = items.filter(
285 ({ title, aliases, isAvailable }) =>
286 (!editor || !isAvailable || isAvailable({ editor })) &&
287 (title.toLowerCase().includes(q) || (aliases && aliases.some((a) => a.toLowerCase().includes(q)))),
288 );
289 return visible.map((item) => ({
290 ...item,
291 blockedReason: editor && item.isBlocked ? (item.isBlocked({ editor }) ?? null) : null,
292 }));
293}