diff --git a/locales/en-US/browser/browser/zen-library.ftl b/locales/en-US/browser/browser/zen-library.ftl index d44155d64..76d98c665 100644 --- a/locales/en-US/browser/browser/zen-library.ftl +++ b/locales/en-US/browser/browser/zen-library.ftl @@ -5,6 +5,7 @@ library-spaces-section-title = Spaces library-downloads-section-title = Downloads library-history-section-title = History +library-boosts-section-title = Boosts library-search-placeholder = .placeholder = Search… @@ -19,8 +20,21 @@ library-history-yesterday = Yesterday library-history-empty = No history found library-downloads-empty = No downloads found library-spaces-empty = No spaces available +library-boosts-empty = No boosts yet library-search-no-results = No results +library-boosts-search-placeholder = + .placeholder = Search Boosts… + +library-boost-toggle = + .tooltiptext = Toggle boost for this site +library-boost-context-edit = + .label = Edit Boost +library-boost-context-export = + .label = Export Boost +library-boost-context-delete = + .label = Delete Boost + library-filter-button = Filter library-history-filter-all = @@ -57,12 +71,12 @@ library-item-context-copy-url = .label = Copy URL library-item-context-delete-history = .label = Forget About This Page -library-item-context-show-in-folder = - .label = Show in Folder library-item-context-open-source = .label = Open Source URL library-item-context-remove = .label = Remove from History +library-item-context-delete-file = + .label = Delete File library-history-action-remove = .tooltiptext = Forget About This Page diff --git a/prefs/firefox/library.yaml b/prefs/firefox/library.yaml new file mode 100644 index 000000000..e2daf98e1 --- /dev/null +++ b/prefs/firefox/library.yaml @@ -0,0 +1,6 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +- name: zen.library.enabled + value: false diff --git a/src/browser/base/content/navigator-toolbox-inc-xhtml.patch b/src/browser/base/content/navigator-toolbox-inc-xhtml.patch index 604d54485..63c963ed7 100644 --- a/src/browser/base/content/navigator-toolbox-inc-xhtml.patch +++ b/src/browser/base/content/navigator-toolbox-inc-xhtml.patch @@ -1,5 +1,5 @@ diff --git a/browser/base/content/navigator-toolbox.inc.xhtml b/browser/base/content/navigator-toolbox.inc.xhtml -index edeb473e46b3aa4b12eb4b59ce62e5ae48edd2a1..99210f8bb5633d50d2cba24f1e13ca866c5b6959 100644 +index edeb473e46b3aa4b12eb4b59ce62e5ae48edd2a1..9fae4c0bca0a7ffb5c3b5517b09d0ef73422813a 100644 --- a/browser/base/content/navigator-toolbox.inc.xhtml +++ b/browser/base/content/navigator-toolbox.inc.xhtml @@ -2,7 +2,7 @@ @@ -55,3 +55,11 @@ index edeb473e46b3aa4b12eb4b59ce62e5ae48edd2a1..99210f8bb5633d50d2cba24f1e13ca86 + { // Close the results pane when the input field contextual menu is open, // because paste and go doesn't want a result selection. @@ -258,7 +266,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f let controller = this.document.commandDispatcher.getControllerForCommand("cmd_paste"); -@@ -4541,7 +4649,11 @@ export class UrlbarInput extends HTMLElement { +@@ -4541,7 +4651,11 @@ export class UrlbarInput extends HTMLElement { if (!engineName && !source && !this.hasAttribute("searchmode")) { return; } @@ -271,7 +279,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f if (this._searchModeIndicatorTitle) { this._searchModeIndicatorTitle.textContent = ""; this._searchModeIndicatorTitle.removeAttribute("data-l10n-id"); -@@ -4851,6 +4963,7 @@ export class UrlbarInput extends HTMLElement { +@@ -4851,6 +4965,7 @@ export class UrlbarInput extends HTMLElement { this.document.l10n.setAttributes( this.inputField, @@ -279,7 +287,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f l10nId, l10nId == "urlbar-placeholder-with-name" ? { name: engineName } -@@ -4964,6 +5077,11 @@ export class UrlbarInput extends HTMLElement { +@@ -4964,6 +5079,11 @@ export class UrlbarInput extends HTMLElement { } _on_click(event) { @@ -291,7 +299,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f switch (event.target) { case this.inputField: case this._inputContainer: -@@ -5042,7 +5160,7 @@ export class UrlbarInput extends HTMLElement { +@@ -5042,7 +5162,7 @@ export class UrlbarInput extends HTMLElement { } } @@ -300,7 +308,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f this.view.autoOpen({ event }); } else { if (this._untrimOnFocusAfterKeydown) { -@@ -5082,9 +5200,16 @@ export class UrlbarInput extends HTMLElement { +@@ -5082,9 +5202,16 @@ export class UrlbarInput extends HTMLElement { } _on_mousedown(event) { @@ -318,7 +326,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f if ( event.composedTarget != this.inputField && event.composedTarget != this._inputContainer -@@ -5094,6 +5219,10 @@ export class UrlbarInput extends HTMLElement { +@@ -5094,6 +5221,10 @@ export class UrlbarInput extends HTMLElement { this.focusedViaMousedown = !this.focused; this._preventClickSelectsAll = this.focused; @@ -329,7 +337,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f // Keep the focus status, since the attribute may be changed // upon calling this.focus(). -@@ -5129,7 +5258,7 @@ export class UrlbarInput extends HTMLElement { +@@ -5129,7 +5260,7 @@ export class UrlbarInput extends HTMLElement { } // Don't close the view when clicking on a tab; we may want to keep the // view open on tab switch, and the TabSelect event arrived earlier. @@ -338,7 +346,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f break; } -@@ -5411,7 +5540,7 @@ export class UrlbarInput extends HTMLElement { +@@ -5411,7 +5542,7 @@ export class UrlbarInput extends HTMLElement { // When we are in actions search mode we can show more results so // increase the limit. let maxResults = diff --git a/src/browser/themes/shared/zen-icons/common/library/library-downloads.svg b/src/browser/themes/shared/zen-icons/common/library/library-downloads.svg new file mode 100644 index 000000000..dfe0eab65 --- /dev/null +++ b/src/browser/themes/shared/zen-icons/common/library/library-downloads.svg @@ -0,0 +1,8 @@ +#filter dumbComments emptyLines substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + + + \ No newline at end of file diff --git a/src/browser/themes/shared/zen-icons/common/library/library-history.svg b/src/browser/themes/shared/zen-icons/common/library/library-history.svg new file mode 100644 index 000000000..2f8aa6d78 --- /dev/null +++ b/src/browser/themes/shared/zen-icons/common/library/library-history.svg @@ -0,0 +1,8 @@ +#filter dumbComments emptyLines substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + + + \ No newline at end of file diff --git a/src/browser/themes/shared/zen-icons/common/library/library-spaces.svg b/src/browser/themes/shared/zen-icons/common/library/library-spaces.svg new file mode 100644 index 000000000..c4343ccec --- /dev/null +++ b/src/browser/themes/shared/zen-icons/common/library/library-spaces.svg @@ -0,0 +1,18 @@ +#filter dumbComments emptyLines substitution +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/browser/themes/shared/zen-icons/jar.inc.mn b/src/browser/themes/shared/zen-icons/jar.inc.mn index be1479dcf..431db3c6f 100644 --- a/src/browser/themes/shared/zen-icons/jar.inc.mn +++ b/src/browser/themes/shared/zen-icons/jar.inc.mn @@ -14,12 +14,12 @@ * skin/classic/browser/zen-icons/autoplay-media.svg (../shared/zen-icons/nucleo/autoplay-media.svg) * skin/classic/browser/zen-icons/back.svg (../shared/zen-icons/nucleo/back.svg) * skin/classic/browser/zen-icons/block.svg (../shared/zen-icons/nucleo/block.svg) +* skin/classic/browser/zen-icons/blocked-element.svg (../shared/zen-icons/nucleo/blocked-element.svg) * skin/classic/browser/zen-icons/bolt.svg (../shared/zen-icons/nucleo/bolt.svg) * skin/classic/browser/zen-icons/bookmark-hollow.svg (../shared/zen-icons/nucleo/bookmark-hollow.svg) * skin/classic/browser/zen-icons/bookmark-star-on-tray.svg (../shared/zen-icons/nucleo/bookmark-star-on-tray.svg) * skin/classic/browser/zen-icons/bookmark.svg (../shared/zen-icons/nucleo/bookmark.svg) * skin/classic/browser/zen-icons/boost.svg (../shared/zen-icons/nucleo/boost.svg) -* skin/classic/browser/zen-icons/blocked-element.svg (../shared/zen-icons/nucleo/blocked-element.svg) * skin/classic/browser/zen-icons/brackets-curly.svg (../shared/zen-icons/nucleo/brackets-curly.svg) * skin/classic/browser/zen-icons/camera-blocked.svg (../shared/zen-icons/nucleo/camera-blocked.svg) * skin/classic/browser/zen-icons/camera-fill.svg (../shared/zen-icons/nucleo/camera-fill.svg) @@ -162,12 +162,12 @@ * skin/classic/browser/zen-icons/autoplay-media.svg (../shared/zen-icons/nucleo/autoplay-media.svg) * skin/classic/browser/zen-icons/back.svg (../shared/zen-icons/nucleo/back.svg) * skin/classic/browser/zen-icons/block.svg (../shared/zen-icons/nucleo/block.svg) +* skin/classic/browser/zen-icons/blocked-element.svg (../shared/zen-icons/nucleo/blocked-element.svg) * skin/classic/browser/zen-icons/bolt.svg (../shared/zen-icons/nucleo/bolt.svg) * skin/classic/browser/zen-icons/bookmark-hollow.svg (../shared/zen-icons/nucleo/bookmark-hollow.svg) * skin/classic/browser/zen-icons/bookmark-star-on-tray.svg (../shared/zen-icons/nucleo/bookmark-star-on-tray.svg) * skin/classic/browser/zen-icons/bookmark.svg (../shared/zen-icons/nucleo/bookmark.svg) * skin/classic/browser/zen-icons/boost.svg (../shared/zen-icons/nucleo/boost.svg) -* skin/classic/browser/zen-icons/blocked-element.svg (../shared/zen-icons/nucleo/blocked-element.svg) * skin/classic/browser/zen-icons/brackets-curly.svg (../shared/zen-icons/nucleo/brackets-curly.svg) * skin/classic/browser/zen-icons/camera-blocked.svg (../shared/zen-icons/nucleo/camera-blocked.svg) * skin/classic/browser/zen-icons/camera-fill.svg (../shared/zen-icons/nucleo/camera-fill.svg) @@ -310,12 +310,12 @@ * skin/classic/browser/zen-icons/autoplay-media.svg (../shared/zen-icons/nucleo/autoplay-media.svg) * skin/classic/browser/zen-icons/back.svg (../shared/zen-icons/nucleo/back.svg) * skin/classic/browser/zen-icons/block.svg (../shared/zen-icons/nucleo/block.svg) +* skin/classic/browser/zen-icons/blocked-element.svg (../shared/zen-icons/nucleo/blocked-element.svg) * skin/classic/browser/zen-icons/bolt.svg (../shared/zen-icons/nucleo/bolt.svg) * skin/classic/browser/zen-icons/bookmark-hollow.svg (../shared/zen-icons/nucleo/bookmark-hollow.svg) * skin/classic/browser/zen-icons/bookmark-star-on-tray.svg (../shared/zen-icons/nucleo/bookmark-star-on-tray.svg) * skin/classic/browser/zen-icons/bookmark.svg (../shared/zen-icons/nucleo/bookmark.svg) * skin/classic/browser/zen-icons/boost.svg (../shared/zen-icons/nucleo/boost.svg) -* skin/classic/browser/zen-icons/blocked-element.svg (../shared/zen-icons/nucleo/blocked-element.svg) * skin/classic/browser/zen-icons/brackets-curly.svg (../shared/zen-icons/nucleo/brackets-curly.svg) * skin/classic/browser/zen-icons/camera-blocked.svg (../shared/zen-icons/nucleo/camera-blocked.svg) * skin/classic/browser/zen-icons/camera-fill.svg (../shared/zen-icons/nucleo/camera-fill.svg) @@ -447,6 +447,9 @@ * skin/classic/browser/zen-icons/zoom-out.svg (../shared/zen-icons/nucleo/zoom-out.svg) #endif * skin/classic/browser/zen-icons/urlbar-arrow.svg (../shared/zen-icons/common/urlbar-arrow.svg) +* skin/classic/browser/zen-icons/library/library-downloads.svg (../shared/zen-icons/common/library/library-downloads.svg) +* skin/classic/browser/zen-icons/library/library-history.svg (../shared/zen-icons/common/library/library-history.svg) +* skin/classic/browser/zen-icons/library/library-spaces.svg (../shared/zen-icons/common/library/library-spaces.svg) * skin/classic/browser/zen-icons/selectable/airplane.svg (../shared/zen-icons/common/selectable/airplane.svg) * skin/classic/browser/zen-icons/selectable/american-football.svg (../shared/zen-icons/common/selectable/american-football.svg) * skin/classic/browser/zen-icons/selectable/baseball.svg (../shared/zen-icons/common/selectable/baseball.svg) diff --git a/src/browser/themes/shared/zen-icons/update-resources.sh b/src/browser/themes/shared/zen-icons/update-resources.sh index 33a6ef63d..74813ba68 100755 --- a/src/browser/themes/shared/zen-icons/update-resources.sh +++ b/src/browser/themes/shared/zen-icons/update-resources.sh @@ -53,12 +53,15 @@ do_common_icons() { echo "Working on $filename" echo "* skin/classic/browser/zen-icons/$filename (../shared/zen-icons/common/$filename) " >> jar.inc.mn done - for filename in common/selectable/*.svg; do - # remove the os/ prefix - add_header_to_file $filename - filename=$(basename $filename) - echo "Working on $filename" - echo "* skin/classic/browser/zen-icons/selectable/$filename (../shared/zen-icons/common/selectable/$filename) " >> jar.inc.mn + # go through all subdirectories of common and do the same + for dir in common/*/; do + display_dir=$(basename $dir) + for filename in $dir/*.svg; do + add_header_to_file $filename + filename=$(basename $filename) + echo "Working on $filename" + echo "* skin/classic/browser/zen-icons/$display_dir/$filename (../shared/zen-icons/common/$display_dir/$filename) " >> jar.inc.mn + done done } diff --git a/src/zen/common/ZenPreloadedScripts.js b/src/zen/common/ZenPreloadedScripts.js index 559d4c8e4..87c1ad03e 100644 --- a/src/zen/common/ZenPreloadedScripts.js +++ b/src/zen/common/ZenPreloadedScripts.js @@ -23,6 +23,7 @@ "chrome://browser/content/zen-components/ZenEmojiPicker.mjs", "chrome://browser/content/zen-components/ZenLiveFoldersUI.mjs", "chrome://browser/content/zen-components/ZenDownloadAnimation.mjs", + "moz-src:///zen/library/ZenLibraryButton.mjs", ]; for (let script of scripts) { diff --git a/src/zen/common/styles/zen-browser-ui.css b/src/zen/common/styles/zen-browser-ui.css index f6bec02be..42e2afe15 100644 --- a/src/zen/common/styles/zen-browser-ui.css +++ b/src/zen/common/styles/zen-browser-ui.css @@ -13,10 +13,6 @@ body, /* see issue #426 */ background: var(--zen-navigator-toolbox-background, transparent) !important; --inactive-titlebar-opacity: 0.8; - - &[zen-library-open="true"] { - display: none; - } } #nav-bar, diff --git a/src/zen/common/styles/zen-single-components.css b/src/zen/common/styles/zen-single-components.css index 2544b8b11..8e0859d65 100644 --- a/src/zen/common/styles/zen-single-components.css +++ b/src/zen/common/styles/zen-single-components.css @@ -787,3 +787,162 @@ display: none; } } + +#zen-library-button { + position: relative; +} + +#zen-library-button::after { + content: ""; + position: absolute; + top: 1px; + right: 1px; + width: 12px; + height: 12px; + border-radius: 50%; + background: + conic-gradient( + var(--zen-colors-primary, currentColor) + calc(var(--zen-library-button-progress, 0) * 360deg), + color-mix(in srgb, currentColor 25%, transparent) 0 + ); + mask: radial-gradient(circle, transparent 3.5px, black 4px); + pointer-events: none; + opacity: 0; + transform: scale(0.6); + transition: + opacity 0.18s ease, + transform 0.18s cubic-bezier(0.32, 0.72, 0, 1); +} + +#zen-library-button[downloading]::after { + opacity: 1; + transform: scale(1); +} + +@keyframes zen-library-button-spin { + to { + transform: rotate(360deg); + } +} + +#zen-library-button[downloading-indeterminate]::after { + background: conic-gradient( + var(--zen-colors-primary, currentColor) 0deg 90deg, + color-mix(in srgb, currentColor 20%, transparent) 90deg 360deg + ); + animation: zen-library-button-spin 1.05s linear infinite; +} + +#zen-library-button[downloading]::before { + content: ""; + position: absolute; + top: 5px; + right: 5px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--zen-colors-primary, currentColor); + pointer-events: none; +} + +/* + * Stack of recent download tiles inserted just before + * #zen-sidebar-foot-buttons. Visible only while hovering the library button + * (or the stack itself). Anchored to the bottom of #navigator-toolbox so it + * floats above the tab strip without pushing layout. + */ +#navigator-toolbox { + position: relative; +} + +.zen-library-button-panel { + position: absolute; + left: 0; + right: 0; + bottom: var(--zen-library-stack-bottom-offset, 42px); + z-index: 2; + padding: 6px 8px; + opacity: 0; + transform: translateY(8px); + pointer-events: none; +} + +.zen-library-button-panel[data-state="open"] { + pointer-events: auto; +} + +/* Fade the bottom of the tab strip so it doesn't visually collide with + * the floating download stack. The fade region scales with the actual + * stack height (published from JS as --zen-library-stack-height). */ +#tabbrowser-tabs[zen-library-stack-open="true"] { + mask-image: linear-gradient(to bottom, black var(--zen-library-stack-height), black 50%, transparent calc(100% - var(--zen-library-stack-height) + 20px)); + transition: mask-image 0.18s ease; +} + +.zen-library-button-panel-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.zen-library-button-panel-empty { + padding: 12px 10px; + text-align: center; + font-size: 12px; + opacity: 0.6; +} + +.zen-library-button-panel-row { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; + transition: background-color 0.12s ease; +} + +.zen-library-button-panel-row:hover { + background: color-mix(in srgb, currentColor 10%, transparent); +} + +.zen-library-button-panel-icon { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 6px; +} + +.zen-library-button-panel-labels { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.zen-library-button-panel-name { + margin: 0; + font-size: 12.5px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} + +.zen-library-button-panel-row[data-file-deleted] .zen-library-button-panel-name { + text-decoration: line-through; + opacity: 0.6; +} + +.zen-library-button-panel-status { + margin: 0; + font-size: 10.5px; + opacity: 0.65; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; +} diff --git a/src/zen/common/sys/ZenCustomizableUI.sys.mjs b/src/zen/common/sys/ZenCustomizableUI.sys.mjs index c5a0e99a6..4579fa325 100644 --- a/src/zen/common/sys/ZenCustomizableUI.sys.mjs +++ b/src/zen/common/sys/ZenCustomizableUI.sys.mjs @@ -9,7 +9,7 @@ export const ZenCustomizableUI = new (class { TYPE_TOOLBAR = "toolbar"; defaultSidebarIcons = [ - "zen-library-button", + Services.prefs.getBoolPref("zen.library.enabled") ? "zen-library-button" : "downloads-button", "zen-workspaces-button", "zen-create-new-button", ]; diff --git a/src/zen/downloads/ZenDownloadAnimation.mjs b/src/zen/downloads/ZenDownloadAnimation.mjs index 8ef63f2bf..e6a73c614 100644 --- a/src/zen/downloads/ZenDownloadAnimation.mjs +++ b/src/zen/downloads/ZenDownloadAnimation.mjs @@ -147,10 +147,20 @@ class nsZenDownloadAnimationElement extends HTMLElement { return Services.prefs.getBoolPref("zen.tabs.vertical.right-side"); } + get #downloadButton() { + const ids = ["zen-library-button", "downloads-button"]; + for (const id of ids) { + const button = document.getElementById(id); + if (button && this.#isElementVisible(button)) { + return button; + } + } + return null; + } + #determineEndPosition() { - const downloadsButton = document.getElementById("downloads-button"); - const isDownloadButtonVisible = - downloadsButton && this.#isElementVisible(downloadsButton); + const downloadsButton = this.#downloadButton; + const isDownloadButtonVisible = downloadsButton !== null; let endPosition = { clientX: 0, clientY: 0 }; diff --git a/src/zen/library/ZenLibrary.mjs b/src/zen/library/ZenLibrary.mjs index b71ed7bfb..6397e9178 100644 --- a/src/zen/library/ZenLibrary.mjs +++ b/src/zen/library/ZenLibrary.mjs @@ -8,6 +8,8 @@ import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; let lazy = {}; let gZenLibraryInstance = null; +const PREVIOUS_TAB_PREF = "zen.library.previous-tab"; + ChromeUtils.defineESModuleGetters( lazy, { @@ -30,13 +32,20 @@ ChromeUtils.defineLazyGetter(lazy, "appContentWrapper", function () { * spaces, and other related data in a unified interface. */ export class ZenLibrary extends MozLitElement { + static #ANIMATION_DURATION = 280; + static #ANIMATION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; + static #TOOLBOX_OPEN_TRANSFORM = "scale(0.96)"; + static #TOOLBOX_OPEN_OPACITY = "-0.5"; + #initialized = false; #resizeObserver = null; #sections = []; + #activeAnimations = new Set(); + #animating = false; _deletionIdleCallbackId = null; static properties = { - activeTab: { type: String }, + _activeTab: { type: String }, }; static queries = { @@ -46,7 +55,19 @@ export class ZenLibrary extends MozLitElement { constructor() { super(); - this.activeTab = "history"; + this.activeTab = Services.prefs.getStringPref(PREVIOUS_TAB_PREF, "") || "history"; + } + + set activeTab(value) { + if (this.activeTab === value) { + return; + } + this._activeTab = value; + Services.prefs.setStringPref(PREVIOUS_TAB_PREF, value); + } + + get activeTab() { + return this._activeTab; } connectedCallback() { @@ -57,24 +78,11 @@ export class ZenLibrary extends MozLitElement { window.addEventListener("keydown", this); // Add connected call back and make `appContentWrapper` transform translate the oposite of this element this.#resizeObserver = new ResizeObserver(() => { - requestAnimationFrame(() => { - let isRightSide = gZenVerticalTabsManager._prefsRightSide; - let translateX = - window.windowUtils.getBoundsWithoutFlushing(this)[ - isRightSide ? "left" : "right" - ]; - let contentPosition = window.windowUtils.getBoundsWithoutFlushing( - lazy.appContentWrapper - )[isRightSide ? "right" : "left"]; - let existingTransform = new DOMMatrix( - lazy.appContentWrapper.style.transform - ).m41; - translateX = translateX - contentPosition + existingTransform; - if (isRightSide) { - translateX = -translateX; - } - lazy.appContentWrapper.style.transform = `translateX(${translateX}px)`; - }); + if (gZenWorkspaces._swipeManager._swipeState.librarySwiping) { + return; + } + let translateX = this.#computeWrapperTargetPx(); + lazy.appContentWrapper.style.transform = `translateX(${translateX}px)`; }); this.#resizeObserver.observe(this); for (const Section of Object.values(lazy.ZenLibrarySections)) { @@ -114,6 +122,9 @@ export class ZenLibrary extends MozLitElement { ?active=${this.activeTab === Section.id} @click=${() => (this.activeTab = Section.id)} > + ` @@ -146,6 +157,11 @@ export class ZenLibrary extends MozLitElement { return this.hasAttribute("open"); } + /** True when a live library instance exists and is in the open state. */ + static get isOpen() { + return gZenLibraryInstance?.isOpen ?? false; + } + static getInstance() { if (!gZenLibraryInstance) { gZenLibraryInstance = new ZenLibrary(); @@ -163,26 +179,325 @@ export class ZenLibrary extends MozLitElement { } static toggle() { + if (gZenLibraryInstance?.#animating) { + return; + } window.docShell.treeOwner .QueryInterface(Ci.nsIInterfaceRequestor) .getInterface(Ci.nsIAppWindow) .rollupAllPopups(); let instance = this.getInstance(); instance.toggleAttribute("open"); - if (!instance.isOpen) { - gNavToolbox.removeAttribute("zen-library-open"); - lazy.appContentWrapper.style.transform = ""; - if (!instance._deletionIdleCallbackId) { - instance._deletionIdleCallbackId = requestIdleCallback(() => { - this.clearInstance(); - }); - } - } else { + if (instance.isOpen) { if (instance._deletionIdleCallbackId) { cancelIdleCallback(instance._deletionIdleCallbackId); instance._deletionIdleCallbackId = null; } gNavToolbox.setAttribute("zen-library-open", "true"); + instance.#animateOpen(); + } else { + gNavToolbox.removeAttribute("zen-library-open"); + // #animateClose schedules the instance disposal itself once the slide- + // out finishes, so we don't queue an idle callback eagerly here — that + // would otherwise race the animation and yank the element out of the + // DOM mid-slide. + instance.#animateClose(); + } + } + + #cancelActiveAnimations() { + for (const anim of this.#activeAnimations) { + try { + anim.cancel(); + } catch { + /* already settled */ + } + } + this.#activeAnimations.clear(); + } + + #animate(element, keyframes) { + const anim = element.animate(keyframes, { + duration: ZenLibrary.#ANIMATION_DURATION, + easing: ZenLibrary.#ANIMATION_EASING, + fill: "forwards", + }); + this.#activeAnimations.add(anim); + anim.finished.then( + () => this.#activeAnimations.delete(anim), + () => this.#activeAnimations.delete(anim) + ); + return anim; + } + + /** + * Mirrors the ResizeObserver's math but returns the translateX (px) instead + * of writing it inline. Used when the observer hasn't fired yet (re-opens + * on the same instance) or when we need a target without side effects. + */ + #computeWrapperTargetPx() { + const isRightSide = gZenVerticalTabsManager._prefsRightSide; + let translateX = + window.windowUtils.getBoundsWithoutFlushing(this)[ + isRightSide ? "left" : "right" + ]; + const contentPosition = window.windowUtils.getBoundsWithoutFlushing( + lazy.appContentWrapper + )[isRightSide ? "right" : "left"]; + const existingTransform = new DOMMatrix( + lazy.appContentWrapper.style.transform + ).m41; + translateX = translateX - contentPosition + existingTransform; + return isRightSide ? -translateX : translateX; + } + + async #animateOpen() { + this.#animating = true; + try { + this.#cancelActiveAnimations(); + await new Promise(r => requestAnimationFrame(r)); + if (!this.isOpen || !this.isConnected) { + return; + } + // Re-opens on the same instance won't trigger the observer (library + // size is unchanged), so fall back to computing the target ourselves. + let wrapperTarget = lazy.appContentWrapper.style.transform; + if (!wrapperTarget) { + wrapperTarget = `translateX(${this.#computeWrapperTargetPx()}px)`; + } + lazy.appContentWrapper.style.transform = ""; + + this.#animate(this, [ + { transform: "translateX(-100%)", opacity: 0 }, + { transform: "translateX(0)", opacity: 1 }, + ]); + this.#animate(gNavToolbox, [ + { transform: "scale(1)", opacity: 1 }, + { + transform: ZenLibrary.#TOOLBOX_OPEN_TRANSFORM, + opacity: ZenLibrary.#TOOLBOX_OPEN_OPACITY, + }, + ]); + const wrapperAnim = this.#animate(lazy.appContentWrapper, [ + { transform: "translateX(0)" }, + { transform: wrapperTarget }, + ]); + + try { + await wrapperAnim.finished; + } catch { + return; + } + if (!this.isOpen) { + return; + } + // Persist the final transform inline so the resize observer's + // diff math keeps working after the animation ends. + wrapperAnim.commitStyles(); + wrapperAnim.cancel(); + this.#activeAnimations.delete(wrapperAnim); + } finally { + this.#animating = false; + } + } + + /** + * Prepare the library for swipe-driven state changes. Works whether the + * library is currently open (close swipe) or closed (open swipe). After + * this resolves, callers drive `updateSwipeProgress(0..1)` directly until + * `finishSwipe(targetOpen)` commits or reverts. + */ + static async beginSwipe() { + // Refuse to enter swipe mode while a non-swipe animation is still + // running; let it finish so the start/end states are well-defined. + if (gZenLibraryInstance?.#animating) { + return; + } + const instance = this.getInstance(); + if (instance._deletionIdleCallbackId) { + cancelIdleCallback(instance._deletionIdleCallbackId); + instance._deletionIdleCallbackId = null; + } + instance.#cancelActiveAnimations(); + const wasOpen = instance.hasAttribute("open"); + if (wasOpen) { + // Library is already open; the wrapper's current inline transform IS + // the target — no remeasure needed. + instance._swipeWrapperTargetPx = + new DOMMatrix(lazy.appContentWrapper.style.transform).m41 || + instance.#computeWrapperTargetPx(); + } else { + // Measure the open-state wrapper target without flashing: temporarily + // mark [open] so layout reflects the open position, then revert. + instance.setAttribute("open", "true"); + instance.style.visibility = "hidden"; + await new Promise(r => requestAnimationFrame(r)); + instance._swipeWrapperTargetPx = instance.#computeWrapperTargetPx(); + instance.style.visibility = ""; + instance.removeAttribute("open"); + lazy.appContentWrapper.style.transform = ""; + } + instance._swipeActive = true; + // Initialize visual state to match the current attribute. + ZenLibrary.updateSwipeProgress(wasOpen ? 1 : 0); + } + + /** + * Sets the library, toolbox, and content-wrapper styles to a fraction + * (0..1) of the way to fully open. Must be preceded by `beginSwipe()`. + * + * @param {number} progress + */ + static updateSwipeProgress(progress) { + const instance = gZenLibraryInstance; + if (!instance?._swipeActive) { + return; + } + const p = Math.max(0, Math.min(1, progress)); + instance.style.transform = `translateX(${(-1 + p) * 100}%)`; + instance.style.opacity = String(p); + const targetOpacity = Number(ZenLibrary.#TOOLBOX_OPEN_OPACITY); + gNavToolbox.style.setProperty( + "transform", + `scale(${1 - p * 0.04})`, + "important" + ); + gNavToolbox.style.setProperty( + "opacity", + String(1 - p * (1 - targetOpacity)), + "important" + ); + lazy.appContentWrapper.style.setProperty( + "transform", + `translateX(${p * (instance._swipeWrapperTargetPx ?? 0)}px)`, + "important" + ); + instance._swipeProgress = p; + } + + /** + * Finish a swipe gesture. If `targetOpen` is true, animate the remaining + * distance to fully open; otherwise animate to fully closed and dispose + * the instance like a normal close. + * + * @param {boolean} targetOpen + */ + static async finishSwipe(targetOpen) { + const instance = gZenLibraryInstance; + if (!instance?._swipeActive) { + return; + } + instance._swipeActive = false; + + const libFromTransform = instance.style.transform; + const libFromOpacity = instance.style.opacity; + const tbFromTransform = gNavToolbox.style.transform; + const tbFromOpacity = gNavToolbox.style.opacity; + const wrapperFromX = new DOMMatrix(lazy.appContentWrapper.style.transform) + .m41; + + // Hand off styling to WAAPI by clearing the inline styles we set during + // the swipe; the keyframes restore the from-state on their first frame. + instance.style.transform = ""; + instance.style.opacity = ""; + gNavToolbox.style.transform = ""; + gNavToolbox.style.opacity = ""; + lazy.appContentWrapper.style.transform = ""; + + if (targetOpen) { + instance.setAttribute("open", "true"); + gNavToolbox.setAttribute("zen-library-open", "true"); + instance.#animate(instance, [ + { transform: libFromTransform, opacity: libFromOpacity }, + { transform: "translateX(0)", opacity: 1 }, + ]); + instance.#animate(gNavToolbox, [ + { transform: tbFromTransform, opacity: tbFromOpacity }, + { + transform: ZenLibrary.#TOOLBOX_OPEN_TRANSFORM, + opacity: ZenLibrary.#TOOLBOX_OPEN_OPACITY, + }, + ]); + const wrapperAnim = instance.#animate(lazy.appContentWrapper, [ + { transform: `translateX(${wrapperFromX}px)` }, + { transform: `translateX(${instance._swipeWrapperTargetPx}px)` }, + ]); + try { + await wrapperAnim.finished; + if (instance.isOpen) { + wrapperAnim.commitStyles(); + wrapperAnim.cancel(); + instance.#activeAnimations.delete(wrapperAnim); + } + } catch { + /* cancelled by a follow-up toggle */ + } + } else { + instance.removeAttribute("open"); + gNavToolbox.removeAttribute("zen-library-open"); + instance.#animate(instance, [ + { transform: libFromTransform, opacity: libFromOpacity }, + { transform: "translateX(-100%)", opacity: 0 }, + ]); + instance.#animate(gNavToolbox, [ + { transform: tbFromTransform, opacity: tbFromOpacity }, + { transform: "scale(1)", opacity: 1 }, + ]); + instance.#animate(lazy.appContentWrapper, [ + { transform: `translateX(${wrapperFromX}px)` }, + { transform: "translateX(0)" }, + ]); + if (!instance._deletionIdleCallbackId) { + instance._deletionIdleCallbackId = requestIdleCallback(() => { + ZenLibrary.clearInstance(); + }); + } + } + } + + async #animateClose() { + this.#animating = true; + try { + this.#cancelActiveAnimations(); + const wrapperCurrent = + lazy.appContentWrapper.style.transform || + getComputedStyle(lazy.appContentWrapper).transform || + "translateX(0)"; + lazy.appContentWrapper.style.transform = ""; + + const libAnim = this.#animate(this, [ + { transform: "translateX(0)", opacity: 1 }, + { transform: "translateX(-100%)", opacity: 0 }, + ]); + this.#animate(gNavToolbox, [ + { + transform: ZenLibrary.#TOOLBOX_OPEN_TRANSFORM, + opacity: ZenLibrary.#TOOLBOX_OPEN_OPACITY, + }, + { transform: "scale(1)", opacity: 1 }, + ]); + this.#animate(lazy.appContentWrapper, [ + { transform: wrapperCurrent }, + { transform: "translateX(0)" }, + ]); + + try { + await libAnim.finished; + } catch { + // Cancelled by a follow-up open before the slide finished. + return; + } + // If the user re-opened in the meantime, leave the live instance alone. + if (this.isOpen) { + return; + } + if (!this._deletionIdleCallbackId) { + this._deletionIdleCallbackId = requestIdleCallback(() => { + ZenLibrary.clearInstance(); + }); + } + } finally { + this.#animating = false; } } } diff --git a/src/zen/library/ZenLibraryButton.mjs b/src/zen/library/ZenLibraryButton.mjs new file mode 100644 index 000000000..10293150c --- /dev/null +++ b/src/zen/library/ZenLibraryButton.mjs @@ -0,0 +1,556 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import { nsZenDOMOperatedFeature } from "chrome://browser/content/zen-components/ZenCommonUtils.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + DownloadsCommon: + "moz-src:///browser/components/downloads/DownloadsCommon.sys.mjs", + DownloadsViewUI: + "moz-src:///browser/components/downloads/DownloadsViewUI.sys.mjs", + DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", +}); + +const RECENT_KEEP = 5; +// Mouse can stray this far past the panel/button before we dismiss the stack. +const HOVER_TOLERANCE_TOP_PX = 100; +const HOVER_TOLERANCE_SIDE_PX = 40; + +/** + * Per-window controller for the toolbar Library button: + * - Watches downloads and overlays a progress ring on the button while + * anything is in flight (indeterminate spinner when total bytes are + * unknown). + * - Pops a small panel with the most recent finished/active downloads + * when the user hovers the button. + */ +class nsZenLibraryButton extends nsZenDOMOperatedFeature { + #button = null; + #downloads = null; + #view = null; + #active = new Set(); + #recent = []; + #panel = null; + #closeAnim = null; + #trackingMouse = false; + #contextMenuOpen = false; + + init() { + this.#button = document.getElementById("zen-library-button"); + if (!this.#button) { + return; + } + + this.#setupDownloads(); + + this.#button.addEventListener("mouseenter", this); + window.addEventListener("unload", this, { once: true }); + } + + handleEvent(event) { + switch (event.type) { + case "mouseenter": + this.#onEnter(); + break; + case "mousemove": + this.#checkMousePosition(event); + break; + case "unload": + this.#button?.removeEventListener("mouseenter", this); + this.#detachMouseTracking(); + if (this.#downloads && this.#view) { + this.#downloads.removeView(this.#view); + } + break; + } + } + + #setupDownloads() { + this.#downloads = lazy.DownloadsCommon.getData(window, true); + this.#view = { + onDownloadAdded: dl => { + if (!dl.stopped) { + this.#active.add(dl); + } else if (dl.succeeded) { + this.#pushRecent(dl); + } + this.#updateRing(); + this.#refreshPanel(); + }, + onDownloadChanged: dl => { + if (dl.stopped) { + this.#active.delete(dl); + if (dl.succeeded) { + this.#pushRecent(dl); + } + } else { + this.#active.add(dl); + } + this.#updateRing(); + this.#refreshPanel(); + }, + onDownloadRemoved: dl => { + this.#active.delete(dl); + this.#recent = this.#recent.filter(d => d !== dl); + this.#updateRing(); + this.#refreshPanel(); + }, + }; + this.#downloads.addView(this.#view); + } + + #pushRecent(dl) { + if (!this.#recent.includes(dl)) { + this.#recent.unshift(dl); + this.#recent = this.#recent.slice(0, RECENT_KEEP); + } + } + + /** Update the conic-gradient progress + indeterminate state on the button. */ + #updateRing() { + if (!this.#button) { + return; + } + if (this.#active.size === 0) { + this.#button.removeAttribute("downloading"); + this.#button.removeAttribute("downloading-indeterminate"); + this.#button.style.removeProperty("--zen-library-button-progress"); + return; + } + let total = 0; + let current = 0; + let hasUnknown = false; + for (const dl of this.#active) { + if (dl.hasProgress && dl.totalBytes > 0) { + total += dl.totalBytes; + current += dl.currentBytes; + } else { + hasUnknown = true; + } + } + this.#button.setAttribute("downloading", "true"); + if (hasUnknown || total === 0) { + this.#button.setAttribute("downloading-indeterminate", "true"); + this.#button.style.removeProperty("--zen-library-button-progress"); + } else { + this.#button.removeAttribute("downloading-indeterminate"); + const ratio = Math.max(0, Math.min(1, current / total)); + this.#button.style.setProperty( + "--zen-library-button-progress", + String(ratio) + ); + } + } + + #ensurePanel() { + if (this.#panel) { + return this.#panel; + } + const stack = document.createElement("div"); + stack.id = "zen-library-button-panel"; + stack.classList.add("zen-library-button-panel"); + stack.dataset.state = "closed"; + + const list = document.createElement("div"); + list.className = "zen-library-button-panel-list"; + stack.appendChild(list); + + const host = document.getElementById("zen-sidebar-foot-buttons"); + host.before(stack); + this.#panel = stack; + return stack; + } + + #refreshPanel() { + if (!this.#panel || this.#panel.dataset.state !== "open") { + return; + } + this.#populatePanel(); + } + + #populatePanel() { + const items = [ + ...Array.from(this.#active), + ...this.#recent.filter(d => !this.#active.has(d)), + ].slice(0, RECENT_KEEP); + + if (!items.length) { + return; + } + + const panel = this.#ensurePanel(); + const list = panel.querySelector(".zen-library-button-panel-list"); + list.replaceChildren(); + + for (const dl of items) { + list.appendChild(this.#renderRow(dl)); + } + } + + #renderRow(dl) { + const row = document.createElement("div"); + row.className = "zen-library-button-panel-row"; + if (this.#isFileMissing(dl)) { + row.dataset.fileDeleted = "true"; + } + row.addEventListener("click", () => { + if (dl.succeeded) { + lazy.DownloadsCommon.openDownload(dl).catch(console.error); + } else if (dl.source?.url) { + window.openTrustedLinkIn(dl.source.url, "tab"); + } + }); + row.addEventListener("contextmenu", e => this.#showContextMenu(e, dl)); + + const icon = document.createElement("img"); + icon.className = "zen-library-button-panel-icon"; + icon.alt = ""; + icon.src = dl.target?.path + ? `moz-icon://${dl.target.path}?size=16` + : "moz-icon://.unknown?size=16"; + row.appendChild(icon); + + const labels = document.createElement("div"); + labels.className = "zen-library-button-panel-labels"; + + const display = lazy.DownloadsViewUI.getDisplayName(dl); + const name = typeof display === "string" ? display : dl.source?.url || ""; + const label = document.createXULElement("label"); + label.className = "zen-library-button-panel-name"; + label.textContent = name; + labels.appendChild(label); + + const sublabel = document.createXULElement("label"); + sublabel.className = "zen-library-button-panel-status"; + sublabel.textContent = this.#statusFor(dl); + labels.appendChild(sublabel); + + row.appendChild(labels); + return row; + } + + /** True when the on-disk file is gone (deleted / moved). */ + #isFileMissing(dl) { + if (!dl) { + return false; + } + if (dl.deleted) { + return true; + } + return dl.succeeded && dl.target?.exists === false; + } + + #statusFor(dl) { + const C = lazy.DownloadsCommon; + const state = C.stateOfDownload(dl); + if (state === C.DOWNLOAD_DOWNLOADING) { + const total = dl.hasProgress ? dl.totalBytes : -1; + const [status] = lazy.DownloadUtils.getDownloadStatus( + dl.currentBytes, + total, + dl.speed + ); + return status; + } + if (state === C.DOWNLOAD_FINISHED) { + return ( + lazy.DownloadsViewUI.getSizeWithUnits(dl) || + C.strings.sizeUnknown || + "" + ); + } + if (state === C.DOWNLOAD_PAUSED) { + return C.strings.statePaused || "Paused"; + } + if (state === C.DOWNLOAD_FAILED) { + return C.strings.stateFailed || "Failed"; + } + if (state === C.DOWNLOAD_CANCELED) { + return C.strings.stateCanceled || "Canceled"; + } + return ""; + } + + /** + * Right-click → small XUL menupopup mirroring the relevant subset of + * Firefox's `downloadsContextMenu` (built dynamically so we can react to + * each row's current state). Reuses `browser/downloads.ftl` strings. + */ + #showContextMenu(event, dl) { + event.preventDefault(); + event.stopPropagation(); + + const C = lazy.DownloadsCommon; + const state = C.stateOfDownload(dl); + const isFinished = state === C.DOWNLOAD_FINISHED; + const isActive = + state === C.DOWNLOAD_DOWNLOADING || state === C.DOWNLOAD_PAUSED; + const fileExists = isFinished && dl.target?.exists !== false && !dl.deleted; + const sourceUrl = dl.source?.originalUrl || dl.source?.url; + const items = []; + + if (state === C.DOWNLOAD_DOWNLOADING) { + items.push({ + l10nId: "downloads-cmd-pause", + onClick: () => dl.cancel().catch(() => {}), + }); + } else if (state === C.DOWNLOAD_PAUSED) { + items.push({ + l10nId: "downloads-cmd-resume", + onClick: () => dl.start?.().catch(() => {}), + }); + } + + if (fileExists) { + items.push({ + l10nId: "downloads-cmd-show-menuitem-2", + onClick: () => { + try { + const file = new lazy.FileUtils.File(dl.target.path); + C.showDownloadedFile(file); + } catch (ex) { + console.error(ex); + } + }, + }); + } + + if (sourceUrl) { + items.push({ + l10nId: "downloads-cmd-go-to-download-page", + onClick: () => window.openTrustedLinkIn(sourceUrl, "tab"), + }); + items.push({ + l10nId: "downloads-cmd-copy-download-link", + onClick: () => { + const helper = Cc[ + "@mozilla.org/widget/clipboardhelper;1" + ].getService(Ci.nsIClipboardHelper); + helper.copyString(sourceUrl); + }, + }); + } + + items.push({ separator: true }); + + if (fileExists) { + items.push({ + l10nId: "downloads-cmd-delete-file", + onClick: () => { + C.deleteDownloadFiles( + dl, + lazy.DownloadsViewUI.clearHistoryOnDelete + ).catch(console.error); + }, + }); + } + + if (!isActive) { + items.push({ + l10nId: "downloads-cmd-remove-from-history", + onClick: () => C.deleteDownload(dl).catch(console.error), + }); + } + + if (!items.some(i => !i.separator)) { + return; + } + + const popupSet = document.getElementById("mainPopupSet"); + const popup = document.createXULElement("menupopup"); + for (const item of items) { + if (item.separator) { + if ( + !popup.lastChild || + popup.lastChild.tagName === "menuseparator" + ) { + continue; + } + popup.appendChild(document.createXULElement("menuseparator")); + continue; + } + const mi = document.createXULElement("menuitem"); + mi.setAttribute("data-l10n-id", item.l10nId); + mi.addEventListener( + "command", + () => { + try { + item.onClick?.(); + } catch (ex) { + console.error(ex); + } + }, + { once: true } + ); + popup.appendChild(mi); + } + // Drop a trailing separator if any. + while (popup.lastChild?.tagName === "menuseparator") { + popup.lastChild.remove(); + } + // Pin the hover panel open for the lifetime of the context menu — the + // popup briefly steals focus and would otherwise let the mouse-distance + // check dismiss the stack mid-interaction. + this.#contextMenuOpen = true; + popup.addEventListener( + "popuphidden", + () => { + this.#contextMenuOpen = false; + popup.remove(); + // Re-evaluate distance now that the popup is gone. + if (this.#panel?.dataset.state === "open") { + this.#checkMousePosition(); + } + }, + { once: true } + ); + popupSet.appendChild(popup); + popup.openPopupAtScreen(event.screenX, event.screenY, true); + } + + #onEnter() { + if (this.#active.size === 0 && this.#recent.length === 0) { + return; + } + // Customize mode can move the button out of the sidebar's foot toolbar — + // the inline stack only makes sense when it's still there. + if (!this.#button.closest("#zen-sidebar-foot-buttons")) { + return; + } + // Interrupt a pending close so we reuse the same element instead of + // racing a tear-down. + this.#closeAnim?.cancel(); + this.#closeAnim = null; + + const panel = this.#ensurePanel(); + this.#populatePanel(); + panel.dataset.state = "open"; + this.#updateMaskHeight(panel); + panel.animate( + [ + { opacity: 0, transform: "translateY(8px)" }, + { opacity: 1, transform: "translateY(0)" }, + ], + { + duration: 180, + easing: "cubic-bezier(0.32, 0.72, 0, 1)", + fill: "forwards", + id: "zen-library-stack-open", + } + ); + this.#attachMouseTracking(); + } + + /** + * Publish the panel's rendered height as a CSS variable on the tab strip + * so the strip's mask gradient fades over exactly the area the stack + * occupies (instead of a hardcoded amount). + */ + #updateMaskHeight(panel) { + const tabs = window.gBrowser?.tabContainer; + if (!tabs) { + return; + } + requestAnimationFrame(() => { + if (!panel.isConnected) { + return; + } + const h = Math.ceil(panel.getBoundingClientRect().height); + tabs.style.setProperty("--zen-library-stack-height", `${h}px`); + tabs.setAttribute("zen-library-stack-open", "true"); + }); + } + + #attachMouseTracking() { + if (this.#trackingMouse) { + return; + } + window.addEventListener("mousemove", this, true); + this.#trackingMouse = true; + } + + #detachMouseTracking() { + if (!this.#trackingMouse) { + return; + } + window.removeEventListener("mousemove", this, true); + this.#trackingMouse = false; + } + + /** + * Hide the stack when the cursor moves outside a tolerance zone around the + * panel + button: HOVER_TOLERANCE_TOP_PX above, HOVER_TOLERANCE_SIDE_PX on + * either side (and the same below). Pinned open while a context menu is up. + */ + #checkMousePosition(event) { + if (this.#contextMenuOpen || !this.#panel) { + return; + } + if (this.#panel.dataset.state !== "open") { + this.#detachMouseTracking(); + return; + } + const x = event?.clientX ?? -Infinity; + const y = event?.clientY ?? -Infinity; + const panelRect = this.#panel.getBoundingClientRect(); + const buttonRect = this.#button?.getBoundingClientRect(); + const left = + Math.min(panelRect.left, buttonRect?.left ?? panelRect.left) - + HOVER_TOLERANCE_SIDE_PX; + const right = + Math.max(panelRect.right, buttonRect?.right ?? panelRect.right) + + HOVER_TOLERANCE_SIDE_PX; + const top = panelRect.top - HOVER_TOLERANCE_TOP_PX; + const bottom = + Math.max(panelRect.bottom, buttonRect?.bottom ?? panelRect.bottom) + + HOVER_TOLERANCE_SIDE_PX; + if (x < left || x > right || y < top || y > bottom) { + this.#hide(); + } + } + + #hide() { + if (!this.#panel || this.#closeAnim) { + return; + } + this.#detachMouseTracking(); + const tabs = window.gBrowser?.tabContainer; + tabs?.removeAttribute("zen-library-stack-open"); + const panel = this.#panel; + panel.dataset.state = "closed"; + const anim = panel.animate( + [ + { opacity: 1, transform: "translateY(0)" }, + { opacity: 0, transform: "translateY(8px)" }, + ], + { + duration: 140, + easing: "cubic-bezier(0.32, 0.72, 0, 1)", + fill: "forwards", + id: "zen-library-stack-close", + } + ); + this.#closeAnim = anim; + anim.finished.then( + () => { + if (this.#closeAnim !== anim) { + return; + } + panel.remove(); + if (this.#panel === panel) { + this.#panel = null; + } + this.#closeAnim = null; + tabs?.style.removeProperty("--zen-library-stack-height"); + }, + () => { + /* cancelled — re-entered while closing */ + } + ); + } +} + +new nsZenLibraryButton(); diff --git a/src/zen/library/ZenLibrarySections.mjs b/src/zen/library/ZenLibrarySections.mjs index 289613c84..94d1ca38e 100644 --- a/src/zen/library/ZenLibrarySections.mjs +++ b/src/zen/library/ZenLibrarySections.mjs @@ -17,6 +17,7 @@ ChromeUtils.defineESModuleGetters(lazy, { "moz-src:///browser/components/downloads/DownloadsViewUI.sys.mjs", DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs", FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + gZenBoostsManager: "resource:///modules/zen/boosts/ZenBoostsManager.sys.mjs", PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", }); @@ -82,6 +83,17 @@ class LibrarySection extends MozLitElement { _l10n(key) { return lazy.l10n.formatValueSync(key); } + + /** + * Returns the named attribute (e.g. "label") of a Fluent message, or "". + * + * @param {string} key + * @param {string} attrName + */ + _l10nAttr(key, attrName) { + const [msg] = lazy.l10n.formatMessagesSync([{ id: key }]); + return msg?.attributes?.find(a => a.name === attrName)?.value ?? ""; + } } /** @@ -147,15 +159,6 @@ class SearchSection extends LibrarySection { }, SEARCH_DEBOUNCE_MS); } - _onClearSearch() { - clearTimeout(this.#searchTimer); - this.#searchTimer = null; - this.searchTerm = ""; - this.inputValue = ""; - this.renderRoot.querySelector("#zen-library-search-input")?.focus(); - this._onSearch(""); - } - _onSearch(_term) {} _dayLabel(date) { @@ -195,7 +198,7 @@ class SearchSection extends LibrarySection { /** * Called when the user picks a filter; subclasses re-query or re-filter. * - * @param _id + * @param {string} _id */ _onFilterChange(_id) {} @@ -228,27 +231,39 @@ class SearchSection extends LibrarySection { _activeFilterLabel() { const filters = this._filters(); const active = filters.find(f => f.id === this.activeFilter) ?? filters[0]; - return active ? this._l10n(active.label) : ""; + return active ? this._l10nAttr(active.label, "label") : ""; } /** * Renders one item row. `sideTop`/`sideBottom` are the stacked right-side * info slots (e.g. domain over time for downloads, just time for history). * - * @param root0 - * @param root0.key - * @param root0.iconSrc - * @param root0.label - * @param root0.sublabel - * @param root0.sideTop - * @param root0.sideBottom - * @param root0.payload + * @param {object} root0 + * @param {string} root0.key + * @param {string} root0.iconSrc + * @param {object} root0.iconTemplate + * @param {string} root0.label + * @param {string} root0.sublabel + * @param {string} root0.sideTop + * @param {string} root0.sideBottom + * @param {object} root0.payload */ - _renderItem({ key, iconSrc, label, sublabel, sideTop, sideBottom, payload }) { + _renderItem({ + key, + iconSrc, + iconTemplate, + label, + sublabel, + sideTop, + sideBottom, + payload, + fileMissing = false, + }) { return html`
this._onItemClick(e, payload)} @auxclick=${e => this._onItemClick(e, payload)} @contextmenu=${e => this._onItemContextMenu(e, payload)} @@ -257,12 +272,17 @@ class SearchSection extends LibrarySection {
- + ${ + iconTemplate ?? + html` + + ` + }
@@ -273,19 +293,19 @@ class SearchSection extends LibrarySection { ? html`
${ - sideTop - ? html`` - : "" - } + sideTop + ? html`` + : "" + } ${ - sideBottom - ? html`` - : "" - } + sideBottom + ? html`` + : "" + }
` : "" @@ -319,7 +339,7 @@ class SearchSection extends LibrarySection { * Subclasses override to provide trailing action buttons. Default is a single * "more options" button that opens the context menu. * - * @param payload + * @param {object} payload */ _renderItemActions(payload) { return [ @@ -333,15 +353,15 @@ class SearchSection extends LibrarySection { /** * Subclasses override; default is a no-op. * - * @param _event - * @param _payload + * @param {Event} _event + * @param {object} _payload */ _onItemClick(_event, _payload) {} /** * Subclasses override to build a menu; return null to skip the menu. * - * @param _payload + * @param {object} _payload */ _buildContextMenu(_payload) { return null; @@ -379,10 +399,10 @@ class SearchSection extends LibrarySection { * the requested location, and remove it on hide. We rebuild on every open * because the action set depends on the row's payload. * - * @param items - * @param opener + * @param {Array} items + * @param {object} anchor */ - _openNativeContextMenu(items, opener) { + _openNativeContextMenu(items, anchor) { const popupSet = document.getElementById("mainPopupSet"); const popup = document.createXULElement("menupopup"); @@ -426,17 +446,17 @@ class SearchSection extends LibrarySection { popupSet.appendChild(popup); - if (opener.type === "anchor") { - popup.openPopup(opener.el, "after_end", 0, 0, true, false); + if (anchor.type === "anchor") { + popup.openPopup(anchor.el, "after_end", 0, 0, true, false); } else { - popup.openPopupAtScreen(opener.x, opener.y, true); + popup.openPopupAtScreen(anchor.x, anchor.y, true); } } /** * True when the modifier configured for Glance activation is held. * - * @param event + * @param {Event} event */ _isGlanceActivation(event) { if (!Services.prefs.getBoolPref("zen.glance.enabled", true)) { @@ -457,8 +477,8 @@ class SearchSection extends LibrarySection { /** * Open `url` in a Glance overlay anchored to the clicked item. * - * @param event - * @param url + * @param {Event} event + * @param {string} url */ _openInGlance(event, url) { const itemEl = event.currentTarget; @@ -521,13 +541,6 @@ class SearchSection extends LibrarySection { @input=${this._onSearchInput} .value=${this.inputValue} /> -
${ @@ -647,7 +660,7 @@ class ProgressiveSearchSection extends SearchSection { /** * Hook called from SearchSection's results scroll handler. * - * @param el + * @param {Element} el */ _onScroll(el) { if (this.#renderedItemCount >= this._allItems.length) { @@ -951,8 +964,8 @@ class ZenLibraryHistorySection extends ProgressiveSearchSection { * Ctrl (or any other modifier / middle-click) falls back to the standard * "where to open" routing. * - * @param event - * @param item + * @param {Event} event + * @param {object} item */ _onItemClick(event, item) { if (event.button === 2) { @@ -972,8 +985,9 @@ class ZenLibraryHistorySection extends ProgressiveSearchSection { !isMiddleClick && Services.prefs.getBoolPref("zen.glance.enabled", true) ) { + // Glance overlays on top of the library; keep the library open so the + // user can fire another glance without re-opening it. this._openInGlance(event, item.url); - this._closeLibrary(); return; } @@ -1024,7 +1038,6 @@ class ZenLibraryHistorySection extends ProgressiveSearchSection { // the cursor and the original item element isn't tracked here. const fakeEvent = { currentTarget: this }; this._openInGlance(fakeEvent, item.url); - this._closeLibrary(); }, }, { @@ -1200,15 +1213,81 @@ class ZenLibraryDownloadsSection extends ProgressiveSearchSection { this._formatTime(this.#downloadTime(dl)), ].filter(Boolean); + const state = lazy.DownloadsCommon.stateOfDownload(dl); + const isActive = + state === lazy.DownloadsCommon.DOWNLOAD_DOWNLOADING || + state === lazy.DownloadsCommon.DOWNLOAD_PAUSED || + state === lazy.DownloadsCommon.DOWNLOAD_NOTSTARTED; + const fileMissing = + dl.deleted || + (dl.succeeded && dl.target?.exists === false); + return this._renderItem({ key: `${this.#downloadTime(dl)}|${dl.source.url}`, iconSrc: iconPath, + iconTemplate: isActive ? this.#renderDownloadProgress(dl) : null, label: nameStr, sublabel: sublabelParts.join(" · "), payload: dl, + fileMissing, }); } + /** + * SVG progress ring that replaces the file icon while a download is in + * flight. The ring fills clockwise to match `currentBytes / totalBytes`; + * downloads of unknown size spin instead. Hovering swaps the ring for an + * X — clicking it cancels and drops any partial data. + * + * @param {object} dl + */ + #renderDownloadProgress(dl) { + const RADIUS = 8; + const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + const hasProgress = dl.hasProgress && dl.totalBytes > 0; + const ratio = hasProgress + ? Math.max(0, Math.min(1, dl.currentBytes / dl.totalBytes)) + : 0; + const dashOffset = CIRCUMFERENCE * (1 - ratio); + + return html` + + `; + } + #sourceDomain(dl) { const url = dl.source.originalUrl || dl.source.url || ""; try { @@ -1236,7 +1315,6 @@ class ZenLibraryDownloadsSection extends ProgressiveSearchSection { dl.source.referrerInfo?.originalReferrer?.spec || dl.source.url; if (previewUrl) { this._openInGlance(event, previewUrl); - this._closeLibrary(); } return; } @@ -1258,56 +1336,84 @@ class ZenLibraryDownloadsSection extends ProgressiveSearchSection { } } + /** + * Mirrors Firefox's `downloadsContextMenu` so the labels stay in sync with + * the rest of the browser. Built dynamically because the relevant entries + * change per state (pause/resume, show, delete, etc.). + */ _buildContextMenu(dl) { if (!dl) { return null; } - const state = lazy.DownloadsCommon.stateOfDownload(dl); - const isFinished = state === lazy.DownloadsCommon.DOWNLOAD_FINISHED; + const C = lazy.DownloadsCommon; + const state = C.stateOfDownload(dl); + const isFinished = state === C.DOWNLOAD_FINISHED; + const isActive = + state === C.DOWNLOAD_DOWNLOADING || state === C.DOWNLOAD_PAUSED; const fileExists = isFinished && dl.target.exists !== false && !dl.deleted; const sourceUrl = dl.source.originalUrl || dl.source.url; const items = []; + + if (state === C.DOWNLOAD_DOWNLOADING) { + items.push({ + l10nId: "downloads-cmd-pause", + onClick: () => dl.cancel().catch(() => {}), + }); + } else if (state === C.DOWNLOAD_PAUSED) { + items.push({ + l10nId: "downloads-cmd-resume", + onClick: () => dl.start?.().catch(() => {}), + }); + } + if (fileExists) { items.push({ - l10nId: "library-item-context-open", - onClick: () => { - lazy.DownloadsCommon.openDownload(dl).catch(console.error); - }, - }); - items.push({ - l10nId: "library-item-context-show-in-folder", + l10nId: "downloads-cmd-show-menuitem-2", onClick: () => { try { const file = new lazy.FileUtils.File(dl.target.path); - lazy.DownloadsCommon.showDownloadedFile(file); + C.showDownloadedFile(file); } catch (ex) { console.error(ex); } }, }); - items.push({ separator: true }); } + if (sourceUrl) { items.push({ - l10nId: "library-item-context-open-source", + l10nId: "downloads-cmd-go-to-download-page", onClick: () => { window.openTrustedLinkIn(sourceUrl, "tab"); this._closeLibrary(); }, }); items.push({ - l10nId: "library-item-context-copy-url", + l10nId: "downloads-cmd-copy-download-link", onClick: () => lazy.clipboardHelper.copyString(sourceUrl), }); - items.push({ separator: true }); } - items.push({ - l10nId: "library-item-context-remove", - onClick: () => { - lazy.DownloadsCommon.deleteDownload(dl).catch(console.error); - }, - }); + + items.push({ separator: true }); + + if (fileExists) { + items.push({ + l10nId: "downloads-cmd-delete-file", + onClick: () => { + C.deleteDownloadFiles( + dl, + lazy.DownloadsViewUI.clearHistoryOnDelete + ).catch(console.error); + }, + }); + } + if (!isActive) { + items.push({ + l10nId: "downloads-cmd-remove-from-history", + onClick: () => C.deleteDownload(dl).catch(console.error), + }); + } return items; } @@ -1454,6 +1560,216 @@ class ZenLibraryDownloadsSection extends ProgressiveSearchSection { } } +/** + * Boosts section: lists every saved boost across all domains. Each row shows + * the boost's name + domain, a hover-revealed export button, and a toggle + * pill that reflects (and flips) the domain's active-boost state. + */ +class ZenLibraryBoostsSection extends SearchSection { + static id = "boosts"; + static label = "library-boosts-section-title"; + + #observer = null; + + get _searchPlaceholder() { + return "library-boosts-search-placeholder"; + } + + connectedCallback() { + super.connectedCallback(); + this.#observer = () => this.requestUpdate(); + Services.obs.addObserver(this.#observer, "zen-boosts-update"); + Services.obs.addObserver(this.#observer, "zen-boosts-active-change"); + } + + disconnectedCallback() { + if (this.#observer) { + Services.obs.removeObserver(this.#observer, "zen-boosts-update"); + Services.obs.removeObserver(this.#observer, "zen-boosts-active-change"); + this.#observer = null; + } + super.disconnectedCallback(); + } + + _onSearch(_term) { + this.requestUpdate(); + } + + /** Flat list of every boost across all domains, filtered by search term. */ + #getBoosts() { + const manager = lazy.gZenBoostsManager; + if (!manager?.registeredDomains) { + return []; + } + const term = this.searchTerm?.trim().toLowerCase(); + const items = []; + for (const [domain, entry] of manager.registeredDomains) { + for (const [id, boostEntry] of entry.boostEntries) { + const displayName = boostEntry.boostData?.boostName || ""; + if ( + term && + !displayName.toLowerCase().includes(term) && + !domain.toLowerCase().includes(term) + ) { + continue; + } + items.push({ + id, + domain, + name: displayName, + isActive: entry.activeBoostId === id, + }); + } + } + items.sort((a, b) => + (a.name || a.domain).localeCompare(b.name || b.domain) + ); + return items; + } + + renderSearchResults() { + const boosts = this.#getBoosts(); + if (boosts.length === 0) { + return html` +
+ `; + } + return html`${boosts.map(b => this.#renderBoost(b))}`; + } + + #renderBoost(boost) { + return html` +
this.#openBoost(e, boost)} + @contextmenu=${e => this._onItemContextMenu(e, boost)} + > +
+
+
+
+ +
+
+ + +
+ +
+
+
+ `; + } + + /** + * Open the boost's domain in a Glance overlay and pop the boost editor + * window next to it so the user can tweak the boost while previewing. + * + * @param {Event} event + * @param {object} boost + */ + #openBoost(event, boost) { + const url = `https://${boost.domain}/`; + this._openInGlance(event, url); + try { + const stored = lazy.gZenBoostsManager.loadBoostFromStore( + boost.domain, + boost.id + ); + if (stored) { + const uri = Services.io.newURI(url); + lazy.gZenBoostsManager.openBoostWindow(window, stored, uri); + } + } catch (ex) { + console.error(ex); + } + } + + #toggle(boost) { + lazy.gZenBoostsManager.toggleBoostActiveForDomain(boost.domain, boost.id); + } + + #export(boost) { + const manager = lazy.gZenBoostsManager; + const stored = manager.loadBoostFromStore(boost.domain, boost.id); + if (!stored) { + return; + } + manager.exportBoost(window, { + domain: boost.domain, + id: boost.id, + boostEntry: stored.boostEntry, + }); + } + + _buildContextMenu(boost) { + if (!boost) { + return null; + } + return [ + { + l10nId: "library-boost-context-edit", + onClick: () => { + const stored = lazy.gZenBoostsManager.loadBoostFromStore( + boost.domain, + boost.id + ); + if (!stored) { + return; + } + try { + const uri = Services.io.newURI(`https://${boost.domain}/`); + lazy.gZenBoostsManager.openBoostWindow(window, stored, uri); + } catch (ex) { + console.error(ex); + } + }, + }, + { + l10nId: "library-boost-context-export", + onClick: () => this.#export(boost), + }, + { separator: true }, + { + l10nId: "library-boost-context-delete", + onClick: () => { + lazy.gZenBoostsManager.deleteBoost({ + domain: boost.domain, + id: boost.id, + }); + }, + }, + ]; + } +} + /** Spaces section: Borgir man will do it :) */ class ZenLibrarySpacesSection extends LibrarySection { static largeContent = true; @@ -1464,6 +1780,7 @@ class ZenLibrarySpacesSection extends LibrarySection { export const ZenLibrarySections = { history: ZenLibraryHistorySection, downloads: ZenLibraryDownloadsSection, + boosts: ZenLibraryBoostsSection, spaces: ZenLibrarySpacesSection, }; diff --git a/src/zen/library/moz.build b/src/zen/library/moz.build index 006a06fd1..baceae62d 100644 --- a/src/zen/library/moz.build +++ b/src/zen/library/moz.build @@ -4,5 +4,6 @@ MOZ_SRC_FILES += [ "ZenLibrary.mjs", + "ZenLibraryButton.mjs", "ZenLibrarySections.mjs", ] diff --git a/src/zen/library/zen-library.css b/src/zen/library/zen-library.css index 2cf4c373c..03e0ca99b 100644 --- a/src/zen/library/zen-library.css +++ b/src/zen/library/zen-library.css @@ -20,13 +20,16 @@ display: flex; position: absolute; height: 100%; - z-index: 1; transform: translateX(-100%); - transition: transform 0.15s ease-in-out; + opacity: 0; + will-change: transform, opacity; + z-index: 2; } :host(zen-library[open]) { transform: translateX(0); + opacity: 1; + z-index: 9; } /* Layout Components */ @@ -87,6 +90,8 @@ padding: 12px 0; background: transparent; font-weight: 600; + flex-direction: column; + gap: 4px; &::before { content: ""; @@ -103,6 +108,19 @@ &[active]::before { background: color-mix(in srgb, currentColor 10%, transparent); } + + & img { + width: 28px; + height: 28px; + fill: rgba(255, 255, 255, 0.8); + stroke: var(--zen-colors-primary); + fill-opacity: 0; + -moz-context-properties: fill, fill-opacity, stroke; + } + + &[active] img { + fill-opacity: 1; + } } /* Search Bar */ @@ -188,37 +206,6 @@ &:focus-within .search-input { color: var(--toolbar-field-focus-color, light-dark(black, rgb(251,251,254))); } - - & .search-clear-button { - display: flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - margin: 0; - margin-inline-start: 6px; - padding: 12px; - appearance: none; - background: transparent; - border: none; - border-radius: calc(var(--border-radius-medium) - 2px); - color: inherit; - opacity: 0; - cursor: pointer; - pointer-events: none; - flex-shrink: 0; - fill: currentColor; - -moz-context-properties: fill, fill-opacity; - - & img { - width: 14px; - height: 14px; - } - - &:hover { - background: var(--urlbar-box-hover-bgcolor, light-dark(rgba(0,0,0,0.08), rgba(255,255,255,0.1))); - } - } } .search-filter-button { @@ -235,7 +222,6 @@ color: inherit; font-weight: 500; line-height: var(--tab-label-line-height); - cursor: pointer; fill: currentColor; fill-opacity: 0.5; -moz-context-properties: fill, fill-opacity; @@ -311,7 +297,6 @@ border-radius: 0; color: inherit; color-scheme: unset; - cursor: pointer; overflow: clip; } @@ -367,6 +352,85 @@ -moz-context-properties: fill, stroke; } +.library-download-progress { + display: flex; + position: relative; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-inline-start: calc(var(--toolbarbutton-inner-padding) / 2); + margin-inline-end: calc(var(--toolbarbutton-inner-padding) * 1.5); + padding: 0; + appearance: none; + background: transparent; + border: none; + border-radius: 50%; + color: currentColor; + + .library-item-stack:hover & { + color: var(--zen-colors-primary); + } + + & > svg { + position: absolute; + inset: 0; + pointer-events: none; + transition: opacity 0.12s ease; + } +} + +.library-download-progress-ring .track { + fill: none; + stroke: color-mix(in srgb, currentColor 22%, transparent); + stroke-width: 1.8; +} + +.library-download-progress-ring .arc { + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + transform: rotate(-90deg); + transform-origin: 50% 50%; + transition: stroke-dashoffset 0.25s linear; +} + +.library-download-progress[data-progress="indeterminate"] .arc { + stroke-dasharray: 14 50 !important; + animation: zen-library-download-spin 1.2s linear infinite; +} + +@keyframes zen-library-download-spin { + from { + transform: rotate(-90deg); + } + to { + transform: rotate(270deg); + } +} + +.library-download-progress-cancel { + opacity: 0; +} + +.library-download-progress-cancel line { + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; +} + +.library-item-stack:hover .library-download-progress-ring .arc, +.library-item-stack:hover .library-download-progress-ring .track { + opacity: 0; +} + +.library-item-stack:hover .library-download-progress-cancel { + opacity: 1; +} + .library-item-label-container { display: flex; flex: 1; @@ -385,6 +449,11 @@ line-height: 1; } +.library-item[file-missing] .library-item-label { + text-decoration: line-through; + opacity: 0.6; +} + /* finished (i hope so) */ .library-item-sublabel { margin: 0; @@ -445,7 +514,6 @@ border: none; border-radius: calc(var(--border-radius-medium) - 6px); color: inherit; - cursor: pointer; fill: currentColor; -moz-context-properties: fill, fill-opacity; @@ -474,3 +542,99 @@ font-weight: 600; opacity: 0.5; } + +/* Boosts section */ + +.library-boost-item .library-item-content { + gap: 6px; +} + +.library-boost-icon { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + margin-inline-end: 8px; + background: rgba(255, 255, 255, 0.8); + box-shadow: 0 0 1px 1px rgba(0, 0, 0, 0.15); + border-radius: 12px; + position: relative; + overflow: hidden; +} + +.library-boost-icon-image { + width: 24px; + height: 24px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.05); + padding: 7.5px; +} + +/* Slashed-out look when inactive — mirrors the screenshot's first row. */ +.library-boost-icon[inactive] { + opacity: 0.5; +} + +.library-boost-icon[inactive]::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom right, + transparent calc(50% - 1px), + currentColor calc(50% - 1px), + currentColor calc(50% + 1px), + transparent calc(50% + 1px) + ); + pointer-events: none; +} + +.library-boost-toggle { + display: inline-flex; + position: relative; + flex-shrink: 0; + align-items: center; + width: 36px; + height: 22px; + padding: 2px; + appearance: none; + background: color-mix(in srgb, currentColor 18%, transparent); + border: none; + border-radius: 999px; + opacity: 0; + transition: + background-color 0.18s ease, + opacity 0.12s ease; +} + +/* Stays visible while it represents an "on" state so the user still sees + * which boosts are enabled at a glance; otherwise reveal on row hover. */ +.library-boost-item:hover .library-boost-toggle, +.library-boost-toggle:focus-visible { + opacity: 1; +} + +.library-boost-item .library-item-content { + padding-left: 6px; +} + +.library-boost-toggle[checked] { + background: var(--zen-colors-primary); +} + +.library-boost-toggle-thumb { + display: block; + width: 18px; + height: 18px; + background: light-dark(white, rgba(255, 255, 255, 0.95)); + border-radius: 50%; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); + transition: transform 0.18s cubic-bezier(0.32, 0.72, 0, 1); + pointer-events: none; +} + +.library-boost-toggle[checked] .library-boost-toggle-thumb { + transform: translateX(14px); +} diff --git a/src/zen/spaces/ZenSpacesSwipe.mjs b/src/zen/spaces/ZenSpacesSwipe.mjs index eea00519d..1ff4e4bfd 100644 --- a/src/zen/spaces/ZenSpacesSwipe.mjs +++ b/src/zen/spaces/ZenSpacesSwipe.mjs @@ -4,6 +4,14 @@ const lazy = {}; +ChromeUtils.defineESModuleGetters( + lazy, + { + ZenLibrary: "moz-src:///zen/library/ZenLibrary.mjs", + }, + { global: "current" } +); + ChromeUtils.defineLazyGetter(lazy, "browserBackgroundElement", () => { return document.getElementById("zen-browser-background"); }); @@ -12,6 +20,11 @@ ChromeUtils.defineLazyGetter(lazy, "toolbarBackgroundElement", () => { return document.getElementById("zen-toolbar-background"); }); +// Distance (in swipe-translate units, after the configured multiplier) that +// corresponds to a fully open library. Crossing this on swipe-end commits. +const LIBRARY_SWIPE_FULL_DISTANCE_FACTOR = 0.6; +const LIBRARY_SWIPE_COMMIT_THRESHOLD = 0.3; + export class ZenSpacesSwipe { _swipeState = { isGestureActive: false, @@ -115,14 +128,25 @@ export class ZenSpacesSwipe { isGestureActive: true, lastDelta: 0, direction: null, + librarySwiping: false, + libraryStartProgress: 0, + libraryProgress: 0, }; Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true); } - _handleSwipeUpdate(event) { - const ws = gZenWorkspaces; + /** True when the active workspace is the first one in the cached list. */ + #isAtFirstWorkspace() { + const workspaces = gZenWorkspaces.getWorkspaces(); + const active = gZenWorkspaces.getActiveWorkspaceFromCache(); + return workspaces.indexOf(active) === 0; + } - if (!ws.workspaceEnabled || !this._swipeState?.isGestureActive) { + _handleSwipeUpdate(event) { + if ( + !gZenWorkspaces.workspaceEnabled || + !this._swipeState?.isGestureActive + ) { return; } @@ -150,13 +174,45 @@ export class ZenSpacesSwipe { } if (Math.abs(delta) > 0.9) { - delete ws._hasAnimatedBackgrounds; + delete gZenWorkspaces._hasAnimatedBackgrounds; this._swipeState.direction = delta > 0 ? "left" : "right"; } + // The library can hijack the swipe in two cases: + // - Already open → swipe leftwards (translateX < 0) closes it. + // - Closed and on the first workspace → swipe rightwards (translateX > 0) + // opens it from the left edge. + const libraryOpen = lazy.ZenLibrary.isOpen; + const wantsClose = libraryOpen && translateX < 0; + const wantsOpen = + !libraryOpen && translateX > 0 && this.#isAtFirstWorkspace(); + if (wantsOpen || wantsClose || this._swipeState.librarySwiping) { + if (!this._swipeState.librarySwiping) { + this._swipeState.librarySwiping = true; + this._swipeState.libraryStartProgress = libraryOpen ? 1 : 0; + // Fire-and-forget; the first update before measurement completes + // re-applies the current start progress, which is already visible. + lazy.ZenLibrary.beginSwipe(); + } + const deltaProgress = + translateX / (stripWidth * LIBRARY_SWIPE_FULL_DISTANCE_FACTOR); + const progress = Math.max( + 0, + Math.min(1, this._swipeState.libraryStartProgress + deltaProgress) + ); + this._swipeState.libraryProgress = progress; + lazy.ZenLibrary.updateSwipeProgress(progress); + // Skip the workspace-strip translate while the library owns the swipe. + return; + } + // Apply a translateX to the tab strip to give the user feedback on the swipe - const currentWorkspace = ws.getActiveWorkspaceFromCache(); - ws._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); + const currentWorkspace = gZenWorkspaces.getActiveWorkspaceFromCache(); + gZenWorkspaces._organizeWorkspaceStripLocations( + currentWorkspace, + true, + translateX + ); } async _handleSwipeEnd(event) { @@ -167,6 +223,17 @@ export class ZenSpacesSwipe { } event.preventDefault(); event.stopPropagation(); + + // If the swipe was driving the library, commit to whichever side of the + // threshold the progress landed on and don't change workspace. + if (this._swipeState.librarySwiping) { + const targetOpen = + this._swipeState.libraryProgress >= LIBRARY_SWIPE_COMMIT_THRESHOLD; + delete this._swipeState.librarySwiping; + await lazy.ZenLibrary.finishSwipe(targetOpen); + return; + } + const isRTL = document.documentElement.matches(":-moz-locale-dir(rtl)"); const moveForward = (event.direction === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL; @@ -179,6 +246,10 @@ export class ZenSpacesSwipe { onSwipeGestureAnimationEnd() { const ws = gZenWorkspaces; + if (this._swipeState.librarySwiping) { + lazy.ZenLibrary.finishSwipe(false); + } + // Reset swipe state this._swipeState = { isGestureActive: false,