a tool for shared writing and social publishing
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Feature/block toolbar (#56)

* a shit ton of stuff

* removed delete buttons on non text blocks

* focus next block after deleting from are you sure modal

* added long press to select blocks, tweaked selection styling on non text blocks to trigger after long press is complete, rather than on mouse down

* made shift click equivalent to longpress

* cancel longpress on mouse move

* only cancel long press if mouse moves more than 16px

* add a multiselect toolbar and clean up block toolbar to match

* clean up selected/multiselect styling stuff

* on escape set focused block to null

* don't call a hook in not a hook silly

---------

Co-authored-by: Jared Pereira <jared@awarm.space>

authored by

celine
Jared Pereira
and committed by
GitHub
446add1a 8a4f2502

+696 -181
+22 -18
components/Blocks/CardBlock.tsx
··· 11 11 import { useUIState } from "src/useUIState"; 12 12 import { RenderedTextBlock } from "components/Blocks/TextBlock"; 13 13 import { useDocMetadata } from "src/hooks/queries/useDocMetadata"; 14 - import { CloseTiny, TrashSmall } from "components/Icons"; 15 14 import { CSSProperties, useEffect, useRef, useState } from "react"; 16 15 import { useEntitySetContext } from "components/EntitySetProvider"; 17 16 import { useBlocks } from "src/hooks/queries/useBlocks"; ··· 24 23 let docMetadata = useDocMetadata(cardEntity); 25 24 let permission = useEntitySetContext().permissions.write; 26 25 27 - let isSelected = useUIState( 28 - (s) => 29 - (props.type !== "text" || s.selectedBlock.length > 1) && 30 - s.selectedBlock.find((b) => b.value === props.entityID), 26 + let isSelected = useUIState((s) => 27 + s.selectedBlock.find((b) => b.value === props.entityID), 31 28 ); 29 + 32 30 let isOpen = useUIState((s) => s.openCards).includes(cardEntity); 33 31 34 32 let [areYouSure, setAreYouSure] = useState(false); 33 + 35 34 useEffect(() => { 36 35 if (!isSelected) { 37 36 setAreYouSure(false); ··· 58 57 focusBlock(props.previousBlock, { type: "end" }); 59 58 } 60 59 } 60 + if (e.key === "Escape" && permission && areYouSure) { 61 + setAreYouSure(false); 62 + focusBlock( 63 + { type: "card", value: props.entityID, parent: props.parent }, 64 + { type: "start" }, 65 + ); 66 + } 61 67 }; 62 68 window.addEventListener("keydown", listener); 63 69 return () => window.removeEventListener("keydown", listener); ··· 67 73 isSelected, 68 74 permission, 69 75 props.entityID, 76 + props.parent, 70 77 props.previousBlock, 71 78 rep, 72 79 ]); ··· 79 86 w-full h-[104px] 80 87 bg-bg-card border shadow-sm outline outline-1 rounded-lg 81 88 flex overflow-clip 82 - ${isSelected ? "border-tertiary outline-tertiary " : isOpen ? "border-tertiary outline-transparent hover:outline-tertiary" : "border-border-light outline-transparent hover:outline-border-light"} 89 + ${ 90 + isSelected 91 + ? "border-tertiary outline-tertiary" 92 + : isOpen 93 + ? "border-border outline-transparent hover:outline-border-light" 94 + : "border-border-light outline-transparent hover:outline-border-light" 95 + } 83 96 `} 84 97 onKeyDown={(e) => { 85 98 if (e.key === "Backspace" && permission) { ··· 102 115 <> 103 116 <div 104 117 className="cardBlockContent w-full flex overflow-clip cursor-pointer" 105 - onMouseDown={(e) => { 118 + onClick={(e) => { 119 + if (e.isDefaultPrevented()) return; 120 + if (e.shiftKey) return; 106 121 e.preventDefault(); 107 122 e.stopPropagation(); 108 123 useUIState.getState().openCard(props.parent, cardEntity); ··· 150 165 </div> 151 166 {props.renderPreview && <CardPreview entityID={cardEntity} />} 152 167 </div> 153 - {permission && ( 154 - <button 155 - className="absolute p-1 top-0.5 right-0.5 hover:text-accent-contrast text-secondary sm:hidden sm:group-hover/cardBlock:block z-10" 156 - onClick={(e) => { 157 - e.stopPropagation(); 158 - setAreYouSure(true); 159 - }} 160 - > 161 - <TrashSmall /> 162 - </button> 163 - )} 164 168 </> 165 169 )} 166 170 </div>
+96 -31
components/Blocks/DeleteBlock.tsx
··· 2 2 import { useEntity, useReplicache } from "src/replicache"; 3 3 import { useUIState } from "src/useUIState"; 4 4 import { focusBlock } from "."; 5 + import { ButtonPrimary } from "components/Buttons"; 6 + import { CloseTiny } from "components/Icons"; 7 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 8 + import { scanIndex } from "src/replicache/utils"; 5 9 6 10 export const AreYouSure = (props: { 7 - entityID: string; 11 + entityID: string[] | string; 8 12 onClick?: () => void; 9 13 closeAreYouSure: () => void; 14 + compact?: boolean; 10 15 }) => { 16 + let entities = [props.entityID].flat(); 11 17 let { rep } = useReplicache(); 12 - let card = useEntity(props.entityID, "block/card"); 18 + let focusedBlock = useUIState((s) => s.focusedBlock); 19 + let card = useEntity(focusedBlock?.entityID || null, "block/card"); 13 20 let cardID = card ? card.data.value : props.entityID; 14 - let type = useEntity(props.entityID, "block/type")?.data.value; 21 + let type = useEntity(focusedBlock?.entityID || null, "block/type")?.data 22 + .value; 15 23 16 24 return ( 17 - <div className="flex flex-col gap-1 w-full h-full place-items-center items-center font-bold py-4 bg-border-light"> 18 - <div className=""> 19 - Delete this{" "} 20 - {type === "card" ? <span>Page</span> : <span>Mailbox and Posts</span>}?{" "} 21 - </div> 22 - <div className="flex gap-2"> 23 - <button 24 - className="bg-accent-1 text-accent-2 px-2 py-1 rounded-md " 25 - onClick={(e) => { 26 - e.stopPropagation(); 27 - // This only handles the case where the literal delete button is clicked. 28 - // In cases where the backspace button is pressed, each block that uses the AreYouSure 29 - // has an event listener that handles the backspace key press. 30 - useUIState.getState().closeCard(cardID); 25 + <div 26 + className={`flex w-full h-full items-center justify-center ${!props.compact && "bg-border-light"}`} 27 + > 28 + <div 29 + className={`flex h-fit justify-center items-center font-bold text-secondary ${props.compact ? "flex-row gap-2 justify-between w-full " : "flex-col py-2 gap-1"}`} 30 + > 31 + <div className="text-center w-fit"> 32 + Delete{" "} 33 + {entities.length > 1 ? ( 34 + "Blocks" 35 + ) : type === "card" ? ( 36 + <span>Page</span> 37 + ) : type === "mailbox" ? ( 38 + <span>Mailbox and Posts</span> 39 + ) : ( 40 + <span>Block</span> 41 + )} 42 + ?{" "} 43 + </div> 44 + <div className="flex gap-2"> 45 + <ButtonPrimary 46 + autoFocus 47 + compact 48 + onClick={async (e) => { 49 + if (!focusedBlock || focusedBlock?.type === "card") return; 50 + e.stopPropagation(); 51 + // This only handles the case where the literal delete button is clicked. 52 + // In cases where the backspace button is pressed, each block that uses the AreYouSure 53 + // has an event listener that handles the backspace key press. 54 + 55 + useUIState.getState().closeCard(cardID); 56 + 57 + let siblings = 58 + (await rep?.query((tx) => 59 + getBlocksWithType(tx, focusedBlock?.parent) 60 + )) || []; 61 + 62 + let nextBlock = 63 + siblings?.[ 64 + siblings.findIndex((s) => s.value === focusedBlock.entityID) - 65 + 1 66 + ]; 31 67 32 - rep && 33 - rep.mutate.removeBlock({ 34 - blockEntity: props.entityID, 68 + if (nextBlock) { 69 + useUIState.getState().setSelectedBlock({ 70 + value: nextBlock.value, 71 + parent: nextBlock.parent, 72 + }); 73 + let nextBlockType = await rep?.query((tx) => 74 + scanIndex(tx).eav(nextBlock.value, "block/type") 75 + ); 76 + if ( 77 + nextBlockType?.[0]?.data.value === "text" || 78 + nextBlockType?.[0]?.data.value === "heading" 79 + ) { 80 + focusBlock( 81 + { 82 + value: nextBlock.value, 83 + type: "text", 84 + parent: nextBlock.parent, 85 + }, 86 + { type: "end" } 87 + ); 88 + } 89 + } 90 + props.closeAreYouSure(); 91 + entities.forEach((entity) => { 92 + rep?.mutate.removeBlock({ 93 + blockEntity: entity, 94 + }); 35 95 }); 36 96 37 - props.onClick && props.onClick(); 38 - }} 39 - > 40 - Delete 41 - </button> 42 - <button 43 - className="text-accent-1" 44 - onClick={() => props.closeAreYouSure()} 45 - > 46 - Nevermind 47 - </button> 97 + props.onClick && props.onClick(); 98 + }} 99 + > 100 + Delete 101 + </ButtonPrimary> 102 + <button 103 + className="text-accent-1" 104 + onClick={() => props.closeAreYouSure()} 105 + > 106 + {props.compact ? ( 107 + <CloseTiny className="mx-2 shrink-0" /> 108 + ) : ( 109 + "Nevermind" 110 + )} 111 + </button> 112 + </div> 48 113 </div> 49 114 </div> 50 115 );
+6 -26
components/Blocks/ExternalLinkBlock.tsx
··· 1 - import { useEntity, useReplicache } from "src/replicache"; 2 - import { CloseTiny, TrashSmall } from "components/Icons"; 3 - import { useEntitySetContext } from "components/EntitySetProvider"; 1 + import { useEntity } from "src/replicache"; 4 2 import { useUIState } from "src/useUIState"; 5 3 6 4 export const ExternalLinkBlock = (props: { entityID: string }) => { ··· 9 7 let description = useEntity(props.entityID, "link/description"); 10 8 let url = useEntity(props.entityID, "link/url"); 11 9 12 - let selected = useUIState((s) => 13 - s.selectedBlock.find((b) => b.value === props.entityID), 10 + let isSelected = useUIState((s) => 11 + s.selectedBlock.find((b) => b.value === props.entityID) 14 12 ); 15 - let permission = useEntitySetContext().permissions.write; 16 - let { rep } = useReplicache(); 17 13 18 14 return ( 19 15 <a ··· 22 18 className={` 23 19 externalLinkBlock flex relative group/linkBlock 24 20 h-[104px] w-full bg-bg-card overflow-hidden text-primary hover:no-underline no-underline 25 - border hover:border-accent-contrast outline outline-1 hover:outline-accent-contrast rounded-lg shadow-sm 26 - ${selected ? "outline-accent-contrast border-accent-contrast" : "outline-transparent border-border-light"} 21 + border hover:border-accent-contrast outline outline-1 -outline-offset-0 rounded-lg shadow-sm 22 + ${isSelected ? "outline-accent-contrast border-accent-contrast" : "outline-transparent border-border-light"} 27 23 `} 28 24 > 29 25 <div className="pt-2 pb-2 px-3 grow min-w-0"> ··· 41 37 </div> 42 38 <div 43 39 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 44 - className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${selected ? "text-accent-contrast" : "text-tertiary"}`} 40 + className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`} 45 41 > 46 42 {url?.data.value} 47 43 </div> ··· 55 51 backgroundPosition: "center", 56 52 }} 57 53 /> 58 - 59 - {permission && ( 60 - <button 61 - className="absolute p-1 top-0.5 right-0.5 hover:text-accent-contrast text-secondary sm:hidden sm:group-hover/linkBlock:block" 62 - onClick={(e) => { 63 - e.preventDefault(); 64 - e.stopPropagation(); 65 - rep && 66 - rep.mutate.removeBlock({ 67 - blockEntity: props.entityID, 68 - }); 69 - }} 70 - > 71 - <TrashSmall /> 72 - </button> 73 - )} 74 54 </a> 75 55 ); 76 56 };
+4 -27
components/Blocks/ImageBlock.tsx
··· 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 4 import { BlockProps } from "components/Blocks"; 5 5 import { useUIState } from "src/useUIState"; 6 - import { theme } from "tailwind.config"; 7 - import { CloseContrastSmall } from "components/Icons"; 8 - import useMeasure from "react-use-measure"; 9 - import { useEntitySetContext } from "components/EntitySetProvider"; 10 6 11 7 export function ImageBlock(props: BlockProps) { 12 - let { rep, permission_token } = useReplicache(); 13 - let entity_set = useEntitySetContext(); 14 - 15 - let permission = useEntitySetContext().permissions.write; 8 + let { rep } = useReplicache(); 16 9 let image = useEntity(props.entityID, "block/image"); 17 - let selected = useUIState( 18 - (s) => 19 - (props.type !== "text" || s.selectedBlock.length > 1) && 20 - s.selectedBlock.find((b) => b.value === props.entityID), 10 + let isSelected = useUIState((s) => 11 + s.selectedBlock.find((b) => b.value === props.entityID) 21 12 ); 22 13 23 14 return ( 24 15 <div className="relative group/image flex w-full justify-center"> 25 - {permission && ( 26 - <button 27 - className={`absolute right-2 top-2 z-10 ${selected ? "block" : "hidden group-hover/image:block"}`} 28 - onClick={() => { 29 - rep?.mutate.removeBlock({ blockEntity: props.entityID }); 30 - }} 31 - > 32 - <CloseContrastSmall 33 - fill={theme.colors.primary} 34 - stroke={theme.colors["bg-card"]} 35 - /> 36 - </button> 37 - )} 38 16 <img 39 - onClick={() => useUIState.getState().setSelectedBlock(props)} 40 17 alt={""} 41 18 src={ 42 19 image?.data.local && image.data.local !== rep?.clientID ··· 47 24 width={image?.data.width} 48 25 className={` 49 26 outline outline-1 border rounded-lg 50 - ${selected ? "border-tertiary outline-tertiary" : "border-transparent outline-transparent"}`} 27 + ${isSelected ? "border-tertiary outline-tertiary" : "border-transparent outline-transparent"}`} 51 28 /> 52 29 </div> 53 30 );
+2 -2
components/Blocks/MailboxBlock.tsx
··· 104 104 return ( 105 105 <div className={`mailboxContent relative w-full flex flex-col gap-1`}> 106 106 <div 107 - className={`flex flex-col gap-2 items-center justify-center w-full rounded-md border outline ${ 107 + className={`flex flex-col gap-2 items-center justify-center w-full rounded-lg border outline ${ 108 108 isSelected 109 - ? "border-border outline-border" 109 + ? "border-tertiary outline-tertiary" 110 110 : "border-border-light outline-transparent" 111 111 }`} 112 112 style={{
+29 -9
components/Blocks/index.tsx
··· 20 20 import { useBlockMouseHandlers } from "./useBlockMouseHandlers"; 21 21 import { indent, outdent } from "src/utils/list-operations"; 22 22 import { CheckboxChecked, CheckboxEmpty } from "components/Icons"; 23 + import { useLongPress } from "src/hooks/useLongPress"; 23 24 export type Block = { 24 25 factID: string; 25 26 parent: string; ··· 233 234 nextPosition: string | null; 234 235 } & Block; 235 236 236 - let textBlocks: { [k in Fact<"block/type">["data"]["value"]]?: boolean } = { 237 + export const textBlocks: { 238 + [k in Fact<"block/type">["data"]["value"]]?: boolean; 239 + } = { 237 240 text: true, 238 241 heading: true, 239 242 }; 240 243 241 244 function Block(props: BlockProps) { 242 245 let { rep } = useReplicache(); 246 + let mouseHandlers = useBlockMouseHandlers(props); 247 + 248 + let { isLongPress, handlers } = useLongPress(() => { 249 + if (isLongPress.current) { 250 + focusBlock( 251 + { type: "card", value: props.entityID, parent: props.parent }, 252 + { type: "start" }, 253 + ); 254 + } 255 + }, mouseHandlers.onMouseDown); 243 256 244 257 let first = props.previousBlock === null; 245 258 ··· 248 261 let actuallySelected = useUIState( 249 262 (s) => !!s.selectedBlock.find((b) => b.value === props.entityID), 250 263 ); 251 - let selected = 264 + let hasSelectionUI = 252 265 (!textBlocks[props.type] || selectedBlocks.length > 1) && actuallySelected; 253 266 254 267 let nextBlockSelected = useUIState((s) => ··· 260 273 261 274 let entity_set = useEntitySetContext(); 262 275 useEffect(() => { 263 - if (!selected || !rep) return; 276 + if (!hasSelectionUI || !rep) return; 264 277 let r = rep; 265 278 let listener = async (e: KeyboardEvent) => { 266 279 if (e.defaultPrevented) return; ··· 349 362 if (e.key === "Escape") { 350 363 e.preventDefault(); 351 364 useUIState.setState({ selectedBlock: [] }); 365 + useUIState.setState({ focusedBlock: null }); 352 366 } 353 367 }; 354 368 window.addEventListener("keydown", listener); 355 369 return () => window.removeEventListener("keydown", listener); 356 - }, [entity_set, selected, props, rep]); 357 - let mouseHandlers = useBlockMouseHandlers(props); 358 - 359 - let focusedElement = useUIState((s) => s.focusedBlock); 370 + }, [entity_set, hasSelectionUI, props, rep]); 360 371 361 372 return ( 362 - <div {...mouseHandlers} className="blockWrapper relative flex"> 363 - {selected && selectedBlocks.length > 1 && ( 373 + <div 374 + {...mouseHandlers} 375 + {...handlers} 376 + className="blockWrapper relative flex" 377 + > 378 + {hasSelectionUI && selectedBlocks.length > 1 && ( 364 379 <div 365 380 className={` 366 381 blockSelectionBG pointer-events-none bg-border-light ··· 526 541 ) { 527 542 if (block.type !== "text" && block.type !== "heading") { 528 543 useUIState.getState().setSelectedBlock(block); 544 + useUIState.getState().setFocusedBlock({ 545 + type: "block", 546 + entityID: block.value, 547 + parent: block.parent, 548 + }); 529 549 return true; 530 550 } 531 551 let nextBlockID = block.value;
+11 -3
components/Blocks/useBlockMouseHandlers.ts
··· 1 1 import { useSelectingMouse } from "components/SelectionManager"; 2 2 import { MouseEvent, useCallback, useRef } from "react"; 3 3 import { useUIState } from "src/useUIState"; 4 - import { Block } from "."; 4 + import { Block, textBlocks } from "."; 5 5 import { useEntitySetContext } from "components/EntitySetProvider"; 6 6 import { useReplicache } from "src/replicache"; 7 7 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; ··· 15 15 useSelectingMouse.setState({ start: props.value }); 16 16 if (e.shiftKey) { 17 17 if ( 18 - useUIState.getState().selectedBlock[0].value === props.value && 18 + useUIState.getState().selectedBlock[0]?.value === props.value && 19 19 useUIState.getState().selectedBlock.length === 1 20 20 ) 21 21 return; 22 22 e.preventDefault(); 23 23 useUIState.getState().addBlockToSelection(props); 24 - } else useUIState.getState().setSelectedBlock(props); 24 + } else { 25 + if (!textBlocks[props.type]) return; 26 + useUIState.getState().setFocusedBlock({ 27 + type: "block", 28 + entityID: props.value, 29 + parent: props.parent, 30 + }); 31 + useUIState.getState().setSelectedBlock(props); 32 + } 25 33 }, 26 34 [props], 27 35 );
+4 -2
components/Buttons.tsx
··· 4 4 export function ButtonPrimary( 5 5 props: { 6 6 children: React.ReactNode; 7 + compact?: boolean; 7 8 } & ButtonProps, 8 9 ) { 9 10 return ( 10 11 <button 11 12 {...props} 12 - className={`m-0 px-2 py-0.5 w-max h-max 13 - bg-accent-1 outline-offset-[-2px] active:outline active:outline-2 13 + className={`m-0 w-max h-max ${props.compact ? "py-0 px-1" : "px-2 py-0.5 "} 14 + bg-accent-1 outline-offset-[-2px] outline-transparent 14 15 border border-accent-1 rounded-md 15 16 text-base font-bold text-accent-2 16 17 flex gap-2 items-center justify-center shrink-0 18 + active:outline active:outline-2 17 19 disabled:border-border-light 18 20 disabled:bg-border-light disabled:text-border disabled:hover:text-border 19 21 ${props.className}
+1 -1
components/DesktopFooter.tsx
··· 18 18 focusedBlock.type === "block" && 19 19 focusedBlockParentID === props.cardID && ( 20 20 <div 21 - className="pointer-events-auto w-fit mx-auto py-1 px-3 bg-bg-card border border-border rounded-full shadow-sm" 21 + className="pointer-events-auto w-fit mx-auto py-1 px-3 h-9 bg-bg-card border border-border rounded-full shadow-sm" 22 22 onMouseDown={(e) => { 23 23 if (e.currentTarget === e.target) e.preventDefault(); 24 24 }}
+60
components/Icons.tsx
··· 551 551 ); 552 552 }; 553 553 554 + export const CopySmall = (props: Props) => { 555 + return ( 556 + <svg 557 + width="24" 558 + height="24" 559 + viewBox="0 0 24 24" 560 + fill="none" 561 + xmlns="http://www.w3.org/2000/svg" 562 + {...props} 563 + > 564 + <path 565 + fillRule="evenodd" 566 + clipRule="evenodd" 567 + d="M8.03036 3.85044C8.19103 2.68789 9.26372 1.87571 10.4263 2.03638L19.0781 3.23213C20.2406 3.39281 21.0528 4.4655 20.8921 5.62805L19.2931 17.1976C19.1324 18.3602 18.0598 19.1724 16.8972 19.0117L16.8768 19.0089L17.9581 11.1853C18.0399 10.5931 17.8827 9.99263 17.5212 9.51651L14.5554 5.61099C14.1951 5.13664 13.6615 4.82447 13.0715 4.74292L8.00381 4.04252L8.03036 3.85044ZM5.97171 6.80481C6.03787 6.32611 6.47957 5.99168 6.95827 6.05784L12.2718 6.79222C12.4984 6.82354 12.7038 6.94239 12.8439 7.12329L15.5846 10.6629C15.7287 10.849 15.7917 11.0853 15.7595 11.3184L14.5626 19.9785C14.4964 20.4572 14.0547 20.7916 13.576 20.7255L5.09311 19.5531C4.61441 19.4869 4.27998 19.0452 4.34614 18.5665L5.97171 6.80481ZM7.1294 4.81961C5.96685 4.65894 4.89416 5.47112 4.73348 6.63368L3.10791 18.3954C2.94724 19.5579 3.75942 20.6306 4.92198 20.7913L13.4049 21.9637C14.5675 22.1244 15.6402 21.3122 15.8008 20.1496L16.9977 11.4896C17.076 10.9234 16.9229 10.3496 16.5729 9.89767L13.8322 6.35801C13.492 5.91868 12.9933 5.63006 12.4429 5.55399L7.1294 4.81961ZM12.2721 8.02572C12.3193 7.6838 12.0805 7.3683 11.7385 7.32104C11.3966 7.27379 11.0811 7.51266 11.0339 7.85459L10.7715 9.75307C10.6297 10.7789 11.3463 11.7253 12.3721 11.8671L14.455 12.155C14.797 12.2022 15.1125 11.9634 15.1597 11.6214C15.207 11.2795 14.9681 10.964 14.6262 10.9168L12.5433 10.6289C12.2013 10.5816 11.9624 10.2661 12.0097 9.9242L12.2721 8.02572ZM5.99683 14.2097C6.05354 13.7994 6.43214 13.5128 6.84245 13.5695L10.0055 14.0066C10.4158 14.0633 10.7024 14.4419 10.6457 14.8523C10.589 15.2626 10.2104 15.5492 9.80011 15.4925L6.63709 15.0554C6.22678 14.9986 5.94012 14.62 5.99683 14.2097ZM6.47584 16.2222C6.06553 16.1655 5.68693 16.4521 5.63022 16.8624C5.57352 17.2728 5.86017 17.6514 6.27048 17.7081L12.8109 18.612C13.2212 18.6687 13.5998 18.3821 13.6565 17.9717C13.7132 17.5614 13.4266 17.1828 13.0163 17.1261L6.47584 16.2222Z" 568 + fill="currentColor" 569 + /> 570 + </svg> 571 + ); 572 + }; 573 + 554 574 export const ItalicSmall = (props: Props) => { 555 575 return ( 556 576 <svg ··· 826 846 fillRule="evenodd" 827 847 clipRule="evenodd" 828 848 d="M19.2777 7.26811C19.6649 6.79541 19.5956 6.09831 19.1229 5.7111C18.6502 5.32389 17.9531 5.3932 17.5658 5.86591L11.0608 13.8073L8.55229 11.4827C8.10409 11.0674 7.40406 11.094 6.98873 11.5422C6.57339 11.9904 6.60003 12.6905 7.04823 13.1058L10.4194 16.2298C10.6432 16.4372 10.9428 16.543 11.2472 16.5221C11.5517 16.5012 11.834 16.3554 12.0273 16.1194L19.2777 7.26811ZM5.72192 5.78943C4.61735 5.78943 3.72192 6.68486 3.72192 7.78943V17.2894C3.72192 18.394 4.61735 19.2894 5.72192 19.2894H15.2219C16.3265 19.2894 17.2219 18.394 17.2219 17.2894V14.4884C17.2219 14.0741 16.8861 13.7384 16.4719 13.7384C16.0577 13.7384 15.7219 14.0741 15.7219 14.4884V17.2894C15.7219 17.5656 15.4981 17.7894 15.2219 17.7894H5.72192C5.44578 17.7894 5.22192 17.5656 5.22192 17.2894V7.78943C5.22192 7.51329 5.44578 7.28943 5.72192 7.28943H12.9815C13.3957 7.28943 13.7315 6.95364 13.7315 6.53943C13.7315 6.12522 13.3957 5.78943 12.9815 5.78943H5.72192Z" 849 + fill="currentColor" 850 + /> 851 + </svg> 852 + ); 853 + }; 854 + 855 + export const MoveBlockDown = (props: Props) => { 856 + return ( 857 + <svg 858 + width="24" 859 + height="24" 860 + viewBox="0 0 24 24" 861 + fill="none" 862 + xmlns="http://www.w3.org/2000/svg" 863 + {...props} 864 + > 865 + <path 866 + fillRule="evenodd" 867 + clipRule="evenodd" 868 + d="M18.3444 3.56272L3.89705 5.84775C3.48792 5.91246 3.20871 6.29658 3.27342 6.7057L3.83176 10.2358C3.89647 10.645 4.28058 10.9242 4.68971 10.8595L19.137 8.57444C19.5462 8.50973 19.8254 8.12561 19.7607 7.71649L19.2023 4.18635C19.1376 3.77722 18.7535 3.49801 18.3444 3.56272ZM3.70177 4.61309C2.69864 4.77175 1.9884 5.65049 2.01462 6.63905C1.6067 6.92894 1.37517 7.43373 1.45854 7.96083L2.02167 11.5213C2.19423 12.6123 3.21854 13.3568 4.30955 13.1843L15.5014 11.4142L15.3472 10.4394L16.6131 10.2392L17.2948 13.9166L15.3038 12.4752C14.9683 12.2322 14.4994 12.3073 14.2565 12.6428C14.0135 12.9783 14.0886 13.4472 14.4241 13.6902L18.5417 16.6712L21.5228 12.5536C21.7658 12.2181 21.6907 11.7492 21.3552 11.5063C21.0197 11.2634 20.5508 11.3385 20.3079 11.674L18.7926 13.7669L18.0952 10.0048L19.3323 9.80909C20.4233 9.63654 21.1679 8.61222 20.9953 7.52121L20.437 3.99107C20.2644 2.90007 19.2401 2.15551 18.1491 2.32807L3.70177 4.61309ZM12.5175 14.1726C12.8583 14.118 13.0904 13.7974 13.0358 13.4566C12.9812 13.1157 12.6606 12.8837 12.3198 12.9383L4.48217 14.1937C3.37941 14.3704 2.62785 15.4065 2.80232 16.5096L3.35244 19.9878C3.52716 21.0925 4.56428 21.8463 5.66893 21.6716L20.0583 19.3958C21.1618 19.2212 21.9155 18.186 21.7426 17.0822L21.6508 16.4961C21.5974 16.1551 21.2776 15.922 20.9366 15.9754C20.5956 16.0288 20.3624 16.3486 20.4158 16.6896L20.5077 17.2757C20.5738 17.6981 20.2854 18.0943 19.8631 18.1611L5.47365 20.437C5.05089 20.5038 4.65396 20.2153 4.5871 19.7925L4.03697 16.3143C3.9702 15.8921 4.25783 15.4956 4.67988 15.428L12.5175 14.1726ZM5.48645 8.13141C5.4213 7.72235 5.70009 7.33793 6.10914 7.27278L12.7667 6.21241C13.1757 6.14726 13.5602 6.42605 13.6253 6.83511C13.6905 7.24417 13.4117 7.62859 13.0026 7.69374L6.34508 8.75411C5.93602 8.81926 5.5516 8.54047 5.48645 8.13141Z" 869 + fill="currentColor" 870 + /> 871 + </svg> 872 + ); 873 + }; 874 + 875 + export const MoveBlockUp = (props: Props) => { 876 + return ( 877 + <svg 878 + width="24" 879 + height="24" 880 + viewBox="0 0 24 24" 881 + fill="none" 882 + xmlns="http://www.w3.org/2000/svg" 883 + {...props} 884 + > 885 + <path 886 + fillRule="evenodd" 887 + clipRule="evenodd" 888 + d="M4.12086 10.3069C3.69777 10.3744 3.30016 10.0858 3.23323 9.66265L2.68364 6.18782C2.61677 5.76506 2.90529 5.36813 3.32805 5.30127L17.7149 3.0258C18.1378 2.95892 18.5348 3.24759 18.6015 3.67049L18.7835 4.82361C18.8373 5.16457 19.1573 5.39736 19.4983 5.34356C19.8392 5.28975 20.072 4.96974 20.0182 4.62878L19.8363 3.47566C19.6619 2.37067 18.6246 1.61639 17.5197 1.79115L3.13278 4.06661C2.02813 4.24133 1.27427 5.27845 1.44899 6.3831L1.99857 9.85793C2.17346 10.9637 3.21238 11.7177 4.31788 11.5413L11.5185 10.392C11.8594 10.3376 12.0916 10.0171 12.0372 9.67628C11.9828 9.33542 11.6624 9.1032 11.3215 9.15761L4.12086 10.3069ZM19.9004 11.6151L5.45305 13.9001C5.04392 13.9649 4.76471 14.349 4.82942 14.7581L5.38775 18.2882C5.45246 18.6974 5.83658 18.9766 6.24571 18.9119L20.6931 16.6268C21.1022 16.5621 21.3814 16.178 21.3167 15.7689L20.7583 12.2388C20.6936 11.8296 20.3095 11.5504 19.9004 11.6151ZM5.25777 12.6655C4.21806 12.8299 3.49299 13.7679 3.57645 14.8C3.17867 15.1511 2.9637 15.6918 3.05264 16.2541L3.57767 19.5737C3.75023 20.6647 4.77455 21.4093 5.86556 21.2367L19.9927 19.0023C20.7197 18.8873 21.2751 18.3524 21.4519 17.6846C22.2223 17.3097 22.6921 16.4638 22.5513 15.5736L21.993 12.0435C21.8204 10.9525 20.7961 10.2079 19.7051 10.3805L17.9019 10.6657L17.3957 7.46986L19.3483 8.96297C19.6773 9.21457 20.148 9.1518 20.3996 8.82276C20.6512 8.49373 20.5885 8.02302 20.2594 7.77141L16.2213 4.68355L13.1334 8.72172C12.8818 9.05076 12.9445 9.52146 13.2736 9.77307C13.6026 10.0247 14.0733 9.96191 14.3249 9.63287L15.8945 7.58034L16.4203 10.9L5.25777 12.6655ZM7.66514 15.3252C7.25609 15.3903 6.97729 15.7748 7.04245 16.1838C7.1076 16.5929 7.49202 16.8717 7.90108 16.8065L14.5586 15.7461C14.9677 15.681 15.2465 15.2966 15.1813 14.8875C15.1162 14.4785 14.7317 14.1997 14.3227 14.2648L7.66514 15.3252Z" 829 889 fill="currentColor" 830 890 /> 831 891 </svg>
+1 -1
components/MobileFooter.tsx
··· 10 10 let focusedBlock = useUIState((s) => s.focusedBlock); 11 11 12 12 return ( 13 - <Media mobile className="w-full z-10 -mt-6 touch-none"> 13 + <Media mobile className="mobileFooter w-full z-10 -mt-6 touch-none"> 14 14 {focusedBlock && focusedBlock.type == "block" ? ( 15 15 <div 16 16 className="w-full z-10 p-2 flex bg-bg-card "
+2 -10
components/SelectionManager.tsx
··· 15 15 import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 16 16 import { elementId } from "src/utils/elementId"; 17 17 import { scrollIntoViewIfNeeded } from "src/utils/scrollIntoViewIfNeeded"; 18 + import { copySelection } from "src/utils/copySelection"; 18 19 export const useSelectingMouse = create(() => ({ 19 20 start: null as null | string, 20 21 })); ··· 449 450 if (e.key === "c" && (e.metaKey || e.ctrlKey)) { 450 451 if (!rep) return; 451 452 let [sortedSelection] = await getSortedSelection(); 452 - let html = await getBlocksAsHTML(rep, sortedSelection); 453 - const data = [ 454 - new ClipboardItem({ 455 - ["text/html"]: new Blob([html.join("\n")], { type: "text/html" }), 456 - "text/plain": new Blob([htmlToMarkdown(html.join("\n"))], { 457 - type: "text/plain", 458 - }), 459 - }), 460 - ]; 461 - await navigator.clipboard.write(data); 453 + await copySelection(rep, sortedSelection); 462 454 } 463 455 }; 464 456 window.addEventListener("keydown", listener);
+217
components/Toolbar/BlockToolbar.tsx
··· 1 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 2 + import { ButtonPrimary } from "components/Buttons"; 3 + import { 4 + DeleteSmall, 5 + MoveBlockDown, 6 + MoveBlockUp, 7 + CloseTiny, 8 + } from "components/Icons"; 9 + import { useState } from "react"; 10 + import { useEntity, useReplicache } from "src/replicache"; 11 + import { ToolbarButton } from "."; 12 + import { Separator, ShortcutKey } from "components/Layout"; 13 + import { metaKey } from "src/utils/metaKey"; 14 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 15 + import { useUIState } from "src/useUIState"; 16 + 17 + export const BlockToolbar = () => { 18 + let { rep } = useReplicache(); 19 + let focusedBlock = useUIState((s) => s.focusedBlock); 20 + const [areYouSure, setAreYouSure] = useState(false); 21 + 22 + let type = useEntity(focusedBlock?.entityID || null, "block/type")?.data 23 + .value; 24 + 25 + const getSortedSelection = async () => { 26 + let selectedBlocks = useUIState.getState().selectedBlock; 27 + let siblings = 28 + (await rep?.query((tx) => 29 + getBlocksWithType(tx, selectedBlocks[0].parent), 30 + )) || []; 31 + let sortedBlocks = siblings.filter((s) => 32 + selectedBlocks.find((sb) => sb.value === s.value), 33 + ); 34 + return [sortedBlocks, siblings]; 35 + }; 36 + 37 + const handleClose = () => { 38 + useUIState.setState({ focusedBlock: null }); 39 + }; 40 + 41 + return ( 42 + <div className="flex items-center gap-2 justify-between w-full"> 43 + <div className="flex items-center gap-2"> 44 + <DeleteBlockButton 45 + blockID={focusedBlock?.entityID || ""} 46 + onAreYouSureChange={setAreYouSure} 47 + /> 48 + {!areYouSure && ( 49 + <> 50 + <Separator classname="h-5" /> 51 + <ToolbarButton 52 + onClick={async () => { 53 + let [sortedBlocks, siblings] = await getSortedSelection(); 54 + if (sortedBlocks.length > 1) return; 55 + let block = sortedBlocks[0]; 56 + let previousBlock = 57 + siblings?.[ 58 + siblings.findIndex((s) => s.value === block.value) - 1 59 + ]; 60 + if (previousBlock.value === block.listData?.parent) { 61 + previousBlock = 62 + siblings?.[ 63 + siblings.findIndex((s) => s.value === block.value) - 2 64 + ]; 65 + } 66 + 67 + if ( 68 + previousBlock?.listData && 69 + block.listData && 70 + block.listData.depth > 1 && 71 + !previousBlock.listData.path.find( 72 + (f) => f.entity === block.listData?.parent, 73 + ) 74 + ) { 75 + let depth = block.listData.depth; 76 + let newParent = previousBlock.listData.path.find( 77 + (f) => f.depth === depth - 1, 78 + ); 79 + if (!newParent) return; 80 + if ( 81 + useUIState 82 + .getState() 83 + .foldedBlocks.includes(newParent.entity) 84 + ) 85 + useUIState.getState().toggleFold(newParent.entity); 86 + rep?.mutate.moveBlock({ 87 + block: block.value, 88 + oldParent: block.listData?.parent, 89 + newParent: newParent.entity, 90 + position: { type: "end" }, 91 + }); 92 + } else { 93 + rep?.mutate.moveBlockUp({ 94 + entityID: block.value, 95 + parent: block.listData?.parent || block.parent, 96 + }); 97 + } 98 + }} 99 + tooltipContent={ 100 + <div className="flex flex-col gap-1 justify-center"> 101 + <div className="text-center">Move Up</div> 102 + <div className="flex gap-1"> 103 + <ShortcutKey>Shift</ShortcutKey> +{" "} 104 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 105 + <ShortcutKey> ↑ </ShortcutKey> 106 + </div> 107 + </div> 108 + } 109 + > 110 + <MoveBlockUp /> 111 + </ToolbarButton> 112 + 113 + <ToolbarButton 114 + onClick={async () => { 115 + let [sortedBlocks, siblings] = await getSortedSelection(); 116 + if (sortedBlocks.length > 1) return; 117 + let block = sortedBlocks[0]; 118 + let nextBlock = siblings 119 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 120 + .find( 121 + (f) => 122 + f.listData && 123 + block.listData && 124 + !f.listData.path.find((f) => f.entity === block.value), 125 + ); 126 + if ( 127 + nextBlock?.listData && 128 + block.listData && 129 + nextBlock.listData.depth === block.listData.depth - 1 130 + ) { 131 + if ( 132 + useUIState.getState().foldedBlocks.includes(nextBlock.value) 133 + ) 134 + useUIState.getState().toggleFold(nextBlock.value); 135 + rep?.mutate.moveBlock({ 136 + block: block.value, 137 + oldParent: block.listData?.parent, 138 + newParent: nextBlock.value, 139 + position: { type: "first" }, 140 + }); 141 + } else { 142 + rep?.mutate.moveBlockDown({ 143 + entityID: block.value, 144 + parent: block.listData?.parent || block.parent, 145 + }); 146 + } 147 + }} 148 + tooltipContent={ 149 + <div className="flex flex-col gap-1 justify-center"> 150 + <div className="text-center">Move Down</div> 151 + <div className="flex gap-1"> 152 + <ShortcutKey>Shift</ShortcutKey> +{" "} 153 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 154 + <ShortcutKey> ↓ </ShortcutKey> 155 + </div> 156 + </div> 157 + } 158 + > 159 + <MoveBlockDown /> 160 + </ToolbarButton> 161 + </> 162 + )} 163 + </div> 164 + {!areYouSure && ( 165 + <button 166 + className="toolbarBackToDefault hover:text-accent-contrast" 167 + onClick={handleClose} 168 + > 169 + <CloseTiny /> 170 + </button> 171 + )} 172 + </div> 173 + ); 174 + }; 175 + 176 + export const DeleteBlockButton = (props: { 177 + confirmDelete?: boolean; 178 + blockID: string; 179 + onAreYouSureChange: (value: boolean) => void; 180 + }) => { 181 + let [areYouSure, setAreYouSure] = useState(false); 182 + let { rep } = useReplicache(); 183 + 184 + const handleAreYouSureChange = (value: boolean) => { 185 + setAreYouSure(value); 186 + props.onAreYouSureChange(value); 187 + }; 188 + 189 + return ( 190 + <> 191 + {areYouSure ? ( 192 + <AreYouSure 193 + compact 194 + entityID={props.blockID} 195 + onClick={() => { 196 + rep && 197 + rep.mutate.removeBlock({ 198 + blockEntity: props.blockID, 199 + }); 200 + }} 201 + closeAreYouSure={() => { 202 + handleAreYouSureChange(false); 203 + }} 204 + /> 205 + ) : ( 206 + <button 207 + onMouseDown={() => { 208 + handleAreYouSureChange(true); 209 + }} 210 + className="-mx-[6px] py-0 px-2 font-bold flex gap-1 rounded-full border border-transparent hover:text-accent-2 hover:bg-accent-1 " 211 + > 212 + <DeleteSmall /> 213 + </button> 214 + )} 215 + </> 216 + ); 217 + };
-1
components/Toolbar/ListToolbar.tsx
··· 13 13 import { ToolbarButton } from "."; 14 14 import { indent, outdent } from "src/utils/list-operations"; 15 15 import { useEffect } from "react"; 16 - import { useEditorStates } from "src/state/useEditorState"; 17 16 18 17 export const ListButton = (props: { setToolbarState: (s: "list") => void }) => { 19 18 let focusedBlock = useUIState((s) => s.focusedBlock);
+93
components/Toolbar/MultiSelectToolbar.tsx
··· 1 + import React, { useState } from "react"; 2 + import { useUIState } from "src/useUIState"; 3 + import { ReplicacheMutators, useReplicache } from "src/replicache"; 4 + import { ToolbarButton } from "./index"; 5 + import { TrashSmall, CloseTiny, CopySmall } from "components/Icons"; 6 + import { AreYouSure } from "components/Blocks/DeleteBlock"; 7 + import { copySelection } from "src/utils/copySelection"; 8 + import { useSmoker } from "components/Toast"; 9 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 10 + import { Replicache } from "replicache"; 11 + 12 + export const MultiSelectToolbar = () => { 13 + const { rep } = useReplicache(); 14 + const selectedBlocks = useUIState((s) => s.selectedBlock || []); 15 + const [areYouSure, setAreYouSure] = useState(false); 16 + const smoker = useSmoker(); 17 + 18 + const handleDeleteBlocks = async () => { 19 + if (!rep) return; 20 + 21 + for (const blockID of selectedBlocks) { 22 + await rep.mutate.removeBlock({ blockEntity: blockID.value }); 23 + } 24 + 25 + useUIState.setState({ selectedBlock: [] }); 26 + setAreYouSure(false); 27 + }; 28 + 29 + const handleClose = () => { 30 + useUIState.setState({ selectedBlock: [] }); 31 + }; 32 + 33 + const handleCopy = async (event: React.MouseEvent) => { 34 + if (!rep) return; 35 + const sortedSelection = await getSortedSelection(rep); 36 + await copySelection(rep, sortedSelection); 37 + smoker({ 38 + position: { x: event.clientX, y: event.clientY }, 39 + text: "Copied to clipboard", 40 + }); 41 + }; 42 + 43 + return ( 44 + <div className="flex items-center gap-2 justify-between w-full"> 45 + <div className="flex items-center gap-2"> 46 + {areYouSure ? ( 47 + <AreYouSure 48 + compact 49 + entityID={selectedBlocks.map((b) => b.value)} 50 + onClick={handleDeleteBlocks} 51 + closeAreYouSure={() => setAreYouSure(false)} 52 + /> 53 + ) : ( 54 + <> 55 + <ToolbarButton 56 + tooltipContent="Delete selected blocks" 57 + onClick={() => setAreYouSure(true)} 58 + > 59 + <TrashSmall /> 60 + </ToolbarButton> 61 + <ToolbarButton 62 + tooltipContent="Copy selected blocks" 63 + onClick={handleCopy} 64 + > 65 + <CopySmall /> 66 + </ToolbarButton> 67 + </> 68 + )} 69 + {/* Add more multi-select toolbar buttons here */} 70 + </div> 71 + {!areYouSure && ( 72 + <button 73 + className="toolbarBackToDefault hover:text-accent-contrast" 74 + onClick={handleClose} 75 + > 76 + <CloseTiny /> 77 + </button> 78 + )} 79 + </div> 80 + ); 81 + }; 82 + 83 + // Helper function to get sorted selection 84 + async function getSortedSelection(rep: Replicache<ReplicacheMutators>) { 85 + const selectedBlocks = useUIState.getState().selectedBlock; 86 + const siblings = 87 + (await rep?.query((tx) => 88 + getBlocksWithType(tx, selectedBlocks[0].parent), 89 + )) || []; 90 + return siblings.filter((s) => 91 + selectedBlocks.find((sb) => sb.value === s.value), 92 + ); 93 + }
+2 -2
components/Toolbar/TextBlockTypeToolbar.tsx
··· 162 162 } 163 163 164 164 export function TextBlockTypeButton(props: { 165 - setToolbarState: (s: "header") => void; 165 + setToolbarState: (s: "heading") => void; 166 166 className?: string; 167 167 }) { 168 168 let focusedBlock = useUIState((s) => s.focusedBlock); ··· 172 172 className={`${props.className} w-8`} 173 173 active 174 174 onClick={() => { 175 - props.setToolbarState("header"); 175 + props.setToolbarState("heading"); 176 176 }} 177 177 > 178 178 <BlockTypeIcon entityID={focusedBlock?.entityID} />
+58 -47
components/Toolbar/index.tsx
··· 1 1 "use client"; 2 2 3 3 import React, { useEffect, useState } from "react"; 4 - import { 5 - BoldSmall, 6 - CloseTiny, 7 - ItalicSmall, 8 - StrikethroughSmall, 9 - HighlightSmall, 10 - PopoverArrow, 11 - ArrowRightTiny, 12 - } from "components/Icons"; 13 - import { schema } from "components/Blocks/TextBlock/schema"; 14 - import { TextDecorationButton } from "./TextDecorationButton"; 15 - import { 16 - keepFocus, 17 - TextBlockTypeButton, 18 - TextBlockTypeToolbar, 19 - } from "./TextBlockTypeToolbar"; 20 - import { LinkButton, InlineLinkToolbar } from "./InlineLinkToolbar"; 4 + import { CloseTiny, PopoverArrow } from "components/Icons"; 5 + import { keepFocus, TextBlockTypeToolbar } from "./TextBlockTypeToolbar"; 6 + import { InlineLinkToolbar } from "./InlineLinkToolbar"; 21 7 import { theme } from "../../tailwind.config"; 22 8 import { useEditorStates } from "src/state/useEditorState"; 23 9 import { useUIState } from "src/useUIState"; 24 - import { useReplicache } from "src/replicache"; 10 + import { useEntity, useReplicache } from "src/replicache"; 25 11 import * as Tooltip from "@radix-ui/react-tooltip"; 26 - import { Separator, ShortcutKey } from "components/Layout"; 27 - import { metaKey } from "src/utils/metaKey"; 28 - import { isMac } from "@react-aria/utils"; 29 12 import { addShortcut } from "src/shortcuts"; 30 - import { ListButton, ListToolbar } from "./ListToolbar"; 13 + import { ListToolbar } from "./ListToolbar"; 31 14 import { HighlightToolbar } from "./HighlightToolbar"; 32 15 import { TextToolbar } from "./TextToolbar"; 16 + import { BlockToolbar, DeleteBlockButton } from "./BlockToolbar"; 17 + import { MultiSelectToolbar } from "./MultiSelectToolbar"; 33 18 34 19 export type ToolbarTypes = 35 20 | "default" 36 21 | "highlight" 37 22 | "link" 38 - | "header" 23 + | "heading" 39 24 | "list" 40 - | "linkBlock"; 25 + | "linkBlock" 26 + | "block" 27 + | "multiSelect"; 41 28 42 29 export const Toolbar = (props: { cardID: string; blockID: string }) => { 43 - let { rep } = useReplicache(); 44 30 let focusedBlock = useUIState((s) => s.focusedBlock); 31 + 32 + let blockType = useEntity(props.blockID, "block/type")?.data.value; 45 33 46 34 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 47 35 ··· 52 40 }); 53 41 54 42 let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 43 + 44 + let selectedBlocks = useUIState((s) => s.selectedBlock); 55 45 56 46 useEffect(() => { 57 47 if (toolbarState !== "default") return; ··· 66 56 removeShortcut(); 67 57 }; 68 58 }, [toolbarState]); 59 + useEffect(() => { 60 + if (blockType !== "heading" && blockType !== "text") { 61 + setToolbarState("block"); 62 + } else { 63 + setToolbarState("default"); 64 + } 65 + }, [blockType]); 66 + 67 + useEffect(() => { 68 + if (selectedBlocks.length > 1) { 69 + setToolbarState("multiSelect"); 70 + } else if (toolbarState === "multiSelect") { 71 + setToolbarState("default"); 72 + } 73 + }, [selectedBlocks.length, toolbarState]); 69 74 70 75 return ( 71 76 <Tooltip.Provider> 72 - <div className="flex items-center justify-between w-full gap-6"> 73 - <div className="flex gap-[6px] items-center grow"> 77 + <div className="toolbar flex items-center justify-between w-full gap-6"> 78 + <div className="toolbarOptions flex gap-[6px] items-center grow"> 74 79 {toolbarState === "default" ? ( 75 80 <TextToolbar 76 81 lastUsedHighlight={lastUsedHighlight} ··· 95 100 setToolbarState("default"); 96 101 }} 97 102 /> 98 - ) : toolbarState === "header" ? ( 103 + ) : toolbarState === "heading" ? ( 99 104 <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 105 + ) : toolbarState === "block" ? ( 106 + <BlockToolbar /> 107 + ) : toolbarState === "multiSelect" ? ( 108 + <MultiSelectToolbar /> 100 109 ) : null} 101 110 </div> 102 - <button 103 - className="hover:text-accent-contrast" 104 - onClick={() => { 105 - if (toolbarState === "default") { 106 - useUIState.setState(() => ({ 107 - focusedBlock: { 108 - type: "card", 109 - entityID: props.cardID, 110 - }, 111 - selectedBlock: [], 112 - })); 113 - } else { 114 - setToolbarState("default"); 115 - focusedBlock && keepFocus(focusedBlock.entityID); 116 - } 117 - }} 118 - > 119 - <CloseTiny /> 120 - </button> 111 + {toolbarState !== "multiSelect" && toolbarState !== "block" && ( 112 + <button 113 + className="toolbarBackToDefault hover:text-accent-contrast" 114 + onClick={() => { 115 + if (toolbarState === "default") { 116 + useUIState.setState(() => ({ 117 + focusedBlock: { 118 + type: "card", 119 + entityID: props.cardID, 120 + }, 121 + selectedBlock: [], 122 + })); 123 + } else { 124 + setToolbarState("default"); 125 + focusedBlock && keepFocus(focusedBlock.entityID); 126 + } 127 + }} 128 + > 129 + <CloseTiny /> 130 + </button> 131 + )} 121 132 </div> 122 133 </Tooltip.Provider> 123 134 );
+69
src/hooks/useLongPress.ts
··· 1 + import { useRef, useEffect, useState } from "react"; 2 + 3 + export const useLongPress = ( 4 + cb: () => void, 5 + onMouseDown?: (e: React.MouseEvent) => void, 6 + cancel?: boolean, 7 + ) => { 8 + let longPressTimer = useRef<number>(); 9 + let isLongPress = useRef(false); 10 + // Change isDown to store the starting position 11 + let [startPosition, setStartPosition] = useState<{ x: number; y: number } | null>(null); 12 + 13 + let start = (e: React.MouseEvent) => { 14 + onMouseDown && onMouseDown(e); 15 + // Set the starting position 16 + setStartPosition({ x: e.clientX, y: e.clientY }); 17 + isLongPress.current = false; 18 + longPressTimer.current = window.setTimeout(() => { 19 + isLongPress.current = true; 20 + cb(); 21 + }, 500); 22 + }; 23 + 24 + useEffect(() => { 25 + if (startPosition) { 26 + let listener = (e: MouseEvent) => { 27 + // Calculate the distance moved 28 + const distance = Math.sqrt( 29 + Math.pow(e.clientX - startPosition.x, 2) + Math.pow(e.clientY - startPosition.y, 2) 30 + ); 31 + // Only end if the distance is greater than 10 pixels 32 + if (distance > 16) { 33 + end(); 34 + } 35 + }; 36 + window.addEventListener("mousemove", listener); 37 + return () => { 38 + window.removeEventListener("mousemove", listener); 39 + }; 40 + } 41 + }, [startPosition]); 42 + 43 + let end = () => { 44 + // Clear the starting position 45 + setStartPosition(null); 46 + window.clearTimeout(longPressTimer.current); 47 + longPressTimer.current = undefined; 48 + }; 49 + 50 + let click = (e: React.MouseEvent | React.PointerEvent) => { 51 + if (isLongPress.current) e.preventDefault(); 52 + if (e.shiftKey) e.preventDefault(); 53 + }; 54 + 55 + useEffect(() => { 56 + if (cancel) { 57 + end(); 58 + } 59 + }, [cancel]); 60 + 61 + return { 62 + isLongPress: isLongPress, 63 + handlers: { 64 + onMouseDown: start, 65 + onMouseUp: end, 66 + onClickCapture: click, 67 + }, 68 + }; 69 + };
+1 -1
src/replicache/index.tsx
··· 164 164 } 165 165 166 166 export function useReferenceToEntity< 167 - A extends keyof FilterAttributes<{ type: "reference" }>, 167 + A extends keyof FilterAttributes<{ type: "reference" | "ordered-reference" }>, 168 168 >(attribute: A, entity: string) { 169 169 let { rep, initialFacts } = useReplicache(); 170 170 let fallbackData = useMemo(
+18
src/utils/copySelection.ts
··· 1 + import { getBlocksAsHTML } from "src/utils/getBlocksAsHTML"; 2 + import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 3 + import { Replicache } from "replicache"; 4 + import { ReplicacheMutators } from "src/replicache"; 5 + import { Block } from "components/Blocks"; 6 + 7 + export async function copySelection(rep: Replicache<ReplicacheMutators>, sortedSelection: Block[]) { 8 + let html = await getBlocksAsHTML(rep, sortedSelection); 9 + const data = [ 10 + new ClipboardItem({ 11 + ["text/html"]: new Blob([html.join("\n")], { type: "text/html" }), 12 + "text/plain": new Blob([htmlToMarkdown(html.join("\n"))], { 13 + type: "text/plain", 14 + }), 15 + }), 16 + ]; 17 + await navigator.clipboard.write(data); 18 + }