Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
23 kB · 707 lines
TSX
at perf/paste
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708import { 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 { EditorState, 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 { 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)/[leaflet_id]/publish/BskyPostEditorProsemirror";import { v7 } from "uuid";import { generateKeyBetween } from "fractional-indexing";
const HeadingStyle = { 1: "font-bold [font-family:var(--theme-heading-font)]", 2: "font-bold [font-family:var(--theme-heading-font)]", 3: "font-bold text-secondary [font-family:var(--theme-heading-font)]", 4: "font-bold 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) && ( <RenderedTextBlock type={props.type} entityID={props.entityID} className={props.className} first={first} pageType={props.pageType} previousBlock={props.previousBlock} /> )} {permission && !props.preview && !stale && ( <div className={`w-full relative group ${!initialized ? "hidden" : ""}`} > <IOSBS {...props} /> <BaseTextBlock {...props} /> </div> )} </> );}
function IOSBS(props: BlockProps) { let [initialRender, setInitialRender] = useState(true); useEffect(() => { setInitialRender(false); }, []); if (initialRender || !isIOS()) return null; return ( <div className="h-full w-full absolute cursor-text group-focus-within:hidden py-[18px]" onPointerUp={(e) => { 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 = <br />; if (!initialFact) { if (permissions.write && (props.first || props.pageType === "canvas")) content = ( <div className={`${props.className} pointer-events-none italic text-tertiary flex flex-col `} > {headingLevel?.data.value === 1 ? "Title" : headingLevel?.data.value === 2 ? "Header" : headingLevel?.data.value === 3 ? "Subheader" : "write something..."} <div className=" text-xs font-normal"> or type "/" for commands </div> </div> ); } else { content = ( <RenderYJSFragment value={initialFact.data.value} wrapper="p" renderComments={permissions.write} /> ); } return ( <div style={{ wordBreak: "break-word", ...(props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : {}), }} onClick={(e) => { 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} </div> );}
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 = useUIState( (s) => !!s.selectedBlocks.find((b) => b.value === 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";
let editorState = useEditorStates( (s) => s.editorStates[props.entityID], )?.editor; const { viewRef, mentionOpen, mentionCoords, openMentionAutocomplete, handleMentionSelect, handleMentionEmbed, handleMentionOpenChange, } = useMentionState(props.entityID, props);
let { mountRef, actionTimeout, overlay } = useMountProsemirror({ props, openMentionAutocomplete, });
return ( <> <div className={`flex items-center justify-between w-full ${selected && props.pageType === "canvas" && "bg-bg-page rounded-md"} ${ props.type === "blockquote" ? props.previousBlock?.type === "blockquote" && !props.listData ? "blockquote pt-3" : "blockquote" : "" }`} > {overlay} <pre data-entityid={props.entityID} onBlur={async () => { 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" }, }); } if (actionTimeout.current) { rep.undoManager.endGroup(); window.clearTimeout(actionTimeout.current); actionTimeout.current = null; } }} onFocus={() => { handleMentionOpenChange(false); setTimeout(() => { useUIState.getState().setSelectedBlock(props); useUIState.setState(() => ({ focusedEntity: { entityType: "block", 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 && ( <MentionAutocomplete open={mentionOpen} onOpenChange={handleMentionOpenChange} view={viewRef} onSelect={handleMentionSelect} onEmbed={handleMentionEmbed} coords={mentionCoords} /> )} {editorState?.doc.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 <div style={ props.type === "heading" ? { fontSize: headingFontSize[headingLevel?.data.value || 1] } : undefined } className={`${props.className} ${alignmentClass} w-full pointer-events-none absolute top-0 left-0 italic text-tertiary flex flex-col ${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle} `} > {props.type === "text" ? "write something..." : headingLevel?.data.value === 3 ? "Subheader" : headingLevel?.data.value === 2 ? "Header" : "Title"} <div className=" text-xs font-normal"> or type "/" to add a block </div> </div> ) : editorState?.doc.textContent.length === 0 && focused ? ( // if not the only block on page but is the block is empty and selected, but NOT multiselected show add button <CommandOptions {...props} className={props.className} /> ) : null}
{editorState?.doc.textContent.startsWith("/") && selected && ( <BlockCommandBar props={props} searchValue={editorState.doc.textContent.slice(1)} /> )} </div> <BlockifyLink entityID={props.entityID} editorState={editorState} /> </> );}
const blueskyclients = ["blacksky.community/", "bsky.app/", "witchsky.app/"];
const BlockifyLink = (props: { entityID: string; editorState: EditorState | undefined;}) => { let [loading, setLoading] = useState(false); let { editorState } = props; let rep = useReplicache(); let smoker = useSmoker(); let focused = useUIState((s) => s.focusedEntity?.entityID === props.entityID);
let isBlueskyPost = blueskyclients.some((client) => editorState?.doc.textContent.includes(client), ) && editorState?.doc.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 ( focused && editorState && betterIsUrl(editorState.doc.textContent) && !editorState.doc.textContent.includes(" ") ) { return ( <button onClick={async (e) => { if (!rep.rep) return; rep.undoManager.startGroup(); if (isBlueskyPost) { let success = await addBlueskyPostBlock( editorState.doc.textContent, props.entityID, rep.rep, ); if (!success) smoker({ error: true, text: "post not found!", position: { x: e.clientX + 12, y: e.clientY, }, }); } else { setLoading(true); await addLinkBlock( editorState.doc.textContent, props.entityID, rep.rep, ); setLoading(false); } rep.undoManager.endGroup(); }} 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 " > {loading ? <DotLoader /> : "embed"} </button> ); } else return null;};
const CommandOptions = (props: BlockProps & { className?: string }) => { let rep = useReplicache(); let entity_set = useEntitySetContext(); let { data: pub } = useLeafletPublicationData();
return ( <div 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]"}`} > <TooltipButton className={props.className} onMouseDown={async () => { 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={ <div className="flex gap-1 font-bold">Add an Image</div> } > <BlockImageSmall className="hover:text-accent-contrast text-border" /> </TooltipButton>
{!pub && ( <TooltipButton className={props.className} onMouseDown={async () => { 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={ <div className="flex gap-1 font-bold">Add a Subpage</div> } > <BlockDocPageSmall className="hover:text-accent-contrast text-border" /> </TooltipButton> )}
<TooltipButton className={props.className} onMouseDown={(e) => { e.preventDefault(); let editor = useEditorStates.getState().editorStates[props.entityID];
let editorState = editor?.editor; if (editorState) { editor?.view?.focus(); let tr = editorState.tr.insertText("/", 1); tr.setSelection(TextSelection.create(tr.doc, 2)); useEditorStates.setState((s) => ({ editorStates: { ...s.editorStates, [props.entityID]: { ...s.editorStates[props.entityID]!, editor: editorState!.apply(tr), }, }, })); } focusBlock( { type: props.type, value: props.entityID, parent: props.parent, }, { type: "end" }, ); }} side="bottom" tooltipContent={<div className="flex gap-1 font-bold">Add More!</div>} > <div className="w-6 h-6 flex place-items-center justify-center"> <AddTiny className="text-accent-contrast" /> </div> </TooltipButton> </div> );};
const useMentionState = (entityID: string, blockProps: BlockProps) => { let view = useEditorStates((s) => s.editorStates[entityID])?.view; let viewRef = useRef(view || null); viewRef.current = view || null;
let { rep } = 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<number | null>(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( (mention: Mention & { type: "service_result" }) => { if (!rep || !mention.embed) return; 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() === "";
let targetEntityID: string; if (blockIsEmpty) { // Replace the current block targetEntityID = props.entityID; rep.mutate.assertFact({ entity: targetEntityID, attribute: "block/type", data: { type: "block-type-union", value: "embed" }, }); rep.mutate.retractAttribute({ entity: targetEntityID, attribute: "block/text", }); } else { // Create a new block below targetEntityID = v7(); rep.mutate.addBlock({ permission_set: entity_set.set, factID: v7(), type: "embed", newEntityID: targetEntityID, parent: props.parent, position: generateKeyBetween( props.position, props.nextPosition, ), }); // 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); view.dispatch(tr); } }
// Set embed attributes let facts: Parameters<typeof rep.mutate.assertFact>[0] = [ { entity: targetEntityID, attribute: "embed/url", data: { type: "string", value: mention.embed.src }, }, ]; let aspectRatio = getAspectRatio( mention.embed.aspectRatio ? mention.embed.aspectRatio : mention.embed.width && mention.embed.height ? { width: mention.embed.width, height: mention.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: mention.embed.height || 360, }, }); } rep.mutate.assertFact(facts); }, [rep, entityID, entity_set.set, mentionInsertPos], );
const handleMentionOpenChange = useCallback((open: boolean) => { setMentionOpen(open); if (!open) { setMentionCoords(null); setMentionInsertPos(null); } }, []);
return { viewRef, mentionOpen, mentionCoords, openMentionAutocomplete, handleMentionSelect, handleMentionEmbed, handleMentionOpenChange, };};