no-bug: Continue library work

This commit is contained in:
mr. m
2026-05-18 16:46:43 +02:00
parent d5b88ead22
commit 36df7e718f
20 changed files with 1862 additions and 196 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
<toolbar id="nav-bar"
class="browser-toolbar chromeclass-location"
@@ -184,7 +192,6 @@
<toolbartabstop/>
<html:moz-urlbar id="urlbar"
class="urlbar"
- popover="manual"
focused="true"
pageproxystate="invalid"
unifiedsearchbutton-available=""

View File

@@ -1,5 +1,5 @@
diff --git a/browser/components/urlbar/content/UrlbarInput.mjs b/browser/components/urlbar/content/UrlbarInput.mjs
index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f5c811a82 100644
index b23244f9d3278918b016bb3fcab19687bc2e292a..118c7fbc7147c84286692fd7188c870e89be05cb 100644
--- a/browser/components/urlbar/content/UrlbarInput.mjs
+++ b/browser/components/urlbar/content/UrlbarInput.mjs
@@ -90,6 +90,13 @@ const lazy = XPCOMUtils.declareLazy({
@@ -118,7 +118,13 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
startLayoutExtend() {
if (!this.#allowBreakout || this.hasAttribute("breakout-extend")) {
// Do not expand if the Urlbar does not support being expanded or it is
@@ -2710,6 +2778,13 @@ export class UrlbarInput extends HTMLElement {
@@ -2705,11 +2773,19 @@ export class UrlbarInput extends HTMLElement {
if (!this.view.isOpen) {
return;
}
+ this.setAttribute("popover", "manual");
this.#updateTextboxPosition();
this.setAttribute("breakout-extend", "true");
@@ -132,7 +138,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
// Enable the animation only after the first extend call to ensure it
// doesn't run when opening a new window.
if (!this.hasAttribute("breakout-extend-animate")) {
@@ -2729,6 +2804,27 @@ export class UrlbarInput extends HTMLElement {
@@ -2729,7 +2805,29 @@ export class UrlbarInput extends HTMLElement {
return;
}
@@ -158,9 +164,11 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
+ this.removeAttribute("zen-floating-urlbar");
+
this.removeAttribute("breakout-extend");
+ this.removeAttribute("popover");
this.#updateTextboxPosition();
}
@@ -2759,7 +2855,7 @@ export class UrlbarInput extends HTMLElement {
@@ -2759,7 +2857,7 @@ export class UrlbarInput extends HTMLElement {
forceUnifiedSearchButtonAvailable = false
) {
let prevState = this.getAttribute("pageproxystate");
@@ -169,7 +177,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
this.setAttribute("pageproxystate", state);
this._inputContainer.setAttribute("pageproxystate", state);
this._identityBox?.setAttribute("pageproxystate", state);
@@ -3031,10 +3127,12 @@ export class UrlbarInput extends HTMLElement {
@@ -3031,10 +3129,12 @@ export class UrlbarInput extends HTMLElement {
return;
}
this.style.top = px(
@@ -182,7 +190,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
);
}
@@ -3093,9 +3191,10 @@ export class UrlbarInput extends HTMLElement {
@@ -3093,9 +3193,10 @@ export class UrlbarInput extends HTMLElement {
return;
}
@@ -194,7 +202,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
);
this.style.setProperty(
"--urlbar-height",
@@ -3597,6 +3696,7 @@ export class UrlbarInput extends HTMLElement {
@@ -3597,6 +3698,7 @@ export class UrlbarInput extends HTMLElement {
}
_toggleActionOverride(event) {
@@ -202,7 +210,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
if (
event.keyCode == KeyEvent.DOM_VK_SHIFT ||
event.keyCode == KeyEvent.DOM_VK_ALT ||
@@ -3709,8 +3809,8 @@ export class UrlbarInput extends HTMLElement {
@@ -3709,8 +3811,8 @@ export class UrlbarInput extends HTMLElement {
if (!this.#isAddressbar) {
return val;
}
@@ -213,7 +221,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
: val;
// Only trim value if the directionality doesn't change to RTL and we're not
// showing a strikeout https protocol.
@@ -4006,6 +4106,7 @@ export class UrlbarInput extends HTMLElement {
@@ -4006,6 +4108,7 @@ export class UrlbarInput extends HTMLElement {
resultDetails = null,
browser = this.window.gBrowser.selectedBrowser
) {
@@ -221,7 +229,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
if (this.#isAddressbar) {
this.#prepareAddressbarLoad(
url,
@@ -4117,6 +4218,10 @@ export class UrlbarInput extends HTMLElement {
@@ -4117,6 +4220,10 @@ export class UrlbarInput extends HTMLElement {
}
reuseEmpty = true;
}
@@ -232,7 +240,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
if (
where == "tab" &&
reuseEmpty &&
@@ -4124,6 +4229,9 @@ export class UrlbarInput extends HTMLElement {
@@ -4124,6 +4231,9 @@ export class UrlbarInput extends HTMLElement {
) {
where = "current";
}
@@ -242,7 +250,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
return where;
}
@@ -4378,6 +4486,7 @@ export class UrlbarInput extends HTMLElement {
@@ -4378,6 +4488,7 @@ export class UrlbarInput extends HTMLElement {
this.setResultForCurrentValue(null);
this.handleCommand();
this.controller.clearLastQueryContextCache();
@@ -250,7 +258,7 @@ index b23244f9d3278918b016bb3fcab19687bc2e292a..ade1f031bbb68202a37e6c9d3071a73f
this._suppressStartQuery = false;
});
@@ -4385,7 +4494,6 @@ export class UrlbarInput extends HTMLElement {
@@ -4385,7 +4496,6 @@ export class UrlbarInput extends HTMLElement {
contextMenu.addEventListener("popupshowing", () => {
// 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 =

View File

@@ -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/.
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9" stroke="context-stroke" stroke-width="2" fill="context-fill" fill-opacity="context-fill-opacity"/>
<path d="M12 8V16M9 13L12 16L15 13" stroke="context-stroke" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

View File

@@ -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/.
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="9" stroke="context-stroke" stroke-width="2" fill="context-fill" fill-opacity="context-fill-opacity"/>
<path d="M12 7V12 L 15.5 14" stroke="context-stroke" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

View File

@@ -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/.
<svg class="icon spaces-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<mask id="spaces-mask">
<rect x="0" y="0" width="24" height="24" fill="white"/>
<rect x="9" y="6" width="10" height="14" rx="2" fill="black"/>
</mask>
</defs>
<g class="back" transform="rotate(-15 12 12)" mask="url(#spaces-mask)">
<rect class="back-rect" x="6" y="4" width="10" height="14" rx="2" stroke="context-stroke" stroke-width="2" fill="context-fill" fill-opacity="context-fill-opacity"/>
</g>
<g class="front">
<rect class="front-rect" x="9" y="6" width="10" height="14" rx="2" stroke="context-stroke" stroke-width="2" fill="context-fill" fill-opacity="context-fill-opacity"/>
</g>
</svg>

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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",
];

View File

@@ -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 };

View File

@@ -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)}
>
<img
src=${`chrome://browser/skin/zen-icons/library/library-${Section.id}.svg`}
/>
<label>${lazy.l10n.formatValueSync(Section.label)}</label>
</vbox>
`
@@ -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;
}
}
}

View File

@@ -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();

View File

@@ -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`
<div
class="library-item"
data-key=${key ?? ""}
?file-missing=${fileMissing}
@click=${e => this._onItemClick(e, payload)}
@auxclick=${e => this._onItemClick(e, payload)}
@contextmenu=${e => this._onItemContextMenu(e, payload)}
@@ -257,12 +272,17 @@ class SearchSection extends LibrarySection {
<div class="library-item-background"></div>
<div class="library-item-content">
<div class="library-item-icon-stack">
<img
class="library-item-icon-image"
src=${iconSrc || ""}
alt=""
role="presentation"
/>
${
iconTemplate ??
html`
<img
class="library-item-icon-image"
src=${iconSrc || ""}
alt=""
role="presentation"
/>
`
}
</div>
<div class="library-item-label-container">
<label class="library-item-label">${label}</label>
@@ -273,19 +293,19 @@ class SearchSection extends LibrarySection {
? html`
<div class="library-item-side">
${
sideTop
? html`<label class="library-item-side-top"
>${sideTop}</label
>`
: ""
}
sideTop
? html`<label class="library-item-side-top"
>${sideTop}</label
>`
: ""
}
${
sideBottom
? html`<label class="library-item-side-bottom"
>${sideBottom}</label
>`
: ""
}
sideBottom
? html`<label class="library-item-side-bottom"
>${sideBottom}</label
>`
: ""
}
</div>
`
: ""
@@ -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}
/>
<button
class="search-clear-button"
tabindex="-1"
@click=${this._onClearSearch}
>
<img src="chrome://browser/skin/zen-icons/close.svg" alt="" />
</button>
</div>
</div>
${
@@ -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`
<button
class="library-download-progress"
data-progress=${hasProgress ? "determinate" : "indeterminate"}
title="Cancel download"
@click=${e => {
e.stopPropagation();
e.preventDefault();
dl.cancel().catch(() => {});
dl.removePartialData().catch(console.error);
}}
@auxclick=${e => e.stopPropagation()}
>
<svg
class="library-download-progress-ring"
viewBox="0 0 18 18"
width="20"
height="20"
>
<circle class="track" cx="9" cy="9" r=${RADIUS}></circle>
<circle
class="arc"
cx="9"
cy="9"
r=${RADIUS}
stroke-dasharray=${CIRCUMFERENCE}
stroke-dashoffset=${dashOffset}
></circle>
</svg>
<img
class="library-download-progress-cancel"
src="chrome://browser/skin/zen-icons/close.svg"
alt=""
/>
</button>
`;
}
#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`
<div
class="empty-state"
data-l10n-id=${this.searchTerm ? "library-search-no-results" : "library-boosts-empty"}
></div>
`;
}
return html`${boosts.map(b => this.#renderBoost(b))}`;
}
#renderBoost(boost) {
return html`
<div
class="library-item library-boost-item"
data-key=${`${boost.domain}|${boost.id}`}
?active=${boost.isActive}
@click=${e => this.#openBoost(e, boost)}
@contextmenu=${e => this._onItemContextMenu(e, boost)}
>
<div class="library-item-stack">
<div class="library-item-background"></div>
<div class="library-item-content">
<div class="library-boost-icon" ?inactive=${!boost.isActive}>
<img
class="library-boost-icon-image"
src="page-icon:https://${boost.domain}/"
alt=""
role="presentation"
/>
</div>
<div class="library-item-label-container">
<label class="library-item-label"
>${boost.name || boost.domain}</label
>
<label class="library-item-sublabel">${boost.domain}</label>
</div>
<button
class="library-boost-toggle"
data-l10n-id="library-boost-toggle"
role="switch"
aria-checked=${boost.isActive ? "true" : "false"}
?checked=${boost.isActive}
tabindex="-1"
@click=${e => {
e.stopPropagation();
e.preventDefault();
this.#toggle(boost);
}}
@auxclick=${e => e.stopPropagation()}
>
<span class="library-boost-toggle-thumb"></span>
</button>
</div>
</div>
</div>
`;
}
/**
* 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,
};

View File

@@ -4,5 +4,6 @@
MOZ_SRC_FILES += [
"ZenLibrary.mjs",
"ZenLibraryButton.mjs",
"ZenLibrarySections.mjs",
]

View File

@@ -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);
}

View File

@@ -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,