// ==UserScript== // @name TRMNL Sticky Preview // @namespace https://github.com/ExcuseMi/trmnl-userscripts // @version 1.0.4 // @description Adds a toggle to keep the plugin markup preview sticky while scrolling the editor. // @author ExcuseMi // @match https://trmnl.com/plugin_settings/*/markup/edit* // @icon https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/refs/heads/main/images/trmnl.svg // @downloadURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/sticky-preview.user.js // @updateURL https://raw.githubusercontent.com/ExcuseMi/trmnl-userscripts/main/sticky-preview.user.js // @grant none // @run-at document-body // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'trmnl_sticky_preview'; const STYLE_ID = 'trmnl-sticky-preview-style'; const BTN_ID = 'trmnl-sticky-preview-toggle'; function isEnabled() { return localStorage.getItem(STORAGE_KEY) === 'true'; } function setEnabled(value) { localStorage.setItem(STORAGE_KEY, value ? 'true' : 'false'); } function getStickyHeaderBottom() { // The editor page has a sticky sub-header (div.flex-grow.sticky) that sits // above the preview/editor columns. Use its bottom edge as the top offset. const header = document.querySelector('div.flex-grow.sticky'); return header ? Math.round(header.getBoundingClientRect().bottom) : 0; } function applyStyle(enabled) { let style = document.getElementById(STYLE_ID); if (enabled) { if (!style) { style = document.createElement('style'); style.id = STYLE_ID; document.head.appendChild(style); } const offset = getStickyHeaderBottom(); style.textContent = ` body:has([data-codemirror-target="previewContainer"]) [data-codemirror-target="previewContainer"] { position: sticky !important; top: ${offset}px !important; } `; } else { if (style) style.remove(); } } function updateButton(btn, enabled) { btn.title = enabled ? 'Disable sticky preview' : 'Enable sticky preview'; btn.setAttribute('aria-pressed', String(enabled)); const dot = btn.querySelector('[data-sticky-dot]'); if (dot) dot.classList.toggle('hidden', !enabled); } function injectUI() { // Guard against double-injection (turbo:load also fires on first page load) if (document.getElementById(BTN_ID)) return false; const resetBtn = document.querySelector('[data-reset-button]'); if (!resetBtn) return false; const btn = document.createElement('button'); btn.id = BTN_ID; btn.type = 'button'; // Match the icon-button style used by dark-mode / orientation toggles btn.className = [ 'inline-block', 'p-2', 'transition-all', 'duration-200', 'text-sm', 'font-medium', 'tracking-tight', 'rounded-full', 'hover:bg-gray-100', 'dark:hover:bg-gray-800', 'text-black', 'dark:text-white', 'bg-transparent', 'border-0', 'cursor-pointer', 'focus:outline-none', 'focus:ring-2', 'focus:ring-primary-500', 'relative', ].join(' '); btn.innerHTML = `
`; const enabled = isEnabled(); updateButton(btn, enabled); applyStyle(enabled); 0 btn.addEventListener('click', () => { const next = !isEnabled(); setEnabled(next); updateButton(btn, next); applyStyle(next); }); resetBtn.insertAdjacentElement('afterend', btn); // Keep the top offset correct when the header resizes (e.g. window resize) const stickyHeader = document.querySelector('div.flex-grow.sticky'); if (stickyHeader) { new ResizeObserver(() => { if (isEnabled()) applyStyle(true); }) .observe(stickyHeader); } return true; } if (!injectUI()) { const observer = new MutationObserver(() => { if (injectUI()) observer.disconnect(); }); observer.observe(document.body, { childList: true, subtree: true }); } document.addEventListener('turbo:load', injectUI); })();