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 { 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 { blockTextSize } from "src/utils/blockTextSize";
import { Mention, MentionAutocomplete } from "components/Mention";
import { addMentionToEditor } from "app/[leaflet_id]/publish/BskyPostEditorProsemirror";
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;
return (
<>
{(!initialized || !permission || props.preview) && (
)}
{permission && !props.preview && (
{
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 (
{content}
);
}
export 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,
handleMentionOpenChange,
} = useMentionState(props.entityID);
let { mountRef, actionTimeout } = useMountProsemirror({
props,
openMentionAutocomplete,
});
return (
<>
{
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
${props.type === "heading" ? HeadingStyle[headingLevel?.data.value || 1] : textStyle}
${props.className}`}
ref={mountRef}
/>
{focused && (
)}
{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
{props.type === "text"
? "write something..."
: headingLevel?.data.value === 3
? "Subheader"
: headingLevel?.data.value === 2
? "Header"
: "Title"}
or type "/" to add a block
) : 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
) : null}
{editorState?.doc.textContent.startsWith("/") && selected && (
)}
>
);
}
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 (
{
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 ? : "embed"}
);
} 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?.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={Add More!
}
>
);
};
const useMentionState = (entityID: string) => {
let view = useEditorStates((s) => s.editorStates[entityID])?.view;
let viewRef = useRef(view || null);
viewRef.current = view || null;
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 handleMentionOpenChange = useCallback((open: boolean) => {
setMentionOpen(open);
if (!open) {
setMentionCoords(null);
setMentionInsertPos(null);
}
}, []);
return {
viewRef,
mentionOpen,
mentionCoords,
openMentionAutocomplete,
handleMentionSelect,
handleMentionOpenChange,
};
};