Compare commits

..

6 Commits

Author SHA1 Message Date
FlamedDogo
b2c577ad4f
Merge pull request #31 from FlamedDogo99/GUI-update
GUI Update
2024-06-23 21:10:32 -06:00
FlamedDogo
1594fec60a
Added controls section to readme 2024-06-23 21:02:16 -06:00
FlamedDogo99
2b3a020fe9 Consolidated mouseEvent parameters
Now passes the entire touch event instead of individual values
2024-06-23 18:41:13 -06:00
FlamedDogo99
43c2b5601e Changed shift key test 2024-06-23 18:18:32 -06:00
FlamedDogo99
1a914493ab Updated userscript version 2024-06-23 17:53:31 -06:00
FlamedDogo99
03ad0553b4 Reworked touch & drag controls 2024-06-23 17:53:13 -06:00
2 changed files with 216 additions and 72 deletions

View File

@ -59,6 +59,49 @@ If you want to download the source, no building is required. The best way to dow
```sh ```sh
git clone https://github.com/FlamedDogo99/EaglerMobile.git git clone https://github.com/FlamedDogo99/EaglerMobile.git
``` ```
## Controls
Eagler Mobile provides a variety of mobile controls and gestures to navigate the EaglerCraft client. While many controls mimic Minecraft Pocket Edition's controls, other controls have been added to interact with Minecraft Java Edition features.
<details>
<summary>See list of controls</summary>
|Input / Gesture|Result|Additional Details|
|:-:|:- |:- |
|![Walk forward button](images/up.png)| Walk forwards | **On Press**: Shows strafe buttons |
|![Strafe left](images/strafeLeft.png)| Strafe left | Hidden without walking forward |
|![Strafe right](images/strafeRight.png)| Strafe right | Hidden without walking forward |
|![Walk left button](images/left.png)| Walk left |
|![Walk right button](images/right.png)| Walk right |
|![Walk backwords button](images/left.png)| Walk backwords |
|![Jump button](images/jumpButton.png)| Jump | **Double Tap**: Fly
|![Crouch button](images/crouch.png)| Crouch | **On Hold**: Locks button |
|![Sprint button](images/sprint.png)| Sprint | **On Hold**: Locks button |
|![Break block button](images/attack.png)| Break block |
|![Place block button](images/place.png)| Place block |
|![Select block button](images/select.png)| Select block |
|![Inventory button](images/inventory.png)| Open inventory |
|![Drop item button](images/drop.png)| Drop item |
|![Scroll left button](images/scrollLeft.png)| Scroll left |
|![Scroll right button](images/scrollRight.png)| Scroll Right |
|![Pause button](images/pauseButton.png)| Pause |
|![Chat button](images/chat.png)| Open chat |
|![Change perspective button](images/perspective5.png)| Change perspective |
|![Screenshot button](images/screenshot.png)| Take screenshot |
|![Toggle coordinates button](images/compass.png)| Toggle coordinates |
|![Back button](images/backButton.png)| Go back |
|![Keyboard button](images/keyboard.png)| Toggle keyboard |
|Short touch| Primary click | **On Drag**: Mouse movement
|Long touch| Secondary click | **On Drag**: Mouse movement
|Two finger touch| Scroll |
</details>
> [!WARNING]
> Because Eagler Mobile does not directly inject code into the EaglerCraft client, Eagler Mobile's touch controls only work for the standard control scheme that the EaglerCraft client is set to start with. Editing the controls in the EaglerCraft client could create unintended behavior.
## Contributing ## Contributing
### Suggestions and bug reports ### Suggestions and bug reports

View File

