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}