Loading packages/client/src/components/CanvasWrapper.tsx +147 −5 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ import { useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { useCaptureContext } from "@/contexts/CaptureContext"; import { usePanel } from "@/contexts/PanelContext"; Loading Loading @@ -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"); Loading Loading @@ -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); Loading @@ -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); Loading Loading @@ -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", Loading @@ -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={[ Loading @@ -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", Loading @@ -265,6 +403,10 @@ const CursorInspectPreview = () => { } /> ); return inspectScreenRect && typeof document !== "undefined" ? createPortal(preview, document.body) : preview; }; const CanvasInner = () => { Loading packages/client/src/components/ToastWrapper.tsx +4 −4 Original line number Diff line number Diff line Loading @@ -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"} /> ); }; packages/client/src/components/Toolbar/Palette.tsx +43 −13 Original line number Diff line number Diff line Loading @@ -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(); Loading @@ -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>) => { Loading @@ -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 Loading @@ -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()} Loading @@ -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 && ( Loading packages/client/src/contexts/CaptureContext.tsx +72 −1 Original line number Diff line number Diff line Loading @@ -14,6 +14,7 @@ import { import { createPortal } from "react-dom"; import { toast } from "react-toastify"; import { useIsMobile } from "@/hooks/useIsMobile"; import { type CanvasCaptureArea, CanvasCore, Loading Loading @@ -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); Loading Loading @@ -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; Loading Loading @@ -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()} Loading packages/client/src/lib/canvas.ts +71 −39 Original line number Diff line number Diff line Loading @@ -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", { Loading @@ -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"); Loading @@ -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({ Loading Loading @@ -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; Loading @@ -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, Loading @@ -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({ Loading Loading @@ -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, Loading @@ -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 Loading
packages/client/src/components/CanvasWrapper.tsx +147 −5 Original line number Diff line number Diff line Loading @@ -15,6 +15,7 @@ import { useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { useCaptureContext } from "@/contexts/CaptureContext"; import { usePanel } from "@/contexts/PanelContext"; Loading Loading @@ -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"); Loading Loading @@ -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); Loading @@ -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); Loading Loading @@ -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", Loading @@ -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={[ Loading @@ -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", Loading @@ -265,6 +403,10 @@ const CursorInspectPreview = () => { } /> ); return inspectScreenRect && typeof document !== "undefined" ? createPortal(preview, document.body) : preview; }; const CanvasInner = () => { Loading
packages/client/src/components/ToastWrapper.tsx +4 −4 Original line number Diff line number Diff line Loading @@ -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"} /> ); };
packages/client/src/components/Toolbar/Palette.tsx +43 −13 Original line number Diff line number Diff line Loading @@ -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(); Loading @@ -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>) => { Loading @@ -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 Loading @@ -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()} Loading @@ -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 && ( Loading
packages/client/src/contexts/CaptureContext.tsx +72 −1 Original line number Diff line number Diff line Loading @@ -14,6 +14,7 @@ import { import { createPortal } from "react-dom"; import { toast } from "react-toastify"; import { useIsMobile } from "@/hooks/useIsMobile"; import { type CanvasCaptureArea, CanvasCore, Loading Loading @@ -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); Loading Loading @@ -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; Loading Loading @@ -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()} Loading
packages/client/src/lib/canvas.ts +71 −39 Original line number Diff line number Diff line Loading @@ -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", { Loading @@ -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"); Loading @@ -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({ Loading Loading @@ -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; Loading @@ -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, Loading @@ -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({ Loading Loading @@ -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, Loading @@ -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