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