// ==UserScript==
// @name Legal Acts Finder — Responsive Sidebar w/ PDF lookup (improved UI + PDF)
// @namespace http://tampermonkey.net/
// @version 1.12.0
// @description Detect legal acts/sections, dedupe, find PDFs (direct if possible). Compact responsive UI, accent applied to controls, draggable/snap sidebar, toggle tab. Uses polite queued search.
// @author iamnobody + AI improvements
// @license MIT
// @match *://*/*
// @exclude *://www.google.*/*
// @exclude *://search.yahoo.com/*
// @exclude *://www.bing.com/*
// @exclude *://duckduckgo.com/*
// @exclude *://search.brave.com/*
// @exclude *://*.yandex.*/*
// @grant GM_xmlhttpRequest
// @icon https://greasyfork.org/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MTg5MTc1LCJwdXIiOiJibG9iX2lkIn19--c218824699773e9e6d58fe11cc76cdbb165a2e65/1000031087.jpg?locale=en
// @banner https://greasyfork.org/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MTg5MTczLCJwdXIiOiJibG9iX2lkIn19--77a89502797ffc05cd152a04c877a3b3de4c24be/1000031086.jpg?locale=en
// ==/UserScript==
(() => {
'use strict';
/* ---------- TL;DR: improvements made ----------
- Accent color applied consistently (buttons, +/- etc).
- PDF lookup implemented per item using GM_xmlhttpRequest with concurrency control.
- Scan & fetch happen when user opens the sidebar (no heavy work on pageload).
- Toggle tab created early to ensure it's always visible.
- Kept draggable / snap / exclude-site behaviors.
------------------------------------------------*/
/* ---------- Helpers ---------- */
const escapeHtml = s => String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const HOST = location.hostname.replace(/^www\./i, '');
const STORAGE_KEYS = { excluded: 'la_excluded_sites_v4', color: 'la_sidebar_color_v4', width: 'la_sidebar_width_v4', pos: 'la_sidebar_position_v4' };
const DEFAULT_ACCENT = '#ff8a00';
const DEFAULT_WIDTH = 300;
const MIN_WIDTH = 140;
const MAX_WIDTH = 600;
const FETCH_CONCURRENCY = 3;
const OPEN_DELAY_MS = 300;
/* ---------- Simple storage wrappers ---------- */
function getStored(key, def) {
try { const v = localStorage.getItem(key); return v === null ? def : JSON.parse(v); } catch { return def; }
}
function setStored(key, val) {
try { localStorage.setItem(key, JSON.stringify(val)); } catch {}
}
/* ---------- Early exit if site excluded ---------- */
const excludedHosts = getStored(STORAGE_KEYS.excluded, []);
if (Array.isArray(excludedHosts) && excludedHosts.includes(HOST)) return;
/* ---------- Regexes ---------- */
const actRegex = /(\b[A-Z]?[a-zA-Z&\-\s]{2,}?\s+act\s+of\s+\d{4}\b)|(\b[A-Z]?[a-zA-Z&\-\s]{2,}?\s+act,\s+\d{4}\b)|(\b[A-Z]?[a-zA-Z&\-\s]{2,}?\s+act\s+of\s+year\s+\d{4}\b)/gi;
const ruleRegex = /\bsection\s+\w+\s+of\s+\w+\s+act,\s+\d{4}\b/gi;
/* ---------- Build minimal UI early ---------- */
const accent = getStored(STORAGE_KEYS.color, DEFAULT_ACCENT);
const initialWidth = getStored(STORAGE_KEYS.width, DEFAULT_WIDTH);
// inject CSS (accent applied to relevant controls)
const css = document.createElement('style');
css.textContent = `
:root{--la-accent:${accent};--la-bg:#fff;--la-fg:#0b1220;--la-muted:rgba(11,18,32,.6);--la-shadow:rgba(12,16,20,.12)}
@media(prefers-color-scheme:dark){:root{--la-bg:#07101a;--la-fg:#e6eef8;--la-muted:rgba(230,238,248,.7);--la-shadow:rgba(0,0,0,.6)}}
#la-toggle-tab{position:fixed;top:50%;right:0;transform:translateY(-50%);width:34px;height:80px;background:var(--la-accent);color:#fff;font-size:26px;font-weight:800;text-align:center;line-height:80px;border-radius:8px 0 0 8px;cursor:pointer;z-index:2147483650;box-shadow:0 8px 20px var(--la-shadow)}
@media(max-width:420px){#la-toggle-tab{width:30px;height:64px;line-height:64px}}
#la-sidebar{position:fixed;right:0;top:50%;transform:translate(100%,-50%);opacity:0;transition:transform .28s cubic-bezier(.2,.9,.2,1),opacity .28s;min-width:${MIN_WIDTH}px;width:${initialWidth}px;max-width:90vw;max-height:80vh;background:var(--la-bg);color:var(--la-fg);border-radius:12px 0 0 12px;box-shadow:0 12px 30px var(--la-shadow);display:flex;flex-direction:column;overflow:hidden;z-index:2147483647}
#la-sidebar.open{transform:translate(0,-50%);opacity:1}
#la-header{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid rgba(0,0,0,.06)}
#la-title{font-weight:700;display:flex;gap:8px;align-items:center}
.la-dot{width:10px;height:10px;border-radius:50%;background:var(--la-accent)}
#la-controls{display:flex;gap:8px;align-items:center}
.la-btn{background:transparent;border:1px solid rgba(0,0,0,.08);padding:6px 10px;border-radius:8px;color:var(--la-fg);cursor:pointer;outline:none}
.la-btn:hover{background:var(--la-accent);color:#fff}
.la-btn.accent{border-color:var(--la-accent);color:var(--la-accent)}
.la-btn[disabled]{opacity:.45;cursor:not-allowed}
#la-list{flex:1 1 auto;overflow-y:auto;padding:10px}
.la-item{padding:8px 10px;border-radius:8px;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center}
.la-item .meta{font-size:12px;color:var(--la-muted);margin-top:4px}
.pdf-btn{background:var(--la-accent);color:#fff;border:none;padding:6px 8px;border-radius:6px;cursor:pointer;margin-left:8px}
.small{font-size:13px;padding:4px 8px}
#la-footer{padding:8px 12px;border-top:1px solid rgba(0,0,0,.05);font-size:12px;color:var(--la-muted);display:flex;justify-content:space-between;align-items:center}
#la-accordion{padding:10px;border-top:1px solid rgba(0,0,0,.04)}
#la-accordion-toggle{display:flex;gap:8px;cursor:pointer;user-select:none}
#la-accordion-content{overflow:hidden;max-height:0;transition:max-height .28s cubic-bezier(.2,.9,.2,1);padding-top:8px}
.icon-muted{opacity:.7}
`;
document.head.appendChild(css);
// toggle tab (create early)
const toggleTab = document.createElement('div');
toggleTab.id = 'la-toggle-tab';
toggleTab.textContent = '‹';
toggleTab.style.background = accent;
document.body.appendChild(toggleTab);
// sidebar container
const panel = document.createElement('aside');
panel.id = 'la-sidebar';
document.body.appendChild(panel);
// header
const header = document.createElement('div'); header.id = 'la-header';
header.innerHTML = `<div id="la-title"><span class="la-dot"></span><span>Acts & Rules</span></div><div id="la-controls"></div>`;
panel.appendChild(header);
const controls = header.querySelector('#la-controls');
// controls: OpenAll, settings, exclude, close
const openAll = document.createElement('button'); openAll.className = 'la-btn small'; openAll.textContent = 'Open PDFs (0)'; openAll.disabled = true; controls.appendChild(openAll);
const settingsBtn = document.createElement('button'); settingsBtn.className = 'la-btn small'; settingsBtn.innerHTML = '⚙'; controls.appendChild(settingsBtn);
const excludeBtn = document.createElement('button'); excludeBtn.className = 'la-btn small accent'; controls.appendChild(excludeBtn);
const closeBtn = document.createElement('button'); closeBtn.className = 'la-btn small'; closeBtn.textContent = '✕'; controls.appendChild(closeBtn);
// list
const list = document.createElement('div'); list.id = 'la-list'; panel.appendChild(list);
// accordion (individual PDFs)
const acc = document.createElement('div'); acc.id = 'la-accordion';
acc.innerHTML = `<div id="la-accordion-toggle"><span id="la-arrow">►</span><span>View direct PDFs</span></div><div id="la-accordion-content"></div>`;
panel.appendChild(acc);
const accToggle = acc.querySelector('#la-accordion-toggle');
const accContent = acc.querySelector('#la-accordion-content');
// footer
const foot = document.createElement('div'); foot.id = 'la-footer';
foot.innerHTML = `<span class="small icon-muted">Alt+Shift+L to toggle</span><span class="small icon-muted">Polite queue</span>`;
panel.appendChild(foot);
/* ---------- Settings Modal ---------- */
const settingsModal = document.createElement('div');
settingsModal.style.cssText = `
position: fixed; inset: 0;
background: rgba(0,0,0,.4);
display: none; align-items: center; justify-content: center;
z-index: 2147483648;
`;
settingsModal.innerHTML = `
<div id="la-settings-box" style="
background: var(--la-bg);
color: var(--la-fg);
min-width: 280px; max-width: 90vw;
border-radius: 12px;
box-shadow: 0 10px 40px var(--la-shadow);
padding: 18px;
display: flex;
flex-direction: column;
gap: 14px;
">
<h2 style="font-size: 18px; font-weight: 700; margin-bottom: 6px;">Sidebar Settings</h2>
<label style="display: flex; flex-direction: column; gap: 4px;">
<span>Accent Color (Hex)</span>
<input type="color" id="la-color-picker" style="height: 34px; border-radius: 8px; border: 1px solid var(--la-muted);" />
</label>
<label style="display: flex; flex-direction: column; gap: 4px;">
<span>Font Family</span>
<select id="la-font-select" style="height: 34px; border-radius: 8px; border: 1px solid var(--la-muted); padding: 4px;">
<option value="system-ui">System Default</option>
<option value="Arial, sans-serif">Arial</option>
<option value="Georgia, serif">Georgia</option>
<option value="'Segoe UI', sans-serif">Segoe UI</option>
<option value="'Courier New', monospace">Courier New</option>
</select>
</label>
<label style="display: flex; flex-direction: column; gap: 4px;">
<span>Sidebar Position</span>
<select id="la-pos-select" style="height: 34px; border-radius: 8px; border: 1px solid var(--la-muted); padding: 4px;">
<option value="right">Right</option>
<option value="left">Left</option>
<option value="top">Top</option>
<option value="bottom">Bottom</option>
</select>
</label>
<div style="display:flex; justify-content: flex-end; gap: 8px; margin-top: 10px;">
<button id="la-save-btn" class="la-btn accent small">Save</button>
<button id="la-cancel-btn" class="la-btn small">Cancel</button>
</div>
</div>
`;
document.body.appendChild(settingsModal);
const colorPicker = settingsModal.querySelector('#la-color-picker');
const fontSelect = settingsModal.querySelector('#la-font-select');
const posSelect = settingsModal.querySelector('#la-pos-select');
const saveBtn = settingsModal.querySelector('#la-save-btn');
const cancelBtn = settingsModal.querySelector('#la-cancel-btn');
/* Load current values */
function loadSettingsUI(){
const accent = getStored(STORAGE_KEYS.color, DEFAULT_ACCENT);
const font = getStored('la_font_family', 'system-ui');
const pos = getStored(STORAGE_KEYS.pos, 'right');
colorPicker.value = accent.startsWith('#') ? accent : '#ff8a00';
fontSelect.value = font;
posSelect.value = pos;
}
/* Apply live preview changes */
colorPicker.addEventListener('input', e => {
document.documentElement.style.setProperty('--la-accent', e.target.value);
toggleTab.style.background = e.target.value;
});
fontSelect.addEventListener('change', e => {
panel.style.fontFamily = e.target.value;
});
posSelect.addEventListener('change', e => {
applySidebarPosition(e.target.value);
});
/* Position helper */
function applySidebarPosition(pos){
setStored(STORAGE_KEYS.pos, pos);
panel.style.top = ''; panel.style.bottom = ''; panel.style.left = ''; panel.style.right = '';
if (pos === 'left'){ panel.style.left = '0'; panel.style.transform = 'translateX(-100%) translateY(-50%)'; panel.style.borderRadius = '0 12px 12px 0'; }
else if (pos === 'right'){ panel.style.right = '0'; panel.style.transform = 'translateX(100%) translateY(-50%)'; panel.style.borderRadius = '12px 0 0 12px'; }
else if (pos === 'top'){ panel.style.top = '0'; panel.style.left = '50%'; panel.style.transform = 'translateX(-50%) translateY(-100%)'; panel.style.borderRadius = '0 0 12px 12px'; }
else if (pos === 'bottom'){ panel.style.bottom = '0'; panel.style.left = '50%'; panel.style.transform = 'translateX(-50%) translateY(100%)'; panel.style.borderRadius = '12px 12px 0 0'; }
}
/* Open/Close modal */
settingsBtn.addEventListener('click', () => {
loadSettingsUI();
settingsModal.style.display = 'flex';
});
cancelBtn.addEventListener('click', () => {
settingsModal.style.display = 'none';
});
settingsModal.addEventListener('click', e => {
if (e.target === settingsModal) settingsModal.style.display = 'none';
});
/* Save handler */
saveBtn.addEventListener('click', () => {
const newAccent = colorPicker.value;
const newFont = fontSelect.value;
const newPos = posSelect.value;
setStored(STORAGE_KEYS.color, newAccent);
setStored('la_font_family', newFont);
setStored(STORAGE_KEYS.pos, newPos);
document.documentElement.style.setProperty('--la-accent', newAccent);
toggleTab.style.background = newAccent;
panel.style.fontFamily = newFont;
applySidebarPosition(newPos);
settingsModal.style.display = 'none';
});
/* ---------- Small UI helpers ---------- */
function updateExcludeBtnText() {
const excluded = getStored(STORAGE_KEYS.excluded, []);
excludeBtn.textContent = excluded.includes(HOST) ? 'Re-enable' : 'Exclude';
}
updateExcludeBtnText();
excludeBtn.addEventListener('click', () => {
let arr = getStored(STORAGE_KEYS.excluded, []);
if (!Array.isArray(arr)) arr = [];
if (arr.includes(HOST)) arr = arr.filter(h => h !== HOST);
else arr.push(HOST);
setStored(STORAGE_KEYS.excluded, arr);
updateExcludeBtnText();
alert('Reload to apply exclusion change.');
});
closeBtn.addEventListener('click', () => { hideSidebar(); });
/* ---------- Toggle behavior & lazy boot ---------- */
let openedOnce = false;
toggleTab.addEventListener('click', () => {
if (panel.classList.contains('open')) hideSidebar();
else showSidebar();
});
// keyboard toggle Alt+Shift+L
window.addEventListener('keydown', e => {
if (e.altKey && e.shiftKey && e.key.toUpperCase() === 'L') {
if (panel.classList.contains('open')) hideSidebar(); else showSidebar();
}
});
function showSidebar(){
panel.classList.add('open'); toggleTab.style.display = 'none';
if (!openedOnce) { openedOnce = true; startScanAndFetch(); }
}
function hideSidebar(){
panel.classList.remove('open'); toggleTab.style.display = 'flex';
}
/* ---------- Extraction (runs on-demand) ---------- */
function extractUniqueMatches(){
const text = (document.body && document.body.innerText) ? document.body.innerText.replace(/\s+/g,' ') : '';
const acts = [...text.matchAll(actRegex)].map(m => (m[0]||'').trim());
const rules = [...text.matchAll(ruleRegex)].map(m => (m[0]||'').trim());
const seen = new Map();
for(const r of [...acts, ...rules]){ const k = r.toLowerCase(); if(!seen.has(k)) seen.set(k,r); }
return [...seen.values()];
}
/* ---------- Queued fetcher (limited concurrency) ---------- */
function createQueue(maxConcurrent){
const q = []; let running = 0;
const next = () => {
if (running >= maxConcurrent || q.length === 0) return;
const job = q.shift(); running++;
job.fn().then(res => { try { job.resolve(res); } catch(e) {} })
.catch(err => { try{ job.reject(err); }catch{} })
.finally(()=>{ running--; next(); });
};
return {
push: (fn) => new Promise((resolve, reject) => { q.push({fn, resolve, reject}); next(); })
};
}
const fetchQueue = createQueue(FETCH_CONCURRENCY);
/* ---------- PDF lookup using Google search HTML (best-effort) ---------- */
function fetchPdfForQuery(query){
return fetchQueue.push(() => new Promise(res => {
const url = `https://www.google.com/search?q=${encodeURIComponent(query + ' pdf')}`;
try {
GM_xmlhttpRequest({
method: 'GET',
url,
headers: { 'User-Agent': navigator.userAgent, 'Accept': 'text/html' },
onload: r => {
const html = r.responseText || '';
// tolerant pdf url match
const m = html.match(/https?:\/\/[^"'>\s]+?\.pdf\b/i);
if (m) {
const pdfUrl = m[0].replace(/\\u0026/g,'&');
res({ type: 'pdf', url: pdfUrl });
} else {
res({ type: 'search', url });
}
},
onerror: ()=> res({ type: 'search', url }),
timeout: 15000
});
} catch (e) {
res({ type: 'search', url });
}
}));
}
/* ---------- Fill UI with placeholders and then fetch PDFs ---------- */
async function startScanAndFetch(){
list.innerHTML = ''; accContent.innerHTML = '';
const matches = extractUniqueMatches();
if (!matches.length) { list.innerHTML = '<div class="la-item">No acts/rules found on this page.</div>'; return; }
// create placeholders quickly using fragment
const frag = document.createDocumentFragment();
const items = []; // will hold {q, node, statusNode, pdfBtn}
for (const q of matches){
const node = document.createElement('div'); node.className = 'la-item';
const left = document.createElement('div'); left.style.flex='1 1 auto';
left.innerHTML = `<div class="title">${escapeHtml(q)}</div><div class="meta">Looking for PDF…</div>`;
const right = document.createElement('div'); right.style.display='flex'; right.style.alignItems='center';
const pdfBtn = document.createElement('button'); pdfBtn.className = 'pdf-btn'; pdfBtn.textContent = '…'; pdfBtn.disabled = true;
right.appendChild(pdfBtn);
node.appendChild(left); node.appendChild(right);
frag.appendChild(node);
items.push({q, node, left, pdfBtn});
// clicking item highlights on page (existing behavior)
node.addEventListener('click', (e)=> { if(e.target === pdfBtn) return; highlightLaw(q); }, {passive:true});
}
list.appendChild(frag);
// Now fetch PDFs one by one (queued)
const pdfList = [];
for (const it of items){
try {
const r = await fetchPdfForQuery(it.q);
// update UI
const meta = it.left.querySelector('.meta');
if (r.type === 'pdf') {
meta.textContent = 'Direct PDF found';
it.pdfBtn.textContent = 'Open PDF';
it.pdfBtn.disabled = false;
it.pdfBtn.onclick = (ev) => { ev.stopPropagation(); window.open(r.url, '_blank'); };
// add to accordion
const a = document.createElement('div'); a.className = 'la-item'; a.innerHTML = `<a href="${r.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(it.q)}</a>`;
accContent.appendChild(a);
pdfList.push(r.url);
} else {
meta.textContent = 'No direct PDF found — open Google search';
it.pdfBtn.textContent = 'Open search';
it.pdfBtn.disabled = false;
it.pdfBtn.onclick = (ev) => { ev.stopPropagation(); window.open(r.url, '_blank'); };
}
} catch (e) {
const meta = it.left.querySelector('.meta');
meta.textContent = 'Lookup failed';
it.pdfBtn.textContent = 'Retry';
it.pdfBtn.disabled = false;
it.pdfBtn.onclick = (ev) => { ev.stopPropagation(); startSingleRetry(it); };
}
updateOpenAllButton(pdfList);
}
// finalize accordion state
if (accContent.children.length === 0) accContent.textContent = 'No direct PDFs found.';
}
async function startSingleRetry(item) {
item.pdfBtn.disabled = true; item.pdfBtn.textContent = '…';
const r = await fetchPdfForQuery(item.q);
const meta = item.left.querySelector('.meta');
if (r.type === 'pdf') {
meta.textContent = 'Direct PDF found';
item.pdfBtn.textContent = 'Open PDF'; item.pdfBtn.disabled = false;
item.pdfBtn.onclick = (ev) => { ev.stopPropagation(); window.open(r.url, '_blank'); };
const a = document.createElement('div'); a.className = 'la-item'; a.innerHTML = `<a href="${r.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.q)}</a>`;
accContent.appendChild(a);
} else {
meta.textContent = 'No direct PDF found — open Google search';
item.pdfBtn.textContent = 'Open search'; item.pdfBtn.disabled = false;
item.pdfBtn.onclick = (ev) => { ev.stopPropagation(); window.open(r.url, '_blank'); };
}
// update open all
const urls = Array.from(accContent.querySelectorAll('a')).map(a => a.href);
updateOpenAllButton(urls);
}
function updateOpenAllButton(urls){
const count = urls.length;
openAll.textContent = `Open PDFs (${count})`;
openAll.disabled = count === 0;
openAll._pdfUrls = urls;
}
// batch open (polite queue)
openAll.addEventListener('click', async () => {
const urls = openAll._pdfUrls || [];
if (!urls.length) return;
if (!confirm(`Open ${urls.length} PDF(s) in new tabs?`)) return;
let blocked = false;
for (const u of urls) {
const w = window.open(u, '_blank');
if (!w) blocked = true;
await new Promise(r=>setTimeout(r, OPEN_DELAY_MS));
}
if (blocked) alert('Some tabs were blocked by the browser. Allow popups or open individually.');
});
// accordion toggle
let accOpen = false;
accToggle.addEventListener('click', ()=>{
accOpen = !accOpen;
accToggle.firstElementChild.style.transform = accOpen ? 'rotate(90deg)' : 'none';
accContent.style.maxHeight = accOpen ? accContent.scrollHeight + 'px' : '0';
});
/* ---------- Highlight logic (kept minimal) ---------- */
let highlightedElements = [];
let currentHighlightIndex = -1;
function clearHighlighting(){
highlightedElements.forEach(el => el.classList.remove('la-highlight'));
highlightedElements = []; currentHighlightIndex = -1; document.body.classList.remove('la-sidebar-highlight');
}
function highlightLaw(lawStr){
clearHighlighting();
if(!lawStr) return;
const lower = lawStr.toLowerCase();
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode(node){
if (!node.nodeValue) return NodeFilter.FILTER_REJECT;
if (node.nodeValue.toLowerCase().includes(lower)) {
if (node.parentElement && !panel.contains(node.parentElement) && !node.parentElement.closest('#la-sidebar')) return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_REJECT;
}
});
while (walker.nextNode()){
const node = walker.currentNode;
const idx = node.nodeValue.toLowerCase().indexOf(lower);
if (idx >= 0){
const before = node.nodeValue.substring(0, idx);
const matchText = node.nodeValue.substring(idx, idx + lawStr.length);
const after = node.nodeValue.substring(idx + lawStr.length);
const parent = node.parentNode;
const span = document.createElement('span'); span.className = 'la-highlight'; span.textContent = matchText;
parent.insertBefore(document.createTextNode(before), node);
parent.insertBefore(span, node);
parent.insertBefore(document.createTextNode(after), node);
parent.removeChild(node);
highlightedElements.push(span);
}
}
if (highlightedElements.length > 0) {
currentHighlightIndex = 0;
highlightedElements[0].scrollIntoView({behavior:'smooth', block:'center'});
document.body.classList.add('la-sidebar-highlight');
} else {
console.info(`No occurrences visible for "${lawStr}"`);
}
}
/* ---------- Draggable & snap behavior (unchanged) ---------- */
// allow dragging by header or panel
let isDragging = false; let dragOffset = {x:0,y:0};
panel.style.cursor = 'grab';
panel.addEventListener('mousedown', e => {
if (e.target.closest('#la-header') || e.target === panel) {
isDragging = true;
const r = panel.getBoundingClientRect();
dragOffset.x = e.clientX - r.left;
dragOffset.y = e.clientY - r.top;
panel.style.cursor = 'grabbing'; document.body.style.userSelect = 'none';
}
});
window.addEventListener('mouseup', e => {
if (!isDragging) return;
isDragging = false; panel.style.cursor = 'grab'; document.body.style.userSelect = '';
// snap to nearest edge (left/right/top/bottom)
const rect = panel.getBoundingClientRect();
const winW = window.innerWidth, winH = window.innerHeight;
const distances = { left: rect.left, right: winW - rect.right, top: rect.top, bottom: winH - rect.bottom };
let minEdge = 'right', minDist = distances.right;
for (const [k,v] of Object.entries(distances)) { if (v < minDist) { minEdge = k; minDist = v; } }
// apply snapping by toggling transform/position — keep it simple: hide then show anchored to chosen edge
if (minEdge === 'left') { panel.style.right = 'auto'; panel.style.left = '0'; panel.style.transform = 'translateX(0) translateY(-50%)'; panel.style.top = '50%'; panel.style.borderRadius = '0 12px 12px 0'; }
else if (minEdge === 'right') { panel.style.left = 'auto'; panel.style.right = '0'; panel.style.transform = 'translateX(0) translateY(-50%)'; panel.style.top = '50%'; panel.style.borderRadius = '12px 0 0 12px'; }
else if (minEdge === 'top') { panel.style.left = '50%'; panel.style.top = '0'; panel.style.transform = 'translateX(-50%) translateY(0)'; panel.style.width = '90vw'; panel.style.borderRadius = '0 0 12px 12px'; }
else if (minEdge === 'bottom') { panel.style.left = '50%'; panel.style.top = 'auto'; panel.style.bottom = '0'; panel.style.transform = 'translateX(-50%) translateY(0)'; panel.style.width = '90vw'; panel.style.borderRadius = '12px 12px 0 0'; }
});
window.addEventListener('mousemove', e => {
if (!isDragging) return;
e.preventDefault();
const newLeft = e.clientX - dragOffset.x;
const newTop = e.clientY - dragOffset.y;
const boundedLeft = Math.min(window.innerWidth - panel.offsetWidth, Math.max(0, newLeft));
const boundedTop = Math.min(window.innerHeight - panel.offsetHeight, Math.max(0, newTop));
panel.style.left = boundedLeft + 'px'; panel.style.top = boundedTop + 'px';
// while dragging, disable the translate(100%) closed transform
panel.style.transform = 'translateX(0) translateY(0)';
});
/* ---------- Responsive adjustments for small screens ---------- */
function applyResponsiveSmall() {
if (window.innerWidth <= 420) {
toggleTab.style.width = '30px'; toggleTab.style.height = '64px'; toggleTab.style.lineHeight = '64px';
panel.style.minWidth = MIN_WIDTH + 'px';
} else {
toggleTab.style.width = '34px'; toggleTab.style.height = '80px'; toggleTab.style.lineHeight = '80px';
panel.style.minWidth = MIN_WIDTH + 'px';
}
}
applyResponsiveSmall();
window.addEventListener('resize', applyResponsiveSmall);
/* ---------- Persist accent + width when user changes later (simple controls could be added) ---------- */
// (kept minimal: coloring already derived from storage; user can update in future iterations)
/* ---------- End of script ---------- */
})();