From 09c06bda03bf94c56464223da7b863deb555effa Mon Sep 17 00:00:00 2001 From: Bruno Pitrus Date: Tue, 16 Jul 2024 14:28:02 +0200 Subject: [PATCH] Add support for browser zoom This code works on Firefox, Chromium and QtWebengine on desktop X11/Linux for at least past two years of releases. I provide a place to possibly support other browsers. --- src/base/Util.js | 13 +++++++++++++ src/draw/CachablePainting.js | 17 +++++++++++++---- src/draw/MathPainter.js | 2 +- src/draw/Painter.js | 6 ++++-- src/fallback.js | 3 +++ src/main.js | 25 +++++++++++++++++++------ src/ui/DisplayedCircuit.js | 8 +++++--- src/ui/DisplayedToolbox.js | 26 +++++++++++++++----------- src/ui/forge.js | 33 ++++++++++++++++++++++----------- 9 files changed, 95 insertions(+), 38 deletions(-) diff --git a/src/base/Util.js b/src/base/Util.js index 2668f32b..a36fb3d7 100644 --- a/src/base/Util.js +++ b/src/base/Util.js @@ -466,6 +466,19 @@ class Util { } return text; } + + /** + * Polyfill to determine browser zoom level. + * @return {!number} + */ + static getDpr() { + // As of 2024, the following works in current versions of Firefox, Chromium and QtWebengine on X11/Linux. + // Notably Epiphany does NOT support this api (always returns 1) + let dpr = window.devicePixelRatio + if (dpr === undefined) + dpr = 1 + return dpr + } } /** diff --git a/src/draw/CachablePainting.js b/src/draw/CachablePainting.js index 7adab5d2..2b014576 100644 --- a/src/draw/CachablePainting.js +++ b/src/draw/CachablePainting.js @@ -16,6 +16,7 @@ import {Painter} from "./Painter.js" import {RestartableRng} from "../base/RestartableRng.js" +import {Util} from "../base/Util.js" const fixedRng = new RestartableRng(); @@ -40,6 +41,7 @@ class CachablePainting { * @private */ this._cachedCanvases = new Map(); + this.dpr = 0; } /** @@ -49,15 +51,22 @@ class CachablePainting { * @param {!*=} key */ paint(x, y, painter, key=undefined) { + let {width, height} = this.sizeFunc(key); + let dpr = Util.getDpr(); + if(this.dpr != dpr) //user changed zoom level, purge bitmaps + { + this.dpr = dpr; + this._cachedCanvases = new Map(); + } if (!this._cachedCanvases.has(key)) { let canvas = /** @type {!HTMLCanvasElement} */ document.createElement('canvas'); - let {width, height} = this.sizeFunc(key); - canvas.width = width; - canvas.height = height; + canvas.width = width * dpr; + canvas.height = height * dpr; this._drawingFunc(new Painter(canvas, fixedRng.restarted()), key); this._cachedCanvases.set(key, canvas); } - painter.ctx.drawImage(this._cachedCanvases.get(key), x, y); + let kanvas=this._cachedCanvases.get(key) + painter.ctx.drawImage(kanvas,Math.round(x*dpr)/dpr,Math.round(y*dpr)/dpr, kanvas.width/dpr, kanvas.height/dpr); } } diff --git a/src/draw/MathPainter.js b/src/draw/MathPainter.js index 46a3a8c8..1ff911ad 100644 --- a/src/draw/MathPainter.js +++ b/src/draw/MathPainter.js @@ -342,7 +342,7 @@ class MathPainter { let height = 40 + (valueText2 === undefined ? 0 : 20); let width = Math.max(Math.max(width1, width2), width3); let boundingRect = new Rect(x, y - height, width, height).snapInside( - new Rect(0, 0, painter.ctx.canvas.clientWidth, painter.ctx.canvas.clientHeight)); + new Rect(0, 0, painter.ctx.canvas.getBoundingClientRect().width, painter.ctx.canvas.getBoundingClientRect().height)); let borderPainter = (w, h) => { let r = new Rect( diff --git a/src/draw/Painter.js b/src/draw/Painter.js index 24683665..3d27fc62 100644 --- a/src/draw/Painter.js +++ b/src/draw/Painter.js @@ -31,6 +31,8 @@ class Painter { this.canvas = canvas; /** @type {!CanvasRenderingContext2D} */ this.ctx = canvas.getContext("2d"); + let dpr = Util.getDpr(); + this.ctx.scale(dpr,dpr); /** * @type {!Array.} * @private @@ -104,7 +106,7 @@ class Painter { * @returns {!Rect} */ paintableArea() { - return new Rect(0, 0, this.canvas.width, this.canvas.height); + return new Rect(0, 0, this.canvas.getBoundingClientRect().width, this.canvas.getBoundingClientRect().height); } /** @@ -112,7 +114,7 @@ class Painter { */ clear(color = Config.DEFAULT_FILL_COLOR) { this.ctx.fillStyle = color; - this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.fillRect(0, 0, this.canvas.getBoundingClientRect().width, this.canvas.getBoundingClientRect().height); } /** diff --git a/src/fallback.js b/src/fallback.js index 06373a9e..7c3f978d 100644 --- a/src/fallback.js +++ b/src/fallback.js @@ -15,6 +15,7 @@ */ import {describe} from "./base/Describe.js" +import {Util} from "./base/Util.js"; /** * @type {!Array.<{regex: !Pattern, handler: !function()}>} @@ -187,6 +188,8 @@ let drawErrorBox = msg => { return; } let ctx = canvas.getContext("2d"); + let dpr = Util.getDpr(); + ctx.scale(dpr,dpr) ctx.font = '12px monospace'; let lines = msg.split("\n"); let w = 0; diff --git a/src/main.js b/src/main.js index 6be15798..1a77dbf9 100644 --- a/src/main.js +++ b/src/main.js @@ -62,8 +62,22 @@ const canvas = document.getElementById("drawCanvas"); if (!canvas) { throw new Error("Couldn't find 'drawCanvas'"); } -canvas.width = canvasDiv.clientWidth; -canvas.height = window.innerHeight*0.9; + +/** @param {!number} w + * @param {!number} h + * @returns {!void} + */ +function resizeCanvas(w,h) { + //Scale canvas for high dpi displays + canvas.style.width=Math.floor(w)+"px" + canvas.style.height=Math.floor(h)+"px" + let dpr = Util.getDpr(); + canvas.width = Math.round(Math.floor(w)* dpr); + canvas.height = Math.round(Math.floor(h)* dpr); +} + +resizeCanvas(canvasDiv.getBoundingClientRect().width,window.innerHeight*0.9); + let haveLoaded = false; const semiStableRng = (() => { const target = {cur: new RestartableRng()}; @@ -83,7 +97,7 @@ const inspectorDiv = document.getElementById("inspectorDiv"); /** @type {ObservableValue.} */ const displayed = new ObservableValue( - DisplayedInspector.empty(new Rect(0, 0, canvas.clientWidth, canvas.clientHeight))); + DisplayedInspector.empty(new Rect(0, 0, canvas.getBoundingClientRect().width, canvas.getBoundingClientRect().height))); const mostRecentStats = new ObservableValue(CircuitStats.EMPTY); /** @type {!Revision} */ let revision = Revision.startingAt(displayed.get().snapshot()); @@ -100,7 +114,7 @@ revision.latestActiveCommit().subscribe(jsonText => { */ let desiredCanvasSizeFor = curInspector => { return { - w: Math.max(canvasDiv.clientWidth, curInspector.desiredWidth()), + w: Math.max(canvasDiv.getBoundingClientRect().width, curInspector.desiredWidth()), h: curInspector.desiredHeight() }; }; @@ -144,8 +158,7 @@ const redrawNow = () => { mostRecentStats.set(stats); let size = desiredCanvasSizeFor(shown); - canvas.width = size.w; - canvas.height = size.h; + resizeCanvas(size.w,size.h); let painter = new Painter(canvas, semiStableRng.cur.restarted()); shown.updateArea(painter.paintableArea()); shown.paint(painter, stats); diff --git a/src/ui/DisplayedCircuit.js b/src/ui/DisplayedCircuit.js index 336cbe53..e029f0ed 100644 --- a/src/ui/DisplayedCircuit.js +++ b/src/ui/DisplayedCircuit.js @@ -399,7 +399,7 @@ class DisplayedCircuit { let lastX = showLabels ? 25 : 5; //noinspection ForLoopThatDoesntUseLoopVariableJS for (let col = 0; - showLabels ? lastX < painter.canvas.width : col <= this.circuitDefinition.columns.length; + showLabels ? lastX < painter.canvas.getBoundingClientRect().width : col <= this.circuitDefinition.columns.length; col++) { let x = this.opRect(col).center().x; if (this.circuitDefinition.locIsMeasured(new Point(col, row))) { @@ -1638,9 +1638,10 @@ let _cachedRowLabelDrawer = new CachablePainting( //noinspection JSCheckFunctionSignatures let suffix = colWires < 4 ? "_".repeat(colWires) : "_.."; //noinspection JSCheckFunctionSignatures + let dpr = Util.getDpr(); _drawLabelsReasonablyFast( painter, - painter.canvas.height / rowCount, + painter.canvas.height / dpr / rowCount, rowCount, i => Util.bin(i, rowWires) + suffix, SUPERPOSITION_GRID_LABEL_SPAN); @@ -1660,7 +1661,8 @@ let _cachedColLabelDrawer = new CachablePainting( (painter, numWire) => { let [colWires, rowWires] = [Math.floor(numWire/2), Math.ceil(numWire/2)]; let colCount = 1 << colWires; - let dw = painter.canvas.width / colCount; + let dpr = Util.getDpr(); + let dw = painter.canvas.width / dpr/ colCount; painter.ctx.translate(colCount*dw, 0); painter.ctx.rotate(Math.PI/2); diff --git a/src/ui/DisplayedToolbox.js b/src/ui/DisplayedToolbox.js index b9d35b80..3619f8ca 100644 --- a/src/ui/DisplayedToolbox.js +++ b/src/ui/DisplayedToolbox.js @@ -26,6 +26,7 @@ import {Painter} from "../draw/Painter.js" import {Point} from "../math/Point.js" import {seq} from "../base/Seq.js" import {WidgetPainter} from "../draw/WidgetPainter.js" +import {Util} from "../base/Util.js"; class DisplayedToolbox { /** @@ -61,8 +62,9 @@ class DisplayedToolbox { this._standardApperance = standardAppearance || new CachablePainting( () => ({width: this.desiredWidth(), height: this.desiredHeight()}), painter => { + let dpr = Util.getDpr(); painter.ctx.save(); - painter.ctx.translate(0, -this.top); + painter.ctx.translate(0, -Math.floor(this.top*dpr)/dpr); this._paintStandardContents(painter); painter.ctx.restore(); }); @@ -109,18 +111,19 @@ class DisplayedToolbox { let dx = gateIndex % 2; let dy = Math.floor(gateIndex / 2); + let dpr = Util.getDpr(); let x = Config.TOOLBOX_MARGIN_X + dx * Config.TOOLBOX_GATE_SPAN + groupIndex * Config.TOOLBOX_GROUP_SPAN; - let y = this.top + + let y = Math.floor(this.top*dpr)/dpr + (this.labelsOnTop ? Config.TOOLBOX_MARGIN_Y : 3) + dy * Config.TOOLBOX_GATE_SPAN; - + //We snap the rectangle to physical pixels to fix jumpy buttons on the lower toolbox on odd DPI return new Rect( - Math.round(x - 0.5) + 0.5, - Math.round(y - 0.5) + 0.5, - Config.GATE_RADIUS * 2, - Config.GATE_RADIUS * 2); + (Math.round(x*dpr - 0.5) + 0.5)/dpr, + (Math.round(y*dpr - 0.5) + 0.5)/dpr, + Math.floor(Config.GATE_RADIUS*dpr)/dpr * 2, + Math.floor(Config.GATE_RADIUS*dpr)/dpr * 2); } /** @@ -203,8 +206,9 @@ class DisplayedToolbox { * @param {!Hand} hand */ paint(painter, stats, hand) { - painter.fillRect(this.curArea(painter.canvas.width), Config.BACKGROUND_COLOR_TOOLBOX); - this._standardApperance.paint(0, this.top, painter); + let dpr = Util.getDpr(); + painter.fillRect(this.curArea(painter.canvas.getBoundingClientRect().width), Config.BACKGROUND_COLOR_TOOLBOX); + this._standardApperance.paint(0, Math.floor(this.top*dpr)/dpr, painter); this._paintDeviations(painter, stats, hand); } @@ -333,8 +337,8 @@ class DisplayedToolbox { painter.ctx.globalAlpha = 0; painter.ctx.translate(-10000, -10000); let {maxW, maxH} = WidgetPainter.paintGateTooltip( - painter, new Rect(0, 0, 500, 300), f.gate, stats.time, true); - let mayNeedToScale = maxW >= 500 || maxH >= 300; + painter, new Rect(0, 0, 500, 300), f.gate, stats.time, false); + let mayNeedToScale = false; painter.ctx.restore(); // Draw tooltip. diff --git a/src/ui/forge.js b/src/ui/forge.js index 1c6a0442..7e184291 100644 --- a/src/ui/forge.js +++ b/src/ui/forge.js @@ -39,6 +39,20 @@ import {Util} from "../base/Util.js" const forgeIsVisible = new ObservableValue(false); const obsForgeIsShowing = forgeIsVisible.observable().whenDifferent(); +/** + * @param {!HTMLCanvasElement} canvas + * @returns {!Painter} + */ +function getNewPainterForCanvas(canvas) { + let dpr = Util.getDpr(); + canvas.height = Math.round(parseInt(canvas.style.height) * dpr) + canvas.width = Math.round(parseInt(canvas.style.width) * dpr) + //Resizing the canvas is a hack to purge context because `new Painter(…)` is not idempotent; otherwise image zooms in and in - BRJSP + let painter = new Painter(canvas); + painter.clear(); + return painter; +} + /** * @param {!Revision} revision * @param {!Observable.} obsIsAnyOverlayShowing @@ -73,9 +87,8 @@ function initForge(revision, obsIsAnyOverlayShowing) { function computeAndPaintOp(canvas, opGetter, button) { button.disabled = true; - let painter = new Painter(canvas); - painter.clear(); - let d = Math.min((canvas.width - 5)/2, canvas.height); + let painter = getNewPainterForCanvas(canvas); + let d = Math.min((canvas.getBoundingClientRect().width - 5)/2, canvas.getBoundingClientRect().height); let rect1 = new Rect(0, 0, d, d); let rect2 = new Rect(d + 5, 0, d, d); try { @@ -103,14 +116,14 @@ function initForge(revision, obsIsAnyOverlayShowing) { Config.OPERATION_FORE_COLOR); } let cx = (rect1.right() + rect2.x)/2; - painter.strokeLine(new Point(cx, 0), new Point(cx, canvas.height), 'black', 2); + painter.strokeLine(new Point(cx, 0), new Point(cx, canvas.getBoundingClientRect().height), 'black', 2); if (!op.hasNaN()) { button.disabled = false; } } catch (ex) { painter.printParagraph( ex+"", - new Rect(0, 0, canvas.width, canvas.height), + new Rect(0, 0, canvas.getBoundingClientRect().width, canvas.getBoundingClientRect().height), new Point(0.5, 0.5), 'red', 24); @@ -237,7 +250,7 @@ function initForge(revision, obsIsAnyOverlayShowing) { let drawGate = (painter, gate) => drawCircuitTooltip( painter, gate.knownCircuitNested, - new Rect(0, 0, circuitCanvas.width, circuitCanvas.height), + new Rect(0, 0, circuitCanvas.getBoundingClientRect().width, circuitCanvas.getBoundingClientRect().height), true, getCircuitCycleTime()); @@ -248,15 +261,13 @@ function initForge(revision, obsIsAnyOverlayShowing) { Observable.requestAnimationTicker().map(_ => e)). flattenLatest(). subscribe(e => { - let painter = new Painter(circuitCanvas); - painter.clear(); + let painter = getNewPainterForCanvas(circuitCanvas); drawGate(painter, e.gate); }); let redraw = () => { circuitButton.disabled = true; - let painter = new Painter(circuitCanvas); - painter.clear(); + let painter = getNewPainterForCanvas(circuitCanvas); try { let {gate} = parseEnteredCircuitGate(); let keys = gate.getUnmetContextKeys(); @@ -274,7 +285,7 @@ function initForge(revision, obsIsAnyOverlayShowing) { spanWeight.innerText = "(err)"; painter.printParagraph( ex+"", - new Rect(0, 0, circuitCanvas.width, circuitCanvas.height), + new Rect(0, 0, circuitCanvas.getBoundingClientRect().width, circuitCanvas.getBoundingClientRect().height), new Point(0.5, 0.5), 'red', 24);