import { useRef, useEffect, useState, useCallback } from "react"; import { elementId } from "src/utils/elementId"; import { useReplicache, useEntity } from "src/replicache"; import { isVisible } from "src/utils/isVisible"; import { TextSelection } from "prosemirror-state"; import { EditorView } from "prosemirror-view"; import { RenderYJSFragment } from "./RenderYJSFragment"; import { useHasPageLoaded } from "components/InitialPageLoadProvider"; import { BlockProps } from "../Block"; import { focusBlock } from "src/utils/focusBlock"; import { addBlockBelow, focusNewTextBlock } from "src/utils/addBlockBelow"; import { useIsBlockSelected, useUIState } from "src/useUIState"; import { addBlueskyPostBlock, addLinkBlock } from "src/utils/addLinkBlock"; import { BlockCommandBar } from "components/Blocks/BlockCommandBar"; import { useEditorStates } from "src/state/useEditorState"; import { useEntitySetContext } from "components/EntitySetProvider"; import { TooltipButton } from "components/Buttons"; import { blockCommands } from "../BlockCommands"; import { betterIsUrl } from "src/utils/isURL"; import { useSmoker } from "components/Toast"; import { AddTiny } from "components/Icons/AddTiny"; import { BlockDocPageSmall } from "components/Icons/BlockDocPageSmall"; import { BlockImageSmall } from "components/Icons/BlockImageSmall"; import { isIOS } from "src/utils/isDevice"; import { useLeafletPublicationData } from "components/PageSWRDataProvider"; import { DotLoader } from "components/utils/DotLoader"; import { useMountProsemirror } from "./mountProsemirror"; import { schema } from "./schema"; import { useStaleClient } from "./schemaVersion"; import { useFootnotePopoverStore } from "components/Footnotes/FootnotePopover"; import { blockTextSize } from "src/utils/blockTextSize"; import { getAspectRatio } from "src/utils/aspectRatio"; import { Mention, MentionAutocomplete } from "components/Mention"; import { addMentionToEditor } from "app/(app)/(identity)/[leaflet_id]/publish/BskyPostEditorProsemirror"; const HeadingStyle = { 1: "font-bold leading-tight pb-1 [font-family:var(--theme-heading-font)]", 2: "font-bold leading-tight pb-1 [font-family:var(--theme-heading-font)]", 3: "font-bold leading-tight pb-1 [font-family:var(--theme-heading-font)]", 4: "font-bold leading-snug pb-1 text-secondary [font-family:var(--theme-heading-font)]", } as { [level: number]: string }; const headingFontSize = { 1: blockTextSize.h1, 2: blockTextSize.h2, 3: blockTextSize.h3, 4: blockTextSize.h4, } as { [level: number]: string }; export function TextBlock( props: BlockProps & { className?: string; preview?: boolean; }, ) { let initialized = useHasPageLoaded(); let first = props.previousBlock === null; let permission = useEntitySetContext().permissions.write; // Stale clients (newer-schema content exists; see ./schemaVersion) keep // rendering but must not mount an editor. let stale = useStaleClient((s) => s.stale); return ( <> {(!initialized || !permission || props.preview || stale) && ( )} {permission && !props.preview && !stale && (
)} ); } function IOSBS(props: BlockProps) { let [initialRender, setInitialRender] = useState(true); useEffect(() => { setInitialRender(false); }, []); if (initialRender || !isIOS()) return null; return (
{ e.preventDefault(); focusBlock(props, { type: "coord", top: e.clientY, left: e.clientX, }); setTimeout(async () => { let target = document.getElementById( elementId.block(props.entityID).container, ); let vis = await isVisible(target as Element); if (!vis) { let parentEl = document.getElementById( elementId.page(props.parent).container, ); if (!parentEl) return; parentEl?.scrollBy({ top: 250, behavior: "smooth", }); } }, 100); }} /> ); } export function RenderedTextBlock(props: { entityID: string; className?: string; first?: boolean; pageType?: "canvas" | "doc"; type: BlockProps["type"]; previousBlock?: BlockProps["previousBlock"]; }) { let initialFact = useEntity(props.entityID, "block/text"); let headingLevel = useEntity(props.entityID, "block/heading-level"); let textSize = useEntity(props.entityID, "block/text-size"); let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; let alignmentClass = { left: "text-left", right: "text-right", center: "text-center", justify: "text-justify", }[alignment]; let textStyle = textSize?.data.value === "small" ? "textSizeSmall" : textSize?.data.value === "large" ? "textSizeLarge" : ""; let { permissions } = useEntitySetContext(); let content =
; if (!initialFact) { if (permissions.write && (props.first || props.pageType === "canvas")) content = (
{headingLevel?.data.value === 1 ? "Title" : headingLevel?.data.value === 2 ? "Header" : headingLevel?.data.value === 3 ? "Subheader" : "write something..."}
or type "/" for commands
); } else { content = ( ); } return (
{ let target = e.target as HTMLElement; let footnoteRef = target.closest(".footnote-ref") as HTMLElement | null; if (!footnoteRef) return; let footnoteID = footnoteRef.dataset.footnoteId; if (!footnoteID) return; let store = useFootnotePopoverStore.getState(); if (store.activeFootnoteID === footnoteID) { store.close(); } else { store.open(footnoteID, footnoteRef); } }} className={` ${alignmentClass} ${props.type === "blockquote" ? (props.previousBlock?.type === "blockquote" ? `blockquote pt-3 ` : "blockquote") : ""} ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} w-full whitespace-pre-wrap outline-hidden ${props.className} `} > {content}
); } function BaseTextBlock(props: BlockProps & { className?: string }) { let headingLevel = useEntity(props.entityID, "block/heading-level"); let textSize = useEntity(props.entityID, "block/text-size"); let alignment = useEntity(props.entityID, "block/text-alignment")?.data.value || "left"; let rep = useReplicache(); let selected = useIsBlockSelected(props.entityID); let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID); let alignmentClass = { left: "text-left", right: "text-right", center: "text-center", justify: "text-justify", }[alignment]; let textStyle = textSize?.data.value === "small" ? "textSizeSmall text-secondary" : textSize?.data.value === "large" ? "textSizeLarge text-primary" : "text-primary"; const { viewRef, mentionOpen, mentionCoords, openMentionAutocomplete, handleMentionSelect, handleMentionEmbed, handleMentionOpenChange, } = useMentionState(props.entityID, props); let { mountRef, overlay } = useMountProsemirror({ props, openMentionAutocomplete, }); return ( <>
{overlay}
 {
            let editorState =
              useEditorStates.getState().editorStates[props.entityID]?.editor;
            if (
              ["***", "---", "___"].includes(
                editorState?.doc.textContent.trim() || "",
              )
            ) {
              await rep.rep?.mutate.assertFact({
                entity: props.entityID,
                attribute: "block/type",
                data: { type: "block-type-union", value: "horizontal-rule" },
              });
            }
          }}
          onFocus={() => {
            handleMentionOpenChange(false);
            setTimeout(() => {
              useUIState.getState().focusAndSelectBlock({
                entityID: props.entityID,
                parent: props.parent,
              });
            }, 5);
          }}
          id={elementId.block(props.entityID).text}
          // unless we break *only* on urls, this is better than tailwind 'break-all'
          // b/c break-all can cause breaks in the middle of words, but break-word still
          // forces break if a single text string (e.g. a url) spans more than a full line
          style={{
            wordBreak: "break-word",
            fontFamily:
              props.type === "heading"
                ? "var(--theme-heading-font)"
                : "var(--theme-font)",
            ...(props.type === "heading"
              ? { fontSize: headingFontSize[headingLevel?.data.value || 1] }
              : {}),
          }}
          className={`
            ${alignmentClass}
          grow resize-none align-top whitespace-pre-wrap bg-transparent
          outline-hidden
          ${focused ? "block-focused" : ""}

          ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle}
          ${props.className}`}
          ref={mountRef}
        />
        {focused && (
          
        )}
        {/* The overlays subscribe to the block's editor text; only mount them
            when one of them could show, so unfocused blocks carry no
            per-keystroke store subscription at all. */}
        {((props.previousBlock === null && props.nextBlock === null) ||
          focused ||
          selected) && (
          
        )}
      
