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