diff options
Diffstat (limited to 'packages/excalidraw/components/hyperlink')
| -rw-r--r-- | packages/excalidraw/components/hyperlink/Hyperlink.scss | 70 | ||||
| -rw-r--r-- | packages/excalidraw/components/hyperlink/Hyperlink.tsx | 480 | ||||
| -rw-r--r-- | packages/excalidraw/components/hyperlink/helpers.ts | 99 |
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), + ); +}; |
