fix: decouple pure functions from hyperlink to prevent mermaid bundling (#7710)

* move hyperlink code into its folder

* move pure js functions to hyperlink/helpers and move actionLink to actions

* fix tests

* fix
aakansha/element
Aakansha Doshi 12 months ago committed by GitHub
parent 79d9dc2f8f
commit 2e719ff671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,54 @@
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
import { LinkIcon } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { isEmbeddableElement } from "../element/typeChecks";
import { t } from "../i18n";
import { KEYS } from "../keys";
import { getSelectedElements } from "../scene";
import { getShortcutKey } from "../utils";
import { register } from "./register";
export const actionLink = register({
name: "hyperlink",
perform: (elements, appState) => {
if (appState.showHyperlinkPopup === "editor") {
return false;
}
return {
elements,
appState: {
...appState,
showHyperlinkPopup: "editor",
openMenu: null,
},
commitToHistory: true,
};
},
trackEvent: { category: "hyperlink", action: "click" },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState),
predicate: (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={LinkIcon}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${
isEmbeddableElement(elements[0])
? t("labels.link.labelEmbed")
: t("labels.link.label")
} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}
selected={selectedElements.length === 1 && !!selectedElements[0].link}
/>
);
},
});

@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText, actionBindText } from "./actionBoundText"; export { actionUnbindText, actionBindText } from "./actionBoundText";
export { actionLink } from "../element/Hyperlink"; export { actionLink } from "./actionLink";
export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleElementLock } from "./actionElementLock";
export { actionToggleLinearEditor } from "./actionLinearEditor"; export { actionToggleLinearEditor } from "./actionLinearEditor";

@ -326,9 +326,7 @@ import {
showHyperlinkTooltip, showHyperlinkTooltip,
hideHyperlinkToolip, hideHyperlinkToolip,
Hyperlink, Hyperlink,
isPointHittingLink, } from "../components/hyperlink/Hyperlink";
isPointHittingLinkIcon,
} from "../element/Hyperlink";
import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { isLocalLink, normalizeLink, toValidURL } from "../data/url";
import { shouldShowBoundingBox } from "../element/transformHandles"; import { shouldShowBoundingBox } from "../element/transformHandles";
import { actionUnlockAllElements } from "../actions/actionElementLock"; import { actionUnlockAllElements } from "../actions/actionElementLock";
@ -410,6 +408,10 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { getRenderOpacity } from "../renderer/renderElement"; import { getRenderOpacity } from "../renderer/renderElement";
import { textWysiwyg } from "../element/textWysiwyg"; import { textWysiwyg } from "../element/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars"; import { isOverScrollBars } from "../scene/scrollbars";
import {
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
const AppContext = React.createContext<AppClassProperties>(null!); const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!); const AppPropsContext = React.createContext<AppProps>(null!);
@ -9571,7 +9573,6 @@ class App extends React.Component<AppProps, AppState> {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TEST HOOKS // TEST HOOKS
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
declare global { declare global {
interface Window { interface Window {
h: { h: {
@ -9584,6 +9585,7 @@ declare global {
} }
} }
export const createTestHook = () => {
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
window.h = window.h || ({} as Window["h"]); window.h = window.h || ({} as Window["h"]);
@ -9599,5 +9601,7 @@ if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
}, },
}); });
} }
};
createTestHook();
export default App; export default App;

