a tool for shared writing and social publishing
at feature/atp-canvas-blocks 423 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 "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 onSelect: async (rep, props, um) => { 334 props.entityID && clearCommandSearchText(props.entityID); 335 let entity = await createBlockWithType(rep, props, "card"); 336 337 let newPage = v7(); 338 await rep?.mutate.addPageLinkBlock({ 339 blockEntity: entity, 340 firstBlockFactID: v7(), 341 firstBlockEntity: v7(), 342 pageEntity: newPage, 343 type: "doc", 344 permission_set: props.entity_set, 345 }); 346 347 useUIState.getState().openPage(props.parent, newPage); 348 um.add({ 349 undo: () => { 350 useUIState.getState().closePage(newPage); 351 setTimeout( 352 () => 353 focusBlock( 354 { parent: props.parent, value: entity, type: "text" }, 355 { type: "end" }, 356 ), 357 100, 358 ); 359 }, 360 redo: () => { 361 useUIState.getState().openPage(props.parent, newPage); 362 focusPage(newPage, rep, "focusFirstBlock"); 363 }, 364 }); 365 focusPage(newPage, rep, "focusFirstBlock"); 366 }, 367 }, 368 { 369 name: "New Canvas", 370 icon: <BlockCanvasPageSmall />, 371 type: "page", 372 onSelect: async (rep, props, um) => { 373 props.entityID && clearCommandSearchText(props.entityID); 374 let entity = await createBlockWithType(rep, props, "card"); 375 376 let newPage = v7(); 377 await rep?.mutate.addPageLinkBlock({ 378 type: "canvas", 379 blockEntity: entity, 380 firstBlockFactID: v7(), 381 firstBlockEntity: v7(), 382 pageEntity: newPage, 383 permission_set: props.entity_set, 384 }); 385 useUIState.getState().openPage(props.parent, newPage); 386 focusPage(newPage, rep, "focusFirstBlock"); 387 um.add({ 388 undo: () => { 389 useUIState.getState().closePage(newPage); 390 setTimeout( 391 () => 392 focusBlock( 393 { parent: props.parent, value: entity, type: "text" }, 394 { type: "end" }, 395 ), 396 100, 397 ); 398 }, 399 redo: () => { 400 useUIState.getState().openPage(props.parent, newPage); 401 focusPage(newPage, rep, "focusFirstBlock"); 402 }, 403 }); 404 }, 405 }, 406]; 407 408async function setHeaderCommand( 409 level: number, 410 rep: Replicache<ReplicacheMutators>, 411 props: Props & { entity_set: string }, 412) { 413 let entity = await createBlockWithType(rep, props, "heading"); 414 await rep.mutate.assertFact({ 415 entity, 416 attribute: "block/heading-level", 417 data: { type: "number", value: level }, 418 }); 419 clearCommandSearchText(entity); 420} 421function focusTextBlock(entityID: string) { 422 document.getElementById(elementId.block(entityID).text)?.focus(); 423}