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:
@@ -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);
|
||||||
|
};
|
||||||
|
|||||||
@@ -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}`}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user