Преглед на файлове

add floating up/down buttons to jump-scroll between playlists

Adds circular fixed buttons in the lower-right corner of the playlists
view that scroll to the start of the next/previous playlist, greying
out when there's nothing further to scroll to. A bottom spacer lets
the last playlist's video action buttons scroll clear of the fixed
buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Brandon Wong преди 1 месец
родител
ревизия
7ba29abb86
променени са 4 файла, в които са добавени 180 реда и са изтрити 0 реда
  1. 30 0
      2026-07-11-claude-floating-playlist-scroll-buttons.md
  2. 42 0
      popup/popup.css
  3. 19 0
      popup/popup.html
  4. 89 0
      popup/popup.js

Файловите разлики са ограничени, защото са твърде много
+ 30 - 0
2026-07-11-claude-floating-playlist-scroll-buttons.md


+ 42 - 0
popup/popup.css

@@ -236,6 +236,48 @@ button:hover {
   color: #3367d6;
 }
 
+/* Floating playlist jump-scroll buttons */
+.playlist-jump-buttons {
+  position: fixed;
+  right: 16px;
+  bottom: 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  z-index: 100;
+}
+
+.jump-btn {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  background-color: #4285f4;
+  color: white;
+  font-size: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+  transition: background-color 0.2s ease;
+}
+
+.jump-btn:hover:not(:disabled) {
+  background-color: #3367d6;
+}
+
+.jump-btn:disabled {
+  background-color: #ccc;
+  color: #888;
+  cursor: not-allowed;
+  box-shadow: none;
+}
+
+/* Reserve room below the last playlist so its videos can scroll clear
+   of the fixed jump buttons (2 x 36px stacked + 8px gap + 16px bottom offset). */
+.playlists-scroll-spacer {
+  height: 110px;
+}
+
 /* History view styles */
 .history-container {
   background: #fff;

+ 19 - 0
popup/popup.html

@@ -199,6 +199,25 @@
             </div>
           </div>
         </template>
+        <div class="playlists-scroll-spacer" aria-hidden="true"></div>
+      </div>
+
+      <!-- Floating jump-scroll buttons (playlists view only) -->
+      <div class="playlist-jump-buttons" x-bind="playlistJumpButtons">
+        <button
+          class="jump-btn jump-up-btn"
+          x-bind="scrollUpButton"
+          title="Jump to previous playlist"
+        >
+          ↑
+        </button>
+        <button
+          class="jump-btn jump-down-btn"
+          x-bind="scrollDownButton"
+          title="Jump to next playlist"
+        >
+          ↓
+        </button>
       </div>
 
       <!-- History view -->

+ 89 - 0
popup/popup.js

@@ -29,6 +29,7 @@ document.addEventListener("alpine:init", () => {
     currentView: "playlists", // Track current view: 'playlists', 'history', 'playlist', 'saveChannel', or 'wikiInsp'
     currentPlaylistName: "", // Track which playlist is being viewed
     playlistsForDisplay: [], // Computed array for display
+    playlistsScrollTop: 0, // Tracks document.body.scrollTop for jump-button reactivity
     currentPlaylistVideos: [], // Videos for current playlist view
     otherPlaylists: [], // Playlist names (with display labels) other than currentPlaylistName
     currentTab: null, // Current active tab info
@@ -78,6 +79,14 @@ document.addEventListener("alpine:init", () => {
           this.closeAllMenus();
         }
       });
+      // Track scroll position of the root scrolling element so the playlist
+      // jump-scroll buttons' disabled state stays reactive. Whether the
+      // scrollable box ends up being <body> or <html> is a browser-decided
+      // CSS quirk (depends on which element's overflow gets propagated to
+      // the viewport), so use window/scrollingElement rather than assuming body.
+      window.addEventListener("scroll", () => {
+        this.playlistsScrollTop = document.scrollingElement.scrollTop;
+      });
     },
 
     async loadPlaylists() {
@@ -146,6 +155,42 @@ document.addEventListener("alpine:init", () => {
       );
     },
 
+    getPlaylistBoundaryOffsets() {
+      const scroller = document.scrollingElement;
+      // For the root scrolling element, getBoundingClientRect().top is
+      // already -scrollTop, so adding scrollTop converts a viewport-relative
+      // rect into a scroll-position-independent document offset.
+      return Array.from(
+        document.querySelectorAll(".playlist-name-clickable"),
+      ).map((el) => el.getBoundingClientRect().top + scroller.scrollTop);
+    },
+
+    hasScrollableOverflow() {
+      const scroller = document.scrollingElement;
+      return scroller.scrollHeight - scroller.clientHeight > 1;
+    },
+
+    scrollToNextPlaylistBoundary() {
+      const offsets = this.getPlaylistBoundaryOffsets();
+      const currentScroll = document.scrollingElement.scrollTop;
+      const next = offsets.find((offset) => offset > currentScroll + 1);
+      if (next !== undefined) {
+        window.scrollTo({ top: next, behavior: "smooth" });
+      }
+    },
+
+    scrollToPreviousPlaylistBoundary() {
+      const offsets = this.getPlaylistBoundaryOffsets();
+      const currentScroll = document.scrollingElement.scrollTop;
+      const previous = [...offsets]
+        .reverse()
+        .find((offset) => offset < currentScroll - 1);
+      window.scrollTo({
+        top: previous !== undefined ? previous : 0,
+        behavior: "smooth",
+      });
+    },
+
     updateCurrentPlaylistVideos() {
       if (
         !this.currentPlaylistName ||
@@ -1394,6 +1439,50 @@ document.addEventListener("alpine:init", () => {
       },
     },
 
+    playlistJumpButtons: {
+      ["x-show"]() {
+        return this.currentView === "playlists";
+      },
+    },
+
+    scrollUpButton: {
+      ["@click"]() {
+        this.scrollToPreviousPlaylistBoundary();
+      },
+      [":disabled"]() {
+        // Read reactive properties unconditionally (before any early return)
+        // so Alpine always tracks them as dependencies for this binding,
+        // regardless of which branch below ends up short-circuiting.
+        const scrollTop = this.playlistsScrollTop;
+        const playlistCount = this.playlistsForDisplay.length;
+        if (playlistCount < 2 || !this.hasScrollableOverflow()) return true;
+        return scrollTop <= 0;
+      },
+    },
+
+    scrollDownButton: {
+      ["@click"]() {
+        this.scrollToNextPlaylistBoundary();
+      },
+      [":disabled"]() {
+        const scrollTop = this.playlistsScrollTop;
+        const playlistCount = this.playlistsForDisplay.length;
+        if (playlistCount < 2 || !this.hasScrollableOverflow()) return true;
+        const offsets = this.getPlaylistBoundaryOffsets();
+        if (offsets.length === 0) return true;
+        // The last playlist's heading may sit past the maximum scrollable
+        // position (there isn't enough content below it to scroll that far),
+        // so cap the target at whichever is reachable.
+        const scroller = document.scrollingElement;
+        const maxScrollTop = scroller.scrollHeight - scroller.clientHeight;
+        const lastReachableOffset = Math.min(
+          offsets[offsets.length - 1],
+          maxScrollTop,
+        );
+        return scrollTop >= lastReachableOffset - 1;
+      },
+    },
+
     // History-specific bindings for CSP compliance
     historyEmptyState: {
       ["x-show"]() {