Commit 22ea4dcc authored by Ategon's avatar Ategon Committed by Grant
Browse files

Add panning using keyboard

parent 0d3e249c
Loading
Loading
Loading
Loading
Loading
+130 −0
Original line number Diff line number Diff line
@@ -164,6 +164,136 @@ const CanvasInner = () => {
    captureTemplateArea,
  } = useCaptureContext();

  useEffect(() => {
    const heldKeys = new Set<string>();
    let animationFrame: number | undefined;
    let previousTime: number | undefined;
    let velocityX = 0;
    let velocityY = 0;
    let viewportChanged = false;
    const panDirections = new Map<string, readonly [number, number]>();
    const addDirection = (
      binding: "PAN_UP" | "PAN_DOWN" | "PAN_LEFT" | "PAN_RIGHT",
      direction: readonly [number, number],
    ) => {
      for (const { key } of KeybindManager.getKeybind(binding)) {
        panDirections.set(key, direction);
      }
    };

    addDirection("PAN_UP", [0, 1]);
    addDirection("PAN_DOWN", [0, -1]);
    addDirection("PAN_LEFT", [1, 0]);
    addDirection("PAN_RIGHT", [-1, 0]);

    const snapToDevicePixel = (position: number) => {
      const screenScale = PanZoom.transform.scale * window.devicePixelRatio;
      return Math.round(position * screenScale) / screenScale;
    };

    const isTypingTarget = (target: EventTarget | null) =>
      target instanceof HTMLElement &&
      Boolean(
        target.closest(
          'input, textarea, select, [contenteditable="true"], [role="textbox"]',
        ),
      );

    const moveViewport = (time: number) => {
      const elapsed = Math.min((time - (previousTime ?? time)) / 1000, 0.05);
      previousTime = time;

      const canMove = KeybindManager.isEnabled() && PanZoom.panning.enabled;
      let horizontal = 0;
      let vertical = 0;

      if (canMove) {
        for (const code of heldKeys) {
          const [x, y] = panDirections.get(code) ?? [0, 0];
          horizontal += x;
          vertical += y;
        }
      }
      const diagonalScale = horizontal && vertical ? Math.SQRT1_2 : 1;
      const targetVelocityX = horizontal * 360 * diagonalScale;
      const targetVelocityY = vertical * 360 * diagonalScale;
      const easing = 1 - Math.exp(-12 * elapsed);

      velocityX += (targetVelocityX - velocityX) * easing;
      velocityY += (targetVelocityY - velocityY) * easing;

      if (Math.abs(velocityX) > 0.5 || Math.abs(velocityY) > 0.5) {
        PanZoom.transform.x +=
          (velocityX * elapsed) / PanZoom.transform.scale;
        PanZoom.transform.y +=
          (velocityY * elapsed) / PanZoom.transform.scale;
        const visualX = snapToDevicePixel(PanZoom.transform.x);
        const visualY = snapToDevicePixel(PanZoom.transform.y);
        PanZoom.$move.style.setProperty(
          "transform",
          `translate3d(${visualX}px, ${visualY}px, 0)`,
        );
        viewportChanged = true;
        animationFrame = requestAnimationFrame(moveViewport);
      } else if (heldKeys.size && canMove) {
        animationFrame = requestAnimationFrame(moveViewport);
      } else {
        velocityX = 0;
        velocityY = 0;
        animationFrame = undefined;
        previousTime = undefined;

        if (viewportChanged) {
          viewportChanged = false;
          PanZoom.transform.x = snapToDevicePixel(PanZoom.transform.x);
          PanZoom.transform.y = snapToDevicePixel(PanZoom.transform.y);
          PanZoom.update();
        }
      }
    };

    const handleKeyDown = (event: KeyboardEvent) => {
      if (
        !KeybindManager.isEnabled() ||
        !panDirections.has(event.code) ||
        event.altKey ||
        event.ctrlKey ||
        event.metaKey ||
        !PanZoom.panning.enabled ||
        isTypingTarget(event.target)
      ) {
        return;
      }

      heldKeys.add(event.code);
      if (animationFrame === undefined) {
        animationFrame = requestAnimationFrame(moveViewport);
      }
      event.preventDefault();
    };

    const handleKeyUp = (event: KeyboardEvent) => {
      if (heldKeys.delete(event.code)) event.preventDefault();
    };

    const stopMoving = () => {
      heldKeys.clear();
      velocityX = 0;
      velocityY = 0;
    };

    document.addEventListener("keydown", handleKeyDown);
    document.addEventListener("keyup", handleKeyUp);
    window.addEventListener("blur", stopMoving);

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      document.removeEventListener("keyup", handleKeyUp);
      window.removeEventListener("blur", stopMoving);
      if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
    };
  }, [PanZoom]);

  /**
   * Is the canvas coordinate within the bounds of the canvas?
   */
+8 −0
Original line number Diff line number Diff line
@@ -23,6 +23,10 @@ interface EmittedKeybind {
export const enforceObjectType: EnforceObjectType<IKeybind[]> = (v) => v;

const KEYBINDS = enforceObjectType({
  PAN_UP: [{ key: "KeyW" }, { key: "ArrowUp" }],
  PAN_DOWN: [{ key: "KeyS" }, { key: "ArrowDown" }],
  PAN_LEFT: [{ key: "KeyA" }, { key: "ArrowLeft" }],
  PAN_RIGHT: [{ key: "KeyD" }, { key: "ArrowRight" }],
  PIXEL_WHOIS: [
    {
      key: "LCLICK",
@@ -303,6 +307,10 @@ class KeybindManager_ extends EventEmitter<{
    this.enabled = enabled;
  }

  isEnabled() {
    return this.enabled;
  }

  getKeybinds() {
    return { ...KEYBINDS };
  }