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

feat: Snapshot Types

parent 20ef3e14
Loading
Loading
Loading
Loading
Loading
+4 −1
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import { Chat, ChatContext } from "./Chat/utils";
import { EventInfoModal } from "./EventInfo/EventInfo";
import AudioContext from "@/contexts/AudioContext";
import PanelContext from "@/contexts/PanelContext";
import { CaptureContext } from "@/contexts/CaptureContext";

console.log("Client init with version " + __COMMIT_HASH__);

@@ -82,7 +83,9 @@ const App = () => {
              <DialogContext>
                <ModeratorContext>
                  <TemplateContext>
                    <CaptureContext>
                      <AppInner />
                    </CaptureContext>
                  </TemplateContext>
                </ModeratorContext>
              </DialogContext>
+29 −4
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@ import { GridOverlay } from "./Overlay/GridOverlay";
import { PaletteLib } from "../lib/palette";
import { getRenderer } from "../lib/utils";
import { usePanel } from "@/contexts/PanelContext";
import { useCaptureContext } from "@/contexts/CaptureContext";

export const CanvasWrapper = () => {
  const hasMod = useHasRole("MOD");
@@ -121,6 +122,12 @@ const CanvasInner = () => {
    enable: templateEnable,
  } = useTemplateContext();
  const PanZoom = useContext(RendererContext);
  const {
    captureCanvas,
    captureView,
    captureRegion,
    captureTemplateArea,
  } = useCaptureContext();

  /**
   * Is the canvas coordinate within the bounds of the canvas?
@@ -237,17 +244,35 @@ const CanvasInner = () => {
      console.log("[CanvasWrapper] received canvasReady");
    });

    // setup keybind listeners
    return () => {
      canvas.current?.destroy();
    };
  }, []);

  useEffect(() => {
    KeybindManager.on("PIXEL_WHOIS", handlePixelWhois);
    KeybindManager.on("PICK_COLOR", handlePickPixel);
    KeybindManager.on("CAPTURE_CANVAS", captureCanvas);
    KeybindManager.on("CAPTURE_VIEW", captureView);
    KeybindManager.on("CAPTURE_REGION", captureRegion);
    KeybindManager.on("CAPTURE_TEMPLATE", captureTemplateArea);

    return () => {
      canvas.current?.destroy();

      KeybindManager.off("PIXEL_WHOIS", handlePixelWhois);
      KeybindManager.off("PICK_COLOR", handlePickPixel);
      KeybindManager.off("CAPTURE_CANVAS", captureCanvas);
      KeybindManager.off("CAPTURE_VIEW", captureView);
      KeybindManager.off("CAPTURE_REGION", captureRegion);
      KeybindManager.off("CAPTURE_TEMPLATE", captureTemplateArea);
    };
  }, []);
  }, [
    handlePixelWhois,
    handlePickPixel,
    captureCanvas,
    captureView,
    captureRegion,
    captureTemplateArea,
  ]);

  useEffect(() => {
    canvas.current?.setPanZoom(PanZoom);
+135 −2
Original line number Diff line number Diff line
@@ -3,12 +3,24 @@ import { useAppContext } from "../../contexts/AppContext";
import { User } from "./User";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ThemeSwitcher } from "./ThemeSwitcher";
import { faCog, faGear, faHammer } from "@fortawesome/free-solid-svg-icons";
import React, { lazy } from "react";
import {
  faBorderAll,
  faCamera,
  faChevronDown,
  faCropSimple,
  faEye,
  faCog,
  faGear,
  faHammer,
  faDrawPolygon,
} from "@fortawesome/free-solid-svg-icons";
import React, { lazy, useEffect, useRef, useState } from "react";
import { useHasRole } from "../../hooks/useHasRole";
import { useModerator } from "../../Moderator/Moderator";
import { Button } from "../core/Button";
import { usePanel } from "@/contexts/PanelContext";
import { useCaptureContext } from "@/contexts/CaptureContext";
import { AnimatePresence, motion } from "framer-motion";

const OpenChatButton = lazy(() => import("../Chat/OpenChatButton"));

@@ -21,13 +33,134 @@ const DynamicChat = () => {
export const HeaderRight = () => {
  const { dispatch } = useModerator();
  const Panel = usePanel();
  const {
    captureCanvas,
    captureView,
    captureRegion,
    captureTemplateArea,
    hasTemplateCapture,
  } = useCaptureContext();
  const hasAdmin = useHasRole("ADMIN");
  const hasMod = useHasRole("MOD");
  const captureMenuRef = useRef<HTMLDivElement | null>(null);
  const [captureMenuOpen, setCaptureMenuOpen] = useState(false);

  useEffect(() => {
    if (!captureMenuOpen) return;

    const handlePointerDown = (event: PointerEvent) => {
      if (
        event.target instanceof Node &&
        captureMenuRef.current?.contains(event.target)
      ) {
        return;
      }

      setCaptureMenuOpen(false);
    };
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") setCaptureMenuOpen(false);
    };

    window.addEventListener("pointerdown", handlePointerDown);
    window.addEventListener("keydown", handleKeyDown);

    return () => {
      window.removeEventListener("pointerdown", handlePointerDown);
      window.removeEventListener("keydown", handleKeyDown);
    };
  }, [captureMenuOpen]);

  const handleCaptureCanvas = () => {
    setCaptureMenuOpen(false);
    captureCanvas();
  };

  const handleCaptureView = () => {
    setCaptureMenuOpen(false);
    captureView();
  };

  const handleCaptureRegion = () => {
    setCaptureMenuOpen(false);
    captureRegion();
  };

  const handleCaptureTemplateArea = () => {
    setCaptureMenuOpen(false);
    captureTemplateArea();
  };

  return (
    <div className="box flex flex-col gap-2">
      <User />
      <div className="flex gap-2">
        <div ref={captureMenuRef} className="relative">
          <Button
            aria-expanded={captureMenuOpen}
            aria-haspopup="menu"
            onPress={() => setCaptureMenuOpen((open) => !open)}
          >
            <FontAwesomeIcon icon={faCamera} />
            <p>Capture</p>
            <FontAwesomeIcon
              className={`transition-transform ${captureMenuOpen ? "rotate-180" : ""}`}
              icon={faChevronDown}
            />
          </Button>
          <AnimatePresence>
            {captureMenuOpen && (
              <motion.div
                animate={{ opacity: 1, scale: 1, y: 0 }}
                aria-label="Capture options"
                className="absolute right-0 top-[calc(100%+0.5rem)] z-50 flex min-w-52 flex-col gap-1"
                exit={{ opacity: 0, scale: 0.98, y: -4 }}
                initial={{ opacity: 0, scale: 0.98, y: -4 }}
                role="menu"
                transition={{ duration: 0.14 }}
              >
                <Button
                  className="justify-start"
                  onPress={handleCaptureCanvas}
                  role="menuitem"
                  size="sm"
                >
                  <FontAwesomeIcon icon={faBorderAll} />
                  <p>Capture Canvas</p>
                </Button>
                <Button
                  className="justify-start"
                  onPress={handleCaptureView}
                  role="menuitem"
                  size="sm"
                >
                  <FontAwesomeIcon icon={faEye} />
                  <p>Capture View</p>
                </Button>
                <Button
                  className="justify-start"
                  onPress={handleCaptureRegion}
                  role="menuitem"
                  size="sm"
                >
                  <FontAwesomeIcon icon={faDrawPolygon} />
                  <p>Capture Region</p>
                </Button>
                {hasTemplateCapture && (
                  <Button
                    className="justify-start"
                    onPress={handleCaptureTemplateArea}
                    role="menuitem"
                    size="sm"
                  >
                    <FontAwesomeIcon icon={faCropSimple} />
                    <p>Capture Template Area</p>
                  </Button>
                )}
              </motion.div>
            )}
          </AnimatePresence>
        </div>
        <Button
          onPress={() => {
            Panel.dispatch(["settings", true]);
+666 −0

File added.

Preview size limit exceeded, changes collapsed.

+275 −12
Original line number Diff line number Diff line
@@ -17,6 +17,58 @@ import { type CanvasPixel } from "./canvasRenderer";
import { CanvasUtils } from "./canvas.utils";
import { PaletteLib } from "./palette";

const padSnapshotDatePart = (value: number) =>
  value.toString().padStart(2, "0");

interface CanvasAreaSnapshotMeta {
  left: number;
  top: number;
  right: number;
  bottom: number;
}

export interface CanvasCaptureArea {
  x: number;
  y: number;
  width: number;
  height: number;
}

interface CanvasScreenRect {
  left: number;
  top: number;
  width: number;
  height: number;
}

const formatSnapshotTimestamp = (date: Date) => {
  const year = date.getFullYear();
  const month = padSnapshotDatePart(date.getMonth() + 1);
  const day = padSnapshotDatePart(date.getDate());
  const hour = padSnapshotDatePart(date.getHours());
  const minute = padSnapshotDatePart(date.getMinutes());
  const second = padSnapshotDatePart(date.getSeconds());

  return `${year}-${month}${day}${hour}${minute}${second}`;
};

const formatSnapshotFilename = (date: Date, area?: CanvasAreaSnapshotMeta) => {
  const timestamp = formatSnapshotTimestamp(date);

  if (!area) return `canvas-${timestamp}.png`;

  return `canvas-${timestamp}-${area.left},${area.top}-${area.right},${area.bottom}.png`;
};

export interface CanvasSnapshot {
  id: number;
  filename: string;
  src: string;
  width: number;
  height: number;
  save: () => void;
}

interface CanvasEvents {
  /**
   * Cursor canvas position
@@ -26,6 +78,7 @@ interface CanvasEvents {
   */
  cursorPos: (position: IPosition) => void;
  canvasReady: () => void;
  snapshot: (snapshot: CanvasSnapshot) => void;
}

export class CanvasCore extends EventEmitter<CanvasEvents> {
@@ -66,8 +119,6 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
    Network.waitForState("pixelLastPlaced").then(([time]) =>
      this.handlePixelLastPlaced(time),
    );

    KeybindManager.on("SNAPSHOT", () => this.snapshot());
  }

  destroy() {
@@ -76,8 +127,6 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
    Network.off("pixel", this.handlePixel);
    Network.off("square", this.handleSquare);
    Network.off("pixelLastPlaced", this.handlePixelLastPlaced);

    // KeybindManager.off("SNAPSHOT", this["test"]);
  }

  setCanvas(canvas: HTMLCanvasElement | null) {
@@ -92,18 +141,232 @@ export class CanvasCore extends EventEmitter<CanvasEvents> {
    }
  }

  snapshot() {
    if (!this.canvas) {
      toast.info("Unable to snapshot: can't find canvas");
      return;
    }
  private emitSnapshot(snapshot: Omit<CanvasSnapshot, "save">) {
    let saved = false;

    const save = () => {
      if (saved) return;
      saved = true;

      const $el = document.createElement("a");
    $el.setAttribute("download", "canvas-" + Date.now() + ".png");
    $el.setAttribute("href", this.canvas.toDataURL("image/png"));
      $el.setAttribute("download", snapshot.filename);
      $el.setAttribute("href", snapshot.src);
      document.body.appendChild($el);
      $el.click();
      $el.remove();
    };

    const handled = this.emit("snapshot", {
      ...snapshot,
      save,
    });

    if (!handled) {
      window.requestAnimationFrame(save);
    }
  }

  captureCanvas() {
    if (!this.canvas) {
      toast.info("Unable to capture canvas: can't find canvas");
      return;
    }

    const id = Date.now();

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

  private captureArea({
    x,
    y,
    width,
    height,
    targetName,
  }: CanvasCaptureArea & {
    targetName: string;
  }) {
    if (!this.canvas) {
      toast.info(`Unable to capture ${targetName}: can't find canvas`);
      return;
    }

    const requestedLeft = Math.floor(x);
    const requestedTop = Math.floor(y);
    const requestedRight = Math.ceil(x + width);
    const requestedBottom = Math.ceil(y + height);
    if (requestedRight <= requestedLeft || requestedBottom <= requestedTop) {
      toast.info(`Unable to capture ${targetName}: area is empty`);
      return;
    }

    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 sourceWidth = sourceRight - sourceLeft;
    const sourceHeight = sourceBottom - sourceTop;

    if (sourceWidth <= 0 || sourceHeight <= 0) {
      toast.info(
        `Unable to capture ${targetName}: area is outside the canvas`,
      );
      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,
      top: sourceTop,
      right: sourceLeft + sourceWidth - 1,
      bottom: sourceTop + sourceHeight - 1,
    };

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

  canvasAreaToScreenRect({
    x,
    y,
    width,
    height,
  }: CanvasCaptureArea): CanvasScreenRect {
    if (!this.canvas) {
      throw new Error("CanvasCore#canvasAreaToScreenRect : no <canvas>");
    }

    if (!this.PanZoom) {
      throw new Error(
        "CanvasCore#canvasAreaToScreenRect : no PanZoom instance",
      );
    }

    const rect = this.canvas.getBoundingClientRect();

    if (this.PanZoom.flags.useZoom) {
      const scale = this.PanZoom.transform.scale;

      return {
        left: (x + rect.left) * scale,
        top: (y + rect.top) * scale,
        width: width * scale,
        height: height * scale,
      };
    }

    const scaleX = rect.width / this.canvas.width;
    const scaleY = rect.height / this.canvas.height;

    return {
      left: rect.left + x * scaleX,
      top: rect.top + y * scaleY,
      width: width * scaleX,
      height: height * scaleY,
    };
  }

  captureView() {
    if (!this.canvas) {
      toast.info("Unable to capture view: can't find canvas");
      return;
    }

    if (!this.PanZoom) {
      toast.info("Unable to capture view: can't find viewport");
      return;
    }

    const rect = this.canvas.getBoundingClientRect();
    const screenToCanvas = (x: number, y: number) => {
      if (!this.canvas || !this.PanZoom) return { x: 0, y: 0 };

      if (this.PanZoom.flags.useZoom) {
        const scale = this.PanZoom.transform.scale;

        return {
          x: x / scale - rect.left,
          y: y / scale - rect.top,
        };
      }

      const scaleX = this.canvas.width / rect.width;
      const scaleY = this.canvas.height / rect.height;

      return {
        x: (x - rect.left) * scaleX,
        y: (y - rect.top) * scaleY,
      };
    };

    const topLeft = screenToCanvas(0, 0);
    const bottomRight = screenToCanvas(window.innerWidth, window.innerHeight);
    const left = Math.max(0, Math.floor(Math.min(topLeft.x, bottomRight.x)));
    const top = Math.max(0, Math.floor(Math.min(topLeft.y, bottomRight.y)));
    const right = Math.min(
      this.canvas.width,
      Math.ceil(Math.max(topLeft.x, bottomRight.x)),
    );
    const bottom = Math.min(
      this.canvas.height,
      Math.ceil(Math.max(topLeft.y, bottomRight.y)),
    );

    this.captureArea({
      x: left,
      y: top,
      width: right - left,
      height: bottom - top,
      targetName: "view",
    });
  }

  captureRegion(area: CanvasCaptureArea) {
    this.captureArea({
      ...area,
      targetName: "region",
    });
  }

  captureTemplateArea(area: CanvasCaptureArea) {
    this.captureArea({
      ...area,
      targetName: "template area",
    });
  }

  setPanZoom(panZoom: PanZoom | null) {
Loading