This commit is contained in:
zees-dev 2026-02-03 13:20:53 +11:00 committed by GitHub
commit c173f27f97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1448 additions and 99 deletions

View File

@ -1650,3 +1650,176 @@
display: flex;
align-items: center;
}
/* Virtual Gamepad Edit Mode */
.ejs_virtualGamepad_edit_container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10000;
background: rgba(0, 0, 0, 0.5);
pointer-events: none;
}
/* Enable pointer events for toolbar */
.ejs_virtualGamepad_edit_toolbar {
pointer-events: auto;
}
/* Style for original elements when in edit mode */
.ejs_virtualGamepad_edit_mode .ejs_virtualGamepad_button {
outline: 2px dashed rgba(255, 255, 255, 0.8);
outline-offset: 2px;
cursor: move;
touch-action: none;
pointer-events: none;
}
/* Dpad container styling in edit mode */
.ejs_virtualGamepad_edit_mode .ejs_dpad_main {
pointer-events: none;
}
.ejs_virtualGamepad_edit_mode [class*="b_dpad"],
.ejs_virtualGamepad_edit_mode [class*="b_stick"] {
outline: 2px dashed rgba(255, 255, 255, 0.8);
outline-offset: 2px;
cursor: move;
touch-action: none;
pointer-events: none;
}
.ejs_virtualGamepad_edit_toolbar {
position: absolute;
top: 0;
left: 0;
width: 100%;
background: linear-gradient(rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0));
padding: 5px 5px 15px;
display: flex;
justify-content: center;
gap: 4px;
z-index: 10010;
pointer-events: none;
}
.ejs_virtualGamepad_edit_toolbar button {
background-color: rgba(var(--ejs-primary-color), 0.9);
border: none;
border-radius: 3px;
padding: 4px 6px;
color: #fff;
cursor: pointer;
touch-action: manipulation;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
pointer-events: auto;
z-index: 10005;
position: relative;
}
.ejs_virtualGamepad_edit_toolbar button:hover {
opacity: 0.85;
}
.ejs_virtualGamepad_edit_toolbar button:active {
opacity: 0.7;
}
.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_close {
background-color: #28a745;
}
.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_default {
background-color: #e6a817;
}
.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_clear {
background-color: #dc3545;
}
.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_undo,
.ejs_virtualGamepad_edit_toolbar button.ejs_edit_btn_redo {
background-color: #555;
}
.ejs_virtualGamepad_edit_toolbar button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ejs_virtualGamepad_resize_handle {
position: absolute;
width: 16px;
height: 16px;
background: #007bff;
border: 2px solid #fff;
border-radius: 50%;
z-index: 10003;
cursor: se-resize;
touch-action: none;
pointer-events: auto;
}
.ejs_virtualGamepad_resize_handle.ejs_resize_se {
bottom: -8px;
right: -8px;
}
/* Overlay container for edit mode */
.ejs_virtualGamepad_overlay_container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10002;
}
/* Individual overlay element (captures touch events for dragging) */
.ejs_edit_overlay_element {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
touch-action: none;
cursor: move;
box-sizing: border-box;
outline: 2px dashed rgba(255, 255, 255, 0.9);
outline-offset: 2px;
opacity: 0.9;
user-select: none;
}
/* Zone (joystick) overlay styling */
.ejs_edit_overlay_element[data-type="zone"] {
border-radius: 50%;
background: radial-gradient(circle, rgba(100,100,100,0.8) 0%, rgba(60,60,60,0.9) 100%);
border: 3px solid rgba(255,255,255,0.5);
box-shadow: 0 0 10px rgba(0,0,0,0.5), inset 0 0 20px rgba(0,0,0,0.3);
}
.ejs_edit_overlay_element span {
pointer-events: none;
user-select: none;
}
/* Highlight when dragging */
.ejs_edit_overlay_element.ejs_dragging {
outline-color: #007bff !important;
background: rgba(0, 123, 255, 0.3) !important;
}
/* Fade original gamepad during editing */
.ejs_virtualGamepad_edit_mode_faded {
opacity: 0.3;
}

View File

