a tool for shared writing and social publishing
at refactor/shared-home-layout 283 lines 8.8 kB view raw
1"use client"; 2 3import { Fact, useEntity, useReplicache } from "src/replicache"; 4 5import { useUIState } from "src/useUIState"; 6import { useBlocks } from "src/hooks/queries/useBlocks"; 7import { useEditorStates } from "src/state/useEditorState"; 8import { useEntitySetContext } from "components/EntitySetProvider"; 9 10import { isTextBlock } from "src/utils/isTextBlock"; 11import { focusBlock } from "src/utils/focusBlock"; 12import { elementId } from "src/utils/elementId"; 13import { generateKeyBetween } from "fractional-indexing"; 14import { v7 } from "uuid"; 15 16import { Block } from "./Block"; 17import { useEffect } from "react"; 18import { addShortcut } from "src/shortcuts"; 19import { QuoteEmbedBlock } from "./QuoteEmbedBlock"; 20import { useHandleDrop } from "./useHandleDrop"; 21 22export function Blocks(props: { entityID: string }) { 23 let rep = useReplicache(); 24 let isPageFocused = useUIState((s) => { 25 let focusedElement = s.focusedEntity; 26 let focusedPageID = 27 focusedElement?.entityType === "page" 28 ? focusedElement.entityID 29 : focusedElement?.parent; 30 return focusedPageID === props.entityID; 31 }); 32 let { permissions } = useEntitySetContext(); 33 let entity_set = useEntitySetContext(); 34 let blocks = useBlocks(props.entityID); 35 let foldedBlocks = useUIState((s) => s.foldedBlocks); 36 useEffect(() => { 37 if (!isPageFocused) return; 38 return addShortcut([ 39 { 40 altKey: true, 41 metaKey: true, 42 key: "ArrowUp", 43 shift: true, 44 handler: () => { 45 let allParents = blocks.reduce((acc, block) => { 46 if (!block.listData) return acc; 47 block.listData.path.forEach((p) => 48 !acc.includes(p.entity) ? acc.push(p.entity) : null, 49 ); 50 return acc; 51 }, [] as string[]); 52 useUIState.setState((s) => { 53 let foldedBlocks = [...s.foldedBlocks]; 54 allParents.forEach((p) => { 55 if (!foldedBlocks.includes(p)) foldedBlocks.push(p); 56 }); 57 return { foldedBlocks }; 58 }); 59 }, 60 }, 61 { 62 altKey: true, 63 metaKey: true, 64 key: "ArrowDown", 65 shift: true, 66 handler: () => { 67 let allParents = blocks.reduce((acc, block) => { 68 if (!block.listData) return acc; 69 block.listData.path.forEach((p) => 70 !acc.includes(p.entity) ? acc.push(p.entity) : null, 71 ); 72 return acc; 73 }, [] as string[]); 74 useUIState.setState((s) => { 75 let foldedBlocks = [...s.foldedBlocks].filter( 76 (f) => !allParents.includes(f), 77 ); 78 return { foldedBlocks }; 79 }); 80 }, 81 }, 82 ]); 83 }, [blocks, isPageFocused]); 84 85 let lastRootBlock = blocks.findLast( 86 (f) => !f.listData || f.listData.depth === 1, 87 ); 88 89 let lastVisibleBlock = blocks.findLast( 90 (f) => 91 !f.listData || 92 !f.listData.path.find( 93 (path) => foldedBlocks.includes(path.entity) && f.value !== path.entity, 94 ), 95 ); 96 97 return ( 98 <div 99 className={`blocks w-full flex flex-col outline-hidden h-fit min-h-full`} 100 onClick={async (e) => { 101 if (!permissions.write) return; 102 if (useUIState.getState().selectedBlocks.length > 1) return; 103 if (e.target === e.currentTarget) { 104 if ( 105 !lastVisibleBlock || 106 (lastVisibleBlock.type !== "text" && 107 lastVisibleBlock.type !== "heading") 108 ) { 109 let newEntityID = v7(); 110 await rep.rep?.mutate.addBlock({ 111 parent: props.entityID, 112 factID: v7(), 113 permission_set: entity_set.set, 114 type: "text", 115 position: generateKeyBetween( 116 lastRootBlock?.position || null, 117 null, 118 ), 119 newEntityID, 120 }); 121 122 setTimeout(() => { 123 document 124 .getElementById(elementId.block(newEntityID).text) 125 ?.focus(); 126 }, 10); 127 } else { 128 lastVisibleBlock && focusBlock(lastVisibleBlock, { type: "end" }); 129 } 130 } 131 }} 132 > 133 {blocks 134 .filter( 135 (f) => 136 !f.listData || 137 !f.listData.path.find( 138 (path) => 139 foldedBlocks.includes(path.entity) && f.value !== path.entity, 140 ), 141 ) 142 .map((f, index, arr) => { 143 let nextBlock = arr[index + 1]; 144 let depth = f.listData?.depth || 1; 145 let nextDepth = nextBlock?.listData?.depth || 1; 146 let nextPosition: string | null; 147 if (depth === nextDepth) nextPosition = nextBlock?.position || null; 148 else nextPosition = null; 149 return ( 150 <Block 151 pageType="doc" 152 {...f} 153 key={f.value} 154 entityID={f.value} 155 parent={props.entityID} 156 previousBlock={arr[index - 1] || null} 157 nextBlock={arr[index + 1] || null} 158 nextPosition={nextPosition} 159 /> 160 ); 161 })} 162 <NewBlockButton 163 lastBlock={lastRootBlock || null} 164 entityID={props.entityID} 165 /> 166 167 <BlockListBottom 168 lastVisibleBlock={lastVisibleBlock || undefined} 169 lastRootBlock={lastRootBlock || undefined} 170 entityID={props.entityID} 171 /> 172 </div> 173 ); 174} 175 176function NewBlockButton(props: { lastBlock: Block | null; entityID: string }) { 177 let { rep } = useReplicache(); 178 let entity_set = useEntitySetContext(); 179 let editorState = useEditorStates((s) => 180 props.lastBlock?.type === "text" 181 ? s.editorStates[props.lastBlock.value] 182 : null, 183 ); 184 185 let isLocked = useEntity(props.lastBlock?.value || null, "block/is-locked"); 186 if (!entity_set.permissions.write) return null; 187 if ( 188 ((props.lastBlock?.type === "text" && !isLocked?.data.value) || 189 props.lastBlock?.type === "heading") && 190 (!editorState?.editor || editorState.editor.doc.content.size <= 2) 191 ) 192 return null; 193 return ( 194 <div className="flex items-center justify-between group/text px-3 sm:px-4"> 195 <div 196 className="h-6 hover:cursor-text italic text-tertiary grow" 197 onMouseDown={async () => { 198 let newEntityID = v7(); 199 await rep?.mutate.addBlock({ 200 parent: props.entityID, 201 type: "text", 202 factID: v7(), 203 permission_set: entity_set.set, 204 position: generateKeyBetween( 205 props.lastBlock?.position || null, 206 null, 207 ), 208 newEntityID, 209 }); 210 211 setTimeout(() => { 212 document.getElementById(elementId.block(newEntityID).text)?.focus(); 213 }, 10); 214 }} 215 > 216 {/* this is here as a fail safe, in case a new page is created and there are no blocks in it yet, 217 we render a newblockbutton with a textblock-like placeholder instead of a proper first block. */} 218 {!props.lastBlock ? ( 219 <div className="pt-2 sm:pt-3">write something...</div> 220 ) : ( 221 " " 222 )} 223 </div> 224 </div> 225 ); 226} 227 228const BlockListBottom = (props: { 229 lastRootBlock: Block | undefined; 230 lastVisibleBlock: Block | undefined; 231 entityID: string; 232}) => { 233 let { rep } = useReplicache(); 234 let entity_set = useEntitySetContext(); 235 let handleDrop = useHandleDrop({ 236 parent: props.entityID, 237 position: props.lastRootBlock?.position || null, 238 nextPosition: null, 239 }); 240 241 if (!entity_set.permissions.write) return; 242 return ( 243 <div 244 className="blockListClickableBottomArea shrink-0 h-[50vh]" 245 onClick={() => { 246 let newEntityID = v7(); 247 if ( 248 // if the last visible(not-folded) block is a text block, focus it 249 props.lastRootBlock && 250 props.lastVisibleBlock && 251 isTextBlock[props.lastVisibleBlock.type] 252 ) { 253 focusBlock( 254 { ...props.lastVisibleBlock, type: "text" }, 255 { type: "end" }, 256 ); 257 } else { 258 // else add a new text block at the end and focus it 259 rep?.mutate.addBlock({ 260 permission_set: entity_set.set, 261 factID: v7(), 262 parent: props.entityID, 263 type: "text", 264 position: generateKeyBetween( 265 props.lastRootBlock?.position || null, 266 null, 267 ), 268 newEntityID, 269 }); 270 271 setTimeout(() => { 272 document.getElementById(elementId.block(newEntityID).text)?.focus(); 273 }, 10); 274 } 275 }} 276 onDragOver={(e) => { 277 e.preventDefault(); 278 e.stopPropagation(); 279 }} 280 onDrop={handleDrop} 281 /> 282 ); 283};