a tool for shared writing and social publishing

added move block up and down buttons to the block options buttons, refactored moveBlockUp/Down to be utility functions

+189 -154
+46 -8
components/Blocks/Block.tsx
··· 34 34 import { isTextBlock } from "src/utils/isTextBlock"; 35 35 import { focusPage } from "src/utils/focusPage"; 36 36 import { DeleteTiny } from "components/Icons/DeleteTiny"; 37 + import { ArrowDownTiny } from "components/Icons/ArrowDownTiny"; 38 + import { Separator } from "components/Layout"; 39 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 37 40 38 41 export type Block = { 39 42 factID: string; ··· 395 398 }) => { 396 399 // this is used to wrap non-text blocks in consistent selected styling, spacing, and top level options like delete 397 400 return ( 398 - <div className={`relative ${props.hasAlignment ? "w-fit" : "w-full"}`}> 401 + <div 402 + className={`nonTextBlockAndControls relative ${props.hasAlignment ? "w-fit" : "w-full"}`} 403 + > 399 404 <div 400 405 className={`nonTextBlock ${props.className} p-2 sm:p-3 overflow-hidden 401 406 ${props.hasAlignment ? "w-fit" : "w-full"} ··· 412 417 > 413 418 {props.children} 414 419 </div> 415 - {props.isSelected && <DeleteBlock />} 420 + {props.isSelected && <NonTextBlockOptions />} 416 421 </div> 417 422 ); 418 423 }; 419 424 420 - const DeleteBlock = () => { 425 + const NonTextBlockOptions = (props: { isCanvas?: boolean }) => { 426 + let { rep } = useReplicache(); 427 + let entity_set = useEntitySetContext(); 428 + let focusedEntity = useUIState((s) => s.focusedEntity); 429 + 430 + let focusedEntityType = useEntity( 431 + focusedEntity?.entityType === "page" 432 + ? focusedEntity.entityID 433 + : focusedEntity?.parent || null, 434 + "page/type", 435 + ); 436 + 437 + let isMultiselected = useUIState((s) => s.selectedBlocks.length > 1); 438 + 439 + if (isMultiselected) return; 440 + 421 441 return ( 422 - <div className="absolute -top-4 right-2"> 423 - <button 424 - className="bg-border border-2 border-bg-page rounded-full p-1 text-bg-page" 425 - onClick={() => {}} 426 - > 442 + <div className="flex gap-1 absolute -top-[25px] right-2 pb-0.5 pt-1 px-1 rounded-t-md bg-border text-bg-page"> 443 + {focusedEntityType?.data.value !== "canvas" && ( 444 + <> 445 + <button 446 + onClick={async () => { 447 + if (!rep) return; 448 + await moveBlockDown(rep, entity_set.set); 449 + }} 450 + > 451 + <ArrowDownTiny /> 452 + </button> 453 + <button 454 + onClick={async () => { 455 + if (!rep) return; 456 + await moveBlockUp(rep); 457 + }} 458 + > 459 + <ArrowDownTiny className="rotate-180" /> 460 + </button> 461 + <Separator classname="border-bg-page! h-4! mx-0.5" /> 462 + </> 463 + )} 464 + <button onClick={() => {}}> 427 465 <DeleteTiny /> 428 466 </button> 429 467 </div>
+1 -2
components/Blocks/ImageBlock.tsx
··· 90 90 > 91 91 <label 92 92 className={` 93 - 94 93 w-full h-full hover:cursor-pointer 95 94 flex flex-col items-center justify-center 96 - ${props.pageType === "canvas" && "bg-bg-page"}`} 95 + `} 97 96 onMouseDown={(e) => e.preventDefault()} 98 97 onDragOver={(e) => { 99 98 e.preventDefault();
+19
components/Icons/DeleteTiny.tsx
··· 1 + import { Props } from "./Props"; 2 + 3 + export const DeleteTiny = (props: Props) => { 4 + return ( 5 + <svg 6 + width="16" 7 + height="16" 8 + viewBox="0 0 16 16" 9 + fill="none" 10 + xmlns="http://www.w3.org/2000/svg" 11 + {...props} 12 + > 13 + <path 14 + d="M4.84013 0.946324C5.45222 0.762486 6.19573 0.729124 6.97782 0.820347C7.32039 0.860495 7.56546 1.1711 7.52568 1.51371C7.48568 1.85656 7.17517 2.10252 6.83232 2.06253C6.16005 1.98415 5.5998 2.02378 5.20048 2.14359C4.79524 2.26531 4.64378 2.43772 4.59599 2.56351C4.34149 3.23723 5.58865 4.19255 6.08232 4.51761C6.54562 4.82266 7.09489 5.10483 7.70536 5.33597C8.80491 5.75229 9.85164 5.90795 10.6409 5.84769C11.4848 5.78311 11.8008 5.50429 11.8714 5.31839C11.9189 5.19263 11.9202 4.96386 11.6976 4.60453C11.478 4.25029 11.0842 3.84861 10.5286 3.46195C10.2455 3.26479 10.1754 2.87508 10.3724 2.59183C10.5696 2.30876 10.9592 2.23946 11.2425 2.43656C11.8885 2.88612 12.4235 3.40237 12.7601 3.94535C13.0932 4.48275 13.2786 5.13116 13.0403 5.76078C12.9083 6.10935 12.6779 6.37566 12.3909 6.57621C12.366 6.82447 12.311 7.37448 12.2425 8.01957C12.1491 8.89816 12.0306 9.96291 11.9329 10.6856C11.809 11.602 11.638 12.5144 11.4153 13.4121C11.237 14.0305 10.6957 14.4725 10.0989 14.75C9.43935 15.0567 8.58506 15.2285 7.63212 15.2285C5.83513 15.2285 4.30017 14.7464 3.83818 13.3604C3.76287 13.0881 3.70695 12.8096 3.6497 12.5332C3.55128 12.058 3.42826 11.4025 3.33134 10.6856C3.23301 9.95806 3.11264 8.74578 3.01689 7.72562C2.96878 7.21315 2.92679 6.74483 2.89677 6.40531C2.87398 6.14754 2.83052 5.88349 2.86454 5.62503C2.95213 4.95974 3.29709 4.69093 3.98271 4.32035C3.46821 3.68093 3.13499 2.89278 3.42704 2.12113C3.66559 1.49131 4.23434 1.12831 4.84013 0.946324ZM4.26103 7.60843C4.35712 8.63219 4.47618 9.82018 4.5706 10.5186C4.66257 11.1989 4.7799 11.8245 4.87431 12.2803C4.92135 12.5074 4.9622 12.6916 4.9915 12.8184C5.24073 13.8967 6.74216 13.9785 7.63212 13.9785C8.44689 13.9785 9.11364 13.8297 9.57255 13.6162C9.98848 13.4227 10.1815 13.2132 10.2727 12.8184C10.3608 12.4373 10.5558 11.538 10.6936 10.5186C10.7887 9.81541 10.9059 8.76587 10.9993 7.88675C11.027 7.62595 11.0512 7.38071 11.0735 7.16507C10.278 7.59273 9.18805 7.88086 7.96318 7.88089C7.26324 7.88089 6.60019 7.80834 6.0081 7.67875C5.37465 7.54007 4.79262 7.29359 4.20732 7.02347C4.22408 7.20782 4.24189 7.4046 4.26103 7.60843ZM5.65849 9.89847C5.93369 9.87744 6.17434 10.0843 6.1956 10.3594C6.21921 10.6683 6.24342 10.9585 6.26396 11.1602C6.29655 11.4801 6.34417 11.7883 6.38407 12.0176C6.40539 12.1401 6.4278 12.2622 6.45341 12.3838C6.51991 12.6498 6.33262 12.9262 6.06962 12.9825C5.79972 13.0399 5.55695 12.8734 5.47587 12.5977C5.43009 12.442 5.42115 12.3183 5.39872 12.1895C5.3563 11.9458 5.30461 11.6127 5.26884 11.2618C5.24694 11.0467 5.22223 10.7457 5.19853 10.4356C5.17773 10.1606 5.38354 9.91976 5.65849 9.89847ZM5.59208 8.40238C6.25224 8.40238 6.25244 9.45121 5.59208 9.45121C4.93177 9.4506 4.93197 8.40299 5.59208 8.40238ZM9.06767 1.26566C9.29634 1.00742 9.69106 0.983602 9.9495 1.21195C10.2079 1.44072 10.2319 1.83535 10.0032 2.09378L9.137 3.07132L8.9622 3.54496C8.84186 3.86848 8.48103 4.03346 8.15751 3.91312C7.83456 3.79265 7.67047 3.43357 7.79032 3.11039L8.00614 2.52738C8.03292 2.45553 8.07353 2.38952 8.12431 2.33207L9.06767 1.26566Z" 15 + fill="currentColor" 16 + /> 17 + </svg> 18 + ); 19 + };
+5 -67
components/SelectionManager/index.tsx
··· 17 17 import { schema } from "../Blocks/TextBlock/schema"; 18 18 import { MarkType } from "prosemirror-model"; 19 19 import { useSelectingMouse, getSortedSelection } from "./selectionState"; 20 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 20 21 21 22 //How should I model selection? As ranges w/ a start and end? Store *blocks* so that I can just construct ranges? 22 23 // How does this relate to *when dragging* ? ··· 240 241 shift: true, 241 242 key: ["ArrowDown", "J"], 242 243 handler: async () => { 243 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 244 - let block = sortedBlocks[0]; 245 - let nextBlock = siblings 246 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 247 - .find( 248 - (f) => 249 - f.listData && 250 - block.listData && 251 - !f.listData.path.find((f) => f.entity === block.value), 252 - ); 253 - if ( 254 - nextBlock?.listData && 255 - block.listData && 256 - nextBlock.listData.depth === block.listData.depth - 1 257 - ) { 258 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 259 - useUIState.getState().toggleFold(nextBlock.value); 260 - await rep?.mutate.moveBlock({ 261 - block: block.value, 262 - oldParent: block.listData?.parent, 263 - newParent: nextBlock.value, 264 - position: { type: "first" }, 265 - }); 266 - } else { 267 - await rep?.mutate.moveBlockDown({ 268 - entityID: block.value, 269 - parent: block.listData?.parent || block.parent, 270 - }); 271 - } 244 + if (!rep) return; 245 + await moveBlockDown(rep, entity_set.set); 272 246 }, 273 247 }, 274 248 { ··· 276 250 shift: true, 277 251 key: ["ArrowUp", "K"], 278 252 handler: async () => { 279 - let [sortedBlocks, siblings] = await getSortedSelectionBound(); 280 - let block = sortedBlocks[0]; 281 - let previousBlock = 282 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 283 - if (previousBlock.value === block.listData?.parent) { 284 - previousBlock = 285 - siblings?.[ 286 - siblings.findIndex((s) => s.value === block.value) - 2 287 - ]; 288 - } 289 - 290 - if ( 291 - previousBlock?.listData && 292 - block.listData && 293 - block.listData.depth > 1 && 294 - !previousBlock.listData.path.find( 295 - (f) => f.entity === block.listData?.parent, 296 - ) 297 - ) { 298 - let depth = block.listData.depth; 299 - let newParent = previousBlock.listData.path.find( 300 - (f) => f.depth === depth - 1, 301 - ); 302 - if (!newParent) return; 303 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 304 - useUIState.getState().toggleFold(newParent.entity); 305 - rep?.mutate.moveBlock({ 306 - block: block.value, 307 - oldParent: block.listData?.parent, 308 - newParent: newParent.entity, 309 - position: { type: "end" }, 310 - }); 311 - } else { 312 - rep?.mutate.moveBlockUp({ 313 - entityID: block.value, 314 - parent: block.listData?.parent || block.parent, 315 - }); 316 - } 253 + if (!rep) return; 254 + await moveBlockUp(rep); 317 255 }, 318 256 }, 319 257
+5 -70
components/Toolbar/BlockToolbar.tsx
··· 11 11 ImageCoverButton, 12 12 } from "./ImageToolbar"; 13 13 import { DeleteSmall } from "components/Icons/DeleteSmall"; 14 - import { getSortedSelection } from "components/SelectionManager/selectionState"; 14 + import { moveBlockUp, moveBlockDown } from "src/utils/moveBlock"; 15 + import { useEntitySetContext } from "components/EntitySetProvider"; 15 16 16 17 export const BlockToolbar = (props: { 17 18 setToolbarState: ( ··· 71 72 72 73 const MoveBlockButtons = () => { 73 74 let { rep } = useReplicache(); 75 + let entity_set = useEntitySetContext(); 74 76 return ( 75 77 <> 76 78 <ToolbarButton 77 79 hiddenOnCanvas 78 80 onClick={async () => { 79 81 if (!rep) return; 80 - let [sortedBlocks, siblings] = await getSortedSelection(rep); 81 - if (sortedBlocks.length > 1) return; 82 - let block = sortedBlocks[0]; 83 - let previousBlock = 84 - siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 85 - if (previousBlock.value === block.listData?.parent) { 86 - previousBlock = 87 - siblings?.[ 88 - siblings.findIndex((s) => s.value === block.value) - 2 89 - ]; 90 - } 91 - 92 - if ( 93 - previousBlock?.listData && 94 - block.listData && 95 - block.listData.depth > 1 && 96 - !previousBlock.listData.path.find( 97 - (f) => f.entity === block.listData?.parent, 98 - ) 99 - ) { 100 - let depth = block.listData.depth; 101 - let newParent = previousBlock.listData.path.find( 102 - (f) => f.depth === depth - 1, 103 - ); 104 - if (!newParent) return; 105 - if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 106 - useUIState.getState().toggleFold(newParent.entity); 107 - rep?.mutate.moveBlock({ 108 - block: block.value, 109 - oldParent: block.listData?.parent, 110 - newParent: newParent.entity, 111 - position: { type: "end" }, 112 - }); 113 - } else { 114 - rep?.mutate.moveBlockUp({ 115 - entityID: block.value, 116 - parent: block.listData?.parent || block.parent, 117 - }); 118 - } 82 + await moveBlockUp(rep); 119 83 }} 120 84 tooltipContent={ 121 85 <div className="flex flex-col gap-1 justify-center"> ··· 135 99 hiddenOnCanvas 136 100 onClick={async () => { 137 101 if (!rep) return; 138 - let [sortedBlocks, siblings] = await getSortedSelection(rep); 139 - if (sortedBlocks.length > 1) return; 140 - let block = sortedBlocks[0]; 141 - let nextBlock = siblings 142 - .slice(siblings.findIndex((s) => s.value === block.value) + 1) 143 - .find( 144 - (f) => 145 - f.listData && 146 - block.listData && 147 - !f.listData.path.find((f) => f.entity === block.value), 148 - ); 149 - if ( 150 - nextBlock?.listData && 151 - block.listData && 152 - nextBlock.listData.depth === block.listData.depth - 1 153 - ) { 154 - if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 155 - useUIState.getState().toggleFold(nextBlock.value); 156 - rep?.mutate.moveBlock({ 157 - block: block.value, 158 - oldParent: block.listData?.parent, 159 - newParent: nextBlock.value, 160 - position: { type: "first" }, 161 - }); 162 - } else { 163 - rep?.mutate.moveBlockDown({ 164 - entityID: block.value, 165 - parent: block.listData?.parent || block.parent, 166 - }); 167 - } 102 + await moveBlockDown(rep, entity_set.set); 168 103 }} 169 104 tooltipContent={ 170 105 <div className="flex flex-col gap-1 justify-center">
+32 -7
src/replicache/mutations.ts
··· 4 4 import { SupabaseClient } from "@supabase/supabase-js"; 5 5 import { Database } from "supabase/database.types"; 6 6 import { generateKeyBetween } from "fractional-indexing"; 7 + import { v7 } from "uuid"; 7 8 8 9 export type MutationContext = { 9 10 permission_token_id: string; ··· 427 428 }, 428 429 }); 429 430 }; 430 - const moveBlockDown: Mutation<{ entityID: string; parent: string }> = async ( 431 - args, 432 - ctx, 433 - ) => { 431 + const moveBlockDown: Mutation<{ 432 + entityID: string; 433 + parent: string; 434 + permission_set?: string; 435 + }> = async (args, ctx) => { 434 436 let children = (await ctx.scanIndex.eav(args.parent, "card/block")).toSorted( 435 437 (a, b) => (a.data.position > b.data.position ? 1 : -1), 436 438 ); 437 439 let index = children.findIndex((f) => f.data.value === args.entityID); 438 440 if (index === -1) return; 439 441 let next = children[index + 1]; 440 - if (!next) return; 442 + if (!next) { 443 + // If this is the last block, create a new empty block above it using the addBlock helper 444 + if (!args.permission_set) return; // Can't create block without permission_set 445 + 446 + let newEntityID = v7(); 447 + let previousBlock = children[index - 1]; 448 + let position = generateKeyBetween( 449 + previousBlock?.data.position || null, 450 + children[index].data.position, 451 + ); 452 + 453 + // Call the addBlock mutation helper directly 454 + await addBlock( 455 + { 456 + parent: args.parent, 457 + permission_set: args.permission_set, 458 + factID: v7(), 459 + type: "text", 460 + newEntityID: newEntityID, 461 + position: position, 462 + }, 463 + ctx, 464 + ); 465 + return; 466 + } 441 467 await ctx.retractFact(children[index].id); 442 468 await ctx.assertFact({ 443 469 id: children[index].id, ··· 670 696 } 671 697 }); 672 698 await ctx.runOnClient(async ({ tx }) => { 673 - if (args.title !== undefined) 674 - await tx.set("publication_title", args.title); 699 + if (args.title !== undefined) await tx.set("publication_title", args.title); 675 700 if (args.description !== undefined) 676 701 await tx.set("publication_description", args.description); 677 702 if (args.tags !== undefined) await tx.set("publication_tags", args.tags);
+81
src/utils/moveBlock.ts
··· 1 + import { Replicache } from "replicache"; 2 + import { ReplicacheMutators } from "src/replicache"; 3 + import { getSortedSelection } from "components/SelectionManager/selectionState"; 4 + import { useUIState } from "src/useUIState"; 5 + 6 + export const moveBlockUp = async (rep: Replicache<ReplicacheMutators>) => { 7 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 8 + if (sortedBlocks.length > 1) return; 9 + let block = sortedBlocks[0]; 10 + let previousBlock = 11 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 1]; 12 + if (previousBlock.value === block.listData?.parent) { 13 + previousBlock = 14 + siblings?.[siblings.findIndex((s) => s.value === block.value) - 2]; 15 + } 16 + 17 + if ( 18 + previousBlock?.listData && 19 + block.listData && 20 + block.listData.depth > 1 && 21 + !previousBlock.listData.path.find( 22 + (f) => f.entity === block.listData?.parent, 23 + ) 24 + ) { 25 + let depth = block.listData.depth; 26 + let newParent = previousBlock.listData.path.find( 27 + (f) => f.depth === depth - 1, 28 + ); 29 + if (!newParent) return; 30 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 31 + useUIState.getState().toggleFold(newParent.entity); 32 + rep?.mutate.moveBlock({ 33 + block: block.value, 34 + oldParent: block.listData?.parent, 35 + newParent: newParent.entity, 36 + position: { type: "end" }, 37 + }); 38 + } else { 39 + rep?.mutate.moveBlockUp({ 40 + entityID: block.value, 41 + parent: block.listData?.parent || block.parent, 42 + }); 43 + } 44 + }; 45 + 46 + export const moveBlockDown = async ( 47 + rep: Replicache<ReplicacheMutators>, 48 + permission_set: string, 49 + ) => { 50 + let [sortedBlocks, siblings] = await getSortedSelection(rep); 51 + if (sortedBlocks.length > 1) return; 52 + let block = sortedBlocks[0]; 53 + let nextBlock = siblings 54 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 55 + .find( 56 + (f) => 57 + f.listData && 58 + block.listData && 59 + !f.listData.path.find((f) => f.entity === block.value), 60 + ); 61 + if ( 62 + nextBlock?.listData && 63 + block.listData && 64 + nextBlock.listData.depth === block.listData.depth - 1 65 + ) { 66 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 67 + useUIState.getState().toggleFold(nextBlock.value); 68 + rep?.mutate.moveBlock({ 69 + block: block.value, 70 + oldParent: block.listData?.parent, 71 + newParent: nextBlock.value, 72 + position: { type: "first" }, 73 + }); 74 + } else { 75 + rep?.mutate.moveBlockDown({ 76 + entityID: block.value, 77 + parent: block.listData?.parent || block.parent, 78 + permission_set: permission_set, 79 + }); 80 + } 81 + };