a tool for shared writing and social publishing
at feature/reader 621 lines 21 kB view raw
1import { useRef, useEffect, useState, useLayoutEffect } from "react"; 2import { elementId } from "src/utils/elementId"; 3import { baseKeymap } from "prosemirror-commands"; 4import { keymap } from "prosemirror-keymap"; 5import * as Y from "yjs"; 6import * as base64 from "base64-js"; 7import { useReplicache, useEntity, ReplicacheMutators } from "src/replicache"; 8import { isVisible } from "src/utils/isVisible"; 9 10import { EditorState, TextSelection } from "prosemirror-state"; 11import { EditorView } from "prosemirror-view"; 12 13import { ySyncPlugin } from "y-prosemirror"; 14import { Replicache } from "replicache"; 15import { RenderYJSFragment } from "./RenderYJSFragment"; 16import { useInitialPageLoad } from "components/InitialPageLoadProvider"; 17import { BlockProps } from "../Block"; 18import { focusBlock } from "src/utils/focusBlock"; 19import { TextBlockKeymap } from "./keymap"; 20import { multiBlockSchema, schema } from "./schema"; 21import { useUIState } from "src/useUIState"; 22import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; 23import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; 24import { useEditorStates } from "src/state/useEditorState"; 25import { useEntitySetContext } from "components/EntitySetProvider"; 26import { useHandlePaste } from "./useHandlePaste"; 27import { highlightSelectionPlugin } from "./plugins"; 28import { inputrules } from "./inputRules"; 29import { autolink } from "./autolink-plugin"; 30import { TooltipButton } from "components/Buttons"; 31import { blockCommands } from "../BlockCommands"; 32import { betterIsUrl } from "src/utils/isURL"; 33import { useSmoker } from "components/Toast"; 34import { AddTiny } from "components/Icons/AddTiny"; 35import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; 36import { BlockImageSmall } from "components/Icons/BlockImageSmall"; 37import { isIOS } from "src/utils/isDevice"; 38import { useLeafletPublicationData } from "components/PageSWRDataProvider"; 39import { DotLoader } from "components/utils/DotLoader"; 40 41const HeadingStyle = { 42 1: "text-xl font-bold", 43 2: "text-lg font-bold", 44 3: "text-base font-bold text-secondary ", 45} as { [level: number]: string }; 46 47export function TextBlock( 48 props: BlockProps & { 49 className?: string; 50 preview?: boolean; 51 }, 52) { 53 let isLocked = useEntity(props.entityID, "block/is-locked"); 54 let initialized = useInitialPageLoad(); 55 let first = props.previousBlock === null; 56 let permission = useEntitySetContext().permissions.write; 57 58 return ( 59 <> 60 {(!initialized || 61 !permission || 62 props.preview || 63 isLocked?.data.value) && ( 64 <RenderedTextBlock 65 type={props.type} 66 entityID={props.entityID} 67 className={props.className} 68 first={first} 69 pageType={props.pageType} 70 /> 71 )} 72 {permission && !props.preview && !isLocked?.data.value && ( 73 <div 74 className={`w-full relative group ${!initialized ? "hidden" : ""}`} 75 > 76 <IOSBS {...props} /> 77 <BaseTextBlock {...props} /> 78 </div> 79 )} 80 </> 81 ); 82} 83 84export function IOSBS(props: BlockProps) { 85 let [initialRender, setInitialRender] = useState(true); 86 useEffect(() => { 87 setInitialRender(false); 88 }, []); 89 if (initialRender || !isIOS()) return null; 90 return ( 91 <div 92 className="h-full w-full absolute cursor-text group-focus-within:hidden py-[18px]" 93 onPointerUp={(e) => { 94 e.preventDefault(); 95 focusBlock(props, { 96 type: "coord", 97 top: e.clientY, 98 left: e.clientX, 99 }); 100 setTimeout(async () => { 101 let target = document.getElementById( 102 elementId.block(props.entityID).container, 103 ); 104 let vis = await isVisible(target as Element); 105 if (!vis) { 106 let parentEl = document.getElementById( 107 elementId.page(props.parent).container, 108 ); 109 if (!parentEl) return; 110 parentEl?.scrollBy({ 111 top: 250, 112 behavior: "smooth", 113 }); 114 } 115 }, 100); 116 }} 117 /> 118 ); 119} 120 121export function RenderedTextBlock(props: { 122 entityID: string; 123 className?: string; 124 first?: boolean; 125 pageType?: "canvas" | "doc"; 126 type: BlockProps["type"]; 127}) { 128 let initialFact = useEntity(props.entityID, "block/text"); 129 let headingLevel = useEntity(props.entityID, "block/heading-level"); 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 { 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" ? " blockquote " : ""} 169 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 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 mountRef = useRef<HTMLPreElement | null>(null); 179 let actionTimeout = useRef<number | null>(null); 180 let repRef = useRef<null | Replicache<ReplicacheMutators>>(null); 181 let headingLevel = useEntity(props.entityID, "block/heading-level"); 182 let entity_set = useEntitySetContext(); 183 let alignment = 184 useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; 185 let propsRef = useRef({ ...props, entity_set, alignment }); 186 useEffect(() => { 187 propsRef.current = { ...props, entity_set, alignment }; 188 }, [props, entity_set, alignment]); 189 let rep = useReplicache(); 190 useEffect(() => { 191 repRef.current = rep.rep; 192 }, [rep?.rep]); 193 194 let selected = useUIState( 195 (s) => !!s.selectedBlocks.find((b) => b.value === props.entityID), 196 ); 197 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 198 let alignmentClass = { 199 left: "text-left", 200 right: "text-right", 201 center: "text-center", 202 justify: "text-justify", 203 }[alignment]; 204 205 let value = useYJSValue(props.entityID); 206 207 let editorState = useEditorStates( 208 (s) => s.editorStates[props.entityID], 209 )?.editor; 210 let handlePaste = useHandlePaste(props.entityID, propsRef); 211 useLayoutEffect(() => { 212 if (!mountRef.current) return; 213 let km = TextBlockKeymap(propsRef, repRef, rep.undoManager); 214 let editor = EditorState.create({ 215 schema: schema, 216 plugins: [ 217 ySyncPlugin(value), 218 keymap(km), 219 inputrules(propsRef, repRef), 220 keymap(baseKeymap), 221 highlightSelectionPlugin, 222 autolink({ 223 type: schema.marks.link, 224 shouldAutoLink: () => true, 225 defaultProtocol: "https", 226 }), 227 ], 228 }); 229 230 let unsubscribe = useEditorStates.subscribe((s) => { 231 let editorState = s.editorStates[props.entityID]; 232 if (editorState?.initial) return; 233 if (editorState?.editor) 234 editorState.view?.updateState(editorState.editor); 235 }); 236 let view = new EditorView( 237 { mount: mountRef.current }, 238 { 239 state: editor, 240 handlePaste, 241 handleClickOn: (view, _pos, node, _nodePos, _event, direct) => { 242 if (!direct) return; 243 if (node.nodeSize - 2 <= _pos) return; 244 let mark = 245 node 246 .nodeAt(_pos - 1) 247 ?.marks.find((f) => f.type === schema.marks.link) || 248 node 249 .nodeAt(Math.max(_pos - 2, 0)) 250 ?.marks.find((f) => f.type === schema.marks.link); 251 if (mark) { 252 window.open(mark.attrs.href, "_blank"); 253 } 254 }, 255 dispatchTransaction(tr) { 256 useEditorStates.setState((s) => { 257 let oldEditorState = this.state; 258 let newState = this.state.apply(tr); 259 let addToHistory = tr.getMeta("addToHistory"); 260 let isBulkOp = tr.getMeta("bulkOp"); 261 let docHasChanges = tr.steps.length !== 0 || tr.docChanged; 262 if (addToHistory !== false && docHasChanges) { 263 if (actionTimeout.current) { 264 window.clearTimeout(actionTimeout.current); 265 } else { 266 if (!isBulkOp) rep.undoManager.startGroup(); 267 } 268 269 if (!isBulkOp) 270 actionTimeout.current = window.setTimeout(() => { 271 rep.undoManager.endGroup(); 272 actionTimeout.current = null; 273 }, 200); 274 rep.undoManager.add({ 275 redo: () => { 276 useEditorStates.setState((oldState) => { 277 let view = oldState.editorStates[props.entityID]?.view; 278 if (!view?.hasFocus() && !isBulkOp) view?.focus(); 279 return { 280 editorStates: { 281 ...oldState.editorStates, 282 [props.entityID]: { 283 ...oldState.editorStates[props.entityID]!, 284 editor: newState, 285 }, 286 }, 287 }; 288 }); 289 }, 290 undo: () => { 291 useEditorStates.setState((oldState) => { 292 let view = oldState.editorStates[props.entityID]?.view; 293 if (!view?.hasFocus() && !isBulkOp) view?.focus(); 294 return { 295 editorStates: { 296 ...oldState.editorStates, 297 [props.entityID]: { 298 ...oldState.editorStates[props.entityID]!, 299 editor: oldEditorState, 300 }, 301 }, 302 }; 303 }); 304 }, 305 }); 306 } 307 308 return { 309 editorStates: { 310 ...s.editorStates, 311 [props.entityID]: { 312 editor: newState, 313 view: this as unknown as EditorView, 314 initial: false, 315 keymap: km, 316 }, 317 }, 318 }; 319 }); 320 }, 321 }, 322 ); 323 return () => { 324 unsubscribe(); 325 view.destroy(); 326 useEditorStates.setState((s) => ({ 327 ...s, 328 editorStates: { 329 ...s.editorStates, 330 [props.entityID]: undefined, 331 }, 332 })); 333 }; 334 }, [props.entityID, props.parent, value, handlePaste, rep]); 335 336 return ( 337 <> 338 <div 339 className={`flex items-center justify-between w-full 340 ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"} 341 ${props.type === "blockquote" ? " blockquote " : ""} 342 `} 343 > 344 <pre 345 data-entityid={props.entityID} 346 onBlur={async () => { 347 if ( 348 ["***", "---", "___"].includes( 349 editorState?.doc.textContent.trim() || "", 350 ) 351 ) { 352 await rep.rep?.mutate.assertFact({ 353 entity: props.entityID, 354 attribute: "block/type", 355 data: { type: "block-type-union", value: "horizontal-rule" }, 356 }); 357 } 358 if (actionTimeout.current) { 359 rep.undoManager.endGroup(); 360 window.clearTimeout(actionTimeout.current); 361 actionTimeout.current = null; 362 } 363 }} 364 onFocus={() => { 365 setTimeout(() => { 366 useUIState.getState().setSelectedBlock(props); 367 useUIState.setState(() => ({ 368 focusedEntity: { 369 entityType: "block", 370 entityID: props.entityID, 371 parent: props.parent, 372 }, 373 })); 374 }, 5); 375 }} 376 id={elementId.block(props.entityID).text} 377 // unless we break *only* on urls, this is better than tailwind 'break-all' 378 // b/c break-all can cause breaks in the middle of words, but break-word still 379 // forces break if a single text string (e.g. a url) spans more than a full line 380 style={{ wordBreak: "break-word" }} 381 className={` 382 ${alignmentClass} 383 grow resize-none align-top whitespace-pre-wrap bg-transparent 384 outline-hidden 385 386 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 387 ${props.className}`} 388 ref={mountRef} 389 /> 390 {editorState?.doc.textContent.length === 0 && 391 props.previousBlock === null && 392 props.nextBlock === null ? ( 393 // if this is the only block on the page and is empty or is a canvas, show placeholder 394 <div 395 className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col 396 ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : ""} 397 `} 398 > 399 {props.type === "text" 400 ? "write something..." 401 : headingLevel?.data.value === 3 402 ? "Subheader" 403 : headingLevel?.data.value === 2 404 ? "Header" 405 : "Title"} 406 <div className=" text-xs font-normal"> 407 or type &quot;/&quot; to add a block 408 </div> 409 </div> 410 ) : editorState?.doc.textContent.length === 0 && focused ? ( 411 // if not the only block on page but is the block is empty and selected, but NOT multiselected show add button 412 <CommandOptions {...props} className={props.className} /> 413 ) : null} 414 415 {editorState?.doc.textContent.startsWith("/") && selected && ( 416 <BlockCommandBar 417 props={props} 418 searchValue={editorState.doc.textContent.slice(1)} 419 /> 420 )} 421 </div> 422 <BlockifyLink entityID={props.entityID} editorState={editorState} /> 423 </> 424 ); 425} 426 427const BlockifyLink = (props: { 428 entityID: string; 429 editorState: EditorState | undefined; 430}) => { 431 let [loading, setLoading] = useState(false); 432 let { editorState } = props; 433 let rep = useReplicache(); 434 let smoker = useSmoker(); 435 let isLocked = useEntity(props.entityID, "block/is-locked"); 436 let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); 437 438 let isBlueskyPost = 439 editorState?.doc.textContent.includes("bsky.app/") && 440 editorState?.doc.textContent.includes("post"); 441 // only if the line stats with http or https and doesn't have other content 442 // if its bluesky, change text to embed post 443 444 if ( 445 !isLocked && 446 focused && 447 editorState && 448 betterIsUrl(editorState.doc.textContent) && 449 !editorState.doc.textContent.includes(" ") 450 ) { 451 return ( 452 <button 453 onClick={async (e) => { 454 if (!rep.rep) return; 455 rep.undoManager.startGroup(); 456 if (isBlueskyPost) { 457 let success = await addBlueskyPostBlock( 458 editorState.doc.textContent, 459 props.entityID, 460 rep.rep, 461 ); 462 if (!success) 463 smoker({ 464 error: true, 465 text: "post not found!", 466 position: { 467 x: e.clientX + 12, 468 y: e.clientY, 469 }, 470 }); 471 } else { 472 setLoading(true); 473 await addLinkBlock( 474 editorState.doc.textContent, 475 props.entityID, 476 rep.rep, 477 ); 478 setLoading(false); 479 } 480 rep.undoManager.endGroup(); 481 }} 482 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 " 483 > 484 {loading ? <DotLoader /> : "embed"} 485 </button> 486 ); 487 } else return null; 488}; 489 490const CommandOptions = (props: BlockProps & { className?: string }) => { 491 let rep = useReplicache(); 492 let entity_set = useEntitySetContext(); 493 let { data: pub } = useLeafletPublicationData(); 494 495 return ( 496 <div 497 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]"}`} 498 > 499 <TooltipButton 500 className={props.className} 501 onMouseDown={async () => { 502 let command = blockCommands.find((f) => f.name === "Image"); 503 if (!rep.rep) return; 504 await command?.onSelect( 505 rep.rep, 506 { ...props, entity_set: entity_set.set }, 507 rep.undoManager, 508 ); 509 }} 510 side="bottom" 511 tooltipContent={ 512 <div className="flex gap-1 font-bold">Add an Image</div> 513 } 514 > 515 <BlockImageSmall className="hover:text-accent-contrast text-border" /> 516 </TooltipButton> 517 518 {!pub && ( 519 <TooltipButton 520 className={props.className} 521 onMouseDown={async () => { 522 let command = blockCommands.find((f) => f.name === "New Page"); 523 if (!rep.rep) return; 524 await command?.onSelect( 525 rep.rep, 526 { ...props, entity_set: entity_set.set }, 527 rep.undoManager, 528 ); 529 }} 530 side="bottom" 531 tooltipContent={ 532 <div className="flex gap-1 font-bold">Add a Subpage</div> 533 } 534 > 535 <BlockDocPageSmall className="hover:text-accent-contrast text-border" /> 536 </TooltipButton> 537 )} 538 539 <TooltipButton 540 className={props.className} 541 onMouseDown={(e) => { 542 e.preventDefault(); 543 let editor = useEditorStates.getState().editorStates[props.entityID]; 544 545 let editorState = editor?.editor; 546 if (editorState) { 547 editor?.view?.focus(); 548 let tr = editorState.tr.insertText("/", 1); 549 tr.setSelection(TextSelection.create(tr.doc, 2)); 550 useEditorStates.setState((s) => ({ 551 editorStates: { 552 ...s.editorStates, 553 [props.entityID]: { 554 ...s.editorStates[props.entityID]!, 555 editor: editorState!.apply(tr), 556 }, 557 }, 558 })); 559 } 560 focusBlock( 561 { 562 type: props.type, 563 value: props.entityID, 564 parent: props.parent, 565 }, 566 { type: "end" }, 567 ); 568 }} 569 side="bottom" 570 tooltipContent={<div className="flex gap-1 font-bold">Add More!</div>} 571 > 572 <div className="w-6 h-6 flex place-items-center justify-center"> 573 <AddTiny className="text-accent-contrast" /> 574 </div> 575 </TooltipButton> 576 </div> 577 ); 578}; 579 580function useYJSValue(entityID: string) { 581 const [ydoc] = useState(new Y.Doc()); 582 const docStateFromReplicache = useEntity(entityID, "block/text"); 583 let rep = useReplicache(); 584 const [yText] = useState(ydoc.getXmlFragment("prosemirror")); 585 586 if (docStateFromReplicache) { 587 const update = base64.toByteArray(docStateFromReplicache.data.value); 588 Y.applyUpdate(ydoc, update); 589 } 590 591 useEffect(() => { 592 if (!rep.rep) return; 593 let timeout = null as null | number; 594 const updateReplicache = async () => { 595 const update = Y.encodeStateAsUpdate(ydoc); 596 await rep.rep?.mutate.assertFact({ 597 //These undos are handled above in the Prosemirror context 598 ignoreUndo: true, 599 entity: entityID, 600 attribute: "block/text", 601 data: { 602 value: base64.fromByteArray(update), 603 type: "text", 604 }, 605 }); 606 }; 607 const f = async (events: Y.YEvent<any>[], transaction: Y.Transaction) => { 608 if (!transaction.origin) return; 609 if (timeout) clearTimeout(timeout); 610 timeout = window.setTimeout(async () => { 611 updateReplicache(); 612 }, 300); 613 }; 614 615 yText.observeDeep(f); 616 return () => { 617 yText.unobserveDeep(f); 618 }; 619 }, [yText, entityID, rep, ydoc]); 620 return yText; 621}