Something went wrong. Try again.
a tool for shared writing and social publishing
Something went wrong. Try again.
21 kB · 669 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670import { 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 <HTMLBlock {...props} />; return <URLEmbedBlock {...props} />;};
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 ? ( <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} /> ) : 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<typeof useEmbedSizing>; children: ReactNode;}) => { let isSelected = useIsBlockSelected(props.entityID); return ( <div className={props.sizing.wrapperClassName}> <BlockLayout isSelected={!!isSelected} className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!" > {props.children} </BlockLayout> {props.sizing.resizeHandle} </div> );};
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<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}`, 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 <EmptyEmbedBlock {...props} />; } if (isEditing) { return ( <EmptyEmbedBlock {...props} initialUrl={url.data.value} isEditingExisting onDone={() => setIsEditing(false)} /> ); }
return ( <EmbedBlockShell entityID={props.entityID} sizing={sizing}> <iframe ref={iframeRef} className={sizing.aspectRatio ? "w-full h-auto" : "w-full"} style={sizing.sizeStyle} src={iframeSrc} allow="fullscreen" loading="lazy" referrerPolicy={ isYouTube ? "strict-origin-when-cross-origin" : "no-referrer" } ></iframe> {permissions.write && ( <EmbedEditButton entityID={props.entityID} parent={props.parent} type={props.type} onClick={() => setIsEditing(true)} /> )} </EmbedBlockShell> );};
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 <EmptyEmbedBlock {...props} />; } if (isEditing) { return ( <EmptyEmbedBlock {...props} initialHtml={html.data.value} isEditingExisting onDone={() => setIsEditing(false)} /> ); }
return ( <EmbedBlockShell entityID={props.entityID} sizing={sizing}> <iframe className={sizing.aspectRatio ? "w-full h-auto" : "w-full"} style={sizing.sizeStyle} srcDoc={html.data.value} sandbox={srcDocSandbox} allow="fullscreen" loading="lazy" referrerPolicy="no-referrer" ></iframe> {permissions.write && ( <EmbedEditButton entityID={props.entityID} parent={props.parent} type={props.type} onClick={() => setIsEditing(true)} /> )} </EmbedBlockShell> );};
const EmbedEditButton = (props: { entityID: string; parent: string; type: BlockProps["type"]; onClick: () => void;}) => { return ( <button aria-label="Edit embed" onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); focusBlock( { type: props.type, entityID: props.entityID, parent: props.parent }, { type: "start" }, ); props.onClick(); }} className="absolute top-2 right-2 z-10 p-1 opaque-container text-secondary" > <EditTiny /> </button> );};
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 ( <div className={sizing.wrapperClassName}> <label id={elementId.block(props.entityID).input} htmlFor={embedInputId(props.entityID)} className={` w-full 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"}`} style={sizing.sizeStyle} onMouseDown={() => { focusBlock( { type: props.type, entityID: props.entityID, parent: props.parent }, { type: "start" }, ); }} > <BlockEmbedInput {...props} /> </label> {sizing.resizeHandle} </div> );};
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<typeof rep.mutate.assertFact>[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<typeof rep.mutate.assertFact>[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 ( <form className="w-full h-full flex flex-col gap-2" onSubmit={(e) => { e.preventDefault(); let rect = document .getElementById("embed-block-submit") ?.getBoundingClientRect(); submit({ x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 }); }} > <div className="w-full flex items-center justify-between gap-2 shrink-0"> <ToggleGroup value={ (aspectRatioFact?.data.value ?? "fixed") as | "fixed" | "4/3" | "1/1" | "4/1" } onChange={setAspectRatio} options={[ { value: "fixed", label: "Fixed" }, { value: "4/3", label: "4:3" }, { value: "1/1", label: "1:1" }, { value: "4/1", label: "4:1" }, ]} /> <button type="submit" id="embed-block-submit" disabled={loading} className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`} onMouseDown={(e) => { e.preventDefault(); submit({ x: e.clientX + 12, y: e.clientY }); }} > {loading ? <DotLoader /> : <CheckTiny />} </button> </div> {mode === "url" ? ( <div className="w-full flex-auto flex items-center justify-center"> <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}> <BlockEmbedSmall className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `} /> <Separator /> <Input id={embedInputId(props.entityID)} type="text" className="w-full grow border-none outline-hidden bg-transparent " placeholder="www.example.com" value={linkValue} onChange={(e) => setLinkValue(e.target.value)} /> </div> </div> ) : ( <textarea id={embedInputId(props.entityID)} className="w-full flex-auto resize-none border-none outline-hidden bg-transparent font-mono text-sm text-secondary" placeholder="<p>paste html here</p>" value={htmlValue} onChange={(e) => setHtmlValue(e.target.value)} /> )} </form> );};