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 (
{ e.preventDefault(); let rect = document .getElementById("embed-block-submit") ?.getBoundingClientRect(); submit({ x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 }); }} >
{mode === "url" ? (
setLinkValue(e.target.value)} />
) : (