{focused && } ); } const TextBlockOverlays = ( props: BlockProps & { className?: string; focused: boolean; selected: boolean; headingLevel: number | undefined; alignmentClass: string; textStyle: string; }, ) => { let textContent = useEditorStates( (s) => s.editorStates[props.entityID]?.editor.doc.textContent, ); if (textContent === undefined) return null; return ( <> {textContent.length === 0 && props.previousBlock === null && props.nextBlock === null ? ( // if this is the only block on the page and is empty or is a canvas, show placeholder
{props.type === "text" ? "write something..." : props.headingLevel === 3 ? "Subheader" : props.headingLevel === 2 ? "Header" : "Title"}
or type "/" to add a block
) : textContent.length === 0 && props.focused ? ( // if not the only block on page but is the block is empty and selected, but NOT multiselected show add button ) : null} {textContent.startsWith("/") && props.selected && ( )} ); }; const blueskyclients = [ "blacksky.community/", "bsky.app/", "witchsky.app/", "anisota.net/", "mu.social/", "bluepy.social/", "reddwarf.app/", "catsky.social/", "deer.social/", ]; const BlockifyLink = (props: { entityID: string }) => { let [loading, setLoading] = useState(false); let rep = useReplicache(); let smoker = useSmoker(); let textContent = useEditorStates( (s) => s.editorStates[props.entityID]?.editor.doc.textContent, ); let isBlueskyPost = textContent !== undefined && blueskyclients.some((client) => textContent.includes(client)) && textContent.includes("post"); // only if the line starts with http or https and doesn't have other content // if its bluesky, change text to embed post if ( textContent !== undefined && betterIsUrl(textContent) && !textContent.includes(" ") ) { let content = textContent; return ( ); } else return null; }; const CommandOptions = (props: BlockProps & { className?: string }) => { let rep = useReplicache(); let entity_set = useEntitySetContext(); let { data: pub } = useLeafletPublicationData(); return (
{ let command = blockCommands.find((f) => f.name === "Image"); if (!rep.rep) return; await command?.onSelect( rep.rep, { ...props, entity_set: entity_set.set }, rep.undoManager, ); }} side="bottom" tooltipContent={
Add an Image
} >
{!pub && ( { let command = blockCommands.find((f) => f.name === "New Page"); if (!rep.rep) return; await command?.onSelect( rep.rep, { ...props, entity_set: entity_set.set }, rep.undoManager, ); }} side="bottom" tooltipContent={
Add a Subpage
} >
)} { e.preventDefault(); let editor = useEditorStates.getState().editorStates[props.entityID]; let editorState = editor?.editor; if (editorState && editor?.view) { editor.view.focus(); let tr = editorState.tr.insertText("/", 1); tr.setSelection(TextSelection.create(tr.doc, 2)); editor.view.dispatch(tr); } focusBlock( { type: props.type, entityID: props.entityID, parent: props.parent, }, { type: "end" }, ); }} side="bottom" tooltipContent={
Add More!
} >
); }; const useMentionState = (entityID: string, blockProps: BlockProps) => { // Select the view directly: it's stable for the lifetime of the mount, so // this never re-renders on typing (the full entry object is rebuilt on // every transaction). let view = useEditorStates((s) => s.editorStates[entityID]?.view); let viewRef = useRef(view || null); viewRef.current = view || null; let { rep, undoManager } = useReplicache(); let entity_set = useEntitySetContext(); let blockPropsRef = useRef(blockProps); blockPropsRef.current = blockProps; const [mentionOpen, setMentionOpen] = useState(false); const [mentionCoords, setMentionCoords] = useState<{ top: number; left: number; } | null>(null); const [mentionInsertPos, setMentionInsertPos] = useState(null); // Close autocomplete when this block is no longer focused const isFocused = useUIState((s) => s.focusedEntity?.entityID === entityID); useEffect(() => { if (!isFocused) { setMentionOpen(false); setMentionCoords(null); setMentionInsertPos(null); } }, [isFocused]); const openMentionAutocomplete = useCallback(() => { const view = useEditorStates.getState().editorStates[entityID]?.view; if (!view) return; // Get the position right after the @ we just inserted const pos = view.state.selection.from; setMentionInsertPos(pos); // Get coordinates for the popup relative to the positioned parent const coords = view.coordsAtPos(pos - 1); // Position of the @ // Find the relative positioned parent container const editorEl = view.dom; const container = editorEl.closest(".relative") as HTMLElement | null; if (container) { const containerRect = container.getBoundingClientRect(); setMentionCoords({ top: coords.bottom - containerRect.top, left: coords.left - containerRect.left, }); } else { setMentionCoords({ top: coords.bottom, left: coords.left, }); } setMentionOpen(true); }, [entityID]); const handleMentionSelect = useCallback( (mention: Mention) => { const view = useEditorStates.getState().editorStates[entityID]?.view; if (!view || mentionInsertPos === null) return; // The @ is at mentionInsertPos - 1, we need to replace it with the mention const from = mentionInsertPos - 1; const to = mentionInsertPos; addMentionToEditor(mention, { from, to }, view); view.focus(); }, [entityID, mentionInsertPos], ); const handleMentionEmbed = useCallback( async (mention: Mention & { type: "service_result" }) => { if (!rep || !mention.embed) return; let embed = mention.embed; let props = blockPropsRef.current; const editorState = useEditorStates.getState().editorStates[entityID]?.editor; // Check if the block is empty (only the @ character) const blockIsEmpty = editorState && editorState.doc.textContent.replace("@", "").trim() === ""; await undoManager.withUndoGroup(async () => { let targetEntityID: string; if (blockIsEmpty) { // Replace the current block targetEntityID = props.entityID; await rep.mutate.assertFact({ entity: targetEntityID, attribute: "block/type", data: { type: "block-type-union", value: "embed" }, }); await rep.mutate.retractAttribute({ entity: targetEntityID, attribute: "block/text", }); } else { // Create a new block below targetEntityID = await addBlockBelow(rep, { parent: props.parent, position: props.position, nextPosition: props.nextPosition, permission_set: entity_set.set, type: "embed", }); // Remove the @ from the current block's editor const view = useEditorStates.getState().editorStates[entityID]?.view; if (view && mentionInsertPos !== null) { const from = mentionInsertPos - 1; const to = mentionInsertPos; const tr = view.state.tr.delete(from, to); tr.setMeta("bulkOp", true); view.dispatch(tr); } } // Set embed attributes let facts: Parameters[0] = [ { entity: targetEntityID, attribute: "embed/url", data: { type: "string", value: embed.src }, }, ]; let aspectRatio = getAspectRatio( embed.aspectRatio ? embed.aspectRatio : embed.width && embed.height ? { width: embed.width, height: embed.height } : undefined, ); if (aspectRatio) { facts.push({ entity: targetEntityID, attribute: "embed/aspect-ratio", data: { type: "string", value: aspectRatio }, }); } else { facts.push({ entity: targetEntityID, attribute: "embed/height", data: { type: "number", value: embed.height || 360, }, }); } await rep.mutate.assertFact(facts); }); }, [rep, entityID, entity_set.set, mentionInsertPos, undoManager], ); const handleMentionOpenChange = useCallback((open: boolean) => { setMentionOpen(open); if (!open) { setMentionCoords(null); setMentionInsertPos(null); } }, []); return { viewRef, mentionOpen, mentionCoords, openMentionAutocomplete, handleMentionSelect, handleMentionEmbed, handleMentionOpenChange, }; };