// 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/.
{
class ZenThemePicker extends ZenMultiWindowFeature {
static GRADIENT_IMAGE_URL = 'chrome://browser/content/zen-images/gradient.png';
static MAX_DOTS = 3;
currentOpacity = 0.5;
currentRotation = -45;
dots = [];
useAlgo = '';
#allowTransparencyOnSidebar = Services.prefs.getBoolPref('zen.theme.acrylic-elements', false);
constructor() {
super();
if (
!Services.prefs.getBoolPref('zen.theme.gradient', true) ||
!gZenWorkspaces.shouldHaveWorkspaces ||
gZenWorkspaces.privateWindowOrDisabled
) {
return;
}
this.promiseInitialized = new Promise((resolve) => {
this._resolveInitialized = resolve;
});
this.dragStartPosition = null;
ChromeUtils.defineLazyGetter(this, 'panel', () =>
document.getElementById('PanelUI-zen-gradient-generator')
);
ChromeUtils.defineLazyGetter(this, 'toolbox', () => document.getElementById('TabsToolbar'));
ChromeUtils.defineLazyGetter(this, 'customColorInput', () =>
document.getElementById('PanelUI-zen-gradient-generator-custom-input')
);
ChromeUtils.defineLazyGetter(this, 'customColorList', () =>
document.getElementById('PanelUI-zen-gradient-generator-custom-list')
);
this.panel.addEventListener('popupshowing', this.handlePanelOpen.bind(this));
this.panel.addEventListener('popuphidden', this.handlePanelClose.bind(this));
this.panel.addEventListener('command', this.handlePanelCommand.bind(this));
document
.getElementById('PanelUI-zen-gradient-generator-opacity')
.addEventListener('input', this.onOpacityChange.bind(this));
this.initCanvas();
this.initCustomColorInput();
this.initTextureInput();
this.initRotationInput();
window
.matchMedia('(prefers-color-scheme: dark)')
.addListener(this.onDarkModeChange.bind(this));
}
get isDarkMode() {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
async onDarkModeChange(event, skipUpdate = false) {
const currentWorkspace = await gZenWorkspaces.getActiveWorkspace();
this.onWorkspaceChange(currentWorkspace, skipUpdate);
}
initContextMenu() {
const menu = window.MozXULElement.parseXULToFragment(`
`);
document.getElementById('toolbar-context-customize').before(menu);
}
openThemePicker(event) {
const fromForm = event.explicitOriginalTarget?.classList?.contains(
'zen-workspace-creation-edit-theme-button'
);
PanelMultiView.openPopup(this.panel, this.toolbox, {
position: 'topright topleft',
triggerEvent: event,
y: fromForm ? -160 : 0,
});
}
initCanvas() {
this.image = new Image();
this.image.src = ZenThemePicker.GRADIENT_IMAGE_URL;
this.canvas = document.createElement('canvas');
this.panel.appendChild(this.canvas);
this.canvasCtx = this.canvas.getContext('2d');
// wait for the image to load
this.image.onload = this.onImageLoad.bind(this);
}
onImageLoad() {
// resize the image to fit the panel
const imageSize = 350 - 20; // 20 is the padding (10px)
const scale = imageSize / Math.max(this.image.width, this.image.height);
this.image.width *= scale;
this.image.height *= scale;
this.canvas.width = this.image.width;
this.canvas.height = this.image.height;
this.canvasCtx.drawImage(this.image, 0, 0);
this.canvas.setAttribute('hidden', 'true');
// Call the rest of the initialization
this.initContextMenu();
this.initPredefinedColors();
this._resolveInitialized();
delete this._resolveInitialized;
this.onDarkModeChange(null);
}
initPredefinedColors() {
document
.getElementById('PanelUI-zen-gradient-generator-predefined')
.addEventListener('click', async (event) => {
const target = event.target;
const rawPosition = target.getAttribute('data-position');
if (!rawPosition) {
return;
}
const algo = target.getAttribute('data-algo');
const numDots = parseInt(target.getAttribute('data-num-dots'));
if (algo == 'float') {
for (const dot of this.dots) {
dot.element.remove();
}
this.dots = [];
} else if (numDots < this.dots.length) {
for (let i = numDots; i < this.dots.length; i++) {
this.dots[i].element.remove();
}
this.dots = this.dots.slice(0, numDots);
}
// Generate new gradient from the single color given
const [x, y] = rawPosition.split(',').map((pos) => parseInt(pos));
let dots = [
{
ID: 0,
position: { x, y },
},
];
for (let i = 1; i < numDots; i++) {
dots.push({
ID: i,
position: { x: 0, y: 0 },
});
}
this.useAlgo = algo;
dots = this.calculateCompliments(dots, 'update', this.useAlgo);
if (algo == 'float') {
for (const dot of dots) {
this.spawnDot(dot.position);
}
this.dots[0].element.classList.add('primary');
}
this.handleColorPositions(dots);
this.updateCurrentWorkspace();
});
}
initCustomColorInput() {
this.customColorInput.addEventListener('keydown', this.onCustomColorKeydown.bind(this));
}
initRotationInput() {
const rotationInput = document.getElementById('PanelUI-zen-gradient-generator-rotation-dot');
this._onRotationMouseDown = this.onRotationMouseDown.bind(this);
this._onRotationMouseMove = this.onRotationMouseMove.bind(this);
this._onRotationMouseUp = this.onRotationMouseUp.bind(this);
rotationInput.addEventListener('mousedown', this._onRotationMouseDown);
}
onRotationMouseDown(event) {
event.preventDefault();
event.stopPropagation();
this._rotating = true;
document.addEventListener('mousemove', this._onRotationMouseMove);
document.addEventListener('mouseup', this._onRotationMouseUp);
}
onRotationMouseMove(event) {
event.preventDefault();
event.stopPropagation();
const rotationInput = document.getElementById('PanelUI-zen-gradient-generator-rotation-dot');
const containerRect = rotationInput.parentElement.getBoundingClientRect();
// We calculate the angle based on the mouse position and the center of the container
const rotation = Math.atan2(
event.clientY - containerRect.top - containerRect.height / 2,
event.clientX - containerRect.left - containerRect.width / 2
);
const endRotation = (rotation * 180) / Math.PI;
// Between 150 and 50, we don't update the rotation
if (!(endRotation < 45 || endRotation > 130)) {
return;
}
this.currentRotation = endRotation;
this.updateCurrentWorkspace();
}
onRotationMouseUp(event) {
event.preventDefault();
event.stopPropagation();
document.removeEventListener('mousemove', this._onRotationMouseMove);
document.removeEventListener('mouseup', this._onRotationMouseUp);
setTimeout(() => {
this._rotating = false;
}, 100);
}
initTextureInput() {
const wrapper = document.getElementById('PanelUI-zen-gradient-generator-texture-wrapper');
const wrapperWidth = wrapper.getBoundingClientRect().width;
// Add elements in a circular pattern, where the center is the center of the wrapper
for (let i = 0; i < 16; i++) {
const dot = document.createElement('div');
dot.classList.add('zen-theme-picker-texture-dot');
const position = (i / 16) * Math.PI * 2 + wrapperWidth;
dot.style.left = `${Math.cos(position) * 50 + 50}%`;
dot.style.top = `${Math.sin(position) * 50 + 50}%`;
wrapper.appendChild(dot);
}
this._textureHandler = document.createElement('div');
this._textureHandler.id = 'PanelUI-zen-gradient-generator-texture-handler';
this._textureHandler.addEventListener('mousedown', this.onTextureHandlerMouseDown.bind(this));
wrapper.appendChild(this._textureHandler);
}
onTextureHandlerMouseDown(event) {
event.preventDefault();
this._onTextureMouseMove = this.onTextureMouseMove.bind(this);
this._onTextureMouseUp = this.onTextureMouseUp.bind(this);
document.addEventListener('mousemove', this._onTextureMouseMove);
document.addEventListener('mouseup', this._onTextureMouseUp);
}
onTextureMouseMove(event) {
event.preventDefault();
const wrapper = document.getElementById('PanelUI-zen-gradient-generator-texture-wrapper');
const wrapperRect = wrapper.getBoundingClientRect();
// Determine how much rotation there is based on the mouse position and the center of the wrapper
const rotation = Math.atan2(
event.clientY - wrapperRect.top - wrapperRect.height / 2,
event.clientX - wrapperRect.left - wrapperRect.width / 2
);
const previousTexture = this.currentTexture;
this.currentTexture = (rotation * 180) / Math.PI + 90;
// if it's negative, add 360 to make it positive
if (this.currentTexture < 0) {
this.currentTexture += 360;
}
// make it go from 1 to 0 instead of being in degrees
this.currentTexture /= 360;
// We clip it to the closest button out of 16 possible buttons
this.currentTexture = Math.round(this.currentTexture * 16) / 16;
if (this.currentTexture === 1) {
this.currentTexture = 0;
}
if (previousTexture !== this.currentTexture) {
this.updateCurrentWorkspace();
Services.zen.playHapticFeedback();
}
}
onTextureMouseUp(event) {
event.preventDefault();
document.removeEventListener('mousemove', this._onTextureMouseMove);
document.removeEventListener('mouseup', this._onTextureMouseUp);
this._onTextureMouseMove = null;
this._onTextureMouseUp = null;
}
onCustomColorKeydown(event) {
// Check for Enter key to add custom colors
if (event.key === 'Enter') {
event.preventDefault();
this.addCustomColor();
}
}
initThemePicker() {
const themePicker = this.panel.querySelector('.zen-theme-picker-gradient');
this._onDotMouseMove = this.onDotMouseMove.bind(this);
this._onDotMouseUp = this.onDotMouseUp.bind(this);
this._onDotMouseDown = this.onDotMouseDown.bind(this);
this._onThemePickerClick = this.onThemePickerClick.bind(this);
document.addEventListener('mousemove', this._onDotMouseMove);
document.addEventListener('mouseup', this._onDotMouseUp);
themePicker.addEventListener('mousedown', this._onDotMouseDown);
themePicker.addEventListener('click', this._onThemePickerClick);
}
uninitThemePicker() {
const themePicker = this.panel.querySelector('.zen-theme-picker-gradient');
document.removeEventListener('mousemove', this._onDotMouseMove);
document.removeEventListener('mouseup', this._onDotMouseUp);
themePicker.removeEventListener('mousedown', this._onDotMouseDown);
themePicker.removeEventListener('click', this._onThemePickerClick);
this._onDotMouseMove = null;
this._onDotMouseUp = null;
this._onDotMouseDown = null;
this._onThemePickerClick = null;
}
calculateInitialPosition(color) {
const [r, g, b] = color.c;
const imageData = this.canvasCtx.getImageData(0, 0, this.canvas.width, this.canvas.height);
// Find all pixels that are at least 90% similar to the color
const similarPixels = [];
for (let i = 0; i < imageData.data.length; i += 4) {
const pixelR = imageData.data[i];
const pixelG = imageData.data[i + 1];
const pixelB = imageData.data[i + 2];
if (Math.abs(r - pixelR) < 25 && Math.abs(g - pixelG) < 25 && Math.abs(b - pixelB) < 25) {
similarPixels.push(i);
}
}
// Check if there's an exact match
for (const pixel of similarPixels) {
const x = (pixel / 4) % this.canvas.width;
const y = Math.floor(pixel / 4 / this.canvas.width);
const pixelColor = this.getColorFromPosition(x, y);
if (pixelColor[0] === r && pixelColor[1] === g && pixelColor[2] === b) {
return { x: x / this.canvas.width, y: y / this.canvas.height };
}
}
// If there's no exact match, return the first similar pixel
const pixel = similarPixels[0];
const x = (pixel / 4) % this.canvas.width;
const y = Math.floor(pixel / 4 / this.canvas.width);
return { x: x / this.canvas.width, y: y / this.canvas.height };
}
getColorFromPosition(x, y) {
// get the color from the x and y from the image
const imageData = this.canvasCtx.getImageData(x, y, 1, 1);
return imageData.data;
}
createDot(color, fromWorkspace = false) {
const [r, g, b] = color.c;
const dot = document.createElement('div');
if (color.isPrimary) {
dot.classList.add('primary');
}
if (color.isCustom) {
if (!color.c) {
return;
}
dot.classList.add('custom');
dot.style.opacity = 0;
dot.style.setProperty('--zen-theme-picker-dot-color', color.c);
} else {
const { x, y } = this.calculateInitialPosition(color);
const dotPad = this.panel.querySelector('.zen-theme-picker-gradient');
dot.classList.add('zen-theme-picker-dot');
dot.style.left = `${x * 100}%`;
dot.style.top = `${y * 100}%`;
if (this.dots.length < 1) {
dot.classList.add('primary');
}
dotPad.appendChild(dot);
let id = this.dots.length;
dot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${r}, ${g}, ${b})`);
this.dots.push({
ID: id,
element: dot,
position: { x: null, y: null }, // at some point possition should instead be stored as percentege just so that the size of the color picker does not matter.
});
}
if (!fromWorkspace) {
this.updateCurrentWorkspace(true);
}
}
addColorToCustomList(color) {
const listItems = window.MozXULElement.parseXULToFragment(`
`);
listItems
.querySelector('.zen-theme-picker-custom-list-item')
.setAttribute('data-color', color);
listItems
.querySelector('.zen-theme-picker-dot')
.style.setProperty('--zen-theme-picker-dot-color', color);
listItems.querySelector('.zen-theme-picker-custom-list-item-label').textContent = color;
listItems
.querySelector('.zen-theme-picker-custom-list-item-remove')
.addEventListener('command', this.removeCustomColor.bind(this));
this.customColorList.appendChild(listItems);
}
async addCustomColor() {
let color = this.customColorInput.value;
if (!color) {
return;
}
// Add '#' prefix if it's missing and the input appears to be a hex color
if (!color.startsWith('#') && /^[0-9A-Fa-f]{3,6}$/.test(color)) {
color = '#' + color;
}
// can be any color format, we just add it to the list as a dot, but hidden
const dot = document.createElement('div');
dot.classList.add('zen-theme-picker-dot', 'hidden', 'custom');
dot.style.opacity = 0;
dot.style.setProperty('--zen-theme-picker-dot-color', color);
this.panel.querySelector('#PanelUI-zen-gradient-generator-custom-list').prepend(dot);
this.customColorInput.value = '';
await this.updateCurrentWorkspace();
}
handlePanelCommand(event) {
const target = event.target.closest('toolbarbutton');
if (!target) {
return;
}
switch (target.id) {
case 'PanelUI-zen-gradient-generator-color-custom-add':
this.addCustomColor();
break;
}
}
spawnDot(relativePosition, primary = false) {
const dotPad = this.panel.querySelector('.zen-theme-picker-gradient');
const dot = document.createElement('div');
dot.classList.add('zen-theme-picker-dot');
dot.style.left = `${relativePosition.x}px`;
dot.style.top = `${relativePosition.y}px`;
dotPad.appendChild(dot);
let id = this.dots.length;
if (primary) {
id = 0;
dot.classList.add('primary');
const existingPrimaryDot = this.dots.find((d) => d.ID === 0);
if (existingPrimaryDot) {
existingPrimaryDot.ID = this.dots.length;
existingPrimaryDot.element.classList.remove('primary');
}
}
const colorFromPos = this.getColorFromPosition(relativePosition.x, relativePosition.y);
dot.style.setProperty(
'--zen-theme-picker-dot-color',
`rgb(${colorFromPos[0]}, ${colorFromPos[1]}, ${colorFromPos[2]})`
);
this.dots.push({
ID: id,
element: dot,
position: { x: relativePosition.x, y: relativePosition.y },
});
}
calculateCompliments(dots, action = 'update', useHarmony = '') {
const colorHarmonies = [
{ type: 'complementary', angles: [180] },
{ type: 'splitComplementary', angles: [150, 210] },
{ type: 'analogous', angles: [30, 330] },
{ type: 'triadic', angles: [120, 240] },
{ type: 'floating', angles: [] },
];
if (dots.length === 0) {
return [];
}
function getColorHarmonyType(numDots, dots) {
if (useHarmony !== '') {
const selectedHarmony = colorHarmonies.find((harmony) => harmony.type === useHarmony);
if (selectedHarmony) {
if (action === 'remove') {
if (dots.length !== 0) {
return colorHarmonies.find(
(harmony) => harmony.angles.length === selectedHarmony.angles.length - 1
);
} else {
return { type: 'floating', angles: [] };
}
}
if (action === 'add') {
return colorHarmonies.find(
(harmony) => harmony.angles.length === selectedHarmony.angles.length + 1
);
}
if (action === 'update') {
return selectedHarmony;
}
}
}
if (action === 'remove') {
return colorHarmonies.find((harmony) => harmony.angles.length === numDots);
}
if (action === 'add') {
return colorHarmonies.find((harmony) => harmony.angles.length + 1 === numDots);
}
if (action === 'update') {
return colorHarmonies.find((harmony) => harmony.angles.length + 1 === numDots);
}
}
function getAngleFromPosition(position, centerPosition) {
let deltaX = position.x - centerPosition.x;
let deltaY = position.y - centerPosition.y;
let angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI);
return (angle + 360) % 360;
}
function getDistanceFromCenter(position, centerPosition) {
const deltaX = position.x - centerPosition.x;
const deltaY = position.y - centerPosition.y;
return Math.sqrt(deltaX * deltaX + deltaY * deltaY);
}
const dotPad = this.panel.querySelector('.zen-theme-picker-gradient');
const rect = dotPad.getBoundingClientRect();
const padding = 20;
let updatedDots = [...dots];
const centerPosition = { x: rect.width / 2, y: rect.height / 2 };
const harmonyAngles = getColorHarmonyType(
dots.length + (action === 'add' ? 1 : action === 'remove' ? -1 : 0),
this.dots
);
this.useAlgo = harmonyAngles.type;
if (!harmonyAngles || harmonyAngles.angles.length === 0) return dots;
let primaryDot = dots.find((dot) => dot.ID === 0);
if (!primaryDot) return [];
if (action === 'add' && this.dots.length) {
updatedDots.push({ ID: this.dots.length, position: centerPosition });
}
const baseAngle = getAngleFromPosition(primaryDot.position, centerPosition);
let distance = getDistanceFromCenter(primaryDot.position, centerPosition);
const radius = (rect.width - padding) / 2;
if (distance > radius) distance = radius;
if (this.dots.length > 0) {
updatedDots = [{ ID: 0, position: primaryDot.position }];
}
harmonyAngles.angles.forEach((angleOffset, index) => {
let newAngle = (baseAngle + angleOffset) % 360;
let radian = (newAngle * Math.PI) / 180;
let newPosition = {
x: centerPosition.x + distance * Math.cos(radian),
y: centerPosition.y + distance * Math.sin(radian),
};
updatedDots.push({ ID: index + 1, position: newPosition });
});
return updatedDots;
}
handleColorPositions(colorPositions) {
colorPositions.sort((a, b) => a.ID - b.ID);
if (this.useAlgo === 'floating') {
const dotPad = this.panel.querySelector('.zen-theme-picker-gradient');
const rect = dotPad.getBoundingClientRect();
this.dots.forEach((dot) => {
dot.element.style.zIndex = 999;
let pixelX, pixelY;
if (dot.position.x === null) {
const leftPercentage = parseFloat(dot.element.style.left) / 100;
const topPercentage = parseFloat(dot.element.style.top) / 100;
pixelX = leftPercentage * rect.width;
pixelY = topPercentage * rect.height;
} else {
pixelX = dot.position.x;
pixelY = dot.position.y;
}
const colorFromPos = this.getColorFromPosition(pixelX, pixelY);
dot.element.style.setProperty(
'--zen-theme-picker-dot-color',
`rgb(${colorFromPos[0]}, ${colorFromPos[1]}, ${colorFromPos[2]})`
);
});
return;
}
const existingPrimaryDot = this.dots.find((d) => d.ID === 0);
if (existingPrimaryDot) {
existingPrimaryDot.element.style.zIndex = 999;
const colorFromPos = this.getColorFromPosition(
existingPrimaryDot.position.x,
existingPrimaryDot.position.y
);
existingPrimaryDot.element.style.setProperty(
'--zen-theme-picker-dot-color',
`rgb(${colorFromPos[0]}, ${colorFromPos[1]}, ${colorFromPos[2]})`
);
}
colorPositions.forEach((dotPosition) => {
const existingDot = this.dots.find((dot) => dot.ID === dotPosition.ID);
if (existingDot) {
existingDot.position = dotPosition.position;
const colorFromPos = this.getColorFromPosition(
dotPosition.position.x,
dotPosition.position.y
);
existingDot.element.style.setProperty(
'--zen-theme-picker-dot-color',
`rgb(${colorFromPos[0]}, ${colorFromPos[1]}, ${colorFromPos[2]})`
);
if (!this.dragging) {
gZenUIManager.motion.animate(
existingDot.element,
{
left: `${dotPosition.position.x}px`,
top: `${dotPosition.position.y}px`,
},
{
duration: 0.4,
type: 'spring',
bounce: 0.3,
}
);
} else {
existingDot.element.style.left = `${dotPosition.position.x}px`;
existingDot.element.style.top = `${dotPosition.position.y}px`;
}
} else {
this.spawnDot(dotPosition.position);
}
});
}
onThemePickerClick(event) {
if (this._rotating) {
return;
}
event.preventDefault();
const target = event.target;
if (target.id === 'PanelUI-zen-gradient-generator-color-add') {
if (this.dots.length >= ZenThemePicker.MAX_DOTS) return;
let colorPositions = this.calculateCompliments(this.dots, 'add', this.useAlgo);
this.handleColorPositions(colorPositions);
this.updateCurrentWorkspace();
return;
} else if (target.id === 'PanelUI-zen-gradient-generator-color-remove') {
this.dots.sort((a, b) => a.ID - b.ID);
if (this.dots.length === 0) return;
const lastDot = this.dots.pop();
lastDot.element.remove();
this.dots.forEach((dot, index) => {
dot.ID = index;
if (index === 0) {
dot.element.classList.add('primary');
} else {
dot.element.classList.remove('primary');
}
});
let colorPositions = this.calculateCompliments(this.dots, 'remove');
this.handleColorPositions(colorPositions);
this.updateCurrentWorkspace();
return;
} else if (target.id === 'PanelUI-zen-gradient-generator-color-toggle-algo') {
const colorHarmonies = [
{ type: 'complementary', angles: [180] },
{ type: 'splitComplementary', angles: [150, 210] },
{ type: 'analogous', angles: [30, 330] },
{ type: 'triadic', angles: [120, 240] },
{ type: 'floating', angles: [] },
];
const applicableHarmonies = colorHarmonies.filter(
(harmony) => harmony.angles.length + 1 === this.dots.length || harmony.type === 'floating'
);
let currentIndex = applicableHarmonies.findIndex(
(harmony) => harmony.type === this.useAlgo
);
let nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % applicableHarmonies.length;
this.useAlgo = applicableHarmonies[nextIndex].type;
let colorPositions = this.calculateCompliments(this.dots, 'update', this.useAlgo);
this.handleColorPositions(colorPositions);
this.updateCurrentWorkspace();
return;
}
if (event.button !== 0 || this.dragging || this.recentlyDragged) return;
const gradient = this.panel.querySelector('.zen-theme-picker-gradient');
const rect = gradient.getBoundingClientRect();
const padding = 20;
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const radius = (rect.width - padding) / 2;
let pixelX = event.clientX;
let pixelY = event.clientY;
const clickedElement = event.target;
let clickedDot = null;
const existingPrimaryDot = this.dots.find((d) => d.ID === 0);
clickedDot = this.dots.find((dot) => dot.element === clickedElement);
if (clickedDot) {
// TODO: this doesnt work and needs to be fixed
existingPrimaryDot.ID = clickedDot.ID;
clickedDot.ID = 0;
clickedDot.element.style.zIndex = 999;
let colorPositions = this.calculateCompliments(this.dots, 'update', this.useAlgo);
this.handleColorPositions(colorPositions);
return;
}
const distance = Math.sqrt((pixelX - centerX) ** 2 + (pixelY - centerY) ** 2);
if (distance > radius) {
const angle = Math.atan2(pixelY - centerY, pixelX - centerX);
pixelX = centerX + Math.cos(angle) * radius;
pixelY = centerY + Math.sin(angle) * radius;
}
const relativeX = pixelX - rect.left;
const relativeY = pixelY - rect.top;
if (!clickedDot && this.dots.length < 1) {
if (this.dots.length === 0) {
this.spawnDot({ x: relativeX, y: relativeY }, true);
} else {
this.spawnDot({ x: relativeX, y: relativeY });
}
this.updateCurrentWorkspace(true);
} else if (!clickedDot && existingPrimaryDot) {
existingPrimaryDot.position = {
x: relativeX,
y: relativeY,
};
let colorPositions = this.calculateCompliments(this.dots, 'update', this.useAlgo);
this.handleColorPositions(colorPositions);
this.updateCurrentWorkspace(true);
gZenUIManager.motion.animate(
existingPrimaryDot.element,
{
left: `${existingPrimaryDot.position.x}px`,
top: `${existingPrimaryDot.position.y}px`,
},
{
duration: 0.4,
type: 'spring',
bounce: 0.3,
}
);
}
}
onDotMouseDown(event) {
if (event.button === 2) {
return;
}
const draggedDot = this.dots.find((dot) => dot.element === event.target);
if (draggedDot) {
event.preventDefault();
this.dragging = true;
this.draggedDot = event.target;
this.draggedDot.classList.add('dragging');
}
// Store the starting position of the drag
this.dragStartPosition = {
x: event.clientX,
y: event.clientY,
};
}
onDotMouseUp(event) {
if (this._rotating) {
return;
}
if (event.button === 2) {
if (!event.target.classList.contains('zen-theme-picker-dot')) {
return;
}
this.dots = this.dots.filter((dot) => dot.element !== event.target);
event.target.remove();
this.dots.sort((a, b) => a.ID - b.ID);
// Reassign the IDs after sorting
this.dots.forEach((dot, index) => {
dot.ID = index;
if (index === 0) {
dot.element.classList.add('primary');
} else {
dot.element.classList.remove('primary');
}
});
let colorPositions = this.calculateCompliments(this.dots, 'remove');
this.handleColorPositions(colorPositions);
this.updateCurrentWorkspace();
return;
}
if (this.dragging) {
event.preventDefault();
event.stopPropagation();
this.dragging = false;
this.draggedDot.classList.remove('dragging');
this.draggedDot = null;
this.dragStartPosition = null; // Reset the drag start position
this.recentlyDragged = true;
setTimeout(() => {
this.recentlyDragged = false;
}, 100);
return;
}
}
onDotMouseMove(event) {
if (this.dragging) {
event.preventDefault();
const rect = this.panel.querySelector('.zen-theme-picker-gradient').getBoundingClientRect();
const padding = 20; // each side
// do NOT let the ball be draged outside of an imaginary circle. You can drag it anywhere inside the circle
// if the distance between the center of the circle and the dragged ball is bigger than the radius, then the ball
// should be placed on the edge of the circle. If it's inside the circle, then the ball just follows the mouse
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const radius = (rect.width - padding) / 2;
let pixelX = event.clientX;
let pixelY = event.clientY;
const distance = Math.sqrt((pixelX - centerX) ** 2 + (pixelY - centerY) ** 2);
if (distance > radius) {
const angle = Math.atan2(pixelY - centerY, pixelX - centerX);
pixelX = centerX + Math.cos(angle) * radius;
pixelY = centerY + Math.sin(angle) * radius;
}
// set the location of the dot in pixels
const relativeX = pixelX - rect.left;
const relativeY = pixelY - rect.top;
const draggedDot = this.dots.find((dot) => dot.element === this.draggedDot);
draggedDot.element.style.left = `${relativeX}px`;
draggedDot.element.style.top = `${relativeY}px`;
draggedDot.position = {
x: relativeX,
y: relativeY,
};
let colorPositions = this.calculateCompliments(this.dots, 'update', this.useAlgo);
this.handleColorPositions(colorPositions);
this.updateCurrentWorkspace();
}
}
themedColors(colors) {
const isDarkMode = this.isDarkMode;
const factor = isDarkMode ? 0.5 : 1.1;
return colors.map((color) => ({
c: color.isCustom
? color.c
: [
Math.min(255, color.c[0] * factor),
Math.min(255, color.c[1] * factor),
Math.min(255, color.c[2] * factor),
],
isCustom: color.isCustom,
algorithm: color.algorithm,
}));
}
onOpacityChange(event) {
this.currentOpacity = event.target.value;
this.updateCurrentWorkspace();
}
getToolbarModifiedBase() {
const opacity = this.#allowTransparencyOnSidebar ? 0.6 : 1;
return this.isDarkMode
? `color-mix(in srgb, var(--zen-themed-toolbar-bg) 96%, rgba(255,255,255,${opacity}) 4%)`
: `color-mix(in srgb, var(--zen-themed-toolbar-bg) 96%, rgba(0,0,0,${opacity}) 4%)`;
}
getSingleRGBColor(color, forToolbar = false) {
if (color.isCustom) {
return color.c;
}
if (forToolbar) {
const toolbarBg = this.getToolbarModifiedBase();
return `color-mix(in srgb, rgb(${color.c[0]}, ${color.c[1]}, ${color.c[2]}) ${this.currentOpacity * 100}%, ${toolbarBg} ${(1 - this.currentOpacity) * 100}%)`;
}
return `rgba(${color.c[0]}, ${color.c[1]}, ${color.c[2]}, ${this.currentOpacity})`;
}
getGradient(colors, forToolbar = false) {
const themedColors = this.themedColors(colors);
this.useAlgo = themedColors[0]?.algorithm ?? '';
if (themedColors.length === 0) {
return forToolbar
? 'var(--zen-themed-toolbar-bg)'
: 'var(--zen-themed-toolbar-bg-transparent)';
} else if (themedColors.length === 1) {
return this.getSingleRGBColor(themedColors[0], forToolbar);
} else if (themedColors.length !== 3) {
return `linear-gradient(${this.currentRotation}deg, ${themedColors.map((color) => this.getSingleRGBColor(color, forToolbar)).join(', ')})`;
} else {
let color1 = this.getSingleRGBColor(themedColors[2], forToolbar);
let color2 = this.getSingleRGBColor(themedColors[0], forToolbar);
let color3 = this.getSingleRGBColor(themedColors[1], forToolbar);
return `linear-gradient(${this.currentRotation}deg, ${color1}, ${color2}, ${color3})`;
}
}
static getTheme(colors = [], opacity = 0.5, rotation = -45, texture = 0) {
return {
type: 'gradient',
gradientColors: colors ? colors.filter((color) => color) : [], // remove undefined
opacity,
rotation,
texture,
};
}
//TODO: add a better noise system that adds noise not just changes transparency
updateNoise(texture) {
document.documentElement.style.setProperty('--zen-grainy-background-opacity', texture);
document.documentElement.setAttribute(
'zen-show-grainy-background',
texture > 0 ? 'true' : 'false'
);
}
hexToRgb(hex) {
if (hex.startsWith('#')) {
hex = hex.substring(1);
}
if (hex.length === 3) {
hex = hex
.split('')
.map((char) => char + char)
.join('');
}
return [
parseInt(hex.substring(0, 2), 16),
parseInt(hex.substring(2, 4), 16),
parseInt(hex.substring(4, 6), 16),
];
}
pSBC = (p, c0, c1, l) => {
let r,
g,
b,
P,
f,
t,
h,
i = parseInt,
m = Math.round,
a = typeof c1 == 'string';
if (
typeof p != 'number' ||
p < -1 ||
p > 1 ||
typeof c0 != 'string' ||
(c0[0] != 'r' && c0[0] != '#') ||
(c1 && !a)
)
return null;
if (!this.pSBCr)
this.pSBCr = (d) => {
let n = d.length,
x = {};
if (n > 9) {
([r, g, b, a] = d = d.split(',')), (n = d.length);
if (n < 3 || n > 4) return null;
(x.r = i(r[3] == 'a' ? r.slice(5) : r.slice(4))),
(x.g = i(g)),
(x.b = i(b)),
(x.a = a ? parseFloat(a) : -1);
} else {
if (n == 8 || n == 6 || n < 4) return null;
if (n < 6)
d = '#' + d[1] + d[1] + d[2] + d[2] + d[3] + d[3] + (n > 4 ? d[4] + d[4] : '');
d = i(d.slice(1), 16);
if (n == 9 || n == 5)
(x.r = (d >> 24) & 255),
(x.g = (d >> 16) & 255),
(x.b = (d >> 8) & 255),
(x.a = m((d & 255) / 0.255) / 1000);
else (x.r = d >> 16), (x.g = (d >> 8) & 255), (x.b = d & 255), (x.a = -1);
}
return x;
};
(h = c0.length > 9),
(h = a ? (c1.length > 9 ? true : c1 == 'c' ? !h : false) : h),
(f = this.pSBCr(c0)),
(P = p < 0),
(t =
c1 && c1 != 'c'
? this.pSBCr(c1)
: P
? { r: 0, g: 0, b: 0, a: -1 }
: { r: 255, g: 255, b: 255, a: -1 }),
(p = P ? p * -1 : p),
(P = 1 - p);
if (!f || !t) return null;
if (l) (r = m(P * f.r + p * t.r)), (g = m(P * f.g + p * t.g)), (b = m(P * f.b + p * t.b));
else
(r = m((P * f.r ** 2 + p * t.r ** 2) ** 0.5)),
(g = m((P * f.g ** 2 + p * t.g ** 2) ** 0.5)),
(b = m((P * f.b ** 2 + p * t.b ** 2) ** 0.5));
(a = f.a),
(t = t.a),
(f = a >= 0 || t >= 0),
(a = f ? (a < 0 ? t : t < 0 ? a : a * P + t * p) : 0);
if (h)
return (
'rgb' +
(f ? 'a(' : '(') +
r +
',' +
g +
',' +
b +
(f ? ',' + m(a * 1000) / 1000 : '') +
')'
);
else
return (
'#' +
(4294967296 + r * 16777216 + g * 65536 + b * 256 + (f ? m(a * 255) : 0))
.toString(16)
.slice(1, f ? undefined : -2)
);
};
getMostDominantColor(allColors) {
const dominantColor = this.getPrimaryColor(allColors);
const result = this.pSBC(
this.isDarkMode ? 0.2 : -0.5,
`rgb(${dominantColor[0]}, ${dominantColor[1]}, ${dominantColor[2]})`
);
const color = result?.match(/\d+/g)?.map(Number);
if (!color || color.length !== 3) {
return this.getNativeAccentColor();
}
return color;
}
async onWorkspaceChange(workspace, skipUpdate = false, theme = null) {
const uuid = workspace.uuid;
// Use theme from workspace object or passed theme
let workspaceTheme = theme || workspace.theme;
await this.foreachWindowAsActive(async (browser) => {
if (!browser.gZenThemePicker.promiseInitialized) {
return;
}
if (browser.closing || (await browser.gZenThemePicker?.promiseInitialized)) {
return;
}
// Do not rebuild if the workspace is not the same as the current one
const windowWorkspace = await browser.gZenWorkspaces.getActiveWorkspace();
if (windowWorkspace.uuid !== uuid && theme !== null) {
return;
}
// get the theme from the window
workspaceTheme = this.fixTheme(theme || windowWorkspace.theme);
if (!skipUpdate) {
for (const dot of browser.gZenThemePicker.panel.querySelectorAll(
'.zen-theme-picker-dot'
)) {
dot.remove();
}
}
const appBackground = browser.document.getElementById('zen-browser-background');
if (!skipUpdate) {
browser.document.documentElement.style.setProperty(
'--zen-main-browser-background-old',
browser.document.documentElement.style.getPropertyValue('--zen-main-browser-background')
);
browser.document.documentElement.style.setProperty(
'--zen-background-opacity',
browser.gZenThemePicker.previousBackgroundOpacity
);
if (browser.gZenThemePicker.previousBackgroundResolve) {
browser.gZenThemePicker.previousBackgroundResolve();
}
delete browser.gZenThemePicker.previousBackgroundOpacity;
}
const button = browser.document.getElementById(
'PanelUI-zen-gradient-generator-color-toggle-algo'
);
if (browser.gZenThemePicker.useAlgo) {
document.l10n.setAttributes(
button,
`zen-panel-ui-gradient-generator-algo-${browser.gZenThemePicker.useAlgo}`
);
} else {
button.removeAttribute('data-l10n-id');
}
browser.gZenThemePicker.resetCustomColorList();
if (!workspaceTheme || workspaceTheme.type !== 'gradient') {
const gradient = browser.gZenThemePicker.getGradient([]);
const gradientToolbar = browser.gZenThemePicker.getGradient([], true);
browser.document.documentElement.style.setProperty(
'--zen-main-browser-background',
gradient
);
browser.document.documentElement.style.setProperty(
'--zen-main-browser-background-toolbar',
gradientToolbar
);
browser.gZenThemePicker.updateNoise(0);
browser.document.documentElement.style.setProperty(
'--zen-primary-color',
this.getNativeAccentColor()
);
return;
}
browser.gZenThemePicker.currentOpacity = workspaceTheme.opacity ?? 0.5;
browser.gZenThemePicker.currentRotation = workspaceTheme.rotation ?? -45;
browser.gZenThemePicker.currentTexture = workspaceTheme.texture ?? 0;
for (const button of browser.document.querySelectorAll(
'#PanelUI-zen-gradient-generator-color-actions button'
)) {
// disable if there are no buttons
button.disabled =
workspaceTheme.gradientColors.length === 0 ||
(button.id === 'PanelUI-zen-gradient-generator-color-add'
? workspaceTheme.gradientColors.length >= ZenThemePicker.MAX_DOTS
: false);
}
document
.getElementById('PanelUI-zen-gradient-generator-color-click-to-add')
.toggleAttribute('hidden', workspaceTheme.gradientColors.length > 0);
browser.document.getElementById('PanelUI-zen-gradient-generator-opacity').value =
browser.gZenThemePicker.currentOpacity;
const textureSelectWrapper = browser.document.getElementById(
'PanelUI-zen-gradient-generator-texture-wrapper'
);
const textureWrapperWidth = textureSelectWrapper.getBoundingClientRect().width;
// Dont show when hidden
if (textureWrapperWidth) {
// rotate and trasnform relative to the wrapper width depending on the texture value
const textureValue = this.currentTexture;
const textureHandler = browser.gZenThemePicker._textureHandler;
const rotation = textureValue * 360 - 90;
textureHandler.style.transform = `rotate(${rotation + 90}deg)`;
// add top and left to center the texture handler in relation with textureWrapperWidth
// based on the rotation
const top = Math.sin((rotation * Math.PI) / 180) * (textureWrapperWidth / 2) - 6;
const left = Math.cos((rotation * Math.PI) / 180) * (textureWrapperWidth / 2) - 3;
textureHandler.style.top = `${textureWrapperWidth / 2 + top}px`;
textureHandler.style.left = `${textureWrapperWidth / 2 + left}px`;
// Highlight the 16 buttons based on the texture value
const buttons = browser.document.querySelectorAll('.zen-theme-picker-texture-dot');
let i = 4;
for (const button of buttons) {
button.classList.toggle('active', i / 16 <= textureValue);
i++;
// We start at point 4 because that's the first point that is not in the middle of the texture
if (i === 16) {
i = 0;
}
}
const numberOfColors = workspaceTheme.gradientColors?.length;
const rotationDot = browser.document.getElementById(
'PanelUI-zen-gradient-generator-rotation-dot'
);
const rotationLine = browser.document.getElementById(
'PanelUI-zen-gradient-generator-rotation-line'
);
if (numberOfColors > 1) {
rotationDot.style.opacity = 1;
rotationLine.style.opacity = 1;
rotationDot.style.removeProperty('pointer-events');
const rotationPadding = 20;
const rotationParentWidth = rotationDot.parentElement.getBoundingClientRect().width;
const rotationDotPosition = this.currentRotation;
const rotationDotWidth = 30;
const rotationDotX =
Math.cos((rotationDotPosition * Math.PI) / 180) *
(rotationParentWidth / 2 - rotationDotWidth / 2);
const rotationDotY =
Math.sin((rotationDotPosition * Math.PI) / 180) *
(rotationParentWidth / 2 - rotationDotWidth / 2);
rotationDot.style.left = `${rotationParentWidth / 2 + rotationDotX - rotationPadding + rotationDotWidth / 4}px`;
rotationDot.style.top = `${rotationParentWidth / 2 + rotationDotY - rotationPadding + rotationDotWidth / 4}px`;
} else {
rotationDot.style.opacity = 0;
rotationLine.style.opacity = 0;
rotationDot.style.pointerEvents = 'none';
}
}
const gradient = browser.gZenThemePicker.getGradient(workspaceTheme.gradientColors);
const gradientToolbar = browser.gZenThemePicker.getGradient(
workspaceTheme.gradientColors,
true
);
browser.gZenThemePicker.updateNoise(workspaceTheme.texture);
browser.gZenThemePicker.customColorList.innerHTML = '';
for (const dot of workspaceTheme.gradientColors) {
if (dot.isCustom) {
browser.gZenThemePicker.addColorToCustomList(dot.c);
}
}
browser.document.documentElement.style.setProperty(
'--zen-main-browser-background-toolbar',
gradientToolbar
);
browser.document.documentElement.style.setProperty(
'--zen-main-browser-background',
gradient
);
const dominantColor = this.getMostDominantColor(workspaceTheme.gradientColors);
if (dominantColor) {
browser.document.documentElement.style.setProperty(
'--zen-primary-color',
typeof dominantColor === 'string'
? dominantColor
: `rgb(${dominantColor[0]}, ${dominantColor[1]}, ${dominantColor[2]})`
);
}
if (!skipUpdate) {
this.dots = [];
browser.gZenThemePicker.recalculateDots(workspaceTheme.gradientColors);
}
});
}
fixTheme(theme) {
// add a primary color if there isn't one
if (
!theme.gradientColors.find((color) => color.isPrimary) &&
theme.gradientColors.length > 0
) {
theme.gradientColors[(theme.gradientColors.length / 2) | 0].isPrimary = true;
}
return theme;
}
getNativeAccentColor() {
return Services.prefs.getStringPref('zen.theme.accent-color');
}
resetCustomColorList() {
this.customColorList.innerHTML = '';
}
removeCustomColor(event) {
const target = event.target.closest('.zen-theme-picker-custom-list-item');
const color = target.getAttribute('data-color');
const dots = this.panel.querySelectorAll('.zen-theme-picker-dot');
for (const dot of dots) {
if (dot.style.getPropertyValue('--zen-theme-picker-dot-color') === color) {
dot.remove();
break;
}
}
target.remove();
this.updateCurrentWorkspace();
}
getPrimaryColor(colors) {
const primaryColor = colors.find((color) => color.isPrimary);
if (primaryColor) {
return primaryColor.c;
}
if (colors.length === 0) {
return this.hexToRgb(this.getNativeAccentColor());
}
// Get the middle color
return colors[Math.floor(colors.length / 2)].c;
}
recalculateDots(colors) {
for (const color of colors) {
this.createDot(color, true);
}
}
async updateCurrentWorkspace(skipSave = true) {
this.updated = skipSave;
const dots = this.panel.querySelectorAll('.zen-theme-picker-dot');
const colors = Array.from(dots)
.sort((a, b) => a.getAttribute('data-index') - b.getAttribute('data-index'))
.map((dot) => {
const color = dot.style.getPropertyValue('--zen-theme-picker-dot-color');
const isPrimary = dot.classList.contains('primary');
if (color === 'undefined') {
return;
}
const isCustom = dot.classList.contains('custom');
const algorithm = this.useAlgo;
return {
c: isCustom ? color : color.match(/\d+/g).map(Number),
isCustom,
algorithm,
isPrimary,
};
});
const gradient = ZenThemePicker.getTheme(
colors,
this.currentOpacity,
this.currentRotation,
this.currentTexture
);
let currentWorkspace = await gZenWorkspaces.getActiveWorkspace();
if (!skipSave) {
await ZenWorkspacesStorage.saveWorkspaceTheme(currentWorkspace.uuid, gradient);
await gZenWorkspaces._propagateWorkspaceData();
gZenUIManager.showToast('zen-panel-ui-gradient-generator-saved-message');
currentWorkspace = await gZenWorkspaces.getActiveWorkspace();
}
await this.onWorkspaceChange(currentWorkspace, true, skipSave ? gradient : null);
}
async handlePanelClose() {
if (this.updated) {
await this.updateCurrentWorkspace(false);
}
this.uninitThemePicker();
}
handlePanelOpen() {
this.initThemePicker();
setTimeout(() => {
this.updateCurrentWorkspace();
}, 200);
}
}
window.ZenThemePicker = ZenThemePicker;
}