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