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,