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