a tool for shared writing and social publishing
at feature/atp-polls 422 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 onSelect: async (rep, props, um) => { 239 let entity = await createBlockWithType(rep, props, "poll"); 240 let pollOptionEntity = v7(); 241 await rep.mutate.addPollOption({ 242 pollEntity: entity, 243 pollOptionEntity, 244 pollOptionName: "", 245 factID: v7(), 246 permission_set: props.entity_set, 247 }); 248 await rep.mutate.addPollOption({ 249 pollEntity: entity, 250 pollOptionEntity: v7(), 251 pollOptionName: "", 252 factID: v7(), 253 permission_set: props.entity_set, 254 }); 255 usePollBlockUIState.setState((s) => ({ [entity]: { state: "editing" } })); 256 setTimeout(() => { 257 focusElement( 258 document.getElementById( 259 elementId.block(entity).pollInput(pollOptionEntity), 260 ) as HTMLInputElement | null, 261 ); 262 }, 20); 263 um.add({ 264 undo: () => { 265 props.entityID && focusTextBlock(props.entityID); 266 }, 267 redo: () => { 268 setTimeout(() => { 269 focusElement( 270 document.getElementById( 271 elementId.block(entity).pollInput(pollOptionEntity), 272 ) as HTMLInputElement | null, 273 ); 274 }, 20); 275 }, 276 }); 277 }, 278 }, 279 { 280 name: "Embed Website", 281 icon: <BlockEmbedSmall />, 282 type: "block", 283 onSelect: async (rep, props) => { 284 createBlockWithType(rep, props, "embed"); 285 }, 286 }, 287 { 288 name: "Bluesky Post", 289 icon: <BlockBlueskySmall />, 290 type: "block", 291 onSelect: async (rep, props) => { 292 createBlockWithType(rep, props, "bluesky-post"); 293 }, 294 }, 295 { 296 name: "Math", 297 icon: <BlockMathSmall />, 298 type: "block", 299 hiddenInPublication: false, 300 onSelect: async (rep, props) => { 301 createBlockWithType(rep, props, "math"); 302 }, 303 }, 304 { 305 name: "Code", 306 icon: <BlockCodeSmall />, 307 type: "block", 308 hiddenInPublication: false, 309 onSelect: async (rep, props) => { 310 createBlockWithType(rep, props, "code"); 311 }, 312 }, 313 314 // EVENT STUFF 315 { 316 name: "Date and Time", 317 icon: <BlockCalendarSmall />, 318 type: "event", 319 hiddenInPublication: true, 320 onSelect: (rep, props) => { 321 props.entityID && clearCommandSearchText(props.entityID); 322 return createBlockWithType(rep, props, "datetime"); 323 }, 324 }, 325 326 // PAGE TYPES 327 328 { 329 name: "New Page", 330 icon: <BlockDocPageSmall />, 331 type: "page", 332 onSelect: async (rep, props, um) => { 333 props.entityID && clearCommandSearchText(props.entityID); 334 let entity = await createBlockWithType(rep, props, "card"); 335 336 let newPage = v7(); 337 await rep?.mutate.addPageLinkBlock({ 338 blockEntity: entity, 339 firstBlockFactID: v7(), 340 firstBlockEntity: v7(), 341 pageEntity: newPage, 342 type: "doc", 343 permission_set: props.entity_set, 344 }); 345 346 useUIState.getState().openPage(props.parent, newPage); 347 um.add({ 348 undo: () => { 349 useUIState.getState().closePage(newPage); 350 setTimeout( 351 () => 352 focusBlock( 353 { parent: props.parent, value: entity, type: "text" }, 354 { type: "end" }, 355 ), 356 100, 357 ); 358 }, 359 redo: () => { 360 useUIState.getState().openPage(props.parent, newPage); 361 focusPage(newPage, rep, "focusFirstBlock"); 362 }, 363 }); 364 focusPage(newPage, rep, "focusFirstBlock"); 365 }, 366 }, 367 { 368 name: "New Canvas", 369 icon: <BlockCanvasPageSmall />, 370 type: "page", 371 onSelect: async (rep, props, um) => { 372 props.entityID && clearCommandSearchText(props.entityID); 373 let entity = await createBlockWithType(rep, props, "card"); 374 375 let newPage = v7(); 376 await rep?.mutate.addPageLinkBlock({ 377 type: "canvas", 378 blockEntity: entity, 379 firstBlockFactID: v7(), 380 firstBlockEntity: v7(), 381 pageEntity: newPage, 382 permission_set: props.entity_set, 383 }); 384 useUIState.getState().openPage(props.parent, newPage); 385 focusPage(newPage, rep, "focusFirstBlock"); 386 um.add({ 387 undo: () => { 388 useUIState.getState().closePage(newPage); 389 setTimeout( 390 () => 391 focusBlock( 392 { parent: props.parent, value: entity, type: "text" }, 393 { type: "end" }, 394 ), 395 100, 396 ); 397 }, 398 redo: () => { 399 useUIState.getState().openPage(props.parent, newPage); 400 focusPage(newPage, rep, "focusFirstBlock"); 401 }, 402 }); 403 }, 404 }, 405]; 406 407async function setHeaderCommand( 408 level: number, 409 rep: Replicache<ReplicacheMutators>, 410 props: Props & { entity_set: string }, 411) { 412 let entity = await createBlockWithType(rep, props, "heading"); 413 await rep.mutate.assertFact({ 414 entity, 415 attribute: "block/heading-level", 416 data: { type: "number", value: level }, 417 }); 418 clearCommandSearchText(entity); 419} 420function focusTextBlock(entityID: string) { 421 document.getElementById(elementId.block(entityID).text)?.focus(); 422}