a tool for shared writing and social publishing
at refactor/page-perf 460 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 { 32 ListUnorderedSmall, 33 ListOrderedSmall, 34} from "components/Toolbar/ListToolbar"; 35import { BlockMathSmall } from "components/Icons/BlockMathSmall"; 36import { BlockCodeSmall } from "components/Icons/BlockCodeSmall"; 37import { QuoteSmall } from "components/Icons/QuoteSmall"; 38import { LAST_USED_CODE_LANGUAGE_KEY } from "src/utils/codeLanguageStorage"; 39 40type Props = { 41 parent: string; 42 entityID: string | null; 43 position: string | null; 44 nextPosition: string | null; 45 factID?: string | undefined; 46 first?: boolean; 47 className?: string; 48}; 49 50async function createBlockWithType( 51 rep: Replicache<ReplicacheMutators>, 52 args: { 53 entity_set: string; 54 parent: string; 55 position: string | null; 56 nextPosition: string | null; 57 entityID: string | null; 58 }, 59 type: Fact<"block/type">["data"]["value"], 60) { 61 let entity; 62 63 if (!args.entityID) { 64 entity = v7(); 65 await rep?.mutate.addBlock({ 66 parent: args.parent, 67 factID: v7(), 68 permission_set: args.entity_set, 69 type: type, 70 position: generateKeyBetween(args.position, args.nextPosition), 71 newEntityID: entity, 72 }); 73 } else { 74 entity = args.entityID; 75 await rep?.mutate.assertFact({ 76 entity, 77 attribute: "block/type", 78 data: { type: "block-type-union", value: type }, 79 }); 80 } 81 return entity; 82} 83 84function clearCommandSearchText(entityID: string) { 85 useEditorStates.setState((s) => { 86 let existingState = s.editorStates[entityID]; 87 if (!existingState) { 88 return s; 89 } 90 91 let tr = existingState.editor.tr; 92 tr.deleteRange(1, tr.doc.content.size - 1); 93 return { 94 editorStates: { 95 ...s.editorStates, 96 [entityID]: { 97 ...existingState, 98 editor: existingState.editor.apply(tr), 99 }, 100 }, 101 }; 102 }); 103} 104 105type Command = { 106 name: string; 107 icon: React.ReactNode; 108 type: string; 109 alternateNames?: string[]; 110 hiddenInPublication?: boolean; 111 onSelect: ( 112 rep: Replicache<ReplicacheMutators>, 113 props: Props & { entity_set: string }, 114 undoManager: UndoManager, 115 ) => Promise<any>; 116}; 117export const blockCommands: Command[] = [ 118 // please keep these in the order that they appear in the menu, grouped by type 119 { 120 name: "Text", 121 icon: <ParagraphSmall />, 122 type: "text", 123 onSelect: async (rep, props, um) => { 124 props.entityID && clearCommandSearchText(props.entityID); 125 let entity = await createBlockWithType(rep, props, "text"); 126 clearCommandSearchText(entity); 127 }, 128 }, 129 { 130 name: "Title", 131 icon: <Header1Small />, 132 type: "text", 133 alternateNames: ["h1"], 134 onSelect: async (rep, props, um) => { 135 await setHeaderCommand(1, rep, props); 136 }, 137 }, 138 { 139 name: "Header", 140 icon: <Header2Small />, 141 type: "text", 142 alternateNames: ["h2"], 143 onSelect: async (rep, props, um) => { 144 await setHeaderCommand(2, rep, props); 145 }, 146 }, 147 { 148 name: "Subheader", 149 icon: <Header3Small />, 150 type: "text", 151 alternateNames: ["h3"], 152 onSelect: async (rep, props, um) => { 153 await setHeaderCommand(3, rep, props); 154 }, 155 }, 156 { 157 name: "Unordered List", 158 icon: <ListUnorderedSmall />, 159 alternateNames: ["Bulleted List"], 160 type: "text", 161 onSelect: async (rep, props, um) => { 162 let entity = await createBlockWithType(rep, props, "text"); 163 await rep?.mutate.assertFact({ 164 entity, 165 attribute: "block/is-list", 166 data: { value: true, type: "boolean" }, 167 }); 168 clearCommandSearchText(entity); 169 }, 170 }, 171 { 172 name: "Ordered List", 173 icon: <ListOrderedSmall />, 174 type: "text", 175 alternateNames: ["Numbered List"], 176 onSelect: async (rep, props, um) => { 177 let entity = await createBlockWithType(rep, props, "text"); 178 await rep?.mutate.assertFact([ 179 { 180 entity, 181 attribute: "block/is-list", 182 data: { value: true, type: "boolean" }, 183 }, 184 { 185 entity, 186 attribute: "block/list-style", 187 data: { value: "ordered", type: "list-style-union" }, 188 }, 189 ]); 190 clearCommandSearchText(entity); 191 }, 192 }, 193 { 194 name: "Block Quote", 195 icon: <QuoteSmall />, 196 type: "text", 197 onSelect: async (rep, props, um) => { 198 if (props.entityID) clearCommandSearchText(props.entityID); 199 let entity = await createBlockWithType(rep, props, "blockquote"); 200 clearCommandSearchText(entity); 201 }, 202 }, 203 204 { 205 name: "Image", 206 icon: <BlockImageSmall />, 207 type: "block", 208 onSelect: async (rep, props, um) => { 209 props.entityID && clearCommandSearchText(props.entityID); 210 let entity = await createBlockWithType(rep, props, "image"); 211 setTimeout(() => { 212 let el = document.getElementById(elementId.block(entity).input); 213 el?.focus(); 214 }, 100); 215 um.add({ 216 undo: () => { 217 focusTextBlock(entity); 218 }, 219 redo: () => { 220 let el = document.getElementById(elementId.block(entity).input); 221 el?.focus(); 222 }, 223 }); 224 }, 225 }, 226 { 227 name: "External Link", 228 icon: <LinkSmall />, 229 type: "block", 230 onSelect: async (rep, props) => { 231 createBlockWithType(rep, props, "link"); 232 }, 233 }, 234 { 235 name: "Button", 236 icon: <BlockButtonSmall />, 237 type: "block", 238 onSelect: async (rep, props, um) => { 239 props.entityID && clearCommandSearchText(props.entityID); 240 await createBlockWithType(rep, props, "button"); 241 um.add({ 242 undo: () => { 243 props.entityID && focusTextBlock(props.entityID); 244 }, 245 redo: () => {}, 246 }); 247 }, 248 }, 249 { 250 name: "Horizontal Rule", 251 icon: "—", 252 type: "block", 253 onSelect: async (rep, props, um) => { 254 props.entityID && clearCommandSearchText(props.entityID); 255 await createBlockWithType(rep, props, "horizontal-rule"); 256 um.add({ 257 undo: () => { 258 props.entityID && focusTextBlock(props.entityID); 259 }, 260 redo: () => {}, 261 }); 262 }, 263 }, 264 { 265 name: "Poll", 266 icon: <BlockPollSmall />, 267 type: "block", 268 onSelect: async (rep, props, um) => { 269 let entity = await createBlockWithType(rep, props, "poll"); 270 let pollOptionEntity = v7(); 271 await rep.mutate.addPollOption({ 272 pollEntity: entity, 273 pollOptionEntity, 274 pollOptionName: "", 275 factID: v7(), 276 permission_set: props.entity_set, 277 }); 278 await rep.mutate.addPollOption({ 279 pollEntity: entity, 280 pollOptionEntity: v7(), 281 pollOptionName: "", 282 factID: v7(), 283 permission_set: props.entity_set, 284 }); 285 usePollBlockUIState.setState((s) => ({ [entity]: { state: "editing" } })); 286 setTimeout(() => { 287 focusElement( 288 document.getElementById( 289 elementId.block(entity).pollInput(pollOptionEntity), 290 ) as HTMLInputElement | null, 291 ); 292 }, 20); 293 um.add({ 294 undo: () => { 295 props.entityID && focusTextBlock(props.entityID); 296 }, 297 redo: () => { 298 setTimeout(() => { 299 focusElement( 300 document.getElementById( 301 elementId.block(entity).pollInput(pollOptionEntity), 302 ) as HTMLInputElement | null, 303 ); 304 }, 20); 305 }, 306 }); 307 }, 308 }, 309 { 310 name: "Embed Website", 311 icon: <BlockEmbedSmall />, 312 type: "block", 313 onSelect: async (rep, props) => { 314 createBlockWithType(rep, props, "embed"); 315 }, 316 }, 317 { 318 name: "Bluesky Post", 319 icon: <BlockBlueskySmall />, 320 type: "block", 321 onSelect: async (rep, props) => { 322 createBlockWithType(rep, props, "bluesky-post"); 323 }, 324 }, 325 { 326 name: "Math", 327 icon: <BlockMathSmall />, 328 type: "block", 329 hiddenInPublication: false, 330 onSelect: async (rep, props) => { 331 createBlockWithType(rep, props, "math"); 332 }, 333 }, 334 { 335 name: "Code", 336 icon: <BlockCodeSmall />, 337 type: "block", 338 hiddenInPublication: false, 339 onSelect: async (rep, props) => { 340 let entity = await createBlockWithType(rep, props, "code"); 341 let lastLang = localStorage.getItem(LAST_USED_CODE_LANGUAGE_KEY); 342 if (lastLang) { 343 await rep.mutate.assertFact({ 344 entity, 345 attribute: "block/code-language", 346 data: { type: "string", value: lastLang }, 347 }); 348 } 349 }, 350 }, 351 352 // EVENT STUFF 353 { 354 name: "Date and Time", 355 icon: <BlockCalendarSmall />, 356 type: "event", 357 hiddenInPublication: true, 358 onSelect: (rep, props) => { 359 props.entityID && clearCommandSearchText(props.entityID); 360 return createBlockWithType(rep, props, "datetime"); 361 }, 362 }, 363 364 // PAGE TYPES 365 366 { 367 name: "New Page", 368 icon: <BlockDocPageSmall />, 369 type: "page", 370 onSelect: async (rep, props, um) => { 371 props.entityID && clearCommandSearchText(props.entityID); 372 let entity = await createBlockWithType(rep, props, "card"); 373 374 let newPage = v7(); 375 await rep?.mutate.addPageLinkBlock({ 376 blockEntity: entity, 377 firstBlockFactID: v7(), 378 firstBlockEntity: v7(), 379 pageEntity: newPage, 380 type: "doc", 381 permission_set: props.entity_set, 382 }); 383 384 useUIState.getState().openPage(props.parent, newPage); 385 um.add({ 386 undo: () => { 387 useUIState.getState().closePage(newPage); 388 setTimeout( 389 () => 390 focusBlock( 391 { parent: props.parent, value: entity, type: "text" }, 392 { type: "end" }, 393 ), 394 100, 395 ); 396 }, 397 redo: () => { 398 useUIState.getState().openPage(props.parent, newPage); 399 focusPage(newPage, rep, "focusFirstBlock"); 400 }, 401 }); 402 focusPage(newPage, rep, "focusFirstBlock"); 403 }, 404 }, 405 { 406 name: "New Canvas", 407 icon: <BlockCanvasPageSmall />, 408 type: "page", 409 onSelect: async (rep, props, um) => { 410 props.entityID && clearCommandSearchText(props.entityID); 411 let entity = await createBlockWithType(rep, props, "card"); 412 413 let newPage = v7(); 414 await rep?.mutate.addPageLinkBlock({ 415 type: "canvas", 416 blockEntity: entity, 417 firstBlockFactID: v7(), 418 firstBlockEntity: v7(), 419 pageEntity: newPage, 420 permission_set: props.entity_set, 421 }); 422 useUIState.getState().openPage(props.parent, newPage); 423 focusPage(newPage, rep, "focusFirstBlock"); 424 um.add({ 425 undo: () => { 426 useUIState.getState().closePage(newPage); 427 setTimeout( 428 () => 429 focusBlock( 430 { parent: props.parent, value: entity, type: "text" }, 431 { type: "end" }, 432 ), 433 100, 434 ); 435 }, 436 redo: () => { 437 useUIState.getState().openPage(props.parent, newPage); 438 focusPage(newPage, rep, "focusFirstBlock"); 439 }, 440 }); 441 }, 442 }, 443]; 444 445async function setHeaderCommand( 446 level: number, 447 rep: Replicache<ReplicacheMutators>, 448 props: Props & { entity_set: string }, 449) { 450 let entity = await createBlockWithType(rep, props, "heading"); 451 await rep.mutate.assertFact({ 452 entity, 453 attribute: "block/heading-level", 454 data: { type: "number", value: level }, 455 }); 456 clearCommandSearchText(entity); 457} 458function focusTextBlock(entityID: string) { 459 document.getElementById(elementId.block(entityID).text)?.focus(); 460}