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