{ class ZenThemePicker extends ZenMultiWindowFeature { static GRADIENT_IMAGE_URL = 'chrome://browser/content/zen-images/gradient.png'; static GRADIENT_DISPLAY_URL = 'chrome://browser/content/zen-images/gradient-display.png'; static MAX_DOTS = 5; currentOpacity = 0.5; currentRotation = 45; numberOfDots = 0; constructor() { super(); if (!Services.prefs.getBoolPref('zen.theme.gradient', true) || !ZenWorkspaces.shouldHaveWorkspaces) { return; } 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')); XPCOMUtils.defineLazyPreferenceGetter( this, 'allowWorkspaceColors', 'zen.theme.color-prefs.use-workspace-colors', true, this.onDarkModeChange.bind(this) ) this.initRotation(); this.initCanvas(); ZenWorkspaces.addChangeListeners(this.onWorkspaceChange.bind(this)); 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 ZenWorkspaces.getActiveWorkspace(); this.onWorkspaceChange(currentWorkspace, skipUpdate); } initContextMenu() { const menu = window.MozXULElement.parseXULToFragment(` `); document.getElementById('toolbar-context-customize').before(menu); } openThemePicker(event) { PanelMultiView.openPopup(this.panel, this.toolbox, { position: 'topright topleft', triggerEvent: event, }); } 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 = 300 - 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.initThemePicker(); this._hasInitialized = true; this.onDarkModeChange(null); } initRotation() { this.rotationInput = document.getElementById('PanelUI-zen-gradient-degrees'); this.rotationInputDot = this.rotationInput.querySelector('.dot'); this.rotationInputText = this.rotationInput.querySelector('.text'); this.rotationInputDot.addEventListener('mousedown', this.onRotationMouseDown.bind(this)); this.rotationInput.addEventListener('wheel', this.onRotationWheel.bind(this)); } onRotationWheel(event) { event.preventDefault(); const delta = event.deltaY; const degrees = this.currentRotation + (delta > 0 ? 10 : -10); this.setRotationInput(degrees); this.updateCurrentWorkspace(); } onRotationMouseDown(event) { event.preventDefault(); this.rotationDragging = true; this.rotationInputDot.style.zIndex = 2; this.rotationInputDot.classList.add('dragging'); document.addEventListener('mousemove', this.onRotationMouseMove.bind(this)); document.addEventListener('mouseup', this.onRotationMouseUp.bind(this)); } onRotationMouseUp(event) { this.rotationDragging = false; this.rotationInputDot.style.zIndex = 1; this.rotationInputDot.classList.remove('dragging'); document.removeEventListener('mousemove', this.onRotationMouseMove.bind(this)); document.removeEventListener('mouseup', this.onRotationMouseUp.bind(this)); } onRotationMouseMove(event) { if (this.rotationDragging) { event.preventDefault(); const rect = this.rotationInput.getBoundingClientRect(); // Make the dot follow the mouse in a circle, it can't go outside or inside the circle const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const angle = Math.atan2(event.clientY - centerY, event.clientX - centerX); const distance = Math.sqrt((event.clientX - centerX) ** 2 + (event.clientY - centerY) ** 2); const radius = rect.width / 2; let x = centerX + Math.cos(angle) * radius; let y = centerY + Math.sin(angle) * radius; if (distance > radius) { x = event.clientX; y = event.clientY; } const degrees = Math.round(Math.atan2(y - centerY, x - centerX) * 180 / Math.PI); this.setRotationInput(degrees); this.updateCurrentWorkspace(); } } setRotationInput(degrees) { let fixedRotation = degrees; while (fixedRotation < 0) { fixedRotation += 360; } while (fixedRotation >= 360) { fixedRotation -= 360; } this.currentRotation = degrees; this.rotationInputDot.style.transform = `rotate(${degrees - 20}deg)`; this.rotationInputText.textContent = `${fixedRotation}°`; } initThemePicker() { const themePicker = this.panel.querySelector('.zen-theme-picker-gradient'); themePicker.style.setProperty('--zen-theme-picker-gradient-image', `url(${ZenThemePicker.GRADIENT_DISPLAY_URL})`); themePicker.addEventListener('mousemove', this.onDotMouseMove.bind(this)); themePicker.addEventListener('mouseup', this.onDotMouseUp.bind(this)); } 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) { if (color.isCustom) { this.addColorToCustomList(color.c); } const [r, g, b] = color.c; const dot = document.createElement('div'); dot.classList.add('zen-theme-picker-dot'); 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 { dot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${r}, ${g}, ${b})`); const { x, y } = this.calculateInitialPosition(color); dot.style.left = `${x * 100}%`; dot.style.top = `${y * 100}%`; dot.addEventListener('mousedown', this.onDotMouseDown.bind(this)); } this.panel.querySelector('.zen-theme-picker-gradient').appendChild(dot); if (!fromWorkspace) { this.updateCurrentWorkspace(true); } } onDotMouseDown(event) { event.preventDefault(); if (event.button === 2) { return; } this.dragging = true; this.draggedDot = event.target; this.draggedDot.style.zIndex = 1; this.draggedDot.classList.add('dragging'); } onDotMouseMove(event) { if (this.dragging) { event.preventDefault(); const rect = this.panel.querySelector('.zen-theme-picker-gradient').getBoundingClientRect(); const padding = 90; // 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; this.draggedDot.style.left = `${relativeX}px`; this.draggedDot.style.top = `${relativeY}px`; const color = this.getColorFromPosition(relativeX, relativeY); this.draggedDot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${color[0]}, ${color[1]}, ${color[2]})`); this.updateCurrentWorkspace(); } } 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-custom').style.setProperty('--zen-theme-picker-dot-color', color); listItems.querySelector('.zen-theme-picker-custom-list-item-label').textContent = color; this.customColorList.appendChild(listItems); } async addCustomColor() { const color = this.customColorInput.value; if (!color) { return; } // 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('.zen-theme-picker-gradient').appendChild(dot); this.customColorInput.value = ''; await this.updateCurrentWorkspace(); } onDotMouseUp(event) { if (event.button === 2) { if (!event.target.classList.contains('zen-theme-picker-dot')) { return; } event.target.remove(); this.updateCurrentWorkspace(); this.numberOfDots--; return; } if (this.dragging) { event.preventDefault(); this.dragging = false; this.draggedDot.style.zIndex = 1; this.draggedDot.classList.remove('dragging'); this.draggedDot = null; return; } this.numberOfDots = this.panel.querySelectorAll('.zen-theme-picker-dot').length; if (this.numberOfDots < ZenThemePicker.MAX_DOTS) { this.createDot({c:[255, 255, 255]}); // right in the center! } } themedColors(colors) { const isDarkMode = this.isDarkMode; const factor = isDarkMode ? 0.5 : 1.1; return colors.map(color => { return { 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, } }); } onOpacityChange(event) { this.currentOpacity = event.target.value; this.updateCurrentWorkspace(); } onTextureChange(event) { this.currentTexture = event.target.value; this.updateCurrentWorkspace(); } getSingleRGBColor(color) { if (color.isCustom) { return color.c; } return `color-mix(in srgb, rgb(${color.c[0]}, ${color.c[1]}, ${color.c[2]}) ${this.currentOpacity * 100}%, var(--zen-themed-toolbar-bg) ${(1 - this.currentOpacity) * 100}%)`; } getGradient(colors) { const themedColors = this.themedColors(colors); if (themedColors.length === 0) { return "var(--zen-themed-toolbar-bg)"; } else if (themedColors.length === 1) { return this.getSingleRGBColor(themedColors[0]); } return `linear-gradient(${this.currentRotation}deg, ${themedColors.map(color => this.getSingleRGBColor(color)).join(', ')})`; } getTheme(colors, opacity = 0.5, rotation = 45, texture = 0) { return { type: 'gradient', gradientColors: colors.filter(color => color), // remove undefined opacity, rotation, texture, }; } updateNoise(texture) { const wrapper = document.getElementById('zen-main-app-wrapper'); wrapper.style.setProperty('--zen-grainy-background-opacity', texture); } 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 colors = this.themedColors(allColors); const themedColors = colors.filter(color => !color.isCustom); if (themedColors.length === 0 || !this.allowWorkspaceColors) { return null; } // get the most dominant color in the gradient let dominantColor = themedColors[0].c; let dominantColorCount = 0; for (const color of themedColors) { const count = themedColors.filter(c => c.c[0] === color.c[0] && c.c[1] === color.c[1] && c.c[2] === color.c[2]).length; if (count > dominantColorCount) { dominantColorCount = count; dominantColor = color.c; } } const result = this.pSBC( this.isDarkMode ? 0.1 : -0.1, `rgb(${dominantColor[0]}, ${dominantColor[1]}, ${dominantColor[2]})`); return result?.match(/\d+/g).map(Number); } 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._hasInitialized) { return; } // Do not rebuild if the workspace is not the same as the current one const windowWorkspace = await browser.ZenWorkspaces.getActiveWorkspace(); if (windowWorkspace.uuid !== uuid && theme !== null) { return; } // get the theme from the window workspaceTheme = theme || windowWorkspace.theme; const appWrapper = browser.document.getElementById('zen-main-app-wrapper'); if (!skipUpdate) { appWrapper.removeAttribute('animating'); appWrapper.setAttribute('animating', 'true'); browser.document.body.style.setProperty('--zen-main-browser-background-old', browser.document.body.style.getPropertyValue('--zen-main-browser-background') ); browser.window.requestAnimationFrame(() => { setTimeout(() => { appWrapper.removeAttribute('animating'); }, 500); }); } browser.gZenThemePicker.resetCustomColorList(); if (!workspaceTheme || workspaceTheme.type !== 'gradient') { browser.document.documentElement.style.removeProperty('--zen-main-browser-background'); browser.gZenThemePicker.updateNoise(0); if (!skipUpdate) { for (const dot of browser.gZenThemePicker.panel.querySelectorAll('.zen-theme-picker-dot')) { dot.remove(); } } 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; browser.gZenThemePicker.numberOfDots = workspaceTheme.gradientColors.length; browser.document.getElementById('PanelUI-zen-gradient-generator-opacity').value = browser.gZenThemePicker.currentOpacity; browser.document.getElementById('PanelUI-zen-gradient-generator-texture').value = browser.gZenThemePicker.currentTexture; browser.gZenThemePicker.setRotationInput(browser.gZenThemePicker.currentRotation); const gradient = browser.gZenThemePicker.getGradient(workspaceTheme.gradientColors); browser.gZenThemePicker.updateNoise(workspaceTheme.texture); for (const dot of workspaceTheme.gradientColors) { if (dot.isCustom) { browser.gZenThemePicker.addColorToCustomList(dot.c); } } 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', `rgb(${dominantColor[0]}, ${dominantColor[1]}, ${dominantColor[2]})`); } if (!skipUpdate) { browser.gZenThemePicker.recalculateDots(workspaceTheme.gradientColors); } }); } 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(); } recalculateDots(colors) { const dots = this.panel.querySelectorAll('.zen-theme-picker-dot'); for (let i = 0; i < colors.length; i++) { dots[i]?.remove(); } 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).map(dot => { const color = dot.style.getPropertyValue('--zen-theme-picker-dot-color'); if (color === 'undefined') { return; } const isCustom = dot.classList.contains('custom'); return {c: isCustom ? color : color.match(/\d+/g).map(Number), isCustom}; }); const gradient = this.getTheme(colors, this.currentOpacity, this.currentRotation, this.currentTexture); let currentWorkspace = await ZenWorkspaces.getActiveWorkspace(); if(!skipSave) { await ZenWorkspacesStorage.saveWorkspaceTheme(currentWorkspace.uuid, gradient); await ZenWorkspaces._propagateWorkspaceData(); ConfirmationHint.show(document.getElementById("PanelUI-menu-button"), "zen-panel-ui-gradient-generator-saved-message"); currentWorkspace = await ZenWorkspaces.getActiveWorkspace(); } await this.onWorkspaceChange(currentWorkspace, true, skipSave ? gradient : null); } async handlePanelClose() { if(this.updated) { await this.updateCurrentWorkspace(false); } } } window.ZenThemePicker = ZenThemePicker; }