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};