Fast Rise

Adds navigation buttons near the left of the header and enhances performance on Articulate Rise authoring pages.

// ==UserScript==
// @name        Fast Rise
// @namespace   Violentmonkey Scripts
// @match       https://rise.articulate.com/author/*
// @grant       none
// @version     1.4.1
// @author      AMC-Albert
// @description Adds navigation buttons near the left of the header and enhances performance on Articulate Rise authoring pages.
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const highlightColor = '#15095814'; // Color for the last visited section button
    const navButtonContainerId = 'rise-nav-buttons-container';
    const prevButtonId = 'rise-prev-button';
    const nextButtonId = 'rise-next-button';
    const debounceDelay = 150; // ms delay for debouncing outline updates

    const hrefDetailsPrefix = '#/author/details/';

    // --- State ---
    let outlineUpdateTimeout = null;

    // --- Helpers ---
    const isCourseOutline = () => window.location.href.includes('/author/course');
    const isSectionPage = () => window.location.href.includes('/author/details/');
    const scrollKey = `scrollPos_${window.location.origin}${window.location.pathname}`;
    const buttonKey = `lastClickedButton_${window.location.origin}${window.location.pathname}`;
    const navKey = `sectionNav_${window.location.origin}${window.location.pathname}`;

    console.log('Fast Rise Userscript loaded. Version:', GM_info.script.version);

    // --- Styling ---
    const style = document.createElement('style');
    style.textContent = `
        /* Disable animations to make things snappy and instant, except on a few elements that break if their animations are removed */
        *:not(.author-layout--process *, [data-rmiz-modal-content] *, .curtain *, .upload-progress *, .review-publish-spinner *) {
            animation: none !important;
            transition: none !important;
        }

        /* Remove banner thumbnails from 'copy to another course' feature to make it load faster */
        .copy-lesson-dialog__course-list__course-icon {
            background-image: none !important;
        }

        /* Remove AI garbage */
        button:has(.ai-gradient), .menu__item:has(.ai-gradient), .ai-tooltip-rich, .authoring-tooltip:has([src="https://cdn.articulate.com/assets/rise/assets/ai/block-wizard/generate-image-thumbnail.webp"]) {
            display: none;
        }

        /* Navigation Button Styles */
        #${navButtonContainerId} {
            display: flex;
            align-items: center;
            gap: 5px;
            margin: 0 15px;
        }

        #${navButtonContainerId} button {
            background-color: #f5f5f5;
            border: 1px solid #ccc;
            border-radius: 4px;
            padding: 5px 10px;
            cursor: pointer;
            font-size: 16px;
            font-weight: bold;
            line-height: 1;
            transition: background-color 0.2s ease; /* Add back transition for buttons */
             display: none; /* Hidden by default, shown via JS */
        }

        #${navButtonContainerId} button:hover {
            background-color: #e0e0e0;
        }

         #${navButtonContainerId} button:active {
             background-color: #d0d0d0;
         }

         /* Ensure app-header uses flexbox if not already */
         .app-header {
             display: flex;
             align-items: center;
             width: 100%;
         }
    `;
    document.head.appendChild(style);

    // --- Core Logic ---

    // Save scroll position periodically on the course outline page
    setInterval(() => {
        if (isCourseOutline()) {
            localStorage.setItem(scrollKey, window.scrollY);
        }
    }, 500);

    // Restore scroll position and setup on load
    window.addEventListener('load', () => {
        console.log('Window loaded. Setting up...');
        setupPage();
    });

    // Handle browser back/forward navigation and hash changes
    window.addEventListener('popstate', handleNavigationChange);
    window.addEventListener('hashchange', handleNavigationChange);

    function handleNavigationChange() {
         console.log('Navigation event (popstate/hashchange). Setting up...');
         // Use a small delay to allow Rise's own routing/rendering to settle
         setTimeout(setupPage, 50);
    }


    // Override Articulate's scroll resets on the course outline page
    const originalScrollTo = window.scrollTo;
    window.scrollTo = function(x, y) {
        if (isCourseOutline() && y === 0) {
            const savedScrollPos = localStorage.getItem(scrollKey);
            if (savedScrollPos !== null && window.scrollY !== parseInt(savedScrollPos, 10)) {
                const newY = parseInt(savedScrollPos, 10);
                // console.log('Overriding scrollTo(0) with saved position:', newY);
                originalScrollTo.call(window, x, newY);
                return; // Prevent original call with y=0
            }
        }
        originalScrollTo.call(window, x, y);
    };

    // Function to run setup tasks based on current page
    function setupPage() {
        addNavigationButtons(); // Attempt to add buttons if not present
        updateButtonVisibility(); // Show/hide buttons based on page type

        if (isCourseOutline()) {
            console.log('On Course Outline page.');
            runOutlineUpdates(); // Update index, attach listeners, highlight immediately
            const savedScrollPos = localStorage.getItem(scrollKey);
            if (savedScrollPos !== null) {
                // Delay slightly to ensure layout is stable
                setTimeout(() => {
                    // Double check we are still on the outline page before scrolling
                    if(isCourseOutline()){
                         window.scrollTo(0, parseInt(savedScrollPos, 10));
                    }
                }, 150);
            }
        } else if (isSectionPage()) {
            console.log('On Section Detail page.');
            updateLastClickedButton();
        } else {
             console.log('On other Rise page.');
        }
    }

    // --- Outline Page Specific Functions ---

    // Debounced function to schedule updates for the course outline page
    function scheduleOutlineUpdate() {
        clearTimeout(outlineUpdateTimeout);
        outlineUpdateTimeout = setTimeout(() => {
            if (isCourseOutline()) { // Final check before running
                console.log('Debounced outline update triggered.');
                runOutlineUpdates();
            }
        }, debounceDelay);
    }

    // Function to perform all necessary updates on the outline page
    function runOutlineUpdates() {
        console.log('Running outline updates (index, listeners, highlight).');
        updateNavigationIndex();
        attachClickListeners();
        highlightLastClickedButton();
    }

    // Scans the course outline and updates the stored navigation order
    function updateNavigationIndex() {
        if (!isCourseOutline()) return; // Only run on the outline page

        // Selector for links leading to section detail pages within the outline
        const sectionLinkSelector = `.course-outline-item__link[href^="${hrefDetailsPrefix}"], a[arc-button][href^="${hrefDetailsPrefix}"]`;
        const sectionLinks = document.querySelectorAll(sectionLinkSelector);
        const hrefs = Array.from(sectionLinks).map(link => link.getAttribute('href'));

        const existingHrefs = JSON.parse(localStorage.getItem(navKey) || '[]');
        // Only update localStorage if the list has actually changed
        if (JSON.stringify(hrefs) !== JSON.stringify(existingHrefs)) {
             if (hrefs.length > 0) {
                localStorage.setItem(navKey, JSON.stringify(hrefs));
                console.log(`Navigation index updated with ${hrefs.length} sections.`);
             } else {
                 // Clear the index if no sections are found
                 localStorage.removeItem(navKey);
                 console.log('Navigation index cleared (no sections found).');
             }
        }
    }


    // Save the href of the last clicked "Edit Content" button
    function attachClickListeners() {
        if (!isCourseOutline()) return; // Only run on the outline page

        // Target links that lead to section detail pages
        const sectionLinkSelector = `.course-outline-item__link[href^="${hrefDetailsPrefix}"], a[arc-button][href^="${hrefDetailsPrefix}"]`;
        const buttons = document.querySelectorAll(sectionLinkSelector);

        buttons.forEach(button => {
            // Remove existing listeners to prevent duplicates if re-run
            button.removeEventListener('click', handleButtonClick);
            button.addEventListener('click', handleButtonClick);
        });
    }

    function handleButtonClick(event) {
        const href = event.currentTarget.getAttribute('href');
        if (href) {
            localStorage.setItem(buttonKey, href);
            console.log('Button clicked, saving href:', href);
             // Remove highlight from previously clicked button
             const sectionLinkSelector = `.course-outline-item__link[href^="${hrefDetailsPrefix}"], a[arc-button][href^="${hrefDetailsPrefix}"]`;
             document.querySelectorAll(sectionLinkSelector).forEach(btn => {
                 if (btn.style.background === highlightColor) {
                     btn.style.background = '';
                 }
             });
             // Highlight the clicked button immediately
             event.currentTarget.style.background = highlightColor;
        }
    }


    // Highlight the button corresponding to the last visited section
    function highlightLastClickedButton() {
         if (!isCourseOutline()) return; // Only run on the outline page

        const lastClickedHref = localStorage.getItem(buttonKey);
        const sectionLinkSelector = `.course-outline-item__link[href^="${hrefDetailsPrefix}"], a[arc-button][href^="${hrefDetailsPrefix}"]`;

        // Remove highlight from any previously highlighted button first
        document.querySelectorAll(sectionLinkSelector).forEach(btn => {
             if (btn.style.background === highlightColor) {
                 btn.style.background = '';
             }
        });

        if (lastClickedHref) {
            const button = document.querySelector(`.course-outline-item__link[href="${lastClickedHref}"]`) || document.querySelector(`a[arc-button][href="${lastClickedHref}"]`);
            if (button) {
                button.style.background = highlightColor;
            }
        }
    }

    // --- Section Page Specific Functions ---

    // Update the last clicked button state when arriving at a section page
    function updateLastClickedButton() {
        const currentHref = window.location.hash;
        // Only update if it looks like a valid section detail URL
        if (currentHref.startsWith(hrefDetailsPrefix)) {
            localStorage.setItem(buttonKey, currentHref);
            console.log('Updated last clicked button on section load:', currentHref);
        }
    }

     // --- Navigation ---

    function navigateToSection(direction) {
        if (!isSectionPage()) {
            console.log('Navigation attempted, but not on a section page.');
            return; // Only navigate when on a section page
        }

        const hrefs = JSON.parse(localStorage.getItem(navKey) || '[]');
        if (hrefs.length === 0) {
             console.log('No navigation data found. Visit the course outline first.');
             alert('Navigation data not available. Please visit the main course outline page first.');
             return;
        }

        const currentHref = window.location.hash;
        const currentIndex = hrefs.indexOf(currentHref);

        let targetIndex = -1;
        if (direction === 'next' && currentIndex < hrefs.length - 1) {
            targetIndex = currentIndex + 1;
        } else if (direction === 'previous' && currentIndex > 0) {
            targetIndex = currentIndex - 1;
        } else if (currentIndex === -1) {
             console.warn(`Current URL (${currentHref}) not found in stored navigation index. Re-visit the course outline.`);
             alert(`Current section not found in navigation index. Please visit the course outline page to refresh it.`);
             return;
        } else {
             // Optionally provide feedback that they are at the start/end
             const buttonId = direction === 'next' ? nextButtonId : prevButtonId;
             const button = document.getElementById(buttonId);
             if (button) {
                 button.style.opacity = '0.5'; // Dim the button briefly
                 setTimeout(() => { button.style.opacity = '1'; }, 300);
             }
            return; // Already at the start/end
        }

        if (targetIndex !== -1) {
            const nextHref = hrefs[targetIndex];
            console.log(`Navigating ${direction} to index ${targetIndex}: ${nextHref}`);
            localStorage.setItem(buttonKey, nextHref); // Update last clicked button state *before* navigating
            window.location.hash = nextHref; // Perform navigation
             // No need for setupPage call here, hashchange listener will handle it
        }
    }

    // Keyboard shortcuts for section navigation (Ctrl + Alt + Left/Right)
    window.addEventListener('keydown', (e) => {
        const activeElement = document.activeElement;
        const isInputFocused = activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable);

        if (!isInputFocused && isSectionPage() && e.ctrlKey && e.altKey) {
            if (e.key === 'ArrowRight') {
                e.preventDefault();
                navigateToSection('next');
            } else if (e.key === 'ArrowLeft') {
                e.preventDefault();
                navigateToSection('previous');
            }
        }
    });


    // --- UI Elements ---

    function addNavigationButtons() {
        if (document.getElementById(navButtonContainerId)) {
            return; // Already added
        }

        const header = document.querySelector('.app-header');
        if (!header) {
            return; // Header not ready
        }

        console.log('Adding navigation buttons to header.');

        const container = document.createElement('div');
        container.id = navButtonContainerId;

        const prevButton = document.createElement('button');
        prevButton.id = prevButtonId;
        prevButton.textContent = '←';
        prevButton.title = 'Previous Section (Ctrl+Alt+Left)';
        prevButton.addEventListener('click', () => navigateToSection('previous'));

        const nextButton = document.createElement('button');
        nextButton.id = nextButtonId;
        nextButton.textContent = '→';
        nextButton.title = 'Next Section (Ctrl+Alt+Right)';
        nextButton.addEventListener('click', () => navigateToSection('next'));

        container.appendChild(prevButton);
        container.appendChild(nextButton);

        header.insertBefore(container, header.children[1]);

        updateButtonVisibility(); // Update visibility immediately after adding
    }

    function updateButtonVisibility() {
        const container = document.getElementById(navButtonContainerId);
        if (container) {
             const shouldShow = isSectionPage();
             const buttons = container.querySelectorAll('button');
             buttons.forEach(button => {
                button.style.display = shouldShow ? 'inline-block' : 'none';
             });
        }
    }

    // --- Dynamic Content Handling ---

    const observer = new MutationObserver((mutationsList, observer) => {
        let needsGeneralSetup = false; // For header/buttons
        let potentialOutlineChange = false; // Flag if outline items might have changed

        // Check for header setup need (independent of outline page)
         if (!document.getElementById(navButtonContainerId)) {
             for (const mutation of mutationsList) {
                 if (mutation.type === 'childList') {
                     for (const node of mutation.addedNodes) {
                         if (node.nodeType === Node.ELEMENT_NODE && (node.matches('.app-header') || node.querySelector('.app-header'))) {
                             needsGeneralSetup = true;
                             break;
                         }
                     }
                 }
                 if (needsGeneralSetup) break;
             }
         }

         // Check for outline changes *if* on the outline page
         if (isCourseOutline()) {
             for (const mutation of mutationsList) {
                 if (mutation.type === 'childList') {
                     const changedNodes = [...mutation.addedNodes, ...mutation.removedNodes];
                     for (const node of changedNodes) {
                         if (node.nodeType === Node.ELEMENT_NODE) {
                             // Check if the node itself or its descendants match outline item/link structure
                             const sectionLinkSelector = `.course-outline-item, .course-outline-item__link, a[arc-button][href^="${hrefDetailsPrefix}"]`;
                             if (node.matches(sectionLinkSelector) || node.querySelector(sectionLinkSelector)) {
                                 potentialOutlineChange = true;
                                 break; // Found a relevant change in this mutation's nodes
                             }
                         }
                     }
                 }
                 if (potentialOutlineChange) break; // Stop checking mutations if change found
             }
         }

         // After looping through mutations:
         if (needsGeneralSetup) {
             addNavigationButtons();
         }

         // Always update button visibility as page state might change implicitly
         updateButtonVisibility();

         // If outline changes were detected, schedule the debounced update
         if (potentialOutlineChange) {
             scheduleOutlineUpdate();
         }
    });


    // Start observing the documentElement for broad coverage
    observer.observe(document.documentElement, { childList: true, subtree: true });

    // Initial setup attempt after script injection
    setTimeout(setupPage, 50);

})();