Loading packages/client/src/components/App.tsx +4 −1 Original line number Diff line number Diff line Loading @@ -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__); Loading Loading @@ -82,7 +83,9 @@ const App = () => { <DialogContext> <ModeratorContext> <TemplateContext> <CaptureContext> <AppInner /> </CaptureContext> </TemplateContext> </ModeratorContext> </DialogContext> Loading packages/client/src/components/CanvasWrapper.tsx +29 −4 Original line number Diff line number Diff line Loading @@ -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"); Loading Loading @@ -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? Loading Loading @@ -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); Loading packages/client/src/components/Header/HeaderRight.tsx +135 −2 Original line number Diff line number Diff line Loading @@ -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")); Loading @@ -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]); Loading packages/client/src/contexts/CaptureContext.tsx 0 → 100644 +666 −0 File added.Preview size limit exceeded, changes collapsed. Show changes packages/client/src/lib/canvas.ts +275 −12 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -26,6 +78,7 @@ interface CanvasEvents { */ cursorPos: (position: IPosition) => void; canvasReady: () => void; snapshot: (snapshot: CanvasSnapshot) => void; } export class CanvasCore extends EventEmitter<CanvasEvents> { Loading Loading @@ -66,8 +119,6 @@ export class CanvasCore extends EventEmitter<CanvasEvents> { Network.waitForState("pixelLastPlaced").then(([time]) => this.handlePixelLastPlaced(time), ); KeybindManager.on("SNAPSHOT", () => this.snapshot()); } destroy() { Loading @@ -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) { Loading @@ -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 Loading
packages/client/src/components/App.tsx +4 −1 Original line number Diff line number Diff line Loading @@ -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__); Loading Loading @@ -82,7 +83,9 @@ const App = () => { <DialogContext> <ModeratorContext> <TemplateContext> <CaptureContext> <AppInner /> </CaptureContext> </TemplateContext> </ModeratorContext> </DialogContext> Loading
packages/client/src/components/CanvasWrapper.tsx +29 −4 Original line number Diff line number Diff line Loading @@ -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"); Loading Loading @@ -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? Loading Loading @@ -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); Loading
packages/client/src/components/Header/HeaderRight.tsx +135 −2 Original line number Diff line number Diff line Loading @@ -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")); Loading @@ -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]); Loading
packages/client/src/contexts/CaptureContext.tsx 0 → 100644 +666 −0 File added.Preview size limit exceeded, changes collapsed. Show changes
packages/client/src/lib/canvas.ts +275 −12 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -26,6 +78,7 @@ interface CanvasEvents { */ cursorPos: (position: IPosition) => void; canvasReady: () => void; snapshot: (snapshot: CanvasSnapshot) => void; } export class CanvasCore extends EventEmitter<CanvasEvents> { Loading Loading @@ -66,8 +119,6 @@ export class CanvasCore extends EventEmitter<CanvasEvents> { Network.waitForState("pixelLastPlaced").then(([time]) => this.handlePixelLastPlaced(time), ); KeybindManager.on("SNAPSHOT", () => this.snapshot()); } destroy() { Loading @@ -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) { Loading @@ -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