Browse Source

Improve scrollbar-mouse interaction (#667)

* fix scrollbar detection on high devicePixelRatio devices

* don't create a new element on pointerdown over a scrollbar

* Return scrollbars from renderScene and use it in isOverScrollBars

* remove unneeded setState

* show default cursor when hovering or dragging a scrollbar

* disable scrollbars when in multielement mode

Co-authored-by: David Luzar <luzar.david@gmail.com>
lissitz 5 years ago
parent
commit
e920c078b9
4 changed files with 109 additions and 45 deletions
  1. 87 19
      src/index.tsx
  2. 4 3
      src/renderer/renderScene.ts
  3. 3 23
      src/scene/scrollbars.ts
  4. 15 0
      src/scene/types.ts

+ 87 - 19
src/index.tsx

@@ -112,6 +112,7 @@ import useIsMobile, { IsMobileProvider } from "./is-mobile";
 import { copyToAppClipboard, getClipboardContent } from "./clipboard";
 import { normalizeScroll } from "./scene/data";
 import { getCenter, getDistance } from "./gesture";
+import { ScrollBars } from "./scene/types";
 import { createUndoAction, createRedoAction } from "./actions/actionHistory";
 
 let { elements } = createScene();
@@ -214,6 +215,8 @@ let cursorX = 0;
 let cursorY = 0;
 let isHoldingSpace: boolean = false;
 let isPanning: boolean = false;
+let isDraggingScrollBar: boolean = false;
+let currentScrollBars: ScrollBars = { horizontal: null, vertical: null };
 
 interface LayerUIProps {
   actionManager: ActionManager;
@@ -1159,12 +1162,9 @@ export class App extends React.Component<any, AppState> {
                 isOverHorizontalScrollBar,
                 isOverVerticalScrollBar,
               } = isOverScrollBars(
-                elements,
-                event.clientX / window.devicePixelRatio,
-                event.clientY / window.devicePixelRatio,
-                canvasWidth / window.devicePixelRatio,
-                canvasHeight / window.devicePixelRatio,
-                this.state,
+                currentScrollBars,
+                event.clientX,
+                event.clientY,
               );
 
               const { x, y } = viewportCoordsToSceneCoords(
@@ -1172,6 +1172,60 @@ export class App extends React.Component<any, AppState> {
                 this.state,
                 this.canvas,
               );
+              let lastX = x;
+              let lastY = y;
+
+              if (
+                (isOverHorizontalScrollBar || isOverVerticalScrollBar) &&
+                !this.state.multiElement
+              ) {
+                isDraggingScrollBar = true;
+                lastX = event.clientX;
+                lastY = event.clientY;
+                const onPointerMove = (event: PointerEvent) => {
+                  const target = event.target;
+                  if (!(target instanceof HTMLElement)) {
+                    return;
+                  }
+
+                  if (isOverHorizontalScrollBar) {
+                    const x = event.clientX;
+                    const dx = x - lastX;
+                    this.setState({
+                      scrollX: normalizeScroll(
+                        this.state.scrollX - dx / this.state.zoom,
+                      ),
+                    });
+                    lastX = x;
+                    return;
+                  }
+
+                  if (isOverVerticalScrollBar) {
+                    const y = event.clientY;
+                    const dy = y - lastY;
+                    this.setState({
+                      scrollY: normalizeScroll(
+                        this.state.scrollY - dy / this.state.zoom,
+                      ),
+                    });
+                    lastY = y;
+                  }
+                };
+
+                const onPointerUp = () => {
+                  isDraggingScrollBar = false;
+                  setCursorForShape(this.state.elementType);
+                  lastPointerUp = null;
+                  window.removeEventListener("pointermove", onPointerMove);
+                  window.removeEventListener("pointerup", onPointerUp);
+                };
+
+                lastPointerUp = onPointerUp;
+
+                window.addEventListener("pointermove", onPointerMove);
+                window.addEventListener("pointerup", onPointerUp);
+                return;
+              }
 
               const originX = x;
               const originY = y;
@@ -1373,14 +1427,6 @@ export class App extends React.Component<any, AppState> {
                 this.setState({ multiElement: null, draggingElement: element });
               }
 
-              let lastX = x;
-              let lastY = y;
-
-              if (isOverHorizontalScrollBar || isOverVerticalScrollBar) {
-                lastX = event.clientX;
-                lastY = event.clientY;
-              }
-
               let resizeArrowFn:
                 | ((
                     element: ExcalidrawElement,
@@ -2115,10 +2161,27 @@ export class App extends React.Component<any, AppState> {
                 gesture.lastCenter = gesture.initialDistance = gesture.initialScale = null;
               }
 
-              if (isHoldingSpace || isPanning) {
+              if (isHoldingSpace || isPanning || isDraggingScrollBar) {
                 return;
               }
-              const hasDeselectedButton = Boolean(event.buttons);
+
+              const {
+                isOverHorizontalScrollBar,
+                isOverVerticalScrollBar,
+              } = isOverScrollBars(
+                currentScrollBars,
+                event.clientX,
+                event.clientY,
+              );
+              const isOverScrollBar =
+                isOverVerticalScrollBar || isOverHorizontalScrollBar;
+              if (!this.state.draggingElement && !this.state.multiElement) {
+                if (isOverScrollBar) {
+                  resetCursor();
+                } else {
+                  setCursorForShape(this.state.elementType);
+                }
+              }
 
               const { x, y } = viewportCoordsToSceneCoords(
                 event,
@@ -2138,6 +2201,7 @@ export class App extends React.Component<any, AppState> {
                 return;
               }
 
+              const hasDeselectedButton = Boolean(event.buttons);
               if (
                 hasDeselectedButton ||
                 this.state.elementType !== "selection"
@@ -2146,7 +2210,7 @@ export class App extends React.Component<any, AppState> {
               }
 
               const selectedElements = getSelectedElements(elements);
-              if (selectedElements.length === 1) {
+              if (selectedElements.length === 1 && !isOverScrollBar) {
                 const resizeElement = getElementWithResizeHandler(
                   elements,
                   { x, y },
@@ -2166,7 +2230,8 @@ export class App extends React.Component<any, AppState> {
                 y,
                 this.state.zoom,
               );
-              document.documentElement.style.cursor = hitElement ? "move" : "";
+              document.documentElement.style.cursor =
+                hitElement && !isOverScrollBar ? "move" : "";
             }}
             onPointerUp={this.removePointer}
             onPointerLeave={this.removePointer}
@@ -2279,7 +2344,7 @@ export class App extends React.Component<any, AppState> {
   }, 300);
 
   componentDidUpdate() {
-    const atLeastOneVisibleElement = renderScene(
+    const { atLeastOneVisibleElement, scrollBars } = renderScene(
       elements,
       this.state.selectionElement,
       this.rc!,
@@ -2294,6 +2359,9 @@ export class App extends React.Component<any, AppState> {
         renderOptimizations: true,
       },
     );
+    if (scrollBars) {
+      currentScrollBars = scrollBars;
+    }
     const scrolledOutside = !atLeastOneVisibleElement && elements.length > 0;
     if (this.state.scrolledOutside !== scrolledOutside) {
       this.setState({ scrolledOutside: scrolledOutside });

+ 4 - 3
src/renderer/renderScene.ts

@@ -36,9 +36,9 @@ export function renderScene(
     renderSelection?: boolean;
     renderOptimizations?: boolean;
   } = {},
-): boolean {
+) {
   if (!canvas) {
-    return false;
+    return { atLeastOneVisibleElement: false };
   }
 
   const context = canvas.getContext("2d")!;
@@ -196,9 +196,10 @@ export function renderScene(
       }
     });
     context.restore();
+    return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
   }
 
-  return visibleElements.length > 0;
+  return { atLeastOneVisibleElement: visibleElements.length > 0 };
 }
 
 function isVisibleElement(

+ 3 - 23
src/scene/scrollbars.ts

@@ -1,6 +1,7 @@
 import { ExcalidrawElement } from "../element/types";
 import { getCommonBounds } from "../element";
 import { FlooredNumber } from "../types";
+import { ScrollBars } from "./types";
 
 const SCROLLBAR_MARGIN = 4;
 export const SCROLLBAR_WIDTH = 6;
@@ -19,7 +20,7 @@ export function getScrollBars(
     scrollY: FlooredNumber;
     zoom: number;
   },
-) {
+): ScrollBars {
   // This is the bounding box of all the elements
   const [
     elementsMinX,
@@ -83,28 +84,7 @@ export function getScrollBars(
   };
 }
 
-export function isOverScrollBars(
-  elements: readonly ExcalidrawElement[],
-  x: number,
-  y: number,
-  viewportWidth: number,
-  viewportHeight: number,
-  {
-    scrollX,
-    scrollY,
-    zoom,
-  }: {
-    scrollX: FlooredNumber;
-    scrollY: FlooredNumber;
-    zoom: number;
-  },
-) {
-  const scrollBars = getScrollBars(elements, viewportWidth, viewportHeight, {
-    scrollX,
-    scrollY,
-    zoom,
-  });
-
+export function isOverScrollBars(scrollBars: ScrollBars, x: number, y: number) {
   const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [
     scrollBars.horizontal,
     scrollBars.vertical,

+ 15 - 0
src/scene/types.ts

@@ -19,3 +19,18 @@ export interface Scene {
 }
 
 export type ExportType = "png" | "clipboard" | "backend" | "svg";
+
+export type ScrollBars = {
+  horizontal: {
+    x: number;
+    y: number;
+    width: number;
+    height: number;
+  } | null;
+  vertical: {
+    x: number;
+    y: number;
+    width: number;
+    height: number;
+  } | null;
+};