Commit b287e3b5 authored by Ategon's avatar Ategon Committed by Grant
Browse files

Misc mobile fixes

parent 2a70e48b
Loading
Loading
Loading
Loading
Loading
+147 −5
Original line number Diff line number Diff line
@@ -15,6 +15,7 @@ import {
  useRef,
  useState,
} from "react";
import { createPortal } from "react-dom";

import { useCaptureContext } from "@/contexts/CaptureContext";
import { usePanel } from "@/contexts/PanelContext";
@@ -45,6 +46,7 @@ import { Template } from "./Templating/Template";
const KEYBOARD_ZOOM_STEP = 1;
const KEYBOARD_ZOOM_SMALL_STEP = 0.35;
const INSPECT_END_ANIMATION_DURATION = 180;
const TOUCH_INSPECT_MOVE_THRESHOLD = 25;

export const CanvasWrapper = () => {
  const hasMod = useHasRole("MOD");
@@ -136,6 +138,7 @@ const CursorInspectPreview = () => {
  const [inspectPosition, setInspectPosition] = useState<
    { x: number; y: number } | undefined
  >(undefined);
  const [, setInspectProjectionVersion] = useState(0);
  const inspectPointerDown = useRef<
    { clientX: number; clientY: number } | undefined
  >(undefined);
@@ -144,6 +147,10 @@ const CursorInspectPreview = () => {
  const inspectEndTimer = useRef<number | undefined>(undefined);

  useEffect(() => {
    let touchInspectPointerId: number | undefined;
    let touchInspectStart: { clientX: number; clientY: number } | undefined;
    let touchInspectReadyTimer: number | undefined;

    const clearEndTimer = () => {
      if (inspectEndTimer.current === undefined) return;
      window.clearTimeout(inspectEndTimer.current);
@@ -207,8 +214,8 @@ const CursorInspectPreview = () => {
          fill.opacity,
        );
        element.style.setProperty(
          "--inspect-release-question-size",
          question.backgroundSize,
          "--inspect-release-question-transform",
          question.transform,
        );
        element.style.setProperty(
          "--inspect-release-question-opacity",
@@ -223,22 +230,140 @@ const CursorInspectPreview = () => {
        setInspectPosition(undefined);
      }, INSPECT_END_ANIMATION_DURATION);
    };
    const clearTouchInspectReadyTimer = () => {
      if (touchInspectReadyTimer === undefined) return;
      window.clearTimeout(touchInspectReadyTimer);
      touchInspectReadyTimer = undefined;
    };
    const endTouchInspect = () => {
      if (touchInspectPointerId === undefined) return;
      clearTouchInspectReadyTimer();
      touchInspectPointerId = undefined;
      touchInspectStart = undefined;
      handleEnd();
    };
    const handleTouchPointerDown = (event: PointerEvent) => {
      if (
        event.pointerType !== "touch" ||
        event.button !== 0 ||
        !(event.target instanceof Element) ||
        !event.target.closest(".board-wrapper")
      ) {
        return;
      }

      if (touchInspectPointerId !== undefined) {
        endTouchInspect();
        return;
      }

      touchInspectPointerId = event.pointerId;
      touchInspectStart = {
        clientX: event.clientX,
        clientY: event.clientY,
      };
      handleStart();
      touchInspectReadyTimer = window.setTimeout(() => {
        touchInspectReadyTimer = undefined;
        if (touchInspectPointerId === event.pointerId) handleReady();
      }, MOUSE_LONG_PRESS_DELAY);
    };
    const handleTouchPointerMove = (event: PointerEvent) => {
      if (
        event.pointerId !== touchInspectPointerId ||
        !touchInspectStart
      ) {
        return;
      }

      const distance = Math.max(
        Math.abs(event.clientX - touchInspectStart.clientX),
        Math.abs(event.clientY - touchInspectStart.clientY),
      );
      if (distance >= TOUCH_INSPECT_MOVE_THRESHOLD) endTouchInspect();
    };
    const handleTouchPointerEnd = (event: PointerEvent) => {
      if (event.pointerId === touchInspectPointerId) endTouchInspect();
    };

    document.addEventListener("pointerdown", handlePointerDown, true);
    document.addEventListener("pointerdown", handleTouchPointerDown, true);
    document.addEventListener("pointermove", handleTouchPointerMove, true);
    document.addEventListener("pointerup", handleTouchPointerEnd, true);
    document.addEventListener("pointercancel", handleTouchPointerEnd, true);
    PanZoom.on("inspectHoldStart", handleStart);
    PanZoom.on("inspectHoldReady", handleReady);
    PanZoom.on("inspectHoldEnd", handleEnd);

    return () => {
      clearEndTimer();
      clearTouchInspectReadyTimer();
      document.removeEventListener("pointerdown", handlePointerDown, true);
      document.removeEventListener("pointerdown", handleTouchPointerDown, true);
      document.removeEventListener("pointermove", handleTouchPointerMove, true);
      document.removeEventListener("pointerup", handleTouchPointerEnd, true);
      document.removeEventListener(
        "pointercancel",
        handleTouchPointerEnd,
        true,
      );
      PanZoom.off("inspectHoldStart", handleStart);
      PanZoom.off("inspectHoldReady", handleReady);
      PanZoom.off("inspectHoldEnd", handleEnd);
    };
  }, [PanZoom]);

  return (
  useEffect(() => {
    if (inspectHold === "idle" && !hasSelectedColor) return;

    let projectionFrame: number | undefined;
    const refreshProjection = () => {
      if (projectionFrame !== undefined) return;

      projectionFrame = window.requestAnimationFrame(() => {
        projectionFrame = undefined;
        setInspectProjectionVersion((version) => version + 1);
      });
    };

    document.addEventListener("pointermove", refreshProjection, true);
    PanZoom.on("viewportMove", refreshProjection);

    return () => {
      if (projectionFrame !== undefined) {
        window.cancelAnimationFrame(projectionFrame);
      }
      document.removeEventListener("pointermove", refreshProjection, true);
      PanZoom.off("viewportMove", refreshProjection);
    };
  }, [PanZoom, hasSelectedColor, inspectHold]);

  const previewPosition = inspectPosition ?? cursor;
  const inspectScreenRect = (() => {
    if (
      (inspectHold === "idle" && !hasSelectedColor) ||
      previewPosition.x < 0 ||
      previewPosition.y < 0
    ) {
      return undefined;
    }

    try {
      return CanvasCore.get().canvasAreaToScreenRect({
        x: previewPosition.x,
        y: previewPosition.y,
        width: 1,
        height: 1,
      });
    } catch {
      return undefined;
    }
  })();
  const inspectBorderSize = inspectScreenRect
    ? Math.min(inspectScreenRect.width, inspectScreenRect.height)
    : 1;

  const preview = (
    <div
      ref={inspectElement}
      className={[
@@ -253,8 +378,21 @@ const CursorInspectPreview = () => {
        .join(" ")}
      style={
        {
          top: inspectPosition?.y ?? cursor.y,
          left: inspectPosition?.x ?? cursor.x,
          top: previewPosition.y,
          left: previewPosition.x,
          ...(inspectScreenRect && {
            position: "fixed",
            top: inspectScreenRect.top,
            left: inspectScreenRect.left,
            width: inspectScreenRect.width,
            height: inspectScreenRect.height,
            zIndex: 1,
            "--inspect-border-start-inner": inspectBorderSize * 0.02 + "px",
            "--inspect-border-start-outer": inspectBorderSize * 0.04 + "px",
            "--inspect-border-inner": inspectBorderSize * 0.07 + "px",
            "--inspect-border-outer": inspectBorderSize * 0.14 + "px",
            "--inspect-question-offset": inspectBorderSize * 0.02 + "px",
          }),
          "--cursor-preview-color": "#" + color,
          "--inspect-hold-delay": MOUSE_LONG_PRESS_DELAY * 0.2 + "ms",
          "--inspect-hold-fill-duration": MOUSE_LONG_PRESS_DELAY * 0.7 + "ms",
@@ -265,6 +403,10 @@ const CursorInspectPreview = () => {
      }
    />
  );

  return inspectScreenRect && typeof document !== "undefined"
    ? createPortal(preview, document.body)
    : preview;
};

const CanvasInner = () => {
+4 −4
Original line number Diff line number Diff line
@@ -2,12 +2,12 @@ import { useTheme } from "next-themes";
import { ToastContainer } from "react-toastify";

export const ToastWrapper = () => {
  const { theme } = useTheme()
  const { resolvedTheme } = useTheme();

  return (
    <ToastContainer
      position="top-left"
      theme={theme}
      theme={resolvedTheme === "dark" ? "dark" : "light"}
    />
  );
};
+43 −13
Original line number Diff line number Diff line
@@ -517,6 +517,24 @@ export const Palette = () => {
    }
  }, [cursor.secondaryColor, isMobile]);

  const finishPickColor = (
    clientX: number,
    clientY: number,
    target: PickColorTarget,
  ) => {
    const pickedColor = handlePickColorAt(clientX, clientY, target);
    if (pickedColor === null) {
      showColorDeselectFeedback(clientX, clientY);
    } else if (typeof pickedColor === "number") {
      showColorPickFeedback(
        clientX,
        clientY,
        PaletteLib.getColor(pickedColor)?.hex,
      );
    }
    setPickColorMode(false);
  };

  const handlePickColorPointerDown = (event: PointerEvent<HTMLDivElement>) => {
    event.preventDefault();
    event.stopPropagation();
@@ -529,21 +547,11 @@ export const Palette = () => {
      );
    }

    const pickedColor = handlePickColorAt(
    finishPickColor(
      event.clientX,
      event.clientY,
      event.button === 2 ? "secondary" : "primary",
    );
    if (pickedColor === null) {
      showColorDeselectFeedback(event.clientX, event.clientY);
    } else if (typeof pickedColor === "number") {
      showColorPickFeedback(
        event.clientX,
        event.clientY,
        PaletteLib.getColor(pickedColor)?.hex,
      );
    }
    setPickColorMode(false);
  };

  const handlePickColorPointerMove = (event: PointerEvent<HTMLDivElement>) => {
@@ -565,6 +573,24 @@ export const Palette = () => {
    });
  };

  useEffect(() => {
    if (!pickColorMode || !isMobile) return;

    const panZoom = CanvasCore.get(false)?.getPanZoom();
    if (!panZoom) return;

    const handleMobilePick = ({
      clientX,
      clientY,
    }: {
      clientX: number;
      clientY: number;
    }) => finishPickColor(clientX, clientY, "primary");

    panZoom.on("click", handleMobilePick);
    return () => panZoom.off("click", handleMobilePick);
  });

  const pickColorOverlay =
    typeof document === "undefined"
      ? null
@@ -575,7 +601,10 @@ export const Palette = () => {
                key="pick-color"
                animate={{ opacity: 1 }}
                aria-label="Pick color mode"
                className="pointer-events-auto fixed inset-0 z-[10000] cursor-crosshair overflow-hidden"
                className={[
                  isMobile ? "pointer-events-none" : "pointer-events-auto",
                  "fixed inset-0 z-[10000] cursor-crosshair overflow-hidden",
                ].join(" ")}
                exit={{ opacity: 0 }}
                initial={{ opacity: 0 }}
                onAuxClick={(event) => event.preventDefault()}
@@ -592,7 +621,8 @@ export const Palette = () => {
                    <span>Pick Color</span>
                  </div>
                  <div className="mt-1 text-xs text-black/65 dark:text-white/65">
                    Click on a pixel to copy its color to your brush.
                    {isMobile ? "Tap" : "Click"} on a pixel to copy its color
                    to your brush.
                  </div>
                </div>
                {pickColorPreview && (
+72 −1
Original line number Diff line number Diff line
@@ -14,6 +14,7 @@ import {
import { createPortal } from "react-dom";
import { toast } from "react-toastify";

import { useIsMobile } from "@/hooks/useIsMobile";
import {
  type CanvasCaptureArea,
  CanvasCore,
@@ -267,6 +268,7 @@ const CaptureSnapshotFeedback = () => {

export const CaptureContext = ({ children }: PropsWithChildren) => {
  const { config } = useAppContext();
  const isMobile = useIsMobile();
  const template = useTemplateContext();
  const canvasSize = config?.canvas.size;
  const [captureRegionMode, setCaptureRegionMode] = useState(false);
@@ -404,6 +406,72 @@ export const CaptureContext = ({ children }: PropsWithChildren) => {
    [getRegionCanvasArea, resetCaptureRegion],
  );

  useEffect(() => {
    if (!captureRegionMode || !isMobile) return;

    const panZoom = CanvasCore.get(false)?.getPanZoom();
    if (!panZoom) return;

    let projectionFrame: number | undefined;
    const refreshRegionProjection = () => {
      if (projectionFrame !== undefined || !regionSelectionRef.current) return;

      projectionFrame = window.requestAnimationFrame(() => {
        projectionFrame = undefined;
        const selection = regionSelectionRef.current;
        if (selection) updateRegionSelection({ ...selection });
      });
    };
    const handleMobileRegionPoint = ({
      clientX,
      clientY,
    }: {
      clientX: number;
      clientY: number;
    }) => {
      const point = getRegionPoint({ clientX, clientY });
      if (!point) {
        toast.info("Unable to capture region: can't find canvas");
        resetCaptureRegion();
        return;
      }

      const anchor = regionAnchorRef.current;
      if (anchor) {
        finishCaptureRegion(anchor, point);
        return;
      }

      updateRegionAnchor(point);
      updateRegionSelection({ start: point, current: point });
    };

    panZoom.on("click", handleMobileRegionPoint);
    panZoom.on("viewportMove", refreshRegionProjection);
    document.addEventListener("pointermove", refreshRegionProjection, true);

    return () => {
      if (projectionFrame !== undefined) {
        window.cancelAnimationFrame(projectionFrame);
      }
      panZoom.off("click", handleMobileRegionPoint);
      panZoom.off("viewportMove", refreshRegionProjection);
      document.removeEventListener(
        "pointermove",
        refreshRegionProjection,
        true,
      );
    };
  }, [
    captureRegionMode,
    finishCaptureRegion,
    getRegionPoint,
    isMobile,
    resetCaptureRegion,
    updateRegionAnchor,
    updateRegionSelection,
  ]);

  const handleCaptureRegionPointerDown = useCallback(
    (event: PointerEvent<HTMLDivElement>) => {
      if (event.button !== 0) return;
@@ -598,7 +666,10 @@ export const CaptureContext = ({ children }: PropsWithChildren) => {
                key="capture-region"
                animate={{ opacity: 1 }}
                aria-label="Capture region mode"
                className="pointer-events-auto fixed inset-0 z-[10000] cursor-crosshair overflow-hidden"
                className={[
                  isMobile ? "pointer-events-none" : "pointer-events-auto",
                  "fixed inset-0 z-[10000] cursor-crosshair overflow-hidden",
                ].join(" ")}
                exit={{ opacity: 0 }}
                initial={{ opacity: 0 }}
                onContextMenu={(event) => event.preventDefault()}
+71 −39
Original line number Diff line number Diff line
@@ -186,6 +186,7 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
      document.body.appendChild($el);
      $el.click();
      $el.remove();
      window.setTimeout(() => URL.revokeObjectURL(snapshot.src), 60_000);
    };

    const handled = this.emit("snapshot", {
@@ -198,6 +199,25 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
    }
  }

  private createSnapshot(
    snapshot: Omit<CanvasSnapshot, "save" | "src">,
    targetName: string,
    area?: CanvasCaptureArea,
  ) {
    void getRenderer()
      .capture(area)
      .then((blob) => {
        this.emitSnapshot({
          ...snapshot,
          src: URL.createObjectURL(blob),
        });
      })
      .catch((error) => {
        console.error(`Unable to capture ${targetName}`, error);
        toast.info(`Unable to capture ${targetName}: can't create image`);
      });
  }

  captureCanvas() {
    if (!this.canvas) {
      toast.info("Unable to capture canvas: can't find canvas");
@@ -205,14 +225,20 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
    }

    const id = Date.now();
    const [width, height] = this.config.canvas?.size ?? [
      this.canvas.width,
      this.canvas.height,
    ];

    this.emitSnapshot({
    this.createSnapshot(
      {
        id,
        filename: formatSnapshotFilename(new Date(id)),
      src: this.canvas.toDataURL("image/png"),
      width: this.canvas.width,
      height: this.canvas.height,
    });
        width,
        height,
      },
      "canvas",
    );
  }

  private captureArea({
@@ -240,8 +266,12 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {

    const sourceLeft = Math.max(0, requestedLeft);
    const sourceTop = Math.max(0, requestedTop);
    const sourceRight = Math.min(this.canvas.width, requestedRight);
    const sourceBottom = Math.min(this.canvas.height, requestedBottom);
    const [canvasWidth, canvasHeight] = this.config.canvas?.size ?? [
      this.canvas.width,
      this.canvas.height,
    ];
    const sourceRight = Math.min(canvasWidth, requestedRight);
    const sourceBottom = Math.min(canvasHeight, requestedBottom);
    const sourceWidth = sourceRight - sourceLeft;
    const sourceHeight = sourceBottom - sourceTop;

@@ -250,29 +280,6 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
      return;
    }

    const capture = document.createElement("canvas");
    capture.width = sourceWidth;
    capture.height = sourceHeight;

    const ctx = capture.getContext("2d");
    if (!ctx) {
      toast.info(`Unable to capture ${targetName}: can't create image`);
      return;
    }

    ctx.imageSmoothingEnabled = false;
    ctx.drawImage(
      this.canvas,
      sourceLeft,
      sourceTop,
      sourceWidth,
      sourceHeight,
      0,
      0,
      sourceWidth,
      sourceHeight,
    );

    const id = Date.now();
    const area = {
      left: sourceLeft,
@@ -281,13 +288,21 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
      bottom: sourceTop + sourceHeight - 1,
    };

    this.emitSnapshot({
    this.createSnapshot(
      {
        id,
        filename: formatSnapshotFilename(new Date(id), area),
      src: capture.toDataURL("image/png"),
        width: sourceWidth,
        height: sourceHeight,
    });
      },
      targetName,
      {
        x: sourceLeft,
        y: sourceTop,
        width: sourceWidth,
        height: sourceHeight,
      },
    );
  }

  canvasAreaToScreenRect({
@@ -539,9 +554,20 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
  }

  handleLongPress = (clientX: number, clientY: number) => {
    if (
      document.body.classList.contains("is-picking-color") ||
      document.body.classList.contains("is-capturing-region")
    ) {
      return;
    }

    KeybindManager.handleInteraction(
      {
        key: "LONG_PRESS",
        alt: false,
        ctrl: false,
        meta: false,
        shift: false,
      },
      {
        clientX,
@@ -558,6 +584,12 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {

  handleMouseDown = (e: ClickEvent) => {
    if (this.destroyed) return;
    if (
      document.body.classList.contains("is-picking-color") ||
      document.body.classList.contains("is-capturing-region")
    ) {
      return;
    }

    const noMods = !e.alt && !e.ctrl && !e.meta && !e.shift;

Loading