diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx new file mode 100644 index 000000000..f7710874e --- /dev/null +++ b/packages/excalidraw/actions/actionLink.tsx @@ -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 ( + updateData(null)} + selected={selectedElements.length === 1 && !!selectedElements[0].link} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index b4551acf5..092060425 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; -export { actionLink } from "../element/Hyperlink"; +export { actionLink } from "./actionLink"; export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9f585d2e1..7b310ca38 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -326,9 +326,7 @@ import { showHyperlinkTooltip, hideHyperlinkToolip, Hyperlink, - isPointHittingLink, - isPointHittingLinkIcon, -} from "../element/Hyperlink"; +} from "../components/hyperlink/Hyperlink"; import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; @@ -410,6 +408,10 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; import { textWysiwyg } from "../element/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; +import { + isPointHittingLink, + isPointHittingLinkIcon, +} from "./hyperlink/helpers"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -9571,7 +9573,6 @@ class App extends React.Component { // ----------------------------------------------------------------------------- // TEST HOOKS // ----------------------------------------------------------------------------- - declare global { interface Window { h: { @@ -9584,20 +9585,23 @@ declare global { } } -if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { - window.h = window.h || ({} as Window["h"]); +export const createTestHook = () => { + if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { + window.h = window.h || ({} as Window["h"]); - Object.defineProperties(window.h, { - elements: { - configurable: true, - get() { - return this.app?.scene.getElementsIncludingDeleted(); + Object.defineProperties(window.h, { + elements: { + configurable: true, + get() { + return this.app?.scene.getElementsIncludingDeleted(); + }, + set(elements: ExcalidrawElement[]) { + return this.app?.scene.replaceAllElements(elements); + }, }, - set(elements: ExcalidrawElement[]) { - return this.app?.scene.replaceAllElements(elements); - }, - }, - }); -} + }); + } +}; +createTestHook(); export default App; diff --git a/packages/excalidraw/element/Hyperlink.scss b/packages/excalidraw/components/hyperlink/Hyperlink.scss similarity index 96% rename from packages/excalidraw/element/Hyperlink.scss rename to packages/excalidraw/components/hyperlink/Hyperlink.scss index ba7e86373..6a5db325a 100644 --- a/packages/excalidraw/element/Hyperlink.scss +++ b/packages/excalidraw/components/hyperlink/Hyperlink.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@import "../../css/variables.module.scss"; .excalidraw-hyperlinkContainer { display: flex; diff --git a/packages/excalidraw/element/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx similarity index 70% rename from packages/excalidraw/element/Hyperlink.tsx rename to packages/excalidraw/components/hyperlink/Hyperlink.tsx index 29b76d31d..c87ff773c 100644 --- a/packages/excalidraw/element/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -1,22 +1,20 @@ -import { AppState, ExcalidrawProps, Point, UIAppState } from "../types"; +import { AppState, ExcalidrawProps, Point } from "../../types"; import { - getShortcutKey, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords, wrapEvent, -} from "../utils"; -import { getEmbedLink, embeddableURLValidator } from "./embeddable"; -import { mutateElement } from "./mutateElement"; +} from "../../utils"; +import { getEmbedLink, embeddableURLValidator } from "../../element/embeddable"; +import { mutateElement } from "../../element/mutateElement"; import { ElementsMap, ExcalidrawEmbeddableElement, NonDeletedExcalidrawElement, -} from "./types"; +} from "../../element/types"; -import { register } from "../actions/register"; -import { ToolButton } from "../components/ToolButton"; -import { FreedrawIcon, LinkIcon, TrashIcon } from "../components/icons"; -import { t } from "../i18n"; +import { ToolButton } from "../ToolButton"; +import { FreedrawIcon, TrashIcon } from "../icons"; +import { t } from "../../i18n"; import { useCallback, useEffect, @@ -25,21 +23,19 @@ import { 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 { isLocalLink, normalizeLink } from "../data/url"; +import { KEYS } from "../../keys"; +import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants"; +import { getElementAbsoluteCoords } from "../../element/bounds"; +import { getTooltipDiv, updateTooltipPosition } from "../Tooltip"; +import { getSelectedElements } from "../../scene"; +import { isPointHittingElementBoundingBox } from "../../element/collision"; +import { isLocalLink, normalizeLink } from "../../data/url"; import "./Hyperlink.scss"; -import { trackEvent } from "../analytics"; -import { useAppProps, useExcalidrawAppState } from "../components/App"; -import { isEmbeddableElement } from "./typeChecks"; +import { trackEvent } from "../../analytics"; +import { useAppProps, useExcalidrawAppState } from "../App"; +import { isEmbeddableElement } from "../../element/typeChecks"; +import { getLinkHandleFromCoords } from "./helpers"; const CONTAINER_WIDTH = 320; const SPACE_BOTTOM = 85; @@ -47,11 +43,6 @@ 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( - ``, -)}`; - let IS_HYPERLINK_TOOLTIP_VISIBLE = false; const embeddableLinkCache = new Map< @@ -339,51 +330,6 @@ const getCoordsForPopover = ( 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 ( - updateData(null)} - selected={selectedElements.length === 1 && !!selectedElements[0].link} - /> - ); - }, -}); - export const getContextMenuLabel = ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, @@ -399,87 +345,6 @@ export const getContextMenuLabel = ( return label; }; -export const getLinkHandleFromCoords = ( - [x1, y1, x2, y2]: Bounds, - angle: number, - appState: Pick, -): 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; export const showHyperlinkTooltip = ( element: NonDeletedExcalidrawElement, @@ -547,7 +412,7 @@ export const hideHyperlinkToolip = () => { } }; -export const shouldHideLinkPopup = ( +const shouldHideLinkPopup = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts new file mode 100644 index 000000000..9b7da3d76 --- /dev/null +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -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( + ``, +)}`; + +export const getLinkHandleFromCoords = ( + [x1, y1, x2, y2]: Bounds, + angle: number, + appState: Pick, +): 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]); +}; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 62c59b6f8..69926b72d 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -73,7 +73,7 @@ import { import { EXTERNAL_LINK_IMG, getLinkHandleFromCoords, -} from "../element/Hyperlink"; +} from "../components/hyperlink/helpers"; import { renderSnaps } from "./renderSnaps"; import { isEmbeddableElement, diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index d22d3f221..503ebfc01 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -31,8 +31,11 @@ import { getSelectedElements } from "../../scene/selection"; import { isLinearElementType } from "../../element/typeChecks"; import { Mutable } from "../../utility-types"; import { assertNever } from "../../utils"; +import { createTestHook } from "../../components/App"; const readFile = util.promisify(fs.readFile); +// so that window.h is available when App.tsx is not imported as well. +createTestHook(); const { h } = window; diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 42685b866..c03b889df 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -33,6 +33,10 @@ import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; import { rotatePoint } from "../../math"; import { getTextEditor } from "../queries/dom"; 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; @@ -460,7 +464,6 @@ export class UI { mouse.reset(); mouse.up(x + width, y + height); } - const origElement = h.elements[h.elements.length - 1] as any; if (angle !== 0) {