Pārlūkot izejas kodu

feat: Support hyperlinks 🔥 (#4620)

* feat: Support hypelinks

* dont show edit when link not present

* auto submit on blur

* Add link button in sidebar and do it react way

* add key to hyperlink to remount when element selection changes

* autofocus input

* remove click handler and use pointerup/down to show /hide popup

* add keydown and support enter/escape to submit

* show extrrnal link icon when element has link

* use icons and open link in new tab

* dnt submit unless link updated

* renamed ffiles

* remove unnecessary changes

* update snap

* hide link popup once user starts interacting with element and show again only if clicked outside and clicked on element again

* render link icon outside the element

* fix hit testing

* rewrite implementation to render hyperlinks outside elements and hide when element selected

* remove

* remove

* tweak icon position and size

* rotate link icon when element rotated, handle zooming and render exactly where ne resize handle is rendered

* no need to create a new reference anymore for element when link added/updated

* rotate the link image as well when rotating element

* calculate hitbox of link icon and show pointer when hovering over link icon

* open link when clicked on link icon

* show tooltip when hovering over link icon

* show link action only when single element selected

* support other protocols

* add shortcut cmd/ctrl+k to edit/update link

* don't hide popup after submit

* renderes decreased woo

* Add context mneu label to add/edit link

* fix tests

* remove tick and show trash when in edit mode

* show edit view when element contains link

* fix snap

* horizontally center the hyperlink container with respect to elemnt

* fix padding

* remove checkcircle

* show popup on hover of selected element and dismiss when outside hitbox

* check if element has link before setting popup state

* move logic of auto hide to hyperlink and dnt hide when editing

* hide popover when drag/resize/rotate

* unmount during autohide

* autohide after 500ms

* fix regression

* prevent cmd/ctrl+k when inside link editor

* submit when input not updated

* allow custom urls

* fix centering of popup when zoomed

* fix hitbox during zoom

* fix

* tweak link normalization

* touch hyperlink tooltip DOM only if needed

* consider 0 if no offsetY

* reduce hitbox of link icon and make sure link icon doesn't show on top of higher z-index elements

* show link tooltip only if element has higher z-index

* dnt show hyperlink popup when selection changes from element with link to element with no link and also hide popover when element type changes from selection to something else

* lint: EOL

* fix link icon tooltip positioning

* open the link only when last pointer down and last pointer up hit the link hitbox

* render tooltip after 300ms delay

* ensure link popup and editor input have same height

* wip: cache the link icon canvas

* fix the image quality after caching using device pixel ratio yay

* some cleanup

* remove unused selectedElementIds from renderConfig

* Update src/renderer/renderElement.ts

* fix `opener` vulnerability

* tweak styling

* decrease padding

* open local links in the same tab

* fix caching

* code style refactor

* remove unnecessary save & restore

* show link shortcut in help dialog

* submit on cmd/ctrl+k

* merge state props

* Add title for link

* update editview if prop changes

* tweak link action logic

* make `Hyperlink` compo editor state fully controlled

* dont show popup when context menu open

* show in contextMenu only for single selection & change pos

* set button `selected` state

* set contextMenuOpen on pointerdown

* set contextMenyOpen to false when action triggered

* don't render link icons on export

* fix tests

* fix buttons wrap

* move focus states to input top-level rule

* fix elements sharing `Hyperlink` state

* fix hitbox for link icon in case of rect

* Early return if hitting link icon

Co-authored-by: dwelle <luzar.david@gmail.com>
Aakansha Doshi 3 gadi atpakaļ
vecāks
revīzija
f47ddb988f

+ 1 - 0
src/actions/index.ts

@@ -81,3 +81,4 @@ export { actionToggleZenMode } from "./actionToggleZenMode";
 
 export { actionToggleStats } from "./actionToggleStats";
 export { actionUnbindText } from "./actionUnbindText";
+export { actionLink } from "../element/Hyperlink";

+ 3 - 1
src/actions/shortcuts.ts

@@ -25,7 +25,8 @@ export type ShortcutName =
   | "addToLibrary"
   | "viewMode"
   | "flipHorizontal"
-  | "flipVertical";
+  | "flipVertical"
+  | "link";
 
 const shortcutMap: Record<ShortcutName, string[]> = {
   cut: [getShortcutKey("CtrlOrCmd+X")],
@@ -62,6 +63,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
   flipHorizontal: [getShortcutKey("Shift+H")],
   flipVertical: [getShortcutKey("Shift+V")],
   viewMode: [getShortcutKey("Alt+R")],
+  link: [getShortcutKey("CtrlOrCmd+K")],
 };
 
 export const getShortcutFromShortcutName = (name: ShortcutName) => {

+ 2 - 1
src/actions/types.ts

@@ -104,7 +104,8 @@ export type ActionName =
   | "toggleTheme"
   | "increaseFontSize"
   | "decreaseFontSize"
-  | "unbindText";
+  | "unbindText"
+  | "link";
 
 export type PanelComponentProps = {
   elements: readonly ExcalidrawElement[];

+ 2 - 0
src/appState.ts

@@ -84,6 +84,7 @@ export const getDefaultAppState = (): Omit<
     },
     viewModeEnabled: false,
     pendingImageElement: null,
+    showHyperlinkPopup: false,
   };
 };
 
@@ -174,6 +175,7 @@ const APP_STATE_STORAGE_CONF = (<
   zoom: { browser: true, export: false, server: false },
   viewModeEnabled: { browser: false, export: false, server: false },
   pendingImageElement: { browser: false, export: false, server: false },
+  showHyperlinkPopup: { browser: false, export: false, server: false },
 });
 
 const _clearAppStateForStorage = <

+ 1 - 0
src/components/Actions.tsx

@@ -158,6 +158,7 @@ export const SelectedShapeActions = ({
             {renderAction("deleteSelectedElements")}
             {renderAction("group")}
             {renderAction("ungroup")}
+            {targetElements.length === 1 && renderAction("link")}
           </div>
         </fieldset>
       )}

+ 183 - 34
src/components/App.tsx

@@ -28,6 +28,7 @@ import {
   actionToggleZenMode,
   actionUnbindText,
   actionUngroup,
+  actionLink,
 } from "../actions";
 import { createRedoAction, createUndoAction } from "../actions/actionHistory";
 import { ActionManager } from "../actions/manager";
