forked from
leaflet.pub/leaflet
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 } 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: "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: "Block Quote",
169 icon: <QuoteSmall />,
170 type: "text",
171 onSelect: async (rep, props, um) => {
172 if (props.entityID) clearCommandSearchText(props.entityID);
173 let entity = await createBlockWithType(rep, props, "blockquote");
174 clearCommandSearchText(entity);
175 },
176 },
177
178 {
179 name: "Image",
180 icon: <BlockImageSmall />,
181 type: "block",
182 onSelect: async (rep, props, um) => {
183 props.entityID && clearCommandSearchText(props.entityID);
184 let entity = await createBlockWithType(rep, props, "image");
185 setTimeout(() => {
186 let el = document.getElementById(elementId.block(entity).input);
187 el?.focus();
188 }, 100);
189 um.add({
190 undo: () => {
191 focusTextBlock(entity);
192 },
193 redo: () => {
194 let el = document.getElementById(elementId.block(entity).input);
195 el?.focus();
196 },
197 });
198 },
199 },
200 {
201 name: "External Link",
202 icon: <LinkSmall />,
203 type: "block",
204 onSelect: async (rep, props) => {
205 createBlockWithType(rep, props, "link");
206 },
207 },
208 {
209 name: "Button",
210 icon: <BlockButtonSmall />,
211 type: "block",
212 onSelect: async (rep, props, um) => {
213 props.entityID && clearCommandSearchText(props.entityID);
214 await createBlockWithType(rep, props, "button");
215 um.add({
216 undo: () => {
217 props.entityID && focusTextBlock(props.entityID);
218 },
219 redo: () => {},
220 });
221 },
222 },
223 {
224 name: "Horizontal Rule",
225 icon: "—",
226 type: "block",
227 onSelect: async (rep, props, um) => {
228 props.entityID && clearCommandSearchText(props.entityID);
229 await createBlockWithType(rep, props, "horizontal-rule");
230 um.add({
231 undo: () => {
232 props.entityID && focusTextBlock(props.entityID);
233 },
234 redo: () => {},
235 });
236 },
237 },
238 {
239 name: "Poll",
240 icon: <BlockPollSmall />,
241 type: "block",
242 onSelect: async (rep, props, um) => {
243 let entity = await createBlockWithType(rep, props, "poll");
244 let pollOptionEntity = v7();
245 await rep.mutate.addPollOption({
246 pollEntity: entity,
247 pollOptionEntity,
248 pollOptionName: "",
249 factID: v7(),
250 permission_set: props.entity_set,
251 });
252 await rep.mutate.addPollOption({
253 pollEntity: entity,
254 pollOptionEntity: v7(),
255 pollOptionName: "",
256 factID: v7(),
257 permission_set: props.entity_set,
258 });
259 usePollBlockUIState.setState((s) => ({ [entity]: { state: "editing" } }));
260 setTimeout(() => {
261 focusElement(
262 document.getElementById(
263 elementId.block(entity).pollInput(pollOptionEntity),
264 ) as HTMLInputElement | null,
265 );
266 }, 20);
267 um.add({
268 undo: () => {
269 props.entityID && focusTextBlock(props.entityID);
270 },
271 redo: () => {
272 setTimeout(() => {
273 focusElement(
274 document.getElementById(
275 elementId.block(entity).pollInput(pollOptionEntity),
276 ) as HTMLInputElement | null,
277 );
278 }, 20);
279 },
280 });
281 },
282 },
283 {
284 name: "Embed Website",
285 icon: <BlockEmbedSmall />,
286 type: "block",
287 onSelect: async (rep, props) => {
288 createBlockWithType(rep, props, "embed");
289 },
290 },
291 {
292 name: "Bluesky Post",
293 icon: <BlockBlueskySmall />,
294 type: "block",
295 onSelect: async (rep, props) => {
296 createBlockWithType(rep, props, "bluesky-post");
297 },
298 },
299 {
300 name: "Math",
301 icon: <BlockMathSmall />,
302 type: "block",
303 hiddenInPublication: false,
304 onSelect: async (rep, props) => {
305 createBlockWithType(rep, props, "math");
306 },
307 },
308 {
309 name: "Code",
310 icon: <BlockCodeSmall />,
311 type: "block",
312 hiddenInPublication: false,
313 onSelect: async (rep, props) => {
314 let entity = await createBlockWithType(rep, props, "code");
315 let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY);
316 if (lastLang) {
317 await rep.mutate.assertFact({
318 entity,
319 attribute: "block/code-language",
320 data: { type: "string", value: lastLang },
321 });
322 }
323 },
324 },
325
326 // EVENT STUFF
327 {
328 name: "Date and Time",
329 icon: <BlockCalendarSmall />,
330 type: "event",
331 hiddenInPublication: true,
332 onSelect: (rep, props) => {
333 props.entityID && clearCommandSearchText(props.entityID);
334 return createBlockWithType(rep, props, "datetime");
335 },
336 },
337
338 // PAGE TYPES
339
340 {
341 name: "New Page",
342 icon: <BlockDocPageSmall />,
343 type: "page",
344 onSelect: async (rep, props, um) => {
345 props.entityID && clearCommandSearchText(props.entityID);
346 let entity = await createBlockWithType(rep, props, "card");
347
348 let newPage = v7();
349 await rep?.mutate.addPageLinkBlock({
350 blockEntity: entity,
351 firstBlockFactID: v7(),
352 firstBlockEntity: v7(),
353 pageEntity: newPage,
354 type: "doc",
355 permission_set: props.entity_set,
356 });
357
358 useUIState.getState().openPage(props.parent, newPage);
359 um.add({
360 undo: () => {
361 useUIState.getState().closePage(newPage);
362 setTimeout(
363 () =>
364 focusBlock(
365 { parent: props.parent, value: entity, type: "text" },
366 { type: "end" },
367 ),
368 100,
369 );
370 },
371 redo: () => {
372 useUIState.getState().openPage(props.parent, newPage);
373 focusPage(newPage, rep, "focusFirstBlock");
374 },
375 });
376 focusPage(newPage, rep, "focusFirstBlock");
377 },
378 },
379 {
380 name: "New Canvas",
381 icon: <BlockCanvasPageSmall />,
382 type: "page",
383 onSelect: async (rep, props, um) => {
384 props.entityID && clearCommandSearchText(props.entityID);
385 let entity = await createBlockWithType(rep, props, "card");
386
387 let newPage = v7();
388 await rep?.mutate.addPageLinkBlock({
389 type: "canvas",
390 blockEntity: entity,
391 firstBlockFactID: v7(),
392 firstBlockEntity: v7(),
393 pageEntity: newPage,
394 permission_set: props.entity_set,
395 });
396 useUIState.getState().openPage(props.parent, newPage);
397 focusPage(newPage, rep, "focusFirstBlock");
398 um.add({
399 undo: () => {
400 useUIState.getState().closePage(newPage);
401 setTimeout(
402 () =>
403 focusBlock(
404 { parent: props.parent, value: entity, type: "text" },
405 { type: "end" },
406 ),
407 100,
408 );
409 },
410 redo: () => {
411 useUIState.getState().openPage(props.parent, newPage);
412 focusPage(newPage, rep, "focusFirstBlock");
413 },
414 });
415 },
416 },
417];
418
419async function setHeaderCommand(
420 level: number,
421 rep: Replicache<ReplicacheMutators>,
422 props: Props & { entity_set: string },
423) {
424 let entity = await createBlockWithType(rep, props, "heading");
425 await rep.mutate.assertFact({
426 entity,
427 attribute: "block/heading-level",
428 data: { type: "number", value: level },
429 });
430 clearCommandSearchText(entity);
431}
432function focusTextBlock(entityID: string) {
433 document.getElementById(elementId.block(entityID).text)?.focus();
434}