a tool for shared writing and social publishing
1import { useEntitySetContext } from "components/EntitySetProvider";
2import { generateKeyBetween } from "fractional-indexing";
3import { useCallback, useEffect, useMemo, useState } from "react";
4import { useEntity, useReplicache } from "src/replicache";
5import { useUIState } from "src/useUIState";
6import { BlockProps, BlockLayout } from "./Block";
7import { v7 } from "uuid";
8import { useSmoker } from "components/Toast";
9import { Separator } from "components/Layout";
10import { Input } from "components/Input";
11import { isUrl } from "src/utils/isURL";
12import { elementId } from "src/utils/elementId";
13import { focusBlock } from "src/utils/focusBlock";
14import { useDrag } from "src/hooks/useDrag";
15import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall";
16import { CheckTiny } from "components/Icons/CheckTiny";
17import { DotLoader } from "components/utils/DotLoader";
18import {
19 LinkPreviewBody,
20 LinkPreviewMetadataResult,
21} from "app/api/link_previews/route";
22import { getAspectRatio } from "src/utils/aspectRatio";
23import { useIframeChannel } from "src/hooks/useIframeChannel";
24import { scrollIntoView } from "src/utils/scrollIntoView";
25import { EmbedBlockData } from "src/partsPageChannel";
26import { useColorAttribute } from "components/ThemeManager/useColorAttribute";
27
28export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => {
29 let entity_set = useEntitySetContext();
30 let { permissions } = entity_set;
31 let { rep } = useReplicache();
32 let url = useEntity(props.entityID, "embed/url");
33 let isCanvasBlock = props.pageType === "canvas";
34
35 let isSelected = useUIState((s) =>
36 s.selectedBlocks.find((b) => b.value === props.entityID),
37 );
38
39 let height = useEntity(props.entityID, "embed/height")?.data.value || 360;
40 let aspectRatio = useEntity(props.entityID, "embed/aspect-ratio")?.data.value;
41
42 let heightOnDragEnd = useCallback(
43 (dragPosition: { x: number; y: number }) => {
44 rep?.mutate.assertFact({
45 entity: props.entityID,
46 attribute: "embed/height",
47 data: {
48 type: "number",
49 value: height + dragPosition.y,
50 },
51 });
52 },
53 [props, rep, height],
54 );
55
56 let heightHandle = useDrag({ onDragEnd: heightOnDragEnd });
57
58 let assertBlockData = useCallback(
59 async (entityID: string, block: EmbedBlockData) => {
60 if (!rep) return;
61 if (block.type === "text") {
62 await rep.mutate.assertFact([
63 {
64 entity: entityID,
65 attribute: "block/type",
66 data: { type: "block-type-union", value: "text" },
67 },
68 {
69 entity: entityID,
70 attribute: "block/text",
71 data: { type: "text", value: block.content },
72 },
73 ]);
74 } else {
75 let facts: Parameters<typeof rep.mutate.assertFact>[0] = [
76 {
77 entity: entityID,
78 attribute: "block/type",
79 data: { type: "block-type-union", value: "embed" },
80 },
81 {
82 entity: entityID,
83 attribute: "embed/url",
84 data: { type: "string", value: block.url },
85 },
86 ];
87 if (block.aspectRatio) {
88 facts.push({
89 entity: entityID,
90 attribute: "embed/aspect-ratio",
91 data: { type: "string", value: block.aspectRatio },
92 });
93 } else if (block.height) {
94 facts.push({
95 entity: entityID,
96 attribute: "embed/height",
97 data: { type: "number", value: block.height },
98 });
99 }
100 await rep.mutate.assertFact(facts);
101 }
102 },
103 [rep],
104 );
105
106 let { iframeRef } = useIframeChannel({
107 onOpen: (openUrl) => {
108 useUIState
109 .getState()
110 .openPage(props.parent, { type: "iframe", url: openUrl });
111 scrollIntoView(`iframe-page-${openUrl}`, "pages", 0.8);
112 },
113 onReplaceWith: (block) => {
114 assertBlockData(props.entityID, block);
115 },
116 onAddBelow: async (block) => {
117 if (!rep) return;
118 let newEntityID = v7();
119 await rep.mutate.addBlock({
120 permission_set: entity_set.set,
121 factID: v7(),
122 parent: props.parent,
123 type: block.type === "text" ? "text" : "card",
124 position: generateKeyBetween(props.position, props.nextPosition),
125 newEntityID,
126 });
127 await assertBlockData(newEntityID, block);
128 },
129 });
130
131 useEffect(() => {
132 if (props.preview) return;
133 let input = document.getElementById(elementId.block(props.entityID).input);
134 if (isSelected) {
135 input?.focus();
136 } else input?.blur();
137 }, [isSelected, props.entityID, props.preview]);
138
139 let bgPage = useColorAttribute(null, "theme/page-background");
140 let primary = useColorAttribute(null, "theme/primary");
141 let iframeSrc = useMemo(() => {
142 if (!url) return undefined;
143 let src = new URL(url.data.value);
144 src.searchParams.set("parts.page.embed.ctx.mode", "edit");
145 src.searchParams.set(
146 "parts.page.embed.ctx.bgColor",
147 bgPage.toString("hex"),
148 );
149 src.searchParams.set(
150 "parts.page.embed.ctx.primaryColor",
151 primary.toString("hex"),
152 );
153 return src.toString();
154 }, [url, bgPage, primary]);
155
156 if (props.preview) return null;
157 if (!url) {
158 if (!permissions.write) return null;
159 return (
160 <label
161 id={props.preview ? undefined : elementId.block(props.entityID).input}
162 className={`
163 w-full h-[420px] p-2
164 text-tertiary hover:text-accent-contrast hover:cursor-pointer
165 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
166 ${isSelected ? "border-2 border-tertiary" : "border border-border"}
167 ${props.pageType === "canvas" && "bg-bg-page"}`}
168 onMouseDown={() => {
169 focusBlock(
170 { type: props.type, value: props.entityID, parent: props.parent },
171 { type: "start" },
172 );
173 }}
174 >
175 <BlockLinkInput {...props} />
176 </label>
177 );
178 }
179
180 return (
181 <div
182 className={`w-full ${!aspectRatio && heightHandle.dragDelta ? "pointer-events-none" : ""}`}
183 >
184 <BlockLayout
185 isSelected={!!isSelected}
186 className="flex flex-col relative w-full overflow-hidden group/embedBlock p-0!"
187 >
188 <iframe
189 ref={iframeRef}
190 className={aspectRatio ? "w-full h-auto" : "w-full"}
191 style={
192 aspectRatio
193 ? { aspectRatio }
194 : { height: height + (heightHandle.dragDelta?.y || 0) }
195 }
196 src={iframeSrc}
197 allow="fullscreen"
198 loading="lazy"
199 referrerPolicy="no-referrer"
200 ></iframe>
201 </BlockLayout>
202
203 {!props.preview && permissions.write && !aspectRatio && (
204 <>
205 <div
206 data-draggable
207 className={`resizeHandle
208
209
210 cursor-ns-resize shrink-0 z-10 w-6 h-[5px]
211 absolute bottom-[3px] right-1/2 translate-x-1/2
212 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]
213 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`}
214 {...heightHandle.handlers}
215 />
216 </>
217 )}
218 </div>
219 );
220};
221
222// TODO: maybe extract into a component…
223// would just have to branch for the mutations (addLinkBlock or addEmbedBlock)
224const BlockLinkInput = (props: BlockProps) => {
225 let isSelected = useUIState((s) =>
226 s.selectedBlocks.find((b) => b.value === props.entityID),
227 );
228
229 let entity_set = useEntitySetContext();
230 let [linkValue, setLinkValue] = useState("");
231 let [loading, setLoading] = useState(false);
232 let { rep } = useReplicache();
233 let submit = async () => {
234 let entity = props.entityID;
235 if (!entity) {
236 entity = v7();
237
238 await rep?.mutate.addBlock({
239 permission_set: entity_set.set,
240 factID: v7(),
241 parent: props.parent,
242 type: "card",
243 position: generateKeyBetween(props.position, props.nextPosition),
244 newEntityID: entity,
245 });
246 }
247 let link = linkValue;
248 if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
249 if (!rep) return;
250
251 // Try to get embed URL from iframely, fallback to direct URL
252 setLoading(true);
253 try {
254 let res = await fetch("/api/link_previews", {
255 headers: { "Content-Type": "application/json" },
256 method: "POST",
257 body: JSON.stringify({ url: link, type: "meta" } as LinkPreviewBody),
258 });
259
260 let embedUrl = link;
261 let embedHeight = 360;
262 let embedAspectRatio: string | null = null;
263
264 if (res.status === 200) {
265 let data = await (res.json() as LinkPreviewMetadataResult);
266 if (data.success && data.data.links?.player?.[0]) {
267 let embed = data.data.links.player[0];
268 embedUrl = embed.href;
269 embedHeight = embed.media?.height || 300;
270 embedAspectRatio = getAspectRatio(embed.media);
271 }
272 }
273
274 let facts: Parameters<typeof rep.mutate.assertFact>[0] = [
275 {
276 entity: entity,
277 attribute: "embed/url",
278 data: {
279 type: "string",
280 value: embedUrl,
281 },
282 },
283 ];
284 if (embedAspectRatio) {
285 facts.push({
286 entity: entity,
287 attribute: "embed/aspect-ratio",
288 data: {
289 type: "string",
290 value: embedAspectRatio,
291 },
292 });
293 } else {
294 facts.push({
295 entity: entity,
296 attribute: "embed/height",
297 data: {
298 type: "number",
299 value: embedHeight,
300 },
301 });
302 }
303 await rep.mutate.assertFact(facts);
304 } catch {
305 // On any error, fallback to using the URL directly
306 await rep.mutate.assertFact([
307 {
308 entity: entity,
309 attribute: "embed/url",
310 data: {
311 type: "string",
312 value: link,
313 },
314 },
315 ]);
316 } finally {
317 setLoading(false);
318 }
319 };
320 let smoker = useSmoker();
321
322 return (
323 <form
324 onSubmit={(e) => {
325 e.preventDefault();
326 if (loading) return;
327 let rect = document
328 .getElementById("embed-block-submit")
329 ?.getBoundingClientRect();
330 if (!linkValue || linkValue === "") {
331 smoker({
332 error: true,
333 text: "no url!",
334 position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 },
335 });
336 return;
337 }
338 if (!isUrl(linkValue)) {
339 smoker({
340 error: true,
341 text: "invalid url!",
342 position: {
343 x: rect ? rect.left + 12 : 0,
344 y: rect ? rect.top : 0,
345 },
346 });
347 return;
348 }
349 submit();
350 }}
351 >
352 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}>
353 <BlockEmbedSmall
354 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
355 />
356 <Separator />
357 <Input
358 type="text"
359 className="w-full grow border-none outline-hidden bg-transparent "
360 placeholder="www.example.com"
361 value={linkValue}
362 onChange={(e) => setLinkValue(e.target.value)}
363 />
364 <button
365 type="submit"
366 id="embed-block-submit"
367 disabled={loading}
368 className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`}
369 onMouseDown={(e) => {
370 e.preventDefault();
371 if (loading) return;
372 if (!linkValue || linkValue === "") {
373 smoker({
374 error: true,
375 text: "no url!",
376 position: { x: e.clientX + 12, y: e.clientY },
377 });
378 return;
379 }
380 if (!isUrl(linkValue)) {
381 smoker({
382 error: true,
383 text: "invalid url!",
384 position: { x: e.clientX + 12, y: e.clientY },
385 });
386 return;
387 }
388 submit();
389 }}
390 >
391 {loading ? <DotLoader /> : <CheckTiny />}
392 </button>
393 </div>
394 </form>
395 );
396};