a tool for shared writing and social publishing
1import { useRef, useEffect, useState } from "react"; 2import { elementId } from "src/utils/elementId"; 3import { useReplicache, useEntity } from "src/replicache"; 4import { isVisible } from "src/utils/isVisible"; 5import { EditorState, TextSelection } from "prosemirror-state"; 6import { RenderYJSFragment } from "./RenderYJSFragment"; 7import { useHasPageLoaded } from "components/InitialPageLoadProvider"; 8import { BlockProps } from "../Block"; 9import { focusBlock } from "src/utils/focusBlock"; 10import { useUIState } from "src/useUIState"; 11import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 12import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 13import { useEditorStates } from "src/state/useEditorState"; 14import { useEntitySetContext } from "components/EntitySetProvider"; 15import { TooltipButton } from "components/Buttons"; 16import { blockCommands } from "../BlockCommands"; 17import { betterIsUrl } from "src/utils/isURL"; 18import { useSmoker } from "components/Toast"; 19import { AddTiny } from "components/Icons/AddTiny"; 20import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 21import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 22import { isIOS } from "src/utils/isDevice"; 23import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 24import { DotLoader } from "components/utils/DotLoader"; 25import { useMountProsemirror } from "./mountProsemirror"; 26 27const HeadingStyle = { 28 1: "text-xl font-bold", 29 2: "text-lg font-bold", 30 3: "text-base font-bold text-secondary ", 31} as { [level: number]: string }; 32 33export function TextBlock( 34 props: BlockProps & { 35 className?: string; 36 preview?: boolean; 37 }, 38) { 39 let isLocked = useEntity(props.entityID, "block/is-locked"); 40 let initialized = useHasPageLoaded(); 41 let first = props.previousBlock === null; 42 let permission = useEntitySetContext().permissions.write; 43 44 return ( 45 <> 46 {(!initialized || 47 !permission || 48 props.preview || 49 isLocked?.data.value) && ( 50 <RenderedTextBlock 51 type={props.type} 52 entityID={props.entityID} 53 className={props.className} 54 first={first} 55 pageType={props.pageType} 56 previousBlock={props.previousBlock} 57 /> 58 )} 59 {permission && !props.preview && !isLocked?.data.value && ( 60 <div 61 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 62 > 63 <IOSBS {...props} /> 64 <BaseTextBlock {...props} /> 65 </div> 66 )} 67 </> 68 ); 69} 70 71export function IOSBS(props: BlockProps) { 72 let [initialRender, setInitialRender] = useState(true); 73 useEffect(() => { 74 setInitialRender(false); 75 }, []); 76 if (initialRender || !isIOS()) return null; 77 return ( 78 <div 79 className="h-full w-full absolute cursor-text group-focus-within:hidden py-[18px]" 80 onPointerUp={(e) => { 81 e.preventDefault(); 82 focusBlock(props, { 83 type: "coord", 84 top: e.clientY, 85 left: e.clientX, 86 }); 87 setTimeout(async () => { 88 let target = document.getElementById( 89 elementId.block(props.entityID).container, 90 ); 91 let vis = await isVisible(target as Element); 92 if (!vis) { 93 let parentEl = document.getElementById( 94 elementId.page(props.parent).container, 95 ); 96 if (!parentEl) return; 97 parentEl?.scrollBy({ 98 top: 250, 99 behavior: "smooth", 100 }); 101 } 102 }, 100); 103 }} 104 /> 105 ); 106} 107 108export function RenderedTextBlock(props: { 109 entityID: string; 110 className?: string; 111 first?: boolean; 112 pageType?: "canvas" | "doc"; 113 type: BlockProps["type"]; 114 previousBlock?: BlockProps["previousBlock"]; 115}) { 116 let initialFact = useEntity(props.entityID, "block/text"); 117 let headingLevel = useEntity(props.entityID, "block/heading-level"); 118 let alignment = 119 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 120 let alignmentClass = { 121 left: "text-left", 122 right: "text-right", 123 center: "text-center", 124 justify: "text-justify", 125 }[alignment]; 126 let { permissions } = useEntitySetContext(); 127 128 let content = <br />; 129 if (!initialFact) { 130 if (permissions.write && (props.first || props.pageType === "canvas")) 131 content = ( 132 <div 133 className={`${props.className} 134 pointer-events-none italic text-tertiary flex flex-col `} 135 > 136 {headingLevel?.data.value === 1 137 ? "Title" 138 : headingLevel?.data.value === 2 139 ? "Header" 140 : headingLevel?.data.value === 3 141 ? "Subheader" 142 : "write something..."} 143 <div className=" text-xs font-normal"> 144 or type &quot;/&quot; for commands 145 </div> 146 </div> 147 ); 148 } else { 149 content = <RenderYJSFragment value={initialFact.data.value} wrapper="p" />; 150 } 151 return ( 152 <div 153 style={{ wordBreak: "break-word" }} // better than tailwind break-all! 154 className={` 155 ${alignmentClass} 156 ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} 157 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 158 w-full whitespace-pre-wrap outline-hidden ${props.className} `} 159 > 160 {content} 161 </div> 162 ); 163} 164 165export function BaseTextBlock(props: BlockProps & { className?: string }) { 166 let headingLevel = useEntity(props.entityID, "block/heading-level"); 167 let alignment = 168 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 169 170 let rep = useReplicache(); 171 172 let selected = useUIState( 173 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 174 ); 175 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 176 let alignmentClass = { 177 left: "text-left", 178 right: "text-right", 179 center: "text-center", 180 justify: "text-justify", 181 }[alignment]; 182 183 let editorState = useEditorStates( 184 (s) => s.editorStates[props.entityID], 185 )?.editor; 186 187 let { mountRef, actionTimeout } = useMountProsemirror({ 188 props, 189 }); 190 191 return ( 192 <> 193 <div 194 className={`flex items-center justify-between w-full 195 ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"} 196 ${ 197 props.type === "blockquote" 198 ? props.previousBlock?.type === "blockquote" && !props.listData 199 ? "blockquote pt-3" 200 : "blockquote" 201 : "" 202 } 203 204 `} 205 > 206 <pre 207 data-entityid={props.entityID} 208 onBlur={async () => { 209 if ( 210 ["***", "---", "___"].includes( 211 editorState?.doc.textContent.trim() || "", 212 ) 213 ) { 214 await rep.rep?.mutate.assertFact({ 215 entity: props.entityID, 216 attribute: "block/type", 217 data: { type: "block-type-union", value: "horizontal-rule" }, 218 }); 219 } 220 if (actionTimeout.current) { 221 rep.undoManager.endGroup(); 222 window.clearTimeout(actionTimeout.current); 223 actionTimeout.current = null; 224 } 225 }} 226 onFocus={() => { 227 setTimeout(() => { 228 useUIState.getState().setSelectedBlock(props); 229 useUIState.setState(() => ({ 230 focusedEntity: { 231 entityType: "block", 232 entityID: props.entityID, 233 parent: props.parent, 234 }, 235 })); 236 }, 5); 237 }} 238 id={elementId.block(props.entityID).text} 239 // unless we break *only* on urls, this is better than tailwind 'break-all' 240 // b/c break-all can cause breaks in the middle of words, but break-word still 241 // forces break if a single text string (e.g. a url) spans more than a full line 242 style={{ wordBreak: "break-word" }} 243 className={` 244 ${alignmentClass} 245 grow resize-none align-top whitespace-pre-wrap bg-transparent 246 outline-hidden 247 248 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 249 ${props.className}`} 250 ref={mountRef} 251 /> 252 {editorState?.doc.textContent.length === 0 && 253 props.previousBlock === null && 254 props.nextBlock === null ? ( 255 // if this is the only block on the page and is empty or is a canvas, show placeholder 256 <div 257 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 258 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 259 `} 260 > 261 {props.type === "text" 262 ? "write something..." 263 : headingLevel?.data.value === 3 264 ? "Subheader" 265 : headingLevel?.data.value === 2 266 ? "Header" 267 : "Title"} 268 <div className=" text-xs font-normal"> 269 or type &quot;/&quot; to add a block 270 </div> 271 </div> 272 ) : editorState?.doc.textContent.length === 0 && focused ? ( 273 // if not the only block on page but is the block is empty and selected, but NOT multiselected show add button 274 <CommandOptions {...props} className={props.className} /> 275 ) : null} 276 277 {editorState?.doc.textContent.startsWith("/") && selected && ( 278 <BlockCommandBar 279 props={props} 280 searchValue={editorState.doc.textContent.slice(1)} 281 /> 282 )} 283 </div> 284 <BlockifyLink entityID={props.entityID} editorState={editorState} /> 285 </> 286 ); 287} 288 289const BlockifyLink = (props: { 290 entityID: string; 291 editorState: EditorState | undefined; 292}) => { 293 let [loading, setLoading] = useState(false); 294 let { editorState } = props; 295 let rep = useReplicache(); 296 let smoker = useSmoker(); 297 let isLocked = useEntity(props.entityID, "block/is-locked"); 298 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 299 300 let isBlueskyPost = 301 editorState?.doc.textContent.includes("bsky.app/") && 302 editorState?.doc.textContent.includes("post"); 303 // only if the line stats with http or https and doesn't have other content 304 // if its bluesky, change text to embed post 305 306 if ( 307 !isLocked && 308 focused && 309 editorState && 310 betterIsUrl(editorState.doc.textContent) && 311 !editorState.doc.textContent.includes(" ") 312 ) { 313 return ( 314 <button 315 onClick={async (e) => { 316 if (!rep.rep) return; 317 rep.undoManager.startGroup(); 318 if (isBlueskyPost) { 319 let success = await addBlueskyPostBlock( 320 editorState.doc.textContent, 321 props.entityID, 322 rep.rep, 323 ); 324 if (!success) 325 smoker({ 326 error: true, 327 text: "post not found!", 328 position: { 329 x: e.clientX + 12, 330 y: e.clientY, 331 }, 332 }); 333 } else { 334 setLoading(true); 335 await addLinkBlock( 336 editorState.doc.textContent, 337 props.entityID, 338 rep.rep, 339 ); 340 setLoading(false); 341 } 342 rep.undoManager.endGroup(); 343 }} 344 className="absolute right-0 top-0 px-1 py-0.5 text-xs text-tertiary sm:hover:text-accent-contrast border border-border-light sm:hover:border-accent-contrast sm:outline-accent-tertiary rounded-md bg-bg-page selected-outline " 345 > 346 {loading ? <DotLoader /> : "embed"} 347 </button> 348 ); 349 } else return null; 350}; 351 352const CommandOptions = (props: BlockProps & { className?: string }) => { 353 let rep = useReplicache(); 354 let entity_set = useEntitySetContext(); 355 let { data: pub } = useLeafletPublicationData(); 356 357 return ( 358 <div 359 className={`absolute top-0 right-0 w-fit flex gap-[6px] items-center font-bold rounded-md text-sm text-border ${props.pageType === "canvas" && "mr-[6px]"}`} 360 > 361 <TooltipButton 362 className={props.className} 363 onMouseDown={async () => { 364 let command = blockCommands.find((f) => f.name === "Image"); 365 if (!rep.rep) return; 366 await command?.onSelect( 367 rep.rep, 368 { ...props, entity_set: entity_set.set }, 369 rep.undoManager, 370 ); 371 }} 372 side="bottom" 373 tooltipContent={ 374 <div className="flex gap-1 font-bold">Add an Image</div> 375 } 376 > 377 <BlockImageSmall className="hover:text-accent-contrast text-border" /> 378 </TooltipButton> 379 380 {!pub && ( 381 <TooltipButton 382 className={props.className} 383 onMouseDown={async () => { 384 let command = blockCommands.find((f) => f.name === "New Page"); 385 if (!rep.rep) return; 386 await command?.onSelect( 387 rep.rep, 388 { ...props, entity_set: entity_set.set }, 389 rep.undoManager, 390 ); 391 }} 392 side="bottom" 393 tooltipContent={ 394 <div className="flex gap-1 font-bold">Add a Subpage</div> 395 } 396 > 397 <BlockDocPageSmall className="hover:text-accent-contrast text-border" /> 398 </TooltipButton> 399 )} 400 401 <TooltipButton 402 className={props.className} 403 onMouseDown={(e) => { 404 e.preventDefault(); 405 let editor = useEditorStates.getState().editorStates[props.entityID]; 406 407 let editorState = editor?.editor; 408 if (editorState) { 409 editor?.view?.focus(); 410 let tr = editorState.tr.insertText("/", 1); 411 tr.setSelection(TextSelection.create(tr.doc, 2)); 412 useEditorStates.setState((s) => ({ 413 editorStates: { 414 ...s.editorStates, 415 [props.entityID]: { 416 ...s.editorStates[props.entityID]!, 417 editor: editorState!.apply(tr), 418 }, 419 }, 420 })); 421 } 422 focusBlock( 423 { 424 type: props.type, 425 value: props.entityID, 426 parent: props.parent, 427 }, 428 { type: "end" }, 429 ); 430 }} 431 side="bottom" 432 tooltipContent={<div className="flex gap-1 font-bold">Add More!</div>} 433 > 434 <div className="w-6 h-6 flex place-items-center justify-center"> 435 <AddTiny className="text-accent-contrast" /> 436 </div> 437 </TooltipButton> 438 </div> 439 ); 440}; 441 442const useMentionState = () => { 443 const [editorState, setEditorState] = useState<EditorState | null>(null); 444 const [mentionState, setMentionState] = useState<{ 445 active: boolean; 446 range: { from: number; to: number } | null; 447 selectedMention: { handle: string; did: string } | null; 448 }>({ active: false, range: null, selectedMention: null }); 449 const mentionStateRef = useRef(mentionState); 450 mentionStateRef.current = mentionState; 451 return { mentionStateRef }; 452};