a tool for shared writing and social publishing
at main 434 lines 12 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 } 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}