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