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