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 alternateNames?: string[];
106 hiddenInPublication?: boolean;
107 onSelect: (
108 rep: Replicache<ReplicacheMutators>,
109 props: Props & { entity_set: string },
110 undoManager: UndoManager,
111 ) => Promise<any>;
112};
113export const blockCommands: Command[] = [
114 // please keep these in the order that they appear in the menu, grouped by type
115 {
116 name: "Text",
117 icon: <ParagraphSmall />,
118 type: "text",
119 onSelect: async (rep, props, um) => {
120 props.entityID && clearCommandSearchText(props.entityID);
121 let entity = await createBlockWithType(rep, props, "text");
122 clearCommandSearchText(entity);
123 },
124 },
125 {
126 name: "Title",
127 icon: <Header1Small />,
128 type: "text",
129 alternateNames: ["h1"],
130 onSelect: async (rep, props, um) => {
131 await setHeaderCommand(1, rep, props);
132 },
133 },
134 {
135 name: "Header",
136 icon: <Header2Small />,
137 type: "text",
138 alternateNames: ["h2"],
139 onSelect: async (rep, props, um) => {
140 await setHeaderCommand(2, rep, props);
141 },
142 },
143 {
144 name: "Subheader",
145 icon: <Header3Small />,
146 type: "text",
147 alternateNames: ["h3"],
148 onSelect: async (rep, props, um) => {
149 await setHeaderCommand(3, rep, props);
150 },
151 },
152 {
153 name: "List",
154 icon: <ListUnorderedSmall />,
155 type: "text",
156 onSelect: async (rep, props, um) => {
157 let entity = await createBlockWithType(rep, props, "text");
158 await rep?.mutate.assertFact({
159 entity,
160 attribute: "block/is-list",
161 data: { value: true, type: "boolean" },
162 });
163 clearCommandSearchText(entity);
164 },
165 },
166 {
167 name: "Block Quote",
168 icon: <QuoteSmall />,
169 type: "text",
170 onSelect: async (rep, props, um) => {
171 if (props.entityID) clearCommandSearchText(props.entityID);
172 let entity = await createBlockWithType(rep, props, "blockquote");
173 clearCommandSearchText(entity);
174 },
175 },
176
177 {
178 name: "Image",
179 icon: <BlockImageSmall />,
180 type: "block",
181 onSelect: async (rep, props, um) => {
182 props.entityID && clearCommandSearchText(props.entityID);
183 let entity = await createBlockWithType(rep, props, "image");
184 setTimeout(() => {
185 let el = document.getElementById(elementId.block(entity).input);
186 el?.focus();
187 }, 100);
188 um.add({
189 undo: () => {
190 focusTextBlock(entity);
191 },
192 redo: () => {
193 let el = document.getElementById(elementId.block(entity).input);
194 el?.focus();
195 },
196 });
197 },
198 },
199 {
200 name: "External Link",
201 icon: <LinkSmall />,
202 type: "block",
203 onSelect: async (rep, props) => {
204 createBlockWithType(rep, props, "link");
205 },
206 },
207 {
208 name: "Button",
209 icon: <BlockButtonSmall />,
210 type: "block",
211 onSelect: async (rep, props, um) => {
212 props.entityID && clearCommandSearchText(props.entityID);
213 await createBlockWithType(rep, props, "button");
214 um.add({
215 undo: () => {
216 props.entityID && focusTextBlock(props.entityID);
217 },
218 redo: () => {},
219 });
220 },
221 },
222 {
223 name: "Horizontal Rule",
224 icon: "—",
225 type: "block",
226 onSelect: async (rep, props, um) => {
227 props.entityID && clearCommandSearchText(props.entityID);
228 await createBlockWithType(rep, props, "horizontal-rule");
229 um.add({
230 undo: () => {
231 props.entityID && focusTextBlock(props.entityID);
232 },
233 redo: () => {},
234 });
235 },
236 },
237 {
238 name: "Poll",
239 icon: <BlockPollSmall />,
240 type: "block",
241 onSelect: async (rep, props, um) => {
242 let entity = await createBlockWithType(rep, props, "poll");
243 let pollOptionEntity = v7();
244 await rep.mutate.addPollOption({
245 pollEntity: entity,
246 pollOptionEntity,
247 pollOptionName: "",
248 factID: v7(),
249 permission_set: props.entity_set,
250 });
251 await rep.mutate.addPollOption({
252 pollEntity: entity,
253 pollOptionEntity: v7(),
254 pollOptionName: "",
255 factID: v7(),
256 permission_set: props.entity_set,
257 });
258 usePollBlockUIState.setState((s) => ({ [entity]: { state: "editing" } }));
259 setTimeout(() => {
260 focusElement(
261 document.getElementById(
262 elementId.block(entity).pollInput(pollOptionEntity),
263 ) as HTMLInputElement | null,
264 );
265 }, 20);
266 um.add({
267 undo: () => {
268 props.entityID && focusTextBlock(props.entityID);
269 },
270 redo: () => {
271 setTimeout(() => {
272 focusElement(
273 document.getElementById(
274 elementId.block(entity).pollInput(pollOptionEntity),
275 ) as HTMLInputElement | null,
276 );
277 }, 20);
278 },
279 });
280 },
281 },
282 {
283 name: "Embed Website",
284 icon: <BlockEmbedSmall />,
285 type: "block",
286 onSelect: async (rep, props) => {
287 createBlockWithType(rep, props, "embed");
288 },
289 },
290 {
291 name: "Bluesky Post",
292 icon: <BlockBlueskySmall />,
293 type: "block",
294 onSelect: async (rep, props) => {
295 createBlockWithType(rep, props, "bluesky-post");
296 },
297 },
298 {
299 name: "Math",
300 icon: <BlockMathSmall />,
301 type: "block",
302 hiddenInPublication: false,
303 onSelect: async (rep, props) => {
304 createBlockWithType(rep, props, "math");
305 },
306 },
307 {
308 name: "Code",
309 icon: <BlockCodeSmall />,
310 type: "block",
311 hiddenInPublication: false,
312 onSelect: async (rep, props) => {
313 createBlockWithType(rep, props, "code");
314 },
315 },
316
317 // EVENT STUFF
318 {
319 name: "Date and Time",
320 icon: <BlockCalendarSmall />,
321 type: "event",
322 hiddenInPublication: true,
323 onSelect: (rep, props) => {
324 props.entityID && clearCommandSearchText(props.entityID);
325 return createBlockWithType(rep, props, "datetime");
326 },
327 },
328
329 // PAGE TYPES
330
331 {
332 name: "New Page",
333 icon: <BlockDocPageSmall />,
334 type: "page",
335 onSelect: async (rep, props, um) => {
336 props.entityID && clearCommandSearchText(props.entityID);
337 let entity = await createBlockWithType(rep, props, "card");
338
339 let newPage = v7();
340 await rep?.mutate.addPageLinkBlock({
341 blockEntity: entity,
342 firstBlockFactID: v7(),
343 firstBlockEntity: v7(),
344 pageEntity: newPage,
345 type: "doc",
346 permission_set: props.entity_set,
347 });
348
349 useUIState.getState().openPage(props.parent, newPage);
350 um.add({
351 undo: () => {
352 useUIState.getState().closePage(newPage);
353 setTimeout(
354 () =>
355 focusBlock(
356 { parent: props.parent, value: entity, type: "text" },
357 { type: "end" },
358 ),
359 100,
360 );
361 },
362 redo: () => {
363 useUIState.getState().openPage(props.parent, newPage);
364 focusPage(newPage, rep, "focusFirstBlock");
365 },
366 });
367 focusPage(newPage, rep, "focusFirstBlock");
368 },
369 },
370 {
371 name: "New Canvas",
372 icon: <BlockCanvasPageSmall />,
373 type: "page",
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}