@ -1,5 +1,6 @@
(async function() {
const scripts = [
"virtualGamepadEditor.js",
"emulator.js",
"nipplejs.js",
"shaders.js",

View File

@ -335,5 +335,10 @@
"System Save interval": "System Save interval",
"Menu Bar Button": "Menu Bar Button",
"visible": "visible",
"hidden": "hidden"
"hidden": "hidden",
"EDIT_VIRTUAL_GAMEPAD": "Edit Virtual Gamepad",
"UNDO_VIRTUAL_GAMEPAD": "Undo",
"REDO_VIRTUAL_GAMEPAD": "Redo",
"RESET_TO_DEFAULT_VIRTUAL_GAMEPAD": "Reset to Default",
"SAVE_AND_CLOSE_VIRTUAL_GAMEPAD": "Save & Close"
}

View File

@ -90,6 +90,9 @@ class EmulatorJS {
data[i].elem.removeEventListener(data[i].listener, data[i].cb);
}
}
findElementId(classList) {
return [...classList].find(cls => cls.startsWith("b_"));
}
downloadFile(path, progressCB, notWithPath, opts) {
return new Promise(async cb => {
const data = this.toData(path); //check other data types
@ -1071,6 +1074,12 @@ class EmulatorJS {
}
this.setupSettingsMenu();
this.loadSettings();
// Apply virtual gamepad layout after settings are loaded
if (this.virtualGamepadLayout && this.virtualGamepadDefaults) {
// Measure default positions BEFORE applying saved layout
this.measureVirtualGamepadDefaults();
this.applyVirtualGamepadLayout();
}
this.updateCheatUI();
this.updateGamepadLabels();
if (!this.muted) this.setVolume(this.volume);
@ -3870,6 +3879,9 @@ class EmulatorJS {
const blockCSS = "height:31px;text-align:center;border:1px solid #ccc;border-radius:5px;line-height:31px;";
const controlSchemeCls = `cs_${this.getControlScheme()}`.split(/\s/g).join("_");
// Store default positions for each element
this.virtualGamepadDefaults = {};
for (let i = 0; i < info.length; i++) {
if (info[i].type !== "button") continue;
if (leftHandedMode && ["left", "right"].includes(info[i].location)) {
@ -3907,13 +3919,25 @@ class EmulatorJS {
button.style = style;
button.innerText = info[i].text;
button.classList.add("ejs_virtualGamepad_button", controlSchemeCls);
if (info[i].id) {
button.classList.add(`b_${info[i].id}`);
}
const buttonId = info[i].id
? `b_${info[i].id}`
: `b_${(info[i].text || info[i].input_value || `button_${i}`).toString().replace(/\s+/g, '_')}`;
button.classList.add(buttonId);
elems[info[i].location].appendChild(button);
// Store default position (styles and absolute position after render)
this.virtualGamepadDefaults[buttonId] = {
left: button.style.left,
right: button.style.right,
top: button.style.top,
transform: "",
width: button.style.width,
height: button.style.height,
element: button // Store reference to calculate absolute pos later
};
const value = info[i].input_new_cores || info[i].input_value;
let downValue = info[i].joystickInput === true ? 0x7fff : 1;
this.addEventListener(button, "touchstart touchend touchcancel", (e) => {
if (this.virtualGamepadEditMode) return;
e.preventDefault();
if (e.type === "touchend" || e.type === "touchcancel") {
e.target.classList.remove("ejs_virtualGamepad_button_down");
@ -3948,6 +3972,7 @@ class EmulatorJS {
dpadMain.appendChild(horizontal);
const updateCb = (e) => {
if (this.virtualGamepadEditMode) return;
e.preventDefault();
const touch = e.targetTouches[0];
if (!touch) return;
@ -3995,6 +4020,7 @@ class EmulatorJS {
callback(up, down, left, right);
}
const cancelCb = (e) => {
if (this.virtualGamepadEditMode) return;
e.preventDefault();
dpadMain.classList.remove("ejs_dpad_up_pressed");
dpadMain.classList.remove("ejs_dpad_down_pressed");
@ -4035,11 +4061,25 @@ class EmulatorJS {
style += "top:" + dpad.top + ";";
}
elem.classList.add(controlSchemeCls);
if (dpad.id) {
elem.classList.add(`b_${dpad.id}`);
}
const dpadId = dpad.id
? `b_${dpad.id}`
: `b_dpad_${dpad.location || index}`;
elem.classList.add(dpadId);
elem.style = style;
elems[dpad.location].appendChild(elem);
// Store default position for dpad container
this.virtualGamepadDefaults[dpadId] = {
left: elem.style.left,
right: elem.style.right,
top: elem.style.top,
transform: "",
width: elem.style.width,
height: elem.style.height,
container: elem,
// measureElement will be set after dpad is created
};
createDPad({
container: elem,
event: (up, down, left, right) => {
@ -4055,6 +4095,12 @@ class EmulatorJS {
this.gameManager.simulateInput(0, dpad.inputValues[3], right);
}
});
// Store reference to inner element for measuring
const dpadMain = elem.querySelector(".ejs_dpad_main");
if (dpadMain) {
this.virtualGamepadDefaults[dpadId].element = dpadMain;
}
})
info.forEach((zone, index) => {
@ -4071,12 +4117,14 @@ class EmulatorJS {
}
const elem = this.createElement("div");
this.addEventListener(elem, "touchstart touchmove touchend touchcancel", (e) => {
if (this.virtualGamepadEditMode) return;
e.preventDefault();
});
elem.classList.add(controlSchemeCls);
if (zone.id) {
elem.classList.add(`b_${zone.id}`);
}
const zoneId = zone.id
? `b_${zone.id}`
: `b_zone_${zone.location || index}`;
elem.classList.add(zoneId);
elems[zone.location].appendChild(elem);
const zoneObj = nipplejs.create({
"zone": elem,
@ -4087,95 +4135,28 @@ class EmulatorJS {
},
"color": zone.color || "red"
});
zoneObj.on("end", () => {
this.gameManager.simulateInput(0, zone.inputValues[0], 0);
this.gameManager.simulateInput(0, zone.inputValues[1], 0);
this.gameManager.simulateInput(0, zone.inputValues[2], 0);
this.gameManager.simulateInput(0, zone.inputValues[3], 0);
});
zoneObj.on("move", (e, info) => {
const degree = info.angle.degree;
const distance = info.distance;
if (zone.joystickInput === true) {
let x = 0, y = 0;
if (degree > 0 && degree <= 45) {
x = distance / 50;
y = -0.022222222222222223 * degree * distance / 50;
}
if (degree > 45 && degree <= 90) {
x = 0.022222222222222223 * (90 - degree) * distance / 50;
y = -distance / 50;
}
if (degree > 90 && degree <= 135) {
x = 0.022222222222222223 * (90 - degree) * distance / 50;
y = -distance / 50;
}
if (degree > 135 && degree <= 180) {
x = -distance / 50;
y = -0.022222222222222223 * (180 - degree) * distance / 50;
}
if (degree > 135 && degree <= 225) {
x = -distance / 50;
y = -0.022222222222222223 * (180 - degree) * distance / 50;
}
if (degree > 225 && degree <= 270) {
x = -0.022222222222222223 * (270 - degree) * distance / 50;
y = distance / 50;
}
if (degree > 270 && degree <= 315) {
x = -0.022222222222222223 * (270 - degree) * distance / 50;
y = distance / 50;
}
if (degree > 315 && degree <= 359.9) {
x = distance / 50;
y = 0.022222222222222223 * (360 - degree) * distance / 50;
}
if (x > 0) {
this.gameManager.simulateInput(0, zone.inputValues[0], 0x7fff * x);
this.gameManager.simulateInput(0, zone.inputValues[1], 0);
} else {
this.gameManager.simulateInput(0, zone.inputValues[1], 0x7fff * -x);
this.gameManager.simulateInput(0, zone.inputValues[0], 0);
}
if (y > 0) {
this.gameManager.simulateInput(0, zone.inputValues[2], 0x7fff * y);
this.gameManager.simulateInput(0, zone.inputValues[3], 0);
} else {
this.gameManager.simulateInput(0, zone.inputValues[3], 0x7fff * -y);
this.gameManager.simulateInput(0, zone.inputValues[2], 0);
}
this.bindZoneEventHandlers(zoneObj, zone);
} else {
if (degree >= 30 && degree < 150) {
this.gameManager.simulateInput(0, zone.inputValues[0], 1);
} else {
window.setTimeout(() => {
this.gameManager.simulateInput(0, zone.inputValues[0], 0);
}, 30);
}
if (degree >= 210 && degree < 330) {
this.gameManager.simulateInput(0, zone.inputValues[1], 1);
} else {
window.setTimeout(() => {
this.gameManager.simulateInput(0, zone.inputValues[1], 0);
}, 30);
}
if (degree >= 120 && degree < 240) {
this.gameManager.simulateInput(0, zone.inputValues[2], 1);
} else {
window.setTimeout(() => {
this.gameManager.simulateInput(0, zone.inputValues[2], 0);
}, 30);
}
if (degree >= 300 || degree >= 0 && degree < 60) {
this.gameManager.simulateInput(0, zone.inputValues[3], 1);
} else {
window.setTimeout(() => {
this.gameManager.simulateInput(0, zone.inputValues[3], 0);
}, 30);
}
}
});
// Store default position and nipplejs config for zone
const nipple = elem.querySelector(".nipple");
const back = nipple ? nipple.querySelector(".back") : null;
// nipplejs default size is 100
const originalSize = zone.size || 100;
this.virtualGamepadDefaults[zoneId] = {
// Original nipplejs position config
originalLeft: zone.left,
originalTop: zone.top,
originalSize: originalSize,
// Zone container reference
container: elem,
nippleElement: nipple,
// nipplejs manager for recreation
nippleManager: zoneObj,
// Zone config for recreation
zoneConfig: zone,
// Use .back (outer circle) for measuring if available
element: back || nipple
};
})
if (this.touch || this.hasTouchScreen) {
@ -4208,6 +4189,248 @@ class EmulatorJS {
this.virtualGamepad.style.display = "none";
}
/** Enter virtual gamepad edit mode using the external editor class */
enterVirtualGamepadEditMode() {
if (!this.gamepadEditor && window.EJS_VirtualGamepadEditor) {
this.gamepadEditor = new window.EJS_VirtualGamepadEditor(this);
}
if (!this.gamepadEditor) return;
this.gamepadEditor.enter();
}
/** Check if virtual gamepad edit mode is active */
get isVirtualGamepadEditMode() {
return this.gamepadEditor?.isActive || false;
}
saveVirtualGamepadLayout(elements) {
const layout = {};
const editElements = elements || [];
editElements.forEach(item => {
// OverlayElement from external class has .original and .id/.type properties
const element = item.original || item.element;
const id = item.id;
const type = item.type;
if (type === "zone") {
// For zones, get the nipple position and size from virtualGamepadDefaults
const defaults = this.virtualGamepadDefaults[id];
const nipple = element.querySelector(".nipple");
if (nipple && defaults) {
layout[id] = {
left: nipple.style.left,
top: nipple.style.top,
size: defaults.currentSize || defaults.originalSize || 100
};
}
} else {
layout[id] = {
left: element.style.left,
top: element.style.top,
right: element.style.right,
transform: element.style.transform,
transformOrigin: element.style.transformOrigin
};
}
});
this.virtualGamepadLayout = layout;
this.saveSettings();
}
bindZoneEventHandlers(zoneObj, zone) {
const endHandler = () => {
if (this.virtualGamepadEditMode) return;
this.gameManager.simulateInput(0, zone.inputValues[0], 0);
this.gameManager.simulateInput(0, zone.inputValues[1], 0);
this.gameManager.simulateInput(0, zone.inputValues[2], 0);
this.gameManager.simulateInput(0, zone.inputValues[3], 0);
};
const moveHandler = (e, info) => {
if (this.virtualGamepadEditMode) return;
this.handleZoneMove(zone, info.angle.degree, info.distance);
};
zoneObj.on("end", endHandler);
zoneObj.on("move", moveHandler);
// Store references for cleanup
zoneObj._ejsHandlers = { end: endHandler, move: moveHandler };
}
unbindZoneEventHandlers(zoneObj) {
if (zoneObj._ejsHandlers) {
zoneObj.off("end", zoneObj._ejsHandlers.end);
zoneObj.off("move", zoneObj._ejsHandlers.move);
delete zoneObj._ejsHandlers;
}
}
handleZoneMove(zone, degree, distance) {
if (zone.joystickInput === true) {
let x = 0, y = 0;
if (degree > 0 && degree <= 45) {
x = distance / 50;
y = -0.022222222222222223 * degree * distance / 50;
}
if (degree > 45 && degree <= 90) {
x = 0.022222222222222223 * (90 - degree) * distance / 50;
y = -distance / 50;
}
if (degree > 90 && degree <= 135) {
x = 0.022222222222222223 * (90 - degree) * distance / 50;
y = -distance / 50;
}
if (degree > 135 && degree <= 180) {
x = -distance / 50;
y = -0.022222222222222223 * (180 - degree) * distance / 50;
}
if (degree > 135 && degree <= 225) {
x = -distance / 50;
y = -0.022222222222222223 * (180 - degree) * distance / 50;
}
if (degree > 225 && degree <= 270) {
x = -0.022222222222222223 * (270 - degree) * distance / 50;
y = distance / 50;
}
if (degree > 270 && degree <= 315) {
x = -0.022222222222222223 * (270 - degree) * distance / 50;
y = distance / 50;
}
if (degree > 315 && degree <= 359.9) {
x = distance / 50;
y = 0.022222222222222223 * (360 - degree) * distance / 50;
}
if (x > 0) {
this.gameManager.simulateInput(0, zone.inputValues[0], 0x7fff * x);
this.gameManager.simulateInput(0, zone.inputValues[1], 0);
} else {
this.gameManager.simulateInput(0, zone.inputValues[1], 0x7fff * -x);
this.gameManager.simulateInput(0, zone.inputValues[0], 0);
}
if (y > 0) {
this.gameManager.simulateInput(0, zone.inputValues[2], 0x7fff * y);
this.gameManager.simulateInput(0, zone.inputValues[3], 0);
} else {
this.gameManager.simulateInput(0, zone.inputValues[3], 0x7fff * -y);
this.gameManager.simulateInput(0, zone.inputValues[2], 0);
}
} else {
if (degree >= 30 && degree < 150) {
this.gameManager.simulateInput(0, zone.inputValues[0], 1);
} else {
window.setTimeout(() => {
this.gameManager.simulateInput(0, zone.inputValues[0], 0);
}, 30);
}
if (degree >= 210 && degree < 330) {
this.gameManager.simulateInput(0, zone.inputValues[1], 1);
} else {
window.setTimeout(() => {
this.gameManager.simulateInput(0, zone.inputValues[1], 0);
}, 30);
}
if (degree >= 120 && degree < 240) {
this.gameManager.simulateInput(0, zone.inputValues[2], 1);
} else {
window.setTimeout(() => {
this.gameManager.simulateInput(0, zone.inputValues[2], 0);
}, 30);
}
if (degree >= 300 || degree >= 0 && degree < 60) {
this.gameManager.simulateInput(0, zone.inputValues[3], 1);
} else {
window.setTimeout(() => {
this.gameManager.simulateInput(0, zone.inputValues[3], 0);
}, 30);
}
}
}
measureVirtualGamepadDefaults() {
if (this._virtualGamepadMeasured) return;
const parentRect = this.elements.parent.getBoundingClientRect();
for (const id in this.virtualGamepadDefaults) {
const def = this.virtualGamepadDefaults[id];
if (def.element) {
const rect = def.element.getBoundingClientRect();
def.absoluteLeft = rect.left - parentRect.left;
def.absoluteTop = rect.top - parentRect.top;
def.absoluteWidth = rect.width;
def.absoluteHeight = rect.height;
}
}
this._virtualGamepadMeasured = true;
}
applyVirtualGamepadLayout() {
if (!this.virtualGamepadLayout) return;
const applyToElement = (element, id) => {
if (this.virtualGamepadLayout[id]) {
const saved = this.virtualGamepadLayout[id];
if (saved.left) element.style.left = saved.left;
if (saved.top) element.style.top = saved.top;
if (saved.right !== undefined) element.style.right = saved.right;
if (saved.transform) {
element.style.transform = saved.transform;
element.style.transformOrigin = saved.transformOrigin || "top left";
}
}
};
const buttons = this.virtualGamepad.querySelectorAll(".ejs_virtualGamepad_button");
const dpads = this.virtualGamepad.querySelectorAll(".ejs_dpad_main");
const nipples = this.virtualGamepad.querySelectorAll(".nipple");
buttons.forEach(btn => {
const id = this.findElementId(btn.classList);
applyToElement(btn, id);
});
dpads.forEach(dpad => {
const dpadContainer = dpad.parentElement;
if (!dpadContainer) return;
const id = this.findElementId(dpadContainer.classList);
applyToElement(dpadContainer, id);
});
// Apply to zones (joysticks) - recreate nipplejs with saved position and size
nipples.forEach(nipple => {
const zone = nipple.parentElement;
if (!zone) return;
const id = this.findElementId(zone.classList);
const saved = this.virtualGamepadLayout[id];
const defaults = this.virtualGamepadDefaults[id];
if (!saved || !defaults || !defaults.nippleManager || !defaults.zoneConfig) return;
// Destroy and recreate nipplejs with saved position and size
this.unbindZoneEventHandlers(defaults.nippleManager);
// Remove existing nipple DOM elements before destroying to prevent memory leaks
const existingNipple = zone.querySelector(".nipple");
if (existingNipple) {
existingNipple.remove();
}
defaults.nippleManager.destroy();
const zoneConfig = defaults.zoneConfig;
const savedSize = saved.size || defaults.originalSize || 100;
const newZoneObj = nipplejs.create({
"zone": zone,
"mode": "static",
"position": {
"left": saved.left || defaults.originalLeft,
"top": saved.top || defaults.originalTop
},
"size": savedSize,
"color": zoneConfig.color || "red"
});
this.bindZoneEventHandlers(newZoneObj, zoneConfig);
// Update stored references
defaults.nippleManager = newZoneObj;
defaults.currentSize = savedSize;
defaults.nippleElement = zone.querySelector(".nipple");
const newBack = defaults.nippleElement ? defaults.nippleElement.querySelector(".back") : null;
defaults.element = newBack || defaults.nippleElement;
});
}
handleResize() {
if (this.virtualGamepad) {
if (this.virtualGamepad.style.display === "none") {
@ -4246,7 +4469,8 @@ class EmulatorJS {
const coreSpecific = {
controlSettings: this.controls,
settings: this.settings,
cheats: this.cheats
cheats: this.cheats,
virtualGamepadLayout: this.virtualGamepadLayout
}
const ejs_settings = {
volume: this.volume,
@ -4346,6 +4570,10 @@ class EmulatorJS {
if (includes) continue;
this.cheats.push(cheat);
}
// Load virtual gamepad layout
if (coreSpecific.virtualGamepadLayout) {
this.virtualGamepadLayout = coreSpecific.virtualGamepadLayout;
}
} catch(e) {
console.warn("Could not load previous settings", e);
@ -5135,6 +5363,20 @@ class EmulatorJS {
"enabled": this.localization("Enabled"),
"disabled": this.localization("Disabled")
}, "disabled", virtualGamepad, true);
// Add Edit Virtual Gamepad button
const editVirtualGamepadRow = this.createElement("div");
editVirtualGamepadRow.classList.add("ejs_settings_main_bar");
const editVirtualGamepadSpan = this.createElement("span");
editVirtualGamepadSpan.innerText = this.localization("Edit Virtual Gamepad");
editVirtualGamepadRow.appendChild(editVirtualGamepadSpan);
virtualGamepad.appendChild(editVirtualGamepadRow);
this.addEventListener(editVirtualGamepadRow, "click", () => {
this.settingsMenu.style.display = "none";
this.settingsMenuOpen = false;
this.enterVirtualGamepadEditMode();
});
checkForEmptyMenu(virtualGamepad);
}

View File

@ -0,0 +1,928 @@
/**
* Manages undo/redo history using the Command pattern.
* Stores actions as pairs of undo/redo functions that can be executed to
* reverse or replay state changes.
*
* @class EJS_HistoryManager
*/
class EJS_HistoryManager {
/**
* Creates a new history manager.
* @param {Function} onUpdate - Callback invoked after any history change
*/
constructor(onUpdate) {
this.undoStack = [];
this.redoStack = [];
this.onUpdate = onUpdate;
}
/**
* Pushes a new action onto the history stack and clears redo history.
* @param {{undo: Function, redo: Function}} action - Action with undo/redo functions
*/
push(action) {
this.undoStack.push(action);
this.redoStack = [];
this.onUpdate();
}
/**
* Undoes the most recent action.
*/
undo() {
const action = this.undoStack.pop();
if (action) {
action.undo();
this.redoStack.push(action);
this.onUpdate();
}
}
/**
* Redoes the most recently undone action.
*/
redo() {
const action = this.redoStack.pop();
if (action) {
action.redo();
this.undoStack.push(action);
this.onUpdate();
}
}
/**
* Clears all history.
*/
clear() {
this.undoStack = [];
this.redoStack = [];
this.onUpdate();
}
/**
* @returns {boolean} True if there are actions to undo
*/
canUndo() { return this.undoStack.length > 0; }
/**
* @returns {boolean} True if there are actions to redo
*/
canRedo() { return this.redoStack.length > 0; }
}
/**
* Represents an editable overlay for a virtual gamepad control element.
* Creates a draggable/resizable overlay positioned over the original element,
* handling position state and undo/redo integration.
*
* @class EJS_OverlayElement
*/
class EJS_OverlayElement {
/**
* Creates a new overlay element.
* @param {EJS_VirtualGamepadEditor} editor - The parent editor instance
* @param {HTMLElement} original - The original DOM element being edited
* @param {string} id - Unique identifier for this element (e.g., "b_A", "b_dpad")
* @param {string} type - Element type: "button", "dpad", or "zone"
* @param {DOMRect} rect - Bounding rectangle of the original element
* @param {DOMRect} parentRect - Bounding rectangle of the parent container
* @param {Object} [defaults] - Default position/size values for reset functionality
*/
constructor(editor, original, id, type, rect, parentRect, defaults) {
this.editor = editor;
this.original = original;
this.id = id;
this.type = type;
this.defaults = defaults || {};
this.cleanupFns = [];
this.overlay = this.createOverlay(rect, parentRect);
this.startState = this.captureState();
}
/**
* Creates the visual overlay element with styling and event handlers.
* @param {DOMRect} rect - Bounding rectangle of the original element
* @param {DOMRect} parentRect - Bounding rectangle of the parent container
* @returns {HTMLElement} The created overlay element
* @private
*/
createOverlay(rect, parentRect) {
const emu = this.editor.emu;
const overlay = emu.createElement("div");
overlay.classList.add("ejs_edit_overlay_element");
overlay.dataset.id = this.id;
overlay.dataset.type = this.type;
// Position exactly where the original is
const left = rect.left - parentRect.left;
const top = rect.top - parentRect.top;
// Dynamic position/size (must be inline)
overlay.style.left = left + "px";
overlay.style.top = top + "px";
overlay.style.width = rect.width + "px";
overlay.style.height = rect.height + "px";
// Copy visual styles from original (for buttons/dpads, not zones)
if (this.type !== "zone") {
const computedStyle = window.getComputedStyle(this.original);
overlay.style.borderRadius = computedStyle.borderRadius;
overlay.style.background = computedStyle.background;
overlay.style.border = computedStyle.border;
overlay.style.boxShadow = computedStyle.boxShadow;
overlay.style.color = computedStyle.color;
overlay.style.fontSize = computedStyle.fontSize;
overlay.style.fontFamily = computedStyle.fontFamily;
overlay.style.fontWeight = computedStyle.fontWeight;
}
// Add label
const labelText = this.type === "button" ? (this.original.innerText || "") :
this.type === "dpad" ? "D-PAD" : "STICK";
if (labelText) {
const label = emu.createElement("span");
label.innerText = labelText;
overlay.appendChild(label);
}
// Store starting state
overlay._startLeft = left;
overlay._startTop = top;
overlay._startWidth = rect.width;
overlay._startHeight = rect.height;
// Store default positions
overlay._defaultLeft = this.defaults.absoluteLeft ?? left;
overlay._defaultTop = this.defaults.absoluteTop ?? top;
overlay._defaultWidth = this.defaults.absoluteWidth ?? rect.width;
overlay._defaultHeight = this.defaults.absoluteHeight ?? rect.height;
this.setupDragHandlers(overlay);
this.setupResizeHandle(overlay);
return overlay;
}
/**
* Sets up drag interaction handlers for moving the overlay.
* @param {HTMLElement} overlay - The overlay element to make draggable
* @private
*/
setupDragHandlers(overlay) {
let startLeft, startTop, dragOldState;
const cleanup = this.editor.setupPointerInteraction(overlay, {
onStart: ({ event }) => {
if (event.target.classList.contains("ejs_virtualGamepad_resize_handle")) return false;
startLeft = parseFloat(overlay.style.left) || 0;
startTop = parseFloat(overlay.style.top) || 0;
// Capture FULL state for proper undo
dragOldState = this.captureState();
overlay.classList.add("ejs_dragging");
},
onMove: ({ deltaX, deltaY }) => {
overlay.style.left = (startLeft + deltaX) + "px";
overlay.style.top = (startTop + deltaY) + "px";
},
onEnd: () => {
overlay.classList.remove("ejs_dragging");
const newState = this.captureState();
// Copy to local const to avoid closure capturing mutable variable
const oldState = { ...dragOldState };
// Check if position actually changed
if (Math.abs(oldState.left - newState.left) > 0.5 ||
Math.abs(oldState.top - newState.top) > 0.5) {
this.editor.history.push({
undo: () => this.setState(oldState),
redo: () => this.setState(newState)
});
}
}
});
if (cleanup) this.cleanupFns.push(cleanup);
}
/**
* Creates and sets up a resize handle for the overlay.
* @param {HTMLElement} overlay - The overlay element to add resize handle to
* @private
*/
setupResizeHandle(overlay) {
const emu = this.editor.emu;
const handle = emu.createElement("div");
handle.classList.add("ejs_virtualGamepad_resize_handle", "ejs_resize_se");
overlay.appendChild(handle);
let startWidth, startHeight, resizeOldState;
const cleanup = this.editor.setupPointerInteraction(handle, {
documentEvents: true,
stopPropagation: true,
onStart: () => {
startWidth = parseFloat(overlay.style.width) || 50;
startHeight = parseFloat(overlay.style.height) || 50;
// Capture FULL state for proper undo
resizeOldState = this.captureState();
},
onMove: ({ deltaX, deltaY }) => {
const delta = (deltaX + deltaY) / 2;
overlay.style.width = Math.max(20, startWidth + delta) + "px";
overlay.style.height = Math.max(20, startHeight + delta) + "px";
},
onEnd: () => {
const newState = this.captureState();
// Copy to local const to avoid closure capturing mutable variable
const oldState = { ...resizeOldState };
// Check if size actually changed
if (Math.abs(oldState.width - newState.width) > 0.5 ||
Math.abs(oldState.height - newState.height) > 0.5) {
this.editor.history.push({
undo: () => this.setState(oldState),
redo: () => this.setState(newState)
});
}
}
});
if (cleanup) this.cleanupFns.push(cleanup);
}
/**
* Captures the current position and size state of the overlay.
* @returns {{left: number, top: number, width: number, height: number}} Current state
*/
captureState() {
return {
left: parseFloat(this.overlay.style.left) || 0,
top: parseFloat(this.overlay.style.top) || 0,
width: parseFloat(this.overlay.style.width) || 50,
height: parseFloat(this.overlay.style.height) || 50
};
}
/**
* Applies a position/size state to the overlay.
* @param {{left?: number, top?: number, width?: number, height?: number}} state - State to apply
*/
setState(state) {
if (state.left !== undefined) this.overlay.style.left = state.left + "px";
if (state.top !== undefined) this.overlay.style.top = state.top + "px";
if (state.width !== undefined) this.overlay.style.width = state.width + "px";
if (state.height !== undefined) this.overlay.style.height = state.height + "px";
}
/**
* Sets the overlay dimensions.
* @param {number} width - Width in pixels
* @param {number} height - Height in pixels
*/
setSize(width, height) {
this.overlay.style.width = width + "px";
this.overlay.style.height = height + "px";
}
/**
* Resets the overlay to its position when the editor was opened.
*/
resetToStart() {
this.setState({
left: this.overlay._startLeft,
top: this.overlay._startTop,
width: this.overlay._startWidth,
height: this.overlay._startHeight
});
}
/**
* Resets the overlay to its default position from CSS/config.
* @returns {{oldState: Object, newState: Object}|false} State change info, or false if unchanged
*/
resetToDefault() {
const oldState = this.captureState();
const newState = {
left: this.overlay._defaultLeft,
top: this.overlay._defaultTop,
width: this.overlay._defaultWidth,
height: this.overlay._defaultHeight
};
// Check if actually changed
if (Math.abs(oldState.left - newState.left) < 0.5 &&
Math.abs(oldState.top - newState.top) < 0.5 &&
Math.abs(oldState.width - newState.width) < 0.5 &&
Math.abs(oldState.height - newState.height) < 0.5) {
return false;
}
this.setState(newState);
return { oldState, newState };
}
/**
* Removes all event listeners and cleans up resources.
*/
cleanup() {
this.cleanupFns.forEach(fn => fn());
this.cleanupFns = [];
}
}
/**
* Orchestrates the virtual gamepad edit mode.
* Provides UI for repositioning and resizing virtual gamepad controls,
* with undo/redo support and persistent storage of layouts.
*
* @class EJS_VirtualGamepadEditor
*/
class EJS_VirtualGamepadEditor {
/**
* Creates a new virtual gamepad editor.
* @param {Object} emulator - The EmulatorJS instance
*/
constructor(emulator) {
this.emu = emulator;
this.elements = [];
this.history = new EJS_HistoryManager(() => this.updateToolbarState());
this.container = null;
this.overlayContainer = null;
this.toolbar = null;
this.toolbarButtons = {};
this.buttonCleanups = [];
this.wasPaused = false;
this.active = false;
}
/**
* Whether edit mode is currently active.
* @type {boolean}
*/
get isActive() {
return this.active;
}
/**
* Sets up unified pointer (touch + mouse) interaction handlers.
* @param {HTMLElement} element - Element to attach handlers to
* @param {Object} options - Handler options
* @param {Function} options.onStart - Called on pointer down, receives {x, y, event}
* @param {Function} options.onMove - Called on pointer move, receives {x, y, deltaX, deltaY, event}
* @param {Function} options.onEnd - Called on pointer up, receives {event}
* @param {boolean} [options.documentEvents=false] - Attach move/end to document
* @param {boolean} [options.stopPropagation=false] - Stop event propagation
* @returns {Function} Cleanup function to remove listeners
*/
setupPointerInteraction(element, { onStart, onMove, onEnd, documentEvents = false, stopPropagation = false }) {
let isActive = false;
let startX = 0, startY = 0;
const handleStart = (e) => {
if (isActive) return; // Prevent duplicate starts from touch+mouse
if (stopPropagation) e.stopPropagation();
e.preventDefault();
const touch = e.touches ? e.touches[0] : e;
const result = onStart({ x: touch.clientX, y: touch.clientY, event: e });
if (result === false) return;
isActive = true;
startX = touch.clientX;
startY = touch.clientY;
};
const handleMove = (e) => {
if (!isActive) return;
e.preventDefault();
const touch = e.touches ? e.touches[0] : e;
onMove({ x: touch.clientX, y: touch.clientY, deltaX: touch.clientX - startX, deltaY: touch.clientY - startY, event: e });
};
const handleEnd = (e) => {
if (!isActive) return;
isActive = false;
onEnd({ event: e });
};
element.addEventListener("touchstart", handleStart, { passive: false });
element.addEventListener("mousedown", handleStart);
const moveEndTarget = documentEvents ? document : element;
moveEndTarget.addEventListener("touchmove", handleMove, { passive: false });
moveEndTarget.addEventListener("mousemove", handleMove);
moveEndTarget.addEventListener("touchend", handleEnd);
moveEndTarget.addEventListener("touchcancel", handleEnd);
moveEndTarget.addEventListener("mouseup", handleEnd);
return () => {
element.removeEventListener("touchstart", handleStart);
element.removeEventListener("mousedown", handleStart);
moveEndTarget.removeEventListener("touchmove", handleMove);
moveEndTarget.removeEventListener("mousemove", handleMove);
moveEndTarget.removeEventListener("touchend", handleEnd);
moveEndTarget.removeEventListener("touchcancel", handleEnd);
moveEndTarget.removeEventListener("mouseup", handleEnd);
};
}
/**
* Enters edit mode, pausing the emulator and displaying the editor UI.
*/
enter() {
if (this.active) return;
this.active = true;
this.emu.virtualGamepadEditMode = true;
// Store pause state and pause emulator
this.wasPaused = this.emu.paused;
if (!this.emu.paused) {
if (this.emu.gameManager) {
this.emu.gameManager.toggleMainLoop(false);
}
this.emu.paused = true;
}
// Ensure virtual gamepad is visible
this.emu.virtualGamepad.style.display = "";
this.emu.virtualGamepad.classList.add("ejs_virtualGamepad_edit_mode");
// Hide bottom menu bar
if (this.emu.elements.menu) {
this.emu.elements.menu.style.display = "none";
}
// Create edit container
this.container = this.emu.createElement("div");
this.container.classList.add("ejs_virtualGamepad_edit_container");
this.emu.elements.parent.appendChild(this.container);
// Create toolbar
this.toolbar = this.createToolbar();
this.container.appendChild(this.toolbar);
// Create overlay container
this.overlayContainer = this.emu.createElement("div");
this.overlayContainer.classList.add("ejs_virtualGamepad_overlay_container");
this.container.appendChild(this.overlayContainer);
// Measure default positions (lazy - only when editor opens)
this.emu.measureVirtualGamepadDefaults();
// Setup overlay elements
this.setupOverlayElements();
// Dim original virtual gamepad
this.emu.virtualGamepad.classList.add("ejs_virtualGamepad_edit_mode_faded");
}
/**
* Creates the editor toolbar with undo/redo/reset/save buttons.
* @returns {HTMLElement} The toolbar element
* @private
*/
createToolbar() {
const toolbar = this.emu.createElement("div");
toolbar.classList.add("ejs_virtualGamepad_edit_toolbar");
// Icons for toolbar buttons
const UNDO_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/></svg>';
const REDO_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/></svg>';
// Prohibited/cancel icon for clear
const CLEAR_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zM4 12c0-4.42 3.58-8 8-8 1.85 0 3.55.63 4.9 1.69L5.69 16.9C4.63 15.55 4 13.85 4 12zm8 8c-1.85 0-3.55-.63-4.9-1.69L18.31 7.1C19.37 8.45 20 10.15 20 12c0 4.42-3.58 8-8 8z"/></svg>';
// Repeat/cycle arrows icon for default
const DEFAULT_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>';
// Floppy disk icon for save
const SAVE_ICON = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/></svg>';
const buttonConfigs = [
{ icon: UNDO_ICON, cls: "undo", title: "UNDO_VIRTUAL_GAMEPAD", action: () => this.history.undo(), getDisabled: () => !this.history.canUndo() },
{ icon: REDO_ICON, cls: "redo", title: "REDO_VIRTUAL_GAMEPAD", action: () => this.history.redo(), getDisabled: () => !this.history.canRedo() },
{ icon: CLEAR_ICON, cls: "clear", title: "CLEAR_VIRTUAL_GAMEPAD", action: () => this.clear(), getDisabled: () => !this.hasChangesFromStart() },
{ icon: DEFAULT_ICON, cls: "default", title: "RESET_TO_DEFAULT_VIRTUAL_GAMEPAD", action: () => this.reset() },
{ icon: SAVE_ICON, cls: "close", title: "SAVE_AND_CLOSE_VIRTUAL_GAMEPAD", action: () => this.exit(true) }
];
buttonConfigs.forEach(cfg => {
const btn = this.createButton(cfg);
this.toolbarButtons[cfg.cls] = { element: btn, getDisabled: cfg.getDisabled };
toolbar.appendChild(btn);
});
return toolbar;
}
/**
* Creates a toolbar button from configuration.
* @param {Object} cfg - Button configuration
* @param {string} [cfg.icon] - SVG icon HTML
* @param {string} [cfg.text] - Button text (localization key)
* @param {string} cfg.cls - CSS class suffix
* @param {string} [cfg.title] - Tooltip text (localization key)
* @param {Function} cfg.action - Click handler
* @param {Function} [cfg.getDisabled] - Returns whether button should be disabled
* @returns {HTMLButtonElement} The button element
* @private
*/
createButton(cfg) {
const btn = this.emu.createElement("button");
if (cfg.icon) {
btn.innerHTML = cfg.icon;
btn.classList.add("ejs_edit_icon_btn");
} else {
btn.innerText = this.emu.localization(cfg.text);
}
btn.classList.add(`ejs_edit_btn_${cfg.cls}`);
if (cfg.title) btn.title = this.emu.localization(cfg.title);
if (cfg.getDisabled) btn.disabled = cfg.getDisabled();
const clickHandler = (e) => {
e.preventDefault();
e.stopPropagation();
cfg.action();
};
btn.addEventListener("click", clickHandler);
this.buttonCleanups.push(() => btn.removeEventListener("click", clickHandler));
return btn;
}
/**
* Checks if any element has changed from its position when the editor opened.
* @returns {boolean} True if any element has moved or resized
*/
hasChangesFromStart() {
return this.elements.some(el => {
const current = el.captureState();
return Math.abs(current.left - el.overlay._startLeft) > 0.5 ||
Math.abs(current.top - el.overlay._startTop) > 0.5 ||
Math.abs(current.width - el.overlay._startWidth) > 0.5 ||
Math.abs(current.height - el.overlay._startHeight) > 0.5;
});
}
/**
* Updates toolbar button disabled states based on current conditions.
* @private
*/
updateToolbarState() {
for (const key in this.toolbarButtons) {
const { element, getDisabled } = this.toolbarButtons[key];
if (getDisabled) element.disabled = getDisabled();
}
}
/**
* Creates overlay elements for all editable virtual gamepad controls.
* @private
*/
setupOverlayElements() {
const parentRect = this.emu.elements.parent.getBoundingClientRect();
const virtualGamepad = this.emu.virtualGamepad;
const findId = (classList) => this.emu.findElementId(classList);
// Process buttons
const buttons = virtualGamepad.querySelectorAll(".ejs_virtualGamepad_button");
buttons.forEach((btn, index) => {
const id = findId(btn.classList, `b_button_${index}`);
const rect = btn.getBoundingClientRect();
const defaults = this.emu.virtualGamepadDefaults[id];
const overlayEl = new EJS_OverlayElement(this, btn, id, "button", rect, parentRect, defaults);
this.overlayContainer.appendChild(overlayEl.overlay);
this.elements.push(overlayEl);
});
// Process dpads
const dpads = virtualGamepad.querySelectorAll(".ejs_dpad_main");
dpads.forEach((dpadMain, index) => {
const dpadContainer = dpadMain.parentElement;
if (!dpadContainer) return;
const id = findId(dpadContainer.classList, `b_dpad_${index}`);
const rect = dpadMain.getBoundingClientRect();
const defaults = this.emu.virtualGamepadDefaults[id];
const overlayEl = new EJS_OverlayElement(this, dpadContainer, id, "dpad", rect, parentRect, defaults);
this.overlayContainer.appendChild(overlayEl.overlay);
this.elements.push(overlayEl);
});
// Process zones (nipplejs joysticks)
const nipples = virtualGamepad.querySelectorAll(".nipple");
const zones = [];
nipples.forEach(nipple => {
const zone = nipple.parentElement;
if (zone && !zones.includes(zone)) zones.push(zone);
});
zones.forEach((zone, index) => {
const id = findId(zone.classList, `b_zone_${index}`);
// Get dimensions from the nipple element
const nipple = zone.querySelector(".nipple");
const back = nipple ? nipple.querySelector(".back") : null;
let rect;
if (back && back.getBoundingClientRect().width > 0) {
rect = back.getBoundingClientRect();
} else if (nipple && nipple.getBoundingClientRect().width > 0) {
rect = nipple.getBoundingClientRect();
} else {
const zoneRect = zone.getBoundingClientRect();
rect = {
left: zoneRect.left + zoneRect.width / 2 - 50,
top: zoneRect.top + zoneRect.height / 2 - 50,
width: 100,
height: 100
};
}
zone.style.position = "absolute";
const defaults = this.emu.virtualGamepadDefaults[id];
const overlayEl = new EJS_OverlayElement(this, zone, id, "zone", rect, parentRect, defaults);
overlayEl.nippleElement = nipple;
this.overlayContainer.appendChild(overlayEl.overlay);
this.elements.push(overlayEl);
});
}
/**
* Clears all changes made in the current session, reverting to session start state.
*/
clear() {
// Capture current states of all elements
const oldStates = this.elements.map(el => ({ element: el, state: el.captureState() }));
// Check if any element has changed from start
const hasChanges = this.elements.some(el => {
const current = el.captureState();
return Math.abs(current.left - el.overlay._startLeft) > 0.5 ||
Math.abs(current.top - el.overlay._startTop) > 0.5 ||
Math.abs(current.width - el.overlay._startWidth) > 0.5 ||
Math.abs(current.height - el.overlay._startHeight) > 0.5;
});
if (!hasChanges) return;
// Reset all elements to start
this.elements.forEach(el => el.resetToStart());
// Capture new states (start states)
const newStates = this.elements.map(el => ({ element: el, state: el.captureState() }));
// Push single action to history
this.history.push({
undo: () => oldStates.forEach(({ element, state }) => element.setState(state)),
redo: () => newStates.forEach(({ element, state }) => element.setState(state))
});
}
/**
* Resets all elements to their default CSS/config positions.
*/
reset() {
// Capture current states of all elements
const oldStates = this.elements.map(el => ({ element: el, state: el.captureState() }));
// Check which elements will actually change
const changes = [];
this.elements.forEach((el) => {
const result = el.resetToDefault();
if (result) {
changes.push({ element: el, oldState: result.oldState, newState: result.newState });
}
});
if (changes.length === 0) return;
// Push single action to history for ALL changes
this.history.push({
undo: () => changes.forEach(({ element, oldState }) => element.setState(oldState)),
redo: () => changes.forEach(({ element, newState }) => element.setState(newState))
});
}
/**
* Exits edit mode, optionally saving changes.
* @param {boolean} save - Whether to save changes before exiting
*/
exit(save) {
if (!this.active) return;
// Apply changes if saving
if (save) {
this.applyChangesToOriginals();
this.emu.saveVirtualGamepadLayout(this.elements);
}
// Cleanup
this.elements.forEach(el => el.cleanup());
this.elements = [];
this.history.clear();
// Cleanup button event listeners
this.buttonCleanups.forEach(cleanup => cleanup());
this.buttonCleanups = [];
if (this.container) {
this.container.remove();
this.container = null;
}
// Restore virtual gamepad state
this.emu.virtualGamepad.classList.remove("ejs_virtualGamepad_edit_mode_faded");
this.emu.virtualGamepad.classList.remove("ejs_virtualGamepad_edit_mode");
// Show menu bar
if (this.emu.elements.menu) {
this.emu.elements.menu.style.display = "";
}
// Resume if wasn't paused before
if (!this.wasPaused) {
if (this.emu.gameManager) {
this.emu.gameManager.toggleMainLoop(true);
}
this.emu.paused = false;
}
this.emu.virtualGamepadEditMode = false;
this.active = false;
}
/**
* Applies overlay positions to the original DOM elements.
* @private
*/
applyChangesToOriginals() {
const parentRect = this.emu.elements.parent.getBoundingClientRect();
this.elements.forEach(overlayEl => {
const state = overlayEl.captureState();
const overlay = overlayEl.overlay;
const element = overlayEl.original;
const id = overlayEl.id;
// Get default positions
const defaultLeft = overlay._defaultLeft || 0;
const defaultTop = overlay._defaultTop || 0;
const defaultWidth = overlay._defaultWidth || state.width;
const defaultHeight = overlay._defaultHeight || state.height;
// Check if at default position
const isAtDefault = Math.abs(state.left - defaultLeft) < 1 &&
Math.abs(state.top - defaultTop) < 1 &&
Math.abs(state.width - defaultWidth) < 1 &&
Math.abs(state.height - defaultHeight) < 1;
const defaults = this.emu.virtualGamepadDefaults[id];
if (isAtDefault && defaults) {
// Restore original CSS positioning
if (overlayEl.type === "zone" && defaults.nippleManager && defaults.zoneConfig) {
this.restoreZoneToDefault(element, defaults);
} else {
element.style.left = defaults.left;
element.style.top = defaults.top;
element.style.right = defaults.right;
element.style.transform = defaults.transform || "";
element.style.width = defaults.width || "";
element.style.height = defaults.height || "";
}
return;
}
// Calculate scale factor
const scaleX = defaultWidth > 0 ? state.width / defaultWidth : 1;
const scaleY = defaultHeight > 0 ? state.height / defaultHeight : 1;
const scale = Math.max(0.1, Math.min(10, (scaleX + scaleY) / 2));
if (overlayEl.type === "zone" && defaults && defaults.nippleManager && defaults.zoneConfig) {
this.applyZonePosition(element, state, scale, defaults, parentRect);
return;
}
// Calculate offset for dpad
let offsetX = 0, offsetY = 0;
if (overlayEl.type === "dpad") {
const dpadMain = element.querySelector(".ejs_dpad_main");
if (dpadMain) {
const containerRect = element.getBoundingClientRect();
const innerRect = dpadMain.getBoundingClientRect();
offsetX = innerRect.left - containerRect.left;
offsetY = innerRect.top - containerRect.top;
}
}
// Calculate position relative to element's parent container
const elementParent = element.parentElement;
const elementParentRect = elementParent ? elementParent.getBoundingClientRect() : parentRect;
const mainParentRect = this.emu.elements.parent.getBoundingClientRect();
const containerOffsetX = elementParentRect.left - mainParentRect.left;
const containerOffsetY = elementParentRect.top - mainParentRect.top;
element.style.right = "";
element.style.left = (state.left - offsetX - containerOffsetX) + "px";
element.style.top = (state.top - offsetY - containerOffsetY) + "px";
// Apply scale transform
if (Math.abs(scale - 1) > 0.01) {
element.style.transform = `scale(${scale})`;
element.style.transformOrigin = "top left";
} else {
element.style.transform = "";
}
});
}
/**
* Recreates a nipplejs zone with new position and size.
* @param {HTMLElement} element - The zone container element
* @param {Object} defaults - Default configuration for the zone
* @param {{left: string, top: string}} position - New position (CSS values)
* @param {number} size - New size in pixels
* @private
*/
recreateZone(element, defaults, position, size) {
this.emu.unbindZoneEventHandlers(defaults.nippleManager);
defaults.nippleManager.destroy();
const zone = defaults.zoneConfig;
const newZoneObj = nipplejs.create({
"zone": element,
"mode": "static",
"position": position,
"size": size,
"color": zone.color || "red"
});
this.emu.bindZoneEventHandlers(newZoneObj, zone);
defaults.nippleManager = newZoneObj;
defaults.currentSize = size;
defaults.nippleElement = element.querySelector(".nipple");
const newBack = defaults.nippleElement ? defaults.nippleElement.querySelector(".back") : null;
defaults.element = newBack || defaults.nippleElement;
}
/**
* Restores a zone to its original default position and size.
* @param {HTMLElement} element - The zone container element
* @param {Object} defaults - Default configuration for the zone
* @private
*/
restoreZoneToDefault(element, defaults) {
const originalSize = defaults.originalSize || 100;
// Calculate position relative to zone element using stored absolute defaults
const elementRect = element.getBoundingClientRect();
const mainParentRect = this.emu.elements.parent.getBoundingClientRect();
// The default position stored is the top-left of the .back element
// The center is at absoluteLeft + absoluteWidth/2
const defaultCenterX = defaults.absoluteLeft + defaults.absoluteWidth / 2;
const defaultCenterY = defaults.absoluteTop + defaults.absoluteHeight / 2;
// Zone element's offset from main parent
const zoneOffsetX = elementRect.left - mainParentRect.left;
const zoneOffsetY = elementRect.top - mainParentRect.top;
// Position relative to zone element
const newLeftPx = defaultCenterX - zoneOffsetX;
const newTopPx = defaultCenterY - zoneOffsetY;
this.recreateZone(element, defaults, {
"left": newLeftPx + "px",
"top": newTopPx + "px"
}, originalSize);
}
/**
* Applies a new position and scale to a zone element.
* @param {HTMLElement} element - The zone container element
* @param {Object} state - Current overlay state {left, top, width, height}
* @param {number} scale - Scale factor to apply
* @param {Object} defaults - Default configuration for the zone
* @param {DOMRect} parentRect - Parent container bounding rectangle
* @private
*/
applyZonePosition(element, state, scale, defaults, parentRect) {
// nipplejs positions relative to the zone element, not its parent
const elementRect = element.getBoundingClientRect();
const mainParentRect = this.emu.elements.parent.getBoundingClientRect();
// Calculate zone element's offset from main parent
const zoneOffsetX = elementRect.left - mainParentRect.left;
const zoneOffsetY = elementRect.top - mainParentRect.top;
// Overlay center position relative to main parent
const overlayCenterX = state.left + state.width / 2;
const overlayCenterY = state.top + state.height / 2;
// Position relative to the zone element
const newLeftPx = overlayCenterX - zoneOffsetX;
const newTopPx = overlayCenterY - zoneOffsetY;
const newSize = Math.round((defaults.originalSize || 100) * scale);
this.recreateZone(element, defaults, {
"left": newLeftPx + "px",
"top": newTopPx + "px"
}, newSize);
}
}
window.EJS_VirtualGamepadEditor = EJS_VirtualGamepadEditor;