@ -6,7 +6,7 @@
// @downloadURL https://raw.githubusercontent.com/FlamedDogo99/EaglerMobile/main/eaglermobile.user.js // @downloadURL https://raw.githubusercontent.com/FlamedDogo99/EaglerMobile/main/eaglermobile.user.js
// @license Apache License 2.0 - http://www.apache.org/licenses/ // @license Apache License 2.0 - http://www.apache.org/licenses/
// @match https://eaglercraft.com/mc/* // @match https://eaglercraft.com/mc/*
// @version 3.0.4 // @version 3.0.5
// @updateURL https://raw.githubusercontent.com/FlamedDogo99/EaglerMobile/main/eaglermobile.user.js // @updateURL https://raw.githubusercontent.com/FlamedDogo99/EaglerMobile/main/eaglermobile.user.js
// @run-at document-start // @run-at document-start
// @grant unsafeWindow // @grant unsafeWindow
@ -18,13 +18,14 @@ try {
unsafeWindow.console.warn("DANGER: This userscript is using unsafeWindow. Unsafe websites could potentially use this to gain access to data and other content that the browser normally wouldn't allow!") unsafeWindow.console.warn("DANGER: This userscript is using unsafeWindow. Unsafe websites could potentially use this to gain access to data and other content that the browser normally wouldn't allow!")
Object.defineProperty(window, "clientWindow", { Object.defineProperty(window, "clientWindow", {
value: unsafeWindow value: unsafeWindow
}); }); // If this is a userscript, use unsafeWindow
} catch { } catch {
Object.defineProperty(window, "clientWindow", { Object.defineProperty(window, "clientWindow", {
value: window value: window
}); }); // If this is plain javascript, use window
} }
// To-do: remove the mobile check is implement the dynamic enabling and disabling of individual features clientWindow.console.log("Eagler Mobile v3.0.4")
// TODO: remove the mobile check is implement the dynamic enabling and disabling of individual features
function isMobile() { function isMobile() {
try { try {
document.createEvent("TouchEvent"); document.createEvent("TouchEvent");
@ -36,18 +37,28 @@ function isMobile() {
if(!isMobile()) { if(!isMobile()) {
alert("WARNING: This script was created for mobile, and may break functionality in non-mobile browsers!"); alert("WARNING: This script was created for mobile, and may break functionality in non-mobile browsers!");
} }
// TODO: consolidate all of these into a single object?
clientWindow.crouchLock = false; // Used for crouch mobile control clientWindow.crouchLock = false; // Used for crouch mobile control
clientWindow.sprintLock = false; // Used for sprint mobile control clientWindow.sprintLock = false; // Used for sprint mobile control
clientWindow.keyboardFix = false; // keyboardFix ? "Standard Keyboard" : "Compatibility Mode" clientWindow.keyboardFix = false; // keyboardFix ? "Standard Keyboard" : "Compatibility Mode"
clientWindow.inputFix = false; // If true, Duplicate Mode clientWindow.inputFix = false; // If true, Duplicate Mode
clientWindow.blockNextInput = false; // Used for Duplicate Mode clientWindow.blockNextInput = false; // Used for Duplicate Mode
clientWindow.hiddenInputFocused = false; clientWindow.hiddenInputFocused = false; // Used for keyboard display on mobile
clientWindow.canvasTouchMode = 0; // Used for canvas touch handling
// Used for changing touchmove events to mousemove events /*
var previousTouchX = null; 0 Idle
var previousTouchY = null; 1 Touch initiated
var startTouchX = null; 2 Primary touch
3 Secondary touch
4 Scroll
5 Finished
*/
clientWindow.canvasTouchStartX = null;
clientWindow.canvasTouchStartY = null;
clientWindow.canvasTouchPreviousX = null;
clientWindow.canvasTouchPreviousY = null;
clientWindow.canvasPrimaryID = null;
clientWindow.buttonTouchStartX = null;
// charCodeAt is designed for unicode characters, and doesn't match the behavior of the keyCodes used by KeyboardEvents, thus necessitating this function // charCodeAt is designed for unicode characters, and doesn't match the behavior of the keyCodes used by KeyboardEvents, thus necessitating this function
String.prototype.toKeyCode = function() { String.prototype.toKeyCode = function() {
@ -61,12 +72,12 @@ Object.defineProperty(EventTarget.prototype, "addEventListener", {
value: function (type, fn, ...rest) { value: function (type, fn, ...rest) {
if(type == 'keydown') { // Check if a keydown event is being added if(type == 'keydown') { // Check if a keydown event is being added
_addEventListener.call(this, type, function(...args) { _addEventListener.call(this, type, function(...args) {
if(!args[0].isValid && clientWindow.keyboardFix) { // Inject check for isValid flag and Compatibility Mode if(args[0].isTrusted && clientWindow.keyboardFix) { // When we are in compatibility mode, we ignore all trusted keyboard events
return; // When we are in Compatibility Mode, standard key presses will be ignored return;
} }
return fn.apply(this, args); // Appends the rest of the function specified by addEventListener return fn.apply(this, args); // Appends the rest of the function specified by addEventListener
}, ...rest); }, ...rest);
} else { // If it's not a keydown event, behave like normal } else { // If it's not a keydown event, behave like normal (hopefully)
_addEventListener.call(this, type, fn, ...rest); _addEventListener.call(this, type, fn, ...rest);
} }
} }
@ -87,18 +98,24 @@ Event.prototype.preventDefault = function(shouldBypass) {
function keyEvent(name, state) { function keyEvent(name, state) {
const charCode = name.toKeyCode(); const charCode = name.toKeyCode();
let evt = new KeyboardEvent(state, { let evt = new KeyboardEvent(state, {
key: name, "key": name,
keyCode: charCode, "keyCode": charCode,
which: charCode "which": charCode
}); });
evt.isValid = true; // Disables fix for bad keyboard input
clientWindow.dispatchEvent(evt); clientWindow.dispatchEvent(evt);
} }
function mouseEvent(number, state, canvas) { function mouseEvent(number, state, element, event = {"clientX": 0, "clientY" : 0, "screenX": 0, "screenY": 0}) {
canvas.dispatchEvent(new PointerEvent(state, {"button": number})) element.dispatchEvent(new PointerEvent(state, {
"button": number,
"buttons": number,
"clientX": event.clientX,
"clientY" : event.clientY,
"screenX": event.screenX,
"screenY": event.screenY
}));
} }
function wheelEvent(canvas, delta) { function wheelEvent(element, delta) {
canvas.dispatchEvent(new WheelEvent("wheel", { element.dispatchEvent(new WheelEvent("wheel", {
"wheelDeltaY": delta "wheelDeltaY": delta
})); }));
} }
@ -168,8 +185,8 @@ const _createElement = document.createElement;
document.createElement = function(type, ignore) { document.createElement = function(type, ignore) {
this._createElement = _createElement; this._createElement = _createElement;
var element = this._createElement(type); var element = this._createElement(type);
if(type == "input" && !ignore) { if(type == "input" && !ignore) { // We set the ingore flag to true when we create the hiddenInput
document.querySelectorAll('#fileUpload').forEach(e => e.parentNode.removeChild(e)); document.querySelectorAll('#fileUpload').forEach(e => e.parentNode.removeChild(e)); // Get rid of any left over fileUpload inputs
element.id = "fileUpload"; element.id = "fileUpload";
element.addEventListener('change', function(e) { element.addEventListener('change', function(e) {
element.hidden = true; element.hidden = true;
@ -237,30 +254,100 @@ waitForElm('canvas').then(() => {insertCanvasElements()});
function insertCanvasElements() { function insertCanvasElements() {
// Translates touchmove events to mousemove events when inGame, and touchmove events to wheele events when inMenu // Translates touchmove events to mousemove events when inGame, and touchmove events to wheele events when inMenu
var canvas = document.querySelector('canvas'); var canvas = document.querySelector('canvas');
canvas.addEventListener("touchmove", function(e) { canvas.addEventListener("touchstart", function(e) {
e.preventDefault(); if(clientWindow.canvasTouchMode < 2) { // If a touch is initiated but not assigned
const touch = e.targetTouches[0]; // We can get away with this because every other touch event will be on different elements if(clientWindow.canvasPrimaryID == null) {
clientWindow.canvasTouchMode = 1;
const primaryTouch = e.changedTouches[0];
clientWindow.canvasPrimaryID = primaryTouch.identifier
canvasTouchStartX = primaryTouch.clientX;
canvasTouchStartY = primaryTouch.clientY;
canvasTouchPreviousX = canvasTouchStartX
canvasTouchPreviousY = canvasTouchStartY
if (!previousTouchX) { clientWindow.touchTimer = setTimeout(function(e) {
previousTouchX = touch.pageX; // If our touch is still set to initiaited, set it to secondary touch
previousTouchY = touch.pageY; if(clientWindow.canvasTouchMode == 1) {
clientWindow.canvasTouchMode = 3;
mouseEvent(2, "mousedown", canvas, primaryTouch)
if(clientWindow.fakelock) { // We only dispatch mouseup inGame because we want to be able to click + drag items in GUI's
mouseEvent(2, "mouseup", canvas, primaryTouch)
}
}
}, 300);
} else if(clientWindow.canvasTouchMode == 1 && !clientWindow.fakelock) { // If we already have a primary touch, it means we're using two fingers
clientWindow.canvasTouchMode = 4;
clearTimeout(clientWindow.crouchTimer); // TODO: Find out why this isn't redudnant
}
} }
e.movementX = touch.pageX - previousTouchX;
e.movementY = touch.pageY - previousTouchY;
var evt = clientWindow.fakelock ? new MouseEvent("mousemove", {movementX: e.movementX, movementY: e.movementY}) : new WheelEvent("wheel", {"wheelDeltaY": e.movementY});
canvas.dispatchEvent(evt);
previousTouchX = touch.pageX;
previousTouchY = touch.pageY;
}, false); }, false);
canvas.addEventListener("touchend", function(e) { canvas.addEventListener("touchmove", function(e) {
previousTouchX = null; e.preventDefault() // Prevents window zoom when using two fingers
previousTouchY = null; var primaryTouch;
}, false) for (let touchIndex = 0; touchIndex < e.targetTouches.length; touchIndex++) { // Iterate through our touches to find a touch event matching the primary touch ID
//Updates button visibility on load if(e.targetTouches[touchIndex].identifier == clientWindow.canvasPrimaryID) {
setButtonVisibility(clientWindow.fakelock != null); primaryTouch = e.targetTouches[touchIndex];
// Adds all of the touch screen controls break;
// Theres probably a better way to do this but this works for now }
}
if(primaryTouch) {
primaryTouch.distanceX = primaryTouch.clientX - canvasTouchStartX;
primaryTouch.distanceY = primaryTouch.clientY - canvasTouchStartY;
primaryTouch.squaredNorm = (primaryTouch.distanceX * primaryTouch.distanceX) + (primaryTouch.distanceY * primaryTouch.distanceY);
primaryTouch.movementX = primaryTouch.clientX - canvasTouchPreviousX;
primaryTouch.movementY = primaryTouch.clientY - canvasTouchPreviousY;
if(clientWindow.canvasTouchMode == 1) { // If the primary touch is still only initiated
if (primaryTouch.squaredNorm > 25) { // If our touch becomes a touch + drag
clearTimeout(clientWindow.crouchTimer);
clientWindow.canvasTouchMode = 2;
if(!clientWindow.fakelock) { // When we're inGame, we don't want to be placing blocks when we are moving the camera around
mouseEvent(1, "mousedown", canvas, primaryTouch);
}
}
} else { // If our touch is primary, secondary, scroll or finished
if(clientWindow.canvasTouchMode == 4) { // If our touch is scrolling
wheelEvent(canvas, primaryTouch.movementY)
} else {
canvas.dispatchEvent(new MouseEvent("mousemove", {
"clientX": primaryTouch.clientX,
"clientY": primaryTouch.clientY,
"screenX": primaryTouch.screenX,
"screenY": primaryTouch.screenY, // The top four are used for item position when in GUI's, the bottom two are for moving the camera inGame
"movementX": primaryTouch.movementX,
"movementY": primaryTouch.movementY
}));
}
}
canvasTouchPreviousX = primaryTouch.clientX
canvasTouchPreviousY = primaryTouch.clientY
}
}, false);
function canvasTouchEnd(e) {
for(let touchIndex = 0; touchIndex < e.changedTouches.length; touchIndex++) { // Iterate through changed touches to find primary touch
if(e.changedTouches[touchIndex].identifier == clientWindow.canvasPrimaryID) {
let primaryTouch = e.changedTouches[touchIndex]
// When any of the controlling fingers go away, we want to wait until we aren't receiving any other touch events
if(clientWindow.canvasTouchMode == 2) {
mouseEvent(1, "mouseup", canvas, primaryTouch)
} else if (clientWindow.canvasTouchMode == 3) {
e.preventDefault(); // This prevents some mobile devices from dispatching a mousedown + mouseup event after a touch is ended
mouseEvent(2, "mouseup", canvas, primaryTouch)
}
clientWindow.canvasTouchMode = 5;
}
}
if(e.targetTouches.length == 0) { // We want to wait until all fingers are off the canvas before we reset for the next cycle
clientWindow.canvasTouchMode = 0;
clientWindow.canvasPrimaryID = null;
}
}
canvas.addEventListener("touchend", canvasTouchEnd, false);
canvas.addEventListener("touchcancel", canvasTouchEnd, false); // TODO: Find out why this is different than touchend
setButtonVisibility(clientWindow.fakelock != null); //Updates our mobile controls when the canvas finally loads
// All of the touch buttons
let strafeRightButton = createTouchButton("strafeRightButton", "inGame", "div"); let strafeRightButton = createTouchButton("strafeRightButton", "inGame", "div");
strafeRightButton.style.cssText = "left:20vh;bottom:20vh;" strafeRightButton.style.cssText = "left:20vh;bottom:20vh;"
document.body.appendChild(strafeRightButton); document.body.appendChild(strafeRightButton);
@ -268,7 +355,7 @@ function insertCanvasElements() {
strafeLeftButton.style.cssText = "left:0vh;bottom:20vh;" strafeLeftButton.style.cssText = "left:0vh;bottom:20vh;"
document.body.appendChild(strafeLeftButton); document.body.appendChild(strafeLeftButton);
let forwardButton = createTouchButton("forwardButton", "inGame", "div"); let forwardButton = createTouchButton("forwardButton", "inGame", "div"); // We use a div here so can use the targetTouches property of touchmove events. If we didn't it would require me to make an actual touch handler and I don't want to
forwardButton.style.cssText = "left:10vh;bottom:20vh;" forwardButton.style.cssText = "left:10vh;bottom:20vh;"
forwardButton.addEventListener("touchstart", function(e){ forwardButton.addEventListener("touchstart", function(e){
keyEvent("w", "keydown"); keyEvent("w", "keydown");
@ -278,12 +365,12 @@ function insertCanvasElements() {
}, false); }, false);
forwardButton.addEventListener("touchmove", function(e) { forwardButton.addEventListener("touchmove", function(e) {
e.preventDefault(); e.preventDefault();
const touch = e.targetTouches[0]; // We can get away with this because every other touch event will be on different elements const touch = e.targetTouches[0]; // We are just hoping that the user will only ever use one finger on the forward button
if (!startTouchX) { if (!buttonTouchStartX) { // TODO: move this to a touchstart event handler
startTouchX = touch.pageX; buttonTouchStartX = touch.pageX;
} }
let movementX = touch.pageX - startTouchX; let movementX = touch.pageX - buttonTouchStartX;
if((movementX * 10) > clientWindow.innerHeight) { if((movementX * 10) > clientWindow.innerHeight) {
strafeRightButton.classList.add("active"); strafeRightButton.classList.add("active");
strafeLeftButton.classList.remove("active"); strafeLeftButton.classList.remove("active");
@ -302,7 +389,7 @@ function insertCanvasElements() {
} }
}, false); }, false);
forwardButton.addEventListener("touchend", function(e) { forwardButton.addEventListener("touchend", function(e) {
keyEvent("w", "keyup"); keyEvent("w", "keyup"); // Luckily, it doesn't seem like eagler cares if we dispatch extra keyup events, so we can get away with just dispatching all of them here
keyEvent("d", "keyup"); keyEvent("d", "keyup");
keyEvent("a", "keyup"); keyEvent("a", "keyup");
strafeRightButton.classList.remove("active"); strafeRightButton.classList.remove("active");
@ -311,7 +398,7 @@ function insertCanvasElements() {
strafeLeftButton.classList.add("hide"); strafeLeftButton.classList.add("hide");
forwardButton.classList.remove("active"); forwardButton.classList.remove("active");
startTouchX = null; buttonTouchStartX = null;
}, false); }, false);
strafeRightButton.classList.add("hide"); strafeRightButton.classList.add("hide");
strafeLeftButton.classList.add("hide"); strafeLeftButton.classList.add("hide");
@ -344,7 +431,7 @@ function insertCanvasElements() {
crouchButton.addEventListener("touchstart", function(e){ crouchButton.addEventListener("touchstart", function(e){
keyEvent("shift", "keydown") keyEvent("shift", "keydown")
clientWindow.crouchLock = clientWindow.crouchLock ? null : false clientWindow.crouchLock = clientWindow.crouchLock ? null : false
clientWindow.crouchTimer = setTimeout(function(e) { clientWindow.crouchTimer = setTimeout(function(e) { // Allows us to lock the button after a long press
clientWindow.crouchLock = (clientWindow.crouchLock != null); clientWindow.crouchLock = (clientWindow.crouchLock != null);
crouchButton.classList.toggle('active'); crouchButton.classList.toggle('active');
}, 1000); }, 1000);
@ -361,8 +448,14 @@ function insertCanvasElements() {
document.body.appendChild(crouchButton); document.body.appendChild(crouchButton);
let inventoryButton = createTouchButton("inventoryButton", "inGame"); let inventoryButton = createTouchButton("inventoryButton", "inGame");
inventoryButton.style.cssText = "right:0vh;bottom:30vh;" inventoryButton.style.cssText = "right:0vh;bottom:30vh;"
inventoryButton.addEventListener("touchstart", function(e){keyEvent("e", "keydown")}, false); inventoryButton.addEventListener("touchstart", function(e) {
inventoryButton.addEventListener("touchend", function(e){keyEvent("e", "keyup")}, false); keyEvent("e", "keydown");
}, false);
inventoryButton.addEventListener("touchend", function(e){
keyEvent("shift", "keydown"); // Sometimes shift gets stuck on, which interferes with item manipulation in GUI's
keyEvent("shift", "keyup"); // Sometimes shift gets stuck on, which interferes with item manipulation in GUI's
keyEvent("e", "keyup");
}, false);
document.body.appendChild(inventoryButton); document.body.appendChild(inventoryButton);
let exitButton = createTouchButton("exitButton", "inMenu"); let exitButton = createTouchButton("exitButton", "inMenu");
exitButton.style.cssText = "top: 0vh; margin: auto; left: 0vh; right:8vh; width: 8vh; height: 8vh;" exitButton.style.cssText = "top: 0vh; margin: auto; left: 0vh; right:8vh; width: 8vh; height: 8vh;"
@ -450,7 +543,7 @@ function insertCanvasElements() {
} }
} }
}, false); }, false);
hiddenInput.addEventListener("blur", function(e) { hiddenInput.addEventListener("blur", function(e) { // Updates clientWindow.hiddenInputFocused to reflect the actual state of the focus
clientWindow.hiddenInputFocused = false; clientWindow.hiddenInputFocused = false;
}); });
document.body.appendChild(hiddenInput); document.body.appendChild(hiddenInput);
@ -568,38 +661,46 @@ customStyle.textContent = `
width: 10vh; width: 10vh;
height: 10vh; height: 10vh;
font-size: 4vh; font-size: 4vh;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
line-height: 0px; line-height: 0px;
padding: 0px; padding: 0px;
background-color: transparent; background-color: transparent;
box-sizing: content-box; box-sizing: content-box;
image-rendering: pixelated; image-rendering: pixelated;
background-size: cover; background-size: cover;
outline:none;
box-shadow: none; box-shadow: none;
border: none; border: none;
touch-action: pan-x pan-y;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
outline: none;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
} }
.mobileControl:active, .mobileControl.active { .mobileControl:active, .mobileControl.active {
position: absolute; position: absolute;
width: 10vh; width: 10vh;
height: 10vh; height: 10vh;
font-size: 4vh; font-size: 4vh;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
line-height: 0px; line-height: 0px;
padding: 0px; padding: 0px;
background-color: transparent; background-color: transparent;
color: #ffffff;
text-shadow: 0.35vh 0.35vh #000000;
box-sizing: content-box; box-sizing: content-box;
image-rendering: pixelated; image-rendering: pixelated;
background-size: contain, cover; background-size: cover;
outline:none;
box-shadow: none; box-shadow: none;
border: none; border: none;
touch-action: pan-x pan-y;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
outline: none;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
} }
html, body, canvas { html, body, canvas {
height: 100svh !important; height: 100svh !important;