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