import { useEntitySetContext } from "components/EntitySetProvider";
import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { useEntity, useReplicache } from "src/replicache";
import { useIsBlockSelected, useUIState } from "src/useUIState";
import { BlockProps, BlockLayout } from "./Block";
import { addBlockBelow } from "src/utils/addBlockBelow";
import { useSmoker } from "components/Toast";
import { Separator } from "components/Layout";
import { Input } from "components/Input";
import { isUrl } from "src/utils/isURL";
import { elementId } from "src/utils/elementId";
import { focusBlock } from "src/utils/focusBlock";
import { useDrag } from "src/hooks/useDrag";
import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall";
import { CheckTiny } from "components/Icons/CheckTiny";
import { EditTiny } from "components/Icons/EditTiny";
import { DotLoader } from "components/utils/DotLoader";
import {
LinkPreviewBody,
LinkPreviewMetadataResult,
} from "app/api/link_previews/route";
import { getAspectRatio } from "src/utils/aspectRatio";
import { useIframeChannel } from "src/hooks/useIframeChannel";
import { scrollIntoView } from "src/utils/scrollIntoView";
import { EmbedBlockData } from "src/partsPageChannel";
import { useColorAttribute } from "components/ThemeManager/useColorAttribute";
import {
assertStandardSitePostFacts,
assertStandardSitePublicationFacts,
} from "src/utils/addLinkBlock";
import { ToggleGroup } from "components/ToggleGroup";
import { srcDocSandbox } from "src/utils/srcDocSandbox";
export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => {
// Blocks written before html embeds were split into their own block type
// have type "embed" with an embed/html fact.
let html = useEntity(props.entityID, "embed/html");
if (html) return ;
return ;
};
const useEmbedSizing = (props: BlockProps & { preview?: boolean }) => {
let { rep } = useReplicache();
let { permissions } = useEntitySetContext();
let isCanvasBlock = props.pageType === "canvas";
let height = useEntity(props.entityID, "embed/height")?.data.value || 360;
let aspectRatio = useEntity(props.entityID, "embed/aspect-ratio")?.data.value;
let heightOnDragEnd = useCallback(
(dragPosition: { x: number; y: number }) => {
rep?.mutate.assertFact({
entity: props.entityID,
attribute: "embed/height",
data: {
type: "number",
value: height + dragPosition.y,
},
});
},
[props, rep, height],
);
let heightHandle = useDrag({ onDragEnd: heightOnDragEnd });
let resizeHandle =
!props.preview && permissions.write && !aspectRatio ? (
) : null;
let sizeStyle = aspectRatio
? { aspectRatio }
: { height: height + (heightHandle.dragDelta?.y || 0) };
return {
aspectRatio,
resizeHandle,
sizeStyle,
wrapperClassName: `w-full ${!aspectRatio && heightHandle.dragDelta ? "pointer-events-none" : ""}`,
};
};
const EmbedBlockShell = (props: {
entityID: string;
sizing: ReturnType;
children: ReactNode;
}) => {
let isSelected = useIsBlockSelected(props.entityID);
return (
{props.children}
{props.sizing.resizeHandle}
);
};
const URLEmbedBlock = (props: BlockProps & { preview?: boolean }) => {
let entity_set = useEntitySetContext();
let { permissions } = entity_set;
let { rep, undoManager } = useReplicache();
let url = useEntity(props.entityID, "embed/url");
let sizing = useEmbedSizing(props);
let [isEditing, setIsEditing] = useState(false);
let assertBlockData = useCallback(
async (entityID: string, block: EmbedBlockData) => {
if (!rep) return;
if (block.type === "text") {
await rep.mutate.assertFact([
{
entity: entityID,
attribute: "block/type",
data: { type: "block-type-union", value: "text" },
},
{
entity: entityID,
attribute: "block/text",
data: { type: "text", value: block.content },
},
]);
} else {
let facts: Parameters[0] = [
{
entity: entityID,
attribute: "block/type",
data: { type: "block-type-union", value: "embed" },
},
{
entity: entityID,
attribute: "embed/url",
data: { type: "string", value: block.url },
},
];
if (block.aspectRatio) {
facts.push({
entity: entityID,
attribute: "embed/aspect-ratio",
data: { type: "string", value: block.aspectRatio },
});
} else if (block.height) {
facts.push({
entity: entityID,
attribute: "embed/height",
data: { type: "number", value: block.height },
});
}
await rep.mutate.assertFact(facts);
}
},
[rep],
);
let { iframeRef } = useIframeChannel({
onOpen: (openUrl) => {
useUIState
.getState()
.openPage(props.parent, { type: "iframe", url: openUrl });
scrollIntoView(`iframe-page-${openUrl}`, 0.8);
},
onReplaceWith: (block) => {
assertBlockData(props.entityID, block);
},
onAddBelow: async (block) => {
await undoManager.withUndoGroup(async () => {
if (!rep) return;
let newEntityID = await addBlockBelow(rep, {
parent: props.parent,
position: props.position,
nextPosition: props.nextPosition,
permission_set: entity_set.set,
type: block.type === "text" ? "text" : "card",
});
await assertBlockData(newEntityID, block);
});
},
});
let bgPage = useColorAttribute(null, "theme/page-background");
let primary = useColorAttribute(null, "theme/primary");
let iframeSrc = useMemo(() => {
if (!url) return undefined;
let src = new URL(url.data.value);
src.searchParams.set("parts.page.embed.ctx.mode", "edit");
src.searchParams.set(
"parts.page.embed.ctx.bgColor",
bgPage.toString("hex"),
);
src.searchParams.set(
"parts.page.embed.ctx.primaryColor",
primary.toString("hex"),
);
return src.toString();
}, [url, bgPage, primary]);
// check if URL is a YouTube link, so we can set the appropriate referrer policy
// to avoid YT config "Error 153" since they're strict about requiring a valid referer header
let isYouTube =
iframeSrc?.includes("youtube.com") || iframeSrc?.includes("youtu.be");
if (props.preview) return null;
if (!url) {
if (!permissions.write) return null;
return ;
}
if (isEditing) {
return (
setIsEditing(false)}
/>
);
}
return (
{permissions.write && (
setIsEditing(true)}
/>
)}
);
};
export const HTMLBlock = (props: BlockProps & { preview?: boolean }) => {
let { permissions } = useEntitySetContext();
let html = useEntity(props.entityID, "embed/html");
let sizing = useEmbedSizing(props);
let [isEditing, setIsEditing] = useState(false);
if (props.preview) return null;
if (!html) {
if (!permissions.write) return null;
return ;
}
if (isEditing) {
return (
setIsEditing(false)}
/>
);
}
return (
{permissions.write && (
setIsEditing(true)}
/>
)}
);
};
const EmbedEditButton = (props: {
entityID: string;
parent: string;
type: BlockProps["type"];
onClick: () => void;
}) => {
return (
);
};
const EmptyEmbedBlock = (
props: BlockProps & {
initialUrl?: string;
initialHtml?: string;
isEditingExisting?: boolean;
onDone?: () => void;
},
) => {
let sizing = useEmbedSizing(props);
let isSelected = useIsBlockSelected(props.entityID);
useEffect(() => {
let input = document.getElementById(elementId.block(props.entityID).input);
if (isSelected) {
input?.focus();
} else input?.blur();
}, [isSelected, props.entityID]);
return (
{sizing.resizeHandle}
);
};
const embedInputId = (entityID: string) => `${entityID}-embed-input`;
const BlockEmbedInput = (
props: BlockProps & {
initialUrl?: string;
initialHtml?: string;
isEditingExisting?: boolean;
onDone?: () => void;
},
) => {
let isSelected = useIsBlockSelected(props.entityID);
let entity_set = useEntitySetContext();
let mode = props.type === "html" ? "html" : "url";
let [linkValue, setLinkValue] = useState(props.initialUrl ?? "");
let [htmlValue, setHtmlValue] = useState(props.initialHtml ?? "");
let [loading, setLoading] = useState(false);
let { rep, undoManager } = useReplicache();
let aspectRatioFact = useEntity(props.entityID, "embed/aspect-ratio");
let heightFact = useEntity(props.entityID, "embed/height");
// The user explicitly sized the block (preset or drag) — submits shouldn't
// overwrite that with metadata or defaults.
let hasExplicitSize = !!aspectRatioFact || !!heightFact;
let setAspectRatio = (value: "fixed" | "4/3" | "1/1" | "4/1") => {
if (!rep) return;
if (value === "fixed") {
if (aspectRatioFact)
rep.mutate.retractFact({ factID: aspectRatioFact.id });
return;
}
rep.mutate.assertFact({
entity: props.entityID,
attribute: "embed/aspect-ratio",
data: { type: "string", value },
});
};
// Re-editing an existing embed just closes the input back into the shell;
// only the initial creation flow chains an empty caption block below.
let finishAfterSubmit = async () => {
if (props.isEditingExisting) {
props.onDone?.();
return;
}
if (!rep) return;
let textEntity = await addBlockBelow(rep, {
parent: props.parent,
position: props.position,
nextPosition: props.nextPosition,
permission_set: entity_set.set,
type: "text",
});
focusBlock(
{ entityID: textEntity, type: "text", parent: props.parent },
{ type: "start" },
);
};
let submitUrl = async () => {
if (!rep) return;
await undoManager.withUndoGroup(async () => {
let entity = props.entityID;
if (!entity) {
entity = await addBlockBelow(rep, {
parent: props.parent,
position: props.position,
nextPosition: props.nextPosition,
permission_set: entity_set.set,
type: "embed",
});
}
let link = linkValue;
if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
if (!rep) return;
// Try to get embed URL from iframely, fallback to direct URL
setLoading(true);
try {
let res = await fetch("/api/link_previews", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody),
});
let embedUrl = link;
let embedHeight = 360;
let embedAspectRatio: string | null = null;
if (res.status === 200) {
let data = await (res.json() as LinkPreviewMetadataResult);
if (data.leafletPost) {
await assertStandardSitePostFacts(
rep,
entity,
data.leafletPost.uri,
);
setLoading(false);
await finishAfterSubmit();
return;
}
if (data.leafletPublication) {
await assertStandardSitePublicationFacts(
rep,
entity,
data.leafletPublication.uri,
);
setLoading(false);
await finishAfterSubmit();
return;
}
if (data.success && data.data.links?.player?.[0]) {
let embed = data.data.links.player[0];
embedUrl = embed.href;
embedHeight = embed.media?.height || 300;
embedAspectRatio = getAspectRatio(embed.media);
}
}
let facts: Parameters[0] = [
{
entity: entity,
attribute: "embed/url",
data: {
type: "string",
value: embedUrl,
},
},
];
if (!hasExplicitSize) {
if (embedAspectRatio) {
facts.push({
entity: entity,
attribute: "embed/aspect-ratio",
data: {
type: "string",
value: embedAspectRatio,
},
});
} else {
facts.push({
entity: entity,
attribute: "embed/height",
data: {
type: "number",
value: embedHeight,
},
});
}
}
await rep.mutate.assertFact(facts);
} catch {
// On any error, fallback to using the URL directly
await rep.mutate.assertFact([
{
entity: entity,
attribute: "embed/url",
data: {
type: "string",
value: link,
},
},
]);
} finally {
setLoading(false);
}
await finishAfterSubmit();
});
};
let submitHtml = async () => {
await undoManager.withUndoGroup(async () => {
if (!rep) return;
setLoading(true);
try {
let entity = props.entityID;
if (!entity) {
entity = await addBlockBelow(rep, {
parent: props.parent,
position: props.position,
nextPosition: props.nextPosition,
permission_set: entity_set.set,
type: "html",
});
}
let facts: Parameters[0] = [
{
entity,
attribute: "embed/html",
data: { type: "string", value: htmlValue },
},
];
if (!hasExplicitSize) {
facts.push({
entity,
attribute: "embed/height",
data: { type: "number", value: 360 },
});
}
await rep.mutate.assertFact(facts);
await finishAfterSubmit();
} finally {
setLoading(false);
}
});
};
let smoker = useSmoker();
let submit = (errorPosition: { x: number; y: number }) => {
if (loading) return;
if (mode === "url") {
if (!linkValue || linkValue === "") {
smoker({ error: true, text: "no url!", position: errorPosition });
return;
}
if (!isUrl(linkValue)) {
smoker({ error: true, text: "invalid url!", position: errorPosition });
return;
}
submitUrl();
} else {
if (!htmlValue.trim()) {
smoker({ error: true, text: "no html!", position: errorPosition });
return;
}
submitHtml();
}
};
return (
);
};