aboutsummaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/components/hyperlink
diff options
context:
space:
mode:
authorkj_sh6042026-03-15 16:19:35 -0400
committerkj_sh6042026-03-15 16:19:35 -0400
commit6ec259a0e71174651bae95d4628138bf6fd68742 (patch)
tree5e33c6a5ec091ecabfcb257fdc7b6a88ed8754ac /packages/excalidraw/components/hyperlink
parent16c8578b15c727f22921f8a80a56ee4d4e7f2272 (diff)
refactor: packages/
Diffstat (limited to 'packages/excalidraw/components/hyperlink')
-rw-r--r--packages/excalidraw/components/hyperlink/Hyperlink.scss70
-rw-r--r--packages/excalidraw/components/hyperlink/Hyperlink.tsx480
-rw-r--r--packages/excalidraw/components/hyperlink/helpers.ts99
3 files changed, 649 insertions, 0 deletions
diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.scss b/packages/excalidraw/components/hyperlink/Hyperlink.scss
new file mode 100644
index 0000000..6a5db32
--- /dev/null
+++ b/packages/excalidraw/components/hyperlink/Hyperlink.scss
@@ -0,0 +1,70 @@
+@import "../../css/variables.module.scss";
+
+.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: var(--zIndex-hyperlinkContainer);
+ 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;
+ }
+ }
+
+ &--remove .ToolIcon__icon svg {
+ color: $oc-red-6;
+ }
+
+ .ToolIcon__icon {
+ width: 2rem;
+ height: 2rem;
+ }
+
+ &__buttons {
+ flex: 0 0 auto;
+ }
+}
diff --git a/packages/excalidraw/components/hyperlink/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx
new file mode 100644
index 0000000..4d3dce6
--- /dev/null
+++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx
@@ -0,0 +1,480 @@
+import type { AppState, ExcalidrawProps, UIAppState } from "../../types";
+import {
+ sceneCoordsToViewportCoords,
+ viewportCoordsToSceneCoords,
+ wrapEvent,
+} from "../../utils";
+import { getEmbedLink, embeddableURLValidator } from "../../element/embeddable";
+import { mutateElement } from "../../element/mutateElement";
+import type {
+ ElementsMap,
+ ExcalidrawEmbeddableElement,
+ NonDeletedExcalidrawElement,
+} from "../../element/types";
+
+import { ToolButton } from "../ToolButton";
+import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons";
+import { t } from "../../i18n";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from "react";
+import clsx from "clsx";
+import { KEYS } from "../../keys";
+import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
+import { getElementAbsoluteCoords } from "../../element/bounds";
+import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
+import { getSelectedElements } from "../../scene";
+import { hitElementBoundingBox } from "../../element/collision";
+import { isLocalLink, normalizeLink } from "../../data/url";
+import { trackEvent } from "../../analytics";
+import { useAppProps, useDevice, useExcalidrawAppState } from "../App";
+import { isEmbeddableElement } from "../../element/typeChecks";
+import { getLinkHandleFromCoords } from "./helpers";
+import { pointFrom, type GlobalPoint } from "@excalidraw/math";
+import { isElementLink } from "../../element/elementLink";
+
+import "./Hyperlink.scss";
+
+const POPUP_WIDTH = 380;
+const POPUP_HEIGHT = 42;
+const POPUP_PADDING = 5;
+const SPACE_BOTTOM = 85;
+const AUTO_HIDE_TIMEOUT = 500;
+
+let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
+
+const embeddableLinkCache = new Map<
+ ExcalidrawEmbeddableElement["id"],
+ string
+>();
+
+export const Hyperlink = ({
+ element,
+ elementsMap,
+ setAppState,
+ onLinkOpen,
+ setToast,
+ updateEmbedValidationStatus,
+}: {
+ element: NonDeletedExcalidrawElement;
+ elementsMap: ElementsMap;
+ setAppState: React.Component<any, AppState>["setState"];
+ onLinkOpen: ExcalidrawProps["onLinkOpen"];
+ setToast: (
+ toast: { message: string; closable?: boolean; duration?: number } | null,
+ ) => void;
+ updateEmbedValidationStatus: (
+ element: ExcalidrawEmbeddableElement,
+ status: boolean,
+ ) => void;
+}) => {
+ const appState = useExcalidrawAppState();
+ const appProps = useAppProps();
+ const device = useDevice();
+
+ const linkVal = element.link || "";
+
+ const [inputVal, setInputVal] = useState(linkVal);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const isEditing = appState.showHyperlinkPopup === "editor";
+
+ const handleSubmit = useCallback(() => {
+ if (!inputRef.current) {
+ return;
+ }
+
+ const link = normalizeLink(inputRef.current.value) || null;
+
+ if (!element.link && link) {
+ trackEvent("hyperlink", "create");
+ }
+
+ if (isEmbeddableElement(element)) {
+ if (appState.activeEmbeddable?.element === element) {
+ setAppState({ activeEmbeddable: null });
+ }
+ if (!link) {
+ mutateElement(element, {
+ link: null,
+ });
+ updateEmbedValidationStatus(element, false);
+ return;
+ }
+
+ if (!embeddableURLValidator(link, appProps.validateEmbeddable)) {
+ if (link) {
+ setToast({ message: t("toast.unableToEmbed"), closable: true });
+ }
+ element.link && embeddableLinkCache.set(element.id, element.link);
+ mutateElement(element, {
+ link,
+ });
+ updateEmbedValidationStatus(element, false);
+ } else {
+ const { width, height } = element;
+ const embedLink = getEmbedLink(link);
+ if (embedLink?.error instanceof URIError) {
+ setToast({
+ message: t("toast.unrecognizedLinkFormat"),
+ closable: true,
+ });
+ }
+ const ar = embedLink
+ ? embedLink.intrinsicSize.w / embedLink.intrinsicSize.h
+ : 1;
+ const hasLinkChanged =
+ embeddableLinkCache.get(element.id) !== element.link;
+ mutateElement(element, {
+ ...(hasLinkChanged
+ ? {
+ width:
+ embedLink?.type === "video"
+ ? width > height
+ ? width
+ : height * ar
+ : width,
+ height:
+ embedLink?.type === "video"
+ ? width > height
+ ? width / ar
+ : height
+ : height,
+ }
+ : {}),
+ link,
+ });
+ updateEmbedValidationStatus(element, true);
+ if (embeddableLinkCache.has(element.id)) {
+ embeddableLinkCache.delete(element.id);
+ }
+ }
+ } else {
+ mutateElement(element, { link });
+ }
+ }, [
+ element,
+ setToast,
+ appProps.validateEmbeddable,
+ appState.activeEmbeddable,
+ setAppState,
+ updateEmbedValidationStatus,
+ ]);
+
+ useLayoutEffect(() => {
+ return () => {
+ handleSubmit();
+ };
+ }, [handleSubmit]);
+
+ useEffect(() => {
+ if (
+ isEditing &&
+ inputRef?.current &&
+ !(device.viewport.isMobile || device.isTouchScreen)
+ ) {
+ inputRef.current.select();
+ }
+ }, [isEditing, device.viewport.isMobile, device.isTouchScreen]);
+
+ useEffect(() => {
+ let timeoutId: number | null = null;
+
+ const handlePointerMove = (event: PointerEvent) => {
+ if (isEditing) {
+ return;
+ }
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ const shouldHide = shouldHideLinkPopup(
+ element,
+ elementsMap,
+ appState,
+ pointFrom(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, elementsMap]);
+
+ const handleRemove = useCallback(() => {
+ trackEvent("hyperlink", "delete");
+ mutateElement(element, { link: null });
+ setAppState({ showHyperlinkPopup: false });
+ }, [setAppState, element]);
+
+ const onEdit = () => {
+ trackEvent("hyperlink", "edit", "popup-ui");
+ setAppState({ showHyperlinkPopup: "editor" });
+ };
+ const { x, y } = getCoordsForPopover(element, appState, elementsMap);
+ if (
+ appState.contextMenu ||
+ appState.selectedElementsAreBeingDragged ||
+ appState.resizingElement ||
+ appState.isRotating ||
+ appState.openMenu ||
+ appState.viewModeEnabled
+ ) {
+ return null;
+ }
+
+ return (
+ <div
+ className="excalidraw-hyperlinkContainer"
+ style={{
+ top: `${y}px`,
+ left: `${x}px`,
+ width: POPUP_WIDTH,
+ padding: POPUP_PADDING,
+ }}
+ >
+ {isEditing ? (
+ <input
+ className={clsx("excalidraw-hyperlinkContainer-input")}
+ placeholder={t("labels.link.hint")}
+ 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();
+ setAppState({ showHyperlinkPopup: "info" });
+ }
+ }}
+ />
+ ) : element.link ? (
+ <a
+ href={normalizeLink(element.link || "")}
+ className="excalidraw-hyperlinkContainer-link"
+ target={isLocalLink(element.link) ? "_self" : "_blank"}
+ onClick={(event) => {
+ if (element.link && onLinkOpen) {
+ const customEvent = wrapEvent(
+ EVENT.EXCALIDRAW_LINK,
+ event.nativeEvent,
+ );
+ onLinkOpen(
+ {
+ ...element,
+ link: normalizeLink(element.link),
+ },
+ customEvent,
+ );
+ if (customEvent.defaultPrevented) {
+ event.preventDefault();
+ }
+ }
+ }}
+ rel="noopener noreferrer"
+ >
+ {element.link}
+ </a>
+ ) : (
+ <div className="excalidraw-hyperlinkContainer-link">
+ {t("labels.link.empty")}
+ </div>
+ )}
+ <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={FreedrawIcon}
+ />
+ )}
+ <ToolButton
+ type="button"
+ title={t("labels.linkToElement")}
+ aria-label={t("labels.linkToElement")}
+ label={t("labels.linkToElement")}
+ onClick={() => {
+ setAppState({
+ openDialog: {
+ name: "elementLinkSelector",
+ sourceElementId: element.id,
+ },
+ });
+ }}
+ icon={elementLinkIcon}
+ />
+ {linkVal && !isEmbeddableElement(element) && (
+ <ToolButton
+ type="button"
+ title={t("buttons.remove")}
+ aria-label={t("buttons.remove")}
+ label={t("buttons.remove")}
+ onClick={handleRemove}
+ className="excalidraw-hyperlinkContainer--remove"
+ icon={TrashIcon}
+ />
+ )}
+ </div>
+ </div>
+ );
+};
+
+const getCoordsForPopover = (
+ element: NonDeletedExcalidrawElement,
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ const [x1, y1] = getElementAbsoluteCoords(element, elementsMap);
+ const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords(
+ { sceneX: x1 + element.width / 2, sceneY: y1 },
+ appState,
+ );
+ const x = viewportX - appState.offsetLeft - POPUP_WIDTH / 2;
+ const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
+ return { x, y };
+};
+
+export const getContextMenuLabel = (
+ elements: readonly NonDeletedExcalidrawElement[],
+ appState: UIAppState,
+) => {
+ const selectedElements = getSelectedElements(elements, appState);
+ const label = isEmbeddableElement(selectedElements[0])
+ ? "labels.link.editEmbed"
+ : selectedElements[0]?.link
+ ? "labels.link.edit"
+ : "labels.link.create";
+ return label;
+};
+
+let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
+export const showHyperlinkTooltip = (
+ element: NonDeletedExcalidrawElement,
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
+ clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
+ }
+ HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout(
+ () => renderTooltip(element, appState, elementsMap),
+ HYPERLINK_TOOLTIP_DELAY,
+ );
+};
+
+const renderTooltip = (
+ element: NonDeletedExcalidrawElement,
+ appState: AppState,
+ elementsMap: ElementsMap,
+) => {
+ if (!element.link) {
+ return;
+ }
+
+ const tooltipDiv = getTooltipDiv();
+
+ tooltipDiv.classList.add("excalidraw-tooltip--visible");
+ tooltipDiv.style.maxWidth = "20rem";
+ tooltipDiv.textContent = isElementLink(element.link)
+ ? t("labels.link.goToElement")
+ : element.link;
+
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+
+ 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",
+ );
+ trackEvent("hyperlink", "tooltip", "link-icon");
+
+ 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");
+ }
+};
+
+const shouldHideLinkPopup = (
+ element: NonDeletedExcalidrawElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ [clientX, clientY]: GlobalPoint,
+): 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 (hitElementBoundingBox(sceneX, sceneY, element, elementsMap)) {
+ return false;
+ }
+ const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap);
+ // hit box to prevent hiding when hovered in the vertical area between element and popover
+ if (
+ sceneX >= x1 &&
+ sceneX <= x2 &&
+ sceneY >= y1 - SPACE_BOTTOM &&
+ sceneY <= y1
+ ) {
+ return false;
+ }
+ // hit box to prevent hiding when hovered around popover within threshold
+ const { x: popoverX, y: popoverY } = getCoordsForPopover(
+ element,
+ appState,
+ elementsMap,
+ );
+
+ if (
+ clientX >= popoverX - threshold &&
+ clientX <= popoverX + POPUP_WIDTH + POPUP_PADDING * 2 + threshold &&
+ clientY >= popoverY - threshold &&
+ clientY <= popoverY + threshold + POPUP_PADDING * 2 + POPUP_HEIGHT
+ ) {
+ return false;
+ }
+ return true;
+};
diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts
new file mode 100644
index 0000000..75c5dad
--- /dev/null
+++ b/packages/excalidraw/components/hyperlink/helpers.ts
@@ -0,0 +1,99 @@
+import type { GlobalPoint, Radians } from "@excalidraw/math";
+import { pointFrom, pointRotateRads } from "@excalidraw/math";
+import { MIME_TYPES } from "../../constants";
+import type { Bounds } from "../../element/bounds";
+import { getElementAbsoluteCoords } from "../../element/bounds";
+import { hitElementBoundingBox } from "../../element/collision";
+import type {
+ ElementsMap,
+ NonDeletedExcalidrawElement,
+} from "../../element/types";
+import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
+import type { AppState, UIAppState } from "../../types";
+
+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>`,
+)}`;
+
+export const ELEMENT_LINK_IMG = document.createElement("img");
+ELEMENT_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="icon icon-tabler icons-tabler-outline icon-tabler-arrow-big-right-line"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 9v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-6v-6h6z" /><path d="M3 9v6" /></svg>`,
+)}`;
+
+export const getLinkHandleFromCoords = (
+ [x1, y1, x2, y2]: Bounds,
+ angle: Radians,
+ appState: Pick<UIAppState, "zoom">,
+): Bounds => {
+ 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] = pointRotateRads(
+ pointFrom(x + linkWidth / 2, y + linkHeight / 2),
+ pointFrom(centerX, centerY),
+ angle,
+ );
+ return [
+ rotatedX - linkWidth / 2,
+ rotatedY - linkHeight / 2,
+ linkWidth,
+ linkHeight,
+ ];
+};
+
+export const isPointHittingLinkIcon = (
+ element: NonDeletedExcalidrawElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ [x, y]: GlobalPoint,
+) => {
+ const threshold = 4 / appState.zoom.value;
+ const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
+ 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;
+};
+
+export const isPointHittingLink = (
+ element: NonDeletedExcalidrawElement,
+ elementsMap: ElementsMap,
+ appState: AppState,
+ [x, y]: GlobalPoint,
+ isMobile: boolean,
+) => {
+ if (!element.link || appState.selectedElementIds[element.id]) {
+ return false;
+ }
+ if (
+ !isMobile &&
+ appState.viewModeEnabled &&
+ hitElementBoundingBox(x, y, element, elementsMap)
+ ) {
+ return true;
+ }
+ return isPointHittingLinkIcon(
+ element,
+ elementsMap,
+ appState,
+ pointFrom(x, y),
+ );
+};