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