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

@ -81,3 +81,4 @@ export { actionToggleZenMode } from "./actionToggleZenMode";
export { actionToggleStats } from "./actionToggleStats"; export { actionToggleStats } from "./actionToggleStats";
export { actionUnbindText } from "./actionUnbindText"; export { actionUnbindText } from "./actionUnbindText";
export { actionLink } from "../element/Hyperlink";

View File

@ -25,7 +25,8 @@ export type ShortcutName =
| "addToLibrary" | "addToLibrary"
| "viewMode" | "viewMode"
| "flipHorizontal" | "flipHorizontal"
| "flipVertical"; | "flipVertical"
| "link";
const shortcutMap: Record<ShortcutName, string[]> = { const shortcutMap: Record<ShortcutName, string[]> = {
cut: [getShortcutKey("CtrlOrCmd+X")], cut: [getShortcutKey("CtrlOrCmd+X")],
@ -62,6 +63,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
flipHorizontal: [getShortcutKey("Shift+H")], flipHorizontal: [getShortcutKey("Shift+H")],
flipVertical: [getShortcutKey("Shift+V")], flipVertical: [getShortcutKey("Shift+V")],
viewMode: [getShortcutKey("Alt+R")], viewMode: [getShortcutKey("Alt+R")],
link: [getShortcutKey("CtrlOrCmd+K")],
}; };
export const getShortcutFromShortcutName = (name: ShortcutName) => { export const getShortcutFromShortcutName = (name: ShortcutName) => {

View File

@ -104,7 +104,8 @@ export type ActionName =
| "toggleTheme" | "toggleTheme"
| "increaseFontSize" | "increaseFontSize"
| "decreaseFontSize" | "decreaseFontSize"
| "unbindText"; | "unbindText"
| "link";
export type PanelComponentProps = { export type PanelComponentProps = {
elements: readonly ExcalidrawElement[]; elements: readonly ExcalidrawElement[];

View File

@ -84,6 +84,7 @@ export const getDefaultAppState = (): Omit<
}, },
viewModeEnabled: false, viewModeEnabled: false,
pendingImageElement: null, pendingImageElement: null,
showHyperlinkPopup: false,
}; };
}; };
@ -174,6 +175,7 @@ const APP_STATE_STORAGE_CONF = (<
zoom: { browser: true, export: false, server: false }, zoom: { browser: true, export: false, server: false },
viewModeEnabled: { browser: false, export: false, server: false }, viewModeEnabled: { browser: false, export: false, server: false },
pendingImageElement: { browser: false, export: false, server: false }, pendingImageElement: { browser: false, export: false, server: false },
showHyperlinkPopup: { browser: false, export: false, server: false },
}); });
const _clearAppStateForStorage = < const _clearAppStateForStorage = <

View File

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

View File

