Hyperlink.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. import { AppState, ExcalidrawProps, Point } from "../types";
  2. import {
  3. getShortcutKey,
  4. sceneCoordsToViewportCoords,
  5. viewportCoordsToSceneCoords,
  6. wrapEvent,
  7. } from "../utils";
  8. import { mutateElement } from "./mutateElement";
  9. import { NonDeletedExcalidrawElement } from "./types";
  10. import { register } from "../actions/register";
  11. import { ToolButton } from "../components/ToolButton";
  12. import { FreedrawIcon, LinkIcon, TrashIcon } from "../components/icons";
  13. import { t } from "../i18n";
  14. import {
  15. useCallback,
  16. useEffect,
  17. useLayoutEffect,
  18. useRef,
  19. useState,
  20. } from "react";
  21. import clsx from "clsx";
  22. import { KEYS } from "../keys";
  23. import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
  24. import { rotate } from "../math";
  25. import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
  26. import { Bounds } from "./bounds";
  27. import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
  28. import { getSelectedElements } from "../scene";
  29. import { isPointHittingElementBoundingBox } from "./collision";
  30. import { getElementAbsoluteCoords } from "./";
  31. import "./Hyperlink.scss";
  32. import { trackEvent } from "../analytics";
  33. import { useExcalidrawAppState } from "../components/App";
  34. const CONTAINER_WIDTH = 320;
  35. const SPACE_BOTTOM = 85;
  36. const CONTAINER_PADDING = 5;
  37. const CONTAINER_HEIGHT = 42;
  38. const AUTO_HIDE_TIMEOUT = 500;
  39. export const EXTERNAL_LINK_IMG = document.createElement("img");
  40. EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
  41. `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`,
  42. )}`;
  43. let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
  44. export const Hyperlink = ({
  45. element,
  46. setAppState,
  47. onLinkOpen,
  48. }: {
  49. element: NonDeletedExcalidrawElement;
  50. setAppState: React.Component<any, AppState>["setState"];
  51. onLinkOpen: ExcalidrawProps["onLinkOpen"];
  52. }) => {
  53. const appState = useExcalidrawAppState();
  54. const linkVal = element.link || "";
  55. const [inputVal, setInputVal] = useState(linkVal);
  56. const inputRef = useRef<HTMLInputElement>(null);
  57. const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
  58. const handleSubmit = useCallback(() => {
  59. if (!inputRef.current) {
  60. return;
  61. }
  62. const link = normalizeLink(inputRef.current.value);
  63. if (!element.link && link) {
  64. trackEvent("hyperlink", "create");
  65. }
  66. mutateElement(element, { link });
  67. setAppState({ showHyperlinkPopup: "info" });
  68. }, [element, setAppState]);
  69. useLayoutEffect(() => {
  70. return () => {
  71. handleSubmit();
  72. };
  73. }, [handleSubmit]);
  74. useEffect(() => {
  75. let timeoutId: number | null = null;
  76. const handlePointerMove = (event: PointerEvent) => {
  77. if (isEditing) {
  78. return;
  79. }
  80. if (timeoutId) {
  81. clearTimeout(timeoutId);
  82. }
  83. const shouldHide = shouldHideLinkPopup(element, appState, [
  84. event.clientX,
  85. event.clientY,
  86. ]) as boolean;
  87. if (shouldHide) {
  88. timeoutId = window.setTimeout(() => {
  89. setAppState({ showHyperlinkPopup: false });
  90. }, AUTO_HIDE_TIMEOUT);
  91. }
  92. };
  93. window.addEventListener(EVENT.POINTER_MOVE, handlePointerMove, false);
  94. return () => {
  95. window.removeEventListener(EVENT.POINTER_MOVE, handlePointerMove, false);
  96. if (timeoutId) {
  97. clearTimeout(timeoutId);
  98. }
  99. };
  100. }, [appState, element, isEditing, setAppState]);
  101. const handleRemove = useCallback(() => {
  102. trackEvent("hyperlink", "delete");
  103. mutateElement(element, { link: null });
  104. if (isEditing) {
  105. inputRef.current!.value = "";
  106. }
  107. setAppState({ showHyperlinkPopup: false });
  108. }, [setAppState, element, isEditing]);
  109. const onEdit = () => {
  110. trackEvent("hyperlink", "edit", "popup-ui");
  111. setAppState({ showHyperlinkPopup: "editor" });
  112. };
  113. const { x, y } = getCoordsForPopover(element, appState);
  114. if (
  115. appState.draggingElement ||
  116. appState.resizingElement ||
  117. appState.isRotating ||
  118. appState.openMenu
  119. ) {
  120. return null;
  121. }
  122. return (
  123. <div
  124. className="excalidraw-hyperlinkContainer"
  125. style={{
  126. top: `${y}px`,
  127. left: `${x}px`,
  128. width: CONTAINER_WIDTH,
  129. padding: CONTAINER_PADDING,
  130. }}
  131. >
  132. {isEditing ? (
  133. <input
  134. className={clsx("excalidraw-hyperlinkContainer-input")}
  135. placeholder="Type or paste your link here"
  136. ref={inputRef}
  137. value={inputVal}
  138. onChange={(event) => setInputVal(event.target.value)}
  139. autoFocus
  140. onKeyDown={(event) => {
  141. event.stopPropagation();
  142. // prevent cmd/ctrl+k shortcut when editing link
  143. if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K) {
  144. event.preventDefault();
  145. }
  146. if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
  147. handleSubmit();
  148. }
  149. }}
  150. />
  151. ) : (
  152. <a
  153. href={element.link || ""}
  154. className={clsx("excalidraw-hyperlinkContainer-link", {
  155. "d-none": isEditing,
  156. })}
  157. target={isLocalLink(element.link) ? "_self" : "_blank"}
  158. onClick={(event) => {
  159. if (element.link && onLinkOpen) {
  160. const customEvent = wrapEvent(
  161. EVENT.EXCALIDRAW_LINK,
  162. event.nativeEvent,
  163. );
  164. onLinkOpen(element, customEvent);
  165. if (customEvent.defaultPrevented) {
  166. event.preventDefault();
  167. }
  168. }
  169. }}
  170. rel="noopener noreferrer"
  171. >
  172. {element.link}
  173. </a>
  174. )}
  175. <div className="excalidraw-hyperlinkContainer__buttons">
  176. {!isEditing && (
  177. <ToolButton
  178. type="button"
  179. title={t("buttons.edit")}
  180. aria-label={t("buttons.edit")}
  181. label={t("buttons.edit")}
  182. onClick={onEdit}
  183. className="excalidraw-hyperlinkContainer--edit"
  184. icon={FreedrawIcon}
  185. />
  186. )}
  187. {linkVal && (
  188. <ToolButton
  189. type="button"
  190. title={t("buttons.remove")}
  191. aria-label={t("buttons.remove")}
  192. label={t("buttons.remove")}
  193. onClick={handleRemove}
  194. className="excalidraw-hyperlinkContainer--remove"
  195. icon={TrashIcon}
  196. />
  197. )}
  198. </div>
  199. </div>
  200. );
  201. };
  202. const getCoordsForPopover = (
  203. element: NonDeletedExcalidrawElement,
  204. appState: AppState,
  205. ) => {
  206. const [x1, y1] = getElementAbsoluteCoords(element);
  207. const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
  208. { sceneX: x1 + element.width / 2, sceneY: y1 },
  209. appState,
  210. );
  211. const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2;
  212. const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
  213. return { x, y };
  214. };
  215. export const normalizeLink = (link: string) => {
  216. link = link.trim();
  217. if (link) {
  218. // prefix with protocol if not fully-qualified
  219. if (!link.includes("://") && !/^[[\\/]/.test(link)) {
  220. link = `https://${link}`;
  221. }
  222. }
  223. return link;
  224. };
  225. export const isLocalLink = (link: string | null) => {
  226. return !!(link?.includes(location.origin) || link?.startsWith("/"));
  227. };
  228. export const actionLink = register({
  229. name: "hyperlink",
  230. perform: (elements, appState) => {
  231. if (appState.showHyperlinkPopup === "editor") {
  232. return false;
  233. }
  234. return {
  235. elements,
  236. appState: {
  237. ...appState,
  238. showHyperlinkPopup: "editor",
  239. openMenu: null,
  240. },
  241. commitToHistory: true,
  242. };
  243. },
  244. trackEvent: { category: "hyperlink", action: "click" },
  245. keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
  246. contextItemLabel: (elements, appState) =>
  247. getContextMenuLabel(elements, appState),
  248. predicate: (elements, appState) => {
  249. const selectedElements = getSelectedElements(elements, appState);
  250. return selectedElements.length === 1;
  251. },
  252. PanelComponent: ({ elements, appState, updateData }) => {
  253. const selectedElements = getSelectedElements(elements, appState);
  254. return (
  255. <ToolButton
  256. type="button"
  257. icon={LinkIcon}
  258. aria-label={t(getContextMenuLabel(elements, appState))}
  259. title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
  260. onClick={() => updateData(null)}
  261. selected={selectedElements.length === 1 && !!selectedElements[0].link}
  262. />
  263. );
  264. },
  265. });
  266. export const getContextMenuLabel = (
  267. elements: readonly NonDeletedExcalidrawElement[],
  268. appState: AppState,
  269. ) => {
  270. const selectedElements = getSelectedElements(elements, appState);
  271. const label = selectedElements[0]!.link
  272. ? "labels.link.edit"
  273. : "labels.link.create";
  274. return label;
  275. };
  276. export const getLinkHandleFromCoords = (
  277. [x1, y1, x2, y2]: Bounds,
  278. angle: number,
  279. appState: AppState,
  280. ): [x: number, y: number, width: number, height: number] => {
  281. const size = DEFAULT_LINK_SIZE;
  282. const linkWidth = size / appState.zoom.value;
  283. const linkHeight = size / appState.zoom.value;
  284. const linkMarginY = size / appState.zoom.value;
  285. const centerX = (x1 + x2) / 2;
  286. const centerY = (y1 + y2) / 2;
  287. const centeringOffset = (size - 8) / (2 * appState.zoom.value);
  288. const dashedLineMargin = 4 / appState.zoom.value;
  289. // Same as `ne` resize handle
  290. const x = x2 + dashedLineMargin - centeringOffset;
  291. const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
  292. const [rotatedX, rotatedY] = rotate(
  293. x + linkWidth / 2,
  294. y + linkHeight / 2,
  295. centerX,
  296. centerY,
  297. angle,
  298. );
  299. return [
  300. rotatedX - linkWidth / 2,
  301. rotatedY - linkHeight / 2,
  302. linkWidth,
  303. linkHeight,
  304. ];
  305. };
  306. export const isPointHittingLinkIcon = (
  307. element: NonDeletedExcalidrawElement,
  308. appState: AppState,
  309. [x, y]: Point,
  310. isMobile: boolean,
  311. ) => {
  312. if (!element.link || appState.selectedElementIds[element.id]) {
  313. return false;
  314. }
  315. const threshold = 4 / appState.zoom.value;
  316. if (
  317. !isMobile &&
  318. appState.viewModeEnabled &&
  319. isPointHittingElementBoundingBox(element, [x, y], threshold)
  320. ) {
  321. return true;
  322. }
  323. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  324. const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
  325. [x1, y1, x2, y2],
  326. element.angle,
  327. appState,
  328. );
  329. const hitLink =
  330. x > linkX - threshold &&
  331. x < linkX + threshold + linkWidth &&
  332. y > linkY - threshold &&
  333. y < linkY + linkHeight + threshold;
  334. return hitLink;
  335. };
  336. let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
  337. export const showHyperlinkTooltip = (
  338. element: NonDeletedExcalidrawElement,
  339. appState: AppState,
  340. ) => {
  341. if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
  342. clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
  343. }
  344. HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout(
  345. () => renderTooltip(element, appState),
  346. HYPERLINK_TOOLTIP_DELAY,
  347. );
  348. };
  349. const renderTooltip = (
  350. element: NonDeletedExcalidrawElement,
  351. appState: AppState,
  352. ) => {
  353. if (!element.link) {
  354. return;
  355. }
  356. const tooltipDiv = getTooltipDiv();
  357. tooltipDiv.classList.add("excalidraw-tooltip--visible");
  358. tooltipDiv.style.maxWidth = "20rem";
  359. tooltipDiv.textContent = element.link;
  360. const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
  361. const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
  362. [x1, y1, x2, y2],
  363. element.angle,
  364. appState,
  365. );
  366. const linkViewportCoords = sceneCoordsToViewportCoords(
  367. { sceneX: linkX, sceneY: linkY },
  368. appState,
  369. );
  370. updateTooltipPosition(
  371. tooltipDiv,
  372. {
  373. left: linkViewportCoords.x,
  374. top: linkViewportCoords.y,
  375. width: linkWidth,
  376. height: linkHeight,
  377. },
  378. "top",
  379. );
  380. trackEvent("hyperlink", "tooltip", "link-icon");
  381. IS_HYPERLINK_TOOLTIP_VISIBLE = true;
  382. };
  383. export const hideHyperlinkToolip = () => {
  384. if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
  385. clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
  386. }
  387. if (IS_HYPERLINK_TOOLTIP_VISIBLE) {
  388. IS_HYPERLINK_TOOLTIP_VISIBLE = false;
  389. getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
  390. }
  391. };
  392. export const shouldHideLinkPopup = (
  393. element: NonDeletedExcalidrawElement,
  394. appState: AppState,
  395. [clientX, clientY]: Point,
  396. ): Boolean => {
  397. const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
  398. { clientX, clientY },
  399. appState,
  400. );
  401. const threshold = 15 / appState.zoom.value;
  402. // hitbox to prevent hiding when hovered in element bounding box
  403. if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
  404. return false;
  405. }
  406. const [x1, y1, x2] = getElementAbsoluteCoords(element);
  407. // hit box to prevent hiding when hovered in the vertical area between element and popover
  408. if (
  409. sceneX >= x1 &&
  410. sceneX <= x2 &&
  411. sceneY >= y1 - SPACE_BOTTOM &&
  412. sceneY <= y1
  413. ) {
  414. return false;
  415. }
  416. // hit box to prevent hiding when hovered around popover within threshold
  417. const { x: popoverX, y: popoverY } = getCoordsForPopover(element, appState);
  418. if (
  419. clientX >= popoverX - threshold &&
  420. clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
  421. clientY >= popoverY - threshold &&
  422. clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
  423. ) {
  424. return false;
  425. }
  426. return true;
  427. };