feat: in canvas links between shapes (#8812)

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Ryan Di 2024-11-27 01:53:25 +08:00 committed by GitHub
parent a758aaf8f6
commit c0b80a03bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1281 additions and 217 deletions

View file

@ -87,7 +87,8 @@ export const actionClearCanvas = register({
predicate: (elements, appState, props, app) => {
return (
!!app.props.UIOptions.canvasActions.clearCanvas &&
!appState.viewModeEnabled
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector"
);
},
perform: (elements, appState, _, app) => {

View file

@ -0,0 +1,105 @@
import { copyTextToSystemClipboard } from "../clipboard";
import { copyIcon, elementLinkIcon } from "../components/icons";
import {
canCreateLinkFromElements,
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "../element/elementLink";
import { t } from "../i18n";
import { getSelectedElements } from "../scene";
import { StoreAction } from "../store";
import { register } from "./register";
export const actionCopyElementLink = register({
name: "copyElementLink",
label: "labels.copyElementLink",
icon: copyIcon,
trackEvent: { category: "element" },
perform: async (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
try {
if (window.location) {
const idAndType = getLinkIdAndTypeFromSelection(
selectedElements,
appState,
);
if (idAndType) {
await copyTextToSystemClipboard(
app.props.generateLinkForSelection
? app.props.generateLinkForSelection(idAndType.id, idAndType.type)
: defaultGetElementLinkFromSelection(
idAndType.id,
idAndType.type,
),
);
return {
appState: {
toast: {
message: t("toast.elementLinkCopied"),
closable: true,
},
},
storeAction: StoreAction.NONE,
};
}
return {
appState,
elements,
app,
storeAction: StoreAction.NONE,
};
}
} catch (error: any) {
console.error(error);
}
return {
appState,
elements,
app,
storeAction: StoreAction.NONE,
};
},
predicate: (elements, appState) =>
canCreateLinkFromElements(getSelectedElements(elements, appState)),
});
export const actionLinkToElement = register({
name: "linkToElement",
label: "labels.linkToElement",
icon: elementLinkIcon,
perform: (elements, appState, _, app) => {
const selectedElements = getSelectedElements(elements, appState);
if (
selectedElements.length !== 1 ||
!canCreateLinkFromElements(selectedElements)
) {
return { elements, appState, app, storeAction: StoreAction.NONE };
}
return {
appState: {
...appState,
openDialog: {
name: "elementLinkSelector",
sourceElementId: getSelectedElements(elements, appState)[0].id,
},
},
storeAction: StoreAction.CAPTURE,
};
},
predicate: (elements, appState, appProps, app) => {
const selectedElements = getSelectedElements(elements, appState);
return (
appState.openDialog?.name !== "elementLinkSelector" &&
selectedElements.length === 1 &&
canCreateLinkFromElements(selectedElements)
);
},
trackEvent: false,
});

View file

@ -135,6 +135,8 @@ export type ActionName =
| "autoResize"
| "elementStats"
| "searchMenu"
| "copyElementLink"
| "linkToElement"
| "cropEditor";
export type PanelComponentProps = {

View file

@ -84,6 +84,7 @@ export const getDefaultAppState = (): Omit<
scrollX: 0,
scrollY: 0,
selectedElementIds: {},
hoveredElementIds: {},
selectedGroupIds: {},
selectedElementsAreBeingDragged: false,
selectionElement: null,
@ -210,6 +211,7 @@ const APP_STATE_STORAGE_CONF = (<
scrollX: { browser: true, export: false, server: false },
scrollY: { browser: true, export: false, server: false },
selectedElementIds: { browser: true, export: false, server: false },
hoveredElementIds: { browser: false, export: false, server: false },
selectedGroupIds: { browser: true, export: false, server: false },
selectedElementsAreBeingDragged: {
browser: false,

View file

@ -49,7 +49,6 @@ import {
} from "../appState";
import type { PastedMixedContent } from "../clipboard";
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
import { ARROW_TYPE, isSafari, type EXPORT_IMAGE_TYPES } from "../constants";
import {
APP_NAME,
CURSOR_TYPE,
@ -88,6 +87,10 @@ import {
supportsResizeObserver,
DEFAULT_COLLISION_THRESHOLD,
DEFAULT_TEXT_ALIGN,
ARROW_TYPE,
DEFAULT_REDUCED_GLOBAL_ALPHA,
isSafari,
type EXPORT_IMAGE_TYPES,
} from "../constants";
import type { ExportedElements } from "../data";
import { exportCanvas, loadFromBlob } from "../data";
@ -461,6 +464,8 @@ import {
} from "../../math";
import { cropElement } from "../element/cropElement";
import { wrapText } from "../element/textWrapping";
import { actionCopyElementLink } from "../actions/actionElementLink";
import { isElementLink, parseElementLinkFromURL } from "../element/elementLink";
const AppContext = React.createContext<AppClassProperties>(null!);
const AppPropsContext = React.createContext<AppProps>(null!);
@ -1202,6 +1207,9 @@ class App extends React.Component<AppProps, AppState> {
getContainingFrame(el, this.scene.getNonDeletedElementsMap()),
this.elementsPendingErasure,
null,
this.state.openDialog?.name === "elementLinkSelector"
? DEFAULT_REDUCED_GLOBAL_ALPHA
: 1,
),
["--embeddable-radius" as string]: `${getCornerRadius(
Math.min(el.width, el.height),
@ -1520,7 +1528,9 @@ class App extends React.Component<AppProps, AppState> {
return (
<div
className={clsx("excalidraw excalidraw-container", {
"excalidraw--view-mode": this.state.viewModeEnabled,
"excalidraw--view-mode":
this.state.viewModeEnabled ||
this.state.openDialog?.name === "elementLinkSelector",
"excalidraw--mobile": this.device.editor.isMobile,
})}
style={{
@ -1579,6 +1589,9 @@ class App extends React.Component<AppProps, AppState> {
}
app={this}
isCollaborating={this.props.isCollaborating}
generateLinkForSelection={
this.props.generateLinkForSelection
}
>
{this.props.children}
</LayerUI>
@ -1590,6 +1603,8 @@ class App extends React.Component<AppProps, AppState> {
trails={[this.laserTrails, this.eraserTrail]}
/>
{selectedElements.length === 1 &&
this.state.openDialog?.name !==
"elementLinkSelector" &&
this.state.showHyperlinkPopup && (
<Hyperlink
key={firstSelectedElement.id}
@ -2325,6 +2340,10 @@ class App extends React.Component<AppProps, AppState> {
this.fonts.loadSceneFonts().then((fontFaces) => {
this.fonts.onLoaded(fontFaces);
});
if (isElementLink(window.location.href)) {
this.scrollToContent(window.location.href, { animate: false });
}
};
private isMobileBreakpoint = (width: number, height: number) => {
@ -2761,6 +2780,18 @@ class App extends React.Component<AppProps, AppState> {
this.deselectElements();
}
// cleanup
if (
(prevState.openDialog?.name === "elementLinkSelector" ||
this.state.openDialog?.name === "elementLinkSelector") &&
prevState.openDialog?.name !== this.state.openDialog?.name
) {
this.deselectElements();
this.setState({
hoveredElementIds: {},
});
}
if (prevProps.zenModeEnabled !== this.props.zenModeEnabled) {
this.setState({ zenModeEnabled: !!this.props.zenModeEnabled });
}
@ -3623,7 +3654,14 @@ class App extends React.Component<AppProps, AppState> {
private cancelInProgressAnimation: (() => void) | null = null;
scrollToContent = (
/**
* target to scroll to
*
* - string - id of element or group, or url containing elementLink
* - ExcalidrawElement | ExcalidrawElement[] - element(s) objects
*/
target:
| string
| ExcalidrawElement
| readonly ExcalidrawElement[] = this.scene.getNonDeletedElements(),
opts?: (
@ -3650,6 +3688,34 @@ class App extends React.Component<AppProps, AppState> {
canvasOffsets?: Offsets;
},
) => {
if (typeof target === "string") {
let id: string | null;
if (isElementLink(target)) {
id = parseElementLinkFromURL(target);
} else {
id = target;
}
if (id) {
const elements = this.scene.getElementsFromId(id);
if (elements?.length) {
this.scrollToContent(elements, {
fitToContent: opts?.fitToContent ?? true,
animate: opts?.animate ?? true,
});
} else if (isElementLink(target)) {
this.setState({
toast: {
message: t("elementLink.notFound"),
duration: 3000,
closable: true,
},
});
}
}
return;
}
this.cancelInProgressAnimation?.();
// convert provided target into ExcalidrawElement[] if necessary
@ -4214,6 +4280,10 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (this.state.openDialog?.name === "elementLinkSelector") {
return;
}
if (this.actionManager.handleKeyDown(event)) {
return;
}
@ -4485,7 +4555,10 @@ class App extends React.Component<AppProps, AppState> {
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) {
if (this.state.viewModeEnabled) {
if (
this.state.viewModeEnabled ||
this.state.openDialog?.name === "elementLinkSelector"
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.activeTool.type === "selection") {
resetCursor(this.interactiveCanvas);
@ -5372,18 +5445,17 @@ class App extends React.Component<AppProps, AppState> {
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.getNonDeletedElements().slice().reverse();
let hitElementIndex = Infinity;
const elements = this.scene.getNonDeletedElements();
let hitElementIndex = -1;
return elements.find((element, index) => {
for (let index = elements.length - 1; index >= 0; index--) {
const element = elements[index];
if (hitElement && element.id === hitElement.id) {
hitElementIndex = index;
}
return (
if (
element.link &&
index <= hitElementIndex &&
index >= hitElementIndex &&
isPointHittingLink(
element,
this.scene.getNonDeletedElementsMap(),
@ -5391,8 +5463,10 @@ class App extends React.Component<AppProps, AppState> {
pointFrom(scenePointer.x, scenePointer.y),
this.device.editor.isMobile,
)
);
});
) {
return element;
}
}
};
private redirectToLink = (
@ -5409,12 +5483,7 @@ class App extends React.Component<AppProps, AppState> {
this.lastPointerUpEvent!.clientY,
),
);
if (
!this.hitLinkElement ||
// For touch screen allow dragging threshold else strict check
(isTouchScreen && draggedDistance > DRAGGING_THRESHOLD) ||
(!isTouchScreen && draggedDistance !== 0)
) {
if (!this.hitLinkElement || draggedDistance > DRAGGING_THRESHOLD) {
return;
}
const lastPointerDownCoords = viewportCoordsToSceneCoords(
@ -5441,6 +5510,7 @@ class App extends React.Component<AppProps, AppState> {
this.device.editor.isMobile,
);
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
hideHyperlinkToolip();
let url = this.hitLinkElement.link;
if (url) {
url = normalizeLink(url);
@ -5827,6 +5897,7 @@ class App extends React.Component<AppProps, AppState> {
if (
(!this.state.selectedLinearElement ||
this.state.selectedLinearElement.hoverPointIndex === -1) &&
this.state.openDialog?.name !== "elementLinkSelector" &&
!(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
) {
const elementWithTransformHandleType =
@ -5851,7 +5922,11 @@ class App extends React.Component<AppProps, AppState> {
return;
}
}
} else if (selectedElements.length > 1 && !isOverScrollBar) {
} else if (
selectedElements.length > 1 &&
!isOverScrollBar &&
this.state.openDialog?.name !== "elementLinkSelector"
) {
const transformHandleType = getTransformHandleTypeFromCoords(
getCommonBounds(selectedElements),
scenePointerX,
@ -5910,6 +5985,8 @@ class App extends React.Component<AppProps, AppState> {
);
} else if (this.state.viewModeEnabled) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else if (this.state.openDialog?.name === "elementLinkSelector") {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (isOverScrollBar) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if (this.state.selectedLinearElement) {
@ -5955,6 +6032,32 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
}
}
if (this.state.openDialog?.name === "elementLinkSelector" && hitElement) {
this.setState((prevState) => {
return {
hoveredElementIds: updateStable(
prevState.hoveredElementIds,
selectGroupsForSelectedElements(
{
editingGroupId: prevState.editingGroupId,
selectedElementIds: { [hitElement.id]: true },
},
this.scene.getNonDeletedElements(),
prevState,
this,
).selectedElementIds,
),
};
});
} else if (
this.state.openDialog?.name === "elementLinkSelector" &&
!hitElement
) {
this.setState((prevState) => ({
hoveredElementIds: updateStable(prevState.hoveredElementIds, {}),
}));
}
};
private handleEraser = (
@ -6212,7 +6315,10 @@ class App extends React.Component<AppProps, AppState> {
this.state,
),
},
storeAction: StoreAction.UPDATE,
storeAction:
this.state.openDialog?.name === "elementLinkSelector"
? StoreAction.NONE
: StoreAction.UPDATE,
});
return;
}
@ -6939,6 +7045,15 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState.origin.y,
);
this.hitLinkElement = this.getElementLinkAtPosition(
pointerDownState.origin,
pointerDownState.hit.element,
);
if (this.hitLinkElement) {
return true;
}
if (
this.state.croppingElementId &&
pointerDownState.hit.element?.id !== this.state.croppingElementId
@ -7032,7 +7147,7 @@ class App extends React.Component<AppProps, AppState> {
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements
) {
this.setState((prevState) => {
const nextSelectedElementIds: { [id: string]: true } = {
let nextSelectedElementIds: { [id: string]: true } = {
...prevState.selectedElementIds,
[hitElement.id]: true,
};
@ -7103,6 +7218,23 @@ class App extends React.Component<AppProps, AppState> {
}
}
// Finally, in shape selection mode, we'd like to
// keep only one shape or group selected at a time.
// This means, if the hitElement is a different shape or group
// than the previously selected ones, we deselect the previous ones
// and select the hitElement
if (prevState.openDialog?.name === "elementLinkSelector") {
if (
!hitElement.groupIds.some(
(gid) => prevState.selectedGroupIds[gid],
)
) {
nextSelectedElementIds = {
[hitElement.id]: true,
};
}
}
return {
...selectGroupsForSelectedElements(
{
@ -7747,6 +7879,9 @@ class App extends React.Component<AppProps, AppState> {
pointerDownState: PointerDownState,
) {
return withBatchedUpdatesThrottled((event: PointerEvent) => {
if (this.state.openDialog?.name === "elementLinkSelector") {
return;
}
const pointerCoords = viewportCoordsToSceneCoords(event, this.state);
const lastPointerCoords =
this.lastPointerMoveCoords ?? pointerDownState.origin;
@ -10498,7 +10633,10 @@ class App extends React.Component<AppProps, AppState> {
actionFlipVertical,
CONTEXT_MENU_SEPARATOR,
actionToggleLinearEditor,
CONTEXT_MENU_SEPARATOR,
actionLink,
actionCopyElementLink,
CONTEXT_MENU_SEPARATOR,
actionDuplicateSelection,
actionToggleElementLock,
CONTEXT_MENU_SEPARATOR,

View file

@ -56,6 +56,10 @@ import { trackEvent } from "../../analytics";
import { useStable } from "../../hooks/useStable";
import "./CommandPalette.scss";
import {
actionCopyElementLink,
actionLinkToElement,
} from "../../actions/actionElementLink";
const lastUsedPaletteItem = atom<CommandPaletteItem | null>(null);
@ -281,6 +285,8 @@ function CommandPaletteInner({
actionManager.actions.toggleLinearEditor,
actionManager.actions.cropEditor,
actionLink,
actionCopyElementLink,
actionLinkToElement,
].map((action: Action) =>
actionToCommand(
action,

View file

@ -0,0 +1,87 @@
@import "../css/variables.module.scss";
.excalidraw {
.ElementLinkDialog {
position: absolute;
top: var(--editor-container-padding);
left: calc(var(--editor-container-padding) * 4);
z-index: 3;
border-radius: 10px;
padding: 1.5rem;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: var(--shadow-island);
background-color: var(--island-bg-color);
@include isMobile {
left: 0;
margin-left: 0.5rem;
margin-right: 0.5rem;
width: calc(100% - 1rem);
box-sizing: border-box;
z-index: 5;
}
.ElementLinkDialog__header {
h2 {
margin-top: 0;
margin-bottom: 0.5rem;
@include isMobile {
font-size: 1.25rem;
}
}
p {
margin: 0;
@include isMobile {
font-size: 0.875rem;
}
}
margin-bottom: 1.5rem;
@include isMobile {
margin-bottom: 1rem;
}
}
.ElementLinkDialog__input {
display: flex;
.ElementLinkDialog__input-field {
flex: 1;
}
.ElementLinkDialog__remove {
color: $oc-red-9;
margin-left: 1rem;
.ToolIcon__icon {
width: 2rem;
height: 2rem;
}
.ToolIcon__icon svg {
color: $oc-red-6;
}
}
}
.ElementLinkDialog__actions {
display: flex;
justify-content: flex-end;
margin-top: 1.5rem;
@include isMobile {
font-size: 0.875rem;
margin-top: 1rem;
}
}
}
}

View file

@ -0,0 +1,174 @@
import { TextField } from "./TextField";
import type { AppProps, AppState, UIAppState } from "../types";
import DialogActionButton from "./DialogActionButton";
import { getSelectedElements } from "../scene";
import {
defaultGetElementLinkFromSelection,
getLinkIdAndTypeFromSelection,
} from "../element/elementLink";
import { mutateElement } from "../element/mutateElement";
import { useCallback, useEffect, useState } from "react";
import { t } from "../i18n";
import type { ElementsMap, ExcalidrawElement } from "../element/types";
import { ToolButton } from "./ToolButton";
import { TrashIcon } from "./icons";
import { KEYS } from "../keys";
import "./ElementLinkDialog.scss";
import { normalizeLink } from "../data/url";
const ElementLinkDialog = ({
sourceElementId,
onClose,
elementsMap,
appState,
generateLinkForSelection = defaultGetElementLinkFromSelection,
}: {
sourceElementId: ExcalidrawElement["id"];
elementsMap: ElementsMap;
appState: UIAppState;
onClose?: () => void;
generateLinkForSelection: AppProps["generateLinkForSelection"];
}) => {
const originalLink = elementsMap.get(sourceElementId)?.link ?? null;
const [nextLink, setNextLink] = useState<string | null>(originalLink);
const [linkEdited, setLinkEdited] = useState(false);
useEffect(() => {
const selectedElements = getSelectedElements(elementsMap, appState);
let nextLink = originalLink;
if (selectedElements.length > 0 && generateLinkForSelection) {
const idAndType = getLinkIdAndTypeFromSelection(
selectedElements,
appState as AppState,
);
if (idAndType) {
nextLink = normalizeLink(
generateLinkForSelection(idAndType.id, idAndType.type),
);
}
}
setNextLink(nextLink);
}, [
elementsMap,
appState,
appState.selectedElementIds,
originalLink,
generateLinkForSelection,
]);
const handleConfirm = useCallback(() => {
if (nextLink && nextLink !== elementsMap.get(sourceElementId)?.link) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
mutateElement(elementToLink, {
link: nextLink,
});
}
if (!nextLink && linkEdited && sourceElementId) {
const elementToLink = elementsMap.get(sourceElementId);
elementToLink &&
mutateElement(elementToLink, {
link: null,
});
}
onClose?.();
}, [sourceElementId, nextLink, elementsMap, linkEdited, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
appState.openDialog?.name === "elementLinkSelector" &&
event.key === KEYS.ENTER
) {
handleConfirm();
}
if (
appState.openDialog?.name === "elementLinkSelector" &&
event.key === KEYS.ESCAPE
) {
onClose?.();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [appState, onClose, handleConfirm]);
return (
<div className="ElementLinkDialog">
<div className="ElementLinkDialog__header">
<h2>{t("elementLink.title")}</h2>
<p>{t("elementLink.desc")}</p>
</div>
<div className="ElementLinkDialog__input">
<TextField
value={nextLink ?? ""}
onChange={(value) => {
if (!linkEdited) {
setLinkEdited(true);
}
setNextLink(value);
}}
onKeyDown={(event) => {
if (event.key === KEYS.ENTER) {
handleConfirm();
}
}}
className="ElementLinkDialog__input-field"
selectOnRender
/>
{originalLink && nextLink && (
<ToolButton
type="button"
title={t("buttons.remove")}
aria-label={t("buttons.remove")}
label={t("buttons.remove")}
onClick={() => {
// removes the link from the input
// but doesn't update the element
// when confirmed, will remove the link from the element
setNextLink(null);
setLinkEdited(true);
}}
className="ElementLinkDialog__remove"
icon={TrashIcon}
/>
)}
</div>
<div className="ElementLinkDialog__actions">
<DialogActionButton
label={t("buttons.cancel")}
onClick={() => {
onClose?.();
}}
style={{
marginRight: 10,
}}
/>
<DialogActionButton
label={t("buttons.confirm")}
onClick={handleConfirm}
actionType="primary"
/>
</div>
</div>
);
};
export default ElementLinkDialog;

View file

@ -60,6 +60,7 @@ import { LaserPointerButton } from "./LaserPointerButton";
import { TTDDialog } from "./TTDDialog/TTDDialog";
import { Stats } from "./Stats";
import { actionToggleStats } from "../actions";
import ElementLinkDialog from "./ElementLinkDialog";
import "./LayerUI.scss";
import "./Toolbar.scss";
@ -84,6 +85,7 @@ interface LayerUIProps {
children?: React.ReactNode;
app: AppClassProperties;
isCollaborating: boolean;
generateLinkForSelection?: AppProps["generateLinkForSelection"];
}
const DefaultMainMenu: React.FC<{
@ -141,6 +143,7 @@ const LayerUI = ({
children,
app,
isCollaborating,
generateLinkForSelection,
}: LayerUIProps) => {
const device = useDevice();
const tunnels = useInitializeTunnels();
@ -232,7 +235,8 @@ const LayerUI = ({
const shouldShowStats =
appState.stats.open &&
!appState.zenModeEnabled &&
!appState.viewModeEnabled;
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector";
return (
<FixedSideContainer side="top">
@ -241,90 +245,91 @@ const LayerUI = ({
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
{!appState.viewModeEnabled && (
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<Island
padding={1}
className={clsx("App-toolbar", {
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<Section heading="shapes" className="shapes-section">
{(heading: React.ReactNode) => (
<div style={{ position: "relative" }}>
{renderWelcomeScreen && (
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
)}
<Stack.Col gap={4} align="start">
<Stack.Row
gap={1}
className={clsx("App-toolbar-container", {
"zen-mode": appState.zenModeEnabled,
})}
>
<HintViewer
appState={appState}
isMobile={device.editor.isMobile}
device={device}
app={app}
/>
{heading}
<Stack.Row gap={1}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
<Island
padding={1}
className={clsx("App-toolbar", {
"zen-mode": appState.zenModeEnabled,
})}
>
<HintViewer
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
isMobile={device.editor.isMobile}
device={device}
app={app}
/>
</Stack.Row>
</Island>
{isCollaborating && (
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
{heading}
<Stack.Row gap={1}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher
appState={appState}
activeTool={appState.activeTool}
UIOptions={UIOptions}
app={app}
/>
</Stack.Row>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
)}
</Section>
)}
{isCollaborating && (
<Island
style={{
marginLeft: 8,
alignSelf: "center",
height: "fit-content",
}}
>
<LaserPointerButton
title={t("toolBar.laser")}
checked={
appState.activeTool.type === TOOL_TYPE.laser
}
onChange={() =>
app.setActiveTool({ type: TOOL_TYPE.laser })
}
isMobile
/>
</Island>
)}
</Stack.Row>
</Stack.Col>
</div>
)}
</Section>
)}
<div
className={clsx(
"layer-ui__wrapper__top-right zen-mode-transition",
@ -341,6 +346,7 @@ const LayerUI = ({
)}
{renderTopRightUI?.(device.editor.isMobile, appState)}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
// hide button when sidebar docked
(!isSidebarDocked ||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
@ -471,6 +477,19 @@ const LayerUI = ({
/>
)}
<ActiveConfirmDialog />
{appState.openDialog?.name === "elementLinkSelector" && (
<ElementLinkDialog
sourceElementId={appState.openDialog.sourceElementId}
onClose={() => {
setAppState({
openDialog: null,
});
}}
elementsMap={app.scene.getNonDeletedElementsMap()}
appState={appState}
generateLinkForSelection={generateLinkForSelection}
/>
)}
<tunnels.OverwriteConfirmDialogTunnel.Out />
{renderImageExportDialog()}
{renderJSONExportDialog()}

View file

@ -91,9 +91,10 @@ export const MobileMenu = ({
</Island>
{renderTopRightUI && renderTopRightUI(true, appState)}
<div className="mobile-misc-tools-container">
{!appState.viewModeEnabled && (
<DefaultSidebarTriggerTunnel.Out />
)}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
<DefaultSidebarTriggerTunnel.Out />
)}
<PenModeButton
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
@ -129,7 +130,10 @@ export const MobileMenu = ({
};
const renderAppToolbar = () => {
if (appState.viewModeEnabled) {
if (
appState.viewModeEnabled ||
appState.openDialog?.name === "elementLinkSelector"
) {
return (
<div className="App-toolbar-content">
<MainMenuTunnel.Out />
@ -154,7 +158,9 @@ export const MobileMenu = ({
return (
<>
{renderSidebars()}
{!appState.viewModeEnabled && renderToolbar()}
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
renderToolbar()}
<div
className="App-bottom-bar"
style={{
@ -166,6 +172,7 @@ export const MobileMenu = ({
<Island padding={0}>
{appState.openMenu === "shape" &&
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
showSelectedShapeActions(appState, elements) ? (
<Section className="App-mobile-menu" heading="selectedShapeActions">
<SelectedShapeActions

View file

@ -182,6 +182,7 @@ const getRelevantAppStateProps = (
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
editingGroupId: appState.editingGroupId,
editingLinearElement: appState.editingLinearElement,
selectedElementIds: appState.selectedElementIds,

View file

@ -92,6 +92,8 @@ const getRelevantAppStateProps = (
width: appState.width,
height: appState.height,
viewModeEnabled: appState.viewModeEnabled,
openDialog: appState.openDialog,
hoveredElementIds: appState.hoveredElementIds,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
theme: appState.theme,

View file

@ -13,7 +13,7 @@ import type {
} from "../../element/types";
import { ToolButton } from "../ToolButton";
import { FreedrawIcon, TrashIcon } from "../icons";
import { FreedrawIcon, TrashIcon, elementLinkIcon } from "../icons";
import { t } from "../../i18n";
import {
useCallback,
@ -30,18 +30,19 @@ import { getTooltipDiv, updateTooltipPosition } from "../../components/Tooltip";
import { getSelectedElements } from "../../scene";
import { hitElementBoundingBox } from "../../element/collision";
import { isLocalLink, normalizeLink } from "../../data/url";
import "./Hyperlink.scss";
import { trackEvent } from "../../analytics";
import { useAppProps, useExcalidrawAppState } from "../App";
import { useAppProps, useDevice, useExcalidrawAppState } from "../App";
import { isEmbeddableElement } from "../../element/typeChecks";
import { getLinkHandleFromCoords } from "./helpers";
import { pointFrom, type GlobalPoint } from "../../../math";
import { isElementLink } from "../../element/elementLink";
const CONTAINER_WIDTH = 320;
import "./Hyperlink.scss";
const POPUP_WIDTH = 380;
const POPUP_HEIGHT = 42;
const POPUP_PADDING = 5;
const SPACE_BOTTOM = 85;
const CONTAINER_PADDING = 5;
const CONTAINER_HEIGHT = 42;
const AUTO_HIDE_TIMEOUT = 500;
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
@ -73,6 +74,7 @@ export const Hyperlink = ({
}) => {
const appState = useExcalidrawAppState();
const appProps = useAppProps();
const device = useDevice();
const linkVal = element.link || "";
@ -170,6 +172,15 @@ export const Hyperlink = ({
useEffect(() => {
let timeoutId: number | null = null;
if (
inputRef &&
inputRef.current &&
!(device.viewport.isMobile || device.isTouchScreen)
) {
inputRef.current.select();
}
const handlePointerMove = (event: PointerEvent) => {
if (isEditing) {
return;
@ -196,16 +207,21 @@ export const Hyperlink = ({
clearTimeout(timeoutId);
}
};
}, [appState, element, isEditing, setAppState, elementsMap]);
}, [
appState,
element,
isEditing,
setAppState,
elementsMap,
device.viewport.isMobile,
device.isTouchScreen,
]);
const handleRemove = useCallback(() => {
trackEvent("hyperlink", "delete");
mutateElement(element, { link: null });
if (isEditing) {
inputRef.current!.value = "";
}
setAppState({ showHyperlinkPopup: false });
}, [setAppState, element, isEditing]);
}, [setAppState, element]);
const onEdit = () => {
trackEvent("hyperlink", "edit", "popup-ui");
@ -229,19 +245,14 @@ export const Hyperlink = ({
style={{
top: `${y}px`,
left: `${x}px`,
width: CONTAINER_WIDTH,
padding: CONTAINER_PADDING,
}}
onClick={() => {
if (!element.link && !isEditing) {
setAppState({ showHyperlinkPopup: "editor" });
}
width: POPUP_WIDTH,
padding: POPUP_PADDING,
}}
>
{isEditing ? (
<input
className={clsx("excalidraw-hyperlinkContainer-input")}
placeholder="Type or paste your link here"
placeholder={t("labels.link.hint")}
ref={inputRef}
value={inputVal}
onChange={(event) => setInputVal(event.target.value)}
@ -302,6 +313,21 @@ export const Hyperlink = ({
icon={FreedrawIcon}
/>
)}
<ToolButton
type="button"
title={t("labels.linkToElement")}
aria-label={t("labels.linkToElement")}
label={t("labels.linkToElement")}
onClick={() => {
setAppState({
openDialog: {
name: "elementLinkSelector",
sourceElementId: element.id,
},
});
}}
icon={elementLinkIcon}
/>
{linkVal && !isEmbeddableElement(element) && (
<ToolButton
type="button"
@ -328,7 +354,7 @@ const getCoordsForPopover = (
{ sceneX: x1 + element.width / 2, sceneY: y1 },
appState,
);
const x = viewportX - appState.offsetLeft - CONTAINER_WIDTH / 2;
const x = viewportX - appState.offsetLeft - POPUP_WIDTH / 2;
const y = viewportY - appState.offsetTop - SPACE_BOTTOM;
return { x, y };
};
@ -338,12 +364,10 @@ export const getContextMenuLabel = (
appState: UIAppState,
) => {
const selectedElements = getSelectedElements(elements, appState);
const label = selectedElements[0]?.link
? isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: "labels.link.edit"
: isEmbeddableElement(selectedElements[0])
? "labels.link.createEmbed"
const label = isEmbeddableElement(selectedElements[0])
? "labels.link.editEmbed"
: selectedElements[0]?.link
? "labels.link.edit"
: "labels.link.create";
return label;
};
@ -376,7 +400,9 @@ const renderTooltip = (
tooltipDiv.classList.add("excalidraw-tooltip--visible");
tooltipDiv.style.maxWidth = "20rem";
tooltipDiv.textContent = element.link;
tooltipDiv.textContent = isElementLink(element.link)
? t("labels.link.goToElement")
: element.link;
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap);
@ -450,9 +476,9 @@ const shouldHideLinkPopup = (
if (
clientX >= popoverX - threshold &&
clientX <= popoverX + CONTAINER_WIDTH + CONTAINER_PADDING * 2 + threshold &&
clientX <= popoverX + POPUP_WIDTH + POPUP_PADDING * 2 + threshold &&
clientY >= popoverY - threshold &&
clientY <= popoverY + threshold + CONTAINER_PADDING * 2 + CONTAINER_HEIGHT
clientY <= popoverY + threshold + POPUP_PADDING * 2 + POPUP_HEIGHT
) {
return false;
}

View file

@ -16,6 +16,11 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-external-link"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`,
)}`;
export const ELEMENT_LINK_IMG = document.createElement("img");
ELEMENT_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1971c2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-arrow-big-right-line"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 9v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-6v-6h6z" /><path d="M3 9v6" /></svg>`,
)}`;
export const getLinkHandleFromCoords = (
[x1, y1, x2, y2]: Bounds,
angle: Radians,

View file

@ -2156,3 +2156,18 @@ export const cropIcon = createIcon(
</g>,
tablerIconProps,
);
export const elementLinkIcon = createIcon(
<g>
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M19 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M5 7l0 10" />
<path d="M7 5l10 0" />
<path d="M7 19l10 0" />
<path d="M19 7l0 10" />
</g>,
tablerIconProps,
);

View file

@ -449,3 +449,6 @@ export const ARROW_TYPE: { [T in AppState["currentItemArrowType"]]: T } = {
round: "round",
elbow: "elbow",
};
export const DEFAULT_REDUCED_GLOBAL_ALPHA = 0.3;
export const ELEMENT_LINK_KEY = "element";

View file

@ -0,0 +1,102 @@
/**
* Create and link between shapes.
*/
import { ELEMENT_LINK_KEY } from "../constants";
import { normalizeLink } from "../data/url";
import { elementsAreInSameGroup } from "../groups";
import type { AppProps, AppState } from "../types";
import type { ExcalidrawElement } from "./types";
export const defaultGetElementLinkFromSelection: Exclude<
AppProps["generateLinkForSelection"],
undefined
> = (id, type) => {
const url = window.location.href;
try {
const link = new URL(url);
link.searchParams.set(ELEMENT_LINK_KEY, id);
return normalizeLink(link.toString());
} catch (error) {
console.error(error);
}
return normalizeLink(url);
};
export const getLinkIdAndTypeFromSelection = (
selectedElements: ExcalidrawElement[],
appState: AppState,
): {
id: string;
type: "element" | "group";
} | null => {
if (
selectedElements.length > 0 &&
canCreateLinkFromElements(selectedElements)
) {
if (selectedElements.length === 1) {
return {
id: selectedElements[0].id,
type: "element",
};
}
if (selectedElements.length > 1) {
const selectedGroupId = Object.keys(appState.selectedGroupIds)[0];
if (selectedGroupId) {
return {
id: selectedGroupId,
type: "group",
};
}
return {
id: selectedElements[0].groupIds[0],
type: "group",
};
}
}
return null;
};
export const canCreateLinkFromElements = (
selectedElements: ExcalidrawElement[],
) => {
if (selectedElements.length === 1) {
return true;
}
if (selectedElements.length > 1 && elementsAreInSameGroup(selectedElements)) {
return true;
}
return false;
};
export const isElementLink = (url: string) => {
try {
const _url = new URL(url);
return (
_url.searchParams.has(ELEMENT_LINK_KEY) &&
_url.host === window.location.host
);
} catch (error) {
return false;
}
};
export const parseElementLinkFromURL = (url: string) => {
try {
const { searchParams } = new URL(url);
if (searchParams.has(ELEMENT_LINK_KEY)) {
const id = searchParams.get(ELEMENT_LINK_KEY);
return id;
}
} catch {}
return null;
};

View file

@ -8,6 +8,7 @@ export const showSelectedShapeActions = (
) =>
Boolean(
!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" &&
((appState.activeTool.type !== "custom" &&
(appState.editingTextElement ||
(appState.activeTool.type !== "selection" &&

View file

@ -43,6 +43,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
autoFocus = false,
generateIdForFile,
onLinkOpen,
generateLinkForSelection,
onPointerDown,
onPointerUp,
onScrollChange,
@ -132,6 +133,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
autoFocus={autoFocus}
generateIdForFile={generateIdForFile}
onLinkOpen={onLinkOpen}
generateLinkForSelection={generateLinkForSelection}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onScrollChange={onScrollChange}
@ -291,3 +293,4 @@ export {
export { DiagramToCodePlugin } from "./components/DiagramToCodePlugin/DiagramToCodePlugin";
export { getDataURL } from "./data/blob";
export { isElementLink } from "./element/elementLink";

View file

@ -125,12 +125,13 @@
"createContainerFromText": "Wrap text in a container",
"link": {
"edit": "Edit link",
"editEmbed": "Edit link & embed",
"create": "Create link",
"createEmbed": "Create link & embed",
"editEmbed": "Edit embeddable link",
"create": "Add link",
"label": "Link",
"labelEmbed": "Link & embed",
"empty": "No link is set"
"empty": "No link is set",
"hint": "Type or paste your link here",
"goToElement": "Go to target element"
},
"lineEditor": {
"edit": "Edit line",
@ -155,7 +156,14 @@
"zoomToFitSelection": "Zoom to fit selection",
"zoomToFit": "Zoom to fit all elements",
"installPWA": "Install Excalidraw locally (PWA)",
"autoResize": "Enable text auto-resizing"
"autoResize": "Enable text auto-resizing",
"copyElementLink": "Copy link to object",
"linkToElement": "Link to object"
},
"elementLink": {
"title": "Link to object",
"desc": "Click on a shape on canvas or paste a link.",
"notFound": "Linked object wasn't found on canvas."
},
"library": {
"noItems": "No items added yet...",
@ -501,7 +509,8 @@
"selection": "selection",
"pasteAsSingleElement": "Use {{shortcut}} to paste as a single element,\nor paste into an existing text editor",
"unableToEmbed": "Embedding this url is currently not allowed. Raise an issue on GitHub to request the url whitelisted",
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site"
"unrecognizedLinkFormat": "The link you embedded does not match the expected format. Please try to paste the 'embed' string provided by the source site",
"elementLinkCopied": "Link copied to clipboard"
},
"colors": {
"transparent": "Transparent",

View file

@ -40,6 +40,7 @@ import type {
import { getDefaultAppState } from "../appState";
import {
BOUND_TEXT_PADDING,
DEFAULT_REDUCED_GLOBAL_ALPHA,
ELEMENT_READY_TO_ERASE_OPACITY,
FRAME_STYLE,
MIME_TYPES,
@ -109,10 +110,13 @@ export const getRenderOpacity = (
containingFrame: ExcalidrawFrameLikeElement | null,
elementsPendingErasure: ElementsPendingErasure,
pendingNodes: Readonly<PendingExcalidrawElements> | null,
globalAlpha: number = 1,
) => {
// multiplying frame opacity with element opacity to combine them
// (e.g. frame 50% and element 50% opacity should result in 25% opacity)
let opacity = ((containingFrame?.opacity ?? 100) * element.opacity) / 10000;
let opacity =
(((containingFrame?.opacity ?? 100) * element.opacity) / 10000) *
globalAlpha;
// if pending erasure, multiply again to combine further
// (so that erasing always results in lower opacity than original)
@ -700,11 +704,17 @@ export const renderElement = (
renderConfig: StaticCanvasRenderConfig,
appState: StaticCanvasAppState,
) => {
const reduceAlphaForSelection =
appState.openDialog?.name === "elementLinkSelector" &&
!appState.selectedElementIds[element.id] &&
!appState.hoveredElementIds[element.id];
context.globalAlpha = getRenderOpacity(
element,
getContainingFrame(element, elementsMap),
renderConfig.elementsPendingErasure,
renderConfig.pendingFlowchartNodes,
reduceAlphaForSelection ? DEFAULT_REDUCED_GLOBAL_ALPHA : 1,
);
switch (element.type) {

View file

@ -25,11 +25,13 @@ import type {
} from "../scene/types";
import {
EXTERNAL_LINK_IMG,
ELEMENT_LINK_IMG,
getLinkHandleFromCoords,
} from "../components/hyperlink/helpers";
import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers";
import { throttleRAF } from "../utils";
import { getBoundTextElement } from "../element/textElement";
import { isElementLink } from "../element/elementLink";
const GridLineColor = {
Bold: "#dddddd",
@ -133,7 +135,16 @@ const frameClip = (
);
};
let linkCanvasCache: any;
type LinkIconCanvas = HTMLCanvasElement & { zoom: number };
const linkIconCanvasCache: {
regularLink: LinkIconCanvas | null;
elementLink: LinkIconCanvas | null;
} = {
regularLink: null,
elementLink: null,
};
const renderLinkIcon = (
element: NonDeletedExcalidrawElement,
context: CanvasRenderingContext2D,
@ -153,38 +164,44 @@ const renderLinkIcon = (
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 =
const canvasKey = isElementLink(element.link)
? "elementLink"
: "regularLink";
let linkCanvas = linkIconCanvasCache[canvasKey];
if (!linkCanvas || linkCanvas.zoom !== appState.zoom.value) {
linkCanvas = Object.assign(document.createElement("canvas"), {
zoom: appState.zoom.value,
});
linkCanvas.width = width * window.devicePixelRatio * appState.zoom.value;
linkCanvas.height =
height * window.devicePixelRatio * appState.zoom.value;
const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!;
linkIconCanvasCache[canvasKey] = linkCanvas;
const linkCanvasCacheContext = linkCanvas.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);
if (canvasKey === "elementLink") {
linkCanvasCacheContext.drawImage(ELEMENT_LINK_IMG, 0, 0, width, height);
} else {
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.drawImage(linkCanvas, x - centerX, y - centerY, width, height);
context.restore();
}
};

View file

@ -25,6 +25,7 @@ import {
import { arrayToMap } from "../utils";
import { toBrandedType } from "../utils";
import { ENV } from "../constants";
import { getElementsInGroup } from "../groups";
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
type ElementKey = ExcalidrawElement | ElementIdKey;
@ -437,6 +438,18 @@ class Scene {
}
return null;
};
getElementsFromId = (id: string): ExcalidrawElement[] => {
const elementsMap = this.getNonDeletedElementsMap();
// first check if the id is an element
const el = elementsMap.get(id);
if (el) {
return [el];
}
// then, check if the id is a group
return getElementsInGroup(elementsMap, id);
};
}
export default Scene;

View file

@ -728,6 +728,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -762,6 +763,42 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -880,6 +917,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1088,6 +1126,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1306,6 +1345,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1639,6 +1679,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1972,6 +2013,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2190,6 +2232,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2432,6 +2475,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2735,6 +2779,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3106,6 +3151,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3583,6 +3629,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3908,6 +3955,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4233,6 +4281,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -5313,6 +5362,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -5347,6 +5397,42 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -5465,6 +5551,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -6486,6 +6573,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -6520,6 +6608,42 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -6638,6 +6762,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -7575,6 +7700,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8381,6 +8507,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -8415,6 +8542,42 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -8533,6 +8696,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9321,6 +9485,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -9355,6 +9520,42 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"category": "hyperlink",
},
},
{
"icon": <svg
aria-hidden="true"
className=""
fill="none"
focusable="false"
role="img"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
>
<React.Fragment>
<path
d="M0 0h24v24H0z"
fill="none"
stroke="none"
/>
<path
d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"
/>
<path
d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"
/>
</React.Fragment>
</svg>,
"label": "labels.copyElementLink",
"name": "copyElementLink",
"perform": [Function],
"predicate": [Function],
"trackEvent": {
"category": "element",
},
},
"separator",
{
"PanelComponent": [Function],
"icon": <svg
@ -9473,6 +9674,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"gridSize": 20,
"gridStep": 5,
"height": 100,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,

View file

@ -53,6 +53,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -657,6 +658,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1165,6 +1167,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1535,6 +1538,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1906,6 +1910,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2175,6 +2180,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2617,6 +2623,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2918,6 +2925,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3204,6 +3212,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3500,6 +3509,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3788,6 +3798,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4025,6 +4036,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4286,6 +4298,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4561,6 +4574,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4794,6 +4808,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -5027,6 +5042,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -5258,6 +5274,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -5489,6 +5506,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -5750,6 +5768,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -6083,6 +6102,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -6510,6 +6530,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -6890,6 +6911,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -7211,6 +7233,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -7511,6 +7534,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -7742,6 +7766,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8099,6 +8124,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8456,6 +8482,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8862,6 +8889,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9151,6 +9179,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9418,6 +9447,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9684,6 +9714,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9917,6 +9948,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -10220,6 +10252,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -10562,6 +10595,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -10799,6 +10833,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -11254,6 +11289,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -11510,6 +11546,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -11751,6 +11788,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -11994,6 +12032,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -12397,6 +12436,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -12646,6 +12686,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -12889,6 +12930,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -13132,6 +13174,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -13381,6 +13424,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -13715,6 +13759,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -13889,6 +13934,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -14179,6 +14225,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -14448,6 +14495,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -14725,6 +14773,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -14888,6 +14937,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -15586,6 +15636,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -16208,6 +16259,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -16830,6 +16882,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -17544,6 +17597,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -18296,6 +18350,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -18772,6 +18827,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -19296,6 +19352,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -19754,6 +19811,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
"gridSize": 20,
"gridStep": 5,
"height": 0,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,

View file

@ -53,6 +53,7 @@ exports[`given element A and group of elements B and given both are selected whe
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -467,6 +468,7 @@ exports[`given element A and group of elements B and given both are selected whe
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -872,6 +874,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": false,
"isCropping": false,
"isLoading": false,
@ -1416,6 +1419,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1619,6 +1623,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -1993,6 +1998,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2232,6 +2238,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2411,6 +2418,7 @@ exports[`regression tests > can drag element that covers another element, while
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2730,6 +2738,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -2975,6 +2984,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3217,6 +3227,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3446,6 +3457,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -3701,6 +3713,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4011,6 +4024,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4424,6 +4438,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4706,6 +4721,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -4958,6 +4974,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -5167,6 +5184,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -5365,6 +5383,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -5746,6 +5765,7 @@ exports[`regression tests > drags selected elements from point inside common bou
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -6035,6 +6055,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -6842,6 +6863,7 @@ exports[`regression tests > given a group of selected elements with an element t
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -7171,6 +7193,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -7446,6 +7469,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -7679,6 +7703,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -7915,6 +7940,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8094,6 +8120,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8273,6 +8300,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8452,6 +8480,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8674,6 +8703,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -8895,6 +8925,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9088,6 +9119,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9310,6 +9342,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9489,6 +9522,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9710,6 +9744,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -9889,6 +9924,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -10082,6 +10118,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -10261,6 +10298,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -10774,6 +10812,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -11050,6 +11089,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -11175,6 +11215,7 @@ exports[`regression tests > shift click on selected element should deselect it o
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -11373,6 +11414,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -11683,6 +11725,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -12094,6 +12137,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -12706,6 +12750,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -12834,6 +12879,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -13417,6 +13463,7 @@ exports[`regression tests > switches from group of selected elements to another
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -13754,6 +13801,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -14018,6 +14066,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -14143,6 +14192,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -14521,6 +14571,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,
@ -14646,6 +14697,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
"gridSize": 20,
"gridStep": 5,
"height": 768,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,

View file

@ -22,6 +22,7 @@ import { copiedStyles } from "../actions/actionStyles";
import { API } from "./helpers/api";
import { setDateTimeForTests } from "../utils";
import { vi } from "vitest";
import type { ActionName } from "../actions/types";
const checkpoint = (name: string) => {
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
@ -115,7 +116,7 @@ describe("contextMenu element", () => {
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
const expectedContextMenuItems: ActionName[] = [
"cut",
"copy",
"paste",
@ -131,14 +132,15 @@ describe("contextMenu element", () => {
"bringToFront",
"duplicateSelection",
"hyperlink",
"copyElementLink",
"toggleElementLock",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length);
expectedContextMenuItems.forEach((item) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
contextMenu?.querySelector(`li[data-testid="${item}"]`),
).not.toBeNull();
});
});
@ -263,13 +265,14 @@ describe("contextMenu element", () => {
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [
const expectedContextMenuItems: ActionName[] = [
"cut",
"copy",
"paste",
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"copyElementLink",
"ungroup",
"addToLibrary",
"flipHorizontal",
@ -283,10 +286,10 @@ describe("contextMenu element", () => {
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(contextMenuOptions?.length).toBe(expectedContextMenuItems.length);
expectedContextMenuItems.forEach((item) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
contextMenu?.querySelector(`li[data-testid="${item}"]`),
).not.toBeNull();
});
});

View file

@ -161,6 +161,7 @@ type _CommonCanvasAppState = {
width: AppState["width"];
height: AppState["height"];
viewModeEnabled: AppState["viewModeEnabled"];
openDialog: AppState["openDialog"];
editingGroupId: AppState["editingGroupId"]; // TODO: move to interactive canvas if possible
selectedElementIds: AppState["selectedElementIds"]; // TODO: move to interactive canvas if possible
frameToHighlight: AppState["frameToHighlight"]; // TODO: move to interactive canvas if possible
@ -181,6 +182,7 @@ export type StaticCanvasAppState = Readonly<
gridStep: AppState["gridStep"];
frameRendering: AppState["frameRendering"];
currentHoveredFontFamily: AppState["currentHoveredFontFamily"];
hoveredElementIds: AppState["hoveredElementIds"];
// Cropping
croppingElementId: AppState["croppingElementId"];
}
@ -332,7 +334,9 @@ export interface AppState {
| null
| { name: "imageExport" | "help" | "jsonExport" }
| { name: "ttd"; tab: "text-to-diagram" | "mermaid" }
| { name: "commandPalette" };
| { name: "commandPalette" }
| { name: "elementLinkSelector"; sourceElementId: ExcalidrawElement["id"] };
/**
* Reflects user preference for whether the default sidebar should be docked.
*
@ -344,6 +348,7 @@ export interface AppState {
lastPointerDownWith: PointerType;
selectedElementIds: Readonly<{ [id: string]: true }>;
hoveredElementIds: Readonly<{ [id: string]: true }>;
previousSelectedElementIds: { [id: string]: true };
selectedElementsAreBeingDragged: boolean;
shouldCacheIgnoreZoom: boolean;
@ -530,6 +535,7 @@ export interface ExcalidrawProps {
onLibraryChange?: (libraryItems: LibraryItems) => void | Promise<any>;
autoFocus?: boolean;
generateIdForFile?: (file: File) => string | Promise<string>;
generateLinkForSelection?: (id: string, type: "element" | "group") => string;
onLinkOpen?: (
element: NonDeletedExcalidrawElement,
event: CustomEvent<{

View file

@ -53,6 +53,7 @@ exports[`exportToSvg > with default arguments 1`] = `
"gridModeEnabled": false,
"gridSize": 20,
"gridStep": 5,
"hoveredElementIds": {},
"isBindingEnabled": true,
"isCropping": false,
"isLoading": false,