Files
desktop/src/zen/boosts/ZenBoostsEditor.mjs

1645 lines
50 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
const { gZenBoostsManager } = ChromeUtils.importESModule(
"resource:///modules/zen/boosts/ZenBoostsManager.sys.mjs"
);
export class nsZenBoostEditor {
doc = null;
editorWindow = null;
openerWindow = null;
codeEditorReady = false;
static OBSERVERS = [
"zen-boosts-kill-editor",
"zap-list-update",
"zap-state-update",
"selector-picker-state-update",
"zen-boosts-active-change",
];
/**
* Creates a new boost editor instance for the specified domain.
*
* @param {Document} doc - The document object for the editor window.
* @param {string} domain - The domain for which to edit the boost.
* @param {Window} editorWindow - The window object for the editor.
* @param {Window} openerWindow - The window object which instanced this editor.
*/
constructor(doc, domain, editorWindow, openerWindow) {
this.doc = doc;
this.editorWindow = editorWindow;
this.openerWindow = openerWindow;
this._codeEditorWidth = 450;
this._boostEditorWidth = 185;
this._pickerCallback = null;
this.isMouseDown = false;
this.wasDragging = false;
this.dragTarget = "";
this.mouseDownPosition = { x: 0, y: 0 };
this.lastDotSetPos = { x: 0, y: 0 };
this.currentBoostData = null;
this.boostInfo = null;
this.isDarkMode = this.openerWindow.gZenThemePicker.isDarkMode;
this.killOtherEditorInstances();
nsZenBoostEditor.OBSERVERS.forEach(observe => {
Services.obs.addObserver(this, observe);
});
this.init();
this.initColorScheme();
this.initColorPicker();
this.initFonts();
this.loadBoost(domain);
}
/**
* Returns the ZenBoosts JSWindowActor child for the currently selected tab.
*
* @returns {ZenBoostsChild} zenBoostsChild Boost JSActor child
*/
get zenBoostsChild() {
const linkedBrowser = this.openerWindow.gBrowser.selectedTab.linkedBrowser;
const actor =
linkedBrowser.browsingContext.currentWindowGlobal.getActor("ZenBoosts");
return actor;
}
/**
* Initializes the boost editor by setting up event listeners for all UI controls.
*/
init() {
this.editorWindow.addEventListener("unload", () => this.handleClose(), {
once: true,
});
this.doc.getElementById("zenBoostWindow").setAttribute("editor", "boost");
this.doc.getElementById("zen-boost-editor-root").style.display = "flex";
this.doc.getElementById("zen-boost-code-editor-root").style.display =
"none";
this.doc
.getElementById("zen-boost-color-contrast")
.addEventListener("input", this.onColorOptionChange.bind(this));
this.doc
.getElementById("zen-boost-color-brightness")
.addEventListener("input", this.onColorOptionChange.bind(this));
this.doc
.getElementById("zen-boost-color-saturation")
.addEventListener("input", this.onColorOptionChange.bind(this));
this.doc
.getElementById("zen-boost-case")
.addEventListener("click", this.onBoostCasePressed.bind(this));
this.doc
.getElementById("zen-boost-size")
.addEventListener("click", this.onBoostSizePressed.bind(this));
this.doc
.getElementById("zen-boost-zap")
.addEventListener("click", this.onZapButtonPressed.bind(this));
this.doc
.getElementById("zen-boost-code")
.addEventListener("click", this.onCodeButtonPressed.bind(this));
this.doc
.getElementById("zen-boost-back")
.addEventListener("click", this.onCodeBackButtonPressed.bind(this));
this.doc
.getElementById("zen-boost-disable")
.addEventListener("click", this.onToggleDisable.bind(this));
this.doc
.getElementById("zen-boost-invert")
.addEventListener("click", this.onToggleInvert.bind(this));
this.doc
.getElementById("zen-boost-controls")
.addEventListener("click", event => this.openAdvancedColorOptions(event));
this.doc
.getElementById("zen-boost-name-container")
.addEventListener("click", this.onNameTextClick.bind(this));
this.doc
.getElementById("zen-boost-close")
.addEventListener("click", this.onClosePressed.bind(this));
this.doc
.getElementById("zen-boost-shuffle")
.addEventListener("click", this.shuffleBoost.bind(this));
this.doc
.getElementById("zen-boost-css-picker")
.addEventListener("click", this.onPickerButtonPressed.bind(this));
this.doc
.getElementById("zen-boost-css-inspector")
.addEventListener("click", this.onInspectorButtonPressed.bind(this));
this.doc.addEventListener("keydown", event => {
if (
event.key === "Escape" ||
(event.key === "w" && (event.ctrlKey || event.metaKey))
) {
this.onClosePressed();
}
});
this.initialized = true;
}
/**
* Uninitializes the boost editor by cleaning up event listeners and observers.
*/
uninit() {
this.uninitColorPicker();
nsZenBoostEditor.OBSERVERS.forEach(observe => {
Services.obs.removeObserver(this, observe);
});
}
/**
* Kills other editor instances by sending a notification to close them.
* This ensures only one editor instance is open at a time.
*/
killOtherEditorInstances() {
Services.obs.notifyObservers(null, "zen-boosts-kill-editor");
}
/**
* Observer callback that handles notifications from the observer service.
* Closes the editor window when a 'zen-boosts-kill-editor' notification is received.
*
* @param {object} subject - The subject of the notification.
* @param {string} topic - The topic of the notification.
* @param {*} data - The message data.
*/
observe(subject, topic, data) {
switch (topic) {
case "zap-state-update":
this.onUpdateZapButtonVisual();
break;
case "selector-picker-state-update":
this.onUpdatePickerButtonVisual();
this.onUpdatePickerObserver(data);
break;
case "selector-picker-picked":
this.onPickerPickedCallback(data);
break;
case "zap-list-update":
this.onUpdateZapValue();
this.currentBoostData.changeWasMade = true;
break;
case "zen-boosts-kill-editor":
this.editorWindow.close();
break;
case "zen-boosts-active-change":
this.editorWindow.close();
break;
}
}
/**
* Initializes the color scheme of the editor window based on the current theme (dark or light mode)
*/
initColorScheme() {
if (this.isDarkMode) {
this.doc.documentElement.style.colorScheme = "dark";
} else {
this.doc.documentElement.style.colorScheme = "light";
}
}
/**
* Initializes the code editor for the css editor
*/
async initCodeEditor() {
if (this.codeEditorReady) {
return;
}
const { DevToolsLoader } = ChromeUtils.importESModule(
"resource://devtools/shared/loader/Loader.sys.mjs"
);
const loader = new DevToolsLoader({
invisibleToDebugger: true,
});
const { require } = loader;
const Editor = require("resource://devtools/client/shared/sourceeditor/editor");
const container = this.doc.getElementById("zen-boost-code-editor");
const editor = new Editor({
mode: Editor.modes.css,
lineNumbers: true,
theme: "mozilla",
readOnly: false,
gutters: ["CodeMirror-linenumbers"],
});
await editor.appendTo(container);
editor.refresh();
editor.on("change", this.onCodeEditorChange.bind(this));
const editorEl =
container.querySelector("iframe").contentDocument.documentElement;
editorEl.className = "theme-" + (this.isDarkMode ? "dark" : "light");
this.editorWindow._editor = editor;
this.codeEditorReady = true;
}
/**
* Inserts a code snippet at the current cursor position
*
* @param {string} code The code to insert
*/
insertCode(code) {
if (!code) {
code = "";
}
const cm = this.editorWindow._editor.codeMirror;
const cursor = cm.getCursor(); // { line, ch }
cm.replaceRange(code, cursor);
cm.focus();
}
/**
* Inserts a code snippet at the end of the code
*
* @param {string} code The code to insert
*/
appendCode(code) {
if (!code) {
code = "";
}
const cm = this.editorWindow._editor.codeMirror;
const line = cm.lineCount();
const content = this.editorWindow._editor.getText();
const ch = 0;
if (content == "") {
cm.replaceRange(code, { line, ch });
} else {
cm.replaceRange(`\n${code}`, { line, ch });
}
cm.focus();
}
onCodeEditorChange() {
this.currentBoostData.customCSS = this.editorWindow._editor.getText();
this.currentBoostData.changeWasMade = true;
this.updateCurrentBoost();
}
get commonFonts() {
const cFonts = [
"Arial",
"Times New Roman",
"Courier New",
"Georgia",
"Comic Sans MS",
"Verdana",
"Trebuchet MS",
"Impact",
"Palatino Linotype",
"Tahoma",
"Helvetica",
"Garamond",
"Century Gothic",
"Arial Black",
"Papyrus",
];
return cFonts;
}
/**
* Initializes the font selection UI by creating font buttons and dropdown options
* for the available font families.
*/
initFonts() {
const commonFonts = this.commonFonts;
const fonts = this.fetchFontList();
const fontButtonGroup = this.doc.getElementById("zen-boost-font-grid");
const fontList = this.doc.getElementById("zen-boost-font-select");
const buttonCount = 15;
for (let i = 0; i < Math.min(commonFonts.length, buttonCount); i++) {
let font = fonts[i]; // Fallback
if (fonts.includes(commonFonts[i])) {
font = commonFonts[i];
}
const fontButton = this.doc.createElement("button");
fontButton.setAttribute("font-data", `${font}`);
fontButton.classList.add("subviewbutton");
fontButton.style.fontFamily = `'${font}'`;
fontButton.innerHTML = "Aa";
fontButton.title = font;
fontButton.addEventListener("click", this.onFontButtonClick.bind(this));
fontButtonGroup.appendChild(fontButton);
}
// Add default value
const defaultOption = this.doc.createElement("option");
defaultOption.value = ""; // Use default font of site
defaultOption.label = "Default";
fontList.appendChild(defaultOption);
fontList.appendChild(this.doc.createElement("hr"));
for (let j = 0; j < fonts.length; j++) {
const font = fonts[j];
const option = this.doc.createElement("option");
option.style.fontFamily = `'${font}'`;
option.value = font;
option.label = font;
fontList.appendChild(option);
}
fontList.addEventListener("change", this.onFontDropdownSelect.bind(this));
}
/**
* Fetches a list of all available system fonts.
*
* @returns {Array<AString>} An array with names of available fonts.
*/
fetchFontList() {
const enumerator = Cc["@mozilla.org/gfx/fontenumerator;1"].createInstance(
Ci.nsIFontEnumerator
);
return enumerator.EnumerateFonts(null, null);
}
/**
* Handles the code editor button press, resizing and offsetting the window and enabling the code view
*/
onCodeButtonPressed() {
const offset = 265;
const openRightAligned =
this.openerWindow.screenX + this.openerWindow.outerWidth / 2 <
this.editorWindow.screenX;
const windowElem = this.doc.getElementById("zenBoostWindow");
if (windowElem.getAttribute("editor") == "code") {
return;
}
windowElem.setAttribute("editor", "code");
this.editorWindow.requestAnimationFrame(() => {
this.editorWindow.resizeTo(
this._codeEditorWidth,
this.editorWindow.outerHeight
);
if (openRightAligned) {
this.editorWindow.moveTo(
this.editorWindow.screenX - offset,
this.editorWindow.screenY
);
}
this.doc.getElementById("zen-boost-editor-root").style.display = "none";
this.doc.getElementById("zen-boost-code-editor-root").style.display =
"initial";
});
}
/**
* Handles the back button in the code view, resizing and offsetting the window and changing back to boost view
*/
onCodeBackButtonPressed() {
const offset = 265;
const openRightAligned =
this.openerWindow.screenX + this.openerWindow.outerWidth / 2 <
this.editorWindow.screenX;
const windowElem = this.doc.getElementById("zenBoostWindow");
if (windowElem.getAttribute("editor") == "boost") {
return;
}
windowElem.setAttribute("editor", "boost");
this.doc.getElementById("zen-boost-editor-root").style.display = "flex";
this.doc.getElementById("zen-boost-code-editor-root").style.display =
"none";
this.editorWindow.promiseDocumentFlushed(() => {
this.editorWindow.resizeTo(
this._boostEditorWidth,
this.editorWindow.outerHeight
);
if (openRightAligned) {
this.editorWindow.moveTo(
this.editorWindow.screenX + offset,
this.editorWindow.screenY
);
}
});
// Disable picker mode
this.disableAllPickers();
}
async onZapButtonPressed() {
this.zenBoostsChild.sendQuery("ZenBoost:ToggleZapMode");
// Focus the parent browser window
this.openerWindow.focus();
}
async onPickerButtonPressed() {
this.zenBoostsChild.sendQuery("ZenBoost:TogglePickerMode");
this.openerWindow.focus();
}
onPickerPickedCallback(cssSelector) {
this.disableAllPickers();
// Insert the css selector at the cursor position in the css editor
this.appendCode(`
${cssSelector} {
}`);
Services.obs.removeObserver(this, "selector-picker-picked");
}
/**
* Disables zap mode and picker mode
*/
disableAllPickers() {
Services.obs.notifyObservers(null, "zen-boosts-disable-zap");
Services.obs.notifyObservers(null, "zen-boosts-disable-picker");
}
onInspectorButtonPressed() {
this.zenBoostsChild.sendQuery("ZenBoost:OpenInspector");
}
async onUpdateZapButtonVisual() {
const actor = this.zenBoostsChild;
const zapButton = this.doc.getElementById("zen-boost-zap");
const zapEnabled = await actor.sendQuery("ZenBoost:ZapModeEnabled");
// Checks if there are any zaps
const zapAny = await actor.sendQuery("ZenBoost:ZapModeAny");
zapButton.setAttribute("enabled", zapEnabled || zapAny ? "true" : "false");
}
async onUpdatePickerButtonVisual() {
const pickerButton = this.doc.getElementById("zen-boost-css-picker");
const selectEnabled = await this.zenBoostsChild.sendQuery(
"ZenBoost:SelectorPickerModeEnabled"
);
pickerButton.setAttribute("enabled", selectEnabled ? "true" : "false");
}
onUpdatePickerObserver(data) {
if (!data) {
return;
}
if (data == "onenable") {
Services.obs.addObserver(this, "selector-picker-picked");
} else if (data == "ondisable") {
Services.obs.removeObserver(this, "selector-picker-picked");
}
}
onUpdateZapValue() {
const zapButton = this.doc.getElementById("zen-boost-zap");
const zapValueBox = this.doc.getElementById("zen-boost-zap-value");
const zapCount = this.currentBoostData.zapSelectors.length;
if (zapCount == 0) {
zapValueBox.textContent = "";
zapButton.setAttribute("hideicon", "false");
} else {
zapValueBox.textContent = zapCount;
zapButton.setAttribute("hideicon", "true");
}
}
/**
* Initializes the color picker by setting up mouse event listeners for
* interactive color selection on the gradient picker.
*/
initColorPicker() {
const themePicker = this.doc.querySelector(
".zen-boost-color-picker-gradient"
);
this._onMouseMove = this.onMouseMove.bind(this);
this._onMouseUp = this.onMouseUp.bind(this);
this._onMouseDown = this.onMouseDown.bind(this);
this._onThemePickerClick = this.onThemePickerClick.bind(this);
this.doc.addEventListener("mousemove", this._onMouseMove);
this.doc.addEventListener("mouseup", this._onMouseUp);
themePicker.addEventListener("mousedown", this._onMouseDown);
themePicker.addEventListener("click", this._onThemePickerClick);
}
/**
* Uninitializes the color picker by removing all mouse event listeners.
*/
uninitColorPicker() {
const themePicker = this.doc.querySelector(
".zen-boost-color-picker-gradient"
);
this.doc.removeEventListener("mousemove", this._onMouseMove);
this.doc.removeEventListener("mouseup", this._onMouseUp);
themePicker.removeEventListener("mousedown", this._onMouseDown);
themePicker.removeEventListener("click", this._onThemePickerClick);
this._onThemePickerClick = null;
this._onMouseMove = null;
this._onMouseUp = null;
this._onMouseDown = null;
}
/**
* Handles mouse move events to update the color picker dot position while dragging.
*
* @param {MouseEvent} event - The mouse move event.
*/
onMouseMove(event) {
const minDragDistance = 4;
let nDistance = Math.sqrt(
(event.clientX - this.mouseDownPosition.x) ** 2 +
(event.clientY - this.mouseDownPosition.y) ** 2
);
if (this.isMouseDown && nDistance > minDragDistance) {
this.wasDragging = true;
event.preventDefault();
this.currentBoostData.changeWasMade = true;
this.updateButtonToggleVisuals();
if (this.dragTarget == "zen-boost-color-picker-dot-secondary") {
this.setSecondaryDotPos(event.clientX, event.clientY);
} else if (event.target.id != "zen-boost-magic-theme") {
this.setDotPos(event.clientX, event.clientY, false);
}
}
}
/**
* Handles mouse down events to initiate color picker dragging.
*
* @param {MouseEvent} event - The mouse down event.
*/
onMouseDown(event) {
if (event.button === 2) {
return;
}
this.mouseDownPosition = { x: event.clientX, y: event.clientY };
this.isMouseDown = true;
this.dragTarget = event.target.id;
}
/**
* Handles mouse up events to end color picker dragging.
*
* @param {MouseEvent} event - The mouse up event.
*/
onMouseUp(event) {
if (event.button === 2) {
return;
}
this.isMouseDown = false;
this.wasDragging = false;
}
/**
* Handles the text case toggle button press, cycling through case override options
* (none, lower, upper) and updating the UI accordingly.
*/
onBoostCasePressed() {
if (this.currentBoostData.textCaseOverride == "uppercase") {
this.currentBoostData.textCaseOverride = "lowercase";
} else if (this.currentBoostData.textCaseOverride == "lowercase") {
this.currentBoostData.textCaseOverride = "capitalize";
} else if (this.currentBoostData.textCaseOverride == "capitalize") {
this.currentBoostData.textCaseOverride = "none";
} else {
this.currentBoostData.textCaseOverride = "uppercase";
}
this.updateCaseButtonVisuals();
this.updateCurrentBoost();
}
/**
* Handles the size toggle button press, cycling through size override options
*/
async onBoostSizePressed() {
if (this.currentBoostData.sizeOverride == 1) {
this.currentBoostData.sizeOverride = 1.1;
} else if (this.currentBoostData.sizeOverride == 1.1) {
this.currentBoostData.sizeOverride = 1.25;
} else if (this.currentBoostData.sizeOverride == 1.25) {
this.currentBoostData.sizeOverride = 1.5;
} else if (this.currentBoostData.sizeOverride == 1.5) {
this.currentBoostData.sizeOverride = 0.9;
} else if (this.currentBoostData.sizeOverride == 0.9) {
this.currentBoostData.sizeOverride = 1;
await this.zenBoostsChild.sendQuery("ZenBoost:DisableSizeOverride");
}
this.updateSizeButtonVisuals();
this.updateCurrentBoost();
}
/**
* Handles changes to color option sliders (contrast, brightness, saturation)
* and updates the current boost data accordingly.
*/
onColorOptionChange() {
this.currentBoostData.contrast = this.doc.getElementById(
"zen-boost-color-contrast"
).value;
this.currentBoostData.brightness = this.doc.getElementById(
"zen-boost-color-brightness"
).value;
this.currentBoostData.saturation = this.doc.getElementById(
"zen-boost-color-saturation"
).value;
this.updateCurrentBoost();
}
/**
* Opens the advanced color options popup panel.
*
* @param {Event} event - The click event that triggered this action.
*/
openAdvancedColorOptions(event) {
const panel = this.doc.getElementById(
"zen-boost-advanced-color-options-panel"
);
panel.openPopup(event.target, "bottomcenter topcenter", 0, 2);
}
/**
* Handles clicks on the theme picker gradient or magic theme button.
* Updates the dot position or toggles auto-theme mode based on the click target.
*
* @param {MouseEvent} event - The click event.
*/
onThemePickerClick(event) {
event.preventDefault();
this.currentBoostData.changeWasMade = true;
this.currentBoostData.enableColorBoost = true;
if (event.target.id == "zen-boost-magic-theme") {
this.currentBoostData.autoTheme = !this.currentBoostData.autoTheme;
this.updateCurrentBoost();
} else if (this.dragTarget != "zen-boost-color-picker-dot-secondary") {
this.setDotPos(event.clientX, event.clientY, !this.wasDragging);
}
this.updateButtonToggleVisuals();
this.wasDragging = false;
}
/**
* Sets the position of the color picker dot on the gradient and updates
* the boost data with the corresponding angle and distance values.
*
* @param {number|null} pixelX - The X coordinate in pixels, or null to center the dot.
* @param {number|null} pixelY - The Y coordinate in pixels, or null to center the dot.
* @param {boolean} animate - Whether to animate the dot movement (currently not implemented).
*/
setDotPos(pixelX, pixelY, animate = true) {
const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient");
const dot = this.doc.querySelector("#zen-boost-color-picker-dot-primary");
const dotSec = this.doc.querySelector(
"#zen-boost-color-picker-dot-secondary"
);
const rect = gradient.getBoundingClientRect();
const padding = 50;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const radius = (rect.width - padding) / 2;
let pixelXSec = pixelX;
let pixelYSec = pixelY;
if (!animate) {
let nDistance = Math.sqrt(
(pixelX - this.lastDotSetPos.x) ** 2 +
(pixelY - this.lastDotSetPos.y) ** 2
);
if (nDistance > 15) {
this.lastDotSetPos = {
x: pixelX,
y: pixelY,
};
}
}
if (pixelX == null || pixelY == null) {
pixelX = centerX;
pixelY = centerY;
pixelXSec = centerX;
pixelYSec = centerY;
this.currentBoostData.dotAngleDeg = 0;
this.currentBoostData.dotDistance = 0;
} else {
let distance = Math.sqrt(
(pixelX - centerX) ** 2 + (pixelY - centerY) ** 2
);
distance = Math.min(distance, radius); // Clamp distance
// Primary dot
const angle = Math.atan2(pixelY - centerY, pixelX - centerX);
pixelX = centerX + Math.cos(angle) * distance;
pixelY = centerY + Math.sin(angle) * distance;
// Rad to degree
this.currentBoostData.dotAngleDeg =
((Math.atan2(pixelY - centerY, pixelX - centerX) * 180) / Math.PI +
100) %
360;
if (this.currentBoostData.dotAngleDeg < 0) {
this.currentBoostData.dotAngleDeg += 360;
}
// Map to 0-1 range
this.currentBoostData.dotDistance = distance / radius;
// Secondary dot
const angleSec =
(angle +
(this.currentBoostData.secondaryDotAngleDegDelta * Math.PI) / 180) %
(Math.PI * 2);
pixelXSec = centerX + Math.cos(angleSec) * distance;
pixelYSec = centerY + Math.sin(angleSec) * distance;
// Enable color boosting again
if (!this.currentBoostData.enableColorBoost) {
this.onToggleDisable(false);
}
this.currentBoostData.autoTheme = false;
}
const relativeX = pixelX - rect.left;
const relativeY = pixelY - rect.top;
const relativeXSec = pixelXSec - rect.left;
const relativeYSec = pixelYSec - rect.top;
// Capture normalized position of dot for restoring it correctly later
this.currentBoostData.dotPos.x = relativeX / rect.width;
this.currentBoostData.dotPos.y = relativeY / rect.height;
// Make sure to update store to feature proper new secondary position
this.currentBoostData.secondaryDotPos.x = relativeXSec / rect.width;
this.currentBoostData.secondaryDotPos.y = relativeYSec / rect.height;
dot.setAttribute("animated", animate ? "true" : "false");
dot.style.left = `${relativeX}px`;
dot.style.top = `${relativeY}px`;
dotSec.setAttribute("animated", animate ? "true" : "false");
dotSec.style.left = `${relativeXSec}px`;
dotSec.style.top = `${relativeYSec}px`;
this.updateButtonToggleVisuals();
this.updateDot();
this.updateCircleRadius();
this.updateCurrentBoost();
}
/**
* Updates the visual appearance of the color picker dot
* based on the current boost data's angle and distance values.
*/
updateDot() {
const dot = this.doc.querySelector("#zen-boost-color-picker-dot-primary");
const dotSec = this.doc.querySelector(
"#zen-boost-color-picker-dot-secondary"
);
const dotDistance = this.currentBoostData.dotDistance;
const dotAngleDeg = this.currentBoostData.dotAngleDeg;
const secondaryDotAngleDelta =
this.currentBoostData.secondaryDotAngleDegDelta ?? 0;
dot.style.setProperty(
"--zen-theme-picker-dot-color",
`hsl(${dotAngleDeg}deg, ${dotDistance * 100}%, 55%)`
);
dotSec.style.setProperty(
"--zen-theme-picker-dot-color",
`hsl(${dotAngleDeg + secondaryDotAngleDelta}deg, ${dotDistance * 100}%, 20%)`
);
}
/**
* Sets the position of the secondary color picker dot on the gradient and updates
* the boost data with the corresponding angle values.
*
* @param {number|null} pixelX - The X coordinate in pixels.
* @param {number|null} pixelY - The Y coordinate in pixels.
*/
setSecondaryDotPos(pixelX, pixelY) {
const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient");
const dotSec = this.doc.querySelector(
"#zen-boost-color-picker-dot-secondary"
);
const rect = gradient.getBoundingClientRect();
const padding = 50;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const radius = (rect.width - padding) / 2;
const dotDistance = this.currentBoostData.dotDistance;
const primaryDotAngleDeg = this.currentBoostData.dotAngleDeg;
let angle = null;
if (pixelX == null || pixelY == null) {
pixelX = centerX;
pixelY = centerY;
angle = this.currentBoostData.secondaryDotAngleDegDelta;
} else {
angle = Math.atan2(pixelY - centerY, pixelX - centerX);
pixelX = centerX + Math.cos(angle) * dotDistance * radius;
pixelY = centerY + Math.sin(angle) * dotDistance * radius;
}
// Rad to degree
this.currentBoostData.secondaryDotAngleDegDelta =
((angle * 180) / Math.PI + 100 - primaryDotAngleDeg) % 360;
if (this.currentBoostData.secondaryDotAngleDegDelta < 0) {
this.currentBoostData.secondaryDotAngleDegDelta += 360;
}
const relativeX = pixelX - rect.left;
const relativeY = pixelY - rect.top;
// Capture normalized position of dot for restoring it correctly later
this.currentBoostData.secondaryDotPos.x = relativeX / rect.width;
this.currentBoostData.secondaryDotPos.y = relativeY / rect.height;
dotSec.setAttribute("animated", "false");
dotSec.style.left = `${relativeX}px`;
dotSec.style.top = `${relativeY}px`;
this.updateButtonToggleVisuals();
this.updateDot();
this.updateCircleRadius();
this.updateCurrentBoost();
}
/**
* Updates the radius of the circle based on the dot's position.
*/
updateCircleRadius() {
const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient");
const rect = gradient.getBoundingClientRect();
const padding = 50;
const radius = (rect.width - padding) / 2;
const cx = rect.width / 2;
const cy = rect.height / 2;
const dotDistance = this.currentBoostData.dotDistance;
const dotAngleDeg = this.currentBoostData.dotAngleDeg;
const secondaryDotAngleDelta =
this.currentBoostData.secondaryDotAngleDegDelta ?? 0;
// Updating the circle size to match the distance of the point
const circle = this.doc.querySelector(".zen-boost-color-picker-circle");
circle.setAttribute("animated", "false");
circle.style.width = `${dotDistance * radius * 2}px`;
circle.style.height = `${dotDistance * radius * 2}px`;
const dotColor = `hsl(${dotAngleDeg}deg, ${dotDistance * 100}%, 55%)`;
const dotColorSec = `hsl(${dotAngleDeg + secondaryDotAngleDelta}deg, ${dotDistance * 100}%, 20%)`;
this.updateArcFill(cx, cy, radius, dotColor, dotColorSec);
}
/**
* Updates the filled gradient arc between both color dots
*
* @param {number} cx - Half width of the gradient area
* @param {number} cy - Half height of the gradient area
* @param {number} radius - The target radius of the circle
* @param {string} color1 - Primary css color
* @param {string} color2 - Secondary css color
*/
updateArcFill(cx, cy, radius, color1, color2) {
const svg = this.doc.querySelector(".zen-boost-color-picker-arc-svg");
// Create SVG if it doesn't exist
if (!svg) {
this.initArcSVG();
this.updateArcFill(cx, cy, radius, color1, color2);
return;
}
const angle1 = this.currentBoostData.dotAngleDeg;
const angle2 =
this.currentBoostData.dotAngleDeg +
this.currentBoostData.secondaryDotAngleDegDelta;
const dist = this.currentBoostData.dotDistance;
const r = dist * radius;
const thickness = 2;
const toXY = (deg, ra) => {
const rad = ((deg - 90) * Math.PI) / 180;
return [cx + ra * Math.cos(rad), cy + ra * Math.sin(rad)];
};
const [x1, y1] = toXY(angle1, r);
const [x2, y2] = toXY(angle2, r);
// Gradient endpoints for matched dot positions
const grad = svg.querySelector("#arc-gradient");
grad.querySelector("#ag-stop1").setAttribute("stop-color", color1);
grad.querySelector("#ag-stop2").setAttribute("stop-color", color2);
grad.setAttribute("x1", x1);
grad.setAttribute("y1", y1);
grad.setAttribute("x2", x2);
grad.setAttribute("y2", y2);
// Ring sector path
const outerR = r + thickness / 2;
const innerR = Math.max(r - thickness / 2, 1);
const delta = (angle2 - angle1 + 360) % 360;
const large = delta > 180 ? 1 : 0;
const [ox1, oy1] = toXY(angle1, outerR);
const [ox2, oy2] = toXY(angle2, outerR);
const [ix2, iy2] = toXY(angle2, innerR);
const [ix1, iy1] = toXY(angle1, innerR);
const d = `M ${ox1} ${oy1} A ${outerR} ${outerR} 0 ${large} 1 ${ox2} ${oy2} L ${ix2} ${iy2} A ${innerR} ${innerR} 0 ${large} 0 ${ix1} ${iy1} Z`;
svg.querySelector(".arc-fill").setAttribute("d", d);
}
/**
* Initializes the filled gradient arc between both color picker dots in form of a svg
*/
initArcSVG() {
const NS = "http://www.w3.org/2000/svg";
const container = this.doc.querySelector(
".zen-boost-color-picker-gradient"
);
if (!container.clientWidth || !container.clientHeight) {
return;
}
const w = container.clientWidth;
const h = container.clientHeight;
const svg = this.doc.createElementNS(NS, "svg");
svg.classList.add("zen-boost-color-picker-arc-svg");
svg.setAttribute("width", w);
svg.setAttribute("height", h);
svg.setAttribute("viewBox", `0 0 ${w} ${h}`);
svg.style.cssText =
"position:absolute; top:0; left:0; pointer-events:none; z-index:3;";
const defs = this.doc.createElementNS(NS, "defs");
const grad = this.doc.createElementNS(NS, "linearGradient");
grad.setAttribute("id", "arc-gradient");
grad.setAttribute("gradientUnits", "userSpaceOnUse");
const stop1 = this.doc.createElementNS(NS, "stop");
stop1.setAttribute("id", "ag-stop1");
stop1.setAttribute("offset", "0%");
const stop2 = this.doc.createElementNS(NS, "stop");
stop2.setAttribute("id", "ag-stop2");
stop2.setAttribute("offset", "100%");
grad.appendChild(stop1);
grad.appendChild(stop2);
defs.appendChild(grad);
svg.appendChild(defs);
// Arc fill path
const arcFill = this.doc.createElementNS(NS, "path");
arcFill.classList.add("arc-fill");
arcFill.setAttribute("fill", "url(#arc-gradient)");
arcFill.setAttribute("opacity", "0.65");
svg.appendChild(arcFill);
container.style.position = "relative";
container.appendChild(svg);
}
/**
* Toggles the color boost enable/disable state.
*
* @param {boolean} userAction - Whether this was triggered by a user action (default: true).
*/
onToggleDisable(userAction = true) {
this.currentBoostData.enableColorBoost =
!this.currentBoostData.enableColorBoost;
if (userAction) {
this.currentBoostData.changeWasMade = true;
}
this.updateButtonToggleVisuals();
this.updateCurrentBoost();
}
/**
* Toggles the smart invert feature, which automatically inverts colors
* based on the window's color scheme.
*
* @param {boolean} userAction - Whether this was triggered by a user action (default: true).
*/
onToggleInvert(userAction = true) {
this.currentBoostData.smartInvert = !this.currentBoostData.smartInvert;
if (userAction) {
this.currentBoostData.changeWasMade = true;
}
this.updateButtonToggleVisuals();
this.updateCurrentBoost();
}
/**
* Updates the visual state of the text case toggle button based on the current
* text case override value (none, upper, or lower).
*/
updateCaseButtonVisuals() {
const caseButton = this.doc.getElementById("zen-boost-case");
const caseText = this.doc.getElementById("zen-boost-case-text");
caseButton.setAttribute(
"case-mode",
this.currentBoostData.textCaseOverride
);
switch (this.currentBoostData.textCaseOverride) {
case "uppercase":
caseButton.setAttribute("mode", "orange");
caseText.style.display = "none";
break;
case "lowercase":
caseButton.setAttribute("mode", "orange-red");
caseText.style.display = "none";
break;
case "capitalize":
caseButton.setAttribute("mode", "red");
caseText.style.display = "none";
break;
default:
caseButton.setAttribute("mode", "none");
caseText.style.display = "initial";
break;
}
}
/**
* Updates the visual state of the text case toggle button based on the current
* text case override value (none, upper, or lower).
*/
updateSizeButtonVisuals() {
const sizeButton = this.doc.getElementById("zen-boost-size");
const sizeText = this.doc.getElementById("zen-boost-size-text");
const sizeValue = this.doc.getElementById("zen-boost-size-value");
switch (this.currentBoostData.sizeOverride) {
case 1:
sizeButton.setAttribute("mode", "none");
sizeText.style.display = "initial";
sizeValue.style.display = "none";
break;
case 1.1:
sizeButton.setAttribute("mode", "orange");
sizeText.style.display = "none";
sizeValue.style.display = "initial";
break;
case 1.25:
sizeButton.setAttribute("mode", "orange-red");
sizeText.style.display = "none";
sizeValue.style.display = "initial";
break;
case 1.5:
sizeButton.setAttribute("mode", "red");
sizeText.style.display = "none";
sizeValue.style.display = "initial";
break;
case 0.9:
sizeButton.setAttribute("mode", "blue");
sizeText.style.display = "none";
sizeValue.style.display = "initial";
break;
}
sizeValue.setHTML(
`${Math.round(this.currentBoostData.sizeOverride * 100)}%`
);
}
/**
* Updates the visual state of all toggle buttons (invert, disable, auto-theme)
* and applies grayscale effect to the gradient when color boosting is disabled.
*/
updateButtonToggleVisuals() {
const invertButton = this.doc.getElementById("zen-boost-invert");
const disableButton = this.doc.getElementById("zen-boost-disable");
const autoThemeButton = this.doc.getElementById("zen-boost-magic-theme");
const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient");
if (this.currentBoostData.autoTheme) {
autoThemeButton.classList.add("zen-boost-button-active");
} else {
autoThemeButton.classList.remove("zen-boost-button-active");
}
if (this.currentBoostData.smartInvert) {
invertButton.classList.add("zen-boost-button-active");
} else {
invertButton.classList.remove("zen-boost-button-active");
}
if (!this.currentBoostData.enableColorBoost) {
disableButton.classList.add("zen-boost-button-active-transparent");
} else {
disableButton.classList.remove("zen-boost-button-active-transparent");
}
// Give the gradient a grayscale effect
// when the color boosting is disabled
// or the theme is set automatically
if (
!this.currentBoostData.enableColorBoost ||
this.currentBoostData.autoTheme
) {
gradient.classList.add("zen-boost-panel-disabled");
} else {
gradient.classList.remove("zen-boost-panel-disabled");
}
}
/**
* Updates the value of the sliders with the current boost data
*/
updateColorControlSliderVisuals() {
const contrastSlider = this.doc.getElementById("zen-boost-color-contrast");
const brightnessSlider = this.doc.getElementById(
"zen-boost-color-brightness"
);
const saturationSlider = this.doc.getElementById(
"zen-boost-color-saturation"
);
contrastSlider.value = this.currentBoostData.contrast;
brightnessSlider.value = this.currentBoostData.brightness;
saturationSlider.value = this.currentBoostData.saturation;
}
/**
* Handles font button clicks to change the selected font family.
*
* @param {Event} event - The click event from a font button.
*/
onFontButtonClick(event) {
const font = event?.target?.getAttribute("font-data") ?? "";
this.onFontChange(font);
}
/**
* Handles font dropdown selection changes to change the selected font family.
*
* @param {Event} event - The change event from the font dropdown.
*/
onFontDropdownSelect(event) {
const select = event.target;
this.onFontChange(select.value);
}
/**
* Changes the font family for the boost. If the same font is selected again,
* it clears the font override (sets to empty string).
*
* @param {string} font - The font family string to apply.
*/
onFontChange(font) {
if (this.currentBoostData.fontFamily == font) {
this.currentBoostData.fontFamily = "";
} else {
this.currentBoostData.fontFamily = font;
}
this.updateFontButtonVisuals();
this.currentBoostData.changeWasMade = true;
this.updateCurrentBoost();
}
/**
* Updates the visual state of font selection buttons and dropdown
* to reflect the currently selected font family.
*/
updateFontButtonVisuals() {
const fontButtonGroup = this.doc.getElementById("zen-boost-font-grid");
let foundActive = false;
for (let i = 0; i < fontButtonGroup.children.length; i++) {
const fontButton = fontButtonGroup.children[i];
if (
fontButton.getAttribute("font-data") == this.currentBoostData.fontFamily
) {
fontButton.classList.add("zen-boost-font-button-active");
foundActive = true;
} else {
fontButton.classList.remove("zen-boost-font-button-active");
}
}
const fontSelect = this.doc.getElementById("zen-boost-font-select");
for (let i = 0; i < fontSelect.options.length; i++) {
const option = fontSelect.options[i];
if (option.value == this.currentBoostData.fontFamily) {
fontSelect.value = option.value;
break;
}
}
if (this.currentBoostData.fontFamily !== "" && !foundActive) {
fontSelect.setAttribute("has-selection", "true");
} else {
fontSelect.removeAttribute("has-selection");
}
}
/**
* Updates the boost data in the boosts manager with the current boost data.
* This triggers notifications to observers but does not persist to disk.
*/
updateCurrentBoost() {
const boost = gZenBoostsManager.loadBoostFromStore(
this.boostInfo.domain,
this.boostInfo.id
);
boost.boostEntry.boostData = this.currentBoostData;
gZenBoostsManager.updateBoost(boost);
}
/**
* Deletes the current boost for the domain and closes the editor window.
*/
deleteBoost() {
const boost = gZenBoostsManager.loadBoostFromStore(
this.boostInfo.domain,
this.boostInfo.id
);
gZenBoostsManager.deleteBoost(boost);
this.currentBoostData = null;
this.editorWindow.close();
}
/**
* Handles showing the popup when clicking the name text
*
* @param {Event} event
*/
onNameTextClick(event) {
const renameBoost = this.doc.getElementById("zen-boost-edit-rename");
const deleteBoost = this.doc.getElementById("zen-boost-edit-delete");
const resetBoost = this.doc.getElementById("zen-boost-edit-reset");
const popup = this.doc.getElementById("zenBoostContextMenu");
popup.addEventListener(
"popupshown",
() => {
// Don't give the user following options if the boost
// is not going to save / not currently saved (unchanged)
let shouldDisable = !this.currentBoostData.changeWasMade;
const items = [renameBoost, deleteBoost, resetBoost];
for (let item of items) {
if (shouldDisable) {
item.setAttribute("disabled", "");
} else {
item.removeAttribute("disabled");
}
}
},
{ once: true }
);
popup.openPopup(
event.target,
"bottomcenter topcenter",
0,
0,
true /* isContextMenu */,
false /* attributesOverride */,
event
);
}
/**
* Handles showing a text field for renaming the boost
*/
async editBoostName() {
const nameText = this.doc.getElementById("zen-boost-name-text");
const [title] = await this.doc.l10n.formatMessages([
"zen-boost-rename-boost-prompt",
]);
let input = {
value: this.currentBoostData.boostName, // Default value and also output
};
const success = await Services.prompt.prompt(
this.openerWindow,
title.value,
null,
input,
null,
{ value: false }
);
if (!success) {
return;
}
const newName = input.value;
const maxDisplayedNameChars = 10;
if (newName.trim().length !== 0) {
var truncatedName = newName.substring(0, maxDisplayedNameChars);
this.currentBoostData.boostName = truncatedName;
nameText.textContent = this.currentBoostData.boostName;
this.updateCurrentBoost();
}
}
/**
* Handles the close button press by closing the editor window.
*/
onClosePressed() {
this.editorWindow.close();
}
/**
* Handles opening a save file dialog and exporting the boost data to a JSON file
*/
async onSaveBoostClick() {
const success = await gZenBoostsManager.exportBoost(
this.editorWindow,
this.currentBoostData
);
if (success) {
this.openerWindow.gZenUIManager.showToast(
"zen-panel-ui-boosts-exported-message"
);
}
}
/**
* Handles opening a load file dialog and importing the boost data to a JSON file
*/
async onLoadBoostClick() {
const data = await gZenBoostsManager.importBoost(this.editorWindow);
if (data) {
this.currentBoostData = data;
this.updateAllVisuals();
this.windowImportAnimation();
}
}
/**
* Handles animating the window with the import glint animation
*/
windowImportAnimation() {
const windowWrapper = this.doc.getElementById("zenBoostWindow");
if (!windowWrapper) {
return;
}
const element = this.doc.createElement("div");
element.id = "import-animation";
const elementBorder = this.doc.createElement("div");
elementBorder.id = "import-animation-border";
const elementShadow = this.doc.createElement("div");
elementShadow.id = "import-animation-shadow";
this.editorWindow.requestAnimationFrame(() => {
if (this.openerWindow.gReduceMotion) {
element.remove();
elementBorder.remove();
elementShadow.remove();
return;
}
windowWrapper.appendChild(element);
windowWrapper.appendChild(elementBorder);
windowWrapper.appendChild(elementShadow);
const anim1 = element.animate(
[
{ top: "100%", opacity: 0.5 },
{ top: "-50%", opacity: 1 },
],
{
duration: 350,
delay: 120,
fill: "forwards",
easing: "ease-out",
}
);
const anim2 = elementBorder.animate(
[{ "--background-top": "150%" }, { "--background-top": "-50%" }],
{
duration: 350,
delay: 200,
fill: "forwards",
easing: "ease-out",
}
);
const anim3 = elementShadow.animate(
[{ opacity: 0 }, { opacity: 1 }, { opacity: 0 }],
{
duration: 460,
fill: "forwards",
easing: "ease-out",
}
);
Promise.all([anim1.finished, anim2.finished, anim3.finished]).then(() => {
element.remove();
elementBorder.remove();
elementShadow.remove();
});
});
}
/**
* Shuffles the boost data and updates the presentation
*/
shuffleBoost() {
const availFonts = this.fetchFontList();
const commonFonts = this.commonFonts;
let font = commonFonts[Math.round(Math.random() * commonFonts.length)];
if (availFonts.includes(font)) {
this.currentBoostData.fontFamily = font;
}
this.currentBoostData.smartInvert = Math.random() > 0.5;
this.currentBoostData.autoTheme = false;
this.currentBoostData.brightness = Math.random();
this.currentBoostData.contrast = Math.random();
this.currentBoostData.saturation = Math.random();
const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient");
const rect = gradient.getBoundingClientRect();
this.currentBoostData.secondaryDotAngleDegDelta = Math.random() * 360;
this.setDotPos(
Math.round(rect.left + Math.random() * rect.width),
Math.round(rect.top + Math.random() * rect.height),
true
);
this.currentBoostData.changeWasMade = true;
this.updateCurrentBoost();
this.updateAllVisuals();
}
/**
* Reverts boost data to defaults
*/
resetBoost() {
this.currentBoostData = gZenBoostsManager.getEmptyBoostEntry().boostData;
this.updateCurrentBoost();
this.updateAllVisuals();
}
/**
* Handles the editor window close event. Saves the boost if changes were made,
* or deletes it if no changes were made (temporary boost).
*/
handleClose() {
this.uninit();
if (this.currentBoostData != null && this.currentBoostData.changeWasMade) {
this.saveBoost();
} else if (
this.currentBoostData != null &&
!this.currentBoostData.changeWasMade
) {
const boost = gZenBoostsManager.loadBoostFromStore(
this.boostInfo.domain,
this.boostInfo.id
);
gZenBoostsManager.deleteBoost(boost);
}
this.disableAllPickers();
}
/**
* Loads boost data for the specified domain and initializes the editor UI
* with the boost settings (dot position, sliders, buttons, etc.).
*
* @param {string} domain - The domain for which to load the boost.
*/
async loadBoost(domain) {
const boost = gZenBoostsManager.loadActiveBoostFromStore(domain);
this.currentBoostData = boost.boostEntry.boostData;
this.boostInfo = { domain, id: boost.id };
// Initial save to register the boost
gZenBoostsManager.saveBoostToStore(boost);
// The code editor needs time to initialize
await this.initCodeEditor();
this.updateAllVisuals();
}
updateAllVisuals() {
this.doc.getElementById("zen-boost-name-text").textContent =
this.currentBoostData.boostName;
const dot = this.doc.querySelector("#zen-boost-color-picker-dot-primary");
const dotSec = this.doc.querySelector(
"#zen-boost-color-picker-dot-secondary"
);
const gradient = this.doc.querySelector(".zen-boost-color-picker-gradient");
const rect = gradient.getBoundingClientRect();
if (!this.currentBoostData.sizeOverride) {
this.currentBoostData.sizeOverride = 1;
}
if (
// Test if the stored position is a non-normalized dot position
this.currentBoostData.dotPos.x > 1 ||
this.currentBoostData.dotPos.x < 0 ||
this.currentBoostData.dotPos.y > 1 ||
this.currentBoostData.dotPos.y < 0
) {
// Normalize position
this.currentBoostData.dotPos.x =
this.currentBoostData.dotPos.x / rect.width;
this.currentBoostData.dotPos.y =
this.currentBoostData.dotPos.y / rect.height;
}
// Convert normalized position to relative position
const xPos = this.currentBoostData.dotPos.x * rect.width;
const yPos = this.currentBoostData.dotPos.y * rect.height;
dot.style.left = `${xPos}px`;
dot.style.top = `${yPos}px`;
const xPosSec = this.currentBoostData.secondaryDotPos.x * rect.width;
const yPosSec = this.currentBoostData.secondaryDotPos.y * rect.height;
dotSec.style.left = `${xPosSec}px`;
dotSec.style.top = `${yPosSec}px`;
this.editorWindow._editor.setText(this.currentBoostData.customCSS || "");
this.updateFontButtonVisuals();
this.updateCaseButtonVisuals();
this.updateSizeButtonVisuals();
this.updateColorControlSliderVisuals();
this.updateButtonToggleVisuals();
this.updateDot();
this.updateCircleRadius();
this.onUpdateZapValue();
this.onUpdateZapButtonVisual();
}
/**
* Saves the current boost data to persistent storage if changes were made.
*/
saveBoost() {
if (this.currentBoostData == null || !this.currentBoostData.changeWasMade) {
return;
}
const boost = gZenBoostsManager.loadBoostFromStore(
this.boostInfo.domain,
this.boostInfo.id
);
boost.boostEntry.boostData = this.currentBoostData;
gZenBoostsManager.saveBoostToStore(boost);
}
}