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