a tool for shared writing and social publishing
0
fork

Configure Feed

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

Virtual branch (#58)

* Added most basic collection block

* moved focusBlock() to src/utils

* renamed textBlocks[type] to isTextBlock[type]

* moved block and baseblock into separate file, moved isTextBlock to a separate util file

Had to alias the type Block in index to avoid some conflicts, but probably gonna rename that type soon too

* removed some unused imports in index, moved Heading to separate component

* removed an unnecessary const in index

* Moved ListMarker from Blocks to Block

* moved some BlockList clickable empty bottom area into a separate component

* moved Block type to Block file, used Block type rather than BlockProps where possible

* move block keyboard handling to another file, split out each key to separate function for ease of viewing

* pulled deleteBlock out into new function, tweaked some incorrect logic about where to focus once block is deleted

* moved areYouSure state to the top of the block component, moved keyabord handleing for are you sure block to BlockKeyboardHandlers, changed block toolbar to use deleteBlock, simplified deleteBlock

* cleaned the block file, added some annotations, minor streamlining

* moved areyousure state after the list marker, and styles the state

* moved around styling for blocks to simplify structure, small tweaks to are you sure in home, renamed preview prop to be more consistant everywhere

* removed some unused imports

* renamed function in useUIState to from selectedBlock => selectedBlocks , and fixed some type errors with are you sure

* renamed focusBlock => focusEntity, renamed parameter inside focusEntity from type =>entityType to avoid confusion

* Moeved areyousure state in toolbar to shared top level

* standardized the casing of multiselect

* removed the border around the are you sure when its in the toolbar

* added zindex to list marker to make sure it stays visible above the multiselection bg

* changed delete block function to focus next block on delete even if multiselected

* fix selected blocks name

* remove collection blocks for now!

---------

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

authored by

celine
Jared Pereira
and committed by
GitHub
c0964d34 9200c4b7

+1190 -1087
+1 -1
app/globals.css
··· 149 149 background-color: transparent; 150 150 } 151 151 152 - .Multiple-Selected:focus-within .selection-highlight { 152 + .multiselected:focus-within .selection-highlight { 153 153 background-color: transparent; 154 154 }
+57 -53
app/home/DocPreview.tsx
··· 13 13 import { removeDocFromHome } from "./storage"; 14 14 import { mutate } from "swr"; 15 15 import useMeasure from "react-use-measure"; 16 + import { ButtonPrimary } from "components/Buttons"; 16 17 17 18 export const DocPreview = (props: { 18 19 token: PermissionToken; ··· 22 23 return ( 23 24 <div className="relative h-40"> 24 25 <ThemeProvider local entityID={props.doc_id}> 25 - {state === "normal" ? ( 26 - <Link 27 - href={"/" + props.token.id} 28 - className={`no-underline hover:no-underline text-primary h-full`} 29 - > 30 - <div className="rounded-lg hover:shadow-sm overflow-clip border border-border outline outline-transparent hover:outline-border bg-bg-page grow w-full h-full"> 26 + <div className="rounded-lg hover:shadow-sm overflow-clip border border-border outline outline-transparent hover:outline-border bg-bg-page grow w-full h-full"> 27 + {state === "normal" ? ( 28 + <Link 29 + href={"/" + props.token.id} 30 + className={`no-underline hover:no-underline text-primary h-full`} 31 + > 31 32 <ThemeBackgroundProvider entityID={props.doc_id}> 32 - <div className="docPreview grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end"> 33 + <div className="docPreview grow shrink-0 h-full w-full px-2 pt-2 sm:px-3 sm:pt-3 flex items-end pointer-events-none"> 33 34 <div 34 - className="docContentWrapper w-full h-full max-w-48 mx-auto border border-border-light border-b-0 rounded-t-md px-1 pt-1 sm:px-[6px] sm:pt-2 overflow-clip" 35 + className="docContentWrapper w-full h-full max-w-48 mx-auto border border-border-light border-b-0 rounded-t-md overflow-clip" 35 36 style={{ 36 37 backgroundColor: 37 38 "rgba(var(--bg-card), var(--bg-card-alpha))", ··· 41 42 </div> 42 43 </div> 43 44 </ThemeBackgroundProvider> 44 - </div> 45 - </Link> 46 - ) : ( 47 - <div className="docPreview grow shrink-0 w-full flex items-end"> 48 - <div 49 - className="docContentWrapper w-full h-full border border-border-light border-b-0 rounded-lg px-1 pt-1 sm:px-[6px] sm:pt-2 overflow-clip flex flex-col gap-2 place-items-center justify-center items-center " 50 - style={{ 51 - backgroundColor: "rgba(var(--bg-card), var(--bg-card-alpha))", 52 - }} 53 - > 54 - <div className="font-bold text-lg text-center"> 55 - Permanently delete this doc? 56 - </div> 57 - <div className="flex gap-2"> 58 - <button 59 - className="bg-accent-1 text-accent-2 px-2 py-1 rounded-md " 60 - onMouseDown={(e) => { 61 - e.stopPropagation(); 62 - e.preventDefault(); 63 - deleteDoc(props.token); 64 - removeDocFromHome(props.token); 65 - mutate("docs"); 66 - }} 67 - > 68 - Delete 69 - </button> 70 - <button 71 - className="text-accent-1" 72 - onMouseDown={(e) => { 73 - e.stopPropagation(); 74 - e.preventDefault(); 75 - setState("normal"); 76 - }} 77 - > 78 - Nevermind 79 - </button> 80 - </div> 81 - </div> 82 - </div> 83 - )} 84 - {state === "normal" && ( 85 - <div className="flex justify-end pt-1"> 86 - <DocOptions doc={props.token} setState={setState} /> 87 - </div> 88 - )} 45 + </Link> 46 + ) : ( 47 + <DocAreYouSure token={props.token} setState={setState} /> 48 + )} 49 + </div> 50 + <div className="flex justify-end pt-1"> 51 + <DocOptions doc={props.token} setState={setState} /> 52 + </div> 89 53 </ThemeProvider> 90 54 </div> 91 55 ); ··· 126 90 </div> 127 91 ); 128 92 }; 93 + 94 + const DocAreYouSure = (props: { 95 + token: PermissionToken; 96 + setState: (s: "normal" | "deleting") => void; 97 + }) => { 98 + return ( 99 + <div 100 + className="docContentWrapper w-full h-full px-1 pt-1 sm:px-[6px] sm:pt-2 flex flex-col gap-2 justify-center items-center " 101 + style={{ 102 + backgroundColor: "rgba(var(--bg-card), var(--bg-card-alpha))", 103 + }} 104 + > 105 + <div className="font-bold text-center">Permanently delete this doc?</div> 106 + <div className="flex gap-2 font-bold "> 107 + <ButtonPrimary 108 + compact 109 + onMouseDown={(e) => { 110 + e.stopPropagation(); 111 + e.preventDefault(); 112 + deleteDoc(props.token); 113 + removeDocFromHome(props.token); 114 + mutate("docs"); 115 + }} 116 + > 117 + Delete 118 + </ButtonPrimary> 119 + <button 120 + className="text-accent-1" 121 + onMouseDown={(e) => { 122 + e.stopPropagation(); 123 + e.preventDefault(); 124 + props.setState("normal"); 125 + }} 126 + > 127 + Nevermind 128 + </button> 129 + </div> 130 + </div> 131 + ); 132 + };
+250
components/Blocks/Block.tsx
··· 1 + "use client"; 2 + 3 + import { Fact, useEntity, useReplicache } from "src/replicache"; 4 + import { useEffect, useState } from "react"; 5 + import { useUIState } from "src/useUIState"; 6 + import { useBlockMouseHandlers } from "./useBlockMouseHandlers"; 7 + import { useBlockKeyboardHandlers } from "./useBlockKeyboardHandlers"; 8 + import { useLongPress } from "src/hooks/useLongPress"; 9 + import { focusBlock } from "src/utils/focusBlock"; 10 + 11 + import { TextBlock } from "components/Blocks/TextBlock"; 12 + import { ImageBlock } from "./ImageBlock"; 13 + import { CardBlock } from "./CardBlock"; 14 + import { ExternalLinkBlock } from "./ExternalLinkBlock"; 15 + import { MailboxBlock } from "./MailboxBlock"; 16 + import { HeadingBlock } from "./HeadingBlock"; 17 + import { CheckboxChecked, CheckboxEmpty } from "components/Icons"; 18 + import { AreYouSure } from "./DeleteBlock"; 19 + 20 + export type Block = { 21 + factID: string; 22 + parent: string; 23 + position: string; 24 + value: string; 25 + type: Fact<"block/type">["data"]["value"]; 26 + listData?: { 27 + checklist?: boolean; 28 + path: { depth: number; entity: string }[]; 29 + parent: string; 30 + depth: number; 31 + }; 32 + }; 33 + export type BlockProps = { 34 + entityID: string; 35 + parent: string; 36 + position: string; 37 + nextBlock: Block | null; 38 + previousBlock: Block | null; 39 + nextPosition: string | null; 40 + } & Block; 41 + 42 + export function Block(props: BlockProps) { 43 + // Block handles all block level events like 44 + // mouse events, keyboard events and longPress, and setting AreYouSure state 45 + // and shared styling like padding and flex for list layouting 46 + 47 + let mouseHandlers = useBlockMouseHandlers(props); 48 + 49 + // focus block on longpress, shouldnt the type be based on the block type (?) 50 + let { isLongPress, handlers } = useLongPress(() => { 51 + if (isLongPress.current) { 52 + focusBlock( 53 + { type: "card", value: props.entityID, parent: props.parent }, 54 + { type: "start" }, 55 + ); 56 + } 57 + }, mouseHandlers.onMouseDown); 58 + 59 + let selected = useUIState( 60 + (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 61 + ); 62 + 63 + let [areYouSure, setAreYouSure] = useState(false); 64 + 65 + useEffect(() => { 66 + if (!selected) { 67 + setAreYouSure(false); 68 + } 69 + }, [selected]); 70 + 71 + useBlockKeyboardHandlers(props, areYouSure, setAreYouSure); 72 + 73 + return ( 74 + <div 75 + {...mouseHandlers} 76 + {...handlers} 77 + className={` 78 + blockWrapper relative 79 + flex flex-row gap-2 80 + px-3 sm:px-4 81 + ${ 82 + props.type === "heading" || 83 + (props.listData && props.nextBlock?.listData) 84 + ? "pb-0" 85 + : "pb-2" 86 + } 87 + ${ 88 + !props.previousBlock 89 + ? props.type === "heading" || props.type === "text" 90 + ? "pt-2 sm:pt-3" 91 + : "pt-3 sm:pt-4" 92 + : "pt-1" 93 + }`} 94 + > 95 + <BlockMultiselectIndicator {...props} /> 96 + <BaseBlock 97 + {...props} 98 + areYouSure={areYouSure} 99 + setAreYouSure={(value) => setAreYouSure(value)} 100 + /> 101 + </div> 102 + ); 103 + } 104 + 105 + export const BaseBlock = ( 106 + props: BlockProps & { 107 + preview?: boolean; 108 + areYouSure?: boolean; 109 + setAreYouSure?: (value: boolean) => void; 110 + }, 111 + ) => { 112 + // BaseBlock renders the actual block content 113 + return ( 114 + <div className="grow flex gap-2"> 115 + {props.listData && <ListMarker {...props} />} 116 + {props.areYouSure ? ( 117 + <AreYouSure 118 + closeAreYouSure={() => 119 + props.setAreYouSure && props.setAreYouSure(false) 120 + } 121 + type={props.type} 122 + entityID={props.entityID} 123 + /> 124 + ) : ( 125 + <> 126 + {props.type === "card" ? ( 127 + <CardBlock {...props} preview={!props.preview} /> 128 + ) : props.type === "text" ? ( 129 + <TextBlock {...props} className="" preview={props.preview} /> 130 + ) : props.type === "heading" ? ( 131 + <HeadingBlock {...props} preview={props.preview} /> 132 + ) : props.type === "image" ? ( 133 + <ImageBlock {...props} /> 134 + ) : props.type === "link" ? ( 135 + <ExternalLinkBlock {...props} /> 136 + ) : props.type === "mailbox" ? ( 137 + <div className="flex flex-col gap-4 w-full"> 138 + <MailboxBlock {...props} /> 139 + </div> 140 + ) : null} 141 + </> 142 + )} 143 + </div> 144 + ); 145 + }; 146 + 147 + export const BlockMultiselectIndicator = (props: BlockProps) => { 148 + let first = props.previousBlock === null; 149 + 150 + let selectedBlocks = useUIState((s) => s.selectedBlocks); 151 + 152 + let selected = useUIState( 153 + (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 154 + ); 155 + 156 + let isMultiselected = selected && selectedBlocks.length > 1; 157 + 158 + let nextBlockSelected = useUIState((s) => 159 + s.selectedBlocks.find((b) => b.value === props.nextBlock?.value), 160 + ); 161 + let prevBlockSelected = useUIState((s) => 162 + s.selectedBlocks.find((b) => b.value === props.previousBlock?.value), 163 + ); 164 + 165 + if (isMultiselected) 166 + // not sure what multiselected and selected is doing (?) 167 + return ( 168 + <div 169 + className={` 170 + blockSelectionBG multiselected selected 171 + pointer-events-none bg-border-light 172 + absolute right-2 left-2 bottom-0 173 + ${first ? "top-2" : "top-0"} 174 + ${!prevBlockSelected && "rounded-t-md"} 175 + ${!nextBlockSelected && "rounded-b-md"} 176 + `} 177 + /> 178 + ); 179 + }; 180 + 181 + export const ListMarker = ( 182 + props: Block & { 183 + previousBlock?: Block | null; 184 + nextBlock?: Block | null; 185 + } & { 186 + className?: string; 187 + }, 188 + ) => { 189 + let checklist = useEntity(props.value, "block/check-list"); 190 + let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; 191 + let children = useEntity(props.value, "card/block"); 192 + let folded = 193 + useUIState((s) => s.foldedBlocks.includes(props.value)) && 194 + children.length > 0; 195 + 196 + let depth = props.listData?.depth; 197 + let { rep } = useReplicache(); 198 + return ( 199 + <div 200 + className={`shrink-0 flex gap-[8px] justify-end items-center h-3 z-[1] 201 + ${props.className} 202 + ${ 203 + props.type === "heading" 204 + ? headingLevel === 3 205 + ? "pt-[12px]" 206 + : headingLevel === 2 207 + ? "pt-[15px]" 208 + : "pt-[20px]" 209 + : "pt-[12px]" 210 + } 211 + `} 212 + style={{ 213 + width: 214 + depth && 215 + `calc(${depth} * ${`var(--list-marker-width) ${checklist ? " + 20px" : ""} - 12px)`} `, 216 + }} 217 + > 218 + <button 219 + onClick={() => { 220 + if (children.length > 0) 221 + useUIState.getState().toggleFold(props.value); 222 + }} 223 + className={`listMarker group/list-marker ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 224 + > 225 + <div 226 + className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-1 outline-offset-1 227 + ${ 228 + folded 229 + ? "outline-secondary" 230 + : ` ${children.length > 0 ? "group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` 231 + }`} 232 + /> 233 + </button> 234 + {checklist && ( 235 + <button 236 + onClick={() => { 237 + rep?.mutate.assertFact({ 238 + entity: props.value, 239 + attribute: "block/check-list", 240 + data: { type: "boolean", value: !checklist.data.value }, 241 + }); 242 + }} 243 + className={`${checklist?.data.value ? "text-accent-contrast" : "text-border"}`} 244 + > 245 + {checklist?.data.value ? <CheckboxChecked /> : <CheckboxEmpty />} 246 + </button> 247 + )} 248 + </div> 249 + ); 250 + };
+2 -2
components/Blocks/BlockOptions.tsx
··· 48 48 "default" | "link" | "heading" 49 49 >("default"); 50 50 51 - let focusedElement = useUIState((s) => s.focusedBlock); 51 + let focusedElement = useUIState((s) => s.focusedEntity); 52 52 let focusedCardID = 53 - focusedElement?.type === "card" 53 + focusedElement?.entityType === "card" 54 54 ? focusedElement.entityID 55 55 : focusedElement?.parent; 56 56
+61 -135
components/Blocks/CardBlock.tsx
··· 1 1 "use client"; 2 - import { 3 - BaseBlock, 4 - Block, 5 - BlockProps, 6 - focusBlock, 7 - ListMarker, 8 - } from "components/Blocks"; 2 + import { BlockProps, BaseBlock, ListMarker, Block } from "./Block"; 3 + import { focusBlock } from "src/utils/focusBlock"; 4 + 9 5 import { focusCard } from "components/Cards"; 10 6 import { useEntity, useReplicache } from "src/replicache"; 11 7 import { useUIState } from "src/useUIState"; 12 8 import { RenderedTextBlock } from "components/Blocks/TextBlock"; 13 9 import { useDocMetadata } from "src/hooks/queries/useDocMetadata"; 14 10 import { CSSProperties, useEffect, useRef, useState } from "react"; 15 - import { useEntitySetContext } from "components/EntitySetProvider"; 16 11 import { useBlocks } from "src/hooks/queries/useBlocks"; 17 - import { AreYouSure } from "./DeleteBlock"; 18 12 19 - export function CardBlock(props: BlockProps & { renderPreview?: boolean }) { 13 + export function CardBlock(props: BlockProps & { preview?: boolean }) { 20 14 let { rep } = useReplicache(); 21 15 let card = useEntity(props.entityID, "block/card"); 22 16 let cardEntity = card ? card.data.value : props.entityID; 23 17 let docMetadata = useDocMetadata(cardEntity); 24 - let permission = useEntitySetContext().permissions.write; 25 18 26 19 let isSelected = useUIState((s) => 27 - s.selectedBlock.find((b) => b.value === props.entityID), 20 + s.selectedBlocks.find((b) => b.value === props.entityID), 28 21 ); 29 22 30 23 let isOpen = useUIState((s) => s.openCards).includes(cardEntity); 31 24 32 - let [areYouSure, setAreYouSure] = useState(false); 33 - 34 - useEffect(() => { 35 - if (!isSelected) { 36 - setAreYouSure(false); 37 - } 38 - }, [isSelected]); 39 - 40 - useEffect(() => { 41 - if (!isSelected) return; 42 - let listener = (e: KeyboardEvent) => { 43 - if (e.key === "Backspace" && permission) { 44 - if (e.defaultPrevented) return; 45 - if (areYouSure === false) { 46 - setAreYouSure(true); 47 - } else { 48 - e.preventDefault(); 49 - useUIState.getState().closeCard(cardEntity); 50 - 51 - rep && 52 - rep.mutate.removeBlock({ 53 - blockEntity: props.entityID, 54 - }); 55 - 56 - props.previousBlock && 57 - focusBlock(props.previousBlock, { type: "end" }); 58 - } 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 - } 67 - }; 68 - window.addEventListener("keydown", listener); 69 - return () => window.removeEventListener("keydown", listener); 70 - }, [ 71 - areYouSure, 72 - cardEntity, 73 - isSelected, 74 - permission, 75 - props.entityID, 76 - props.parent, 77 - props.previousBlock, 78 - rep, 79 - ]); 80 - 81 25 return ( 82 26 <div 83 27 style={{ "--list-marker-width": "20px" } as CSSProperties} ··· 94 38 : "border-border-light outline-transparent hover:outline-border-light" 95 39 } 96 40 `} 97 - onKeyDown={(e) => { 98 - if (e.key === "Backspace" && permission) { 99 - e.stopPropagation(); 100 - useUIState.getState().closeCard(cardEntity); 101 - 102 - rep && 103 - rep.mutate.removeBlock({ 104 - blockEntity: props.entityID, 105 - }); 106 - } 107 - }} 108 41 > 109 - {areYouSure ? ( 110 - <AreYouSure 111 - closeAreYouSure={() => setAreYouSure(false)} 112 - entityID={props.entityID} 113 - /> 114 - ) : ( 115 - <> 116 - <div 117 - className="cardBlockContent w-full flex overflow-clip cursor-pointer" 118 - onClick={(e) => { 119 - if (e.isDefaultPrevented()) return; 120 - if (e.shiftKey) return; 121 - e.preventDefault(); 122 - e.stopPropagation(); 123 - useUIState.getState().openCard(props.parent, cardEntity); 124 - if (rep) focusCard(cardEntity, rep); 125 - }} 126 - > 127 - <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip "> 128 - {docMetadata[0] && ( 129 - <div 130 - className={`cardBlockOne outline-none resize-none align-top flex gap-2 ${docMetadata[0].type === "heading" ? "font-bold text-base" : ""}`} 131 - > 132 - {docMetadata[0].listData && ( 133 - <ListMarker 134 - {...docMetadata[0]} 135 - className={ 136 - docMetadata[0].type === "heading" 137 - ? "!pt-[12px]" 138 - : "!pt-[8px]" 139 - } 140 - /> 141 - )} 142 - <RenderedTextBlock entityID={docMetadata[0].value} /> 143 - </div> 144 - )} 145 - {docMetadata[1] && ( 146 - <div 147 - className={`cardBlockLineTwo outline-none resize-none align-top flex gap-2 ${docMetadata[1].type === "heading" ? "font-bold" : ""}`} 148 - > 149 - {docMetadata[1].listData && ( 150 - <ListMarker {...docMetadata[1]} className="!pt-[8px]" /> 151 - )} 152 - <RenderedTextBlock entityID={docMetadata[1].value} /> 153 - </div> 154 - )} 155 - {docMetadata[2] && ( 156 - <div 157 - className={`cardBlockLineThree outline-none resize-none align-top flex gap-2 ${docMetadata[2].type === "heading" ? "font-bold" : ""}`} 158 - > 159 - {docMetadata[2].listData && ( 160 - <ListMarker {...docMetadata[2]} className="!pt-[8px]" /> 161 - )} 162 - <RenderedTextBlock entityID={docMetadata[2].value} /> 163 - </div> 164 - )} 165 - </div> 166 - {props.renderPreview && <CardPreview entityID={cardEntity} />} 42 + <> 43 + <div 44 + className="cardBlockContent w-full flex overflow-clip cursor-pointer" 45 + onClick={(e) => { 46 + if (e.isDefaultPrevented()) return; 47 + if (e.shiftKey) return; 48 + e.preventDefault(); 49 + e.stopPropagation(); 50 + useUIState.getState().openCard(props.parent, cardEntity); 51 + if (rep) focusCard(cardEntity, rep); 52 + }} 53 + > 54 + <div className="my-2 ml-3 grow min-w-0 text-sm bg-transparent overflow-clip "> 55 + {docMetadata[0] && ( 56 + <div 57 + className={`cardBlockOne outline-none resize-none align-top flex gap-2 ${docMetadata[0].type === "heading" ? "font-bold text-base" : ""}`} 58 + > 59 + {docMetadata[0].listData && ( 60 + <ListMarker 61 + {...docMetadata[0]} 62 + className={ 63 + docMetadata[0].type === "heading" 64 + ? "!pt-[12px]" 65 + : "!pt-[8px]" 66 + } 67 + /> 68 + )} 69 + <RenderedTextBlock entityID={docMetadata[0].value} /> 70 + </div> 71 + )} 72 + {docMetadata[1] && ( 73 + <div 74 + className={`cardBlockLineTwo outline-none resize-none align-top flex gap-2 ${docMetadata[1].type === "heading" ? "font-bold" : ""}`} 75 + > 76 + {docMetadata[1].listData && ( 77 + <ListMarker {...docMetadata[1]} className="!pt-[8px]" /> 78 + )} 79 + <RenderedTextBlock entityID={docMetadata[1].value} /> 80 + </div> 81 + )} 82 + {docMetadata[2] && ( 83 + <div 84 + className={`cardBlockLineThree outline-none resize-none align-top flex gap-2 ${docMetadata[2].type === "heading" ? "font-bold" : ""}`} 85 + > 86 + {docMetadata[2].listData && ( 87 + <ListMarker {...docMetadata[2]} className="!pt-[8px]" /> 88 + )} 89 + <RenderedTextBlock entityID={docMetadata[2].value} /> 90 + </div> 91 + )} 167 92 </div> 168 - </> 169 - )} 93 + {props.preview && <CardPreview entityID={cardEntity} />} 94 + </div> 95 + </> 170 96 </div> 171 97 ); 172 98 } ··· 179 105 return ( 180 106 <div 181 107 ref={previewRef} 182 - className={`cardBlockPreview w-[120px] overflow-clip p-1 mx-3 mt-3 -mb-2 bg-bg-card border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center`} 108 + className={`cardBlockPreview w-[120px] overflow-clip mx-3 mt-3 -mb-2 bg-bg-card border rounded-md shrink-0 border-border-light flex flex-col gap-0.5 rotate-[4deg] origin-center`} 183 109 > 184 110 <div 185 - className="absolute top-0 left-0 w-full h-full origin-top-left pointer-events-none" 111 + className="absolute top-0 left-0 h-full origin-top-left pointer-events-none" 186 112 style={{ 187 113 width: `calc(1px * ${cardWidth})`, 188 114 transform: `scale(calc((120 / ${cardWidth} )))`, ··· 232 158 observer.observe(ref.current); 233 159 return () => observer.disconnect(); 234 160 }, [b.previewRef]); 235 - return <div ref={ref}>{isVisible && <BaseBlock {...b} preview />}</div>; 161 + return <div ref={ref}>{isVisible && <Block {...b} />}</div>; 236 162 }
+120 -61
components/Blocks/DeleteBlock.tsx
··· 1 - import { useEffect } from "react"; 2 - import { useEntity, useReplicache } from "src/replicache"; 1 + import { 2 + Fact, 3 + ReplicacheMutators, 4 + useEntity, 5 + useReplicache, 6 + } from "src/replicache"; 7 + import { Replicache } from "replicache"; 3 8 import { useUIState } from "src/useUIState"; 4 - import { focusBlock } from "."; 9 + import { scanIndex } from "src/replicache/utils"; 10 + import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 11 + import { focusBlock } from "src/utils/focusBlock"; 5 12 import { ButtonPrimary } from "components/Buttons"; 6 13 import { CloseTiny } from "components/Icons"; 7 - import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 8 - import { scanIndex } from "src/replicache/utils"; 9 14 10 15 export const AreYouSure = (props: { 11 16 entityID: string[] | string; 12 17 onClick?: () => void; 13 18 closeAreYouSure: () => void; 19 + type: Fact<"block/type">["data"]["value"] | undefined; 14 20 compact?: boolean; 15 21 }) => { 16 22 let entities = [props.entityID].flat(); 17 23 let { rep } = useReplicache(); 18 - let focusedBlock = useUIState((s) => s.focusedBlock); 19 - let card = useEntity(focusedBlock?.entityID || null, "block/card"); 20 - let cardID = card ? card.data.value : props.entityID; 21 - let type = useEntity(focusedBlock?.entityID || null, "block/type")?.data 22 - .value; 23 24 24 25 return ( 25 26 <div 26 - className={`flex w-full h-full items-center justify-center ${!props.compact && "bg-border-light"}`} 27 + className={` 28 + w-full 29 + flex items-center justify-center 30 + ${ 31 + !props.compact && 32 + `bg-border-light border-2 border-border rounded-lg 33 + ${ 34 + props.type === "card" 35 + ? "h-[104px]" 36 + : props.type === "mailbox" 37 + ? "h-[92px]" 38 + : "h-full" 39 + }` 40 + }`} 27 41 > 28 42 <div 29 43 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"}`} ··· 32 46 Delete{" "} 33 47 {entities.length > 1 ? ( 34 48 "Blocks" 35 - ) : type === "card" ? ( 49 + ) : props.type === "card" ? ( 36 50 <span>Page</span> 37 - ) : type === "mailbox" ? ( 51 + ) : props.type === "mailbox" ? ( 38 52 <span>Mailbox and Posts</span> 39 53 ) : ( 40 54 <span>Block</span> ··· 46 60 autoFocus 47 61 compact 48 62 onClick={async (e) => { 49 - if (!focusedBlock || focusedBlock?.type === "card") return; 50 63 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 - ]; 67 - 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 - }); 95 - }); 96 - 97 - props.onClick && props.onClick(); 64 + if (rep) await deleteBlock(entities, rep); 98 65 }} 99 66 > 100 67 Delete ··· 114 81 </div> 115 82 ); 116 83 }; 84 + 85 + export async function deleteBlock( 86 + entities: string[], 87 + rep: Replicache<ReplicacheMutators>, 88 + ) { 89 + let focusedBlock = useUIState.getState().focusedEntity; 90 + 91 + // if the focused thing is a page and not a block, return 92 + if (!focusedBlock || focusedBlock?.entityType === "card") return; 93 + let [type] = await rep.query((tx) => 94 + scanIndex(tx).eav(focusedBlock.entityID, "block/type"), 95 + ); 96 + 97 + // get what cards we need to close as a result of deleting this block 98 + let cardsToClose = [] as string[]; 99 + if (type.data.value === "card") { 100 + let [childCards] = await rep?.query( 101 + (tx) => scanIndex(tx).eav(focusedBlock.entityID, "block/card") || [], 102 + ); 103 + cardsToClose = [childCards?.data.value]; 104 + } 105 + if (type.data.value === "mailbox") { 106 + let [archive] = await rep?.query( 107 + (tx) => scanIndex(tx).eav(focusedBlock.entityID, "mailbox/archive") || [], 108 + ); 109 + let [draft] = await rep?.query( 110 + (tx) => scanIndex(tx).eav(focusedBlock.entityID, "mailbox/draft") || [], 111 + ); 112 + cardsToClose = [archive?.data.value, draft?.data.value]; 113 + } 114 + 115 + // the next and previous blocks in the block list 116 + 117 + let siblings = 118 + (await rep?.query((tx) => getBlocksWithType(tx, focusedBlock?.parent))) || 119 + []; 120 + 121 + let selectedBlocks = useUIState.getState().selectedBlocks; 122 + let firstSelected = selectedBlocks[0]; 123 + let lastSelected = selectedBlocks[entities.length - 1]; 124 + 125 + let prevBlock = 126 + siblings?.[siblings.findIndex((s) => s.value === firstSelected.value) - 1]; 127 + let prevBlockType = await rep?.query((tx) => 128 + scanIndex(tx).eav(prevBlock?.value, "block/type"), 129 + ); 130 + 131 + let nextBlock = 132 + siblings?.[siblings.findIndex((s) => s.value === lastSelected.value) + 1]; 133 + let nextBlockType = await rep?.query((tx) => 134 + scanIndex(tx).eav(nextBlock?.value, "block/type"), 135 + ); 136 + 137 + if (prevBlock) { 138 + useUIState.getState().setSelectedBlock({ 139 + value: prevBlock.value, 140 + parent: prevBlock.parent, 141 + }); 142 + 143 + focusBlock( 144 + { 145 + value: prevBlock.value, 146 + type: prevBlockType?.[0].data.value, 147 + parent: prevBlock.parent, 148 + }, 149 + { type: "end" }, 150 + ); 151 + } else { 152 + useUIState.getState().setSelectedBlock({ 153 + value: nextBlock.value, 154 + parent: nextBlock.parent, 155 + }); 156 + 157 + focusBlock( 158 + { 159 + value: nextBlock.value, 160 + type: nextBlockType?.[0]?.data.value, 161 + parent: nextBlock.parent, 162 + }, 163 + { type: "start" }, 164 + ); 165 + } 166 + 167 + cardsToClose.forEach((card) => card && useUIState.getState().closeCard(card)); 168 + await Promise.all( 169 + entities.map((entity) => 170 + rep?.mutate.removeBlock({ 171 + blockEntity: entity, 172 + }), 173 + ), 174 + ); 175 + }
+1 -1
components/Blocks/ExternalLinkBlock.tsx
··· 8 8 let url = useEntity(props.entityID, "link/url"); 9 9 10 10 let isSelected = useUIState((s) => 11 - s.selectedBlock.find((b) => b.value === props.entityID) 11 + s.selectedBlocks.find((b) => b.value === props.entityID), 12 12 ); 13 13 14 14 return (
+22
components/Blocks/HeadingBlock.tsx
··· 1 + import { useEntity } from "src/replicache"; 2 + 3 + import { BlockProps } from "./Block"; 4 + import { TextBlock } from "./TextBlock/index"; 5 + 6 + const HeadingStyle = { 7 + 1: "text-xl font-bold", 8 + 2: "text-lg font-bold", 9 + 3: "text-base font-bold text-secondary ", 10 + } as { [level: number]: string }; 11 + 12 + export function HeadingBlock(props: BlockProps & { preview?: boolean }) { 13 + let headingLevel = useEntity(props.entityID, "block/heading-level"); 14 + 15 + return ( 16 + <TextBlock 17 + {...props} 18 + preview={props.preview} 19 + className={HeadingStyle[headingLevel?.data.value || 1]} 20 + /> 21 + ); 22 + }
+4 -4
components/Blocks/ImageBlock.tsx
··· 1 1 "use client"; 2 2 3 3 import { useEntity, useReplicache } from "src/replicache"; 4 - import { BlockProps } from "components/Blocks"; 4 + import { Block } from "./Block"; 5 5 import { useUIState } from "src/useUIState"; 6 6 7 - export function ImageBlock(props: BlockProps) { 7 + export function ImageBlock(props: Block) { 8 8 let { rep } = useReplicache(); 9 - let image = useEntity(props.entityID, "block/image"); 9 + let image = useEntity(props.value, "block/image"); 10 10 let isSelected = useUIState((s) => 11 - s.selectedBlock.find((b) => b.value === props.entityID) 11 + s.selectedBlocks.find((b) => b.value === props.value), 12 12 ); 13 13 14 14 return (
+28 -40
components/Blocks/MailboxBlock.tsx
··· 5 5 import { useUIState } from "src/useUIState"; 6 6 import { useEffect, useState } from "react"; 7 7 import { useSmoker, useToaster } from "components/Toast"; 8 - import { BlockProps } from "."; 8 + import { BlockProps } from "./Block"; 9 9 import { useEntity, useReplicache } from "src/replicache"; 10 - import { AreYouSure } from "./DeleteBlock"; 11 - import { focusBlock } from "."; 10 + import { focusBlock } from "src/utils/focusBlock"; 12 11 import { useEntitySetContext } from "components/EntitySetProvider"; 13 12 import { subscribeToMailboxWithEmail } from "actions/subscriptions/subscribeToMailboxWithEmail"; 14 13 import { confirmEmailSubscription } from "actions/subscriptions/confirmEmailSubscription"; ··· 31 30 let isSubscribed = useSubscriptionStatus(props.entityID); 32 31 let [areYouSure, setAreYouSure] = useState(false); 33 32 let isSelected = useUIState((s) => 34 - s.selectedBlock.find((b) => b.value === props.entityID), 33 + s.selectedBlocks.find((b) => b.value === props.entityID), 35 34 ); 36 35 37 36 let card = useEntity(props.entityID, "block/card"); ··· 114 113 "color-mix(in oklab, rgb(var(--accent-contrast)), rgb(var(--bg-card)) 85%)", 115 114 }} 116 115 > 117 - {!areYouSure ? ( 118 - <div className="flex gap-2 p-4"> 119 - <ButtonPrimary 120 - onClick={async () => { 121 - let entity; 122 - if (draft) { 123 - entity = draft.data.value; 124 - } else { 125 - entity = v7(); 126 - await rep?.mutate.createDraft({ 127 - mailboxEntity: props.entityID, 128 - permission_set: entity_set.set, 129 - newEntity: entity, 130 - firstBlockEntity: v7(), 131 - firstBlockFactID: v7(), 132 - }); 133 - } 134 - useUIState.getState().openCard(props.parent, entity); 135 - if (rep) focusCard(entity, rep, "focusFirstBlock"); 136 - return; 137 - }} 138 - > 139 - {draft ? "Edit Draft" : "Write a Post"} 140 - </ButtonPrimary> 141 - <MailboxInfo /> 142 - </div> 143 - ) : ( 144 - <AreYouSure 145 - entityID={props.entityID} 146 - closeAreYouSure={() => setAreYouSure(false)} 147 - onClick={() => { 148 - draft && useUIState.getState().closeCard(draft.data.value); 149 - archive && useUIState.getState().closeCard(archive.data.value); 116 + <div className="flex gap-2 p-4"> 117 + <ButtonPrimary 118 + onClick={async () => { 119 + let entity; 120 + if (draft) { 121 + entity = draft.data.value; 122 + } else { 123 + entity = v7(); 124 + await rep?.mutate.createDraft({ 125 + mailboxEntity: props.entityID, 126 + permission_set: entity_set.set, 127 + newEntity: entity, 128 + firstBlockEntity: v7(), 129 + firstBlockFactID: v7(), 130 + }); 131 + } 132 + useUIState.getState().openCard(props.parent, entity); 133 + if (rep) focusCard(entity, rep, "focusFirstBlock"); 134 + return; 150 135 }} 151 - /> 152 - )} 136 + > 137 + {draft ? "Edit Draft" : "Write a Post"} 138 + </ButtonPrimary> 139 + <MailboxInfo /> 140 + </div> 153 141 </div> 154 142 <div className="flex gap-3 items-center justify-between"> 155 143 { ··· 199 187 const MailboxReaderView = (props: { entityID: string; parent: string }) => { 200 188 let isSubscribed = useSubscriptionStatus(props.entityID); 201 189 let isSelected = useUIState((s) => 202 - s.selectedBlock.find((b) => b.value === props.entityID), 190 + s.selectedBlocks.find((b) => b.value === props.entityID), 203 191 ); 204 192 let archive = useEntity(props.entityID, "mailbox/archive"); 205 193 let smoke = useSmoker();
+9 -15
components/Blocks/TextBlock/index.tsx
··· 23 23 import { generateKeyBetween } from "fractional-indexing"; 24 24 import { RenderYJSFragment } from "./RenderYJSFragment"; 25 25 import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 26 - import { addImage } from "src/utils/addImage"; 27 - import { BlockProps, focusBlock } from "components/Blocks"; 26 + import { BlockProps } from "../Block"; 27 + import { focusBlock } from "src/utils/focusBlock"; 28 28 import { TextBlockKeymap } from "./keymap"; 29 29 import { schema } from "./schema"; 30 30 import { useUIState } from "src/useUIState"; ··· 43 43 import { inputrules } from "./inputRules"; 44 44 45 45 export function TextBlock( 46 - props: BlockProps & { className: string; previewOnly?: boolean }, 46 + props: BlockProps & { className: string; preview?: boolean }, 47 47 ) { 48 48 let initialized = useInitialPageLoad(); 49 49 let first = props.previousBlock === null; ··· 51 51 52 52 return ( 53 53 <> 54 - {(!initialized || !permission || props.previewOnly) && ( 54 + {(!initialized || !permission || props.preview) && ( 55 55 <RenderedTextBlock 56 56 entityID={props.entityID} 57 57 className={props.className} 58 58 first={first} 59 59 /> 60 60 )} 61 - {permission && !props.previewOnly && ( 61 + {permission && !props.preview && ( 62 62 <div 63 63 className={`w-full relative group/text ${!initialized ? "hidden" : ""}`} 64 64 > ··· 72 72 73 73 export function IOSBS(props: BlockProps) { 74 74 let selected = useUIState((s) => 75 - s.selectedBlock.find((b) => b.value === props.entityID), 75 + s.selectedBlocks.find((b) => b.value === props.entityID), 76 76 ); 77 77 let [initialRender, setInitialRender] = useState(true); 78 78 useEffect(() => { ··· 161 161 }, [rep?.rep]); 162 162 163 163 let selected = useUIState((s) => 164 - s.selectedBlock.find((b) => b.value === props.entityID), 164 + s.selectedBlocks.find((b) => b.value === props.entityID), 165 165 ); 166 166 let first = props.previousBlock === null; 167 167 let headingLevel = useEntity(props.entityID, "block/heading-level"); ··· 244 244 setTimeout(() => { 245 245 useUIState.getState().setSelectedBlock(props); 246 246 useUIState.setState(() => ({ 247 - focusedBlock: { 248 - type: "block", 247 + focusedEntity: { 248 + entityType: "block", 249 249 entityID: props.entityID, 250 250 parent: props.parent, 251 251 }, ··· 296 296 </ProseMirror> 297 297 ); 298 298 } 299 - 300 - const HeadingStyle = { 301 - 1: "text-xl font-bold", 302 - 2: "text-lg font-bold", 303 - 3: "text-base font-bold", 304 - } as { [level: number]: string }; 305 299 306 300 function CommandHandler(props: { entityID: string }) { 307 301 let cb = useEditorEventCallback(
+2 -1
components/Blocks/TextBlock/inputRules.ts
··· 6 6 import { MutableRefObject } from "react"; 7 7 import { Replicache } from "replicache"; 8 8 import { ReplicacheMutators } from "src/replicache"; 9 - import { BlockProps, focusBlock } from "components/Blocks"; 9 + import { BlockProps } from "../Block"; 10 + import { focusBlock } from "src/utils/focusBlock"; 10 11 import { schema } from "./schema"; 11 12 import { useUIState } from "src/useUIState"; 12 13 export const inputrules = (
+25 -15
components/Blocks/TextBlock/keymap.ts
··· 1 - import { BlockProps, focusBlock } from "components/Blocks"; 1 + import { BlockProps } from "../Block"; 2 + import { focusBlock } from "src/utils/focusBlock"; 2 3 import { EditorView } from "prosemirror-view"; 3 4 import { generateKeyBetween } from "fractional-indexing"; 4 5 import { setBlockType, toggleMark } from "prosemirror-commands"; ··· 36 37 "Ctrl-a": metaA(propsRef, repRef), 37 38 "Meta-a": metaA(propsRef, repRef), 38 39 Tab: () => { 39 - if (useUIState.getState().selectedBlock.length > 1) return false; 40 + if (useUIState.getState().selectedBlocks.length > 1) return false; 40 41 if (!repRef.current || !propsRef.current.previousBlock) return false; 41 42 indent(propsRef.current, propsRef.current.previousBlock, repRef.current); 42 43 return true; ··· 45 46 Escape: (_state, _dispatch, view) => { 46 47 view?.dom.blur(); 47 48 useUIState.setState(() => ({ 48 - focusedBlock: { 49 - type: "card", 49 + focusedEntity: { 50 + entityType: "card", 50 51 entityID: propsRef.current.parent, 51 52 }, 52 - selectedBlock: [], 53 + selectedBlocks: [], 53 54 })); 54 55 55 56 return false; ··· 64 65 .getState() 65 66 .setSelectedBlocks([propsRef.current, propsRef.current.nextBlock]); 66 67 useUIState.getState().setFocusedBlock({ 67 - type: "block", 68 + entityType: "block", 68 69 entityID: propsRef.current.nextBlock.value, 69 70 parent: propsRef.current.parent, 70 71 }); ··· 86 87 propsRef.current.previousBlock, 87 88 ]); 88 89 useUIState.getState().setFocusedBlock({ 89 - type: "block", 90 + entityType: "block", 90 91 entityID: propsRef.current.previousBlock.value, 91 92 parent: propsRef.current.parent, 92 93 }); ··· 100 101 }, 101 102 ArrowUp: (state, _tr, view) => { 102 103 if (!view) return false; 103 - if (useUIState.getState().selectedBlock.length > 1) return true; 104 + if (useUIState.getState().selectedBlocks.length > 1) return true; 104 105 if (view.state.selection.from !== view.state.selection.to) return false; 105 106 const viewClientRect = view.dom.getBoundingClientRect(); 106 107 const coords = view.coordsAtPos(view.state.selection.anchor); ··· 117 118 }, 118 119 ArrowDown: (state, tr, view) => { 119 120 if (!view) return true; 120 - if (useUIState.getState().selectedBlock.length > 1) return true; 121 + if (useUIState.getState().selectedBlocks.length > 1) return true; 121 122 if (view.state.selection.from !== view.state.selection.to) return false; 122 123 const viewClientRect = view.dom.getBoundingClientRect(); 123 124 const coords = view.coordsAtPos(view.state.selection.anchor); ··· 171 172 dispatch?: (tr: Transaction) => void, 172 173 view?: EditorView, 173 174 ) => { 174 - if (useUIState.getState().selectedBlock.length > 1) { 175 + // if multiple blocks are selected, don't do anything (handled in SelectionManager) 176 + if (useUIState.getState().selectedBlocks.length > 1) { 175 177 return false; 176 178 } 179 + // if you are selecting text within a block, don't do anything (handled by proseMirror) 177 180 if (state.selection.anchor > 1 || state.selection.content().size > 0) { 178 181 return false; 179 182 } 183 + // if you are in a list... 180 184 if (propsRef.current.listData) { 185 + // ...and the item is a checklist item, remove the checklist attribute 181 186 if (propsRef.current.listData.checklist) { 182 187 repRef.current?.mutate.retractAttribute({ 183 188 entity: propsRef.current.entityID, ··· 185 190 }); 186 191 return true; 187 192 } 193 + // ...move the child list items to next eligible parent (?) 188 194 let depth = propsRef.current.listData.depth; 189 195 repRef.current?.mutate.moveChildren({ 190 196 oldParent: propsRef.current.entityID, ··· 197 203 null, 198 204 }); 199 205 } 206 + // if this is the first block and is it a list, remove list attribute 200 207 if (!propsRef.current.previousBlock) { 201 208 if (propsRef.current.listData) { 202 209 repRef.current?.mutate.retractAttribute({ ··· 205 212 }); 206 213 return true; 207 214 } 215 + 216 + // If the block is a heading, convert it to a text block 208 217 if (propsRef.current.type === "heading") { 209 218 repRef.current?.mutate.assertFact({ 210 219 entity: propsRef.current.entityID, ··· 216 225 focusBlock( 217 226 { 218 227 value: propsRef.current.entityID, 219 - type: "heading", 228 + type: "text", 220 229 parent: propsRef.current.parent, 221 230 }, 222 231 { type: "start" }, ··· 292 301 repRef: MutableRefObject<Replicache<ReplicacheMutators> | null>, 293 302 ) => 294 303 () => { 295 - if (useUIState.getState().selectedBlock.length > 1) return false; 304 + if (useUIState.getState().selectedBlocks.length > 1) return false; 296 305 if (!repRef.current) return false; 297 306 if (!repRef.current) return false; 298 307 outdent(propsRef.current, propsRef.current.previousBlock, repRef.current); ··· 358 367 data: { type: "boolean", value: false }, 359 368 }); 360 369 } 361 - 370 + // if the block is not a list, add a new text block after it 362 371 if (!propsRef.current.listData) { 363 372 position = generateKeyBetween( 364 373 propsRef.current.position, ··· 373 382 position, 374 383 }); 375 384 } 376 - 385 + // if you are are the beginning of a heading, move the heading level to the new block 377 386 if (blockType === "heading") { 378 387 await repRef.current?.mutate.assertFact({ 379 388 entity: propsRef.current.entityID, ··· 393 402 }; 394 403 asyncRun(); 395 404 405 + // if you are in the middle of a text block, split the block 396 406 setTimeout(() => { 397 407 let block = useEditorStates.getState().editorStates[newEntityID]; 398 408 if (block) { ··· 454 464 let allBlocks = await getBlocksWithType(tx, propsRef.current.parent) ||[] 455 465 console.log("allBlocks", allBlocks) 456 466 useUIState.setState({ 457 - selectedBlock: allBlocks.map(b=>({value: b.value, parent: propsRef.current.parent})) 467 + selectedBlocks: allBlocks.map(b=>({value: b.value, parent: propsRef.current.parent})) 458 468 }) 459 469 }) 460 470 return true
+2 -1
components/Blocks/TextBlock/useHandlePaste.ts
··· 6 6 import { schema } from "./schema"; 7 7 import { generateKeyBetween } from "fractional-indexing"; 8 8 import { addImage } from "src/utils/addImage"; 9 - import { BlockProps, focusBlock } from "components/Blocks"; 9 + import { BlockProps } from "../Block"; 10 + import { focusBlock } from "src/utils/focusBlock"; 10 11 import { useEntitySetContext } from "components/EntitySetProvider"; 11 12 import { v7 } from "uuid"; 12 13 import { Replicache } from "replicache";
+54 -431
components/Blocks/index.tsx
··· 1 1 "use client"; 2 - import { Fact, useEntity, useReplicache } from "src/replicache"; 3 - import { TextBlock } from "components/Blocks/TextBlock"; 4 - import { generateKeyBetween } from "fractional-indexing"; 5 - import { useEffect } from "react"; 6 - import { elementId } from "src/utils/elementId"; 7 - import { TextSelection } from "prosemirror-state"; 8 - import { useSelectingMouse } from "components/SelectionManager"; 9 - import { ImageBlock } from "./ImageBlock"; 10 - import { useUIState } from "src/useUIState"; 11 - import { CardBlock } from "./CardBlock"; 12 - import { ExternalLinkBlock } from "./ExternalLinkBlock"; 13 - import { BlockOptions } from "./BlockOptions"; 14 - import { MailboxBlock } from "./MailboxBlock"; 15 2 3 + import { Fact, useReplicache } from "src/replicache"; 4 + 5 + import { useUIState } from "src/useUIState"; 16 6 import { useBlocks } from "src/hooks/queries/useBlocks"; 17 - import { setEditorState, useEditorStates } from "src/state/useEditorState"; 7 + import { useEditorStates } from "src/state/useEditorState"; 18 8 import { useEntitySetContext } from "components/EntitySetProvider"; 9 + 10 + import { isTextBlock } from "src/utils/isTextBlock"; 11 + import { focusBlock } from "src/utils/focusBlock"; 12 + import { elementId } from "src/utils/elementId"; 13 + import { generateKeyBetween } from "fractional-indexing"; 19 14 import { v7 } from "uuid"; 20 - import { useBlockMouseHandlers } from "./useBlockMouseHandlers"; 21 - import { indent, outdent } from "src/utils/list-operations"; 22 - import { CheckboxChecked, CheckboxEmpty } from "components/Icons"; 23 - import { useLongPress } from "src/hooks/useLongPress"; 24 - export type Block = { 25 - factID: string; 26 - parent: string; 27 - position: string; 28 - value: string; 29 - type: Fact<"block/type">["data"]["value"]; 30 - listData?: { 31 - checklist?: boolean; 32 - path: { depth: number; entity: string }[]; 33 - parent: string; 34 - depth: number; 35 - }; 36 - }; 15 + 16 + import { BlockOptions } from "./BlockOptions"; 17 + import { Block } from "./Block"; 18 + 37 19 export function Blocks(props: { entityID: string }) { 38 20 let rep = useReplicache(); 39 21 let entity_set = useEntitySetContext(); ··· 54 36 55 37 return ( 56 38 <div 57 - className={`blocks w-full flex flex-col outline-none h-fit min-h-full ${!entity_set.permissions.write && "pb-6"}`} 39 + className={`blocks w-full flex flex-col outline-none h-fit min-h-full`} 58 40 onClick={async (e) => { 59 - if (useUIState.getState().selectedBlock.length > 1) return; 41 + if (useUIState.getState().selectedBlocks.length > 1) return; 60 42 if (e.target === e.currentTarget) { 61 43 if ( 62 44 !lastVisibleBlock || ··· 123 105 lastBlock={lastRootBlock || null} 124 106 entityID={props.entityID} 125 107 /> 126 - {entity_set.permissions.write ? ( 127 - <div 128 - className="shrink-0 h-[50vh]" 129 - onClick={() => { 130 - let newEntityID = v7(); 131 108 132 - if ( 133 - lastRootBlock && 134 - lastVisibleBlock && 135 - textBlocks[lastVisibleBlock.type] 136 - ) { 137 - focusBlock( 138 - { ...lastVisibleBlock, type: "text" }, 139 - { type: "end" }, 140 - ); 141 - } else { 142 - rep?.rep?.mutate.addBlock({ 143 - permission_set: entity_set.set, 144 - factID: v7(), 145 - parent: props.entityID, 146 - type: "text", 147 - position: generateKeyBetween( 148 - lastRootBlock?.position || null, 149 - null, 150 - ), 151 - newEntityID, 152 - }); 153 - 154 - setTimeout(() => { 155 - document 156 - .getElementById(elementId.block(newEntityID).text) 157 - ?.focus(); 158 - }, 10); 159 - } 160 - }} 161 - /> 162 - ) : ( 163 - <div className="h-4" /> 164 - )} 109 + <BlockListBottom 110 + lastVisibleBlock={lastVisibleBlock || undefined} 111 + lastRootBlock={lastRootBlock || undefined} 112 + entityID={props.entityID} 113 + /> 165 114 </div> 166 115 ); 167 116 } ··· 174 123 ? s.editorStates[props.lastBlock.value] 175 124 : null, 176 125 ); 177 - let type = useEntity(props.entityID, "block/type"); 178 - let headingLevel = useEntity(props.entityID, "block/heading-level"); 179 126 180 127 if (!entity_set.permissions.write) return null; 181 128 if ( ··· 225 172 ); 226 173 } 227 174 228 - export type BlockProps = { 175 + const BlockListBottom = (props: { 176 + lastRootBlock: Block | undefined; 177 + lastVisibleBlock: Block | undefined; 229 178 entityID: string; 230 - parent: string; 231 - position: string; 232 - nextBlock: Block | null; 233 - previousBlock: Block | null; 234 - nextPosition: string | null; 235 - } & Block; 236 - 237 - export const textBlocks: { 238 - [k in Fact<"block/type">["data"]["value"]]?: boolean; 239 - } = { 240 - text: true, 241 - heading: true, 242 - }; 243 - 244 - function Block(props: BlockProps) { 179 + }) => { 180 + let newEntityID = v7(); 245 181 let { rep } = useReplicache(); 246 - let mouseHandlers = useBlockMouseHandlers(props); 182 + let entity_set = useEntitySetContext(); 247 183 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); 256 - 257 - let first = props.previousBlock === null; 258 - 259 - let selectedBlocks = useUIState((s) => s.selectedBlock); 260 - 261 - let actuallySelected = useUIState( 262 - (s) => !!s.selectedBlock.find((b) => b.value === props.entityID), 263 - ); 264 - let hasSelectionUI = 265 - (!textBlocks[props.type] || selectedBlocks.length > 1) && actuallySelected; 266 - 267 - let nextBlockSelected = useUIState((s) => 268 - s.selectedBlock.find((b) => b.value === props.nextBlock?.value), 269 - ); 270 - let prevBlockSelected = useUIState((s) => 271 - s.selectedBlock.find((b) => b.value === props.previousBlock?.value), 272 - ); 273 - 274 - let entity_set = useEntitySetContext(); 275 - useEffect(() => { 276 - if (!hasSelectionUI || !rep) return; 277 - let r = rep; 278 - let listener = async (e: KeyboardEvent) => { 279 - if (e.defaultPrevented) return; 280 - if (e.key === "Tab") { 281 - if (textBlocks[props.type]) return; 282 - if (e.shiftKey) { 283 - e.preventDefault(); 284 - outdent(props, props.previousBlock, rep); 184 + if (!entity_set.permissions.write) return <div className="h-4" />; 185 + return ( 186 + <div 187 + className="blockListClickableBottomArea shrink-0 h-[50vh]" 188 + onClick={() => { 189 + if ( 190 + // if the last visible(not-folded) block is a text block, focus it 191 + props.lastRootBlock && 192 + props.lastVisibleBlock && 193 + isTextBlock[props.lastVisibleBlock.type] 194 + ) { 195 + focusBlock( 196 + { ...props.lastVisibleBlock, type: "text" }, 197 + { type: "end" }, 198 + ); 285 199 } else { 286 - e.preventDefault(); 287 - if (props.previousBlock) indent(props, props.previousBlock, rep); 288 - } 289 - } 290 - if (e.key === "ArrowDown") { 291 - e.preventDefault(); 292 - let nextBlock = props.nextBlock; 293 - if (nextBlock && useUIState.getState().selectedBlock.length <= 1) 294 - focusBlock(nextBlock, { 295 - type: "top", 296 - left: useEditorStates.getState().lastXPosition, 297 - }); 298 - if (!nextBlock) return; 299 - } 300 - if (e.key === "ArrowUp") { 301 - e.preventDefault(); 302 - let prevBlock = props.previousBlock; 303 - if (prevBlock && useUIState.getState().selectedBlock.length <= 1) { 304 - focusBlock(prevBlock, { 305 - type: "bottom", 306 - left: useEditorStates.getState().lastXPosition, 307 - }); 308 - } 309 - if (!prevBlock) return; 310 - } 311 - if (e.key === "Backspace") { 312 - if (!entity_set.permissions.write) return; 313 - if (textBlocks[props.type]) return; 314 - if (props.type === "card" || props.type === "mailbox") return; 315 - e.preventDefault(); 316 - r.mutate.removeBlock({ blockEntity: props.entityID }); 317 - useUIState.getState().closeCard(props.entityID); 318 - let prevBlock = props.previousBlock; 319 - if (prevBlock) focusBlock(prevBlock, { type: "end" }); 320 - } 321 - if (e.key === "Enter") { 322 - if (!entity_set.permissions.write) return; 323 - let newEntityID = v7(); 324 - let position; 325 - if (props.listData) { 326 - let hasChild = 327 - props.nextBlock?.listData && 328 - props.nextBlock.listData.depth > props.listData.depth; 329 - position = generateKeyBetween( 330 - hasChild ? null : props.position, 331 - props.nextPosition, 332 - ); 333 - await r?.mutate.addBlock({ 334 - newEntityID, 200 + // else add a new text block at the end and focus it 201 + rep?.mutate.addBlock({ 202 + permission_set: entity_set.set, 335 203 factID: v7(), 336 - permission_set: entity_set.set, 337 - parent: hasChild ? props.entityID : props.listData.parent, 204 + parent: props.entityID, 338 205 type: "text", 339 - position, 340 - }); 341 - await r?.mutate.assertFact({ 342 - entity: newEntityID, 343 - attribute: "block/is-list", 344 - data: { type: "boolean", value: true }, 345 - }); 346 - } 347 - if (!props.listData) { 348 - position = generateKeyBetween(props.position, props.nextPosition); 349 - await r?.mutate.addBlock({ 206 + position: generateKeyBetween( 207 + props.lastRootBlock?.position || null, 208 + null, 209 + ), 350 210 newEntityID, 351 - factID: v7(), 352 - permission_set: entity_set.set, 353 - parent: props.parent, 354 - type: "text", 355 - position, 356 211 }); 212 + 213 + setTimeout(() => { 214 + document.getElementById(elementId.block(newEntityID).text)?.focus(); 215 + }, 10); 357 216 } 358 - setTimeout(() => { 359 - document.getElementById(elementId.block(newEntityID).text)?.focus(); 360 - }, 10); 361 - } 362 - if (e.key === "Escape") { 363 - e.preventDefault(); 364 - useUIState.setState({ selectedBlock: [] }); 365 - useUIState.setState({ focusedBlock: null }); 366 - } 367 - }; 368 - window.addEventListener("keydown", listener); 369 - return () => window.removeEventListener("keydown", listener); 370 - }, [entity_set, hasSelectionUI, props, rep]); 371 - 372 - return ( 373 - <div 374 - {...mouseHandlers} 375 - {...handlers} 376 - className="blockWrapper relative flex" 377 - > 378 - {hasSelectionUI && selectedBlocks.length > 1 && ( 379 - <div 380 - className={` 381 - blockSelectionBG pointer-events-none bg-border-light 382 - absolute right-2 left-2 bottom-0 383 - ${selectedBlocks.length > 1 ? "Multiple-Selected" : ""} 384 - ${actuallySelected ? "selected" : ""} 385 - ${first ? "top-2" : "top-0"} 386 - ${!prevBlockSelected && "rounded-t-md"} 387 - ${!nextBlockSelected && "rounded-b-md"} 388 - `} 389 - /> 390 - )} 391 - <BaseBlock {...props} /> 392 - </div> 393 - ); 394 - } 395 - 396 - export const BaseBlock = (props: BlockProps & { preview?: boolean }) => { 397 - return ( 398 - <div 399 - data-entityid={props.entityID} 400 - className={` 401 - blockContent relative 402 - grow flex flex-row gap-2 403 - px-3 sm:px-4 404 - ${ 405 - props.type === "heading" || 406 - (props.listData && props.nextBlock?.listData) 407 - ? "pb-0" 408 - : "pb-2" 409 - } 410 - ${!props.previousBlock ? `${props.type === "heading" || props.type === "text" ? "pt-2 sm:pt-3" : "pt-3 sm:pt-4"}` : "pt-1"} 411 - `} 412 - id={elementId.block(props.entityID).container} 413 - > 414 - {props.listData && <ListMarker {...props} />} 415 - 416 - {props.type === "card" ? ( 417 - <CardBlock {...props} renderPreview={!props.preview} /> 418 - ) : props.type === "text" ? ( 419 - <TextBlock {...props} className="" previewOnly={props.preview} /> 420 - ) : props.type === "heading" ? ( 421 - <HeadingBlock {...props} preview={props.preview} /> 422 - ) : props.type === "image" ? ( 423 - <ImageBlock {...props} /> 424 - ) : props.type === "link" ? ( 425 - <ExternalLinkBlock {...props} /> 426 - ) : props.type === "mailbox" ? ( 427 - <div className="flex flex-col gap-4 w-full"> 428 - <MailboxBlock {...props} /> 429 - </div> 430 - ) : null} 431 - </div> 432 - ); 433 - }; 434 - 435 - export const ListMarker = ( 436 - props: Block & { previousBlock?: Block | null; nextBlock?: Block | null } & { 437 - className?: string; 438 - }, 439 - ) => { 440 - let entity_set = useEntitySetContext(); 441 - let checklist = useEntity(props.value, "block/check-list"); 442 - let headingLevel = useEntity(props.value, "block/heading-level")?.data.value; 443 - let children = useEntity(props.value, "card/block"); 444 - let folded = 445 - useUIState((s) => s.foldedBlocks.includes(props.value)) && 446 - children.length > 0; 447 - 448 - let depth = props.listData?.depth; 449 - let { rep } = useReplicache(); 450 - return ( 451 - <div 452 - className={`shrink-0 flex gap-[8px] justify-end items-center h-3 453 - ${props.className} 454 - ${ 455 - props.type === "heading" 456 - ? headingLevel === 3 457 - ? "pt-[12px]" 458 - : headingLevel === 2 459 - ? "pt-[15px]" 460 - : "pt-[20px]" 461 - : "pt-[12px]" 462 - } 463 - `} 464 - style={{ 465 - width: 466 - depth && 467 - `calc(${depth} * ${`var(--list-marker-width) ${checklist ? " + 20px" : ""} - 12px)`} `, 468 217 }} 469 - > 470 - <button 471 - onClick={() => { 472 - if (children.length > 0) 473 - useUIState.getState().toggleFold(props.value); 474 - }} 475 - className={`listMarker group/list-marker ${children.length > 0 ? "cursor-pointer" : "cursor-default"}`} 476 - > 477 - <div 478 - className={`h-[5px] w-[5px] rounded-full bg-secondary shrink-0 right-0 outline outline-1 outline-offset-1 479 - ${ 480 - folded 481 - ? "outline-secondary" 482 - : ` ${children.length > 0 ? "group-hover/list-marker:outline-secondary outline-transparent" : "outline-transparent"}` 483 - }`} 484 - /> 485 - </button> 486 - {checklist && ( 487 - <button 488 - onClick={() => { 489 - if (!entity_set.permissions.write) return; 490 - rep?.mutate.assertFact({ 491 - entity: props.value, 492 - attribute: "block/check-list", 493 - data: { type: "boolean", value: !checklist.data.value }, 494 - }); 495 - }} 496 - className={`${checklist?.data.value ? "text-accent-contrast" : "text-border"}`} 497 - > 498 - {checklist?.data.value ? <CheckboxChecked /> : <CheckboxEmpty />} 499 - </button> 500 - )} 501 - </div> 218 + /> 502 219 ); 503 220 }; 504 - 505 - const HeadingStyle = { 506 - 1: "text-xl font-bold", 507 - 2: "text-lg font-bold", 508 - 3: "text-base font-bold text-secondary ", 509 - } as { [level: number]: string }; 510 - 511 - export function HeadingBlock(props: BlockProps & { preview?: boolean }) { 512 - let headingLevel = useEntity(props.entityID, "block/heading-level"); 513 - return ( 514 - <TextBlock 515 - {...props} 516 - previewOnly={props.preview} 517 - className={HeadingStyle[headingLevel?.data.value || 1]} 518 - /> 519 - ); 520 - } 521 - 522 - type Position = 523 - | { 524 - type: "start"; 525 - } 526 - | { type: "end" } 527 - | { 528 - type: "coord"; 529 - top: number; 530 - left: number; 531 - } 532 - | { 533 - type: "top"; 534 - left: number; 535 - } 536 - | { 537 - type: "bottom"; 538 - left: number; 539 - }; 540 - export function focusBlock( 541 - block: Pick<Block, "type" | "value" | "parent">, 542 - position: Position, 543 - ) { 544 - if (block.type !== "text" && block.type !== "heading") { 545 - useUIState.getState().setSelectedBlock(block); 546 - useUIState.getState().setFocusedBlock({ 547 - type: "block", 548 - entityID: block.value, 549 - parent: block.parent, 550 - }); 551 - return true; 552 - } 553 - let nextBlockID = block.value; 554 - let nextBlock = useEditorStates.getState().editorStates[nextBlockID]; 555 - if (!nextBlock || !nextBlock.view) return; 556 - nextBlock.view.dom.focus({ preventScroll: true }); 557 - let nextBlockViewClientRect = nextBlock.view.dom.getBoundingClientRect(); 558 - let tr = nextBlock.editor.tr; 559 - let pos: { pos: number } | null = null; 560 - switch (position.type) { 561 - case "end": { 562 - pos = { pos: tr.doc.content.size - 1 }; 563 - break; 564 - } 565 - case "start": { 566 - pos = { pos: 1 }; 567 - break; 568 - } 569 - case "top": { 570 - pos = nextBlock.view.posAtCoords({ 571 - top: nextBlockViewClientRect.top + 12, 572 - left: position.left, 573 - }); 574 - break; 575 - } 576 - case "bottom": { 577 - pos = nextBlock.view.posAtCoords({ 578 - top: nextBlockViewClientRect.bottom - 12, 579 - left: position.left, 580 - }); 581 - break; 582 - } 583 - case "coord": { 584 - pos = nextBlock.view.posAtCoords({ 585 - top: position.top, 586 - left: position.left, 587 - }); 588 - break; 589 - } 590 - } 591 - 592 - let newState = nextBlock.editor.apply( 593 - tr.setSelection(TextSelection.create(tr.doc, pos?.pos || 1)), 594 - ); 595 - 596 - setEditorState(nextBlockID, { editor: newState }); 597 - }
+186
components/Blocks/useBlockKeyboardHandlers.ts
··· 1 + import { useEffect } from "react"; 2 + import { useUIState } from "src/useUIState"; 3 + import { useEditorStates } from "src/state/useEditorState"; 4 + 5 + import { isTextBlock } from "src/utils/isTextBlock"; 6 + import { focusBlock } from "src/utils/focusBlock"; 7 + import { elementId } from "src/utils/elementId"; 8 + import { indent, outdent } from "src/utils/list-operations"; 9 + import { generateKeyBetween } from "fractional-indexing"; 10 + import { v7 } from "uuid"; 11 + import { BlockProps } from "./Block"; 12 + import { ReplicacheMutators, useEntity, useReplicache } from "src/replicache"; 13 + import { useEntitySetContext } from "components/EntitySetProvider"; 14 + import { Replicache } from "replicache"; 15 + import { deleteBlock } from "./DeleteBlock"; 16 + import { entities } from "drizzle/schema"; 17 + import { scanIndex } from "src/replicache/utils"; 18 + 19 + export function useBlockKeyboardHandlers( 20 + props: BlockProps, 21 + areYouSure: boolean, 22 + setAreYouSure: (value: boolean) => void, 23 + ) { 24 + let { rep } = useReplicache(); 25 + let entity_set = useEntitySetContext(); 26 + 27 + let selectedBlocks = useUIState((s) => s.selectedBlocks); 28 + let actuallySelected = useUIState( 29 + (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 30 + ); 31 + let hasSelectionUI = 32 + (!isTextBlock[props.type] || selectedBlocks.length > 1) && actuallySelected; 33 + 34 + useEffect(() => { 35 + if (!hasSelectionUI || !rep) return; 36 + let listener = async (e: KeyboardEvent) => { 37 + // keymapping for textBlocks is handled in TextBlock/keymap 38 + if (e.defaultPrevented) return; 39 + //if no permissions, do nothing 40 + if (!entity_set.permissions.write) return; 41 + let command = { Tab, ArrowUp, ArrowDown, Backspace, Enter, Escape }[ 42 + e.key 43 + ]; 44 + command?.({ e, props, rep, entity_set, areYouSure, setAreYouSure }); 45 + }; 46 + window.addEventListener("keydown", listener); 47 + return () => window.removeEventListener("keydown", listener); 48 + }, [entity_set, hasSelectionUI, props, rep, areYouSure, setAreYouSure]); 49 + } 50 + 51 + type Args = { 52 + e: KeyboardEvent; 53 + props: BlockProps; 54 + rep: Replicache<ReplicacheMutators>; 55 + entity_set: { set: string }; 56 + areYouSure: boolean; 57 + setAreYouSure: (value: boolean) => void; 58 + }; 59 + 60 + function Tab({ e, props, rep }: Args) { 61 + // if tab or shift tab & not a textBlock, indent or outdent 62 + if (isTextBlock[props.type]) return; 63 + if (e.shiftKey) { 64 + e.preventDefault(); 65 + outdent(props, props.previousBlock, rep); 66 + } else { 67 + e.preventDefault(); 68 + if (props.previousBlock) indent(props, props.previousBlock, rep); 69 + } 70 + } 71 + 72 + function ArrowDown({ e, props }: Args) { 73 + e.preventDefault(); 74 + let nextBlock = props.nextBlock; 75 + if (nextBlock && useUIState.getState().selectedBlocks.length <= 1) 76 + focusBlock(nextBlock, { 77 + type: "top", 78 + left: useEditorStates.getState().lastXPosition, 79 + }); 80 + if (!nextBlock) return; 81 + } 82 + 83 + function ArrowUp({ e, props }: Args) { 84 + e.preventDefault(); 85 + let prevBlock = props.previousBlock; 86 + if (prevBlock && useUIState.getState().selectedBlocks.length <= 1) { 87 + focusBlock(prevBlock, { 88 + type: "bottom", 89 + left: useEditorStates.getState().lastXPosition, 90 + }); 91 + } 92 + if (!prevBlock) return; 93 + } 94 + 95 + async function Backspace({ e, props, rep, areYouSure, setAreYouSure }: Args) { 96 + // if this is a textBlock, let the textBlock/keymap handle the backspace 97 + if (isTextBlock[props.type]) return; 98 + 99 + // if the block is a card or mailbox... 100 + if (props.type === "card" || props.type === "mailbox") { 101 + // ...and areYouSure state is false, set it to true 102 + if (!areYouSure) { 103 + setAreYouSure(true); 104 + return; 105 + } 106 + // ... and areYouSure state is true, 107 + // and the user is not in an input or textarea, 108 + // if there is a card to close, close it and remove the block 109 + if (areYouSure) { 110 + let el = e.target as HTMLElement; 111 + 112 + if ( 113 + el.tagName === "INPUT" || 114 + el.tagName === "textarea" || 115 + el.contentEditable === "true" 116 + ) 117 + return; 118 + 119 + return deleteBlock([props.entityID].flat(), rep); 120 + } 121 + } 122 + 123 + e.preventDefault(); 124 + rep.mutate.removeBlock({ blockEntity: props.entityID }); 125 + useUIState.getState().closeCard(props.entityID); 126 + let prevBlock = props.previousBlock; 127 + if (prevBlock) focusBlock(prevBlock, { type: "end" }); 128 + } 129 + 130 + async function Enter({ props, rep, entity_set }: Args) { 131 + let newEntityID = v7(); 132 + let position; 133 + // if it's a list, create a new list item at the same depth 134 + if (props.listData) { 135 + let hasChild = 136 + props.nextBlock?.listData && 137 + props.nextBlock.listData.depth > props.listData.depth; 138 + position = generateKeyBetween( 139 + hasChild ? null : props.position, 140 + props.nextPosition, 141 + ); 142 + await rep?.mutate.addBlock({ 143 + newEntityID, 144 + factID: v7(), 145 + permission_set: entity_set.set, 146 + parent: hasChild ? props.entityID : props.listData.parent, 147 + type: "text", 148 + position, 149 + }); 150 + await rep?.mutate.assertFact({ 151 + entity: newEntityID, 152 + attribute: "block/is-list", 153 + data: { type: "boolean", value: true }, 154 + }); 155 + } 156 + 157 + // if it's not a list, create a new block between current and next block 158 + if (!props.listData) { 159 + position = generateKeyBetween(props.position, props.nextPosition); 160 + await rep?.mutate.addBlock({ 161 + newEntityID, 162 + factID: v7(), 163 + permission_set: entity_set.set, 164 + parent: props.parent, 165 + type: "text", 166 + position, 167 + }); 168 + } 169 + setTimeout(() => { 170 + document.getElementById(elementId.block(newEntityID).text)?.focus(); 171 + }, 10); 172 + } 173 + 174 + function Escape({ e, props, areYouSure, setAreYouSure }: Args) { 175 + e.preventDefault(); 176 + if (areYouSure) { 177 + setAreYouSure(false); 178 + focusBlock( 179 + { type: "card", value: props.entityID, parent: props.parent }, 180 + { type: "start" }, 181 + ); 182 + } 183 + 184 + useUIState.setState({ selectedBlocks: [] }); 185 + useUIState.setState({ focusedEntity: null }); 186 + }
+6 -5
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, textBlocks } from "."; 4 + import { Block } from "./Block"; 5 + import { isTextBlock } from "src/utils/isTextBlock"; 5 6 import { useEntitySetContext } from "components/EntitySetProvider"; 6 7 import { useReplicache } from "src/replicache"; 7 8 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; ··· 16 17 useSelectingMouse.setState({ start: props.value }); 17 18 if (e.shiftKey) { 18 19 if ( 19 - useUIState.getState().selectedBlock[0]?.value === props.value && 20 - useUIState.getState().selectedBlock.length === 1 20 + useUIState.getState().selectedBlocks[0]?.value === props.value && 21 + useUIState.getState().selectedBlocks.length === 1 21 22 ) 22 23 return; 23 24 e.preventDefault(); 24 25 useUIState.getState().addBlockToSelection(props); 25 26 } else { 26 - if (!textBlocks[props.type]) return; 27 + if (!isTextBlock[props.type]) return; 27 28 useUIState.getState().setFocusedBlock({ 28 - type: "block", 29 + entityType: "block", 29 30 entityID: props.value, 30 31 parent: props.parent, 31 32 });
+13 -13
components/Cards.tsx
··· 1 1 "use client"; 2 2 import { useUIState } from "src/useUIState"; 3 - import { Blocks, focusBlock } from "components/Blocks"; 3 + import { Blocks } from "components/Blocks"; 4 + import { focusBlock } from "src/utils/focusBlock"; 4 5 import useMeasure from "react-use-measure"; 5 6 import { elementId } from "src/utils/elementId"; 6 7 import { ThemePopover } from "./ThemeManager/ThemeSetter"; ··· 93 94 let { rep } = useReplicache(); 94 95 let isDraft = useReferenceToEntity("mailbox/draft", props.entityID); 95 96 96 - let focusedElement = useUIState((s) => s.focusedBlock); 97 + let focusedElement = useUIState((s) => s.focusedEntity); 97 98 let focusedCardID = 98 - focusedElement?.type === "card" 99 + focusedElement?.entityType === "card" 99 100 ? focusedElement.entityID 100 101 : focusedElement?.parent; 101 102 let isFocused = focusedCardID === props.entityID; ··· 244 245 export async function focusCard( 245 246 cardID: string, 246 247 rep: Replicache<ReplicacheMutators>, 247 - focusFirstBlock?: "focusFirstBlock" 248 + focusFirstBlock?: "focusFirstBlock", 248 249 ) { 249 250 // if this card is already focused, 250 - let focusedBlock = useUIState.getState().focusedBlock; 251 + let focusedBlock = useUIState.getState().focusedEntity; 251 252 if ( 252 - (focusedBlock?.type == "card" && focusedBlock.entityID === cardID) || 253 - (focusedBlock?.type === "block" && focusedBlock.parent === cardID) 253 + (focusedBlock?.entityType == "card" && focusedBlock.entityID === cardID) || 254 + (focusedBlock?.entityType === "block" && focusedBlock.parent === cardID) 254 255 ) 255 256 return; 256 257 // else set this card as focused 257 258 useUIState.setState(() => ({ 258 - focusedBlock: { 259 - type: "card", 259 + focusedEntity: { 260 + entityType: "card", 260 261 entityID: cardID, 261 262 }, 262 263 })); ··· 268 269 inline: "nearest", 269 270 }); 270 271 271 - // if we asked that the function focus the first block, do that 272 - 272 + // if we asked that the function focus the first block, focus the first block 273 273 if (focusFirstBlock === "focusFirstBlock") { 274 274 let firstBlock = await rep.query(async (tx) => { 275 275 let blocks = await tx ··· 315 315 316 316 const blurCard = () => { 317 317 useUIState.setState(() => ({ 318 - focusedBlock: null, 319 - selectedBlock: [], 318 + focusedEntity: null, 319 + selectedBlocks: [], 320 320 })); 321 321 };
+3 -3
components/DesktopFooter.tsx
··· 5 5 import { useEntitySetContext } from "./EntitySetProvider"; 6 6 7 7 export function DesktopCardFooter(props: { cardID: string }) { 8 - let focusedBlock = useUIState((s) => s.focusedBlock); 8 + let focusedBlock = useUIState((s) => s.focusedEntity); 9 9 let focusedBlockParentID = 10 - focusedBlock?.type === "card" 10 + focusedBlock?.entityType === "card" 11 11 ? focusedBlock.entityID 12 12 : focusedBlock?.parent; 13 13 let entity_set = useEntitySetContext(); ··· 17 17 className="absolute bottom-4 w-full z-10 pointer-events-none" 18 18 > 19 19 {focusedBlock && 20 - focusedBlock.type === "block" && 20 + focusedBlock.entityType === "block" && 21 21 entity_set.permissions.write && 22 22 focusedBlockParentID === props.cardID && ( 23 23 <div
+1 -1
components/Icons.tsx
··· 401 401 xmlns="http://www.w3.org/2000/svg" 402 402 > 403 403 <path 404 - fill-Rule="evenodd" 404 + fillRule="evenodd" 405 405 clipRule="evenodd" 406 406 d="M0 1.875C0 0.839466 0.839466 0 1.875 0H10.125C11.1605 0 12 0.839466 12 1.875V10.125C12 11.1605 11.1605 12 10.125 12H1.875C0.839466 12 0 11.1605 0 10.125V1.875ZM1.875 1.25C1.52982 1.25 1.25 1.52982 1.25 1.875V10.125C1.25 10.4702 1.52982 10.75 1.875 10.75H10.125C10.4702 10.75 10.75 10.4702 10.75 10.125V1.875C10.75 1.52982 10.4702 1.25 10.125 1.25H1.875Z" 407 407 fill="currentColor"
+2 -2
components/MobileFooter.tsx
··· 8 8 import { useEntitySetContext } from "./EntitySetProvider"; 9 9 10 10 export function MobileFooter(props: { entityID: string }) { 11 - let focusedBlock = useUIState((s) => s.focusedBlock); 11 + let focusedBlock = useUIState((s) => s.focusedEntity); 12 12 let entity_set = useEntitySetContext(); 13 13 14 14 return ( 15 15 <Media mobile className="mobileFooter w-full z-10 -mt-6 touch-none"> 16 16 {focusedBlock && 17 - focusedBlock.type == "block" && 17 + focusedBlock.entityType == "block" && 18 18 entity_set.permissions.write ? ( 19 19 <div 20 20 className="w-full z-10 p-2 flex bg-bg-card "
+14 -14
components/SelectionManager.tsx
··· 4 4 import { useReplicache } from "src/replicache"; 5 5 import { useUIState } from "src/useUIState"; 6 6 import { scanIndex } from "src/replicache/utils"; 7 - import { Block, focusBlock } from "./Blocks"; 7 + import { focusBlock } from "src/utils/focusBlock"; 8 8 import { useEditorStates } from "src/state/useEditorState"; 9 9 import { useEntitySetContext } from "./EntitySetProvider"; 10 10 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; ··· 23 23 // How does this relate to *when dragging* ? 24 24 25 25 export function SelectionManager() { 26 - let moreThanOneSelected = useUIState((s) => s.selectedBlock.length > 1); 26 + let moreThanOneSelected = useUIState((s) => s.selectedBlocks.length > 1); 27 27 let entity_set = useEntitySetContext(); 28 28 let { rep } = useReplicache(); 29 29 useEffect(() => { 30 30 if (!entity_set.permissions.write) return; 31 31 const getSortedSelection = async () => { 32 - let selectedBlocks = useUIState.getState().selectedBlock; 32 + let selectedBlocks = useUIState.getState().selectedBlocks; 33 33 let siblings = 34 34 (await rep?.query((tx) => 35 35 getBlocksWithType(tx, selectedBlocks[0].parent), ··· 48 48 (await rep?.query((tx) => 49 49 getBlocksWithType( 50 50 tx, 51 - useUIState.getState().selectedBlock[0].parent, 51 + useUIState.getState().selectedBlocks[0].parent, 52 52 ), 53 53 )) || []; 54 54 if (firstBlock) focusBlock(firstBlock, { type: "start" }); ··· 62 62 (await rep?.query((tx) => 63 63 getBlocksWithType( 64 64 tx, 65 - useUIState.getState().selectedBlock[0].parent, 65 + useUIState.getState().selectedBlocks[0].parent, 66 66 ), 67 67 )) || []; 68 68 let folded = useUIState.getState().foldedBlocks; ··· 196 196 if (moreThanOneSelected) { 197 197 e.preventDefault(); 198 198 let [sortedBlocks, siblings] = await getSortedSelection(); 199 - let selectedBlocks = useUIState.getState().selectedBlock; 199 + let selectedBlocks = useUIState.getState().selectedBlocks; 200 200 let firstBlock = sortedBlocks[0]; 201 201 202 202 await rep?.mutate.removeBlock( ··· 234 234 } 235 235 if (e.key === "ArrowUp") { 236 236 let [sortedBlocks, siblings] = await getSortedSelection(); 237 - let focusedBlock = useUIState.getState().focusedBlock; 237 + let focusedBlock = useUIState.getState().focusedEntity; 238 238 if (!e.shiftKey) { 239 239 if (sortedBlocks.length === 1) return; 240 240 let firstBlock = sortedBlocks[0]; ··· 253 253 if ( 254 254 sortedBlocks.length <= 1 || 255 255 !focusedBlock || 256 - focusedBlock.type === "card" 256 + focusedBlock.entityType === "card" 257 257 ) 258 258 return; 259 259 let b = focusedBlock; ··· 275 275 ...nextSelectedBlock, 276 276 }); 277 277 useUIState.getState().setFocusedBlock({ 278 - type: "block", 278 + entityType: "block", 279 279 parent: nextSelectedBlock.parent, 280 280 entityID: nextSelectedBlock.value, 281 281 }); 282 282 } else { 283 283 let nextBlock = sortedBlocks[sortedBlocks.length - 2]; 284 284 useUIState.getState().setFocusedBlock({ 285 - type: "block", 285 + entityType: "block", 286 286 parent: b.parent, 287 287 entityID: nextBlock.value, 288 288 }); ··· 374 374 } 375 375 if (e.key === "ArrowDown") { 376 376 let [sortedSelection, siblings] = await getSortedSelection(); 377 - let focusedBlock = useUIState.getState().focusedBlock; 377 + let focusedBlock = useUIState.getState().focusedEntity; 378 378 if (!e.shiftKey) { 379 379 if (sortedSelection.length === 1) return; 380 380 let lastBlock = sortedSelection[sortedSelection.length - 1]; ··· 394 394 if ( 395 395 sortedSelection.length <= 1 || 396 396 !focusedBlock || 397 - focusedBlock.type === "card" 397 + focusedBlock.entityType === "card" 398 398 ) 399 399 return; 400 400 let b = focusedBlock; ··· 418 418 false, 419 419 ); 420 420 useUIState.getState().setFocusedBlock({ 421 - type: "block", 421 + entityType: "block", 422 422 parent: nextSelectedBlock.parent, 423 423 entityID: nextSelectedBlock.value, 424 424 }); ··· 434 434 false, 435 435 ); 436 436 useUIState.getState().setFocusedBlock({ 437 - type: "block", 437 + entityType: "block", 438 438 parent: b.parent, 439 439 entityID: nextBlock.value, 440 440 });
+115 -186
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"; 1 + import { DeleteSmall, MoveBlockDown, MoveBlockUp } from "components/Icons"; 2 + import { useReplicache } from "src/replicache"; 11 3 import { ToolbarButton } from "."; 12 4 import { Separator, ShortcutKey } from "components/Layout"; 13 5 import { metaKey } from "src/utils/metaKey"; 14 6 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 15 7 import { useUIState } from "src/useUIState"; 16 8 17 - export const BlockToolbar = () => { 9 + export const BlockToolbar = (props: { 10 + setToolbarState: (state: "areYouSure" | "block") => void; 11 + }) => { 18 12 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 13 25 14 const getSortedSelection = async () => { 26 - let selectedBlocks = useUIState.getState().selectedBlock; 15 + let selectedBlocks = useUIState.getState().selectedBlocks; 27 16 let siblings = 28 17 (await rep?.query((tx) => 29 18 getBlocksWithType(tx, selectedBlocks[0].parent), ··· 34 23 return [sortedBlocks, siblings]; 35 24 }; 36 25 37 - const handleClose = () => { 38 - useUIState.setState({ focusedBlock: null }); 39 - }; 40 - 41 26 return ( 42 27 <div className="flex items-center gap-2 justify-between w-full"> 43 28 <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 - } 29 + <ToolbarButton 30 + onClick={() => { 31 + props.setToolbarState("areYouSure"); 32 + }} 33 + tooltipContent="Delete Block" 34 + > 35 + <DeleteSmall /> 36 + </ToolbarButton> 66 37 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> 38 + <Separator classname="h-5" /> 39 + <ToolbarButton 40 + onClick={async () => { 41 + let [sortedBlocks, siblings] = await getSortedSelection(); 42 + if (sortedBlocks.length > 1) return; 43 + let block = sortedBlocks[0]; 44 + let previousBlock = 45 + siblings?.[ 46 + siblings.findIndex((s) => s.value === block.value) - 1 47 + ]; 48 + if (previousBlock.value === block.listData?.parent) { 49 + previousBlock = 50 + siblings?.[ 51 + siblings.findIndex((s) => s.value === block.value) - 2 52 + ]; 53 + } 112 54 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} 55 + if ( 56 + previousBlock?.listData && 57 + block.listData && 58 + block.listData.depth > 1 && 59 + !previousBlock.listData.path.find( 60 + (f) => f.entity === block.listData?.parent, 61 + ) 62 + ) { 63 + let depth = block.listData.depth; 64 + let newParent = previousBlock.listData.path.find( 65 + (f) => f.depth === depth - 1, 66 + ); 67 + if (!newParent) return; 68 + if (useUIState.getState().foldedBlocks.includes(newParent.entity)) 69 + useUIState.getState().toggleFold(newParent.entity); 70 + rep?.mutate.moveBlock({ 71 + block: block.value, 72 + oldParent: block.listData?.parent, 73 + newParent: newParent.entity, 74 + position: { type: "end" }, 75 + }); 76 + } else { 77 + rep?.mutate.moveBlockUp({ 78 + entityID: block.value, 79 + parent: block.listData?.parent || block.parent, 80 + }); 81 + } 82 + }} 83 + tooltipContent={ 84 + <div className="flex flex-col gap-1 justify-center"> 85 + <div className="text-center">Move Up</div> 86 + <div className="flex gap-1"> 87 + <ShortcutKey>Shift</ShortcutKey> +{" "} 88 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 89 + <ShortcutKey> ↑ </ShortcutKey> 90 + </div> 91 + </div> 92 + } 168 93 > 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 - }; 94 + <MoveBlockUp /> 95 + </ToolbarButton> 188 96 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, 97 + <ToolbarButton 98 + onClick={async () => { 99 + let [sortedBlocks, siblings] = await getSortedSelection(); 100 + if (sortedBlocks.length > 1) return; 101 + let block = sortedBlocks[0]; 102 + let nextBlock = siblings 103 + .slice(siblings.findIndex((s) => s.value === block.value) + 1) 104 + .find( 105 + (f) => 106 + f.listData && 107 + block.listData && 108 + !f.listData.path.find((f) => f.entity === block.value), 109 + ); 110 + if ( 111 + nextBlock?.listData && 112 + block.listData && 113 + nextBlock.listData.depth === block.listData.depth - 1 114 + ) { 115 + if (useUIState.getState().foldedBlocks.includes(nextBlock.value)) 116 + useUIState.getState().toggleFold(nextBlock.value); 117 + rep?.mutate.moveBlock({ 118 + block: block.value, 119 + oldParent: block.listData?.parent, 120 + newParent: nextBlock.value, 121 + position: { type: "first" }, 122 + }); 123 + } else { 124 + rep?.mutate.moveBlockDown({ 125 + entityID: block.value, 126 + parent: block.listData?.parent || block.parent, 199 127 }); 128 + } 200 129 }} 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 " 130 + tooltipContent={ 131 + <div className="flex flex-col gap-1 justify-center"> 132 + <div className="text-center">Move Down</div> 133 + <div className="flex gap-1"> 134 + <ShortcutKey>Shift</ShortcutKey> +{" "} 135 + <ShortcutKey>{metaKey()}</ShortcutKey> +{" "} 136 + <ShortcutKey> ↓ </ShortcutKey> 137 + </div> 138 + </div> 139 + } 211 140 > 212 - <DeleteSmall /> 213 - </button> 214 - )} 215 - </> 141 + <MoveBlockDown /> 142 + </ToolbarButton> 143 + </div> 144 + </div> 216 145 ); 217 146 };
+2 -2
components/Toolbar/HighlightToolbar.tsx
··· 92 92 lastUsedHighlight: "1" | "2" | "3"; 93 93 setLastUsedHighlight: (color: "1" | "2" | "3") => void; 94 94 }) => { 95 - let focusedBlock = useUIState((s) => s.focusedBlock); 95 + let focusedBlock = useUIState((s) => s.focusedEntity); 96 96 let focusedEditor = useEditorStates((s) => 97 97 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 98 98 ); ··· 138 138 lastUsedHighlight: "1" | "2" | "3"; 139 139 setLastUsedHightlight: (color: "1" | "2" | "3") => void; 140 140 }) => { 141 - let focusedBlock = useUIState((s) => s.focusedBlock); 141 + let focusedBlock = useUIState((s) => s.focusedEntity); 142 142 let focusedEditor = useEditorStates((s) => 143 143 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 144 144 );
+2 -2
components/Toolbar/InlineLinkToolbar.tsx
··· 11 11 import { Input } from "components/Input"; 12 12 13 13 export function LinkButton(props: { setToolbarState: (s: "link") => void }) { 14 - let focusedBlock = useUIState((s) => s.focusedBlock); 14 + let focusedBlock = useUIState((s) => s.focusedEntity); 15 15 let focusedEditor = useEditorStates((s) => 16 16 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 17 17 ); ··· 46 46 } 47 47 48 48 export function InlineLinkToolbar(props: { onClose: () => void }) { 49 - let focusedBlock = useUIState((s) => s.focusedBlock); 49 + let focusedBlock = useUIState((s) => s.focusedEntity); 50 50 let focusedEditor = useEditorStates((s) => 51 51 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 52 52 );
+3 -3
components/Toolbar/ListToolbar.tsx
··· 15 15 import { useEffect } from "react"; 16 16 17 17 export const ListButton = (props: { setToolbarState: (s: "list") => void }) => { 18 - let focusedBlock = useUIState((s) => s.focusedBlock); 18 + let focusedBlock = useUIState((s) => s.focusedEntity); 19 19 let isList = useEntity(focusedBlock?.entityID || null, "block/is-list"); 20 20 21 21 let { rep } = useReplicache(); ··· 68 68 }; 69 69 70 70 export const ListToolbar = (props: { onClose: () => void }) => { 71 - let focusedBlock = useUIState((s) => s.focusedBlock); 71 + let focusedBlock = useUIState((s) => s.focusedEntity); 72 72 let siblings = useBlocks( 73 - focusedBlock?.type === "block" ? focusedBlock.parent : null, 73 + focusedBlock?.entityType === "block" ? focusedBlock.parent : null, 74 74 ); 75 75 76 76 let isCheckbox = useEntity(
+19 -51
components/Toolbar/MultiSelectToolbar.tsx
··· 2 2 import { useUIState } from "src/useUIState"; 3 3 import { ReplicacheMutators, useReplicache } from "src/replicache"; 4 4 import { ToolbarButton } from "./index"; 5 - import { TrashSmall, CloseTiny, CopySmall } from "components/Icons"; 5 + import { TrashSmall, CopySmall } from "components/Icons"; 6 6 import { AreYouSure } from "components/Blocks/DeleteBlock"; 7 7 import { copySelection } from "src/utils/copySelection"; 8 8 import { useSmoker } from "components/Toast"; 9 9 import { getBlocksWithType } from "src/hooks/queries/useBlocks"; 10 10 import { Replicache } from "replicache"; 11 11 12 - export const MultiSelectToolbar = () => { 12 + export const MultiselectToolbar = (props: { 13 + setToolbarState: (state: "areYouSure" | "multiselect") => void; 14 + }) => { 13 15 const { rep } = useReplicache(); 14 - const selectedBlocks = useUIState((s) => s.selectedBlock || []); 15 - const [areYouSure, setAreYouSure] = useState(false); 16 16 const smoker = useSmoker(); 17 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 18 const handleCopy = async (event: React.MouseEvent) => { 34 19 if (!rep) return; 35 20 const sortedSelection = await getSortedSelection(rep); ··· 43 28 return ( 44 29 <div className="flex items-center gap-2 justify-between w-full"> 45 30 <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 - )} 31 + <ToolbarButton 32 + tooltipContent="Delete Selected Blocks" 33 + onClick={() => { 34 + props.setToolbarState("areYouSure"); 35 + }} 36 + > 37 + <TrashSmall /> 38 + </ToolbarButton> 39 + <ToolbarButton 40 + tooltipContent="Copy Selected Blocks" 41 + onClick={handleCopy} 42 + > 43 + <CopySmall /> 44 + </ToolbarButton> 69 45 {/* Add more multi-select toolbar buttons here */} 70 46 </div> 71 - {!areYouSure && ( 72 - <button 73 - className="toolbarBackToDefault hover:text-accent-contrast" 74 - onClick={handleClose} 75 - > 76 - <CloseTiny /> 77 - </button> 78 - )} 79 47 </div> 80 48 ); 81 49 }; 82 50 83 51 // Helper function to get sorted selection 84 52 async function getSortedSelection(rep: Replicache<ReplicacheMutators>) { 85 - const selectedBlocks = useUIState.getState().selectedBlock; 53 + const selectedBlocks = useUIState.getState().selectedBlocks; 86 54 const siblings = 87 55 (await rep?.query((tx) => 88 56 getBlocksWithType(tx, selectedBlocks[0].parent),
+2 -2
components/Toolbar/TextBlockTypeToolbar.tsx
··· 16 16 onClose: () => void; 17 17 className?: string; 18 18 }) => { 19 - let focusedBlock = useUIState((s) => s.focusedBlock); 19 + let focusedBlock = useUIState((s) => s.focusedEntity); 20 20 let blockType = useEntity(focusedBlock?.entityID || null, "block/type"); 21 21 let headingLevel = useEntity( 22 22 focusedBlock?.entityID || null, ··· 165 165 setToolbarState: (s: "heading") => void; 166 166 className?: string; 167 167 }) { 168 - let focusedBlock = useUIState((s) => s.focusedBlock); 168 + let focusedBlock = useUIState((s) => s.focusedEntity); 169 169 return ( 170 170 <ToolbarButton 171 171 tooltipContent={<div>Format Text</div>}
+2 -2
components/Toolbar/TextDecorationButton.tsx
··· 14 14 icon: React.ReactNode; 15 15 tooltipContent: React.ReactNode; 16 16 }) { 17 - let focusedBlock = useUIState((s) => s.focusedBlock); 17 + let focusedBlock = useUIState((s) => s.focusedEntity); 18 18 let focusedEditor = useEditorStates((s) => 19 19 focusedBlock ? s.editorStates[focusedBlock.entityID] : null, 20 20 ); ··· 50 50 } 51 51 52 52 export function toggleMarkInFocusedBlock(mark: MarkType, attrs?: any) { 53 - let focusedBlock = useUIState.getState().focusedBlock; 53 + let focusedBlock = useUIState.getState().focusedEntity; 54 54 if (!focusedBlock) return; 55 55 publishAppEvent(focusedBlock.entityID, "toggleMark", { mark, attrs }); 56 56 }
+59 -20
components/Toolbar/index.tsx
··· 13 13 import { ListToolbar } from "./ListToolbar"; 14 14 import { HighlightToolbar } from "./HighlightToolbar"; 15 15 import { TextToolbar } from "./TextToolbar"; 16 - import { BlockToolbar, DeleteBlockButton } from "./BlockToolbar"; 17 - import { MultiSelectToolbar } from "./MultiSelectToolbar"; 16 + import { BlockToolbar } from "./BlockToolbar"; 17 + import { MultiselectToolbar } from "./MultiSelectToolbar"; 18 + import { focusCard } from "components/Cards"; 19 + import { AreYouSure, deleteBlock } from "components/Blocks/DeleteBlock"; 18 20 19 21 export type ToolbarTypes = 22 + | "areYouSure" 20 23 | "default" 21 24 | "highlight" 22 25 | "link" ··· 24 27 | "list" 25 28 | "linkBlock" 26 29 | "block" 27 - | "multiSelect"; 30 + | "multiselect"; 28 31 29 32 export const Toolbar = (props: { cardID: string; blockID: string }) => { 30 - let focusedBlock = useUIState((s) => s.focusedBlock); 31 - 32 - let blockType = useEntity(props.blockID, "block/type")?.data.value; 33 + let { rep } = useReplicache(); 33 34 34 35 let [toolbarState, setToolbarState] = useState<ToolbarTypes>("default"); 35 36 37 + let focusedBlock = useUIState((s) => s.focusedEntity); 38 + let selectedBlocks = useUIState((s) => s.selectedBlocks); 39 + let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 40 + 41 + let blockType = useEntity(props.blockID, "block/type")?.data.value; 42 + 36 43 let lastUsedHighlight = useUIState((s) => s.lastUsedHighlight); 37 44 let setLastUsedHighlight = (color: "1" | "2" | "3") => 38 45 useUIState.setState({ 39 46 lastUsedHighlight: color, 40 47 }); 41 48 42 - let activeEditor = useEditorStates((s) => s.editorStates[props.blockID]); 43 - 44 - let selectedBlocks = useUIState((s) => s.selectedBlock); 45 - 46 49 useEffect(() => { 47 50 if (toolbarState !== "default") return; 48 51 let removeShortcut = addShortcut({ ··· 56 59 removeShortcut(); 57 60 }; 58 61 }, [toolbarState]); 62 + 59 63 useEffect(() => { 60 64 if (blockType !== "heading" && blockType !== "text") { 61 65 setToolbarState("block"); ··· 65 69 }, [blockType]); 66 70 67 71 useEffect(() => { 68 - if (selectedBlocks.length > 1) { 69 - setToolbarState("multiSelect"); 70 - } else if (toolbarState === "multiSelect") { 72 + if (selectedBlocks.length > 1 && toolbarState !== "areYouSure") { 73 + setToolbarState("multiselect"); 74 + } else if (toolbarState === "multiselect") { 71 75 setToolbarState("default"); 72 76 } 73 77 }, [selectedBlocks.length, toolbarState]); ··· 103 107 ) : toolbarState === "heading" ? ( 104 108 <TextBlockTypeToolbar onClose={() => setToolbarState("default")} /> 105 109 ) : toolbarState === "block" ? ( 106 - <BlockToolbar /> 107 - ) : toolbarState === "multiSelect" ? ( 108 - <MultiSelectToolbar /> 110 + <BlockToolbar 111 + setToolbarState={(state) => { 112 + setToolbarState(state); 113 + }} 114 + /> 115 + ) : toolbarState === "multiselect" ? ( 116 + <MultiselectToolbar 117 + setToolbarState={(state) => { 118 + setToolbarState(state); 119 + }} 120 + /> 121 + ) : toolbarState === "areYouSure" ? ( 122 + <AreYouSure 123 + compact 124 + type={blockType} 125 + entityID={selectedBlocks.map((b) => b.value)} 126 + onClick={() => { 127 + rep && 128 + deleteBlock( 129 + selectedBlocks.map((b) => b.value), 130 + rep, 131 + ); 132 + }} 133 + closeAreYouSure={() => { 134 + let state: ToolbarTypes = 135 + selectedBlocks.length > 1 136 + ? "multiselect" 137 + : blockType !== "heading" && blockType !== "text" 138 + ? "block" 139 + : "default"; 140 + }} 141 + /> 109 142 ) : null} 110 143 </div> 111 - {toolbarState !== "multiSelect" && toolbarState !== "block" && ( 144 + {/* if the thing is are you sure state, don't show the x... is each thing handling its own are you sure? theres no need for that */} 145 + {toolbarState !== "areYouSure" && ( 112 146 <button 113 147 className="toolbarBackToDefault hover:text-accent-contrast" 114 148 onClick={() => { 149 + if (toolbarState === "multiselect" || toolbarState === "block") { 150 + useUIState.setState({ selectedBlocks: [] }); 151 + rep && focusCard(props.cardID, rep); 152 + } 153 + 115 154 if (toolbarState === "default") { 116 155 useUIState.setState(() => ({ 117 - focusedBlock: { 118 - type: "card", 156 + focusedEntity: { 157 + entityType: "card", 119 158 entityID: props.cardID, 120 159 }, 121 - selectedBlock: [], 160 + selectedBlocks: [], 122 161 })); 123 162 } else { 124 163 setToolbarState("default");
+1 -1
components/utils/UpdatePageTitle.tsx
··· 7 7 import * as base64 from "base64-js"; 8 8 import { YJSFragmentToString } from "components/Blocks/TextBlock/RenderYJSFragment"; 9 9 import { useParams, useRouter, useSearchParams } from "next/navigation"; 10 - import { focusBlock } from "components/Blocks"; 10 + import { focusBlock } from "src/utils/focusBlock"; 11 11 import { useIsMobile } from "src/hooks/isMobile"; 12 12 13 13 export function UpdatePageTitle(props: { entityID: string }) {
+1 -1
src/hooks/queries/useBlocks.ts
··· 1 - import { Block } from "components/Blocks"; 1 + import { Block } from "components/Blocks/Block"; 2 2 import { useMemo } from "react"; 3 3 import { ReadTransaction } from "replicache"; 4 4 import { useSubscribe } from "replicache-react";
+8 -1
src/replicache/attributes.ts
··· 153 153 reference: { type: "reference"; value: string }; 154 154 "block-type-union": { 155 155 type: "block-type-union"; 156 - value: "text" | "image" | "card" | "heading" | "link" | "mailbox"; 156 + value: 157 + | "text" 158 + | "image" 159 + | "card" 160 + | "heading" 161 + | "link" 162 + | "mailbox" 163 + | "collection"; 157 164 }; 158 165 color: { type: "color"; value: string }; 159 166 }[(typeof Attributes)[A]["type"]];
+1
src/replicache/push.ts
··· 48 48 console.log( 49 49 `Error occured while running mutation: ${name}`, 50 50 JSON.stringify(e), 51 + JSON.stringify(mutation, null, 2), 51 52 ); 52 53 } 53 54 await tx
+13 -13
src/useUIState.ts
··· 1 - import { Block } from "components/Blocks"; 1 + import { Block } from "components/Blocks/Block"; 2 2 import { create } from "zustand"; 3 3 import { combine, createJSONStorage, persist } from "zustand/middleware"; 4 4 ··· 7 7 combine( 8 8 { 9 9 lastUsedHighlight: "1" as "1" | "2" | "3", 10 - focusedBlock: null as 11 - | { type: "card"; entityID: string } 12 - | { type: "block"; entityID: string; parent: string } 10 + focusedEntity: null as 11 + | { entityType: "card"; entityID: string } 12 + | { entityType: "block"; entityID: string; parent: string } 13 13 | null, 14 14 foldedBlocks: [] as string[], 15 15 openCards: [] as string[], 16 - selectedBlock: [] as SelectedBlock[], 16 + selectedBlocks: [] as SelectedBlock[], 17 17 }, 18 18 (set) => ({ 19 19 toggleFold: (entityID: string) => { ··· 41 41 })), 42 42 setFocusedBlock: ( 43 43 b: 44 - | { type: "card"; entityID: string } 45 - | { type: "block"; entityID: string; parent: string } 44 + | { entityType: "card"; entityID: string } 45 + | { entityType: "block"; entityID: string; parent: string } 46 46 | null, 47 - ) => set(() => ({ focusedBlock: b })), 47 + ) => set(() => ({ focusedEntity: b })), 48 48 setSelectedBlock: (block: SelectedBlock) => 49 49 set((state) => { 50 - return { ...state, selectedBlock: [block] }; 50 + return { ...state, selectedBlocks: [block] }; 51 51 }), 52 52 setSelectedBlocks: (blocks: SelectedBlock[]) => 53 53 set((state) => { 54 - return { ...state, selectedBlock: blocks }; 54 + return { ...state, selectedBlocks: blocks }; 55 55 }), 56 56 addBlockToSelection: (block: SelectedBlock) => 57 57 set((state) => { 58 - if (state.selectedBlock.find((b) => b.value === block.value)) 58 + if (state.selectedBlocks.find((b) => b.value === block.value)) 59 59 return state; 60 - return { ...state, selectedBlock: [...state.selectedBlock, block] }; 60 + return { ...state, selectedBlocks: [...state.selectedBlocks, block] }; 61 61 }), 62 62 removeBlockFromSelection: (block: { value: string }) => 63 63 set((state) => { 64 64 return { 65 65 ...state, 66 - selectedBlock: state.selectedBlock.filter( 66 + selectedBlocks: state.selectedBlocks.filter( 67 67 (f) => f.value !== block.value, 68 68 ), 69 69 };
+6 -3
src/utils/copySelection.ts
··· 2 2 import { htmlToMarkdown } from "src/htmlMarkdownParsers"; 3 3 import { Replicache } from "replicache"; 4 4 import { ReplicacheMutators } from "src/replicache"; 5 - import { Block } from "components/Blocks"; 5 + import { Block } from "components/Blocks/Block"; 6 6 7 - export async function copySelection(rep: Replicache<ReplicacheMutators>, sortedSelection: Block[]) { 7 + export async function copySelection( 8 + rep: Replicache<ReplicacheMutators>, 9 + sortedSelection: Block[], 10 + ) { 8 11 let html = await getBlocksAsHTML(rep, sortedSelection); 9 12 const data = [ 10 13 new ClipboardItem({ ··· 15 18 }), 16 19 ]; 17 20 await navigator.clipboard.write(data); 18 - } 21 + }
+83
src/utils/focusBlock.ts
··· 1 + import { TextSelection } from "prosemirror-state"; 2 + import { useUIState } from "src/useUIState"; 3 + import { Block } from "components/Blocks/Block"; 4 + 5 + import { setEditorState, useEditorStates } from "src/state/useEditorState"; 6 + 7 + export function focusBlock( 8 + block: Pick<Block, "type" | "value" | "parent">, 9 + position: Position, 10 + ) { 11 + if (block.type !== "text" && block.type !== "heading") { 12 + useUIState.getState().setSelectedBlock(block); 13 + useUIState.getState().setFocusedBlock({ 14 + entityType: "block", 15 + entityID: block.value, 16 + parent: block.parent, 17 + }); 18 + return true; 19 + } 20 + let nextBlockID = block.value; 21 + let nextBlock = useEditorStates.getState().editorStates[nextBlockID]; 22 + if (!nextBlock || !nextBlock.view) return; 23 + nextBlock.view.dom.focus({ preventScroll: true }); 24 + let nextBlockViewClientRect = nextBlock.view.dom.getBoundingClientRect(); 25 + let tr = nextBlock.editor.tr; 26 + let pos: { pos: number } | null = null; 27 + switch (position.type) { 28 + case "end": { 29 + pos = { pos: tr.doc.content.size - 1 }; 30 + break; 31 + } 32 + case "start": { 33 + pos = { pos: 1 }; 34 + break; 35 + } 36 + case "top": { 37 + pos = nextBlock.view.posAtCoords({ 38 + top: nextBlockViewClientRect.top + 12, 39 + left: position.left, 40 + }); 41 + break; 42 + } 43 + case "bottom": { 44 + pos = nextBlock.view.posAtCoords({ 45 + top: nextBlockViewClientRect.bottom - 12, 46 + left: position.left, 47 + }); 48 + break; 49 + } 50 + case "coord": { 51 + pos = nextBlock.view.posAtCoords({ 52 + top: position.top, 53 + left: position.left, 54 + }); 55 + break; 56 + } 57 + } 58 + 59 + let newState = nextBlock.editor.apply( 60 + tr.setSelection(TextSelection.create(tr.doc, pos?.pos || 1)), 61 + ); 62 + 63 + setEditorState(nextBlockID, { editor: newState }); 64 + } 65 + 66 + type Position = 67 + | { 68 + type: "start"; 69 + } 70 + | { type: "end" } 71 + | { 72 + type: "coord"; 73 + top: number; 74 + left: number; 75 + } 76 + | { 77 + type: "top"; 78 + left: number; 79 + } 80 + | { 81 + type: "bottom"; 82 + left: number; 83 + };
+1 -1
src/utils/getBlocksAsHTML.tsx
··· 5 5 import * as Y from "yjs"; 6 6 import * as base64 from "base64-js"; 7 7 import { RenderYJSFragment } from "components/Blocks/TextBlock/RenderYJSFragment"; 8 - import { Block } from "components/Blocks"; 8 + import { Block } from "components/Blocks/Block"; 9 9 10 10 export async function getBlocksAsHTML( 11 11 rep: Replicache<ReplicacheMutators>,
+8
src/utils/isTextBlock.ts
··· 1 + import { Fact, useEntity, useReplicache } from "../replicache"; 2 + 3 + export const isTextBlock: { 4 + [k in Fact<"block/type">["data"]["value"]]?: boolean; 5 + } = { 6 + text: true, 7 + heading: true, 8 + };
+1 -1
src/utils/list-operations.ts
··· 1 - import { Block, BlockProps } from "components/Blocks"; 1 + import { Block } from "components/Blocks/Block"; 2 2 import { Replicache } from "replicache"; 3 3 import { ReplicacheMutators } from "src/replicache"; 4 4 import { useUIState } from "src/useUIState";