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 onSelect: async (rep, props, um) => {
208 props.entityID && clearCommandSearchText(props.entityID);
209 await createBlockWithType(rep, props, "button");
210 um.add({
211 undo: () => {
212 props.entityID && focusTextBlock(props.entityID);
213 },
214 redo: () => {},
215 });
216 },
217 },
218 {
219 name: "Horizontal Rule",
220 icon: "—",
221 type: "block",
222 onSelect: async (rep, props, um) => {
223 props.entityID && clearCommandSearchText(props.entityID);
224 await createBlockWithType(rep, props, "horizontal-rule");
225 um.add({
226 undo: () => {
227 props.entityID && focusTextBlock(props.entityID);
228 },
229 redo: () => {},
230 });
231 },
232 },
233 {
234 name: "Poll",
235 icon: <BlockPollSmall />,
236 type: "block",
237 onSelect: async (rep, props, um) => {
238 let entity = await createBlockWithType(rep, props, "poll");
239 let pollOptionEntity = v7();
240 await rep.mutate.addPollOption({
241 pollEntity: entity,
242 pollOptionEntity,
243 pollOptionName: "",
244 factID: v7(),
245 permission_set: props.entity_set,
246 });
247 await rep.mutate.addPollOption({
248 pollEntity: entity,
249 pollOptionEntity: v7(),
250 pollOptionName: "",
251 factID: v7(),
252 permission_set: props.entity_set,
253 });
254 usePollBlockUIState.setState((s) => ({ [entity]: { state: "editing" } }));
255 setTimeout(() => {
256 focusElement(
257 document.getElementById(
258 elementId.block(entity).pollInput(pollOptionEntity),
259 ) as HTMLInputElement | null,
260 );
261 }, 20);
262 um.add({
263 undo: () => {
264 props.entityID && focusTextBlock(props.entityID);
265 },
266 redo: () => {
267 setTimeout(() => {
268 focusElement(
269 document.getElementById(
270 elementId.block(entity).pollInput(pollOptionEntity),
271 ) as HTMLInputElement | null,
272 );
273 }, 20);
274 },
275 });
276 },
277 },
278 {
279 name: "Embed Website",
280 icon: <BlockEmbedSmall />,
281 type: "block",
282 onSelect: async (rep, props) => {
283 createBlockWithType(rep, props, "embed");
284 },
285 },
286 {
287 name: "Bluesky Post",
288 icon: <BlockBlueskySmall />,
289 type: "block",
290 onSelect: async (rep, props) => {
291 createBlockWithType(rep, props, "bluesky-post");
292 },
293 },
294 {
295 name: "Math",
296 icon: <BlockMathSmall />,
297 type: "block",
298 hiddenInPublication: false,
299 onSelect: async (rep, props) => {
300 createBlockWithType(rep, props, "math");
301 },
302 },
303 {
304 name: "Code",
305 icon: <BlockCodeSmall />,
306 type: "block",
307 hiddenInPublication: false,
308 onSelect: async (rep, props) => {
309 createBlockWithType(rep, props, "code");
310 },
311 },
312
313 // EVENT STUFF
314 {
315 name: "Date and Time",
316 icon: <BlockCalendarSmall />,
317 type: "event",
318 hiddenInPublication: true,
319 onSelect: (rep, props) => {
320 props.entityID && clearCommandSearchText(props.entityID);
321 return createBlockWithType(rep, props, "datetime");
322 },
323 },
324
325 // PAGE TYPES
326
327 {
328 name: "New Page",
329 icon: <BlockDocPageSmall />,
330 type: "page",
331 onSelect: async (rep, props, um) => {
332 props.entityID && clearCommandSearchText(props.entityID);
333 let entity = await createBlockWithType(rep, props, "card");
334
335 let newPage = v7();
336 await rep?.mutate.addPageLinkBlock({
337 blockEntity: entity,
338 firstBlockFactID: v7(),
339 firstBlockEntity: v7(),
340 pageEntity: newPage,
341 type: "doc",
342 permission_set: props.entity_set,
343 });
344
345 useUIState.getState().openPage(props.parent, newPage);
346 um.add({
347 undo: () => {
348 useUIState.getState().closePage(newPage);
349 setTimeout(
350 () =>
351 focusBlock(
352 { parent: props.parent, value: entity, type: "text" },
353 { type: "end" },
354 ),
355 100,
356 );
357 },
358 redo: () => {
359 useUIState.getState().openPage(props.parent, newPage);
360 focusPage(newPage, rep, "focusFirstBlock");
361 },
362 });
363 focusPage(newPage, rep, "focusFirstBlock");
364 },
365 },
366 {
367 name: "New Canvas",
368 icon: <BlockCanvasPageSmall />,
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 type: "canvas",
377 blockEntity: entity,
378 firstBlockFactID: v7(),
379 firstBlockEntity: v7(),
380 pageEntity: newPage,
381 permission_set: props.entity_set,
382 });
383 useUIState.getState().openPage(props.parent, newPage);
384 focusPage(newPage, rep, "focusFirstBlock");
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 },
403 },
404];
405
406async function setHeaderCommand(
407 level: number,
408 rep: Replicache<ReplicacheMutators>,
409 props: Props & { entity_set: string },
410) {
411 let entity = await createBlockWithType(rep, props, "heading");
412 await rep.mutate.assertFact({
413 entity,
414 attribute: "block/heading-level",
415 data: { type: "number", value: level },
416 });
417 clearCommandSearchText(entity);
418}
419function focusTextBlock(entityID: string) {
420 document.getElementById(elementId.block(entityID).text)?.focus();
421}