forked from
leaflet.pub/leaflet
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 } 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 <a
68 href={url?.data.value}
69 target="_blank"
70 className={`
71 externalLinkBlock flex relative group/linkBlock
72 h-[104px] w-full bg-bg-page overflow-hidden text-primary hover:no-underline no-underline
73 hover:border-accent-contrast shadow-sm
74 ${isSelected ? "block-border-selected outline-accent-contrast! border-accent-contrast!" : "block-border"}
75
76 `}
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 );
114};
115
116const BlockLinkInput = (props: BlockProps & { preview?: boolean }) => {
117 let isSelected = useUIState((s) =>
118 s.selectedBlocks.find((b) => b.value === props.entityID),
119 );
120 let isLocked = useEntity(props.value, "block/is-locked")?.data.value;
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 disabled={isLocked}
176 className="w-full grow border-none outline-hidden bg-transparent "
177 placeholder="www.example.com"
178 value={linkValue}
179 onChange={(e) => setLinkValue(e.target.value)}
180 onKeyDown={(e) => {
181 if (e.key === "Enter") {
182 e.preventDefault();
183 if (!linkValue) return;
184 if (!isUrl(linkValue)) {
185 let rect = e.currentTarget.getBoundingClientRect();
186 smoker({
187 alignOnMobile: "left",
188 error: true,
189 text: "invalid url!",
190 position: { x: rect.left, y: rect.top - 8 },
191 });
192 return;
193 }
194 submit();
195 }
196 }}
197 />
198 <div className="flex items-center gap-3 ">
199 <button
200 autoFocus={false}
201 className={`p-1 ${isSelected && !isLocked ? "text-accent-contrast" : "text-border"}`}
202 onMouseDown={(e) => {
203 e.preventDefault();
204 if (!linkValue || linkValue === "") {
205 smoker({
206 alignOnMobile: "left",
207 error: true,
208 text: "no url!",
209 position: { x: e.clientX, y: e.clientY },
210 });
211 return;
212 }
213 if (!isUrl(linkValue)) {
214 smoker({
215 alignOnMobile: "left",
216 error: true,
217 text: "invalid url!",
218 position: { x: e.clientX, y: e.clientY },
219 });
220 return;
221 }
222 submit();
223 }}
224 >
225 <CheckTiny />
226 </button>
227 </div>
228 </>
229 </div>
230 );
231};