feat(editor): allow laser-pointing in view mode (#10802)

* feat(editor): allow laser pointing in view mode

* feat: allow switching between laser/hand in view mode

* fix lint

* factor out to utils

* fix: only handle primary clicks with the selection/laser tools
This commit is contained in:
David Luzar
2026-02-20 22:49:46 +01:00
committed by GitHub
parent 4c3d037f9c
commit eb959128ac
12 changed files with 287 additions and 147 deletions
+10 -1
View File
@@ -1,5 +1,7 @@
import { average } from "@excalidraw/math"; import { average } from "@excalidraw/math";
import type { GlobalCoord } from "@excalidraw/math";
import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type { FontFamilyValues, FontString } from "@excalidraw/element/types";
import type { import type {
@@ -441,7 +443,7 @@ export const viewportCoordsToSceneCoords = (
const x = (clientX - offsetLeft) / zoom.value - scrollX; const x = (clientX - offsetLeft) / zoom.value - scrollX;
const y = (clientY - offsetTop) / zoom.value - scrollY; const y = (clientY - offsetTop) / zoom.value - scrollY;
return { x, y }; return { x, y } as GlobalCoord;
}; };
export const sceneCoordsToViewportCoords = ( export const sceneCoordsToViewportCoords = (
@@ -1330,3 +1332,10 @@ export const setFeatureFlag = <F extends keyof FEATURE_FLAGS>(
console.error("unable to set feature flag", e); console.error("unable to set feature flag", e);
} }
}; };
export const oneOf = <N extends string | number | symbol | null, H extends N>(
needle: N,
haystack: readonly H[],
): needle is H => {
return haystack.includes(needle as any);
};
+6 -2
View File
@@ -1081,8 +1081,9 @@ export const ShapesSwitcher = ({
return ( return (
<> <>
{getToolbarTools(app).map( {getToolbarTools(app).map(
({ value, icon, key, numericKey, fillable }, index) => { ({ value, icon, key, numericKey, fillable, toolbar }) => {
if ( if (
toolbar === false ||
UIOptions.tools?.[ UIOptions.tools?.[
value as Extract< value as Extract<
typeof value, typeof value,
@@ -1099,6 +1100,9 @@ export const ShapesSwitcher = ({
const shortcut = letter const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}` ? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`; : `${numericKey}`;
const keybindingLabel =
value === "hand" ? undefined : numericKey || letter;
// when in compact styles panel mode (tablet) // when in compact styles panel mode (tablet)
// use a ToolPopover for selection/lasso toggle as well // use a ToolPopover for selection/lasso toggle as well
if ( if (
@@ -1143,7 +1147,7 @@ export const ShapesSwitcher = ({
checked={activeTool.type === value} checked={activeTool.type === value}
name="editor-current-shape" name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`} title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter} keyBindingLabel={keybindingLabel}
aria-label={capitalizeString(label)} aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut} aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`} data-testid={`toolbar-${value}`}
+170 -128
View File
@@ -108,6 +108,7 @@ import {
loadDesktopUIModePreference, loadDesktopUIModePreference,
setDesktopUIMode, setDesktopUIMode,
isSelectionLikeTool, isSelectionLikeTool,
oneOf,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
@@ -1219,12 +1220,14 @@ class App extends React.Component<AppProps, AppState> {
if ( if (
hitElement && hitElement &&
isIframeLikeElement(hitElement) && isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter( (this.state.viewModeEnabled ||
hitElement, this.state.activeTool.type === "laser" ||
moveEvent, this.isIframeLikeElementCenter(
scenePointer.x, hitElement,
scenePointer.y, moveEvent,
) scenePointer.x,
scenePointer.y,
))
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({ this.setState({
@@ -1239,61 +1242,72 @@ class App extends React.Component<AppProps, AppState> {
/** @returns true if iframe-like element click handled */ /** @returns true if iframe-like element click handled */
private handleIframeLikeCenterClick(): boolean { private handleIframeLikeCenterClick(): boolean {
if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) {
return false;
}
const scenePointerStart = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
);
const scenePointerEnd = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
);
const hitElementStart = this.getElementAtPosition(
scenePointerStart.x,
scenePointerStart.y,
);
const hitElementEnd = this.getElementAtPosition(
scenePointerEnd.x,
scenePointerEnd.y,
);
if ( if (
!hitElementStart || !this.lastPointerDownEvent ||
!hitElementEnd || !this.lastPointerUpEvent ||
hitElementStart !== hitElementEnd || // middle-click or something other than primary
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp > this.lastPointerDownEvent.button !== POINTER_BUTTON.MAIN ||
300 || // panning
gesture.pointers.size > 1 || isHoldingSpace ||
!isIframeLikeElement(hitElementStart) || // wrong tool
!isIframeLikeElement(hitElementEnd) || !oneOf(this.state.activeTool.type, ["laser", "selection", "lasso"])
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
) { ) {
return false; return false;
} }
const iframeLikeElement = hitElementEnd; const viewportClickStart_scenePoint = pointFrom(
viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
),
);
const viewportClickEnd_scenePoint = pointFrom(
viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
),
);
const draggedDistance = pointDistance(
viewportClickStart_scenePoint,
viewportClickEnd_scenePoint,
);
if (draggedDistance > DRAGGING_THRESHOLD) {
return false;
}
const hitElement = this.getElementAtPosition(
viewportClickStart_scenePoint[0],
viewportClickStart_scenePoint[1],
);
const shouldActivate =
hitElement &&
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp <=
300 &&
gesture.pointers.size < 2 &&
isIframeLikeElement(hitElement) &&
(this.state.viewModeEnabled ||
this.state.activeTool.type === "laser" ||
this.isIframeLikeElementCenter(
hitElement,
this.lastPointerUpEvent,
viewportClickEnd_scenePoint[0],
viewportClickEnd_scenePoint[1],
));
if (!shouldActivate) {
return false;
}
const iframeLikeElement = hitElement;
if ( if (
this.state.activeEmbeddable?.element === iframeLikeElement && this.state.activeEmbeddable?.element === iframeLikeElement &&
@@ -4844,6 +4858,74 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
// view mode hardcoded from upstream -> disable tool switching for now
const shouldPreventToolSwitching = this.props.viewModeEnabled === true;
if (
!shouldPreventToolSwitching &&
this.state.viewModeEnabled &&
event.key === KEYS.ESCAPE
) {
this.setActiveTool({ type: "hand" });
return;
}
if (
!shouldPreventToolSwitching &&
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
!this.state.newElement &&
!this.state.selectionElement &&
!this.state.selectedElementsAreBeingDragged
) {
const shape = findShapeByKey(event.key, this);
if (this.state.viewModeEnabled && !oneOf(shape, ["laser", "hand"])) {
return;
}
if (shape) {
if (this.state.activeTool.type !== shape) {
trackEvent(
"toolbar",
shape,
`keyboard (${
this.editorInterface.formFactor === "phone"
? "mobile"
: "desktop"
})`,
);
}
if (shape === "arrow" && this.state.activeTool.type === "arrow") {
this.setState((prevState) => ({
currentItemArrowType:
prevState.currentItemArrowType === ARROW_TYPE.sharp
? ARROW_TYPE.round
: prevState.currentItemArrowType === ARROW_TYPE.round
? ARROW_TYPE.elbow
: ARROW_TYPE.sharp,
}));
}
if (shape === "lasso" && this.state.activeTool.type === "laser") {
this.setActiveTool({
type: this.state.preferredSelectionTool.type,
});
} else {
this.setActiveTool({ type: shape });
}
event.stopPropagation();
return;
} else if (event.key === KEYS.Q) {
this.toggleLock("keyboard");
event.stopPropagation();
return;
}
}
if (this.state.viewModeEnabled) { if (this.state.viewModeEnabled) {
return; return;
} }
@@ -4977,44 +5059,8 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
} }
} else if (
!event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
!this.state.newElement &&
!this.state.selectionElement &&
!this.state.selectedElementsAreBeingDragged
) {
const shape = findShapeByKey(event.key, this);
if (shape) {
if (this.state.activeTool.type !== shape) {
trackEvent(
"toolbar",
shape,
`keyboard (${
this.editorInterface.formFactor === "phone"
? "mobile"
: "desktop"
})`,
);
}
if (shape === "arrow" && this.state.activeTool.type === "arrow") {
this.setState((prevState) => ({
currentItemArrowType:
prevState.currentItemArrowType === ARROW_TYPE.sharp
? ARROW_TYPE.round
: prevState.currentItemArrowType === ARROW_TYPE.round
? ARROW_TYPE.elbow
: ARROW_TYPE.sharp,
}));
}
this.setActiveTool({ type: shape });
event.stopPropagation();
} else if (event.key === KEYS.Q) {
this.toggleLock("keyboard");
event.stopPropagation();
}
} }
if (event.key === KEYS.SPACE && gesture.pointers.size === 0) { if (event.key === KEYS.SPACE && gesture.pointers.size === 0) {
isHoldingSpace = true; isHoldingSpace = true;
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
@@ -5078,15 +5124,6 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
if (this.state.activeTool.type === "laser") {
this.setActiveTool({ type: this.state.preferredSelectionTool.type });
} else {
this.setActiveTool({ type: "laser" });
}
return;
}
if ( if (
event[KEYS.CTRL_OR_CMD] && event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@@ -5113,7 +5150,8 @@ class App extends React.Component<AppProps, AppState> {
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => { private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) { if (event.key === KEYS.SPACE) {
if ( if (
this.state.viewModeEnabled || (this.state.viewModeEnabled &&
this.state.activeTool.type !== "laser") ||
this.state.openDialog?.name === "elementLinkSelector" this.state.openDialog?.name === "elementLinkSelector"
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
@@ -6227,9 +6265,8 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private redirectToLink = ( private handleElementLinkClick = (
event: React.PointerEvent<HTMLCanvasElement>, event: React.PointerEvent<HTMLCanvasElement>,
isTouchScreen: boolean,
) => { ) => {
const draggedDistance = pointDistance( const draggedDistance = pointDistance(
pointFrom( pointFrom(
@@ -6803,6 +6840,10 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
if (isEraserActive(this.state)) {
return;
}
const hitElementMightBeLocked = this.getElementAtPosition( const hitElementMightBeLocked = this.getElementAtPosition(
scenePointerX, scenePointerX,
scenePointerY, scenePointerY,
@@ -6819,18 +6860,25 @@ class App extends React.Component<AppProps, AppState> {
hitElement = hitElementMightBeLocked; hitElement = hitElementMightBeLocked;
} }
this.hitLinkElement = this.getElementLinkAtPosition( if (
scenePointer, !this.handleIframeLikeElementHover({
hitElementMightBeLocked, hitElement,
); scenePointer,
if (isEraserActive(this.state)) { moveEvent: event,
return; })
) {
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElementMightBeLocked,
);
} }
if ( if (
this.hitLinkElement && this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id] !this.state.selectedElementIds[this.hitLinkElement.id]
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
showHyperlinkTooltip( showHyperlinkTooltip(
this.hitLinkElement, this.hitLinkElement,
this.state, this.state,
@@ -6839,11 +6887,6 @@ class App extends React.Component<AppProps, AppState> {
} else { } else {
hideHyperlinkToolip(); hideHyperlinkToolip();
if (isLaserTool) { if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return; return;
} }
if ( if (
@@ -6878,15 +6921,10 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked !hitElement?.locked
) { ) {
if ( if (
!this.handleIframeLikeElementHover({ !hitElement ||
hitElement, // Elbow arrows can only be moved when unconnected
scenePointer, !isElbowArrow(hitElement) ||
moveEvent: event, !(hitElement.startBinding || hitElement.endBinding)
}) &&
(!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding))
) { ) {
if ( if (
this.state.activeTool.type !== "lasso" || this.state.activeTool.type !== "lasso" ||
@@ -7568,7 +7606,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement && this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id] !this.state.selectedElementIds[this.hitLinkElement.id]
) { ) {
this.redirectToLink(event, this.editorInterface.isTouchScreen); this.handleElementLinkClick(event);
} else if (this.state.viewModeEnabled) { } else if (this.state.viewModeEnabled) {
this.setState({ this.setState({
activeEmbeddable: null, activeEmbeddable: null,
@@ -7628,7 +7666,8 @@ class App extends React.Component<AppProps, AppState> {
(event.button === POINTER_BUTTON.WHEEL || (event.button === POINTER_BUTTON.WHEEL ||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) || (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
isHandToolActive(this.state) || isHandToolActive(this.state) ||
this.state.viewModeEnabled) (this.state.viewModeEnabled &&
this.state.activeTool.type !== "laser"))
) )
) { ) {
return false; return false;
@@ -7706,7 +7745,10 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUp = null; lastPointerUp = null;
isPanning = false; isPanning = false;
if (!isHoldingSpace) { if (!isHoldingSpace) {
if (this.state.viewModeEnabled) { if (
this.state.viewModeEnabled &&
this.state.activeTool.type !== "laser"
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else { } else {
setCursorForShape(this.interactiveCanvas, this.state); setCursorForShape(this.interactiveCanvas, this.state);
@@ -15,7 +15,7 @@ export type CommandPaletteItem = {
category: string; category: string;
order?: number; order?: number;
predicate?: boolean | Action["predicate"]; predicate?: boolean | Action["predicate"];
shortcut?: string; shortcut?: string | null;
/** if false, command will not show while in view mode */ /** if false, command will not show while in view mode */
viewMode?: boolean; viewMode?: boolean;
perform: (data: { perform: (data: {
@@ -20,7 +20,6 @@ import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
import { actionToggleStats } from "../actions"; import { actionToggleStats } from "../actions";
import { trackEvent } from "../analytics"; import { trackEvent } from "../analytics";
import { isHandToolActive } from "../appState";
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
import { UIAppStateContext } from "../context/ui-appState"; import { UIAppStateContext } from "../context/ui-appState";
import { useAtom, useAtomValue } from "../editor-jotai"; import { useAtom, useAtomValue } from "../editor-jotai";
@@ -55,7 +54,6 @@ import ElementLinkDialog from "./ElementLinkDialog";
import { ErrorDialog } from "./ErrorDialog"; import { ErrorDialog } from "./ErrorDialog";
import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper";
import { FixedSideContainer } from "./FixedSideContainer"; import { FixedSideContainer } from "./FixedSideContainer";
import { HandButton } from "./HandButton";
import { HelpDialog } from "./HelpDialog"; import { HelpDialog } from "./HelpDialog";
import { HintViewer } from "./HintViewer"; import { HintViewer } from "./HintViewer";
import { ImageExportDialog } from "./ImageExportDialog"; import { ImageExportDialog } from "./ImageExportDialog";
@@ -359,13 +357,6 @@ const LayerUI = ({
<div className="App-toolbar__divider" /> <div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}
onChange={() => onHandToolToggle()}
title={t("toolBar.hand")}
isMobile
/>
<ShapesSwitcher <ShapesSwitcher
setAppState={setAppState} setAppState={setAppState}
activeTool={appState.activeTool} activeTool={appState.activeTool}
@@ -202,9 +202,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
style={{ style={{
width: props.appState.width, width: props.appState.width,
height: props.appState.height, height: props.appState.height,
cursor: props.appState.viewModeEnabled cursor:
? CURSOR_TYPE.GRAB props.appState.viewModeEnabled &&
: CURSOR_TYPE.AUTO, props.appState.activeTool.type !== "laser"
? CURSOR_TYPE.GRAB
: CURSOR_TYPE.AUTO,
}} }}
width={props.appState.width * props.scale} width={props.appState.width * props.scale}
height={props.appState.height * props.scale} height={props.appState.height * props.scale}
@@ -233,6 +235,7 @@ const getRelevantAppStateProps = (
width: appState.width, width: appState.width,
height: appState.height, height: appState.height,
viewModeEnabled: appState.viewModeEnabled, viewModeEnabled: appState.viewModeEnabled,
activeTool: appState.activeTool,
openDialog: appState.openDialog, openDialog: appState.openDialog,
editingGroupId: appState.editingGroupId, editingGroupId: appState.editingGroupId,
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
+29
View File
@@ -11,17 +11,28 @@ import {
TextIcon, TextIcon,
ImageIcon, ImageIcon,
EraserIcon, EraserIcon,
laserPointerToolIcon,
handIcon,
} from "./icons"; } from "./icons";
import type { AppClassProperties } from "../types"; import type { AppClassProperties } from "../types";
export const SHAPES = [ export const SHAPES = [
{
icon: handIcon,
value: "hand",
key: KEYS.H,
numericKey: null,
fillable: false,
toolbar: true,
},
{ {
icon: SelectionIcon, icon: SelectionIcon,
value: "selection", value: "selection",
key: KEYS.V, key: KEYS.V,
numericKey: KEYS["1"], numericKey: KEYS["1"],
fillable: true, fillable: true,
toolbar: true,
}, },
{ {
icon: RectangleIcon, icon: RectangleIcon,
@@ -29,6 +40,7 @@ export const SHAPES = [
key: KEYS.R, key: KEYS.R,
numericKey: KEYS["2"], numericKey: KEYS["2"],
fillable: true, fillable: true,
toolbar: true,
}, },
{ {
icon: DiamondIcon, icon: DiamondIcon,
@@ -36,6 +48,7 @@ export const SHAPES = [
key: KEYS.D, key: KEYS.D,
numericKey: KEYS["3"], numericKey: KEYS["3"],
fillable: true, fillable: true,
toolbar: true,
}, },
{ {
icon: EllipseIcon, icon: EllipseIcon,
@@ -43,6 +56,7 @@ export const SHAPES = [
key: KEYS.O, key: KEYS.O,
numericKey: KEYS["4"], numericKey: KEYS["4"],
fillable: true, fillable: true,
toolbar: true,
}, },
{ {
icon: ArrowIcon, icon: ArrowIcon,
@@ -50,6 +64,7 @@ export const SHAPES = [
key: KEYS.A, key: KEYS.A,
numericKey: KEYS["5"], numericKey: KEYS["5"],
fillable: true, fillable: true,
toolbar: true,
}, },
{ {
icon: LineIcon, icon: LineIcon,
@@ -57,6 +72,7 @@ export const SHAPES = [
key: KEYS.L, key: KEYS.L,
numericKey: KEYS["6"], numericKey: KEYS["6"],
fillable: true, fillable: true,
toolbar: true,
}, },
{ {
icon: FreedrawIcon, icon: FreedrawIcon,
@@ -64,6 +80,7 @@ export const SHAPES = [
key: [KEYS.P, KEYS.X], key: [KEYS.P, KEYS.X],
numericKey: KEYS["7"], numericKey: KEYS["7"],
fillable: false, fillable: false,
toolbar: true,
}, },
{ {
icon: TextIcon, icon: TextIcon,
@@ -71,6 +88,7 @@ export const SHAPES = [
key: KEYS.T, key: KEYS.T,
numericKey: KEYS["8"], numericKey: KEYS["8"],
fillable: false, fillable: false,
toolbar: true,
}, },
{ {
icon: ImageIcon, icon: ImageIcon,
@@ -78,6 +96,7 @@ export const SHAPES = [
key: null, key: null,
numericKey: KEYS["9"], numericKey: KEYS["9"],
fillable: false, fillable: false,
toolbar: true,
}, },
{ {
icon: EraserIcon, icon: EraserIcon,
@@ -85,6 +104,15 @@ export const SHAPES = [
key: KEYS.E, key: KEYS.E,
numericKey: KEYS["0"], numericKey: KEYS["0"],
fillable: false, fillable: false,
toolbar: true,
},
{
icon: laserPointerToolIcon,
value: "laser",
key: KEYS.K,
numericKey: null,
fillable: false,
toolbar: false,
}, },
] as const; ] as const;
@@ -97,6 +125,7 @@ export const getToolbarTools = (app: AppClassProperties) => {
key: KEYS.V, key: KEYS.V,
numericKey: KEYS["1"], numericKey: KEYS["1"],
fillable: true, fillable: true,
toolbar: true,
}, },
...SHAPES.slice(1), ...SHAPES.slice(1),
] as const) ] as const)
+22
View File
@@ -106,4 +106,26 @@ describe("laser tool interactions", () => {
handleIframeLikeCenterClickSpy.mockRestore(); handleIframeLikeCenterClickSpy.mockRestore();
}); });
it("doesn't pan in view mode when laser tool is active", async () => {
await render(<Excalidraw />);
API.setAppState({ viewModeEnabled: true });
act(() => {
h.app.setActiveTool({ type: "laser" });
});
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("");
const initialScrollX = h.state.scrollX;
const initialScrollY = h.state.scrollY;
mouse.downAt(100, 100);
mouse.moveTo(180, 160);
mouse.upAt(180, 160);
expect(h.state.scrollX).toBe(initialScrollX);
expect(h.state.scrollY).toBe(initialScrollY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toContain("");
});
}); });
+3 -1
View File
@@ -504,7 +504,9 @@ describe("tool locking & selection", () => {
value !== "image" && value !== "image" &&
value !== "selection" && value !== "selection" &&
value !== "eraser" && value !== "eraser" &&
value !== "arrow" value !== "arrow" &&
value !== "hand" &&
value !== "laser"
) { ) {
const element = UI.createElement(value); const element = UI.createElement(value);
expect(h.state.selectedElementIds[element.id]).not.toBe(true); expect(h.state.selectedElementIds[element.id]).not.toBe(true);
+1
View File
@@ -215,6 +215,7 @@ export type StaticCanvasAppState = Readonly<
export type InteractiveCanvasAppState = Readonly< export type InteractiveCanvasAppState = Readonly<
_CommonCanvasAppState & { _CommonCanvasAppState & {
activeTool: AppState["activeTool"];
// renderInteractiveScene // renderInteractiveScene
activeEmbeddable: AppState["activeEmbeddable"]; activeEmbeddable: AppState["activeEmbeddable"];
selectionElement: AppState["selectionElement"]; selectionElement: AppState["selectionElement"];
+18 -1
View File
@@ -8,6 +8,8 @@ import type {
Radians, Radians,
Degrees, Degrees,
Vector, Vector,
GlobalCoord,
LocalCoord,
} from "./types"; } from "./types";
/** /**
@@ -20,8 +22,23 @@ import type {
export function pointFrom<Point extends GlobalPoint | LocalPoint>( export function pointFrom<Point extends GlobalPoint | LocalPoint>(
x: number, x: number,
y: number, y: number,
): Point;
// TODO remove the overload once we migrate to using Point tuples everywhere
export function pointFrom<Coord extends GlobalCoord | LocalCoord>(
coords: Coord,
): Coord extends GlobalCoord ? GlobalPoint : LocalPoint;
// TODO remove the overload once we migrate to using Point tuples everywhere
export function pointFrom<Point extends GlobalPoint | LocalPoint>(coords: {
x: number;
y: number;
}): Point;
export function pointFrom<Point extends GlobalPoint | LocalPoint>(
xOrCoords: number | { x: number; y: number },
y?: number,
): Point { ): Point {
return [x, y] as Point; return typeof xOrCoords === "object"
? ([xOrCoords.x, xOrCoords.y] as Point)
: ([xOrCoords, y!] as Point);
} }
/** /**
+21 -1
View File
@@ -28,13 +28,23 @@ export type InclusiveRange = [number, number] & { _brand: "excalimath_degree" };
// //
/** /**
* Represents a 2D position in world or canvas space. A * Represents a 2D position in world/canvas/scene space. A
* global coordinate. * global coordinate.
*/ */
export type GlobalPoint = [x: number, y: number] & { export type GlobalPoint = [x: number, y: number] & {
_brand: "excalimath__globalpoint"; _brand: "excalimath__globalpoint";
}; };
/**
* Represents a 2D position in world/canvas/scene space. A
* global coordinate.
*
* TODO remove this once we migrate the codebase to use Point tuples everywhere
*/
export type GlobalCoord = { x: number; y: number } & {
_brand: "excalimath__globalcoord";
};
/** /**
* Represents a 2D position in whatever local space it's * Represents a 2D position in whatever local space it's
* needed. A local coordinate. * needed. A local coordinate.
@@ -43,6 +53,16 @@ export type LocalPoint = [x: number, y: number] & {
_brand: "excalimath__localpoint"; _brand: "excalimath__localpoint";
}; };
/**
* Represents a 2D position in whatever local space it's needed.
* A local coordinate.
*
* TODO remove this once we migrate the codebase to use Point tuples everywhere
*/
export type LocalCoord = { x: number; y: number } & {
_brand: "excalimath__localcoord";
};
// Line // Line
/** /**