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