diff --git a/vivaldi/mods/CSS/siteinfo.css b/vivaldi/mods/CSS/siteinfo.css index 70ceb69..7101679 100755 --- a/vivaldi/mods/CSS/siteinfo.css +++ b/vivaldi/mods/CSS/siteinfo.css @@ -1,5 +1,5 @@ /* SiteInfoButton */ -.SiteInfoButton { +.SiteInfoButton button { opacity: unset !important; height: 22px; width: 22px; @@ -19,9 +19,10 @@ /* SiteInfo secure background colour */ .SiteInfoButton.secure, .SiteInfoButton.certified { background-color: #004400 !important; + border-radius: var(--radius); } /* SiteInfo hide EV/Not Secure text */ -.SiteInfoButton .siteinfo-text { +.SiteInfoButton.warning .button-badge { display: none; } diff --git a/vivaldi/mods/JS/tab-cycler-close.js b/vivaldi/mods/JS/tab-cycler-close.js new file mode 100755 index 0000000..b9356f0 --- /dev/null +++ b/vivaldi/mods/JS/tab-cycler-close.js @@ -0,0 +1,469 @@ +// ==UserScript== +// @name Mod: Use Tab cycler to close multiple tabs at once +// @description Mark multiple tabs with checkboxes as you navigate the Tab Cycler, then close them all at once. +// @version 1.0 +// @author barbudo2005 +// @match *://*/* +// ==/UserScript== + +(function() { + 'use strict'; + + let cyclerActive = false; + let markedTabIds = new Set(); + let processedTabs = new Set(); + let focusObserver = null; + + console.log('🚀 Tab Cycler Batch Close v1.0 loaded'); + + // M key - toggle checkbox on highlighted tab + window.addEventListener('keydown', function(e) { + if (!cyclerActive) return; + + if (e.code === 'Space' && e.ctrlKey) { + e.preventDefault(); + e.stopPropagation(); + + const activeLi = document.querySelector('.tabswitcher.list li.selected'); + if (!activeLi) return; + + const checkbox = activeLi.querySelector('.tab-checkbox'); + if (!checkbox) return; + + // Toggle checkbox + if (checkbox.checked) { + checkbox.checked = false; + checkbox.removeAttribute('checked'); + } else { + checkbox.checked = true; + checkbox.setAttribute('checked', ''); + } + + const tabId = parseInt(checkbox.dataset.tabId); + + if (checkbox.checked) { + markedTabIds.add(tabId); + } else { + markedTabIds.delete(tabId); + } + + return false; + } + }, true); + + // CTRL or ALT keyup - batch close + document.addEventListener('keyup', function(e) { + if (e.key === 'Control' || e.key === 'Alt') { + if (markedTabIds.size === 0) return; + executeBatchClose(); + } + }, true); + + function executeBatchClose() { + const idsToClose = Array.from(markedTabIds); + + chrome.tabs.remove(idsToClose, () => { + if (chrome.runtime.lastError) { + console.error('❌ Batch close error:', chrome.runtime.lastError); + } + markedTabIds.clear(); + }); + } + + // Polling + setInterval(function() { + const cycler = document.querySelector('.tabswitcher.list'); + + if (cycler) { + if (!cyclerActive) { + cyclerActive = true; + processedTabs.clear(); + markedTabIds.clear(); + applyCustomStyles(); + setupFocusObserver(cycler); + } + injectCheckboxes(cycler); + } else if (cyclerActive) { + cyclerActive = false; + processedTabs.clear(); + + // Batch close when cycler closes (any method) + if (markedTabIds.size > 0) { + executeBatchClose(); + } + + // Cleanup observer + if (focusObserver) { + focusObserver.disconnect(); + focusObserver = null; + } + } + }, 500); + + function setupFocusObserver(cycler) { + if (focusObserver) return; + + focusObserver = new MutationObserver(() => { + const selectedLi = cycler.querySelector('li.selected'); + if (selectedLi) { + const checkbox = selectedLi.querySelector('.tab-checkbox'); + if (checkbox && document.activeElement !== checkbox) { + checkbox.focus(); + } + } + }); + + focusObserver.observe(cycler, { + attributes: true, + attributeFilter: ['class'], + subtree: true + }); + } + + function applyCustomStyles() { + if (document.getElementById('tab-cycler-custom-styles')) return; + + const style = document.createElement('style'); + style.id = 'tab-cycler-custom-styles'; + style.textContent = ` + .tabswitcher.list li.active-page { + filter: brightness(1.4) !important; + } + + .tab-checkbox { + appearance: none; + -webkit-appearance: none; + width: 13px !important; + height: 13px !important; + min-width: 13px !important; + min-height: 13px !important; + border: 1px solid var(--colorFgFadedMost) !important; + border-radius: 2px !important; + background: transparent !important; + cursor: pointer !important; + position: absolute !important; + right: 6px !important; + top: 50% !important; + transform: translateY(-50%) !important; + z-index: 99999 !important; + transition: all 0.15s ease !important; + pointer-events: auto !important; + } + + .tab-checkbox:hover { + border-color: var(--colorFgFaded) !important; + } + + /* Override Vivaldi's checkbox ::before and ::after */ + input[type=checkbox].tab-checkbox::before, + input[type=checkbox].tab-checkbox::after { + display: none !important; + content: none !important; + } + + .tab-checkbox:checked { + border-color: var(--colorFg) !important; + background-color: var(--colorHighlightBg) !important; + } + + /* Our custom checkmark - more specific selector */ + + input[type=checkbox].tab-checkbox:checked::after { + content: '✓' !important; + display: block !important; + color: var(--colorFg) !important; + font-size: 10px !important; + font-weight: bold !important; + position: absolute !important; + top: -1px !important; + left: 1px !important; + transform: none !important; + background: none !important; + } + `; + document.head.appendChild(style); + } + + function injectCheckboxes(cycler) { + const tabItems = cycler.querySelectorAll('ul.listed-tabs > li.visual-list'); + + chrome.tabs.query({currentWindow: true}, (allTabs) => { + tabItems.forEach((li) => { + if (processedTabs.has(li)) return; + if (li.querySelector('.tab-checkbox')) { + processedTabs.add(li); + return; + } + + let matchingTab = null; + + // Get URL from favicon + const favicon = li.querySelector('.visual-tab-list-favicon'); + + if (favicon && favicon.srcset) { + const match = favicon.srcset.match(/chrome:\/\/favicon\/size\/\d+\/(.+?)\s/); + if (match) { + const tabUrl = match[1]; + matchingTab = allTabs.find(t => t.url === tabUrl); + } + } + + if (!matchingTab) { + processedTabs.add(li); + return; + } + + li.style.position = 'relative'; + li.style.display = 'flex'; + li.style.alignItems = 'center'; + li.style.gap = '8px'; + li.style.paddingRight = '25px'; + li.style.minWidth = '0'; + + const textNodes = Array.from(li.childNodes).filter(node => node.nodeType === Node.TEXT_NODE); + textNodes.forEach(textNode => { + if (textNode.textContent.trim()) { + const span = document.createElement('span'); + span.textContent = textNode.textContent; + span.style.overflow = 'hidden'; + span.style.textOverflow = 'ellipsis'; + span.style.whiteSpace = 'nowrap'; + span.style.flex = '1'; + span.style.minWidth = '0'; + span.style.cursor = 'pointer'; + + span.addEventListener('mousedown', function(e) { + chrome.tabs.update(matchingTab.id, {active: true}); + + if (markedTabIds.size > 0) { + executeBatchClose(); + } + }, true); + + li.replaceChild(span, textNode); + } + }); + + if (favicon) { + favicon.style.flexShrink = '0'; + } + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.className = 'tab-checkbox'; + checkbox.dataset.tabId = matchingTab.id; + + checkbox.addEventListener('change', function() { + const tabId = parseInt(checkbox.dataset.tabId); + + if (checkbox.checked) { + markedTabIds.add(tabId); + } else { + markedTabIds.delete(tabId); + } + }); + + // Block propagation to prevent tab activation + checkbox.addEventListener('mousedown', function(e) { + e.stopPropagation(); + e.stopImmediatePropagation(); + }, true); + + checkbox.addEventListener('mouseup', function(e) { + e.stopPropagation(); + e.stopImmediatePropagation(); + }, true); + + checkbox.addEventListener('click', function(e) { + e.stopPropagation(); + e.stopImmediatePropagation(); + }, true); + + li.appendChild(checkbox); + processedTabs.add(li); + }); + }); + } + + console.log('✅ Active'); +})(); + + +/*****************************************************************************************************************/ + +(async () => { + 'use strict'; + + const config = { + // タブスタックにベースドメインを使用する (true: 有効, false: 無効) + // Use the base domain for tab stacks (true: enabled, false: disabled) + base_domain: true, + + // タブスタックの名前を自動的に変更する (0: 無効, 1: ホスト名を使用, 2: ベースドメインから生成) + // Automatically change the name of the tab stack (0: disabled, 1: use hostname, 2: generate from base domain) + rename_stack: 0, + + // 自動タブスタックを許可するワークスペース (完全一致もしくは ) + // * 未設定の場合はすべてのワークスペースで自動タブスタックを許可する + // Workspaces that allow automatic tab stacking (exact match or ) + // * If not set, automatic tab stacking is allowed in all workspaces + allow_workspaces: [ + // "", + // "Shopping", + ], + + // 自動タブスタックを許可するドメイン (完全一致もしくは正規表現) + // * 未設定の場合はすべてのドメインで自動タブスタックを許可する + // Domains that allow automatic tab stacking (exact match or regular expression) + // * If not set, automatic tab stacking is allowed for all domains + allow_domains: [ + // "www.example.com", + // /^(.+\.)?example\.net$/, + ], + + // 自動タブスタックから除外するドメイン (完全一致もしくは正規表現) + // Domains to exclude from automatic tab stacking (exact match or regular expression) + block_domains: [ + // "www.example.com", + // /^(.+\.)?example\.net$/, + ], + }; + + const mergeArrays = (...arrays) => [...new Set(arrays.flat())]; + + const getUrlFragments = (url) => vivaldi.utilities.getUrlFragments(url); + + const getBaseDomain = (url) => { + const {hostForSecurityDisplay, tld} = getUrlFragments(url); + return hostForSecurityDisplay.match(`([^.]+\\.${ tld })$`)?.[1] || hostForSecurityDisplay; + }; + + const getHostname = (url) => { + const {hostForSecurityDisplay} = getUrlFragments(url); + return config.base_domain ? getBaseDomain(url) : hostForSecurityDisplay; + }; + + const matchHostRule = (url, rule) => { + const {hostForSecurityDisplay} = getUrlFragments(url); + return rule instanceof RegExp ? rule.test(hostForSecurityDisplay) : hostForSecurityDisplay === rule; + }; + + const getTab = async (tabId) => { + const tab = await chrome.tabs.get(tabId); + + if (tab.vivExtData) { + tab.vivExtData = JSON.parse(tab.vivExtData); + return tab; + } + }; + + const getTabIndex = async (tabId) => (await getTab(tabId)).index; + + const getWorkspaceName = async (workspaceId) => { + if (!workspaceId) { + return ''; + } + const workspaceList = await vivaldi.prefs.get('vivaldi.workspaces.list'); + return workspaceList.find(item => item.id === workspaceId).name; + }; + + const getTabsByWorkspace = async () => { + const tabs = (await chrome.tabs.query({ currentWindow: true })) + .filter(tab => tab.id !== -1 && tab.vivExtData) + .map(tab => Object.assign(tab, { vivExtData: JSON.parse(tab.vivExtData) })) + .filter(tab => !tab.pinned && !tab.vivExtData.panelId) + .filter(tab => !config.allow_domains.length || config.allow_domains.find(rule => matchHostRule(tab.url, rule))) + .filter(tab => !config.block_domains.length || !config.block_domains.find(rule => matchHostRule(tab.url, rule))); + + return Object.groupBy(tabs, tab => tab.vivExtData.workspaceId); + }; + + const getTabsByStack = (tabs) => Object.groupBy(tabs, tab => tab.vivExtData.group); + + const getTabsByHost = (tabs) => Object.groupBy(tabs, tab => getHostname(tab.url)); + + const getMaxTabsStackId = (tabsByStack, targetHost) => { + const counts = {}; + + for (const [stackId, tabs] of Object.entries(tabsByStack)) { + if (stackId !== 'undefined') { + const tabsByHost = getTabsByHost(tabs); + const count = tabsByHost[targetHost]?.length || 0; + + delete tabsByHost[targetHost]; + counts[stackId] = Object.values(tabsByHost) + .reduce((acc, tabs) => { + return acc > tabs.length ? acc : 0; + }, count); + } + } + + return Object.entries(counts) + .reduce((acc, [stackId, count]) => { + return acc[1] < count ? [stackId, count] : acc; + }, [, 0])[0]; + }; + + const getTabStackName = (url) => { + let stackName; + + switch (config.rename_stack) { + case 1: + stackName = getHostname(url); + break; + case 2: + stackName = getBaseDomain(url).split('.')[0]; + stackName = stackName.charAt(0).toUpperCase() + stackName.slice(1); + break; + } + return stackName; + }; + + const addTabStack = async (tabId, stackId, stackName) => { + const {vivExtData} = await getTab(tabId); + + if (stackName) { + vivExtData.fixedGroupTitle = stackName; + } + vivExtData.group = stackId; + chrome.tabs.update(tabId, { vivExtData: JSON.stringify(vivExtData) }); + }; + + const stackingTabs = async (workspaceId) => { + const workspaceName = await getWorkspaceName(workspaceId); + + if (!config.allow_workspaces.length || config.allow_workspaces.includes(workspaceName)) { + const tabsByWorkspace = await getTabsByWorkspace(); + const tabsByStack = getTabsByStack(tabsByWorkspace[workspaceId]); + const tabsByHost = getTabsByHost(tabsByWorkspace[workspaceId]); + + for (const [host, tabs] of Object.entries(tabsByHost)) { + const targetStackId = getMaxTabsStackId(tabsByStack, host) || crypto.randomUUID(); + const targetStackTabs = tabsByStack[targetStackId] ? getTabsByHost(tabsByStack[targetStackId])[host] : []; + const targetTabs = mergeArrays(targetStackTabs, tabs); + const targetStackName = getTabStackName(tabs[0].pendingUrl || tabs[0].url); + + let tabIndex = await getTabIndex(targetTabs[0].id); + + for (const tab of targetTabs) { + addTabStack(tab.id, targetStackId, targetStackName); + chrome.tabs.move(tab.id, { index: tabIndex }); + tabIndex++; + } + } + } + }; + + chrome.webNavigation.onCommitted.addListener(async details => { + if (details.tabId !== -1) { + const tab = await getTab(details.tabId); + + if (tab && !tab.pinned && !tab.vivExtData.panelId && details.frameType === 'outermost_frame') { + const workspaceId = tab.vivExtData.workspaceId; + stackingTabs(workspaceId); + } + } + }); +})(); + +