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}