a tool for shared writing and social publishing
1import { useEntitySetContext } from "components/EntitySetProvider";
2import { generateKeyBetween } from "fractional-indexing";
3import { useCallback, useEffect, useState } from "react";
4import { useEntity, useReplicache } from "src/replicache";
5import { useUIState } from "src/useUIState";
6import { BlockProps } 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 { deleteBlock } from "./DeleteBlock";
14import { focusBlock } from "src/utils/focusBlock";
15import { useDrag } from "src/hooks/useDrag";
16import { BlockEmbedSmall } from "components/Icons/BlockEmbedSmall";
17import { CheckTiny } from "components/Icons/CheckTiny";
18
19export const EmbedBlock = (props: BlockProps & { preview?: boolean }) => {
20 let { permissions } = useEntitySetContext();
21 let { rep } = useReplicache();
22 let url = useEntity(props.entityID, "embed/url");
23 let isCanvasBlock = props.pageType === "canvas";
24
25 let isSelected = useUIState((s) =>
26 s.selectedBlocks.find((b) => b.value === props.entityID),
27 );
28
29 let height = useEntity(props.entityID, "embed/height")?.data.value || 360;
30
31 let heightOnDragEnd = useCallback(
32 (dragPosition: { x: number; y: number }) => {
33 rep?.mutate.assertFact({
34 entity: props.entityID,
35 attribute: "embed/height",
36 data: {
37 type: "number",
38 value: height + dragPosition.y,
39 },
40 });
41 },
42 [props, rep, height],
43 );
44
45 let heightHandle = useDrag({ onDragEnd: heightOnDragEnd });
46
47 useEffect(() => {
48 if (props.preview) return;
49 let input = document.getElementById(elementId.block(props.entityID).input);
50 if (isSelected) {
51 input?.focus();
52 } else input?.blur();
53 }, [isSelected, props.entityID, props.preview]);
54
55 if (!url) {
56 if (!permissions.write) return null;
57 return (
58 <label
59 id={props.preview ? undefined : elementId.block(props.entityID).input}
60 className={`
61 w-full h-[420px] p-2
62 text-tertiary hover:text-accent-contrast hover:cursor-pointer
63 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
64 ${isSelected ? "border-2 border-tertiary" : "border border-border"}
65 ${props.pageType === "canvas" && "bg-bg-page"}`}
66 onMouseDown={() => {
67 focusBlock(
68 { type: props.type, value: props.entityID, parent: props.parent },
69 { type: "start" },
70 );
71 }}
72 >
73 <BlockLinkInput {...props} />
74 </label>
75 );
76 }
77 if (props.preview) return null;
78
79 return (
80 <div
81 className={`w-full ${heightHandle.dragDelta ? "pointer-events-none" : ""}`}
82 >
83 {/*
84 the iframe!
85 can also add 'allow' and 'referrerpolicy' attributes later if needed
86 */}
87 <iframe
88 className={`
89 flex flex-col relative w-full overflow-hidden group/embedBlock
90 ${isSelected ? "block-border-selected " : "block-border"}
91 `}
92 width="100%"
93 height={height + (heightHandle.dragDelta?.y || 0)}
94 src={url?.data.value}
95 allow="fullscreen"
96 loading="lazy"
97 ></iframe>
98 {/* <div className="w-full overflow-x-hidden truncate text-xs italic text-accent-contrast">
99 <a
100 href={url?.data.value}
101 target="_blank"
102 className={`py-0.5 min-w-0 w-full whitespace-nowrap`}
103 >
104 {url?.data.value}
105 </a>
106 </div> */}
107
108 {!props.preview && permissions.write && (
109 <>
110 <div
111 data-draggable
112 className={`resizeHandle
113 cursor-ns-resize shrink-0 z-10 w-6 h-[5px]
114 absolute bottom-2 right-1/2 translate-x-1/2 translate-y-[2px]
115 rounded-full bg-white border-2 border-[#8C8C8C] shadow-[0_0_0_1px_white,inset_0_0_0_1px_white]
116 ${isCanvasBlock ? "hidden group-hover/canvas-block:block" : ""}`}
117 {...heightHandle.handlers}
118 />
119 </>
120 )}
121 </div>
122 );
123};
124
125// TODO: maybe extract into a component…
126// would just have to branch for the mutations (addLinkBlock or addEmbedBlock)
127const BlockLinkInput = (props: BlockProps) => {
128 let isSelected = useUIState((s) =>
129 s.selectedBlocks.find((b) => b.value === props.entityID),
130 );
131 let isLocked = useEntity(props.entityID, "block/is-locked")?.data.value;
132
133 let entity_set = useEntitySetContext();
134 let [linkValue, setLinkValue] = useState("");
135 let { rep } = useReplicache();
136 let submit = async () => {
137 let entity = props.entityID;
138 if (!entity) {
139 entity = v7();
140
141 await rep?.mutate.addBlock({
142 permission_set: entity_set.set,
143 factID: v7(),
144 parent: props.parent,
145 type: "card",
146 position: generateKeyBetween(props.position, props.nextPosition),
147 newEntityID: entity,
148 });
149 }
150 let link = linkValue;
151 if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
152 // these mutations = simpler subset of addLinkBlock
153 if (!rep) return;
154 await rep.mutate.assertFact({
155 entity: entity,
156 attribute: "block/type",
157 data: { type: "block-type-union", value: "embed" },
158 });
159 await rep?.mutate.assertFact({
160 entity: entity,
161 attribute: "embed/url",
162 data: {
163 type: "string",
164 value: link,
165 },
166 });
167 };
168 let smoker = useSmoker();
169
170 return (
171 <form
172 onSubmit={(e) => {
173 e.preventDefault();
174 let rect = document
175 .getElementById("embed-block-submit")
176 ?.getBoundingClientRect();
177 if (!linkValue || linkValue === "") {
178 smoker({
179 error: true,
180 text: "no url!",
181 position: { x: rect ? rect.left + 12 : 0, y: rect ? rect.top : 0 },
182 });
183 return;
184 }
185 if (!isUrl(linkValue)) {
186 smoker({
187 error: true,
188 text: "invalid url!",
189 position: {
190 x: rect ? rect.left + 12 : 0,
191 y: rect ? rect.top : 0,
192 },
193 });
194 return;
195 }
196 submit();
197 }}
198 >
199 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}>
200 <BlockEmbedSmall
201 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
202 />
203 <Separator />
204 <Input
205 type="text"
206 className="w-full grow border-none outline-hidden bg-transparent "
207 placeholder="www.example.com"
208 value={linkValue}
209 disabled={isLocked}
210 onChange={(e) => setLinkValue(e.target.value)}
211 />
212 <button
213 type="submit"
214 id="embed-block-submit"
215 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`}
216 onMouseDown={(e) => {
217 e.preventDefault();
218 if (!linkValue || linkValue === "") {
219 smoker({
220 error: true,
221 text: "no url!",
222 position: { x: e.clientX + 12, y: e.clientY },
223 });
224 return;
225 }
226 if (!isUrl(linkValue)) {
227 smoker({
228 error: true,
229 text: "invalid url!",
230 position: { x: e.clientX + 12, y: e.clientY },
231 });
232 return;
233 }
234 submit();
235 }}
236 >
237 <CheckTiny />
238 </button>
239 </div>
240 </form>
241 );
242};