@ -1,4 +1,4 @@
@import "../css/variables.module.scss"; @import "../../css/variables.module.scss";
.excalidraw-hyperlinkContainer { .excalidraw-hyperlinkContainer {
display: flex; display: flex;

@ -1,22 +1,20 @@
import { AppState, ExcalidrawProps, Point, UIAppState } from "../types"; import { AppState, ExcalidrawProps, Point } from "../../types";
import { import {
getShortcutKey,
sceneCoordsToViewportCoords, sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords, viewportCoordsToSceneCoords,
wrapEvent, wrapEvent,
} from "../utils"; } from "../../utils";
import { getEmbedLink, embeddableURLValidator } from "./embeddable"; import { getEmbedLink, embeddableURLValidator } from "../../element/embeddable";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "../../element/mutateElement";
import { import {
ElementsMap, ElementsMap,
ExcalidrawEmbeddableElement, ExcalidrawEmbeddableElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "../../element/types";
import { register } from "../actions/register"; import { ToolButton } from "../ToolButton";
import { ToolButton } from "../components/ToolButton"; import { FreedrawIcon, TrashIcon } from "../icons";
import { FreedrawIcon, LinkIcon, TrashIcon } from "../components/icons"; import { t } from "../../i18n";
import { t } from "../i18n";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@ -25,21 +23,19 @@ import {
useState, useState,
} from "react"; } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { KEYS } from "../keys"; import { KEYS } from "../../keys";
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement"; import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants";
import { rotate } from "../math"; import { getElementAbsoluteCoords } from "../../element/bounds";
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants"; import { getTooltipDiv, updateTooltipPosition } from "../Tooltip";
import { Bounds } from "./bounds"; import { getSelectedElements } from "../../scene";
import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip"; import { isPointHittingElementBoundingBox } from "../../element/collision";
import { getSelectedElements } from "../scene"; import { isLocalLink, normalizeLink } from "../../data/url";
import { isPointHittingElementBoundingBox } from "./collision";
import { getElementAbsoluteCoords } from ".";
import { isLocalLink, normalizeLink } from "../data/url";
import "./Hyperlink.scss"; import "./Hyperlink.scss";
import { trackEvent } from "../analytics"; import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../components/App"; import { useAppProps, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "./typeChecks"; import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers";
const CONTAINER_WIDTH = 320; const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85; const SPACE_BOTTOM = 85;
@ -47,11 +43,6 @@ const CONTAINER_PADDING = 5;
const CONTAINER_HEIGHT = 42; const CONTAINER_HEIGHT = 42;
const AUTO_HIDE_TIMEOUT = 500; 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; let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
const embeddableLinkCache = new Map< const embeddableLinkCache = new Map<
@ -339,51 +330,6 @@ const getCoordsForPopover = (
return { x, y }; return { x, y };
}; };
export const actionLink = register({
name: "hyperlink",
perform: (elements, appState) => {
if (appState.showHyperlinkPopup === "editor") {
return false;
}
return {
elements,
appState: {
...appState,
showHyperlinkPopup: "editor",
openMenu: null,
},
commitToHistory: true,
};
},
trackEvent: { category: "hyperlink", action: "click" },
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState),
predicate: (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={LinkIcon}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${
isEmbeddableElement(elements[0])
? t("labels.link.labelEmbed")
: t("labels.link.label")
} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}
selected={selectedElements.length === 1 && !!selectedElements[0].link}
/>
);
},
});
export const getContextMenuLabel = ( export const getContextMenuLabel = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: AppState, appState: AppState,
@ -399,87 +345,6 @@ export const getContextMenuLabel = (
return label; return label;
}; };
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
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] = rotate(
x + linkWidth / 2,
y + linkHeight / 2,
centerX,
centerY,
angle,
);
return [
rotatedX - linkWidth / 2,
rotatedY - linkHeight / 2,
linkWidth,
linkHeight,
];
};
export const isPointHittingLinkIcon = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
[x, y]: Point,
) => {
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]: Point,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&
appState.viewModeEnabled &&
isPointHittingElementBoundingBox(
element,
elementsMap,
[x, y],
threshold,
null,
)
) {
return true;
}
return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
};
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null; let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
export const showHyperlinkTooltip = ( export const showHyperlinkTooltip = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
@ -547,7 +412,7 @@ export const hideHyperlinkToolip = () => {
} }
}; };
export const shouldHideLinkPopup = ( const shouldHideLinkPopup = (
element: NonDeletedExcalidrawElement, element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
appState: AppState, appState: AppState,

@ -0,0 +1,93 @@
import { MIME_TYPES } from "../../constants";
import { Bounds, getElementAbsoluteCoords } from "../../element/bounds";
import { isPointHittingElementBoundingBox } from "../../element/collision";
import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types";
import { rotate } from "../../math";
import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement";
import { AppState, Point, 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 getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
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] = rotate(
x + linkWidth / 2,
y + linkHeight / 2,
centerX,
centerY,
angle,
);
return [
rotatedX - linkWidth / 2,
rotatedY - linkHeight / 2,
linkWidth,
linkHeight,
];
};
export const isPointHittingLinkIcon = (
element: NonDeletedExcalidrawElement,
elementsMap: ElementsMap,
appState: AppState,
[x, y]: Point,
) => {
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]: Point,
isMobile: boolean,
) => {
if (!element.link || appState.selectedElementIds[element.id]) {
return false;
}
const threshold = 4 / appState.zoom.value;
if (
!isMobile &&
appState.viewModeEnabled &&
isPointHittingElementBoundingBox(
element,
elementsMap,
[x, y],
threshold,
null,
)
) {
return true;
}
return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]);
};

@ -73,7 +73,7 @@ import {
import { import {
EXTERNAL_LINK_IMG, EXTERNAL_LINK_IMG,
getLinkHandleFromCoords, getLinkHandleFromCoords,
} from "../element/Hyperlink"; } from "../components/hyperlink/helpers";
import { renderSnaps } from "./renderSnaps"; import { renderSnaps } from "./renderSnaps";
import { import {
isEmbeddableElement, isEmbeddableElement,

@ -31,8 +31,11 @@ import { getSelectedElements } from "../../scene/selection";
import { isLinearElementType } from "../../element/typeChecks"; import { isLinearElementType } from "../../element/typeChecks";
import { Mutable } from "../../utility-types"; import { Mutable } from "../../utility-types";
import { assertNever } from "../../utils"; import { assertNever } from "../../utils";
import { createTestHook } from "../../components/App";
const readFile = util.promisify(fs.readFile); const readFile = util.promisify(fs.readFile);
// so that window.h is available when App.tsx is not imported as well.
createTestHook();
const { h } = window; const { h } = window;

@ -33,6 +33,10 @@ import { getCommonBounds, getElementPointsCoords } from "../../element/bounds";
import { rotatePoint } from "../../math"; import { rotatePoint } from "../../math";
import { getTextEditor } from "../queries/dom"; import { getTextEditor } from "../queries/dom";
import { arrayToMap } from "../../utils"; import { arrayToMap } from "../../utils";
import { createTestHook } from "../../components/App";
// so that window.h is available when App.tsx is not imported as well.
createTestHook();
const { h } = window; const { h } = window;
@ -460,7 +464,6 @@ export class UI {
mouse.reset(); mouse.reset();
mouse.up(x + width, y + height); mouse.up(x + width, y + height);
} }
const origElement = h.elements[h.elements.length - 1] as any; const origElement = h.elements[h.elements.length - 1] as any;
if (angle !== 0) { if (angle !== 0) {

Loading…
Cancel
Save