diff --git a/.gitmodules b/.gitmodules index 145ac86df..cfb9d2b93 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "src/browser/base/content/zen-components"] - path = src/browser/base/content/zen-components - url = https://github.com/zen-browser/components [submodule "l10n"] path = l10n url = https://github.com/zen-browser/l10n-packs diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn index 2f86ad5dd..db767710e 100644 --- a/src/browser/base/content/zen-assets.jar.inc.mn +++ b/src/browser/base/content/zen-assets.jar.inc.mn @@ -4,24 +4,24 @@ content/browser/ZenStartup.mjs (content/ZenStartup.mjs) content/browser/ZenUIManager.mjs (content/ZenUIManager.mjs) content/browser/ZenCustomizableUI.sys.mjs (content/ZenCustomizableUI.sys.mjs) - content/browser/zen-components/ZenCompactMode.mjs (content/zen-components/src/ZenCompactMode.mjs) - content/browser/zen-components/ZenViewSplitter.mjs (content/zen-components/src/ZenViewSplitter.mjs) - content/browser/zen-components/ZenThemesCommon.mjs (content/zen-components/src/ZenThemesCommon.mjs) - content/browser/zen-components/ZenWorkspaces.mjs (content/zen-components/src/ZenWorkspaces.mjs) - content/browser/zen-components/ZenWorkspacesStorage.mjs (content/zen-components/src/ZenWorkspacesStorage.mjs) - content/browser/zen-components/ZenWorkspacesSync.mjs (content/zen-components/src/ZenWorkspacesSync.mjs) - content/browser/zen-components/ZenSidebarManager.mjs (content/zen-components/src/ZenSidebarManager.mjs) - content/browser/zen-components/ZenProfileDialogUI.mjs (content/zen-components/src/ZenProfileDialogUI.mjs) - content/browser/zen-components/ZenKeyboardShortcuts.mjs (content/zen-components/src/ZenKeyboardShortcuts.mjs) - content/browser/zen-components/ZenThemeBuilder.mjs (content/zen-components/src/ZenThemeBuilder.mjs) - content/browser/zen-components/ZenThemesImporter.mjs (content/zen-components/src/ZenThemesImporter.mjs) - content/browser/zen-components/ZenTabUnloader.mjs (content/zen-components/src/ZenTabUnloader.mjs) - content/browser/zen-components/ZenPinnedTabsStorage.mjs (content/zen-components/src/ZenPinnedTabsStorage.mjs) - content/browser/zen-components/ZenPinnedTabManager.mjs (content/zen-components/src/ZenPinnedTabManager.mjs) - content/browser/zen-components/ZenCommonUtils.mjs (content/zen-components/src/ZenCommonUtils.mjs) - content/browser/zen-components/ZenGradientGenerator.mjs (content/zen-components/src/ZenGradientGenerator.mjs) - content/browser/zen-components/ZenGlanceManager.mjs (content/zen-components/src/ZenGlanceManager.mjs) - content/browser/zen-components/ZenActorsManager.mjs (content/zen-components/src/ZenActorsManager.mjs) + content/browser/zen-components/ZenCompactMode.mjs (zen-components/ZenCompactMode.mjs) + content/browser/zen-components/ZenViewSplitter.mjs (zen-components/ZenViewSplitter.mjs) + content/browser/zen-components/ZenThemesCommon.mjs (zen-components/ZenThemesCommon.mjs) + content/browser/zen-components/ZenWorkspaces.mjs (zen-components/ZenWorkspaces.mjs) + content/browser/zen-components/ZenWorkspacesStorage.mjs (zen-components/ZenWorkspacesStorage.mjs) + content/browser/zen-components/ZenWorkspacesSync.mjs (zen-components/ZenWorkspacesSync.mjs) + content/browser/zen-components/ZenSidebarManager.mjs (zen-components/ZenSidebarManager.mjs) + content/browser/zen-components/ZenProfileDialogUI.mjs (zen-components/ZenProfileDialogUI.mjs) + content/browser/zen-components/ZenKeyboardShortcuts.mjs (zen-components/ZenKeyboardShortcuts.mjs) + content/browser/zen-components/ZenThemeBuilder.mjs (zen-components/ZenThemeBuilder.mjs) + content/browser/zen-components/ZenThemesImporter.mjs (zen-components/ZenThemesImporter.mjs) + content/browser/zen-components/ZenTabUnloader.mjs (zen-components/ZenTabUnloader.mjs) + content/browser/zen-components/ZenPinnedTabsStorage.mjs (zen-components/ZenPinnedTabsStorage.mjs) + content/browser/zen-components/ZenPinnedTabManager.mjs (zen-components/ZenPinnedTabManager.mjs) + content/browser/zen-components/ZenCommonUtils.mjs (zen-components/ZenCommonUtils.mjs) + content/browser/zen-components/ZenGradientGenerator.mjs (zen-components/ZenGradientGenerator.mjs) + content/browser/zen-components/ZenGlanceManager.mjs (zen-components/ZenGlanceManager.mjs) + content/browser/zen-components/ZenActorsManager.mjs (zen-components/ZenActorsManager.mjs) content/browser/zen-styles/zen-theme.css (content/zen-styles/zen-theme.css) content/browser/zen-styles/zen-buttons.css (content/zen-styles/zen-buttons.css) @@ -56,7 +56,7 @@ content/browser/zen-images/gradient-display.png (content/zen-images/gradient-display.png) # Actors - content/browser/zen-components/actors/ZenThemeMarketplaceParent.sys.mjs (content/zen-components/src/actors/ZenThemeMarketplaceParent.sys.mjs) - content/browser/zen-components/actors/ZenThemeMarketplaceChild.sys.mjs (content/zen-components/src/actors/ZenThemeMarketplaceChild.sys.mjs) - content/browser/zen-components/actors/ZenGlanceChild.sys.mjs (content/zen-components/src/actors/ZenGlanceChild.sys.mjs) - content/browser/zen-components/actors/ZenGlanceParent.sys.mjs (content/zen-components/src/actors/ZenGlanceParent.sys.mjs) + content/browser/zen-components/actors/ZenThemeMarketplaceParent.sys.mjs (zen-components/actors/ZenThemeMarketplaceParent.sys.mjs) + content/browser/zen-components/actors/ZenThemeMarketplaceChild.sys.mjs (zen-components/actors/ZenThemeMarketplaceChild.sys.mjs) + content/browser/zen-components/actors/ZenGlanceChild.sys.mjs (zen-components/actors/ZenGlanceChild.sys.mjs) + content/browser/zen-components/actors/ZenGlanceParent.sys.mjs (zen-components/actors/ZenGlanceParent.sys.mjs) diff --git a/src/browser/base/content/zen-components b/src/browser/base/content/zen-components deleted file mode 160000 index 037547460..000000000 --- a/src/browser/base/content/zen-components +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 037547460d0e25a276ad340e1351d3acd9d29f60 diff --git a/src/browser/base/zen-components/ZenActorsManager.mjs b/src/browser/base/zen-components/ZenActorsManager.mjs new file mode 100644 index 000000000..404895247 --- /dev/null +++ b/src/browser/base/zen-components/ZenActorsManager.mjs @@ -0,0 +1,19 @@ + +// Utility to register JSWindowActors +var gZenActorsManager = { + _actors: new Set(), + + addJSWindowActor(...args) { + if (this._actors.has(args[0])) { + // Actor already registered, nothing to do + return; + } + + try { + ChromeUtils.registerWindowActor(...args); + this._actors.add(args[0]); + } catch (e) { + console.warn(`Failed to register JSWindowActor: ${e}`); + } + }, +} diff --git a/src/browser/base/zen-components/ZenCommonUtils.mjs b/src/browser/base/zen-components/ZenCommonUtils.mjs new file mode 100644 index 000000000..cf8aec999 --- /dev/null +++ b/src/browser/base/zen-components/ZenCommonUtils.mjs @@ -0,0 +1,55 @@ +var gZenOperatingSystemCommonUtils = { + kZenOSToSmallName: { + WINNT: 'windows', + Darwin: 'macos', + Linux: 'linux', + }, + + get currentOperatingSystem() { + let os = Services.appinfo.OS; + return this.kZenOSToSmallName[os]; + }, +}; + +class ZenMultiWindowFeature { + constructor() {} + + static get browsers() { + return Services.wm.getEnumerator('navigator:browser'); + } + + static get currentBrowser() { + return Services.wm.getMostRecentWindow('navigator:browser'); + } + + isActiveWindow() { + return ZenMultiWindowFeature.currentBrowser === window; + } + + async foreachWindowAsActive(callback) { + if (!this.isActiveWindow()) { + return; + } + for (const browser of ZenMultiWindowFeature.browsers) { + try { + await callback(browser); + } catch (e) { + console.error(e); + } + } + } +} + +class ZenDOMOperatedFeature { + constructor() { + var initBound = this.init.bind(this); + document.addEventListener('DOMContentLoaded', initBound, { once: true }); + } +} + +class ZenPreloadedFeature { + constructor() { + var initBound = this.init.bind(this); + document.addEventListener('MozBeforeInitialXULLayout', initBound, { once: true }); + } +} diff --git a/src/browser/base/zen-components/ZenCompactMode.mjs b/src/browser/base/zen-components/ZenCompactMode.mjs new file mode 100644 index 000000000..9a446e4f8 --- /dev/null +++ b/src/browser/base/zen-components/ZenCompactMode.mjs @@ -0,0 +1,253 @@ +const lazyCompactMode = {}; + +XPCOMUtils.defineLazyPreferenceGetter( + lazyCompactMode, + 'COMPACT_MODE_FLASH_DURATION', + 'zen.view.compact.toolbar-flash-popup.duration', + 800 +); + +var gZenCompactModeManager = { + _flashTimeouts: {}, + _evenListeners: [], + _removeHoverFrames: {}, + + init() { + Services.prefs.addObserver('zen.view.compact', this._updateEvent.bind(this)); + Services.prefs.addObserver('zen.view.sidebar-expanded.on-hover', this._disableTabsOnHoverIfConflict.bind(this)); + Services.prefs.addObserver('zen.tabs.vertical.right-side', this._updateSidebarIsOnRight.bind(this)); + + gZenUIManager.addPopupTrackingAttribute(this.sidebar); + gZenUIManager.addPopupTrackingAttribute(document.getElementById('zen-appcontent-navbar-container')); + + this.addMouseActions(); + this.addContextMenu(); + }, + + get prefefence() { + return Services.prefs.getBoolPref('zen.view.compact'); + }, + + set preference(value) { + Services.prefs.setBoolPref('zen.view.compact', value); + return value; + }, + + get sidebarIsOnRight() { + if (this._sidebarIsOnRight) { + return this._sidebarIsOnRight; + } + return Services.prefs.getBoolPref('zen.tabs.vertical.right-side'); + }, + + get sidebar() { + if (!this._sidebar) { + this._sidebar = document.getElementById('navigator-toolbox'); + } + return this._sidebar; + }, + + addContextMenu() { + const fragment = window.MozXULElement.parseXULToFragment(` + + + + + + + + + + `); + document.getElementById('viewToolbarsMenuSeparator').before(fragment); + this.updateContextMenu(); + }, + + hideSidebar() { + Services.prefs.setBoolPref('zen.view.compact.hide-tabbar', true); + Services.prefs.setBoolPref('zen.view.compact.hide-toolbar', false); + }, + + hideToolbar() { + Services.prefs.setBoolPref('zen.view.compact.hide-toolbar', true); + Services.prefs.setBoolPref('zen.view.compact.hide-tabbar', false); + }, + + hideBoth() { + Services.prefs.setBoolPref('zen.view.compact.hide-tabbar', true); + Services.prefs.setBoolPref('zen.view.compact.hide-toolbar', true); + }, + + addEventListener(callback) { + this._evenListeners.push(callback); + }, + + _updateEvent() { + this._evenListeners.forEach((callback) => callback()); + this._disableTabsOnHoverIfConflict(); + this.updateContextMenu(); + }, + + updateContextMenu() { + document + .getElementById('zen-context-menu-compact-mode-toggle') + .setAttribute('checked', Services.prefs.getBoolPref('zen.view.compact')); + + const hideTabBar = Services.prefs.getBoolPref('zen.view.compact.hide-tabbar'); + const hideToolbar = Services.prefs.getBoolPref('zen.view.compact.hide-toolbar'); + const hideBoth = hideTabBar && hideToolbar; + + const idName = 'zen-context-menu-compact-mode-hide-'; + document.getElementById(idName + 'sidebar').setAttribute('checked', !hideBoth && hideTabBar); + document.getElementById(idName + 'toolbar').setAttribute('checked', !hideBoth && hideToolbar); + document.getElementById(idName + 'both').setAttribute('checked', hideBoth); + }, + + _removeOpenStateOnUnifiedExtensions() { + // Fix for bug https://github.com/zen-browser/desktop/issues/1925 + const buttons = document.querySelectorAll('toolbarbutton:is(#unified-extensions-button, .webextension-browser-action)'); + for (let button of buttons) { + button.removeAttribute('open'); + } + }, + + _disableTabsOnHoverIfConflict() { + if (Services.prefs.getBoolPref('zen.view.compact') && Services.prefs.getBoolPref('zen.view.compact.hide-tabbar')) { + Services.prefs.setBoolPref('zen.view.sidebar-expanded.on-hover', false); + } + }, + + toggle() { + return (this.preference = !this.prefefence); + }, + + _updateSidebarIsOnRight() { + this._sidebarIsOnRight = Services.prefs.getBoolPref('zen.tabs.vertical.right-side'); + }, + + toggleSidebar() { + this.sidebar.toggleAttribute('zen-user-show'); + }, + + get hideAfterHoverDuration() { + if (this._hideAfterHoverDuration) { + return this._hideAfterHoverDuration; + } + return Services.prefs.getIntPref('zen.view.compact.toolbar-hide-after-hover.duration'); + }, + + get hoverableElements() { + return [ + { + element: this.sidebar, + screenEdge: this.sidebarIsOnRight ? 'right' : 'left', + }, + { + element: document.getElementById('zen-appcontent-navbar-container'), + screenEdge: 'top', + }, + ]; + }, + + flashSidebar(duration = lazyCompactMode.COMPACT_MODE_FLASH_DURATION) { + let tabPanels = document.getElementById('tabbrowser-tabpanels'); + if (!tabPanels.matches("[zen-split-view='true']")) { + this.flashElement(this.sidebar, duration, this.sidebar.id); + } + }, + + flashElement(element, duration, id, attrName = 'flash-popup') { + if (element.matches(':hover')) { + return; + } + if (this._flashTimeouts[id]) { + clearTimeout(this._flashTimeouts[id]); + } else { + requestAnimationFrame(() => element.setAttribute(attrName, 'true')); + } + this._flashTimeouts[id] = setTimeout(() => { + window.requestAnimationFrame(() => { + element.removeAttribute(attrName); + this._flashTimeouts[id] = null; + }); + }, duration); + }, + + clearFlashTimeout(id) { + clearTimeout(this._flashTimeouts[id]); + this._flashTimeouts[id] = null; + }, + + addMouseActions() { + for (let i = 0; i < this.hoverableElements.length; i++) { + let target = this.hoverableElements[i].element; + target.addEventListener('mouseenter', (event) => { + this.clearFlashTimeout('has-hover' + target.id); + window.requestAnimationFrame(() => target.setAttribute('zen-has-hover', 'true')); + }); + + target.addEventListener('mouseleave', (event) => { + // If on Mac, ignore mouseleave in the area of window buttons + if (AppConstants.platform == 'macosx') { + const MAC_WINDOW_BUTTONS_X_BORDER = 75; + const MAC_WINDOW_BUTTONS_Y_BORDER = 40; + if (event.clientX < MAC_WINDOW_BUTTONS_X_BORDER && event.clientY < MAC_WINDOW_BUTTONS_Y_BORDER) { + return; + } + } + + if (this.hoverableElements[i].keepHoverDuration) { + this.flashElement(target, keepHoverDuration, 'has-hover' + target.id, 'zen-has-hover'); + } else { + this._removeHoverFrames[target.id] = window.requestAnimationFrame(() => target.removeAttribute('zen-has-hover')); + } + }); + } + + document.documentElement.addEventListener('mouseleave', (event) => { + const screenEdgeCrossed = this._getCrossedEdge(event.pageX, event.pageY); + if (!screenEdgeCrossed) return; + for (let entry of this.hoverableElements) { + if (screenEdgeCrossed !== entry.screenEdge) continue; + const target = entry.element; + const boundAxis = entry.screenEdge === 'right' || entry.screenEdge === 'left' ? 'y' : 'x'; + if (!this._positionInBounds(boundAxis, target, event.pageX, event.pageY, 7)) { + continue; + } + window.cancelAnimationFrame(this._removeHoverFrames[target.id]); + + this.flashElement(target, this.hideAfterHoverDuration, 'has-hover' + target.id, 'zen-has-hover'); + document.addEventListener( + 'mousemove', + () => { + if (target.matches(':hover')) return; + target.removeAttribute('zen-has-hover'); + this.clearFlashTimeout('has-hover' + target.id); + }, + { once: true } + ); + } + }); + }, + + _getCrossedEdge(posX, posY, element = document.documentElement, maxDistance = 10) { + const targetBox = element.getBoundingClientRect(); + posX = Math.max(targetBox.left, Math.min(posX, targetBox.right)); + posY = Math.max(targetBox.top, Math.min(posY, targetBox.bottom)); + return ['top', 'bottom', 'left', 'right'].find((edge, i) => { + const distance = Math.abs((i < 2 ? posY : posX) - targetBox[edge]); + return distance <= maxDistance; + }); + }, + + _positionInBounds(axis = 'x', element, x, y, error = 0) { + const bBox = element.getBoundingClientRect(); + if (axis === 'y') return bBox.top - error < y && y < bBox.bottom + error; + else return bBox.left - error < x && x < bBox.right + error; + }, + + toggleToolbar() { + let toolbar = document.getElementById('zen-appcontent-navbar-container'); + toolbar.toggleAttribute('zen-user-show'); + }, +}; diff --git a/src/browser/base/zen-components/ZenGlanceManager.mjs b/src/browser/base/zen-components/ZenGlanceManager.mjs new file mode 100644 index 000000000..f4bb5146c --- /dev/null +++ b/src/browser/base/zen-components/ZenGlanceManager.mjs @@ -0,0 +1,352 @@ + +{ + + class ZenGlanceManager extends ZenDOMOperatedFeature { + #currentBrowser = null; + #currentTab = null; + + #animating = false; + + init() { + document.documentElement.setAttribute("zen-glance-uuid", gZenUIManager.generateUuidv4()); + window.addEventListener("keydown", this.onKeyDown.bind(this)); + window.addEventListener("TabClose", this.onTabClose.bind(this)); + + ChromeUtils.defineLazyGetter( + this, + 'sidebarButtons', + () => document.getElementById('zen-glance-sidebar-container') + ); + + document.getElementById('tabbrowser-tabpanels').addEventListener("click", this.onOverlayClick.bind(this)); + + Services.obs.addObserver(this, "quit-application-requested"); + } + + onKeyDown(event) { + if (event.key === "Escape" && this.#currentBrowser) { + event.preventDefault(); + event.stopPropagation(); + this.closeGlance(); + } + } + + onOverlayClick(event) { + if (event.target === this.overlay && event.originalTarget !== this.contentWrapper) { + this.closeGlance(); + } + } + + observe(subject, topic) { + switch (topic) { + case "quit-application-requested": + this.onUnload(); + break; + } + } + + onUnload() { + // clear everything + if (this.#currentBrowser) { + gBrowser.removeTab(this.#currentTab); + } + } + + createBrowserElement(url, currentTab) { + const newTabOptions = { + userContextId: currentTab.getAttribute("usercontextid") || "", + skipBackgroundNotify: true, + insertTab: true, + skipLoad: false, + index: currentTab._tPos + 1, + }; + this.currentParentTab = currentTab; + const newTab = gBrowser.addTrustedTab(Services.io.newURI(url).spec, newTabOptions); + + gBrowser.selectedTab = newTab; + currentTab.querySelector(".tab-content").appendChild(newTab); + newTab.setAttribute("zen-glance-tab", true); + this.#currentBrowser = newTab.linkedBrowser; + this.#currentTab = newTab; + return this.#currentBrowser; + } + + openGlance(data) { + if (this.#currentBrowser) { + return; + } + + const initialX = data.x; + const initialY = data.y; + const initialWidth = data.width; + const initialHeight = data.height; + + this.browserWrapper?.removeAttribute("animate"); + this.browserWrapper?.removeAttribute("animate-end"); + this.browserWrapper?.removeAttribute("animate-full"); + this.browserWrapper?.removeAttribute("animate-full-end"); + this.browserWrapper?.removeAttribute("has-finished-animation"); + + const url = data.url; + const currentTab = gBrowser.selectedTab; + + this.animatingOpen = true; + const browserElement = this.createBrowserElement(url, currentTab); + + this.overlay = browserElement.closest(".browserSidebarContainer"); + this.browserWrapper = browserElement.closest(".browserContainer"); + this.contentWrapper = browserElement.closest(".browserStack"); + + this.browserWrapper.prepend(this.sidebarButtons); + + this.overlay.classList.add("zen-glance-overlay"); + + this.browserWrapper.removeAttribute("animate-end"); + window.requestAnimationFrame(() => { + this.quickOpenGlance(); + + this.browserWrapper.style.setProperty("--initial-x", `${initialX}px`); + this.browserWrapper.style.setProperty("--initial-y", `${initialY}px`); + this.browserWrapper.style.setProperty("--initial-width", initialWidth + "px"); + this.browserWrapper.style.setProperty("--initial-height", initialHeight + "px"); + + this.overlay.removeAttribute("fade-out"); + this.browserWrapper.setAttribute("animate", true); + this.#animating = true; + setTimeout(() => { + this.browserWrapper.setAttribute("animate-end", true); + this.browserWrapper.setAttribute("has-finished-animation", true); + this.#animating = false; + this.animatingOpen = false; + }, 400); + }); + } + + closeGlance({ noAnimation = false, onTabClose = false } = {}) { + if (this.#animating || !this.#currentBrowser || this.animatingOpen || this._duringOpening) { + return; + } + + this.browserWrapper.removeAttribute("has-finished-animation"); + if (noAnimation) { + this.quickCloseGlance({ closeCurrentTab: false }); + this.#currentBrowser = null; + this.#currentTab = null; + return; + } + + gBrowser._insertTabAtIndex(this.#currentTab, { + index: this.currentParentTab._tPos + 1, + }); + + let quikcCloseZen = false; + if (onTabClose) { + // Create new tab if no more ex + if (gBrowser.tabs.length === 1) { + gBrowser.selectedTab = gZenUIManager.openAndChangeToTab(Services.prefs.getStringPref('browser.startup.homepage')); + return; + } else if (gBrowser.selectedTab === this.#currentTab) { + this._duringOpening = true; + gBrowser.tabContainer.advanceSelectedTab(1, true); // to skip the current tab + this._duringOpening = false; + quikcCloseZen = true; + } + } + + // do NOT touch here, I don't know what it does, but it works... + window.requestAnimationFrame(() => { + this.#currentTab.style.display = "none"; + this.browserWrapper.removeAttribute("animate"); + this.browserWrapper.removeAttribute("animate-end"); + this.overlay.setAttribute("fade-out", true); + window.requestAnimationFrame(() => { + this.browserWrapper.setAttribute("animate", true); + setTimeout(() => { + if (!this.currentParentTab) { + return; + } + + if (!onTabClose || quikcCloseZen) { + this.quickCloseGlance(); + } + this.overlay.removeAttribute("fade-out"); + this.browserWrapper.removeAttribute("animate"); + + this.lastCurrentTab = this.#currentTab; + + this.overlay.classList.remove("zen-glance-overlay"); + gBrowser._getSwitcher().setTabStateNoAction(this.lastCurrentTab, gBrowser.AsyncTabSwitcher.STATE_UNLOADED); + + if (!onTabClose && gBrowser.selectedTab === this.lastCurrentTab) { + this._duringOpening = true; + gBrowser.selectedTab = this.currentParentTab; + } + + // reset everything + this.currentParentTab = null; + this.browserWrapper = null; + this.overlay = null; + this.contentWrapper = null; + + this.lastCurrentTab.removeAttribute("zen-glance-tab"); + + gBrowser.tabContainer._invalidateCachedTabs(); + this.lastCurrentTab.closing = true; + gBrowser.removeTab(this.lastCurrentTab, { animate: false }); + + this.#currentTab = null; + this.#currentBrowser = null; + + this.lastCurrentTab = null; + this._duringOpening = false; + }, 500); + }); + }); + } + + quickOpenGlance() { + if (!this.#currentBrowser || this._duringOpening) { + return; + } + this._duringOpening = true; + try { + gBrowser.selectedTab = this.#currentTab; + } catch (e) {} + + this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.add("deck-selected", "zen-glance-background"); + this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.remove("zen-glance-overlay"); + this.currentParentTab.linkedBrowser.zenModeActive = true; + this.#currentBrowser.zenModeActive = true; + this.currentParentTab.linkedBrowser.docShellIsActive = true; + this.#currentBrowser.docShellIsActive = true; + this.#currentBrowser.setAttribute("zen-glance-selected", true); + + this.currentParentTab._visuallySelected = true; + this.overlay.classList.add("deck-selected"); + + this._duringOpening = false; + } + + quickCloseGlance({ closeCurrentTab = true, closeParentTab = true } = {}) { + const parentHasBrowser = !!(this.currentParentTab.linkedBrowser); + if (parentHasBrowser) { + if (closeParentTab) { + this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.remove("deck-selected"); + } + this.currentParentTab.linkedBrowser.zenModeActive = false; + } + this.#currentBrowser.zenModeActive = false; + if (closeParentTab && parentHasBrowser) { + this.currentParentTab.linkedBrowser.docShellIsActive = false; + } + if (closeCurrentTab) { + this.#currentBrowser.docShellIsActive = false; + this.overlay.classList.remove("deck-selected"); + } + if (!this.currentParentTab._visuallySelected && closeParentTab) { + this.currentParentTab._visuallySelected = false; + } + this.#currentBrowser.removeAttribute("zen-glance-selected"); + if (parentHasBrowser) { + this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.remove("zen-glance-background"); + } + } + + onLocationChange(_) { + if (this._duringOpening) { + return; + } + if (gBrowser.selectedTab === this.#currentTab && !this.animatingOpen && !this._duringOpening && this.#currentBrowser) { + this.quickOpenGlance(); + return; + } + if (gBrowser.selectedTab === this.currentParentTab && this.#currentBrowser) { + this.quickOpenGlance(); + } else if ((!this.animatingFullOpen || this.animatingOpen) && this.#currentBrowser) { + this.closeGlance(); + } + } + + onTabClose(event) { + if (event.target === this.currentParentTab) { + this.closeGlance({ onTabClose: true }); + } + } + + fullyOpenGlance() { + gBrowser._insertTabAtIndex(this.#currentTab, { + index: this.#currentTab._tPos + 1, + }); + + this.animatingFullOpen = true; + this.currentParentTab._visuallySelected = false; + + this.browserWrapper.removeAttribute("has-finished-animation"); + this.browserWrapper.setAttribute("animate-full", true); + this.#currentTab.removeAttribute("zen-glance-tab"); + gBrowser.selectedTab = this.#currentTab; + this.currentParentTab.linkedBrowser.closest(".browserSidebarContainer").classList.remove("zen-glance-background"); + setTimeout(() => { + window.requestAnimationFrame(() => { + this.browserWrapper.setAttribute("animate-full-end", true); + setTimeout(() => { + this.animatingFullOpen = false; + this.closeGlance({ noAnimation: true }); + }, 600); + }); + }, 300); + } + + openGlanceForBookmark(event) { + const activationMethod = Services.prefs.getStringPref('zen.glance.activation-method', 'ctrl'); + + if (activationMethod === 'ctrl' && !event.ctrlKey) { + return; + } else if (activationMethod === 'alt' && !event.altKey) { + return; + } else if (activationMethod === 'shift' && !event.shiftKey) { + return; + } else if (activationMethod === 'meta' && !event.metaKey) { + return; + }else if (activationMethod === 'mantain' || typeof activationMethod === 'undefined') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const rect = event.target.getBoundingClientRect(); + const data = { + url: event.target._placesNode.uri, + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }; + + this.openGlance(data); + + return false; + } + } + + window.gZenGlanceManager = new ZenGlanceManager(); + + function registerWindowActors() { + if (Services.prefs.getBoolPref("zen.glance.enabled", true)) { + gZenActorsManager.addJSWindowActor("ZenGlance", { + parent: { + esModuleURI: "chrome://browser/content/zen-components/actors/ZenGlanceParent.sys.mjs", + }, + child: { + esModuleURI: "chrome://browser/content/zen-components/actors/ZenGlanceChild.sys.mjs", + events: { + DOMContentLoaded: {}, + }, + }, + }); + } + } + + registerWindowActors(); +} diff --git a/src/browser/base/zen-components/ZenGradientGenerator.mjs b/src/browser/base/zen-components/ZenGradientGenerator.mjs new file mode 100644 index 000000000..9fc0dd429 --- /dev/null +++ b/src/browser/base/zen-components/ZenGradientGenerator.mjs @@ -0,0 +1,722 @@ + +{ + 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; + } + 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')); + + 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)); + themePicker.addEventListener('click', this.onThemePickerClick.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); + } + } + + onThemePickerClick(event) { + event.preventDefault(); + + + if (event.button !== 0 || this.dragging ) return; + + const gradient = this.panel.querySelector('.zen-theme-picker-gradient'); + const rect = gradient.getBoundingClientRect(); + const padding = 90; // each side + + 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; + + // Check if the click is within the circle + const distance = Math.sqrt((pixelX - centerX) ** 2 + (pixelY - centerY) ** 2); + if (distance > radius) { + return; // Don't create a dot if clicking outside the circle + } + + // Check if we clicked on an existing dot + const clickedElement = event.target; + const isExistingDot = clickedElement.classList.contains('zen-theme-picker-dot'); + + // Only proceed if not clicking on an existing dot + if (!isExistingDot) { + + const relativeX = event.clientX - rect.left; + const relativeY = event.clientY - rect.top; + + + const color = this.getColorFromPosition(relativeX, relativeY); + + // Create new dot + const dot = document.createElement('div'); + dot.classList.add('zen-theme-picker-dot'); + dot.addEventListener('mousedown', this.onDotMouseDown.bind(this)); + + dot.style.left = `${relativeX}px`; + dot.style.top = `${relativeY}px`; + dot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${color[0]}, ${color[1]}, ${color[2]})`); + + gradient.appendChild(dot); + + 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'); + + // Store the starting position of the drag + this.dragStartPosition = { + x: event.clientX, + y: event.clientY + }; + } + + + 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(); + } + + + + onThemePickerClick(event) { + event.preventDefault(); + + if (event.button !== 0 || this.dragging) return; + + const gradient = this.panel.querySelector('.zen-theme-picker-gradient'); + const rect = gradient.getBoundingClientRect(); + const padding = 90; // each side + + 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; + + // Check if the click is within the circle + const distance = Math.sqrt((pixelX - centerX) ** 2 + (pixelY - centerY) ** 2); + if (distance > radius) { + return; + } + + + const clickedElement = event.target; + const isExistingDot = clickedElement.classList.contains('zen-theme-picker-dot'); + + + if (!isExistingDot && this.numberOfDots < ZenThemePicker.MAX_DOTS) { + const relativeX = event.clientX - rect.left; + const relativeY = event.clientY - rect.top; + + const color = this.getColorFromPosition(relativeX, relativeY); + + const dot = document.createElement('div'); + dot.classList.add('zen-theme-picker-dot'); + dot.addEventListener('mousedown', this.onDotMouseDown.bind(this)); + + dot.style.left = `${relativeX}px`; + dot.style.top = `${relativeY}px`; + dot.style.setProperty('--zen-theme-picker-dot-color', `rgb(${color[0]}, ${color[1]}, ${color[2]})`); + + gradient.appendChild(dot); + + 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'); + + // Store the starting position of the drag + this.dragStartPosition = { + x: event.clientX, + y: event.clientY + }; + } + + 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(); + event.stopPropagation(); + this.dragging = false; + this.draggedDot.style.zIndex = 1; + this.draggedDot.classList.remove('dragging'); + this.draggedDot = null; + this.dragStartPosition = null; // Reset the drag start position + return; + } + + this.numberOfDots = this.panel.querySelectorAll('.zen-theme-picker-dot').length; + } + + + 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, + }; + } + //TODO: add a better noise system that adds noise not just changes transparency + 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.5 : -0.5, + `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; +} diff --git a/src/browser/base/zen-components/ZenKeyboardShortcuts.mjs b/src/browser/base/zen-components/ZenKeyboardShortcuts.mjs new file mode 100644 index 000000000..2655ddd73 --- /dev/null +++ b/src/browser/base/zen-components/ZenKeyboardShortcuts.mjs @@ -0,0 +1,1014 @@ +const KEYCODE_MAP = { + F1: 'VK_F1', + F2: 'VK_F2', + F3: 'VK_F3', + F4: 'VK_F4', + F5: 'VK_F5', + F6: 'VK_F6', + F7: 'VK_F7', + F8: 'VK_F8', + F9: 'VK_F9', + F10: 'VK_F10', + F11: 'VK_F11', + F12: 'VK_F12', + TAB: 'VK_TAB', + ENTER: 'VK_RETURN', + ESCAPE: 'VK_ESCAPE', + SPACE: 'VK_SPACE', + ARROWLEFT: 'VK_LEFT', + ARROWRIGHT: 'VK_RIGHT', + ARROWUP: 'VK_UP', + ARROWDOWN: 'VK_DOWN', + DELETE: 'VK_DELETE', + BACKSPACE: 'VK_BACK', + HOME: 'VK_HOME', +}; + +const defaultKeyboardGroups = { + windowAndTabManagement: [ + 'zen-window-new-shortcut', + 'zen-tab-new-shortcut', + 'zen-key-enter-full-screen', + 'zen-key-exit-full-screen', + 'zen-quit-app-shortcut', + 'zen-close-tab-shortcut', + 'zen-close-shortcut', + 'id:key_selectTab1', + 'id:key_selectTab2', + 'id:key_selectTab3', + 'id:key_selectTab4', + 'id:key_selectTab5', + 'id:key_selectTab6', + 'id:key_selectTab7', + 'id:key_selectTab8', + 'id:key_selectLastTab', + ], + navigation: [ + 'zen-nav-back-shortcut-alt', + 'zen-nav-fwd-shortcut-alt', + 'zen-nav-reload-shortcut-2', + 'zen-nav-reload-shortcut-skip-cache', + 'zen-nav-reload-shortcut', + 'zen-key-stop', + 'zen-window-new-shortcut', + 'zen-private-browsing-shortcut', + 'id:goHome', + 'id:key_gotoHistory', + 'id:goBackKb', + 'id:goForwardKb', + ], + searchAndFind: [ + 'zen-search-focus-shortcut', + 'zen-search-focus-shortcut-alt', + 'zen-find-shortcut', + 'zen-search-find-again-shortcut-2', + 'zen-search-find-again-shortcut', + 'zen-search-find-again-shortcut-prev', + ], + pageOperations: [ + 'zen-location-open-shortcut', + 'zen-location-open-shortcut-alt', + 'zen-save-page-shortcut', + 'zen-print-shortcut', + 'zen-page-source-shortcut', + 'zen-page-info-shortcut', + 'zen-reader-mode-toggle-shortcut-other', + 'zen-picture-in-picture-toggle-shortcut', + ], + historyAndBookmarks: [ + 'zen-history-show-all-shortcut', + 'zen-bookmark-this-page-shortcut', + 'zen-bookmark-show-library-shortcut', + ], + mediaAndDisplay: [ + 'zen-mute-toggle-shortcut', + 'zen-full-zoom-reduce-shortcut', + 'zen-full-zoom-enlarge-shortcut', + 'zen-full-zoom-reset-shortcut', + 'zen-bidi-switch-direction-shortcut', + 'zen-screenshot-shortcut', + ], +}; + +const fixedL10nIds = { + cmd_findPrevious: 'zen-search-find-again-shortcut-prev', + 'Browser:ReloadSkipCache': 'zen-nav-reload-shortcut-skip-cache', + cmd_close: 'zen-close-tab-shortcut', + 'History:RestoreLastClosedTabOrWindowOrSession': 'zen-restore-last-closed-tab-shortcut', +}; + +const ZEN_MAIN_KEYSET_ID = 'mainKeyset'; +const ZEN_KEYSET_ID = 'zenKeyset'; + +const ZEN_COMPACT_MODE_SHORTCUTS_GROUP = 'zen-compact-mode'; +const ZEN_WORKSPACE_SHORTCUTS_GROUP = 'zen-workspace'; +const ZEN_OTHER_SHORTCUTS_GROUP = 'zen-other'; +const ZEN_SPLIT_VIEW_SHORTCUTS_GROUP = 'zen-split-view'; +const FIREFOX_SHORTCUTS_GROUP = 'zen-kbs-invalid'; +const VALID_SHORTCUT_GROUPS = [ + ZEN_COMPACT_MODE_SHORTCUTS_GROUP, + ZEN_WORKSPACE_SHORTCUTS_GROUP, + ZEN_SPLIT_VIEW_SHORTCUTS_GROUP, + ...Object.keys(defaultKeyboardGroups), + ZEN_OTHER_SHORTCUTS_GROUP, + 'other', +]; + +class KeyShortcutModifiers { + #control = false; + #alt = false; + #shift = false; + #meta = false; + #accel = false; + + constructor(ctrl, alt, shift, meta, accel) { + this.#control = ctrl; + this.#alt = alt; + this.#shift = shift; + this.#meta = meta; + this.#accel = accel; + + if (AppConstants.platform != 'macosx') { + // Replace control with accel, to make it more consistent + this.#accel = ctrl || accel; + this.#control = false; + } + } + + static parseFromJSON(modifiers) { + if (!modifiers) { + return new KeyShortcutModifiers(false, false, false, false, false); + } + + return new KeyShortcutModifiers( + modifiers['control'] == true, + modifiers['alt'] == true, + modifiers['shift'] == true, + modifiers['meta'] == true, + modifiers['accel'] == true + ); + } + + static parseFromXHTMLAttribute(modifiers) { + if (!modifiers) { + return new KeyShortcutModifiers(false, false, false, false, false); + } + + return new KeyShortcutModifiers( + modifiers.includes('control'), + modifiers.includes('alt'), + modifiers.includes('shift'), + modifiers.includes('meta'), + modifiers.includes('accel') + ); + } + + // used to avoid any future changes to the object + static fromObject({ ctrl = false, alt = false, shift = false, meta = false, accel = false }) { + return new KeyShortcutModifiers(ctrl, alt, shift, meta, accel); + } + + toUserString() { + let str = ''; + if (this.#control && !this.#accel) { + str += 'Ctrl+'; + } + if (this.#alt) { + str += AppConstants.platform == 'macosx' ? 'Option+' : 'Alt+'; + } + if (this.#shift) { + str += 'Shift+'; + } + if (this.#meta) { + str += AppConstants.platform == 'macosx' ? 'Cmd+' : 'Win+'; + } + if (this.#accel) { + str += AppConstants.platform == 'macosx' ? 'Cmd+' : 'Ctrl+'; + } + return str; + } + + equals(other) { + if (!other) { + return false; + } + // If we are on macos, we can have accel and meta at the same time + return ( + this.#alt == other.#alt && + this.#shift == other.#shift && + this.#control == other.#control && + (AppConstants.platform == 'macosx' + ? ((this.#meta || this.#accel) == (other.#meta || other.#accel) && this.#control == other.#control) + // In other platforms, we can have control and accel counting as the same thing + : (this.#meta == other.#meta && (this.#control || this.#accel) == (other.#control || other.#accel))) + ); + } + + toString() { + let str = ''; + if (this.#control) { + str += 'control,'; + } + if (this.#alt) { + str += 'alt,'; + } + if (this.#shift) { + str += 'shift,'; + } + if (this.#accel) { + str += 'accel,'; + } + if (this.#meta) { + str += 'meta,'; + } + return str.slice(0, -1); + } + + toJSONString() { + return { + control: this.#control, + alt: this.#alt, + shift: this.#shift, + meta: this.#meta, + accel: this.#accel, + }; + } + + areAnyActive() { + return this.#control || this.#alt || this.#shift || this.#meta || this.#accel; + } + + get control() { + return this.#control; + } + + get alt() { + return this.#alt; + } + + get shift() { + return this.#shift; + } + + get meta() { + return this.#meta; + } + + get accel() { + return this.#accel; + } +} + +class KeyShortcut { + #id = ''; + #key = ''; + #keycode = ''; + #group = FIREFOX_SHORTCUTS_GROUP; + #modifiers = new KeyShortcutModifiers(false, false, false, false, false); + #action = ''; + #l10nId = ''; + #disabled = false; + #reserved = false; + #internal = false; + + constructor(id, key, keycode, group, modifiers, action, l10nId, disabled = false, reserved = false, internal = false) { + this.#id = id; + this.#key = key?.toLowerCase(); + this.#keycode = keycode; + + if (!VALID_SHORTCUT_GROUPS.includes(group)) { + throw new Error('Illegal group value: ' + group); + } + + this.#group = group; + this.#modifiers = modifiers; + this.#action = action; + this.#l10nId = KeyShortcut.sanitizeL10nId(l10nId, action); + this.#disabled = disabled; + this.#reserved = reserved; + this.#internal = internal; + } + + isEmpty() { + return !this.#key && !this.getRealKeycode(); + } + + static parseFromSaved(json) { + let rv = []; + + for (let key of json) { + rv.push(this.#parseFromJSON(key)); + } + + return rv; + } + + static getGroupFromL10nId(l10nId, id) { + // Find inside defaultKeyboardGroups + for (let group of Object.keys(defaultKeyboardGroups)) { + for (let shortcut of defaultKeyboardGroups[group]) { + if (shortcut == l10nId || shortcut == 'id:' + id) { + return group; + } + } + } + return 'other'; + } + + static #parseFromJSON(json) { + return new KeyShortcut( + json['id'], + json['key'], + json['keycode'], + json['group'], + KeyShortcutModifiers.parseFromJSON(json['modifiers']), + json['action'], + json['l10nId'], + json['disabled'], + json['reserved'], + json['internal'] + ); + } + + static parseFromXHTML(key) { + return new KeyShortcut( + key.getAttribute('id'), + key.getAttribute('key'), + key.getAttribute('keycode'), + KeyShortcut.getGroupFromL10nId(KeyShortcut.sanitizeL10nId(key.getAttribute('data-l10n-id')), key.getAttribute('id')), + KeyShortcutModifiers.parseFromXHTMLAttribute(key.getAttribute('modifiers')), + key.getAttribute('command'), + key.getAttribute('data-l10n-id'), + key.getAttribute('disabled') == 'true', + key.getAttribute('reserved') == 'true', + key.getAttribute('internal') == 'true' + ); + } + + static sanitizeL10nId(id, action) { + if (!id || id.startsWith('zen-')) { + return id; + } + // Check if any action is on the list of fixed l10n ids + if (fixedL10nIds[action]) { + return fixedL10nIds[action]; + } + return `zen-${id}`; + } + + toXHTMLElement(window) { + let key = window.document.createXULElement('key'); + key.id = this.#id; + if (this.#keycode) { + key.setAttribute('keycode', this.#keycode); + } else { + // note to "mr. macos": Better use setAttribute, because without it, there's a + // risk of malforming the XUL element. + key.setAttribute('key', this.#key); + } + key.setAttribute('group', this.#group); + + // note to "mr. macos": We add the `zen-` prefix because since firefox hasnt been built with the + // shortcuts in mind, it will siply just override the shortcuts with whatever the default is. + // note that this l10n id is not used for actually translating the key's label, but rather to + // identify the default keybinds. + if (this.#l10nId) { + key.setAttribute('data-l10n-id', this.#l10nId); + } + key.setAttribute('modifiers', this.#modifiers.toString()); + if (this.#action) { + if (this.#action?.startsWith('code:')) { + key.setAttribute('oncommand', this.#action.substring(5)); + } else { + key.setAttribute('command', this.#action); + } + } + if (this.#disabled) { + key.setAttribute('disabled', this.#disabled); + } + if (this.#reserved) { + key.setAttribute('reserved', this.#reserved); + } + if (this.#internal) { + key.setAttribute('internal', this.#internal); + } + key.setAttribute('zen-keybind', 'true'); + + return key; + } + + _modifyInternalAttribute(value) { + this.#internal = value; + } + + getRealKeycode() { + if (this.#keycode === '') { + return null; + } + return this.#keycode; + } + + getID() { + return this.#id; + } + + getAction() { + return this.#action; + } + + getL10NID() { + return this.#l10nId; + } + + getGroup() { + return this.#group; + } + + getModifiers() { + return this.#modifiers; + } + + getKeyName() { + return this.#key?.toLowerCase(); + } + + getKeyCode() { + return this.getRealKeycode(); + } + + getKeyNameOrCode() { + return this.#key ? this.getKeyName() : this.getKeyCode(); + } + + isDisabled() { + return this.#disabled; + } + + isReserved() { + return this.#reserved; + } + + isInternal() { + return this.#internal; + } + + setModifiers(modifiers) { + if ((!modifiers) instanceof KeyShortcutModifiers) { + throw new Error('Only KeyShortcutModifiers allowed'); + } + this.#modifiers = modifiers; + } + + toJSONForm() { + return { + id: this.#id, + key: this.#key, + keycode: this.#keycode, + group: this.#group, + l10nId: this.#l10nId, + modifiers: this.#modifiers.toJSONString(), + action: this.#action, + disabled: this.#disabled, + reserved: this.#reserved, + internal: this.#internal, + }; + } + + toUserString() { + let str = this.#modifiers.toUserString(); + + if (this.#key) { + str += this.#key.toUpperCase(); + } else if (this.#keycode) { + // Get the key from the value + for (let [key, value] of Object.entries(KEYCODE_MAP)) { + if (value == this.#keycode) { + str += key.toLowerCase(); + break; + } + } + } else { + return ''; + } + return str; + } + + isUserEditable() { + if (!this.#id || this.#internal || (this.#group == FIREFOX_SHORTCUTS_GROUP && this.#disabled)) { + return false; + } + return true; + } + + clearKeybind() { + this.#key = ''; + this.#keycode = ''; + this.#modifiers = new KeyShortcutModifiers(false, false, false, false); + } + + setNewBinding(shortcut) { + for (let keycode of Object.keys(KEYCODE_MAP)) { + if (keycode == shortcut.toUpperCase()) { + this.#keycode = KEYCODE_MAP[keycode]; + this.#key = ''; + return; + } + } + + this.#keycode = ''; // Clear the keycode + this.#key = shortcut; + } +} + +class ZenKeyboardShortcutsLoader { + constructor() {} + + get shortcutsFile() { + return PathUtils.join(PathUtils.profileDir, 'zen-keyboard-shortcuts.json'); + } + + async save(data) { + await IOUtils.writeJSON(this.shortcutsFile, data); + } + + async loadObject() { + try { + return await IOUtils.readJSON(this.shortcutsFile); + } catch (e) { + // Recreate shortcuts file + Services.prefs.clearUserPref('zen.keyboard.shortcuts.version'); + console.error('Error loading shortcuts file', e); + return null; + } + } + + async load() { + return (await this.loadObject())?.shortcuts; + } + + async remove() { + await IOUtils.remove(this.shortcutsFile); + } +} + +function zenGetDefaultShortcuts() { + // DO NOT CHANGE ANYTHING HERE + // For adding new default shortcuts, add them to inside the migration function + // and increment the version number. + + console.info('Zen CKS: Loading default shortcuts...'); + let keySet = document.getElementById(ZEN_MAIN_KEYSET_ID); + let newShortcutList = []; + + // Firefox's standard keyset. Reverse order to keep the order of the keys + for (let i = keySet.children.length - 1; i >= 0; i--) { + let key = keySet.children[i]; + let parsed = KeyShortcut.parseFromXHTML(key); + newShortcutList.push(parsed); + } + + // Compact mode's keyset + newShortcutList.push( + new KeyShortcut( + 'zen-compact-mode-toggle', + 'C', + '', + ZEN_COMPACT_MODE_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + 'code:gZenCompactModeManager.toggle()', + 'zen-compact-mode-shortcut-toggle' + ) + ); + newShortcutList.push( + new KeyShortcut( + 'zen-compact-mode-show-sidebar', + 'S', + '', + ZEN_COMPACT_MODE_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + 'code:gZenCompactModeManager.toggleSidebar()', + 'zen-compact-mode-shortcut-show-sidebar' + ) + ); + newShortcutList.push( + new KeyShortcut( + 'zen-compact-mode-show-toolbar', + 'T', + '', + ZEN_COMPACT_MODE_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + 'code:gZenCompactModeManager.toggleToolbar()', + 'zen-compact-mode-shortcut-show-toolbar' + ) + ); + + // Workspace's keyset + for (let i = 10; i > 0; i--) { + newShortcutList.push( + new KeyShortcut( + `zen-workspace-switch-${i}`, + '', + '', + ZEN_WORKSPACE_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({}), + `code:ZenWorkspaces.shortcutSwitchTo(${i - 1})`, + `zen-workspace-shortcut-switch-${i}` + ) + ); + } + newShortcutList.push( + new KeyShortcut( + 'zen-workspace-forward', + 'E', + '', + ZEN_WORKSPACE_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + 'code:ZenWorkspaces.changeWorkspaceShortcut()', + 'zen-workspace-shortcut-forward' + ) + ); + newShortcutList.push( + new KeyShortcut( + 'zen-workspace-backward', + 'Q', + '', + ZEN_WORKSPACE_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + 'code:ZenWorkspaces.changeWorkspaceShortcut(-1)', + 'zen-workspace-shortcut-backward' + ) + ); + + // Other keyset + newShortcutList.push( + new KeyShortcut( + 'zen-toggle-web-panel', + 'P', + '', + ZEN_OTHER_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ alt: true }), + 'code:gZenBrowserManagerSidebar.toggle()', + 'zen-web-panel-shortcut-toggle' + ) + ); + newShortcutList.push( + new KeyShortcut( + 'zen-toggle-sidebar', + 'B', + '', + ZEN_OTHER_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ alt: true }), + 'code:gZenVerticalTabsManager.toggleExpand()', + 'zen-sidebar-shortcut-toggle' + ) + ); + + // Split view + newShortcutList.push( + new KeyShortcut( + 'zen-split-view-grid', + 'G', + '', + ZEN_SPLIT_VIEW_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + "code:gZenViewSplitter.toggleShortcut('grid')", + 'zen-split-view-shortcut-grid' + ) + ); + newShortcutList.push( + new KeyShortcut( + 'zen-split-view-vertical', + 'V', + '', + ZEN_SPLIT_VIEW_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + "code:gZenViewSplitter.toggleShortcut('vsep')", + 'zen-split-view-shortcut-vertical' + ) + ); + newShortcutList.push( + new KeyShortcut( + 'zen-split-view-horizontal', + 'H', + '', + ZEN_SPLIT_VIEW_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + "code:gZenViewSplitter.toggleShortcut('hsep')", + 'zen-split-view-shortcut-horizontal' + ) + ); + newShortcutList.push( + new KeyShortcut( + 'zen-split-view-unsplit', + 'U', + '', + ZEN_SPLIT_VIEW_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({ accel: true, alt: true }), + "code:gZenViewSplitter.toggleShortcut('unsplit')", + 'zen-split-view-shortcut-unsplit' + ) + ); + + return newShortcutList; +} + +class ZenKeyboardShortcutsVersioner { + static LATEST_KBS_VERSION = 3; + + constructor() {} + + get version() { + return Services.prefs.getIntPref('zen.keyboard.shortcuts.version', 0); + } + + set version(version) { + Services.prefs.setIntPref('zen.keyboard.shortcuts.version', version); + } + + getVersionedData(data) { + return { + shortcuts: data, + }; + } + + isVersionUpToDate() { + return this.version == ZenKeyboardShortcutsVersioner.LATEST_KBS_VERSION; + } + + isVersionOutdated() { + return this.version < ZenKeyboardShortcutsVersioner.LATEST_KBS_VERSION; + } + + migrateIfNeeded(data) { + if (!data) { + // Rebuid the shortcuts, just in case + this.version = 0; + } + + if (this.isVersionUpToDate()) { + return data; + } + + if (this.isVersionOutdated()) { + const version = this.version; + console.info( + 'Zen CKS: Migrating shortcuts from version', + version, + 'to', + ZenKeyboardShortcutsVersioner.LATEST_KBS_VERSION + ); + const newData = this.migrate(data, version); + this.version = ZenKeyboardShortcutsVersioner.LATEST_KBS_VERSION; + return newData; + } + + console.error('Unknown keyboar shortcuts version'); + this.version = 0; + return this.migrateIfNeeded(data); + } + + migrate(data, version) { + if (version < 1) { + // Migrate from 0 to 1 + // Here, we do a complet reset of the shortcuts, + // since nothing seems to work properly. + data = zenGetDefaultShortcuts(); + } + if (version < 2) { + // Migrate from 1 to 2 + // In this new version, we are resolving the conflicts between + // shortcuts having keycode and key at the same time. + // If there's both, we remove the keycodes. + for (let shortcut of data) { + if (shortcut.getKeyCode() && shortcut.getKeyName()) { + shortcut.setNewBinding(shortcut.getKeyName()); + } + } + data.push( + new KeyShortcut( + 'zen-pinned-tab-reset-shortcut', + '', + '', + ZEN_OTHER_SHORTCUTS_GROUP, + KeyShortcutModifiers.fromObject({}), + 'code:gZenPinnedTabManager.resetPinnedTab(gBrowser.selectedTab)', + 'zen-pinned-tab-shortcut-reset' + ) + ); + } + if (version < 3) { + // Migrate from 2 to 3 + // In this new version, there was this *really* annoying bug. Shortcuts + // detection for internal keys was not working properly, so every internal + // shortcut was being saved as a user-editable shortcut. + // This migration will fix this issue. + const defaultShortcuts = zenGetDefaultShortcuts(); + // Get the default shortcut, compare the id and set the internal flag if needed + for (let shortcut of data) { + for (let defaultShortcut of defaultShortcuts) { + if (shortcut.getID() == defaultShortcut.getID()) { + shortcut._modifyInternalAttribute(defaultShortcut.isInternal()); + } + } + } + } + return data; + } +} + +var gZenKeyboardShortcutsManager = { + loader: new ZenKeyboardShortcutsLoader(), + beforeInit() { + if (!this.inBrowserView) { + return; + } + // Create the main keyset before calling the async init function, + // This is because other browser-sets needs this element and the JS event + // handled wont wait for the async function to finish. + void(this.getZenKeyset()); + + this._hasCleared = Services.prefs.getBoolPref('zen.keyboard.shortcuts.disable-mainkeyset-clear', false); + + this.init(); + }, + + async init() { + if (this.inBrowserView) { + const loadedShortcuts = await this._loadSaved(); + + this._currentShortcutList = this.versioner.migrateIfNeeded(loadedShortcuts); + this._applyShortcuts(); + + await this._saveShortcuts(); + + console.info('Zen CKS: Initialized'); + } + }, + + get inBrowserView() { + return window.location.href == 'chrome://browser/content/browser.xhtml'; + }, + + async _loadSaved() { + var innerLoad = async () => { + let data = await this.loader.load(); + if (!data || data.length == 0) { + return null; + } + + try { + return KeyShortcut.parseFromSaved(data); + } catch (e) { + console.error('Zen CKS: Error parsing saved shortcuts. Resetting to defaults...', e); + return null; + } + }; + + const loadedShortcuts = await innerLoad(); + this.versioner = new ZenKeyboardShortcutsVersioner(loadedShortcuts); + return loadedShortcuts; + }, + + getZenKeyset(browser = window) { + if (!browser.gZenKeyboardShortcutsManager._zenKeyset) { + const existingKeyset = browser.document.getElementById(ZEN_KEYSET_ID); + if (existingKeyset) { + browser.gZenKeyboardShortcutsManager._zenKeyset = existingKeyset; + return browser.gZenKeyboardShortcutsManager._zenKeyset; + } + + browser.gZenKeyboardShortcutsManager._zenKeyset = browser.document.createXULElement('keyset'); + browser.gZenKeyboardShortcutsManager._zenKeyset.id = ZEN_KEYSET_ID; + + const mainKeyset = browser.document.getElementById(ZEN_MAIN_KEYSET_ID); + mainKeyset.after(browser.gZenKeyboardShortcutsManager._zenKeyset); + } + return browser.gZenKeyboardShortcutsManager._zenKeyset; + }, + + clearMainKeyset(element) { + if (this._hasCleared) { + return; + } + this._hasCleared = true; + const children = element.children; + for (let i = children.length - 1; i >= 0; i--) { + const key = children[i]; + if (key.getAttribute('internal') == 'true') { + continue; + } + key.remove(); + } + + // Restore the keyset, https://searchfox.org/mozilla-central/rev/a59018f9ff34170810b43e12bf6f09a1512de7ab/dom/events/GlobalKeyListener.cpp#478 + const parent = element.parentElement; + element.remove(); + parent.prepend(element); + }, + + _applyShortcuts() { + for (const browser of ZenMultiWindowFeature.browsers) { + let mainKeyset = browser.document.getElementById(ZEN_MAIN_KEYSET_ID); + if (!mainKeyset) { + throw new Error('Main keyset not found'); + } + browser.gZenKeyboardShortcutsManager.clearMainKeyset(mainKeyset); + + const keyset = this.getZenKeyset(browser); + keyset.innerHTML = ''; + + // We dont check this anymore since we are skiping internal keys + //if (mainKeyset.children.length > 0) { + // throw new Error('Child list not empty'); + //} + + for (let key of this._currentShortcutList) { + if (key.isEmpty() || key.isInternal()) { + continue; + } + let child = key.toXHTMLElement(browser); + keyset.appendChild(child); + } + + mainKeyset.after(keyset); + console.debug('Shortcuts applied...'); + } + }, + + async resetAllShortcuts() { + await this.loader.remove(); + Services.prefs.clearUserPref('zen.keyboard.shortcuts.version'); + }, + + async _saveShortcuts() { + let json = []; + for (shortcut of this._currentShortcutList) { + json.push(shortcut.toJSONForm()); + } + + await this.loader.save(this.versioner.getVersionedData(json)); + }, + + triggerShortcutRebuild() { + this._applyShortcuts(); + }, + + async setShortcut(action, shortcut, modifiers) { + if (!action) { + throw new Error('Action cannot be null'); + } + + // Unsetting shortcut + for (let targetShortcut of this._currentShortcutList) { + if (targetShortcut.getID() != action) { + continue; + } + if (!shortcut && !modifiers) { + targetShortcut.clearKeybind(); + } else { + targetShortcut.setNewBinding(shortcut); + targetShortcut.setModifiers(modifiers); + } + } + + await this._saveShortcuts(); + this.triggerShortcutRebuild(); + }, + + async getModifiableShortcuts() { + let rv = []; + + if (!this._currentShortcutList) { + this._currentShortcutList = await this._loadSaved(); + } + + for (let shortcut of this._currentShortcutList) { + if (shortcut.isUserEditable()) { + rv.push(shortcut); + } + } + + return rv; + }, + + checkForConflicts(shortcut, modifiers, id) { + const realShortcut = shortcut.toLowerCase(); + for (let targetShortcut of this._currentShortcutList) { + if (targetShortcut.getID() == id) { + continue; + } + + if (targetShortcut.getModifiers().equals(modifiers) && targetShortcut.getKeyNameOrCode()?.toLowerCase() == realShortcut) { + return true; + } + } + + return false; + }, +}; + +document.addEventListener("MozBeforeInitialXULLayout", () => { + if (Services.prefs.getBoolPref('zen.keyboard.shortcuts.enabled', false)) { + gZenKeyboardShortcutsManager.beforeInit(); + } +}, { once: true }); diff --git a/src/browser/base/zen-components/ZenPinnedTabManager.mjs b/src/browser/base/zen-components/ZenPinnedTabManager.mjs new file mode 100644 index 000000000..c626bfdd1 --- /dev/null +++ b/src/browser/base/zen-components/ZenPinnedTabManager.mjs @@ -0,0 +1,422 @@ +{ + const lazy = {}; + + class ZenPinnedTabsObserver { + static ALL_EVENTS = ['TabPinned', 'TabUnpinned', 'TabClose']; + + #listeners = []; + + constructor() { + XPCOMUtils.defineLazyPreferenceGetter(lazy, 'zenPinnedTabRestorePinnedTabsToPinnedUrl', 'zen.pinned-tab-manager.restore-pinned-tabs-to-pinned-url', false); + XPCOMUtils.defineLazyPreferenceGetter(lazy, 'zenPinnedTabCloseShortcutBehavior', 'zen.pinned-tab-manager.close-shortcut-behavior', 'switch'); + ChromeUtils.defineESModuleGetters(lazy, {E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs"}); + this.#listenPinnedTabEvents(); + } + + #listenPinnedTabEvents() { + const eventListener = this.#eventListener.bind(this); + for (const event of ZenPinnedTabsObserver.ALL_EVENTS) { + window.addEventListener(event, eventListener); + } + window.addEventListener('unload', () => { + for (const event of ZenPinnedTabsObserver.ALL_EVENTS) { + window.removeEventListener(event, eventListener); + } + }); + } + + #eventListener(event) { + for (const listener of this.#listeners) { + listener(event.type, event); + } + } + + addPinnedTabListener(listener) { + this.#listeners.push(listener); + } + } + + class ZenPinnedTabManager extends ZenDOMOperatedFeature { + + init() { + this.observer = new ZenPinnedTabsObserver(); + this._initClosePinnedTabShortcut(); + this._insertItemsIntoTabContextMenu(); + this.observer.addPinnedTabListener(this._onPinnedTabEvent.bind(this)); + + this._zenClickEventListener = this._onTabClick.bind(this); + } + + async initTabs() { + await ZenPinnedTabsStorage.init(); + await this._refreshPinnedTabs(); + } + + async _refreshPinnedTabs() { + await this._initializePinsCache(); + this._initializePinnedTabs(); + } + + async _initializePinsCache() { + try { + // Get pin data + const pins = await ZenPinnedTabsStorage.getPins(); + + // Enhance pins with favicons + const enhancedPins = await Promise.all(pins.map(async pin => { + try { + const faviconData = await PlacesUtils.promiseFaviconData(pin.url); + return { + ...pin, + iconUrl: faviconData?.uri?.spec || null + }; + } catch(ex) { + // If favicon fetch fails, continue without icon + return { + ...pin, + iconUrl: null + }; + } + })); + + this._pinsCache = enhancedPins.sort((a, b) => { + if (!a.workspaceUuid && b.workspaceUuid) return -1; + if (a.workspaceUuid && !b.workspaceUuid) return 1; + return 0; + }); + + } catch (ex) { + console.error("Failed to initialize pins cache:", ex); + this._pinsCache = []; + } + + return this._pinsCache; + } + + _initializePinnedTabs() { + const pins = this._pinsCache; + if (!pins?.length) { + // If there are no pins, we should remove any existing pinned tabs + for (let tab of gBrowser.tabs) { + if (tab.pinned && !tab.getAttribute("zen-pin-id")) { + gBrowser.removeTab(tab); + } + } + return; + } + + const activeTab = gBrowser.selectedTab; + const pinnedTabsByUUID = new Map(); + const pinsToCreate = new Set(pins.map(p => p.uuid)); + + // First pass: identify existing tabs and remove those without pins + for (let tab of gBrowser.tabs) { + const pinId = tab.getAttribute("zen-pin-id"); + if (!pinId) { + continue; + } + + if (pinsToCreate.has(pinId)) { + // This is a valid pinned tab that matches a pin + pinnedTabsByUUID.set(pinId, tab); + pinsToCreate.delete(pinId); + } else { + // This is a pinned tab that no longer has a corresponding pin + gBrowser.removeTab(tab); + } + } + + // Second pass: create new tabs for pins that don't have tabs + for (let pin of pins) { + if (!pinsToCreate.has(pin.uuid)) { + continue; // Skip pins that already have tabs + } + + let newTab = gBrowser.addTrustedTab(pin.url, { + skipAnimation: true, + userContextId: pin.containerTabId || 0, + allowInheritPrincipal: false, + createLazyBrowser: true, + skipLoad: true, + }); + + // Set the favicon from cache + if (!!pin.iconUrl) { + // TODO: Figure out if there is a better way - + // calling gBrowser.setIcon messes shit up and should be avoided. I think this works for now. + newTab.setAttribute("image", pin.iconUrl); + } + + newTab.setAttribute("zen-pin-id", pin.uuid); + gBrowser.setInitialTabTitle(newTab, pin.title); + + if (pin.workspaceUuid) { + newTab.setAttribute("zen-workspace-id", pin.workspaceUuid); + } + + gBrowser.pinTab(newTab); + } + + // Restore active tab + if (!activeTab.closing) { + gBrowser.selectedTab = activeTab; + } + } + + _onPinnedTabEvent(action, event) { + const tab = event.target; + switch (action) { + case "TabPinned": + tab._zenClickEventListener = this._zenClickEventListener; + tab.addEventListener("click", tab._zenClickEventListener); + this._setPinnedAttributes(tab); + break; + case "TabUnpinned": + this._removePinnedAttributes(tab); + if (tab._zenClickEventListener) { + tab.removeEventListener("click", tab._zenClickEventListener); + delete tab._zenClickEventListener; + } + break; + // TODO: Do this in a better way. Closing a second window could trigger remove tab and delete it from db + // case "TabClose": + // this._removePinnedAttributes(tab); + // break; + default: + console.warn('ZenPinnedTabManager: Unhandled tab event', action); + break; + } + } + + _onTabClick(e) { + const tab = e.target; + if (e.button === 1) { + this._onCloseTabShortcut(e, tab); + } + } + + async resetPinnedTab(tab) { + + if (!tab) { + tab = TabContextMenu.contextTab; + } + + if (!tab || !tab.pinned) { + return; + } + + await this._resetTabToStoredState(tab); + } + + async replacePinnedUrlWithCurrent() { + const tab = TabContextMenu.contextTab; + if (!tab || !tab.pinned || !tab.getAttribute("zen-pin-id")) { + return; + } + + const browser = tab.linkedBrowser; + + const pin = this._pinsCache.find(pin => pin.uuid === tab.getAttribute("zen-pin-id")); + + if (!pin) { + return; + } + + pin.title = tab.label || browser.contentTitle; + pin.url = browser.currentURI.spec; + pin.workspaceUuid = tab.getAttribute("zen-workspace-id"); + pin.userContextId = tab.getAttribute("userContextId"); + + await ZenPinnedTabsStorage.savePin(pin); + await this._refreshPinnedTabs(); + } + + async _setPinnedAttributes(tab) { + + if (tab.hasAttribute("zen-pin-id")) { + return; + } + + const browser = tab.linkedBrowser; + + const uuid = gZenUIManager.generateUuidv4(); + + await ZenPinnedTabsStorage.savePin({ + uuid, + title: tab.label || browser.contentTitle, + url: browser.currentURI.spec, + containerTabId: tab.getAttribute("userContextId"), + workspaceUuid: tab.getAttribute("zen-workspace-id") + }); + + tab.setAttribute("zen-pin-id", uuid); + + await this._refreshPinnedTabs(); + } + + async _removePinnedAttributes(tab) { + if(!tab.getAttribute("zen-pin-id")) { + return; + } + + await ZenPinnedTabsStorage.removePin(tab.getAttribute("zen-pin-id")); + + tab.removeAttribute("zen-pin-id"); + + if(!tab.hasAttribute("zen-workspace-id") && ZenWorkspaces.workspaceEnabled) { + const workspace = await ZenWorkspaces.getActiveWorkspace(); + tab.setAttribute("zen-workspace-id", workspace.uuid); + } + + await this._refreshPinnedTabs(); + } + + _initClosePinnedTabShortcut() { + let cmdClose = document.getElementById('cmd_close'); + + if (cmdClose) { + cmdClose.addEventListener('command', this._onCloseTabShortcut.bind(this)); + } + } + + _onCloseTabShortcut(event, selectedTab = gBrowser.selectedTab) { + if ( + !selectedTab?.pinned + ) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + + const behavior = lazy.zenPinnedTabCloseShortcutBehavior; + + switch (behavior) { + case 'close': + gBrowser.removeTab(selectedTab, { animate: true }); + break; + case 'reset-unload-switch': + case 'unload-switch': + case 'reset-switch': + case 'switch': + this._handleTabSwitch(selectedTab); + if (behavior.includes('reset')) { + this._resetTabToStoredState(selectedTab); + } + if (behavior.includes('unload')) { + gBrowser.discardBrowser(selectedTab); + } + break; + case 'reset': + this._resetTabToStoredState(selectedTab); + break; + default: + return; + } + + + } + + _handleTabSwitch(selectedTab) { + if(selectedTab !== gBrowser.selectedTab) { + return; + } + const findNextTab = (direction) => + gBrowser.tabContainer.findNextTab(selectedTab, { + direction, + filter: tab => !tab.hidden && !tab.pinned, + }); + + let nextTab = findNextTab(1) || findNextTab(-1); + + if (!nextTab) { + ZenWorkspaces._createNewTabForWorkspace({ uuid: ZenWorkspaces.activeWorkspace }); + + nextTab = findNextTab(1) || findNextTab(-1); + } + + if (nextTab) { + gBrowser.selectedTab = nextTab; + } + } + + async _resetTabToStoredState(tab) { + const id = tab.getAttribute("zen-pin-id"); + + if (!id) { + return; + } + + const pin = this._pinsCache.find(pin => pin.uuid === id); + + if (pin) { + const tabState = SessionStore.getTabState(tab); + const state = JSON.parse(tabState); + const icon = await PlacesUtils.promiseFaviconData(pin.url); + + state.entries = [{ + url: pin.url, + title: pin.title, + triggeringPrincipal_base64: lazy.E10SUtils.SERIALIZED_SYSTEMPRINCIPAL + }]; + state.image = icon; + state.index = 0; + + SessionStore.setTabState(tab, state); + } + } + + _addGlobalPin() { + const tab = TabContextMenu.contextTab; + if (!tab || tab.pinned) { + return; + } + + tab.removeAttribute("zen-workspace-id"); + + gBrowser.pinTab(tab); + } + + _insertItemsIntoTabContextMenu() { + const elements = window.MozXULElement.parseXULToFragment(` +