diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx index 3d3655217e005805a23ec384f57743051ba0daba..238691c8f94d30113c4ce0cb01fc9216e7a0f866 100644 --- a/packages/client/src/components/App.tsx +++ b/packages/client/src/components/App.tsx @@ -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 = () => { - + + + diff --git a/packages/client/src/components/CanvasWrapper.tsx b/packages/client/src/components/CanvasWrapper.tsx index 8e50307e3afc86d8ae81da95ea699b3764bf23a1..56d982c3d3e6522f17c42badd26b770bdcb9c710 100644 --- a/packages/client/src/components/CanvasWrapper.tsx +++ b/packages/client/src/components/CanvasWrapper.tsx @@ -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); diff --git a/packages/client/src/components/Header/HeaderRight.tsx b/packages/client/src/components/Header/HeaderRight.tsx index 7782cc260f4d0f040020a3fad846edd74dbf2200..4c59a7e64804aba3225134b9e871340821ba20d1 100644 --- a/packages/client/src/components/Header/HeaderRight.tsx +++ b/packages/client/src/components/Header/HeaderRight.tsx @@ -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(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 ( + + setCaptureMenuOpen((open) => !open)} + > + + Capture + + + + {captureMenuOpen && ( + + + + Capture Canvas + + + + Capture View + + + + Capture Region + + {hasTemplateCapture && ( + + + Capture Template Area + + )} + + )} + + { Panel.dispatch(["settings", true]); diff --git a/packages/client/src/contexts/CaptureContext.tsx b/packages/client/src/contexts/CaptureContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..334b82a7957ca8db92594b92d00d8e72aa63d167 --- /dev/null +++ b/packages/client/src/contexts/CaptureContext.tsx @@ -0,0 +1,666 @@ +import { + createContext, + type PointerEvent, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faDrawPolygon } from "@fortawesome/free-solid-svg-icons"; +import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { toast } from "react-toastify"; +import { useAppContext } from "./AppContext"; +import { useTemplateContext } from "./TemplateContext"; +import { Template as TemplateRenderer } from "@/lib/template"; +import { + CanvasCore, + type CanvasCaptureArea, + type CanvasSnapshot, +} from "@/lib/canvas"; + +const TEMPLATE_CAPTURE_PADDING = 2; +const REGION_CLICK_THRESHOLD = 4; +const SNAPSHOT_FEEDBACK_DELAY_MS = 250; +const POLAROID_PADDING = 8; +const POLAROID_BOTTOM_PADDING = 34; +const POLAROID_TARGET_SCALE = 0.32; + +interface RegionPoint { + clientX: number; + clientY: number; + canvasX: number; + canvasY: number; +} + +interface RegionSelection { + start: RegionPoint; + current: RegionPoint; +} + +interface SnapshotPreview { + snapshot: CanvasSnapshot; + width: number; + height: number; + photoHeight: number; + targetX: number; + targetY: number; +} + +interface CaptureContextValue { + captureCanvas: () => void; + captureView: () => void; + captureRegion: () => void; + captureTemplateArea: () => void; + hasTemplateCapture: boolean; +} + +const context = createContext(null); + +export const useCaptureContext = () => { + const value = useContext(context); + + if (!value) { + throw new Error("useCaptureContext must be used inside CaptureContext"); + } + + return value; +}; + +const createSnapshotPreview = (snapshot: CanvasSnapshot): SnapshotPreview => { + const sourceWidth = Math.max(snapshot.width, 1); + const sourceHeight = Math.max(snapshot.height, 1); + const maxWidth = Math.min(296, Math.max(196, window.innerWidth * 0.48)); + const maxPhotoSize = maxWidth - POLAROID_PADDING * 2; + const isPortrait = sourceHeight > sourceWidth; + const photoWidth = isPortrait + ? Math.max(1, Math.round(maxPhotoSize * (sourceWidth / sourceHeight))) + : maxPhotoSize; + const photoHeight = isPortrait + ? maxPhotoSize + : Math.max(1, Math.round(photoWidth * (sourceHeight / sourceWidth))); + const width = photoWidth + POLAROID_PADDING * 2; + const height = photoHeight + POLAROID_PADDING + POLAROID_BOTTOM_PADDING; + const targetRight = 48; + const targetTop = -96; + + return { + snapshot, + width, + height, + photoHeight, + targetX: + window.innerWidth / 2 - targetRight - (width * POLAROID_TARGET_SCALE) / 2, + targetY: + targetTop + (height * POLAROID_TARGET_SCALE) / 2 - window.innerHeight / 2, + }; +}; + +const CaptureSnapshotFeedback = () => { + const prefersReducedMotion = useReducedMotion(); + const pendingSnapshot = useRef(null); + const snapshotDelay = useRef(undefined); + const [snapshotFlashId, setSnapshotFlashId] = useState(); + const [snapshotPreview, setSnapshotPreview] = + useState(null); + + useEffect(() => { + const canvas = CanvasCore.get(); + const clearSnapshotDelay = () => { + if (typeof snapshotDelay.current === "undefined") return; + + window.clearTimeout(snapshotDelay.current); + snapshotDelay.current = undefined; + }; + const handleSnapshot = (snapshot: CanvasSnapshot) => { + setSnapshotFlashId(snapshot.id); + pendingSnapshot.current?.save(); + pendingSnapshot.current = null; + clearSnapshotDelay(); + setSnapshotPreview((current) => { + current?.snapshot.save(); + return null; + }); + + if (prefersReducedMotion) { + pendingSnapshot.current = snapshot; + snapshotDelay.current = window.setTimeout(() => { + pendingSnapshot.current = null; + snapshotDelay.current = undefined; + snapshot.save(); + }, SNAPSHOT_FEEDBACK_DELAY_MS); + return; + } + + pendingSnapshot.current = snapshot; + snapshotDelay.current = window.setTimeout(() => { + pendingSnapshot.current = null; + snapshotDelay.current = undefined; + setSnapshotPreview(createSnapshotPreview(snapshot)); + }, SNAPSHOT_FEEDBACK_DELAY_MS); + }; + + canvas.on("snapshot", handleSnapshot); + + return () => { + canvas.off("snapshot", handleSnapshot); + clearSnapshotDelay(); + pendingSnapshot.current?.save(); + pendingSnapshot.current = null; + }; + }, [prefersReducedMotion]); + + const handlePolaroidComplete = (snapshot: CanvasSnapshot) => { + snapshot.save(); + setSnapshotPreview((current) => + current?.snapshot.id === snapshot.id ? null : current, + ); + }; + + return ( + <> + + {snapshotFlashId && ( + { + setSnapshotFlashId((current) => + current === snapshotFlashId ? undefined : current, + ); + }} + /> + )} + + + {snapshotPreview && ( + + handlePolaroidComplete(snapshotPreview.snapshot) + } + > + + + + + + + )} + + > + ); +}; + +export const CaptureContext = ({ children }: PropsWithChildren) => { + const { config } = useAppContext(); + const template = useTemplateContext(); + const canvasSize = config?.canvas.size; + const [captureRegionMode, setCaptureRegionMode] = useState(false); + const regionAnchorRef = useRef(null); + const regionSelectionRef = useRef(null); + const [regionSelection, setRegionSelection] = + useState(null); + + const updateRegionAnchor = useCallback((anchor: RegionPoint | null) => { + regionAnchorRef.current = anchor; + }, []); + + const updateRegionSelection = useCallback( + (selection: RegionSelection | null) => { + regionSelectionRef.current = selection; + setRegionSelection(selection); + }, + [], + ); + + const resetCaptureRegion = useCallback(() => { + document.body.classList.remove("is-capturing-region"); + setCaptureRegionMode(false); + updateRegionAnchor(null); + updateRegionSelection(null); + }, [updateRegionAnchor, updateRegionSelection]); + + useEffect(() => { + if (!captureRegionMode) return; + + document.body.classList.add("is-capturing-region"); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + resetCaptureRegion(); + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + document.body.classList.remove("is-capturing-region"); + }; + }, [captureRegionMode, resetCaptureRegion]); + + const captureCanvas = useCallback(() => { + CanvasCore.get().captureCanvas(); + }, []); + + const captureView = useCallback(() => { + CanvasCore.get().captureView(); + }, []); + + const captureRegion = useCallback(() => { + document.body.classList.add("is-capturing-region"); + updateRegionAnchor(null); + updateRegionSelection(null); + setCaptureRegionMode(true); + }, [updateRegionAnchor, updateRegionSelection]); + + const getRegionPoint = useCallback( + ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }): RegionPoint | undefined => { + try { + const [canvasX, canvasY] = CanvasCore.get().screenToPos( + clientX, + clientY, + ); + + return { + clientX, + clientY, + canvasX, + canvasY, + }; + } catch (error) { + console.warn("Unable to read capture region point", error); + return undefined; + } + }, + [], + ); + + const getRegionCanvasArea = useCallback( + (start: RegionPoint, end: RegionPoint): CanvasCaptureArea => { + const left = Math.min(start.canvasX, end.canvasX); + const top = Math.min(start.canvasY, end.canvasY); + const right = Math.max(start.canvasX, end.canvasX) + 1; + const bottom = Math.max(start.canvasY, end.canvasY) + 1; + + return { + x: left, + y: top, + width: right - left, + height: bottom - top, + }; + }, + [], + ); + + const clampRegionCanvasArea = useCallback( + (area: CanvasCaptureArea): CanvasCaptureArea | undefined => { + const [canvasWidth, canvasHeight] = canvasSize ?? []; + if (!canvasWidth || !canvasHeight) return area; + + const left = Math.max(0, area.x); + const top = Math.max(0, area.y); + const right = Math.min(canvasWidth, area.x + area.width); + const bottom = Math.min(canvasHeight, area.y + area.height); + + if (right <= left || bottom <= top) return undefined; + + return { + x: left, + y: top, + width: right - left, + height: bottom - top, + }; + }, + [canvasSize], + ); + + const finishCaptureRegion = useCallback( + (start: RegionPoint, end: RegionPoint) => { + const area = getRegionCanvasArea(start, end); + + resetCaptureRegion(); + CanvasCore.get().captureRegion(area); + }, + [getRegionCanvasArea, resetCaptureRegion], + ); + + const handleCaptureRegionPointerDown = useCallback( + (event: PointerEvent) => { + if (event.button !== 0) return; + + const point = getRegionPoint(event); + if (!point) { + toast.info("Unable to capture region: can't find canvas"); + resetCaptureRegion(); + return; + } + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + updateRegionSelection({ + start: regionAnchorRef.current ?? point, + current: point, + }); + }, + [getRegionPoint, resetCaptureRegion, updateRegionSelection], + ); + + const handleCaptureRegionPointerMove = useCallback( + (event: PointerEvent) => { + const point = getRegionPoint(event); + if (!point) return; + + const selection = regionSelectionRef.current; + if (selection) { + updateRegionSelection({ ...selection, current: point }); + return; + } + + if (regionAnchorRef.current) { + updateRegionSelection({ + start: regionAnchorRef.current, + current: point, + }); + } + }, + [getRegionPoint, updateRegionSelection], + ); + + const handleCaptureRegionPointerUp = useCallback( + (event: PointerEvent) => { + const point = getRegionPoint(event); + const selection = regionSelectionRef.current; + if (!point || !selection) return; + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + const dragDistance = Math.hypot( + point.clientX - selection.start.clientX, + point.clientY - selection.start.clientY, + ); + + if (!regionAnchorRef.current && dragDistance < REGION_CLICK_THRESHOLD) { + updateRegionAnchor(point); + updateRegionSelection({ start: point, current: point }); + return; + } + + finishCaptureRegion(regionAnchorRef.current ?? selection.start, point); + }, + [ + finishCaptureRegion, + getRegionPoint, + updateRegionAnchor, + updateRegionSelection, + ], + ); + + const handleCaptureRegionPointerCancel = useCallback( + (event: PointerEvent) => { + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + updateRegionSelection( + regionAnchorRef.current + ? { start: regionAnchorRef.current, current: regionAnchorRef.current } + : null, + ); + }, + [updateRegionSelection], + ); + + const getTemplateCaptureArea = useCallback(() => { + const canvasWidth = canvasSize?.[0]; + const canvasHeight = canvasSize?.[1]; + const dimensions = TemplateRenderer.instance?.getDimentions(); + let width = Math.round(dimensions?.display.width ?? 0); + let height = Math.round(dimensions?.display.height ?? 0); + + if (!Number.isFinite(width) || width <= 0) { + width = Math.round(template.width ?? 0); + } + + if (!Number.isFinite(height) || height <= 0) { + const templateCanvas = + typeof document === "undefined" + ? null + : document.querySelector("#template canvas"); + + if (templateCanvas && width > 0 && templateCanvas.width > 0) { + height = Math.round( + width * (templateCanvas.height / templateCanvas.width), + ); + } + } + + if ( + !template.url || + template.x === undefined || + template.y === undefined || + canvasWidth === undefined || + canvasHeight === undefined || + width <= 0 || + height <= 0 + ) { + return undefined; + } + + const left = Math.max(0, Math.floor(template.x) - TEMPLATE_CAPTURE_PADDING); + const top = Math.max(0, Math.floor(template.y) - TEMPLATE_CAPTURE_PADDING); + const right = Math.min( + canvasWidth, + Math.ceil(template.x + width) + TEMPLATE_CAPTURE_PADDING, + ); + const bottom = Math.min( + canvasHeight, + Math.ceil(template.y + height) + TEMPLATE_CAPTURE_PADDING, + ); + const captureWidth = right - left; + const captureHeight = bottom - top; + + if (captureWidth <= 0 || captureHeight <= 0) { + return undefined; + } + + return { + x: left, + y: top, + width: captureWidth, + height: captureHeight, + }; + }, [canvasSize, template.url, template.x, template.y, template.width]); + + const captureTemplateArea = useCallback(() => { + const area = getTemplateCaptureArea(); + + if (!area) { + toast.info("Unable to capture template area: template is not ready"); + return; + } + + CanvasCore.get().captureTemplateArea(area); + }, [getTemplateCaptureArea]); + + const hasTemplateCapture = Boolean(getTemplateCaptureArea()); + const regionSelectionArea = regionSelection + ? clampRegionCanvasArea( + getRegionCanvasArea(regionSelection.start, regionSelection.current), + ) + : undefined; + + const regionSelectionRect = regionSelectionArea + ? (() => { + try { + return CanvasCore.get().canvasAreaToScreenRect(regionSelectionArea); + } catch (error) { + console.warn("Unable to project capture region", error); + return undefined; + } + })() + : undefined; + + const regionStatusText = regionAnchorRef.current + ? "Pick opposite corner" + : regionSelection + ? "Selecting region" + : "Pick first corner"; + + const captureRegionOverlay = + typeof document === "undefined" + ? null + : createPortal( + + {captureRegionMode && ( + event.preventDefault()} + onPointerCancel={handleCaptureRegionPointerCancel} + onPointerDown={handleCaptureRegionPointerDown} + onPointerMove={handleCaptureRegionPointerMove} + onPointerUp={handleCaptureRegionPointerUp} + role="application" + style={{ background: "rgba(14, 165, 233, 0.06)" }} + transition={{ duration: 0.12 }} + > + + + + Capture Region + + + {regionStatusText} + + {regionSelectionArea && ( + + {regionSelectionArea.width} x{" "} + {regionSelectionArea.height}px + + )} + + {regionSelectionRect && ( + + )} + + )} + , + document.body, + ); + + const value = useMemo( + () => ({ + captureCanvas, + captureView, + captureRegion, + captureTemplateArea, + hasTemplateCapture, + }), + [ + captureCanvas, + captureView, + captureRegion, + captureTemplateArea, + hasTemplateCapture, + ], + ); + + return ( + + {children} + + {captureRegionOverlay} + + ); +}; \ No newline at end of file diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts index c74c48b457ccc18cf2a7739b47dcb69741005154..39be18dec15abf9da37bbc571edacd43bdc02e0f 100644 --- a/packages/client/src/lib/canvas.ts +++ b/packages/client/src/lib/canvas.ts @@ -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 { @@ -66,8 +119,6 @@ export class CanvasCore extends EventEmitter { Network.waitForState("pixelLastPlaced").then(([time]) => this.handlePixelLastPlaced(time), ); - - KeybindManager.on("SNAPSHOT", () => this.snapshot()); } destroy() { @@ -76,8 +127,6 @@ export class CanvasCore extends EventEmitter { 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 { } } - snapshot() { + private emitSnapshot(snapshot: Omit) { + let saved = false; + + const save = () => { + if (saved) return; + saved = true; + + const $el = document.createElement("a"); + $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 snapshot: can't find canvas"); + toast.info("Unable to capture canvas: can't find canvas"); return; } - const $el = document.createElement("a"); - $el.setAttribute("download", "canvas-" + Date.now() + ".png"); - $el.setAttribute("href", this.canvas.toDataURL("image/png")); - document.body.appendChild($el); - $el.click(); - $el.remove(); + 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 "); + } + + 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) { diff --git a/packages/client/src/lib/keybinds.ts b/packages/client/src/lib/keybinds.ts index 710fcedb405e1761242451457a702898098d01ba..6125197cd6b183e524f5081d7350ae6d9c2c07ed 100644 --- a/packages/client/src/lib/keybinds.ts +++ b/packages/client/src/lib/keybinds.ts @@ -80,9 +80,40 @@ const KEYBINDS = enforceObjectType({ ctrl: true, }, ], - SNAPSHOT: [ + CAPTURE_CANVAS: [ { key: "KeyP", + ctrl: false, + alt: false, + meta: false, + shift: false, + }, + ], + CAPTURE_VIEW: [ + { + key: "KeyL", + ctrl: false, + alt: false, + meta: false, + shift: false, + }, + ], + CAPTURE_REGION: [ + { + key: "KeyL", + ctrl: false, + alt: false, + meta: false, + shift: true, + }, + ], + CAPTURE_TEMPLATE: [ + { + key: "KeyP", + ctrl: false, + alt: false, + meta: false, + shift: true, }, ], KEYBINDS: [ diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss index 71dee0f20e4f014a3edc27a151ee45a1684af3f1..d999324d1c1ab61f60e68439db391aaddf2a5a4e 100644 --- a/packages/client/src/style.scss +++ b/packages/client/src/style.scss @@ -21,6 +21,15 @@ body { overflow: hidden; } +body.is-capturing-region #main-ui { + pointer-events: none; + + > * { + opacity: 0; + transition: opacity 120ms ease; + } +} + @media (width >= 48rem) { #main-ui { grid-template-columns: max(20rem, 30vw) minmax(0, 1fr) max(20rem, 30vw);
Capture
Capture Canvas
Capture View
Capture Region
Capture Template Area