diff --git a/packages/common/src/utils.ts b/packages/common/src/utils.ts index 3727e562d3..5bafa41813 100644 --- a/packages/common/src/utils.ts +++ b/packages/common/src/utils.ts @@ -1,5 +1,7 @@ import { average } from "@excalidraw/math"; +import type { GlobalCoord } from "@excalidraw/math"; + import type { FontFamilyValues, FontString } from "@excalidraw/element/types"; import type { @@ -441,7 +443,7 @@ export const viewportCoordsToSceneCoords = ( const x = (clientX - offsetLeft) / zoom.value - scrollX; const y = (clientY - offsetTop) / zoom.value - scrollY; - return { x, y }; + return { x, y } as GlobalCoord; }; export const sceneCoordsToViewportCoords = ( @@ -1330,3 +1332,10 @@ export const setFeatureFlag = ( console.error("unable to set feature flag", e); } }; + +export const oneOf = ( + needle: N, + haystack: readonly H[], +): needle is H => { + return haystack.includes(needle as any); +}; diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index d9f3415d64..9372e2b243 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -1081,8 +1081,9 @@ export const ShapesSwitcher = ({ return ( <> {getToolbarTools(app).map( - ({ value, icon, key, numericKey, fillable }, index) => { + ({ value, icon, key, numericKey, fillable, toolbar }) => { if ( + toolbar === false || UIOptions.tools?.[ value as Extract< typeof value, @@ -1099,6 +1100,9 @@ export const ShapesSwitcher = ({ const shortcut = letter ? `${letter} ${t("helpDialog.or")} ${numericKey}` : `${numericKey}`; + const keybindingLabel = + value === "hand" ? undefined : numericKey || letter; + // when in compact styles panel mode (tablet) // use a ToolPopover for selection/lasso toggle as well if ( @@ -1143,7 +1147,7 @@ export const ShapesSwitcher = ({ checked={activeTool.type === value} name="editor-current-shape" title={`${capitalizeString(label)} — ${shortcut}`} - keyBindingLabel={numericKey || letter} + keyBindingLabel={keybindingLabel} aria-label={capitalizeString(label)} aria-keyshortcuts={shortcut} data-testid={`toolbar-${value}`} diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 14ebca6bce..59a11f804c 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -108,6 +108,7 @@ import { loadDesktopUIModePreference, setDesktopUIMode, isSelectionLikeTool, + oneOf, } from "@excalidraw/common"; import { @@ -1219,12 +1220,14 @@ class App extends React.Component { 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 { /** @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 { 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 { }); } } - } 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 { } } - 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 { 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 { } }; - private redirectToLink = ( + private handleElementLinkClick = ( event: React.PointerEvent, - isTouchScreen: boolean, ) => { const draggedDistance = pointDistance( pointFrom( @@ -6803,6 +6840,10 @@ class App extends React.Component { } } + if (isEraserActive(this.state)) { + return; + } + const hitElementMightBeLocked = this.getElementAtPosition( scenePointerX, scenePointerY, @@ -6819,18 +6860,25 @@ class App extends React.Component { 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 { } else { hideHyperlinkToolip(); if (isLaserTool) { - this.handleIframeLikeElementHover({ - hitElement, - scenePointer, - moveEvent: event, - }); return; } if ( @@ -6878,15 +6921,10 @@ class App extends React.Component { !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 { 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 { (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 { 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); diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts index 3eed838ce8..bb01d66b1e 100644 --- a/packages/excalidraw/components/CommandPalette/types.ts +++ b/packages/excalidraw/components/CommandPalette/types.ts @@ -15,7 +15,7 @@ export type CommandPaletteItem = { category: string; order?: number; predicate?: boolean | Action["predicate"]; - shortcut?: string; + shortcut?: string | null; /** if false, command will not show while in view mode */ viewMode?: boolean; perform: (data: { diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 17dffdfd9d..11447bbd31 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -20,7 +20,6 @@ import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; import { actionToggleStats } from "../actions"; import { trackEvent } from "../analytics"; -import { isHandToolActive } from "../appState"; import { TunnelsContext, useInitializeTunnels } from "../context/tunnels"; import { UIAppStateContext } from "../context/ui-appState"; import { useAtom, useAtomValue } from "../editor-jotai"; @@ -55,7 +54,6 @@ import ElementLinkDialog from "./ElementLinkDialog"; import { ErrorDialog } from "./ErrorDialog"; import { EyeDropper, activeEyeDropperAtom } from "./EyeDropper"; import { FixedSideContainer } from "./FixedSideContainer"; -import { HandButton } from "./HandButton"; import { HelpDialog } from "./HelpDialog"; import { HintViewer } from "./HintViewer"; import { ImageExportDialog } from "./ImageExportDialog"; @@ -359,13 +357,6 @@ const LayerUI = ({
- onHandToolToggle()} - title={t("toolBar.hand")} - isMobile - /> - { style={{ width: props.appState.width, height: props.appState.height, - cursor: props.appState.viewModeEnabled - ? CURSOR_TYPE.GRAB - : CURSOR_TYPE.AUTO, + cursor: + props.appState.viewModeEnabled && + props.appState.activeTool.type !== "laser" + ? CURSOR_TYPE.GRAB + : CURSOR_TYPE.AUTO, }} width={props.appState.width * props.scale} height={props.appState.height * props.scale} @@ -233,6 +235,7 @@ const getRelevantAppStateProps = ( width: appState.width, height: appState.height, viewModeEnabled: appState.viewModeEnabled, + activeTool: appState.activeTool, openDialog: appState.openDialog, editingGroupId: appState.editingGroupId, selectedElementIds: appState.selectedElementIds, diff --git a/packages/excalidraw/components/shapes.tsx b/packages/excalidraw/components/shapes.tsx index d46f08a311..999dfe1c14 100644 --- a/packages/excalidraw/components/shapes.tsx +++ b/packages/excalidraw/components/shapes.tsx @@ -11,17 +11,28 @@ import { TextIcon, ImageIcon, EraserIcon, + laserPointerToolIcon, + handIcon, } from "./icons"; import type { AppClassProperties } from "../types"; export const SHAPES = [ + { + icon: handIcon, + value: "hand", + key: KEYS.H, + numericKey: null, + fillable: false, + toolbar: true, + }, { icon: SelectionIcon, value: "selection", key: KEYS.V, numericKey: KEYS["1"], fillable: true, + toolbar: true, }, { icon: RectangleIcon, @@ -29,6 +40,7 @@ export const SHAPES = [ key: KEYS.R, numericKey: KEYS["2"], fillable: true, + toolbar: true, }, { icon: DiamondIcon, @@ -36,6 +48,7 @@ export const SHAPES = [ key: KEYS.D, numericKey: KEYS["3"], fillable: true, + toolbar: true, }, { icon: EllipseIcon, @@ -43,6 +56,7 @@ export const SHAPES = [ key: KEYS.O, numericKey: KEYS["4"], fillable: true, + toolbar: true, }, { icon: ArrowIcon, @@ -50,6 +64,7 @@ export const SHAPES = [ key: KEYS.A, numericKey: KEYS["5"], fillable: true, + toolbar: true, }, { icon: LineIcon, @@ -57,6 +72,7 @@ export const SHAPES = [ key: KEYS.L, numericKey: KEYS["6"], fillable: true, + toolbar: true, }, { icon: FreedrawIcon, @@ -64,6 +80,7 @@ export const SHAPES = [ key: [KEYS.P, KEYS.X], numericKey: KEYS["7"], fillable: false, + toolbar: true, }, { icon: TextIcon, @@ -71,6 +88,7 @@ export const SHAPES = [ key: KEYS.T, numericKey: KEYS["8"], fillable: false, + toolbar: true, }, { icon: ImageIcon, @@ -78,6 +96,7 @@ export const SHAPES = [ key: null, numericKey: KEYS["9"], fillable: false, + toolbar: true, }, { icon: EraserIcon, @@ -85,6 +104,15 @@ export const SHAPES = [ key: KEYS.E, numericKey: KEYS["0"], fillable: false, + toolbar: true, + }, + { + icon: laserPointerToolIcon, + value: "laser", + key: KEYS.K, + numericKey: null, + fillable: false, + toolbar: false, }, ] as const; @@ -97,6 +125,7 @@ export const getToolbarTools = (app: AppClassProperties) => { key: KEYS.V, numericKey: KEYS["1"], fillable: true, + toolbar: true, }, ...SHAPES.slice(1), ] as const) diff --git a/packages/excalidraw/tests/laser.test.tsx b/packages/excalidraw/tests/laser.test.tsx index b8b6510259..fc5c3aa5aa 100644 --- a/packages/excalidraw/tests/laser.test.tsx +++ b/packages/excalidraw/tests/laser.test.tsx @@ -106,4 +106,26 @@ describe("laser tool interactions", () => { handleIframeLikeCenterClickSpy.mockRestore(); }); + + it("doesn't pan in view mode when laser tool is active", async () => { + await render(); + + 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(""); + }); }); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 86be835f7b..93135fb2e1 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -504,7 +504,9 @@ describe("tool locking & selection", () => { value !== "image" && value !== "selection" && value !== "eraser" && - value !== "arrow" + value !== "arrow" && + value !== "hand" && + value !== "laser" ) { const element = UI.createElement(value); expect(h.state.selectedElementIds[element.id]).not.toBe(true); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 6ce4ae9267..f7e86cc524 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -215,6 +215,7 @@ export type StaticCanvasAppState = Readonly< export type InteractiveCanvasAppState = Readonly< _CommonCanvasAppState & { + activeTool: AppState["activeTool"]; // renderInteractiveScene activeEmbeddable: AppState["activeEmbeddable"]; selectionElement: AppState["selectionElement"]; diff --git a/packages/math/src/point.ts b/packages/math/src/point.ts index 863febfd41..ed25a713d8 100644 --- a/packages/math/src/point.ts +++ b/packages/math/src/point.ts @@ -8,6 +8,8 @@ import type { Radians, Degrees, Vector, + GlobalCoord, + LocalCoord, } from "./types"; /** @@ -20,8 +22,23 @@ import type { export function pointFrom( x: number, y: number, +): Point; +// TODO remove the overload once we migrate to using Point tuples everywhere +export function pointFrom( + coords: Coord, +): Coord extends GlobalCoord ? GlobalPoint : LocalPoint; +// TODO remove the overload once we migrate to using Point tuples everywhere +export function pointFrom(coords: { + x: number; + y: number; +}): Point; +export function pointFrom( + xOrCoords: number | { x: number; y: number }, + y?: number, ): Point { - return [x, y] as Point; + return typeof xOrCoords === "object" + ? ([xOrCoords.x, xOrCoords.y] as Point) + : ([xOrCoords, y!] as Point); } /** diff --git a/packages/math/src/types.ts b/packages/math/src/types.ts index da7d5d6abd..9adb208291 100644 --- a/packages/math/src/types.ts +++ b/packages/math/src/types.ts @@ -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. */ export type GlobalPoint = [x: number, y: number] & { _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 * needed. A local coordinate. @@ -43,6 +53,16 @@ export type LocalPoint = [x: number, y: number] & { _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 /**