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
+170 -128
View File
@@ -108,6 +108,7 @@ import {
loadDesktopUIModePreference,
setDesktopUIMode,
isSelectionLikeTool,
oneOf,
} from "@excalidraw/common";
import {
@@ -1219,12 +1220,14 @@ class App extends React.Component<AppProps, AppState> {
if (
hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
)
(this.state.viewModeEnabled ||
this.state.activeTool.type === "laser" ||
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
))
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
@@ -1239,61 +1242,72 @@ class App extends React.Component<AppProps, AppState> {
/** @returns true if iframe-like element click handled */
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 (
!hitElementStart ||
!hitElementEnd ||
hitElementStart !== hitElementEnd ||
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp >
300 ||
gesture.pointers.size > 1 ||
!isIframeLikeElement(hitElementStart) ||
!isIframeLikeElement(hitElementEnd) ||
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
!this.lastPointerDownEvent ||
!this.lastPointerUpEvent ||
// middle-click or something other than primary
this.lastPointerDownEvent.button !== POINTER_BUTTON.MAIN ||
// panning
isHoldingSpace ||
// wrong tool
!oneOf(this.state.activeTool.type, ["laser", "selection", "lasso"])
) {
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 (
this.state.activeEmbeddable?.element === iframeLikeElement &&
@@ -4844,6 +4858,74 @@ class App extends React.Component<AppProps, AppState> {
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) {
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) {
isHoldingSpace = true;
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 (
event[KEYS.CTRL_OR_CMD] &&
(event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE)
@@ -5113,7 +5150,8 @@ class App extends React.Component<AppProps, AppState> {
private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => {
if (event.key === KEYS.SPACE) {
if (
this.state.viewModeEnabled ||
(this.state.viewModeEnabled &&
this.state.activeTool.type !== "laser") ||
this.state.openDialog?.name === "elementLinkSelector"
) {
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>,
isTouchScreen: boolean,
) => {
const draggedDistance = pointDistance(
pointFrom(
@@ -6803,6 +6840,10 @@ class App extends React.Component<AppProps, AppState> {
}
}
if (isEraserActive(this.state)) {
return;
}
const hitElementMightBeLocked = this.getElementAtPosition(
scenePointerX,
scenePointerY,
@@ -6819,18 +6860,25 @@ class App extends React.Component<AppProps, AppState> {
hitElement = hitElementMightBeLocked;
}
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElementMightBeLocked,
);
if (isEraserActive(this.state)) {
return;
if (
!this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
})
) {
this.hitLinkElement = this.getElementLinkAtPosition(
scenePointer,
hitElementMightBeLocked,
);
}
if (
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
showHyperlinkTooltip(
this.hitLinkElement,
this.state,
@@ -6839,11 +6887,6 @@ class App extends React.Component<AppProps, AppState> {
} else {
hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if (
@@ -6878,15 +6921,10 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked
) {
if (
!this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
}) &&
(!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding))
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) {
if (
this.state.activeTool.type !== "lasso" ||
@@ -7568,7 +7606,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id]
) {
this.redirectToLink(event, this.editorInterface.isTouchScreen);
this.handleElementLinkClick(event);
} else if (this.state.viewModeEnabled) {
this.setState({
activeEmbeddable: null,
@@ -7628,7 +7666,8 @@ class App extends React.Component<AppProps, AppState> {
(event.button === POINTER_BUTTON.WHEEL ||
(event.button === POINTER_BUTTON.MAIN && isHoldingSpace) ||
isHandToolActive(this.state) ||
this.state.viewModeEnabled)
(this.state.viewModeEnabled &&
this.state.activeTool.type !== "laser"))
)
) {
return false;
@@ -7706,7 +7745,10 @@ class App extends React.Component<AppProps, AppState> {
lastPointerUp = null;
isPanning = false;
if (!isHoldingSpace) {
if (this.state.viewModeEnabled) {
if (
this.state.viewModeEnabled &&
this.state.activeTool.type !== "laser"
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB);
} else {
setCursorForShape(this.interactiveCanvas, this.state);