@ -28,6 +28,7 @@ import {
actionToggleZenMode, actionToggleZenMode,
actionUnbindText, actionUnbindText,
actionUngroup, actionUngroup,
actionLink,
} from "../actions"; } from "../actions";
import { createRedoAction, createUndoAction } from "../actions/actionHistory"; import { createRedoAction, createUndoAction } from "../actions/actionHistory";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
@ -141,6 +142,7 @@ import {
InitializedExcalidrawImageElement, InitializedExcalidrawImageElement,
ExcalidrawImageElement, ExcalidrawImageElement,
FileId, FileId,
NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { import {
@ -239,6 +241,14 @@ import {
getBoundTextElementId, getBoundTextElementId,
} from "../element/textElement"; } from "../element/textElement";
import { isHittingElementNotConsideringBoundingBox } from "../element/collision"; import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
import {
normalizeLink,
showHyperlinkTooltip,
hideHyperlinkToolip,
Hyperlink,
isPointHittingLinkIcon,
isLocalLink,
} from "../element/Hyperlink";
const IsMobileContext = React.createContext(false); const IsMobileContext = React.createContext(false);
export const useIsMobile = () => useContext(IsMobileContext); export const useIsMobile = () => useContext(IsMobileContext);
@ -298,6 +308,11 @@ class App extends React.Component<AppProps, AppState> {
public files: BinaryFiles = {}; public files: BinaryFiles = {};
public imageCache: AppClassProperties["imageCache"] = new Map(); 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) { constructor(props: AppProps) {
super(props); super(props);
const defaultAppState = getDefaultAppState(); const defaultAppState = getDefaultAppState();
@ -320,6 +335,7 @@ class App extends React.Component<AppProps, AppState> {
name, name,
width: window.innerWidth, width: window.innerWidth,
height: window.innerHeight, height: window.innerHeight,
showHyperlinkPopup: false,
}; };
this.id = nanoid(); this.id = nanoid();
@ -433,7 +449,10 @@ class App extends React.Component<AppProps, AppState> {
public render() { public render() {
const { zenModeEnabled, viewModeEnabled } = this.state; const { zenModeEnabled, viewModeEnabled } = this.state;
const selectedElement = getSelectedElements(
this.scene.getElements(),
this.state,
);
const { const {
onCollabButtonClick, onCollabButtonClick,
renderTopRightUI, renderTopRightUI,
@ -499,6 +518,14 @@ class App extends React.Component<AppProps, AppState> {
/> />
<div className="excalidraw-textEditorContainer" /> <div className="excalidraw-textEditorContainer" />
<div className="excalidraw-contextMenuContainer" /> <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 && ( {this.state.showStats && (
<Stats <Stats
appState={this.state} appState={this.state}
@ -537,6 +564,8 @@ class App extends React.Component<AppProps, AppState> {
private syncActionResult = withBatchedUpdates( private syncActionResult = withBatchedUpdates(
(actionResult: ActionResult) => { (actionResult: ActionResult) => {
// Since context menu closes when action triggered so setting to false
this.contextMenuOpen = false;
if (this.unmounted || actionResult === false) { if (this.unmounted || actionResult === false) {
return; return;
} }
@ -1012,6 +1041,14 @@ class App extends React.Component<AppProps, AppState> {
} }
componentDidUpdate(prevProps: AppProps, prevState: 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) { if (prevProps.langCode !== this.props.langCode) {
this.updateLanguage(); this.updateLanguage();
} }
@ -1157,6 +1194,7 @@ class App extends React.Component<AppProps, AppState> {
renderScrollbars: !this.isMobile, renderScrollbars: !this.isMobile,
}, },
); );
if (scrollBars) { if (scrollBars) {
currentScrollBars = scrollBars; currentScrollBars = scrollBars;
} }
@ -1481,6 +1519,7 @@ class App extends React.Component<AppProps, AppState> {
}; };
removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => { removePointer = (event: React.PointerEvent<HTMLElement> | PointerEvent) => {
this.lastPointerUp = event;
// remove touch handler for context menu on touch devices // remove touch handler for context menu on touch devices
if (event.pointerType === "touch" && touchTimeout) { if (event.pointerType === "touch" && touchTimeout) {
clearTimeout(touchTimeout); clearTimeout(touchTimeout);
@ -2083,6 +2122,7 @@ class App extends React.Component<AppProps, AppState> {
.filter( .filter(
(element) => !(isTextElement(element) && element.containerId), (element) => !(isTextElement(element) && element.containerId),
); );
return getElementsAtPosition(elements, (element) => return getElementsAtPosition(elements, (element) =>
hitTest(element, this.state, x, y), 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 = ( private handleCanvasPointerMove = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
) => { ) => {
@ -2540,42 +2643,68 @@ class App extends React.Component<AppProps, AppState> {
scenePointer.x, scenePointer.x,
scenePointer.y, scenePointer.y,
); );
if (this.state.elementType === "text") { this.hitLinkElement = this.getElementLinkAtPosition(
setCursor( scenePointer,
this.canvas, hitElement,
isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR, );
);
} else if (this.state.viewModeEnabled) { if (
setCursor(this.canvas, CURSOR_TYPE.GRAB); this.hitLinkElement &&
} else if (isOverScrollBar) { !this.state.selectedElementIds[this.hitLinkElement.id]
setCursor(this.canvas, CURSOR_TYPE.AUTO); ) {
} else if (this.state.editingLinearElement) { setCursor(this.canvas, CURSOR_TYPE.POINTER);
const element = LinearElementEditor.getElement( showHyperlinkTooltip(this.hitLinkElement, this.state);
this.state.editingLinearElement.elementId, this.attachLinkListener();
); } else {
hideHyperlinkToolip();
this.detachLinkListener();
if ( if (
element && hitElement &&
isHittingElementNotConsideringBoundingBox(element, this.state, [ hitElement.link &&
scenePointer.x, this.state.selectedElementIds[hitElement.id] &&
scenePointer.y, !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); setCursor(this.canvas, CURSOR_TYPE.MOVE);
} else { } else {
setCursor(this.canvas, CURSOR_TYPE.AUTO); 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) { if (selection?.anchorNode) {
selection.removeAllRanges(); selection.removeAllRanges();
} }
this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event); this.maybeOpenContextMenuAfterPointerDownOnTouchDevices(event);
this.maybeCleanupAfterMissingPointerUp(event); this.maybeCleanupAfterMissingPointerUp(event);
@ -2612,7 +2740,7 @@ class App extends React.Component<AppProps, AppState> {
if (isPanning) { if (isPanning) {
return; return;
} }
this.lastPointerDown = event;
this.setState({ this.setState({
lastPointerDownWith: event.pointerType, lastPointerDownWith: event.pointerType,
cursorButton: "down", cursorButton: "down",
@ -2646,6 +2774,8 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// Since context menu closes on pointer down so setting to false
this.contextMenuOpen = false;
this.clearSelectionIfNotUsingSelection(); this.clearSelectionIfNotUsingSelection();
this.updateBindingEnabledOnPointerMove(event); this.updateBindingEnabledOnPointerMove(event);
@ -3072,7 +3202,6 @@ class App extends React.Component<AppProps, AppState> {
return true; return true;
} }
} }
// hitElement may already be set above, so check first // hitElement may already be set above, so check first
pointerDownState.hit.element = pointerDownState.hit.element =
pointerDownState.hit.element ?? pointerDownState.hit.element ??
@ -3082,6 +3211,15 @@ class App extends React.Component<AppProps, AppState> {
); );
if (pointerDownState.hit.element) { 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 = pointerDownState.hit.hasHitElementInside =
isHittingElementNotConsideringBoundingBox( isHittingElementNotConsideringBoundingBox(
pointerDownState.hit.element, pointerDownState.hit.element,
@ -3163,6 +3301,7 @@ class App extends React.Component<AppProps, AppState> {
...prevState.selectedElementIds, ...prevState.selectedElementIds,
[hitElement.id]: true, [hitElement.id]: true,
}, },
showHyperlinkPopup: hitElement.link ? "info" : false,
}, },
this.scene.getElements(), this.scene.getElements(),
); );
@ -3819,6 +3958,11 @@ class App extends React.Component<AppProps, AppState> {
} }
: null), : null),
}, },
showHyperlinkPopup:
elementsWithinSelection.length === 1 &&
elementsWithinSelection[0].link
? "info"
: false,
}, },
this.scene.getElements(), this.scene.getElements(),
), ),
@ -4970,6 +5114,10 @@ class App extends React.Component<AppProps, AppState> {
}, },
type: "canvas" | "element", type: "canvas" | "element",
) => { ) => {
if (this.state.showHyperlinkPopup) {
this.setState({ showHyperlinkPopup: false });
}
this.contextMenuOpen = true;
const maybeGroupAction = actionGroup.contextItemPredicate!( const maybeGroupAction = actionGroup.contextItemPredicate!(
this.actionManager.getElementsIncludingDeleted(), this.actionManager.getElementsIncludingDeleted(),
this.actionManager.getAppState(), this.actionManager.getAppState(),
@ -5116,6 +5264,7 @@ class App extends React.Component<AppProps, AppState> {
maybeFlipHorizontal && actionFlipHorizontal, maybeFlipHorizontal && actionFlipHorizontal,
maybeFlipVertical && actionFlipVertical, maybeFlipVertical && actionFlipVertical,
(maybeFlipHorizontal || maybeFlipVertical) && separator, (maybeFlipHorizontal || maybeFlipVertical) && separator,
actionLink.contextItemPredicate(elements, this.state) && actionLink,
actionDuplicateSelection, actionDuplicateSelection,
actionDeleteSelected, actionDeleteSelected,
], ],

View File

@ -205,6 +205,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("helpDialog.preventBinding")} label={t("helpDialog.preventBinding")}
shortcuts={[getShortcutKey("CtrlOrCmd")]} shortcuts={[getShortcutKey("CtrlOrCmd")]}
/> />
<Shortcut
label={t("toolBar.link")}
shortcuts={[getShortcutKey("CtrlOrCmd+K")]}
/>
</ShortcutIsland> </ShortcutIsland>
<ShortcutIsland caption={t("helpDialog.view")}> <ShortcutIsland caption={t("helpDialog.view")}>
<Shortcut <Shortcut

View File

@ -2,7 +2,7 @@ import "./Tooltip.scss";
import React, { useEffect } from "react"; import React, { useEffect } from "react";
const getTooltipDiv = () => { export const getTooltipDiv = () => {
const existingDiv = document.querySelector<HTMLDivElement>( const existingDiv = document.querySelector<HTMLDivElement>(
".excalidraw-tooltip", ".excalidraw-tooltip",
); );
@ -15,6 +15,50 @@ const getTooltipDiv = () => {
return div; 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 = ( const updateTooltip = (
item: HTMLDivElement, item: HTMLDivElement,
tooltip: HTMLDivElement, tooltip: HTMLDivElement,
@ -27,35 +71,8 @@ const updateTooltip = (
tooltip.textContent = label; tooltip.textContent = label;
const { const itemRect = item.getBoundingClientRect();
x: itemX, updateTooltipPosition(tooltip, itemRect);
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`,
});
}; };
type TooltipProps = { type TooltipProps = {
@ -75,7 +92,6 @@ export const Tooltip = ({
return () => return () =>
getTooltipDiv().classList.remove("excalidraw-tooltip--visible"); getTooltipDiv().classList.remove("excalidraw-tooltip--visible");
}, []); }, []);
return ( return (
<div <div
className="excalidraw-tooltip-wrapper" className="excalidraw-tooltip-wrapper"

View File

@ -892,3 +892,11 @@ export const publishIcon = createIcon(
/>, />,
{ width: 640, height: 512 }, { 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 },
);

View File

@ -115,6 +115,7 @@ export const TOAST_TIMEOUT = 5000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
export const SCROLL_TIMEOUT = 100; export const SCROLL_TIMEOUT = 100;
export const ZOOM_STEP = 0.1; export const ZOOM_STEP = 0.1;
export const HYPERLINK_TOOLTIP_DELAY = 300;
// Report a user inactive after IDLE_THRESHOLD milliseconds // Report a user inactive after IDLE_THRESHOLD milliseconds
export const IDLE_THRESHOLD = 60_000; export const IDLE_THRESHOLD = 60_000;

View File

@ -105,6 +105,7 @@ const restoreElementWithProperties = <
? element.boundElementIds.map((id) => ({ type: "arrow", id })) ? element.boundElementIds.map((id) => ({ type: "arrow", id }))
: element.boundElements ?? [], : element.boundElements ?? [],
updated: element.updated ?? getUpdatedTimestamp(), updated: element.updated ?? getUpdatedTimestamp(),
link: element.link ?? null,
}; };
return { return {

View File

@ -0,0 +1,74 @@
@import "../css/variables.module";
.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: 100;
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;
}
}
.d-none {
display: none;
}
&--remove .ToolIcon__icon svg {
color: $oc-red-6;
}
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
&__buttons {
flex: 0 0 auto;
}
}

429
src/element/Hyperlink.tsx Normal file
View File

@ -0,0 +1,429 @@
import { AppState, Point } from "../types";
import {
getShortcutKey,
sceneCoordsToViewportCoords,
viewportCoordsToSceneCoords,
} from "../utils";
import { mutateElement } from "./mutateElement";
import { NonDeletedExcalidrawElement } from "./types";
import { register } from "../actions/register";
import { ToolButton } from "../components/ToolButton";
import { editIcon, link, trash } from "../components/icons";
import { t } from "../i18n";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
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 "./Hyperlink.scss";
const CONTAINER_WIDTH = 320;
const SPACE_BOTTOM = 85;
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(
`<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;
export const Hyperlink = ({
element,
appState,
setAppState,
}: {
element: NonDeletedExcalidrawElement;
appState: AppState;
setAppState: React.Component<any, AppState>["setState"];
}) => {
const linkVal = element.link || "";
const [inputVal, setInputVal] = useState(linkVal);
const inputRef = useRef<HTMLInputElement>(null);
const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
const handleSubmit = useCallback(() => {
if (!inputRef.current) {
return;
}
const link = normalizeLink(inputRef.current.value);
mutateElement(element, { link });
setAppState({ showHyperlinkPopup: "info" });
}, [element, setAppState]);
useLayoutEffect(() => {
return () => {
handleSubmit();
};
}, [handleSubmit]);
useEffect(() => {
let timeoutId: number | null = null;
const handlePointerMove = (event: PointerEvent) => {
if (isEditing) {
return;
}
if (timeoutId) {
clearTimeout(timeoutId);
}
const shouldHide = shouldHideLinkPopup(element, appState, [
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]);
const handleRemove = useCallback(() => {
mutateElement(element, { link: null });
if (isEditing) {
inputRef.current!.value = "";
}
setAppState({ showHyperlinkPopup: false });
}, [setAppState, element, isEditing]);
const onEdit = () => {
setAppState({ showHyperlinkPopup: "editor" });
};
const { x, y } = getCoordsForPopover(element, appState);
if (
appState.draggingElement ||
appState.resizingElement ||
appState.isRotating
) {
return null;
}
return (
<div
className="excalidraw-hyperlinkContainer"
style={{
top: `${y}px`,
left: `${x}px`,
width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
>
{isEditing ? (
<input
className={clsx("excalidraw-hyperlinkContainer-input")}
placeholder="Type or paste your link here"
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();
}
}}
/>
) : (
<a
href={element.link || ""}
className={clsx("excalidraw-hyperlinkContainer-link", {
"d-none": isEditing,
})}
target={isLocalLink(element.link) ? "_self" : "_blank"}
rel="noopener noreferrer"
>
{element.link}
</a>
)}
<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={editIcon}
/>
)}
{linkVal && (
<ToolButton
type="button"
title={t("buttons.remove")}
aria-label={t("buttons.remove")}
label={t("buttons.remove")}
onClick={handleRemove}
className="excalidraw-hyperlinkContainer--remove"
icon={trash}
/>
)}
</div>
</div>
);
};
const getCoordsForPopover = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
const { x: viewPortX, y: viewPortY } = sceneCoordsToViewportCoords(
{ sceneX: element.x + element.width / 2, sceneY: element.y },
appState,
);
const x = viewPortX - CONTAINER_WIDTH / 2;
const y = viewPortY - SPACE_BOTTOM;
return { x, y };
};
export const normalizeLink = (link: string) => {
link = link.trim();
if (link) {
// prefix with protocol if not fully-qualified
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
link = `https://${link}`;
}
}
return link;
};
export const isLocalLink = (link: string | null) => {
return !!(link?.includes(location.origin) || link?.startsWith("/"));
};
export const actionLink = register({
name: "link",
perform: (elements, appState) => {
if (appState.showHyperlinkPopup === "editor") {
return false;
}
return {
elements,
appState: {
...appState,
showHyperlinkPopup: "editor",
},
commitToHistory: true,
};
},
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K,
contextItemLabel: (elements, appState) =>
getContextMenuLabel(elements, appState),
contextItemPredicate: (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={link}
aria-label={t(getContextMenuLabel(elements, appState))}
title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
onClick={() => updateData(null)}
selected={selectedElements.length === 1 && !!selectedElements[0].link}
/>
);
},
});
export const getContextMenuLabel = (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
const selectedElements = getSelectedElements(elements, appState);
const label = selectedElements[0]!.link
? "labels.link.edit"
: "labels.link.create";
return label;
};
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: number,
appState: AppState,
): [x: number, y: number, width: number, height: number] => {
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,
appState: AppState,
[x, y]: Point,
) => {
const threshold = 4 / appState.zoom.value;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
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;
};
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
export const showHyperlinkTooltip = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
if (HYPERLINK_TOOLTIP_TIMEOUT_ID) {
clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID);
}
HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout(
() => renderTooltip(element, appState),
HYPERLINK_TOOLTIP_DELAY,
);
};
const renderTooltip = (
element: NonDeletedExcalidrawElement,
appState: AppState,
) => {
if (!element.link) {
return;
}
const tooltipDiv = getTooltipDiv();
tooltipDiv.classList.add("excalidraw-tooltip--visible");
tooltipDiv.style.maxWidth = "20rem";
tooltipDiv.textContent = element.link;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
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",
);
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");
}
};
export const shouldHideLinkPopup = (
element: NonDeletedExcalidrawElement,
appState: AppState,
[clientX, clientY]: Point,
): 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 (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
return false;
}
// hit box to prevent hiding when hovered in the vertical area between element and popover
if (
sceneX >= element.x &&
sceneX <= element.x + element.width &&
sceneY <= element.y &&
sceneY >= element.y - SPACE_BOTTOM
) {
return false;
}
// hit box to prevent hiding when hovered around popover within threshold
const { x: popoverX, y: popoverY } = getCoordsForPopover(element, appState);
if (
clientX >= popoverX - threshold &&
clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
clientY >= popoverY - threshold &&
clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
) {
return false;
}
return true;
};

View File

@ -96,7 +96,6 @@ export const isHittingElementNotConsideringBoundingBox = (
: isElementDraggableFromInside(element) : isElementDraggableFromInside(element)
? isInsideCheck ? isInsideCheck
: isNearCheck; : isNearCheck;
return hitTestPointAgainstElement({ element, point, threshold, check }); return hitTestPointAgainstElement({ element, point, threshold, check });
}; };
@ -105,7 +104,7 @@ const isElementSelected = (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
) => appState.selectedElementIds[element.id]; ) => appState.selectedElementIds[element.id];
const isPointHittingElementBoundingBox = ( export const isPointHittingElementBoundingBox = (
element: NonDeleted<ExcalidrawElement>, element: NonDeleted<ExcalidrawElement>,
[x, y]: Point, [x, y]: Point,
threshold: number, threshold: number,

View File

@ -35,6 +35,7 @@ type ElementConstructorOpts = MarkOptional<
| "seed" | "seed"
| "version" | "version"
| "versionNonce" | "versionNonce"
| "link"
>; >;
const _newElementBase = <T extends ExcalidrawElement>( const _newElementBase = <T extends ExcalidrawElement>(
@ -55,6 +56,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
groupIds = [], groupIds = [],
strokeSharpness, strokeSharpness,
boundElements = null, boundElements = null,
link = null,
...rest ...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">, }: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
) => { ) => {
@ -81,6 +83,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
isDeleted: false as false, isDeleted: false as false,
boundElements, boundElements,
updated: getUpdatedTimestamp(), updated: getUpdatedTimestamp(),
link,
}; };
return element; return element;
}; };

View File

@ -52,6 +52,7 @@ type _ExcalidrawElementBase = Readonly<{
| null; | null;
/** epoch (ms) timestamp of last element update */ /** epoch (ms) timestamp of last element update */
updated: number; updated: number;
link: string | null;
}>; }>;
export type ExcalidrawSelectionElement = _ExcalidrawElementBase & { export type ExcalidrawSelectionElement = _ExcalidrawElementBase & {

View File

@ -61,6 +61,7 @@ export const KEYS = {
X: "x", X: "x",
Y: "y", Y: "y",
Z: "z", Z: "z",
K: "k",
} as const; } as const;
export type Key = keyof typeof KEYS; export type Key = keyof typeof KEYS;

View File

@ -105,7 +105,12 @@
"excalidrawLib": "Excalidraw Library", "excalidrawLib": "Excalidraw Library",
"decreaseFontSize": "Decrease font size", "decreaseFontSize": "Decrease font size",
"increaseFontSize": "Increase font size", "increaseFontSize": "Increase font size",
"unbindText": "Unbind text" "unbindText": "Unbind text",
"link": {
"edit": "Edit link",
"create": "Create link",
"label": "Link"
}
}, },
"buttons": { "buttons": {
"clearReset": "Reset the canvas", "clearReset": "Reset the canvas",
@ -188,7 +193,8 @@
"text": "Text", "text": "Text",
"library": "Library", "library": "Library",
"lock": "Keep selected tool active after drawing", "lock": "Keep selected tool active after drawing",
"penMode": "Prevent pinch-zoom and accept freedraw input only from pen" "penMode": "Prevent pinch-zoom and accept freedraw input only from pen",
"link": "Add/ Update link for a selected shape"
}, },
"headings": { "headings": {
"canvasActions": "Canvas actions", "canvasActions": "Canvas actions",

View File

@ -145,6 +145,8 @@ const generateElementCanvas = (
}; };
}; };
export const DEFAULT_LINK_SIZE = 14;
const IMAGE_PLACEHOLDER_IMG = document.createElement("img"); const IMAGE_PLACEHOLDER_IMG = document.createElement("img");
IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent( IMAGE_PLACEHOLDER_IMG.src = `data:${MIME_TYPES.svg},${encodeURIComponent(
`<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`, `<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="image" class="svg-inline--fa fa-image fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="#888" d="M464 448H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h416c26.51 0 48 21.49 48 48v288c0 26.51-21.49 48-48 48zM112 120c-30.928 0-56 25.072-56 56s25.072 56 56 56 56-25.072 56-56-25.072-56-56-56zM64 384h384V272l-87.515-87.515c-4.686-4.686-12.284-4.686-16.971 0L208 320l-55.515-55.515c-4.686-4.686-12.284-4.686-16.971 0L64 336v48z"></path></svg>`,

View File

@ -50,6 +50,10 @@ import {
import { viewportCoordsToSceneCoords, supportsEmoji } from "../utils"; import { viewportCoordsToSceneCoords, supportsEmoji } from "../utils";
import { UserIdleState } from "../types"; import { UserIdleState } from "../types";
import { THEME_FILTER } from "../constants"; import { THEME_FILTER } from "../constants";
import {
EXTERNAL_LINK_IMG,
getLinkHandleFromCoords,
} from "../element/Hyperlink";
const hasEmojiSupport = supportsEmoji(); const hasEmojiSupport = supportsEmoji();
@ -260,6 +264,9 @@ export const renderScene = (
visibleElements.forEach((element) => { visibleElements.forEach((element) => {
try { try {
renderElement(element, rc, context, renderConfig); renderElement(element, rc, context, renderConfig);
if (!isExporting) {
renderLinkIcon(element, context, appState);
}
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
} }
@ -740,6 +747,61 @@ const renderBindingHighlightForSuggestedPointBinding = (
}); });
}; };
let linkCanvasCache: any;
const renderLinkIcon = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
appState: AppState,
) => {
if (element.link && !appState.selectedElementIds[element.id]) {
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
const [x, y, width, height] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
element.angle,
appState,
);
const centerX = x + width / 2;
const centerY = y + height / 2;
context.save();
context.translate(appState.scrollX + centerX, appState.scrollY + centerY);
context.rotate(element.angle);
if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) {
linkCanvasCache = document.createElement("canvas");
linkCanvasCache.zoom = appState.zoom.value;
linkCanvasCache.width =
width * window.devicePixelRatio * appState.zoom.value;
linkCanvasCache.height =
height * window.devicePixelRatio * appState.zoom.value;
const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
linkCanvasCacheContext.scale(
window.devicePixelRatio * appState.zoom.value,
window.devicePixelRatio * appState.zoom.value,
);
linkCanvasCacheContext.fillStyle = "#fff";
linkCanvasCacheContext.fillRect(0, 0, width, height);
linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height);
linkCanvasCacheContext.restore();
context.drawImage(
linkCanvasCache,
x - centerX,
y - centerY,
width,
height,
);
} else {
context.drawImage(
linkCanvasCache,
x - centerX,
y - centerY,
width,
height,
);
}
context.restore();
}
};
const isVisibleElement = ( const isVisibleElement = (
element: ExcalidrawElement, element: ExcalidrawElement,
canvasWidth: number, canvasWidth: number,

View File

@ -64,6 +64,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -89,6 +90,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -143,6 +145,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -232,6 +235,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -257,6 +261,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -284,6 +289,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -338,6 +344,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -376,6 +383,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -400,6 +408,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -438,6 +447,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -462,6 +472,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -551,6 +562,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -576,6 +588,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -603,6 +616,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -657,6 +671,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -695,6 +710,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -719,6 +735,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -757,6 +774,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -781,6 +799,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -870,6 +889,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -895,6 +915,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -949,6 +970,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -1036,6 +1058,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -1061,6 +1084,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": true, "isDeleted": true,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1115,6 +1139,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1151,6 +1176,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": true, "isDeleted": true,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1240,6 +1266,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -1265,6 +1292,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1292,6 +1320,7 @@ Object {
"height": 20, "height": 20,
"id": "id0_copy", "id": "id0_copy",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -1346,6 +1375,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1384,6 +1414,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1408,6 +1439,7 @@ Object {
"height": 20, "height": 20,
"id": "id0_copy", "id": "id0_copy",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -1503,6 +1535,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -1530,6 +1563,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1559,6 +1593,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -1613,6 +1648,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1651,6 +1687,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1675,6 +1712,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -1719,6 +1757,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1745,6 +1784,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -1834,6 +1874,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -1859,6 +1900,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"seed": 1278240551, "seed": 1278240551,
@ -1886,6 +1928,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"seed": 400692809, "seed": 400692809,
@ -1940,6 +1983,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -1978,6 +2022,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2002,6 +2047,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2040,6 +2086,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2064,6 +2111,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2102,6 +2150,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2126,6 +2175,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2164,6 +2214,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2188,6 +2239,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2226,6 +2278,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2250,6 +2303,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2288,6 +2342,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2312,6 +2367,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2350,6 +2406,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2374,6 +2431,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 2, "roughness": 2,
"seed": 400692809, "seed": 400692809,
@ -2412,6 +2470,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2436,6 +2495,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"seed": 400692809, "seed": 400692809,
@ -2474,6 +2534,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"seed": 1278240551, "seed": 1278240551,
@ -2498,6 +2559,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 60, "opacity": 60,
"roughness": 2, "roughness": 2,
"seed": 400692809, "seed": 400692809,
@ -2587,6 +2649,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -2612,6 +2675,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2639,6 +2703,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2693,6 +2758,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2731,6 +2797,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2755,6 +2822,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2793,6 +2861,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2817,6 +2886,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -2906,6 +2976,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -2931,6 +3002,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -2958,6 +3030,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -3012,6 +3085,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -3050,6 +3124,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -3074,6 +3149,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -3112,6 +3188,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -3136,6 +3213,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -3229,6 +3307,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -3254,6 +3333,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -3281,6 +3361,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
@ -3335,6 +3416,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -3373,6 +3455,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -3397,6 +3480,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
@ -3441,6 +3525,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -3467,6 +3552,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
@ -3507,6 +3593,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -3531,6 +3618,7 @@ Object {
"height": 20, "height": 20,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
@ -3626,6 +3714,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -3651,6 +3740,7 @@ Object {
"height": 10, "height": 10,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -3678,6 +3768,7 @@ Object {
"height": 10, "height": 10,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -3732,6 +3823,7 @@ Object {
"height": 10, "height": 10,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -3770,6 +3862,7 @@ Object {
"height": 10, "height": 10,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -3794,6 +3887,7 @@ Object {
"height": 10, "height": 10,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 453191, "seed": 453191,
@ -3891,6 +3985,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -3918,6 +4013,7 @@ Object {
"height": 10, "height": 10,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -3947,6 +4043,7 @@ Object {
"height": 10, "height": 10,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
@ -4001,6 +4098,7 @@ Object {
"height": 10, "height": 10,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -4039,6 +4137,7 @@ Object {
"height": 10, "height": 10,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -4063,6 +4162,7 @@ Object {
"height": 10, "height": 10,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
@ -4108,6 +4208,7 @@ Object {
"height": 10, "height": 10,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -4134,6 +4235,7 @@ Object {
"height": 10, "height": 10,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
@ -4221,6 +4323,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -4324,6 +4427,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -4403,6 +4507,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],
@ -4428,6 +4533,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -4455,6 +4561,7 @@ Object {
"height": 200, "height": 200,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
@ -4482,6 +4589,7 @@ Object {
"height": 200, "height": 200,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,
@ -4536,6 +4644,7 @@ Object {
"height": 20, "height": 20,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 1278240551, "seed": 1278240551,

View File

@ -15,6 +15,7 @@ Object {
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [
@ -56,6 +57,7 @@ Object {
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
@ -85,6 +87,7 @@ Object {
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
@ -115,6 +118,7 @@ Object {
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [
@ -156,6 +160,7 @@ Object {
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,

View File

@ -10,6 +10,7 @@ Object {
"height": 50, "height": 50,
"id": "id0_copy", "id": "id0_copy",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
@ -37,6 +38,7 @@ Object {
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
@ -64,6 +66,7 @@ Object {
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
@ -96,6 +99,7 @@ Object {
"height": 100, "height": 100,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
@ -128,6 +132,7 @@ Object {
"height": 300, "height": 300,
"id": "id1", "id": "id1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 449462985, "seed": 449462985,
@ -162,6 +167,7 @@ Object {
"id": "id2", "id": "id2",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [

View File

@ -16,6 +16,7 @@ Object {
70, 70,
110, 110,
], ],
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [
@ -65,6 +66,7 @@ Object {
70, 70,
110, 110,
], ],
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [

File diff suppressed because it is too large Load Diff

View File

@ -13,6 +13,7 @@ Object {
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [
@ -55,6 +56,7 @@ Object {
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [
@ -94,6 +96,7 @@ Object {
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
@ -121,6 +124,7 @@ Object {
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
@ -148,6 +152,7 @@ Object {
"height": 50, "height": 50,
"id": "id0", "id": "id0",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,

View File

@ -136,6 +136,7 @@ describe("contextMenu element", () => {
"sendToBack", "sendToBack",
"bringToFront", "bringToFront",
"duplicateSelection", "duplicateSelection",
"link",
]; ];
expect(contextMenu).not.toBeNull(); expect(contextMenu).not.toBeNull();

View File

@ -13,6 +13,7 @@ Object {
"id": "id-arrow01", "id": "id-arrow01",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [
@ -56,6 +57,7 @@ Object {
"height": 200, "height": 200,
"id": "1", "id": "1",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 10, "opacity": 10,
"roughness": 2, "roughness": 2,
"seed": Any<Number>, "seed": Any<Number>,
@ -87,6 +89,7 @@ Object {
"height": 200, "height": 200,
"id": "2", "id": "2",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 10, "opacity": 10,
"roughness": 2, "roughness": 2,
"seed": Any<Number>, "seed": Any<Number>,
@ -118,6 +121,7 @@ Object {
"height": 200, "height": 200,
"id": "3", "id": "3",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 10, "opacity": 10,
"roughness": 2, "roughness": 2,
"seed": Any<Number>, "seed": Any<Number>,
@ -146,6 +150,7 @@ Object {
"id": "id-freedraw01", "id": "id-freedraw01",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [], "points": Array [],
"pressures": Array [], "pressures": Array [],
@ -179,6 +184,7 @@ Object {
"id": "id-line01", "id": "id-line01",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [
@ -221,6 +227,7 @@ Object {
"id": "id-draw01", "id": "id-draw01",
"isDeleted": false, "isDeleted": false,
"lastCommittedPoint": null, "lastCommittedPoint": null,
"link": null,
"opacity": 100, "opacity": 100,
"points": Array [ "points": Array [
Array [ Array [
@ -264,6 +271,7 @@ Object {
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"originalText": "text", "originalText": "text",
"roughness": 1, "roughness": 1,
@ -299,6 +307,7 @@ Object {
"height": 100, "height": 100,
"id": "id-text01", "id": "id-text01",
"isDeleted": false, "isDeleted": false,
"link": null,
"opacity": 100, "opacity": 100,
"originalText": "test", "originalText": "test",
"roughness": 1, "roughness": 1,

View File

@ -22,6 +22,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
isDeleted: false, isDeleted: false,
boundElements: null, boundElements: null,
updated: 1, updated: 1,
link: null,
}; };
export const rectangleFixture: ExcalidrawElement = { export const rectangleFixture: ExcalidrawElement = {

View File

@ -60,6 +60,7 @@ Object {
"selectionElement": null, "selectionElement": null,
"shouldCacheIgnoreZoom": false, "shouldCacheIgnoreZoom": false,
"showHelpDialog": false, "showHelpDialog": false,
"showHyperlinkPopup": false,
"showStats": false, "showStats": false,
"startBoundElement": null, "startBoundElement": null,
"suggestedBindings": Array [], "suggestedBindings": Array [],

View File

@ -74,7 +74,7 @@ exports[`exportToSvg with default arguments 1`] = `
exports[`exportToSvg with exportEmbedScene 1`] = ` exports[`exportToSvg with exportEmbedScene 1`] = `
" "
<!-- svg-source:excalidraw --> <!-- svg-source:excalidraw -->
<!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1STU9cdTAwMDMhXHUwMDEwvfdXbPDapLtrv+ytWmNMjFx1MDAxZXpoovFAl9mFlFx1MDAwMlx1MDAwNbZcdTAwMWZp+t9cdTAwMDXaLrrx4l1cdTAwMGUk83hvZph5x06SIHtQgCZcdIJ9gTkjXHUwMDFh71DX41vQhknhnvJcdTAwMTBcdTAwMWJZ61wiMKm1atLrcelcdTAwMDRUXHUwMDFhe+ZcdTAwMDOHNVxia1x1MDAxY+PDxUlyXGa3e2HEq7ZcdTAwMGK9eZuWKyZIvinWo5fZ9Ok9SFx1MDAwM2nvOP2s38RcdTAwMDdf+HbUxDtGLHVYlqZcckaBVdS2QCwq7tuMiLFaruBBcql9IzdpOLH0XHUwMDEyXHUwMDE3q0rLWpDIyVx1MDAwNlx1MDAxOC/LyClcdTAwMTnnc3vg51x1MDAwMeCC1lx1MDAxYVCrwuLaYlx1MDAwYm90RrpcdTAwMDFHlStZUVx1MDAwMcb80EiFXHUwMDBiZlx1MDAwZq1f+f7UM1x00/1s56dYq0tcdTAwMWVkfPCtM1x1MDAwMFx1MDAxMlL1s+FgdJeOm5e43yxP2+irXHUwMDE0YddZNlx1MDAxZadpP1x1MDAxZlxyXHUwMDFiXHUwMDA2MzO3alx1MDAxYtKWmFx1MDAxYohz9CN8jDZcdTAwMTA1581jrVxiPoviV6/WI1xmr6UgKOCn7r97/t3zXHUwMDA391x1MDAwMOdMXHUwMDE5uLjH3eGHXGIrNbdO5ChnL6Etg939L9sqw/H64D2/LfBcdTAwMWRcdTAwMWNPndNcdTAwMTfZZ1DTIn0=<!-- payload-end --> <!-- payload-type:application/vnd.excalidraw+json --><!-- payload-version:2 --><!-- payload-start -->eyJ2ZXJzaW9uIjoiMSIsImVuY29kaW5nIjoiYnN0cmluZyIsImNvbXByZXNzZWQiOnRydWUsImVuY29kZWQiOiJ4nO1STU9cdTAwMDMhXHUwMDEwvfdXbPDapLtrv+ytWmNMjFx1MDAxZXpoovFAl9mFlFx1MDAwMlx1MDAwNbZcdTAwMWZp+t9cdTAwMDXaLrrx5lVcdTAwMGUk83hvZph5x06SIHtQgCZcdIJ9gTkjXHUwMDFh71DX41vQhknhnvJcdTAwMTBcdTAwMWJZ61wiMKm1atLrcelcdTAwMDRUXHUwMDFhe+ZcdTAwMDOHNVxia1x1MDAxY+PDxUlyXGa3e2HEq7ZcdTAwMGK9eZuWKyZIvinWo5fZ9Ok9SFx1MDAwM2nvOP2s38RcdTAwMDdf+HbUxDtGLHVYlqZcckaBVdS2QCwq7tuMiLFaruBBcql9IzdpOLH0XHUwMDEyXHUwMDE3q0rLWpDIyVx1MDAwNlx1MDAxOC/LyClcdTAwMTnnc3vg51x1MDAwMeCC1lx1MDAxYVCrwuLaYlx1MDAwYm90RrpcdTAwMDFHlStZUVx1MDAwMcb80EiFXHUwMDBiZlx1MDAwZq1f+f7UM1x00/1s56dYq0tcdTAwMWVkfPCtM1x1MDAwMFx1MDAxMlL1s+FgdJeOm5e43yxP2+irXHUwMDE0YddZNlx1MDAxZadpP1x1MDAxZlxyXHUwMDFiXHUwMDA2MzO3alx1MDAxYtKWmFx1MDAxYohz9CN8jDZcdTAwMTA1581jrVxiPoviVzlcdTAwMTOrNu9qR8LwWlxuglx1MDAwMn7q/jvq31F/dFx1MDAxNHDOlIGLo9xcdTAwMWR+jbBSc+tcdTAwMTI5ytlfaMtgd//LXHUwMDA2y3C8PvjRb1x1MDAxMHxXx1Pn9Fx1MDAwNbeWWs0ifQ==<!-- payload-end -->
<defs> <defs>
<style> <style>
@font-face { @font-face {

View File

@ -151,6 +151,7 @@ export type AppState = {
}; };
/** imageElement waiting to be placed on canvas */ /** imageElement waiting to be placed on canvas */
pendingImageElement: NonDeleted<ExcalidrawImageElement> | null; pendingImageElement: NonDeleted<ExcalidrawImageElement> | null;
showHyperlinkPopup: false | "info" | "editor";
}; };
export type NormalizedZoomValue = number & { _brand: "normalizedZoom" }; export type NormalizedZoomValue = number & { _brand: "normalizedZoom" };