From 1a5df33569127545d9c220193512e492ec0739bc Mon Sep 17 00:00:00 2001 From: Mykeyy Date: Fri, 7 Aug 2026 06:20:05 +0800 Subject: [PATCH] fix(client): align grid overlay and fix pan zoom pixel rounding --- .../client/src/components/CanvasWrapper.tsx | 17 ++++-- .../src/components/Overlay/GridOverlay.tsx | 10 ++-- packages/client/src/lib/canvas.ts | 4 +- packages/client/src/lib/router.ts | 2 +- packages/lib/src/renderer/PanZoom.ts | 52 ++++++++++++++++++- .../lib/src/renderer/lib/panning.utils.ts | 3 +- 6 files changed, 74 insertions(+), 14 deletions(-) diff --git a/packages/client/src/components/CanvasWrapper.tsx b/packages/client/src/components/CanvasWrapper.tsx index c42d392..35d7b7b 100644 --- a/packages/client/src/components/CanvasWrapper.tsx +++ b/packages/client/src/components/CanvasWrapper.tsx @@ -47,6 +47,7 @@ const KEYBOARD_ZOOM_STEP = 1; const KEYBOARD_ZOOM_SMALL_STEP = 0.35; const INSPECT_END_ANIMATION_DURATION = 180; const TOUCH_INSPECT_MOVE_THRESHOLD = 25; +const GRID_LINE_FRACTION = 1 / 8; export const CanvasWrapper = () => { const hasMod = useHasRole("MOD"); @@ -122,7 +123,7 @@ export const CanvasWrapper = () => { } const CursorInspectPreview = () => { - const { cursor } = useAppContext(); + const { cursor, gridOverlay } = useAppContext(); const PanZoom = useContext(RendererContext); const selectedColor = typeof cursor.color === "number" @@ -342,6 +343,8 @@ const CursorInspectPreview = () => { const inspectScreenRect = (() => { if ( (inspectHold === "idle" && !hasSelectedColor) || + typeof previewPosition.x !== "number" || + typeof previewPosition.y !== "number" || previewPosition.x < 0 || previewPosition.y < 0 ) { @@ -362,6 +365,10 @@ const CursorInspectPreview = () => { const inspectBorderSize = inspectScreenRect ? Math.min(inspectScreenRect.width, inspectScreenRect.height) : 1; + const gridInset = + inspectScreenRect && gridOverlay.enabled && hasSelectedColor + ? inspectBorderSize * GRID_LINE_FRACTION + : 0; const preview = (
{ left: previewPosition.x, ...(inspectScreenRect && { position: "fixed", - top: inspectScreenRect.top, - left: inspectScreenRect.left, - width: inspectScreenRect.width, - height: inspectScreenRect.height, + top: inspectScreenRect.top + gridInset, + left: inspectScreenRect.left + gridInset, + width: Math.max(0, inspectScreenRect.width - gridInset), + height: Math.max(0, inspectScreenRect.height - gridInset), zIndex: 1, "--inspect-border-start-inner": inspectBorderSize * 0.02 + "px", "--inspect-border-start-outer": inspectBorderSize * 0.04 + "px", diff --git a/packages/client/src/components/Overlay/GridOverlay.tsx b/packages/client/src/components/Overlay/GridOverlay.tsx index 8d24f50..bcaec7e 100644 --- a/packages/client/src/components/Overlay/GridOverlay.tsx +++ b/packages/client/src/components/Overlay/GridOverlay.tsx @@ -28,7 +28,10 @@ export const GridOverlay = () => { if (!ctx) return; ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = "black"; // full black + // Keep grid lines neutral instead of tinting them with placed pixel colors. + const opacity = Math.min(Math.max(gridOverlay.opacity ?? 1, 0), 1); + const channel = Math.round(255 - (255 - 128) * opacity); + ctx.fillStyle = `rgb(${channel}, ${channel}, ${channel})`; for (let x = 0; x <= canvas.width; x += GRID_SPACING) { ctx.fillRect(x, 0, 1, canvas.height); // vertical line as 1px wide column @@ -37,7 +40,7 @@ export const GridOverlay = () => { for (let y = 0; y <= canvas.height; y += GRID_SPACING) { ctx.fillRect(0, y, canvas.width, 1); // horizontal line as 1px tall row } - }, [gridOverlay.enabled]); + }, [gridOverlay.enabled, gridOverlay.opacity]); return ( { width: config.canvas.size[0], height: config.canvas.size[1], display: gridOverlay.enabled ? "block" : "none", - opacity: gridOverlay.opacity?.toFixed(1) ?? "1", + opacity: 1, pointerEvents: "none", + zIndex: 1, }} /> ); diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts index 927ba6a..428e69c 100644 --- a/packages/client/src/lib/canvas.ts +++ b/packages/client/src/lib/canvas.ts @@ -881,8 +881,8 @@ export class CanvasCore extends EventEmitter { canvasY += this.canvas.height; } - canvasX >>= 0; - canvasY >>= 0; + canvasX = Math.round(canvasX); + canvasY = Math.round(canvasY); return { canvasX, canvasY, zoom }; } diff --git a/packages/client/src/lib/router.ts b/packages/client/src/lib/router.ts index 89c685a..cdf4c47 100644 --- a/packages/client/src/lib/router.ts +++ b/packages/client/src/lib/router.ts @@ -99,7 +99,7 @@ class _Router extends EventEmitter { params.set(CLIENT_PARAMS.canvas_y, position.canvasY + ""); params.set( CLIENT_PARAMS.canvas_zoom, - (this.PanZoom!.transform.scale >> 0) + "", + Math.round(this.PanZoom!.transform.scale) + "", ); if (this.templateState.enabled && this.templateState.url) { diff --git a/packages/lib/src/renderer/PanZoom.ts b/packages/lib/src/renderer/PanZoom.ts index 804a1ce..4f462f7 100644 --- a/packages/lib/src/renderer/PanZoom.ts +++ b/packages/lib/src/renderer/PanZoom.ts @@ -152,6 +152,8 @@ interface PanZoomEvents { export class PanZoom extends EventEmitter { private initialized = false; + private resizeFrame: number | undefined; + private devicePixelOrigin = { x: 0, y: 0 }; // eslint-disable-next-line @typescript-eslint/no-explicit-any public $wrapper: HTMLDivElement = null as any; @@ -221,6 +223,7 @@ export class PanZoom extends EventEmitter { this.registerMouseEvents(); this.registerTouchEvents(); + window.addEventListener("resize", this.handleResize); this.initialized = true; @@ -233,12 +236,24 @@ export class PanZoom extends EventEmitter { this.transform.x = x; this.transform.y = y; this.transform.scale = scale; - this.update({ suppressEmit: true }); } + this.update({ suppressEmit: true }); + this.emit("initialize"); } + private handleResize = () => { + if (this.resizeFrame !== undefined) { + cancelAnimationFrame(this.resizeFrame); + } + + this.resizeFrame = requestAnimationFrame(() => { + this.resizeFrame = undefined; + if (this.initialized) this.update({ suppressEmit: true }); + }); + }; + /** * Get scale that would fit the zoom element in the viewport * @returns @@ -859,9 +874,17 @@ export class PanZoom extends EventEmitter { ); } + const zoomRect = this.$zoom.getBoundingClientRect(); + this.devicePixelOrigin.x = zoomRect.left; + this.devicePixelOrigin.y = zoomRect.top; + + const position = this.alignToDevicePixel( + this.transform.x, + this.transform.y, + ); this.$move.style.setProperty( "transform", - `translate(${this.transform.x}px, ${this.transform.y}px)`, + `translate(${position.x}px, ${position.y}px)`, ); if (!suppressEmit) { @@ -879,10 +902,35 @@ export class PanZoom extends EventEmitter { this.endInspectHold(); this.unregisterTouchEvents(); this.unregisterMouseEvents(); + window.removeEventListener("resize", this.handleResize); + + if (this.resizeFrame !== undefined) { + cancelAnimationFrame(this.resizeFrame); + this.resizeFrame = undefined; + } } // utilities + alignToDevicePixel(x: number, y: number) { + const scale = this.transform.scale; + const dpr = window.devicePixelRatio; + const screenScale = scale * dpr; + + if (!Number.isFinite(screenScale) || screenScale <= 0) return { x, y }; + + return { + x: + (Math.round((this.devicePixelOrigin.x + x * scale) * dpr) / dpr - + this.devicePixelOrigin.x) / + scale, + y: + (Math.round((this.devicePixelOrigin.y + y * scale) * dpr) / dpr - + this.devicePixelOrigin.y) / + scale, + }; + } + nudgeScale(adj: number) { this.transform.scale = checkZoomBounds( this.transform.scale * 1.5 ** adj, diff --git a/packages/lib/src/renderer/lib/panning.utils.ts b/packages/lib/src/renderer/lib/panning.utils.ts index 4da602e..3c8721d 100644 --- a/packages/lib/src/renderer/lib/panning.utils.ts +++ b/packages/lib/src/renderer/lib/panning.utils.ts @@ -43,10 +43,11 @@ export class Panning { const deltaY = (y - this.y) / this.instance.transform.scale; const newX = this.instance.transform.x + deltaX; const newY = this.instance.transform.y + deltaY; + const position = this.instance.alignToDevicePixel(newX, newY); this.instance.$move.style.setProperty( "transform", - `translate(${newX}px, ${newY}px)` + `translate(${position.x}px, ${position.y}px)`, ); } -- GitLab