a tool for shared writing and social publishing
1import type { Fact, ReplicacheMutators } from "src/replicache";
2import { useUIState } from "src/useUIState";
3
4import { generateKeyBetween } from "fractional-indexing";
5import { focusPage } from "src/utils/focusPage";
6import { v7 } from "uuid";
7import { Replicache } from "replicache";
8import { useEditorStates } from "src/state/useEditorState";
9import { elementId } from "src/utils/elementId";
10import { UndoManager } from "src/undoManager";
11import { focusBlock } from "src/utils/focusBlock";
12import { usePollBlockUIState } from "./PollBlock/pollBlockState";
13import { focusElement } from "src/utils/focusElement";
14import { BlockBlueskySmall } from "components/Icons/BlockBlueskySmall";
15import { BlockButtonSmall } from "components/Icons/BlockButtonSmall";
16import { BlockCalendarSmall } from "components/Icons/BlockCalendarSmall";
17import { BlockCanvasPageSmall } from "components/Icons/BlockCanvasPageSmall";
18import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall";
19import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall";
20import { BlockImageSmall } from "components/Icons/BlockImageSmall";
21import { BlockMailboxSmall } from "components/Icons/BlockMailboxSmall";
22import { BlockPollSmall } from "components/Icons/BlockPollSmall";
23import {
24 ParagraphSmall,
25 Header1Small,
26 Header2Small,
27 Header3Small,
28} from "components/Icons/BlockTextSmall";
29import { LinkSmall } from "components/Icons/LinkSmall";
30import { BlockRSVPSmall } from "components/Icons/BlockRSVPSmall";
31import {
32 ListUnorderedSmall,
33 ListOrderedSmall,
34} from "components/Toolbar/ListToolbar";
35import { BlockMathSmall } from "components/Icons/BlockMathSmall";
36import { BlockCodeSmall } from "components/Icons/BlockCodeSmall";
37import { QuoteSmall } from "components/Icons/QuoteSmall";
38import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage";
39
40type Props = {
41 parent: string;
42 entityID: string | null;
43 position: string | null;
44 nextPosition: string | null;
45 factID?: string | undefined;
46 first?: boolean;
47 className?: string;
48};
49
50async function createBlockWithType(
51 rep: Replicache<ReplicacheMutators>,
52 args: {
53 entity_set: string;
54 parent: string;
55 position: string | null;
56 nextPosition: string | null;
57 entityID: string | null;
58 },
59 type: Fact<"block/type">["data"]["value"],
60) {
61 let entity;
62
63 if (!args.entityID) {
64 entity = v7();
65 await rep?.mutate.addBlock({
66 parent: args.parent,
67 factID: v7(),
68 permission_set: args.entity_set,
69 type: type,
70 position: generateKeyBetween(args.position, args.nextPosition),
71 newEntityID: entity,
72 });
73 } else {
74 entity = args.entityID;
75 await rep?.mutate.assertFact({
76 entity,
77 attribute: "block/type",
78 data: { type: "block-type-union", value: type },
79 });
80 }
81 return entity;
82}
83
84function clearCommandSearchText(entityID: string) {
85 useEditorStates.setState((s) => {
86 let existingState = s.editorStates[entityID];
87 if (!existingState) {
88 return s;
89 }
90
91 let tr = existingState.editor.tr;
92 tr.deleteRange(1, tr.doc.content.size - 1);
93 return {
94 editorStates: {
95 ...s.editorStates,
96 [entityID]: {
97 ...existingState,
98 editor: existingState.editor.apply(tr),
99 },
100 },
101 };
102 });
103}
104
105type Command = {
106 name: string;
107 icon: React.ReactNode;
108 type: string;
109 alternateNames?: string[];
110 hiddenInPublication?: boolean;
111 onSelect: (
112 rep: Replicache<ReplicacheMutators>,
113 props: Props & { entity_set: string },
114 undoManager: UndoManager,
115 ) => Promise<any>;
116};
117export const blockCommands: Command[] = [
118 // please keep these in the order that they appear in the menu, grouped by type
119 {
120 name: "Text",
121 icon: <ParagraphSmall />,
122 type: "text",
123 onSelect: async (rep, props, um) => {
124 props.entityID && clearCommandSearchText(props.entityID);
125 let entity = await createBlockWithType(rep, props, "text");
126 clearCommandSearchText(entity);
127 },
128 },
129 {
130 name: "Title",
131 icon: <Header1Small />,
132 type: "text",
133 alternateNames: ["h1"],
134 onSelect: async (rep, props, um) => {
135 await setHeaderCommand(1, rep, props);
136 },
137 },
138 {
139 name: "Header",
140 icon: <Header2Small />,
141 type: "text",
142 alternateNames: ["h2"],
143 onSelect: async (rep, props, um) => {
144 await setHeaderCommand(2, rep, props);
145 },
146 },
147 {
148 name: "Subheader",
149 icon: <Header3Small />,
150 type: "text",
151 alternateNames: ["h3"],
152 onSelect: async (rep, props, um) => {
153 await setHeaderCommand(3, rep, props);
154 },
155 },
156 {
157 name: "Unordered List",
158 icon: <ListUnorderedSmall />,
159 alternateNames: ["Bulleted List"],
160 type: "text",
161 onSelect: async (rep, props, um) => {
162 let entity = await createBlockWithType(rep, props, "text");
163 await rep?.mutate.assertFact({
164 entity,
165 attribute: "block/is-list",
166 data: { value: true, type: "boolean" },
167 });
168 clearCommandSearchText(entity);
169 },
170 },
171 {
172 name: "Ordered List",
173 icon: <ListOrderedSmall />,
174 type: "text",
175 alternateNames: ["Numbered List"],
176 onSelect: async (rep, props, um) => {
177 let entity = await createBlockWithType(rep, props, "text");
178 await rep?.mutate.assertFact([
179 {
180 entity,
181 attribute: "block/is-list",
182 data: { value: true, type: "boolean" },
183 },
184 {
185 entity,
186 attribute: "block/list-style",
187 data: { value: "ordered", type: "list-style-union" },
188 },
189 ]);
190 clearCommandSearchText(entity);
191 },
192 },
193 {
194 name: "Block Quote",
195 icon: <QuoteSmall />,
196 type: "text",
197 onSelect: async (rep, props, um) => {
198 if (props.entityID) clearCommandSearchText(props.entityID);
199 let entity = await createBlockWithType(rep, props, "blockquote");
200 clearCommandSearchText(entity);
201 },
202 },
203
204 {
205 name: "Image",
206 icon: <BlockImageSmall />,
207 type: "block",
208 onSelect: async (rep, props, um) => {
209 props.entityID && clearCommandSearchText(props.entityID);
210 let entity = await createBlockWithType(rep, props, "image");
211 setTimeout(() => {
212 let el = document.getElementById(elementId.block(entity).input);
213 el?.focus();
214 }, 100);
215 um.add({
216 undo: () => {
217 focusTextBlock(entity);
218 },
219 redo: () => {
220 let el = document.getElementById(elementId.block(entity).input);
221 el?.focus();
222 },
223 });
224 },
225 },
226 {
227 name: "External Link",
228 icon: <LinkSmall />,
229 type: "block",
230 onSelect: async (rep, props) => {
231 createBlockWithType(rep, props, "link");
232 },
233 },
234 {
235 name: "Button",
236 icon: <BlockButtonSmall />,
237 type: "block",
238 onSelect: async (rep, props, um) => {
239 props.entityID && clearCommandSearchText(props.entityID);
240 await createBlockWithType(rep, props, "button");
241 um.add({
242 undo: () => {
243 props.entityID && focusTextBlock(props.entityID);
244 },
245 redo: () => {},
246 });
247 },
248 },
249 {
250 name: "Horizontal Rule",
251 icon: "—",
252 type: "block",
253 onSelect: async (rep, props, um) => {
254 props.entityID && clearCommandSearchText(props.entityID);
255 await createBlockWithType(rep, props, "horizontal-rule");
256 um.add({
257 undo: () => {
258 props.entityID && focusTextBlock(props.entityID);
259 },
260 redo: () => {},
261 });
262 },
263 },
264 {
265 name: "Poll",
266 icon: <BlockPollSmall />,
267 type: "block",
268 onSelect: async (rep, props, um) => {
269 let entity = await createBlockWithType(rep, props, "poll");
270 let pollOptionEntity = v7();
271 await rep.mutate.addPollOption({
272 pollEntity: entity,
273 pollOptionEntity,
274 pollOptionName: "",
275 factID: v7(),
276 permission_set: props.entity_set,
277 });
278 await rep.mutate.addPollOption({
279 pollEntity: entity,
280 pollOptionEntity: v7(),
281 pollOptionName: "",
282 factID: v7(),
283 permission_set: props.entity_set,
284 });
285 usePollBlockUIState.setState((s) => ({ [entity]: { state: "editing" } }));
286 setTimeout(() => {
287 focusElement(
288 document.getElementById(
289 elementId.block(entity).pollInput(pollOptionEntity),
290 ) as HTMLInputElement | null,
291 );
292 }, 20);
293 um.add({
294 undo: () => {
295 props.entityID && focusTextBlock(props.entityID);
296 },
297 redo: () => {
298 setTimeout(() => {
299 focusElement(
300 document.getElementById(
301 elementId.block(entity).pollInput(pollOptionEntity),
302 ) as HTMLInputElement | null,
303 );
304 }, 20);
305 },
306 });
307 },
308 },
309 {
310 name: "Embed Website",
311 icon: <BlockEmbedSmall />,
312 type: "block",
313 onSelect: async (rep, props) => {
314 createBlockWithType(rep, props, "embed");
315 },
316 },
317 {
318 name: "Bluesky Post",
319 icon: <BlockBlueskySmall />,
320 type: "block",
321 onSelect: async (rep, props) => {
322 createBlockWithType(rep, props, "bluesky-post");
323 },
324 },
325 {
326 name: "Math",
327 icon: <BlockMathSmall />,
328 type: "block",
329 hiddenInPublication: false,
330 onSelect: async (rep, props) => {
331 createBlockWithType(rep, props, "math");
332 },
333 },
334 {
335 name: "Code",
336 icon: <BlockCodeSmall />,
337 type: "block",
338 hiddenInPublication: false,
339 onSelect: async (rep, props) => {
340 let entity = await createBlockWithType(rep, props, "code");
341 let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY);
342 if (lastLang) {
343 await rep.mutate.assertFact({
344 entity,
345 attribute: "block/code-language",
346 data: { type: "string", value: lastLang },
347 });
348 }
349 },
350 },
351
352 // EVENT STUFF
353 {
354 name: "Date and Time",
355 icon: <BlockCalendarSmall />,
356 type: "event",
357 hiddenInPublication: true,
358 onSelect: (rep, props) => {
359 props.entityID && clearCommandSearchText(props.entityID);
360 return createBlockWithType(rep, props, "datetime");
361 },
362 },
363
364 // PAGE TYPES
365
366 {
367 name: "New Page",
368 icon: <BlockDocPageSmall />,
369 type: "page",
370 onSelect: async (rep, props, um) => {
371 props.entityID && clearCommandSearchText(props.entityID);
372 let entity = await createBlockWithType(rep, props, "card");
373
374 let newPage = v7();
375 await rep?.mutate.addPageLinkBlock({
376 blockEntity: entity,
377 firstBlockFactID: v7(),
378 firstBlockEntity: v7(),
379 pageEntity: newPage,
380 type: "doc",
381 permission_set: props.entity_set,
382 });
383
384 useUIState.getState().openPage(props.parent, newPage);
385 um.add({
386 undo: () => {
387 useUIState.getState().closePage(newPage);
388 setTimeout(
389 () =>
390 focusBlock(
391 { parent: props.parent, value: entity, type: "text" },
392 { type: "end" },
393 ),
394 100,
395 );
396 },
397 redo: () => {
398 useUIState.getState().openPage(props.parent, newPage);
399 focusPage(newPage, rep, "focusFirstBlock");
400 },
401 });
402 focusPage(newPage, rep, "focusFirstBlock");
403 },
404 },
405 {
406 name: "New Canvas",
407 icon: <BlockCanvasPageSmall />,
408 type: "page",
409 onSelect: async (rep, props, um) => {
410 props.entityID && clearCommandSearchText(props.entityID);
411 let entity = await createBlockWithType(rep, props, "card");
412
413 let newPage = v7();
414 await rep?.mutate.addPageLinkBlock({
415 type: "canvas",
416 blockEntity: entity,
417 firstBlockFactID: v7(),
418 firstBlockEntity: v7(),
419 pageEntity: newPage,
420 permission_set: props.entity_set,
421 });
422 useUIState.getState().openPage(props.parent, newPage);
423 focusPage(newPage, rep, "focusFirstBlock");
424 um.add({
425 undo: () => {
426 useUIState.getState().closePage(newPage);
427 setTimeout(
428 () =>
429 focusBlock(
430 { parent: props.parent, value: entity, type: "text" },
431 { type: "end" },
432 ),
433 100,
434 );
435 },
436 redo: () => {
437 useUIState.getState().openPage(props.parent, newPage);
438 focusPage(newPage, rep, "focusFirstBlock");
439 },
440 });
441 },
442 },
443];
444
445async function setHeaderCommand(
446 level: number,
447 rep: Replicache<ReplicacheMutators>,
448 props: Props & { entity_set: string },
449) {
450 let entity = await createBlockWithType(rep, props, "heading");
451 await rep.mutate.assertFact({
452 entity,
453 attribute: "block/heading-level",
454 data: { type: "number", value: level },
455 });
456 clearCommandSearchText(entity);
457}
458function focusTextBlock(entityID: string) {
459 document.getElementById(elementId.block(entityID).text)?.focus();
460}