Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
13 kB · 420 lines
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421import { useEntitySetContext } from "components/EntitySetProvider";import { generateKeyBetween } from "fractional-indexing";import { useCallback, useEffect, useMemo, useState } from "react";import { useEntity, useReplicache } from "src/replicache";import { useUIState } from "src/useUIState";import { BlockProps, BlockLayout } from "./Block";import { v7 } from "uuid";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 { 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";
export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => { let entity_set = useEntitySetContext(); let { permissions } = entity_set; let { rep } = useReplicache(); let url = useEntity(props.entityID, "embed/url"); let isCanvasBlock = props.pageType === "canvas";
let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), );
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 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<typeof rep.mutate.assertFact>[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}`, "pages", 0.8); }, onReplaceWith: (block) => { assertBlockData(props.entityID, block); }, onAddBelow: async (block) => { if (!rep) return; let newEntityID = v7(); await rep.mutate.addBlock({ permission_set: entity_set.set, factID: v7(), parent: props.parent, type: block.type === "text" ? "text" : "card", position: generateKeyBetween(props.position, props.nextPosition), newEntityID, }); await assertBlockData(newEntityID, block); }, });
useEffect(() => { if (props.preview) return; let input = document.getElementById(elementId.block(props.entityID).input); if (isSelected) { input?.focus(); } else input?.blur(); }, [isSelected, props.entityID, props.preview]);
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 ( <label id={props.preview ? undefined : elementId.block(props.entityID).input} className={` w-full h-[420px] p-2 text-tertiary hover:text-accent-contrast hover:cursor-pointer flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg ${isSelected ? "border-2 border-tertiary" : "border border-border"} ${props.pageType === "canvas" && "bg-bg-page"}`} onMouseDown={() => { focusBlock( { type: props.type, value: props.entityID, parent: props.parent }, { type: "start" }, ); }} > <BlockLinkInput {...props} /> </label> ); }
return ( <div className={`w-full ${!aspectRatio && heightHandle.dragDelta ? "pointer-events-none" : ""}`} > <BlockLayout isSelected={!!isSelected} className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!" > <iframe ref={iframeRef} className={aspectRatio ? "w-full h-auto" : "w-full"} style={ aspectRatio ? { aspectRatio } : { height: height + (heightHandle.dragDelta?.y || 0) } } src={iframeSrc} allow="fullscreen" loading="lazy" referrerPolicy={ isYouTube ? "strict-origin-when-cross-origin" : "no-referrer" } ></iframe> </BlockLayout>
{!props.preview && permissions.write && !aspectRatio && ( <> <div data-draggable className={`resizeHandle cursor-ns-resize shrink-0 z-10 w-6 h-[5px] absolute bottom-[3px] right-1/2 translate-x-1/2 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white] ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`} {...heightHandle.handlers} /> </> )} </div> );};
// TODO: maybe extract into a component…// would just have to branch for the mutations (addLinkBlock or addEmbedBlock)const BlockLinkInput = (props: BlockProps) => { let isSelected = useUIState((s) => s.selectedBlocks.find((b) => b.value === props.entityID), );
let entity_set = useEntitySetContext(); let [linkValue, setLinkValue] = useState(""); let [loading, setLoading] = useState(false); let { rep } = useReplicache(); let submit = async () => { let entity = props.entityID; if (!entity) { entity = v7();
await rep?.mutate.addBlock({ permission_set: entity_set.set, factID: v7(), parent: props.parent, type: "card", position: generateKeyBetween(props.position, props.nextPosition), newEntityID: entity, }); } 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.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<typeof rep.mutate.assertFact>[0] = [ { entity: entity, attribute: "embed/url", data: { type: "string", value: embedUrl, }, }, ]; 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); }
let textEntity = v7(); await rep.mutate.addBlock({ permission_set: entity_set.set, factID: v7(), parent: props.parent, type: "text", position: generateKeyBetween(props.position, props.nextPosition), newEntityID: textEntity, });
focusBlock( { value: textEntity, type: "text", parent: props.parent, }, { type: "start" }, ); }; let smoker = useSmoker();
return ( <form onSubmit={(e) => { e.preventDefault(); if (loading) return; let rect = document .getElementById("embed-block-submit") ?.getBoundingClientRect(); if (!linkValue || linkValue === "") { smoker({ error: true, text: "no url!", position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 }, }); return; } if (!isUrl(linkValue)) { smoker({ error: true, text: "invalid url!", position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0, }, }); return; } submit(); }} > <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> <BlockEmbedSmall className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} /> <Separator /> <Input type="text" className="w-full grow border-none outline-hidden bg-transparent " placeholder="www.example.com" value={linkValue} onChange={(e) => setLinkValue(e.target.value)} /> <button type="submit" id="embed-block-submit" disabled={loading} className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} onMouseDown={(e) => { e.preventDefault(); if (loading) return; if (!linkValue || linkValue === "") { smoker({ error: true, text: "no url!", position: { x: e.clientX + 12, y: e.clientY }, }); return; } if (!isUrl(linkValue)) { smoker({ error: true, text: "invalid url!", position: { x: e.clientX + 12, y: e.clientY }, }); return; } submit(); }} > {loading ? <DotLoader /> : <CheckTiny />} </button> </div> </form> );};