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>
This commit is contained in:
Aakansha Doshi 2022-02-03 20:34:59 +05:30 committed by GitHub
parent 59cbf5fde5
commit f47ddb988f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1396 additions and 79 deletions

View file

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

View file

@ -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,
],

View file

@ -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

View file

@ -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,6 +15,50 @@ const getTooltipDiv = () => {
return div;
};
export const updateTooltipPosition = (
tooltip: HTMLDivElement,
item: {
left: number;
top: number;
width: number;
height: number;
},
position: "bottom" | "top" = "bottom",
) => {
const tooltipRect = tooltip.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 5;
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;
}
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}px`,
left: `${left}px`,
});
};
const updateTooltip = (
item: HTMLDivElement,
tooltip: HTMLDivElement,
@ -27,35 +71,8 @@ const updateTooltip = (
tooltip.textContent = label;
const {
x: itemX,
bottom: itemBottom,
top: itemTop,
width: itemWidth,
} = item.getBoundingClientRect();
const { width: labelWidth, height: labelHeight } =
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;
const top = itemBottom + margin;
const offsetTop =
top + labelHeight >= viewportHeight
? itemBottom - itemTop + labelHeight + margin * 2
: 0;
Object.assign(tooltip.style, {
top: `${top - offsetTop}px`,
left: `${left - offsetLeft}px`,
});
const itemRect = item.getBoundingClientRect();
updateTooltipPosition(tooltip, itemRect);
};
type TooltipProps = {
@ -75,7 +92,6 @@ export const Tooltip = ({
return () =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
}, []);
return (
<div
className="excalidraw-tooltip-wrapper"

View file

@ -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 },
);