@@ -141,6 +142,7 @@ import {
   InitializedExcalidrawImageElement,
   ExcalidrawImageElement,
   FileId,
+  NonDeletedExcalidrawElement,
 } from "../element/types";
 import { getCenter, getDistance } from "../gesture";
 import {
@@ -239,6 +241,14 @@ import {
   getBoundTextElementId,
 } from "../element/textElement";
 import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
+import {
+  normalizeLink,
+  showHyperlinkTooltip,
+  hideHyperlinkToolip,
+  Hyperlink,
+  isPointHittingLinkIcon,
+  isLocalLink,
+} from "../element/Hyperlink";
 
 const IsMobileContext = React.createContext(false);
 export const useIsMobile = () => useContext(IsMobileContext);
@@ -298,6 +308,11 @@ class App extends React.Component<AppProps, AppState> {
   public files: BinaryFiles = {};
   public imageCache: AppClassProperties["imageCache"] = new Map();
 
+  hitLinkElement?: NonDeletedExcalidrawElement;
+  lastPointerDown: React.PointerEvent<HTMLCanvasElement> | null = null;
+  lastPointerUp: React.PointerEvent<HTMLElement> | PointerEvent | null = null;
+  contextMenuOpen: boolean = false;
+
   constructor(props: AppProps) {
     super(props);
     const defaultAppState = getDefaultAppState();
@@ -320,6 +335,7 @@ class App extends React.Component<AppProps, AppState> {
       name,
       width: window.innerWidth,
       height: window.innerHeight,
+      showHyperlinkPopup: false,
     };
 
     this.id = nanoid();
@@ -433,7 +449,10 @@ class App extends React.Component<AppProps, AppState> {
 
   public render() {
     const { zenModeEnabled, viewModeEnabled } = this.state;
-
+    const selectedElement = getSelectedElements(
+      this.scene.getElements(),
+      this.state,
+    );
     const {
       onCollabButtonClick,
       renderTopRightUI,
@@ -499,6 +518,14 @@ class App extends React.Component<AppProps, AppState> {
             />
             <div className="excalidraw-textEditorContainer" />
             <div className="excalidraw-contextMenuContainer" />
+            {selectedElement.length === 1 && this.state.showHyperlinkPopup && (
+              <Hyperlink
+                key={selectedElement[0].id}
+                element={selectedElement[0]}
+                appState={this.state}
+                setAppState={this.setAppState}
+              />
+            )}
             {this.state.showStats && (
               <Stats
                 appState={this.state}
@@ -537,6 +564,8 @@ class App extends React.Component<AppProps, AppState> {
 
   private syncActionResult = withBatchedUpdates(
     (actionResult: ActionResult) => {
+      // Since context menu closes when action triggered so setting to false
+      this.contextMenuOpen = false;
       if (this.unmounted || actionResult === false) {
         return;
       }
@@ -1012,6 +1041,14 @@ class App extends React.Component<AppProps, AppState> {
   }
 
   componentDidUpdate(prevProps: AppProps, prevState: AppState) {
+    // Hide hyperlink popup if shown when element type is not selection
+    if (
+      prevState.elementType === "selection" &&
+      this.state.elementType !== "selection" &&
+      this.state.showHyperlinkPopup
+    ) {
+      this.setState({ showHyperlinkPopup: false });
+    }
     if (prevProps.langCode !== this.props.langCode) {
       this.updateLanguage();
     }
@@ -1157,6 +1194,7 @@ class App extends React.Component<AppProps, AppState> {
         renderScrollbars: !this.isMobile,
       },
     );
+
     if (scrollBars) {
       currentScrollBars = scrollBars;
     }
@@ -1481,6 +1519,7 @@ class App extends React.Component<AppProps, AppState> {
   };
 
   removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
+    this.lastPointerUp = event;
     // remove touch handler for context menu on touch devices
     if (event.pointerType === "touch" && touchTimeout) {
       clearTimeout(touchTimeout);
@@ -2083,6 +2122,7 @@ class App extends React.Component<AppProps, AppState> {
           .filter(
             (element) => !(isTextElement(element) && element.containerId),
           );
+
     return getElementsAtPosition(elements, (element) =>
       hitTest(element, this.state, x, y),
     );
@@ -2308,6 +2348,69 @@ class App extends React.Component<AppProps, AppState> {
     }
   };
 
+  private getElementLinkAtPosition = (
+    scenePointer: Readonly<{ x: number; y: number }>,
+    hitElement: NonDeletedExcalidrawElement | null,
+  ): ExcalidrawElement | undefined => {
+    // Reversing so we traverse the elements in decreasing order
+    // of z-index
+    const elements = this.scene.getElements().slice().reverse();
+    let hitElementIndex = Infinity;
+
+    return elements.find((element, index) => {
+      if (hitElement && element.id === hitElement.id) {
+        hitElementIndex = index;
+      }
+      return (
+        element.link &&
+        isPointHittingLinkIcon(element, this.state, [
+          scenePointer.x,
+          scenePointer.y,
+        ]) &&
+        index <= hitElementIndex
+      );
+    });
+  };
+
+  private redirectToLink = () => {
+    const lastPointerDownCoords = viewportCoordsToSceneCoords(
+      this.lastPointerDown!,
+      this.state,
+    );
+    const lastPointerDownHittingLinkIcon = isPointHittingLinkIcon(
+      this.hitLinkElement!,
+      this.state,
+      [lastPointerDownCoords.x, lastPointerDownCoords.y],
+    );
+    const lastPointerUpCoords = viewportCoordsToSceneCoords(
+      this.lastPointerUp!,
+      this.state,
+    );
+    const LastPointerUpHittingLinkIcon = isPointHittingLinkIcon(
+      this.hitLinkElement!,
+      this.state,
+      [lastPointerUpCoords.x, lastPointerUpCoords.y],
+    );
+    if (lastPointerDownHittingLinkIcon && LastPointerUpHittingLinkIcon) {
+      const url = this.hitLinkElement?.link;
+      if (url) {
+        const target = isLocalLink(url) ? "_self" : "_blank";
+        const newWindow = window.open(undefined, target);
+        // https://mathiasbynens.github.io/rel-noopener/
+        if (newWindow) {
+          newWindow.opener = null;
+          newWindow.location = normalizeLink(url);
+        }
+      }
+    }
+  };
+  private attachLinkListener = () => {
+    this.canvas?.addEventListener("click", this.redirectToLink);
+  };
+  private detachLinkListener = () => {
+    this.canvas?.removeEventListener("click", this.redirectToLink);
+  };
+
   private handleCanvasPointerMove = (
     event: React.PointerEvent<HTMLCanvasElement>,
   ) => {
@@ -2540,42 +2643,68 @@ class App extends React.Component<AppProps, AppState> {
       scenePointer.x,
       scenePointer.y,
     );
-    if (this.state.elementType === "text") {
-      setCursor(
-        this.canvas,
-        isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
-      );
-    } else if (this.state.viewModeEnabled) {
-      setCursor(this.canvas, CURSOR_TYPE.GRAB);
-    } else if (isOverScrollBar) {
-      setCursor(this.canvas, CURSOR_TYPE.AUTO);
-    } else if (this.state.editingLinearElement) {
-      const element = LinearElementEditor.getElement(
-        this.state.editingLinearElement.elementId,
-      );
+    this.hitLinkElement = this.getElementLinkAtPosition(
+      scenePointer,
+      hitElement,
+    );
+
+    if (
+      this.hitLinkElement &&
+      !this.state.selectedElementIds[this.hitLinkElement.id]
+    ) {
+      setCursor(this.canvas, CURSOR_TYPE.POINTER);
+      showHyperlinkTooltip(this.hitLinkElement, this.state);
+      this.attachLinkListener();
+    } else {
+      hideHyperlinkToolip();
+      this.detachLinkListener();
       if (
-        element &&
-        isHittingElementNotConsideringBoundingBox(element, this.state, [
-          scenePointer.x,
-          scenePointer.y,
-        ])
+        hitElement &&
+        hitElement.link &&
+        this.state.selectedElementIds[hitElement.id] &&
+        !this.contextMenuOpen &&
+        !this.state.showHyperlinkPopup
+      ) {
+        this.setState({ showHyperlinkPopup: "info" });
+      }
+      if (this.state.elementType === "text") {
+        setCursor(
+          this.canvas,
+          isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR,
+        );
+      } else if (this.state.viewModeEnabled) {
+        setCursor(this.canvas, CURSOR_TYPE.GRAB);
+      } else if (isOverScrollBar) {
+        setCursor(this.canvas, CURSOR_TYPE.AUTO);
+      } else if (this.state.editingLinearElement) {
+        const element = LinearElementEditor.getElement(
+          this.state.editingLinearElement.elementId,
+        );
+
+        if (
+          element &&
+          isHittingElementNotConsideringBoundingBox(element, this.state, [
+            scenePointer.x,
+            scenePointer.y,
+          ])
+        ) {
+          setCursor(this.canvas, CURSOR_TYPE.MOVE);
+        } else {
+          setCursor(this.canvas, CURSOR_TYPE.AUTO);
+        }
+      } else if (
+        // if using cmd/ctrl, we're not dragging
+        !event[KEYS.CTRL_OR_CMD] &&
+        (hitElement ||
+          this.isHittingCommonBoundingBoxOfSelectedElements(
+            scenePointer,
+            selectedElements,
+          ))
       ) {
         setCursor(this.canvas, CURSOR_TYPE.MOVE);
       } else {
         setCursor(this.canvas, CURSOR_TYPE.AUTO);
       }
-    } else if (
-      // if using cmd/ctrl, we're not dragging
-      !event[KEYS.CTRL_OR_CMD] &&
-      (hitElement ||
-        this.isHittingCommonBoundingBoxOfSelectedElements(
-          scenePointer,
-          selectedElements,
-        ))
-    ) {
-      setCursor(this.canvas, CURSOR_TYPE.MOVE);
-    } else {
-      setCursor(this.canvas, CURSOR_TYPE.AUTO);
     }
   };
 
@@ -2594,7 +2723,6 @@ class App extends React.Component<AppProps, AppState> {
     if (selection?.anchorNode) {
       selection.removeAllRanges();
     }
-
     this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
     this.maybeCleanupAfterMissingPointerUp(event);
 
@@ -2612,7 +2740,7 @@ class App extends React.Component<AppProps, AppState> {
     if (isPanning) {
       return;
     }
-
+    this.lastPointerDown = event;
     this.setState({
       lastPointerDownWith: event.pointerType,
       cursorButton: "down",
@@ -2646,6 +2774,8 @@ class App extends React.Component<AppProps, AppState> {
       return;
     }
 
+    // Since context menu closes on pointer down so setting to false
+    this.contextMenuOpen = false;
     this.clearSelectionIfNotUsingSelection();
     this.updateBindingEnabledOnPointerMove(event);
 
@@ -3072,7 +3202,6 @@ class App extends React.Component<AppProps, AppState> {
             return true;
           }
         }
-
         // hitElement may already be set above, so check first
         pointerDownState.hit.element =
           pointerDownState.hit.element ??
@@ -3082,6 +3211,15 @@ class App extends React.Component<AppProps, AppState> {
           );
 
         if (pointerDownState.hit.element) {
+          // Early return if pointer is hitting link icon
+          if (
+            isPointHittingLinkIcon(pointerDownState.hit.element, this.state, [
+              pointerDownState.origin.x,
+              pointerDownState.origin.y,
+            ])
+          ) {
+            return false;
+          }
           pointerDownState.hit.hasHitElementInside =
             isHittingElementNotConsideringBoundingBox(
               pointerDownState.hit.element,
@@ -3163,6 +3301,7 @@ class App extends React.Component<AppProps, AppState> {
                       ...prevState.selectedElementIds,
                       [hitElement.id]: true,
                     },
+                    showHyperlinkPopup: hitElement.link ? "info" : false,
                   },
                   this.scene.getElements(),
                 );
@@ -3819,6 +3958,11 @@ class App extends React.Component<AppProps, AppState> {
                       }
                     : null),
                 },
+                showHyperlinkPopup:
+                  elementsWithinSelection.length === 1 &&
+                  elementsWithinSelection[0].link
+                    ? "info"
+                    : false,
               },
               this.scene.getElements(),
             ),
@@ -4970,6 +5114,10 @@ class App extends React.Component<AppProps, AppState> {
     },
     type: "canvas" | "element",
   ) => {
+    if (this.state.showHyperlinkPopup) {
+      this.setState({ showHyperlinkPopup: false });
+    }
+    this.contextMenuOpen = true;
     const maybeGroupAction = actionGroup.contextItemPredicate!(
       this.actionManager.getElementsIncludingDeleted(),
       this.actionManager.getAppState(),
@@ -5116,6 +5264,7 @@ class App extends React.Component<AppProps, AppState> {
             maybeFlipHorizontal && actionFlipHorizontal,
             maybeFlipVertical && actionFlipVertical,
             (maybeFlipHorizontal || maybeFlipVertical) && separator,
+            actionLink.contextItemPredicate(elements, this.state) && actionLink,
             actionDuplicateSelection,
             actionDeleteSelected,
           ],

+ 4 - 0
src/components/HelpDialog.tsx

@@ -205,6 +205,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
                   label={t("helpDialog.preventBinding")}
                   shortcuts={[getShortcutKey("CtrlOrCmd")]}
                 />
+                <Shortcut
+                  label={t("toolBar.link")}
+                  shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
+                />
               </ShortcutIsland>
               <ShortcutIsland caption={t("helpDialog.view")}>
                 <Shortcut

+ 47 - 31
src/components/Tooltip.tsx

@@ -2,7 +2,7 @@ import "./Tooltip.scss";
 
 import React, { useEffect } from "react";
 
-const getTooltipDiv = () => {
+export const getTooltipDiv = () => {
   const existingDiv = document.querySelector<HTMLDivElement>(
     ".excalidraw-tooltip",
   );
@@ -15,49 +15,66 @@ const getTooltipDiv = () => {
   return div;
 };
 
-const updateTooltip = (
-  item: HTMLDivElement,
+export const updateTooltipPosition = (
   tooltip: HTMLDivElement,
-  label: string,
-  long: boolean,
+  item: {
+    left: number;
+    top: number;
+    width: number;
+    height: number;
+  },
+  position: "bottom" | "top" = "bottom",
 ) => {
-  tooltip.classList.add("excalidraw-tooltip--visible");
-  tooltip.style.minWidth = long ? "50ch" : "10ch";
-  tooltip.style.maxWidth = long ? "50ch" : "15ch";
-
-  tooltip.textContent = label;
-
-  const {
-    x: itemX,
-    bottom: itemBottom,
-    top: itemTop,
-    width: itemWidth,
-  } = item.getBoundingClientRect();
-
-  const { width: labelWidth, height: labelHeight } =
-    tooltip.getBoundingClientRect();
+  const tooltipRect = tooltip.getBoundingClientRect();
 
   const viewportWidth = window.innerWidth;
   const viewportHeight = window.innerHeight;
 
   const margin = 5;
 
-  const left = itemX + itemWidth / 2 - labelWidth / 2;
-  const offsetLeft =
-    left + labelWidth >= viewportWidth ? left + labelWidth - viewportWidth : 0;
+  let left = item.left + item.width / 2 - tooltipRect.width / 2;
+  if (left < 0) {
+    left = margin;
+  } else if (left + tooltipRect.width >= viewportWidth) {
+    left = viewportWidth - tooltipRect.width - margin;
+  }
 
-  const top = itemBottom + margin;
-  const offsetTop =
-    top + labelHeight >= viewportHeight
-      ? itemBottom - itemTop + labelHeight + margin * 2
-      : 0;
+  let top: number;
+
+  if (position === "bottom") {
+    top = item.top + item.height + margin;
+    if (top + tooltipRect.height >= viewportHeight) {
+      top = item.top - tooltipRect.height - margin;
+    }
+  } else {
+    top = item.top - tooltipRect.height - margin;
+    if (top < 0) {
+      top = item.top + item.height + margin;
+    }
+  }
 
   Object.assign(tooltip.style, {
-    top: `${top - offsetTop}px`,
-    left: `${left - offsetLeft}px`,
+    top: `${top}px`,
+    left: `${left}px`,
   });
 };
 
+const updateTooltip = (
+  item: HTMLDivElement,
+  tooltip: HTMLDivElement,
+  label: string,
+  long: boolean,
+) => {
+  tooltip.classList.add("excalidraw-tooltip--visible");
+  tooltip.style.minWidth = long ? "50ch" : "10ch";
+  tooltip.style.maxWidth = long ? "50ch" : "15ch";
+
+  tooltip.textContent = label;
+
+  const itemRect = item.getBoundingClientRect();
+  updateTooltipPosition(tooltip, itemRect);
+};
+
 type TooltipProps = {
   children: React.ReactNode;
   label: string;
@@ -75,7 +92,6 @@ export const Tooltip = ({
     return () =>
       getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
   }, []);
-
   return (
     <div
       className="excalidraw-tooltip-wrapper"

+ 8 - 0
src/components/icons.tsx

@@ -892,3 +892,11 @@ export const publishIcon = createIcon(
   />,
   { width: 640, height: 512 },
 );
+
+export const editIcon = createIcon(
+  <path
+    fill="currentColor"
+    d="M402.3 344.9l32-32c5-5 13.7-1.5 13.7 5.7V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h273.5c7.1 0 10.7 8.6 5.7 13.7l-32 32c-1.5 1.5-3.5 2.3-5.7 2.3H48v352h352V350.5c0-2.1.8-4.1 2.3-5.6zm156.6-201.8L296.3 405.7l-90.4 10c-26.2 2.9-48.5-19.2-45.6-45.6l10-90.4L432.9 17.1c22.9-22.9 59.9-22.9 82.7 0l43.2 43.2c22.9 22.9 22.9 60 .1 82.8zM460.1 174L402 115.9 216.2 301.8l-7.3 65.3 65.3-7.3L460.1 174zm64.8-79.7l-43.2-43.2c-4.1-4.1-10.8-4.1-14.8 0L436 82l58.1 58.1 30.9-30.9c4-4.2 4-10.8-.1-14.9z"
+  ></path>,
+  { width: 640, height: 512 },
+);

+ 1 - 0
src/constants.ts

@@ -115,6 +115,7 @@ export const TOAST_TIMEOUT = 5000;
 export const VERSION_TIMEOUT = 30000;
 export const SCROLL_TIMEOUT = 100;
 export const ZOOM_STEP = 0.1;
+export const HYPERLINK_TOOLTIP_DELAY = 300;
 
 // Report a user inactive after IDLE_THRESHOLD milliseconds
 export const IDLE_THRESHOLD = 60_000;

+ 1 - 0
src/data/restore.ts

@@ -105,6 +105,7 @@ const restoreElementWithProperties = <
       ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
       : element.boundElements ?? [],
     updated: element.updated ?? getUpdatedTimestamp(),
+    link: element.link ?? null,
   };
 
   return {

+ 74 - 0
src/element/Hyperlink.scss

@@ -0,0 +1,74 @@
+@import "../css/variables.module";
+
+.excalidraw-hyperlinkContainer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  position: absolute;
+  box-shadow: 0px 2px 4px 0 rgb(0 0 0 / 30%);
+  z-index: 100;
+  background: var(--island-bg-color);
+  border-radius: var(--border-radius-md);
+  box-sizing: border-box;
+  // to account for LS due to rendering icons after new link created
+  min-height: 42px;
+
+  &-input,
+  button {
+    z-index: 100;
+  }
+
+  &-input,
+  &-link {
+    height: 24px;
+    padding: 0 8px;
+    line-height: 24px;
+    font-size: 0.9rem;
+    font-weight: 500;
+    font-family: var(--ui-font);
+  }
+
+  &-input {
+    width: 18rem;
+    border: none;
+    background-color: transparent;
+    color: var(--text-primary-color);
+
+    outline: none;
+    border: none;
+    box-shadow: none !important;
+  }
+
+  &-link {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    max-width: 15rem;
+  }
+
+  button {
+    color: $oc-blue-6;
+    background-color: transparent !important;
+    font-weight: 500;
+    &.excalidraw-hyperlinkContainer--remove {
+      color: $oc-red-9;
+    }
+  }
+
+  .d-none {
+    display: none;
+  }
+
+  &--remove .ToolIcon__icon svg {
+    color: $oc-red-6;
+  }
+
+  .ToolIcon__icon {
+    width: 2rem;
+    height: 2rem;
+  }
+
+  &__buttons {
+    flex: 0 0 auto;
+  }
+}

+ 429 - 0
src/element/Hyperlink.tsx

@@ -0,0 +1,429 @@
+import { AppState, Point } from "../types";
+import {
+  getShortcutKey,
+  sceneCoordsToViewportCoords,
+  viewportCoordsToSceneCoords,
+} from "../utils";
+import { mutateElement } from "./mutateElement";
+import { NonDeletedExcalidrawElement } from "./types";
+
+import { register } from "../actions/register";
+import { ToolButton } from "../components/ToolButton";
+import { editIcon, link, trash } from "../components/icons";
+import { t } from "../i18n";
+import {
+  useCallback,
+  useEffect,
+  useLayoutEffect,
+  useRef,
+  useState,
+} from "react";
+import clsx from "clsx";
+import { KEYS } from "../keys";
+import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
+import { rotate } from "../math";
+import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
+import { Bounds } from "./bounds";
+import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
+import { getSelectedElements } from "../scene";
+import { isPointHittingElementBoundingBox } from "./collision";
+import { getElementAbsoluteCoords } from "./";
+
+import "./Hyperlink.scss";
+
+const CONTAINER_WIDTH = 320;
+const SPACE_BOTTOM = 85;
+const CONTAINER_PADDING = 5;
+const CONTAINER_HEIGHT = 42;
+const AUTO_HIDE_TIMEOUT = 500;
+
+export const EXTERNAL_LINK_IMG = document.createElement("img");
+EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
+  `<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>`,
+)}`;
+
+let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
+
+export const Hyperlink = ({
+  element,
+  appState,
+  setAppState,
+}: {
+  element: NonDeletedExcalidrawElement;
+  appState: AppState;
+  setAppState: React.Component<any, AppState>["setState"];
+}) => {
+  const linkVal = element.link || "";
+
+  const [inputVal, setInputVal] = useState(linkVal);
+  const inputRef = useRef<HTMLInputElement>(null);
+  const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
+
+  const handleSubmit = useCallback(() => {
+    if (!inputRef.current) {
+      return;
+    }
+
+    const link = normalizeLink(inputRef.current.value);
+
+    mutateElement(element, { link });
+    setAppState({ showHyperlinkPopup: "info" });
+  }, [element, setAppState]);
+
+  useLayoutEffect(() => {
+    return () => {
+      handleSubmit();
+    };
+  }, [handleSubmit]);
+
+  useEffect(() => {
+    let timeoutId: number | null = null;
+    const handlePointerMove = (event: PointerEvent) => {
+      if (isEditing) {
+        return;
+      }
+      if (timeoutId) {
+        clearTimeout(timeoutId);
+      }
+      const shouldHide = shouldHideLinkPopup(element, appState, [
+        event.clientX,
+        event.clientY,
+      ]) as boolean;
+      if (shouldHide) {
+        timeoutId = window.setTimeout(() => {
+          setAppState({ showHyperlinkPopup: false });
+        }, AUTO_HIDE_TIMEOUT);
+      }
+    };
+    window.addEventListener(EVENT.POINTER_MOVE, handlePointerMove, false);
+    return () => {
+      window.removeEventListener(EVENT.POINTER_MOVE, handlePointerMove, false);
+      if (timeoutId) {
+        clearTimeout(timeoutId);
+      }
+    };
+  }, [appState, element, isEditing, setAppState]);
+
+  const handleRemove = useCallback(() => {
+    mutateElement(element, { link: null });
+    if (isEditing) {
+      inputRef.current!.value = "";
+    }
+    setAppState({ showHyperlinkPopup: false });
+  }, [setAppState, element, isEditing]);
+
+  const onEdit = () => {
+    setAppState({ showHyperlinkPopup: "editor" });
+  };
+  const { x, y } = getCoordsForPopover(element, appState);
+  if (
+    appState.draggingElement ||
+    appState.resizingElement ||
+    appState.isRotating
+  ) {
+    return null;
+  }
+  return (
+    <div
+      className="excalidraw-hyperlinkContainer"
+      style={{
+        top: `${y}px`,
+        left: `${x}px`,
+        width: CONTAINER_WIDTH,
+        padding: CONTAINER_PADDING,
+      }}
+    >
+      {isEditing ? (
+        <input
+          className={clsx("excalidraw-hyperlinkContainer-input")}
+          placeholder="Type or paste your link here"
+          ref={inputRef}
+          value={inputVal}
+          onChange={(event) => setInputVal(event.target.value)}
+          autoFocus
+          onKeyDown={(event) => {
+            event.stopPropagation();
+            // prevent cmd/ctrl+k shortcut when editing link
+            if (event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K) {
+              event.preventDefault();
+            }
+            if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
+              handleSubmit();
+            }
+          }}
+        />
+      ) : (
+        <a
+          href={element.link || ""}
+          className={clsx("excalidraw-hyperlinkContainer-link", {
+            "d-none": isEditing,
+          })}
+          target={isLocalLink(element.link) ? "_self" : "_blank"}
+          rel="noopener noreferrer"
+        >
+          {element.link}
+        </a>
+      )}
+      <div className="excalidraw-hyperlinkContainer__buttons">
+        {!isEditing && (
+          <ToolButton
+            type="button"
+            title={t("buttons.edit")}
+            aria-label={t("buttons.edit")}
+            label={t("buttons.edit")}
+            onClick={onEdit}
+            className="excalidraw-hyperlinkContainer--edit"
+            icon={editIcon}
+          />
+        )}
+
+        {linkVal && (
+          <ToolButton
+            type="button"
+            title={t("buttons.remove")}
+            aria-label={t("buttons.remove")}
+            label={t("buttons.remove")}
+            onClick={handleRemove}
+            className="excalidraw-hyperlinkContainer--remove"
+            icon={trash}
+          />
+        )}
+      </div>
+    </div>
+  );
+};
+
+const getCoordsForPopover = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+) => {
+  const { x: viewPortX, y: viewPortY } = sceneCoordsToViewportCoords(
+    { sceneX: element.x + element.width / 2, sceneY: element.y },
+    appState,
+  );
+  const x = viewPortX - CONTAINER_WIDTH / 2;
+  const y = viewPortY - SPACE_BOTTOM;
+  return { x, y };
+};
+
+export const normalizeLink = (link: string) => {
+  link = link.trim();
+  if (link) {
+    // prefix with protocol if not fully-qualified
+    if (!link.includes("://") && !/^[[\\/]/.test(link)) {
+      link = `https://${link}`;
+    }
+  }
+  return link;
+};
+
+export const isLocalLink = (link: string | null) => {
+  return !!(link?.includes(location.origin) || link?.startsWith("/"));
+};
+
+export const actionLink = register({
+  name: "link",
+  perform: (elements, appState) => {
+    if (appState.showHyperlinkPopup === "editor") {
+      return false;
+    }
+    return {
+      elements,
+      appState: {
+        ...appState,
+        showHyperlinkPopup: "editor",
+      },
+      commitToHistory: true,
+    };
+  },
+  keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
+  contextItemLabel: (elements, appState) =>
+    getContextMenuLabel(elements, appState),
+  contextItemPredicate: (elements, appState) => {
+    const selectedElements = getSelectedElements(elements, appState);
+    return selectedElements.length === 1;
+  },
+  PanelComponent: ({ elements, appState, updateData }) => {
+    const selectedElements = getSelectedElements(elements, appState);
+
+    return (
+      <ToolButton
+        type="button"
+        icon={link}
+        aria-label={t(getContextMenuLabel(elements, appState))}
+        title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
+        onClick={() => updateData(null)}
+        selected={selectedElements.length === 1 && !!selectedElements[0].link}
+      />
+    );
+  },
+});
+
+export const getContextMenuLabel = (
+  elements: readonly NonDeletedExcalidrawElement[],
+  appState: AppState,
+) => {
+  const selectedElements = getSelectedElements(elements, appState);
+  const label = selectedElements[0]!.link
+    ? "labels.link.edit"
+    : "labels.link.create";
+  return label;
+};
+export const getLinkHandleFromCoords = (
+  [x1, y1, x2, y2]: Bounds,
+  angle: number,
+  appState: AppState,
+): [x: number, y: number, width: number, height: number] => {
+  const size = DEFAULT_LINK_SIZE;
+  const linkWidth = size / appState.zoom.value;
+  const linkHeight = size / appState.zoom.value;
+  const linkMarginY = size / appState.zoom.value;
+  const centerX = (x1 + x2) / 2;
+  const centerY = (y1 + y2) / 2;
+  const centeringOffset = (size - 8) / (2 * appState.zoom.value);
+  const dashedLineMargin = 4 / appState.zoom.value;
+
+  // Same as `ne` resize handle
+  const x = x2 + dashedLineMargin - centeringOffset;
+  const y = y1 - dashedLineMargin - linkMarginY + centeringOffset;
+
+  const [rotatedX, rotatedY] = rotate(
+    x + linkWidth / 2,
+    y + linkHeight / 2,
+    centerX,
+    centerY,
+    angle,
+  );
+  return [
+    rotatedX - linkWidth / 2,
+    rotatedY - linkHeight / 2,
+    linkWidth,
+    linkHeight,
+  ];
+};
+
+export const isPointHittingLinkIcon = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+  [x, y]: Point,
+) => {
+  const threshold = 4 / appState.zoom.value;
+
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+
+  const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
+    [x1, y1, x2, y2],
+    element.angle,
+    appState,
+  );
+  const hitLink =
+    x > linkX - threshold &&
+    x < linkX + threshold + linkWidth &&
+    y > linkY - threshold &&
+    y < linkY + linkHeight + threshold;
+
+  return hitLink;
+};
+
+let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
+export const showHyperlinkTooltip = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+) => {
+  if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
+    clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
+  }
+  HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout(
+    () => renderTooltip(element, appState),
+    HYPERLINK_TOOLTIP_DELAY,
+  );
+};
+
+const renderTooltip = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+) => {
+  if (!element.link) {
+    return;
+  }
+
+  const tooltipDiv = getTooltipDiv();
+
+  tooltipDiv.classList.add("excalidraw-tooltip--visible");
+  tooltipDiv.style.maxWidth = "20rem";
+  tooltipDiv.textContent = element.link;
+
+  const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+
+  const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
+    [x1, y1, x2, y2],
+    element.angle,
+    appState,
+  );
+
+  const linkViewportCoords = sceneCoordsToViewportCoords(
+    { sceneX: linkX, sceneY: linkY },
+    appState,
+  );
+
+  updateTooltipPosition(
+    tooltipDiv,
+    {
+      left: linkViewportCoords.x,
+      top: linkViewportCoords.y,
+      width: linkWidth,
+      height: linkHeight,
+    },
+    "top",
+  );
+
+  IS_HYPERLINK_TOOLTIP_VISIBLE = true;
+};
+export const hideHyperlinkToolip = () => {
+  if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
+    clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
+  }
+  if (IS_HYPERLINK_TOOLTIP_VISIBLE) {
+    IS_HYPERLINK_TOOLTIP_VISIBLE = false;
+    getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
+  }
+};
+
+export const shouldHideLinkPopup = (
+  element: NonDeletedExcalidrawElement,
+  appState: AppState,
+  [clientX, clientY]: Point,
+): Boolean => {
+  const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
+    { clientX, clientY },
+    appState,
+  );
+
+  const threshold = 15 / appState.zoom.value;
+  // hitbox to prevent hiding when hovered in element bounding box
+  if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
+    return false;
+  }
+
+  // hit box to prevent hiding when hovered in the vertical area between element and popover
+  if (
+    sceneX >= element.x &&
+    sceneX <= element.x + element.width &&
+    sceneY <= element.y &&
+    sceneY >= element.y - SPACE_BOTTOM
+  ) {
+    return false;
+  }
+  // hit box to prevent hiding when hovered around popover within threshold
+  const { x: popoverX, y: popoverY } = getCoordsForPopover(element, appState);
+
+  if (
+    clientX >= popoverX - threshold &&
+    clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
+    clientY >= popoverY - threshold &&
+    clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
+  ) {
+    return false;
+  }
+  return true;
+};

+ 1 - 2
src/element/collision.ts

@@ -96,7 +96,6 @@ export const isHittingElementNotConsideringBoundingBox = (
     : isElementDraggableFromInside(element)
     ? isInsideCheck
     : isNearCheck;
-
   return hitTestPointAgainstElement({ element, point, threshold, check });
 };
 
@@ -105,7 +104,7 @@ const isElementSelected = (
   element: NonDeleted<ExcalidrawElement>,
 ) => appState.selectedElementIds[element.id];
 
-const isPointHittingElementBoundingBox = (
+export const isPointHittingElementBoundingBox = (
   element: NonDeleted<ExcalidrawElement>,
   [x, y]: Point,
   threshold: number,

+ 3 - 0
src/element/newElement.ts

@@ -35,6 +35,7 @@ type ElementConstructorOpts = MarkOptional<
   | "seed"
   | "version"
   | "versionNonce"
+  | "link"
 >;
 
 const _newElementBase = <T extends ExcalidrawElement>(
@@ -55,6 +56,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     groupIds = [],
     strokeSharpness,
     boundElements = null,
+    link = null,
     ...rest
   }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
 ) => {
@@ -81,6 +83,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
     isDeleted: false as false,
     boundElements,
     updated: getUpdatedTimestamp(),
+    link,
   };
   return element;
 };

+ 1 - 0
src/element/types.ts

@@ -52,6 +52,7 @@ type _ExcalidrawElementBase = Readonly<{
     | null;
   /** epoch (ms) timestamp of last element update */
   updated: number;
+  link: string | null;
 }>;
 
 export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {

+ 1 - 0
src/keys.ts

@@ -61,6 +61,7 @@ export const KEYS = {
   X: "x",
   Y: "y",
   Z: "z",
+  K: "k",
 } as const;
 
 export type Key = keyof typeof KEYS;

+ 8 - 2
src/locales/en.json

@@ -105,7 +105,12 @@
     "excalidrawLib": "Excalidraw Library",
     "decreaseFontSize": "Decrease font size",
     "increaseFontSize": "Increase font size",
-    "unbindText": "Unbind text"
+    "unbindText": "Unbind text",
+    "link": {
+      "edit": "Edit link",
+      "create": "Create link",
+      "label": "Link"
+    }
   },
   "buttons": {
     "clearReset": "Reset the canvas",
@@ -188,7 +193,8 @@
     "text": "Text",
     "library": "Library",
     "lock": "Keep selected tool active after drawing",
-    "penMode": "Prevent pinch-zoom and accept freedraw input only from pen"
+    "penMode": "Prevent pinch-zoom and accept freedraw input only from pen",
+    "link": "Add/ Update link for a selected shape"
   },
   "headings": {
     "canvasActions": "Canvas actions",

+ 9 - 7
src/renderer/renderElement.ts

@@ -145,6 +145,8 @@ const generateElementCanvas = (
   };
 };
 
+export const DEFAULT_LINK_SIZE = 14;
+
 const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
 IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
   `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,
@@ -410,23 +412,23 @@ const generateElementShape = (
               topY + (rightY - topY) * 0.25
             } L ${rightX - (rightX - topX) * 0.25} ${
               rightY - (rightY - topY) * 0.25
-            } 
+            }
             C ${rightX} ${rightY}, ${rightX} ${rightY}, ${
               rightX - (rightX - bottomX) * 0.25
-            } ${rightY + (bottomY - rightY) * 0.25} 
+            } ${rightY + (bottomY - rightY) * 0.25}
             L ${bottomX + (rightX - bottomX) * 0.25} ${
               bottomY - (bottomY - rightY) * 0.25
-            }  
+            }
             C ${bottomX} ${bottomY}, ${bottomX} ${bottomY}, ${
               bottomX - (bottomX - leftX) * 0.25
-            } ${bottomY - (bottomY - leftY) * 0.25} 
+            } ${bottomY - (bottomY - leftY) * 0.25}
             L ${leftX + (bottomX - leftX) * 0.25} ${
               leftY + (bottomY - leftY) * 0.25
-            } 
+            }
             C ${leftX} ${leftY}, ${leftX} ${leftY}, ${
               leftX + (topX - leftX) * 0.25
-            } ${leftY - (leftY - topY) * 0.25} 
-            L ${topX - (topX - leftX) * 0.25} ${topY + (leftY - topY) * 0.25} 
+            } ${leftY - (leftY - topY) * 0.25}
+            L ${topX - (topX - leftX) * 0.25} ${topY + (leftY - topY) * 0.25}
             C ${topX} ${topY}, ${topX} ${topY}, ${
               topX + (rightX - topX) * 0.25
             } ${topY + (rightY - topY) * 0.25}`,

+ 62 - 0
src/renderer/renderScene.ts

@@ -50,6 +50,10 @@ import {
 import { viewportCoordsToSceneCoords, supportsEmoji } from "../utils";
 import { UserIdleState } from "../types";
 import { THEME_FILTER } from "../constants";
+import {
+  EXTERNAL_LINK_IMG,
+  getLinkHandleFromCoords,
+} from "../element/Hyperlink";
 
 const hasEmojiSupport = supportsEmoji();
 
@@ -260,6 +264,9 @@ export const renderScene = (
   visibleElements.forEach((element) => {
     try {
       renderElement(element, rc, context, renderConfig);
+      if (!isExporting) {
+        renderLinkIcon(element, context, appState);
+      }
     } catch (error: any) {
       console.error(error);
     }
@@ -740,6 +747,61 @@ const renderBindingHighlightForSuggestedPointBinding = (
   });
 };
 
+let linkCanvasCache: any;
+const renderLinkIcon = (
+  element: NonDeletedExcalidrawElement,
+  context: CanvasRenderingContext2D,
+  appState: AppState,
+) => {
+  if (element.link && !appState.selectedElementIds[element.id]) {
+    const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
+    const [x, y, width, height] = getLinkHandleFromCoords(
+      [x1, y1, x2, y2],
+      element.angle,
+      appState,
+    );
+    const centerX = x + width / 2;
+    const centerY = y + height / 2;
+    context.save();
+    context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
+    context.rotate(element.angle);
+
+    if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) {
+      linkCanvasCache = document.createElement("canvas");
+      linkCanvasCache.zoom = appState.zoom.value;
+      linkCanvasCache.width =
+        width * window.devicePixelRatio * appState.zoom.value;
+      linkCanvasCache.height =
+        height * window.devicePixelRatio * appState.zoom.value;
+      const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
+      linkCanvasCacheContext.scale(
+        window.devicePixelRatio * appState.zoom.value,
+        window.devicePixelRatio * appState.zoom.value,
+      );
+      linkCanvasCacheContext.fillStyle = "#fff";
+      linkCanvasCacheContext.fillRect(0, 0, width, height);
+      linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height);
+      linkCanvasCacheContext.restore();
+      context.drawImage(
+        linkCanvasCache,
+        x - centerX,
+        y - centerY,
+        width,
+        height,
+      );
+    } else {
+      context.drawImage(
+        linkCanvasCache,
+        x - centerX,
+        y - centerY,
+        width,
+        height,
+      );
+    }
+    context.restore();
+  }
+};
+
 const isVisibleElement = (
   element: ExcalidrawElement,
   canvasWidth: number,

+ 109 - 0
src/tests/__snapshots__/contextmenu.test.tsx.snap

@@ -64,6 +64,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -89,6 +90,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -143,6 +145,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -232,6 +235,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -257,6 +261,7 @@ Object {
   "height": 20,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -284,6 +289,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -338,6 +344,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -376,6 +383,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -400,6 +408,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -438,6 +447,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -462,6 +472,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -551,6 +562,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -576,6 +588,7 @@ Object {
   "height": 20,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -603,6 +616,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -657,6 +671,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -695,6 +710,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -719,6 +735,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -757,6 +774,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -781,6 +799,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -870,6 +889,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -895,6 +915,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 449462985,
@@ -949,6 +970,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -1036,6 +1058,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -1061,6 +1084,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": true,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -1115,6 +1139,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1151,6 +1176,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": true,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1240,6 +1266,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -1265,6 +1292,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -1292,6 +1320,7 @@ Object {
   "height": 20,
   "id": "id0_copy",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -1346,6 +1375,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1384,6 +1414,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1408,6 +1439,7 @@ Object {
           "height": 20,
           "id": "id0_copy",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -1503,6 +1535,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -1530,6 +1563,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -1559,6 +1593,7 @@ Object {
   "height": 20,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -1613,6 +1648,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1651,6 +1687,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1675,6 +1712,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -1719,6 +1757,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1745,6 +1784,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -1834,6 +1874,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -1859,6 +1900,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 60,
   "roughness": 2,
   "seed": 1278240551,
@@ -1886,6 +1928,7 @@ Object {
   "height": 20,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 60,
   "roughness": 2,
   "seed": 400692809,
@@ -1940,6 +1983,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -1978,6 +2022,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2002,6 +2047,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2040,6 +2086,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2064,6 +2111,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2102,6 +2150,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2126,6 +2175,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2164,6 +2214,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2188,6 +2239,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2226,6 +2278,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2250,6 +2303,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2288,6 +2342,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2312,6 +2367,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2350,6 +2406,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2374,6 +2431,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 2,
           "seed": 400692809,
@@ -2412,6 +2470,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2436,6 +2495,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 60,
           "roughness": 2,
           "seed": 400692809,
@@ -2474,6 +2534,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 60,
           "roughness": 2,
           "seed": 1278240551,
@@ -2498,6 +2559,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 60,
           "roughness": 2,
           "seed": 400692809,
@@ -2587,6 +2649,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -2612,6 +2675,7 @@ Object {
   "height": 20,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -2639,6 +2703,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -2693,6 +2758,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2731,6 +2797,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2755,6 +2822,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2793,6 +2861,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -2817,6 +2886,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -2906,6 +2976,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -2931,6 +3002,7 @@ Object {
   "height": 20,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -2958,6 +3030,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -3012,6 +3085,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3050,6 +3124,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3074,6 +3149,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -3112,6 +3188,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -3136,6 +3213,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3229,6 +3307,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -3254,6 +3333,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 449462985,
@@ -3281,6 +3361,7 @@ Object {
   "height": 20,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 401146281,
@@ -3335,6 +3416,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -3373,6 +3455,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -3397,6 +3480,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -3441,6 +3525,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -3467,6 +3552,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -3507,6 +3593,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -3531,6 +3618,7 @@ Object {
           "height": 20,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -3626,6 +3714,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -3651,6 +3740,7 @@ Object {
   "height": 10,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -3678,6 +3768,7 @@ Object {
   "height": 10,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 453191,
@@ -3732,6 +3823,7 @@ Object {
           "height": 10,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3770,6 +3862,7 @@ Object {
           "height": 10,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,
@@ -3794,6 +3887,7 @@ Object {
           "height": 10,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 453191,
@@ -3891,6 +3985,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -3918,6 +4013,7 @@ Object {
   "height": 10,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 449462985,
@@ -3947,6 +4043,7 @@ Object {
   "height": 10,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 401146281,
@@ -4001,6 +4098,7 @@ Object {
           "height": 10,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -4039,6 +4137,7 @@ Object {
           "height": 10,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -4063,6 +4162,7 @@ Object {
           "height": 10,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -4108,6 +4208,7 @@ Object {
           "height": 10,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 449462985,
@@ -4134,6 +4235,7 @@ Object {
           "height": 10,
           "id": "id1",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 401146281,
@@ -4221,6 +4323,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -4324,6 +4427,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -4403,6 +4507,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],
@@ -4428,6 +4533,7 @@ Object {
   "height": 20,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -4455,6 +4561,7 @@ Object {
   "height": 200,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -4482,6 +4589,7 @@ Object {
   "height": 200,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 1278240551,
@@ -4536,6 +4644,7 @@ Object {
           "height": 20,
           "id": "id0",
           "isDeleted": false,
+          "link": null,
           "opacity": 100,
           "roughness": 1,
           "seed": 1278240551,

+ 5 - 0
src/tests/__snapshots__/dragCreate.test.tsx.snap

@@ -15,6 +15,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [
@@ -56,6 +57,7 @@ Object {
   "height": 50,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -85,6 +87,7 @@ Object {
   "height": 50,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -115,6 +118,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [
@@ -156,6 +160,7 @@ Object {
   "height": 50,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,

+ 6 - 0
src/tests/__snapshots__/move.test.tsx.snap

@@ -10,6 +10,7 @@ Object {
   "height": 50,
   "id": "id0_copy",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 401146281,
@@ -37,6 +38,7 @@ Object {
   "height": 50,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -64,6 +66,7 @@ Object {
   "height": 50,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -96,6 +99,7 @@ Object {
   "height": 100,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -128,6 +132,7 @@ Object {
   "height": 300,
   "id": "id1",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 449462985,
@@ -162,6 +167,7 @@ Object {
   "id": "id2",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [

+ 2 - 0
src/tests/__snapshots__/multiPointCreate.test.tsx.snap

@@ -16,6 +16,7 @@ Object {
     70,
     110,
   ],
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [
@@ -65,6 +66,7 @@ Object {
     70,
     110,
   ],
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 126 - 0
src/tests/__snapshots__/regressionTests.test.tsx.snap


+ 5 - 0
src/tests/__snapshots__/selection.test.tsx.snap

@@ -13,6 +13,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [
@@ -55,6 +56,7 @@ Object {
   "id": "id0",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [
@@ -94,6 +96,7 @@ Object {
   "height": 50,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -121,6 +124,7 @@ Object {
   "height": 50,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,
@@ -148,6 +152,7 @@ Object {
   "height": 50,
   "id": "id0",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "roughness": 1,
   "seed": 337897,

+ 1 - 0
src/tests/contextmenu.test.tsx

@@ -136,6 +136,7 @@ describe("contextMenu element", () => {
       "sendToBack",
       "bringToFront",
       "duplicateSelection",
+      "link",
     ];
 
     expect(contextMenu).not.toBeNull();

+ 9 - 0
src/tests/data/__snapshots__/restore.test.ts.snap

@@ -13,6 +13,7 @@ Object {
   "id": "id-arrow01",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [
@@ -56,6 +57,7 @@ Object {
   "height": 200,
   "id": "1",
   "isDeleted": false,
+  "link": null,
   "opacity": 10,
   "roughness": 2,
   "seed": Any<Number>,
@@ -87,6 +89,7 @@ Object {
   "height": 200,
   "id": "2",
   "isDeleted": false,
+  "link": null,
   "opacity": 10,
   "roughness": 2,
   "seed": Any<Number>,
@@ -118,6 +121,7 @@ Object {
   "height": 200,
   "id": "3",
   "isDeleted": false,
+  "link": null,
   "opacity": 10,
   "roughness": 2,
   "seed": Any<Number>,
@@ -146,6 +150,7 @@ Object {
   "id": "id-freedraw01",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [],
   "pressures": Array [],
@@ -179,6 +184,7 @@ Object {
   "id": "id-line01",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [
@@ -221,6 +227,7 @@ Object {
   "id": "id-draw01",
   "isDeleted": false,
   "lastCommittedPoint": null,
+  "link": null,
   "opacity": 100,
   "points": Array [
     Array [
@@ -264,6 +271,7 @@ Object {
   "height": 100,
   "id": "id-text01",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "originalText": "text",
   "roughness": 1,
@@ -299,6 +307,7 @@ Object {
   "height": 100,
   "id": "id-text01",
   "isDeleted": false,
+  "link": null,
   "opacity": 100,
   "originalText": "test",
   "roughness": 1,

+ 1 - 0
src/tests/fixtures/elementFixture.ts

@@ -22,6 +22,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
   isDeleted: false,
   boundElements: null,
   updated: 1,
+  link: null,
 };
 
 export const rectangleFixture: ExcalidrawElement = {

+ 1 - 0
src/tests/packages/__snapshots__/utils.test.ts.snap

@@ -60,6 +60,7 @@ Object {
   "selectionElement": null,
   "shouldCacheIgnoreZoom": false,
   "showHelpDialog": false,
+  "showHyperlinkPopup": false,
   "showStats": false,
   "startBoundElement": null,
   "suggestedBindings": Array [],

+ 1 - 1
src/tests/scene/__snapshots__/export.test.ts.snap

@@ -74,7 +74,7 @@ exports[`exportToSvg with default arguments 1`] = `
 exports[`exportToSvg with exportEmbedScene 1`] = `
 "
   <!-- svg-source:excalidraw -->
-  <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1STU9cdTAwMDMhXHUwMDEwvfdXbPDapLtrv+ytWmNMjFx1MDAxZXpoovFAl9mFlFx1MDAwMlx1MDAwNbZcdTAwMWZp+t9cdTAwMDXaLrrx4l1cdTAwMGUk83hvZph5x06SIHtQgCZcdIJ9gTkjXHUwMDFh71DX41vQhknhnvJcdTAwMTBcdTAwMWJZ61wiMKm1atLrcelcdTAwMDRUXHUwMDFhe+ZcdTAwMDOHNVxia1x1MDAxY+PDxUlyXGa3e2HEq7ZcdTAwMGK9eZuWKyZIvinWo5fZ9Ok9SFx1MDAwM2nvOP2s38RcdTAwMDdf+HbUxDtGLHVYlqZcckaBVdS2QCwq7tuMiLFaruBBcql9IzdpOLH0XHUwMDEyXHUwMDE3q0rLWpDIyVx1MDAwNlx1MDAxOC/LyClcdTAwMTnnc3vg51x1MDAwMeCC1lx1MDAxYVCrwuLaYlx1MDAwYm90RrpcdTAwMDFHlStZUVx1MDAwMcb80EiFXHUwMDBiZlx1MDAwZq1f+f7UM1x00/1s56dYq0tcdTAwMWVkfPCtM1x1MDAwMFx1MDAxMlL1s+FgdJeOm5e43yxP2+irXHUwMDE0YddZNlx1MDAxZadpP1x1MDAxZlxyXHUwMDFiXHUwMDA2MzO3alx1MDAxYtKWmFx1MDAxYohz9CN8jDZcdTAwMTA1581jrVxiPoviV6/WI1xmr6UgKOCn7r97/t3zXHUwMDA391x1MDAwMOdMXHUwMDE5uLjH3eGHXGIrNbdO5ChnL6Etg939L9sqw/H64D2/LfBcdTAwMWRcdTAwMWNPndNcdTAwMTfZZ1DTIn0=<!-- payload-end -->
+  <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1STU9cdTAwMDMhXHUwMDEwvfdXbPDapLtrv+ytWmNMjFx1MDAxZXpoovFAl9mFlFx1MDAwMlx1MDAwNbZcdTAwMWZp+t9cdTAwMDXaLrrx5lVcdTAwMGUk83hvZph5x06SIHtQgCZcdIJ9gTkjXHUwMDFh71DX41vQhknhnvJcdTAwMTBcdTAwMWJZ61wiMKm1atLrcelcdTAwMDRUXHUwMDFhe+ZcdTAwMDOHNVxia1x1MDAxY+PDxUlyXGa3e2HEq7ZcdTAwMGK9eZuWKyZIvinWo5fZ9Ok9SFx1MDAwM2nvOP2s38RcdTAwMDdf+HbUxDtGLHVYlqZcckaBVdS2QCwq7tuMiLFaruBBcql9IzdpOLH0XHUwMDEyXHUwMDE3q0rLWpDIyVx1MDAwNlx1MDAxOC/LyClcdTAwMTnnc3vg51x1MDAwMeCC1lx1MDAxYVCrwuLaYlx1MDAwYm90RrpcdTAwMDFHlStZUVx1MDAwMcb80EiFXHUwMDBiZlx1MDAwZq1f+f7UM1x00/1s56dYq0tcdTAwMWVkfPCtM1x1MDAwMFx1MDAxMlL1s+FgdJeOm5e43yxP2+irXHUwMDE0YddZNlx1MDAxZadpP1x1MDAxZlxyXHUwMDFiXHUwMDA2MzO3alx1MDAxYtKWmFx1MDAxYohz9CN8jDZcdTAwMTA1581jrVxiPoviVzlcdTAwMTOrNu9qR8LwWlxuglx1MDAwMn7q/jvq31F/dFx1MDAxNHDOlIGLo9xcdTAwMWR+jbBSc+tcdTAwMTI5ytlfaMtgd//LXHUwMDA2y3C8PvjRb1x1MDAxMHxXx1Pn9Fx1MDAwNbeWWs0ifQ==<!-- payload-end -->
   <defs>
     <style>
       @font-face {

+ 1 - 0
src/types.ts

@@ -151,6 +151,7 @@ export type AppState = {
       };
   /** imageElement waiting to be placed on canvas */
   pendingImageElement: NonDeleted<ExcalidrawImageElement> | null;
+  showHyperlinkPopup: false | "info" | "editor";
 };
 
 export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels