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