a tool for shared writing and social publishing
1import { useEntitySetContext } from "components/EntitySetProvider";
2import { generateKeyBetween } from "fractional-indexing";
3import { useEffect, useState } from "react";
4import { useEntity, useReplicache } from "src/replicache";
5import { useUIState } from "src/useUIState";
6import { addLinkBlock } from "src/utils/addLinkBlock";
7import { BlockProps, BlockLayout } from "./Block";
8import { v7 } from "uuid";
9import { useSmoker } from "components/Toast";
10import { Separator } from "components/Layout";
11import { Input } from "components/Input";
12import { focusElement } from "src/utils/focusElement";
13import { isUrl } from "src/utils/isURL";
14import { elementId } from "src/utils/elementId";
15import { focusBlock } from "src/utils/focusBlock";
16import { CheckTiny } from "components/Icons/CheckTiny";
17import { LinkSmall } from "components/Icons/LinkSmall";
18
19export const ExternalLinkBlock = (
20 props: BlockProps & { preview?: boolean },
21) => {
22 let { permissions } = useEntitySetContext();
23 let previewImage = useEntity(props.entityID, "link/preview");
24 let title = useEntity(props.entityID, "link/title");
25 let description = useEntity(props.entityID, "link/description");
26 let url = useEntity(props.entityID, "link/url");
27
28 let isSelected = useUIState((s) =>
29 s.selectedBlocks.find((b) => b.value === props.entityID),
30 );
31 useEffect(() => {
32 if (props.preview) return;
33 let input = document.getElementById(elementId.block(props.entityID).input);
34 if (isSelected) {
35 setTimeout(() => {
36 let input = document.getElementById(
37 elementId.block(props.entityID).input,
38 );
39 focusElement(input as HTMLInputElement | null);
40 }, 20);
41 } else input?.blur();
42 }, [isSelected, props.entityID, props.preview]);
43
44 if (url === undefined) {
45 if (!permissions.write) return null;
46 return (
47 <label
48 className={`
49 w-full h-[104px] p-2
50 text-tertiary hover:text-accent-contrast hover:cursor-pointer
51 flex flex-auto gap-2 items-center justify-center hover:border-2 border-dashed rounded-lg
52 ${isSelected ? "border-2 border-tertiary" : "border border-border"}
53 ${props.pageType === "canvas" && "bg-bg-page"}`}
54 onMouseDown={() => {
55 focusBlock(
56 { type: props.type, value: props.entityID, parent: props.parent },
57 { type: "start" },
58 );
59 }}
60 >
61 <BlockLinkInput {...props} />
62 </label>
63 );
64 }
65
66 return (
67 <BlockLayout
68 isSelected={!!isSelected}
69 hasBackground="page"
70 borderOnHover
71 className="externalLinkBlock flex relative group/linkBlock h-[104px] p-0!"
72 >
73 <a
74 href={url?.data.value}
75 target="_blank"
76 className="flex w-full h-full text-primary hover:no-underline no-underline"
77 >
78 <div className="pt-2 pb-2 px-3 grow min-w-0">
79 <div className="flex flex-col w-full min-w-0 h-full grow ">
80 <div
81 className={`linkBlockTitle bg-transparent -mb-0.5 border-none text-base font-bold outline-hidden resize-none align-top border h-[24px] line-clamp-1`}
82 style={{
83 overflow: "hidden",
84 textOverflow: "ellipsis",
85 wordBreak: "break-all",
86 }}
87 >
88 {title?.data.value}
89 </div>
90
91 <div
92 className={`linkBlockDescription text-sm bg-transparent border-none outline-hidden resize-none align-top grow line-clamp-2`}
93 >
94 {description?.data.value}
95 </div>
96 <div
97 style={{ wordBreak: "break-word" }} // better than tailwind break-all!
98 className={`min-w-0 w-full line-clamp-1 text-xs italic group-hover/linkBlock:text-accent-contrast ${isSelected ? "text-accent-contrast" : "text-tertiary"}`}
99 >
100 {url?.data.value}
101 </div>
102 </div>
103 </div>
104
105 <div
106 className={`linkBlockPreview w-[120px] m-2 -mb-2 bg-cover shrink-0 rounded-t-md border border-border rotate-[4deg] origin-center`}
107 style={{
108 backgroundImage: `url(${previewImage?.data.src})`,
109 backgroundPosition: "center",
110 }}
111 />
112 </a>
113 </BlockLayout>
114 );
115};
116
117const BlockLinkInput = (props: BlockProps & { preview?: boolean }) => {
118 let isSelected = useUIState((s) =>
119 s.selectedBlocks.find((b) => b.value === props.entityID),
120 );
121 let entity_set = useEntitySetContext();
122 let [linkValue, setLinkValue] = useState("");
123 let { rep } = useReplicache();
124 let submit = async () => {
125 let linkEntity = props.entityID;
126 if (!linkEntity) {
127 linkEntity = v7();
128
129 await rep?.mutate.addBlock({
130 permission_set: entity_set.set,
131 factID: v7(),
132 parent: props.parent,
133 type: "card",
134 position: generateKeyBetween(props.position, props.nextPosition),
135 newEntityID: linkEntity,
136 });
137 }
138 let link = linkValue;
139 if (!linkValue.startsWith("http")) link = `https://${linkValue}`;
140 addLinkBlock(link, linkEntity, rep);
141
142 let textEntity = v7();
143 await rep?.mutate.addBlock({
144 permission_set: entity_set.set,
145 factID: v7(),
146 parent: props.parent,
147 type: "text",
148 position: generateKeyBetween(props.position, props.nextPosition),
149 newEntityID: textEntity,
150 });
151
152 focusBlock(
153 {
154 value: textEntity,
155 type: "text",
156 parent: props.parent,
157 },
158 { type: "start" },
159 );
160 };
161 let smoker = useSmoker();
162
163 return (
164 <div className={`max-w-sm flex gap-2 rounded-md text-secondary`}>
165 <>
166 <LinkSmall
167 className={`shrink-0 ${isSelected ? "text-tertiary" : "text-border"} `}
168 />
169 <Separator />
170 <Input
171 id={
172 !props.preview ? elementId.block(props.entityID).input : undefined
173 }
174 type="url"
175 className="w-full grow border-none outline-hidden bg-transparent "
176 placeholder="www.example.com"
177 value={linkValue}
178 onChange={(e) => setLinkValue(e.target.value)}
179 onKeyDown={(e) => {
180 if (e.key === "Enter") {
181 e.preventDefault();
182 if (!linkValue) return;
183 if (!isUrl(linkValue)) {
184 let rect = e.currentTarget.getBoundingClientRect();
185 smoker({
186 alignOnMobile: "left",
187 error: true,
188 text: "invalid url!",
189 position: { x: rect.left, y: rect.top - 8 },
190 });
191 return;
192 }
193 submit();
194 }
195 }}
196 />
197 <div className="flex items-center gap-3 ">
198 <button
199 autoFocus={false}
200 className={`p-1 ${isSelected ? "text-accent-contrast" : "text-border"}`}
201 onMouseDown={(e) => {
202 e.preventDefault();
203 if (!linkValue || linkValue === "") {
204 smoker({
205 alignOnMobile: "left",
206 error: true,
207 text: "no url!",
208 position: { x: e.clientX, y: e.clientY },
209 });
210 return;
211 }
212 if (!isUrl(linkValue)) {
213 smoker({
214 alignOnMobile: "left",
215 error: true,
216 text: "invalid url!",
217 position: { x: e.clientX, y: e.clientY },
218 });
219 return;
220 }
221 submit();
222 }}
223 >
224 <CheckTiny />
225 </button>
226 </div>
227 </>
228 </div>
229 );
230};