(() => { 'use strict'; // ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── // Open in Preview ************************************************************************************************************* // ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── const KEYCODE_CONFIG = 'Ctrl+Alt+Period, Ctrl+Shift+F, Esc, Alt+Period, Alt+/'; // User defined for constructor() Line 1607 // First & second keys = searchForSelectedText(), third & fourth keys = removePreview(viewId), fifth key = toggle url-input const IMAGE_CONFIG = { preview: '🖥️ ', }; // or 🔍 const PREVIEW_CONFIG = { height: 100, width: 100 }; // Percentage values const CONTEXT_MENU_CONFIG = { linkMenuTitle: 'Open in Preview', searchMenuTitle: 'Search in Preview', selectSearchMenuTitle: 'Select Search for Preview', }; const ICON_CONFIG = { linkIcon: '', linkIconInteractionOnHover: false, showIconDelay: 100, showPreviewOnHoverDelay: 100, }; const TIMING_CONFIG = { closeTimeout: 800, fade: 200, fadeDelay: 200, middleClickDelay: 400, optionsHideDelay: 800, previewDelay: 200, progressEasing: 0.12, titleFetchDelay: 2000, }; const chromeAsync = { getLastFocusedWindow: () => new Promise(resolve => chrome.windows.getLastFocused(resolve)), getCurrentWindow: () => new Promise(resolve => chrome.windows.getCurrent(resolve)), queryTabs: query => new Promise(resolve => chrome.tabs.query(query, resolve)), removeTab: tabId => new Promise(resolve => chrome.tabs.remove(tabId, resolve)), getSelectedText: tabId => new Promise(resolve => vivaldi.utilities.getSelectedText(tabId, resolve)) }; let showUrlInput; async function init() { document.removeEventListener('DOMContentLoaded', init); const SHOW_KEY = 'showUrlInput'; if (storage.getMod(SHOW_KEY) === null) { await storage.setMod(SHOW_KEY, false); } let isEnab = await storage.getMod(SHOW_KEY, s.showUrlInput); showUrlInput = isEnab; waitForPreview(); } function waitForPreview() { const maxAttempts = 50; let attempts = 0; const delay = TIMING_CONFIG.previewDelay; const poll = async () => { const browser = document.getElementById('browser'); if (browser) { try { new PreviewWindow(); return; } catch (err) { console.error("Failed to create PreviewWindow:", err); return; } } attempts++; if (attempts < maxAttempts) { await new Promise(r => setTimeout(r, delay)); poll(); } else { console.warn("Browser element not found after maximum attempts"); } }; poll(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } class PreviewWindow { rootBrowser = document.getElementById('browser'); #canvasContext = document.createElement('canvas').getContext('2d'); webviews = new Map(); addListener(target, event, handler, options) { target.addEventListener(event, handler, options); this._listeners.push({ target, event, handler, options }); return handler; } removeAllListeners() { for (const l of this._listeners) { l.target.removeEventListener(l.event, l.handler, l.options); } this._listeners.length = 0; } #iconUtils; get iconUtils() { return this.#iconUtils ??= new IconUtils(); } #renderer; get renderer() { return this.#renderer ??= new PreviewRenderer(this); } searchEngineUtils = new SearchEngineUtils( url => this.previewWindow(url), (engineId, searchText) => this.previewWindowSearch(engineId, searchText), CONTEXT_MENU_CONFIG ); READER_VIEW_URL = 'https://www.smry.ai/proxy?url='; constructor() { this._listeners = []; vivaldi.tabsPrivate.onKeyboardShortcut.addListener((id, combination) => { const webviewValues = Array.from(this.webviews.values()); let webviewData = webviewValues.at(-1); if (!webviewData.fromPanel) { const tabId = Number(this.getActiveWebview()?.tab_id); webviewData = webviewValues.findLast(_data => _data.tabId === tabId); } let viewId = webviewData.webview.id; // KEYCODE_CONFIG = 'Ctrl+Alt+Period, Ctrl+Shift+F, Esc, Alt+Period, Alt+/' if (!KEYCODE_CONFIG || typeof KEYCODE_CONFIG !== 'string') return; const normalize = (str) => str.toLowerCase().replace(/\s+/g, ''); const key_Code = KEYCODE_CONFIG.split(',').map(k => normalize(k)).filter(Boolean); const key_Position = normalize(combination); if (!key_Code.length) return; const [first, second, third, fourth, fifth] = key_Code; if (key_Position === first || key_Position === second) { this.searchForSelectedText(); } else if (key_Position === third || key_Position === fourth) { this.removePreview(viewId); } else if (key_Position === fifth) { this.toggleInput(); } }); new WebsiteInjectionUtils( navigationDetails => this.getWebviewConfig(navigationDetails), (url, fromPanel, origin) => this.previewWindow(url, fromPanel, origin), ICON_CONFIG ); window.addEventListener('unload', () => this.cleanupAll()); } getWebviewConfig(navigationDetails) { if (navigationDetails.frameType !== 'outermost_frame') { return { webview: null, fromPanel: false }; } const tabSelector = `webview[tab_id="${navigationDetails.tabId}"]`; const webview = document.querySelector(tabSelector); if (webview) { return { webview, fromPanel: webview.name === 'vivaldi-webpanel' }; } const panelView = [...this.webviews.values()].find(v => v.fromPanel)?.webview; if (panelView) { return { webview: panelView, fromPanel: true }; } const active = this.getActiveWebview(); const container = active?.closest('.preview-container'); const lastWebviewId = container?.querySelector('webview')?.id; return { webview: this.webviews.get(lastWebviewId)?.webview, fromPanel: false }; } getActiveWebview() { return document.querySelector('.active.visible.webpageview webview'); } async searchForSelectedText() { try { const tabs = await chromeAsync.queryTabs({ active: true, currentWindow: true }); const tab = tabs[0]; if (!tab) return; let text = await chromeAsync.getSelectedText(tab.id); if (!text) return; this.previewWindowSearch(this.searchEngineUtils.defaultSearchId, text); } catch (e) { console.error('searchForSelectedText failed:', e); } } async previewWindowSearch(engineId, selectionText) { const searchRequest = await vivaldi.searchEngines.getSearchRequest(engineId, selectionText); this.previewWindow(searchRequest.url); } async toggleInput() { showUrlInput = !showUrlInput; await storage.setMod('showUrlInput', showUrlInput); } removePreview(webviewId) { const data = this.webviews.get(webviewId); if (!data) return; const container = data.divContainer; const previewWindow = container?.querySelector('.preview-window'); if (!container || !previewWindow) return; if (container.dataset.closing === '1') return; container.dataset.closing = '1'; const pointerX = Number(container.dataset.pointerX ?? window.innerWidth / 2); const pointerY = Number(container.dataset.pointerY ?? window.innerHeight / 2); this.setAnchoredTransformVars(previewWindow, pointerX, pointerY); requestAnimationFrame(() => { container.classList.remove('is-open'); container.classList.add('is-leave'); container.style.backdropFilter = 'none'; previewWindow.classList.add('animating-close'); const finishRemoval = async () => { const tabs = await chromeAsync.queryTabs({}); const tab = tabs.find(t =>String(t.vivExtData || '').includes(`${webviewId}tabId`)); if (tab) { await chromeAsync.removeTab(tab.id); } container.classList.remove('is-leave'); data.divContainer.remove(); if (data.tabCloseListener) { chrome.tabs.onRemoved.removeListener(data.tabCloseListener); } if (data.pointerdownListener) { document.body.removeEventListener('pointerdown', data.pointerdownListener); } this.webviews.delete(webviewId); }; const onCloseEnd = e => { if (e.animationName === 'preview-window-close-anchored') { previewWindow.removeEventListener('animationend', onCloseEnd); finishRemoval(); } }; previewWindow.addEventListener('animationend', onCloseEnd); setTimeout(finishRemoval, TIMING_CONFIG.closeTimeout); }); } async previewWindow(linkUrl, fromPanel = undefined, origin = undefined) { let lastFocused, current; try { [lastFocused, current] = await Promise.all([ chromeAsync.getLastFocusedWindow(), chromeAsync.getCurrentWindow() ]); } catch (err) { console.error('Failed to get windows:', err); return; } const isValidWindow = lastFocused.id === current.id && lastFocused.state !== chrome.windows.WindowState.MINIMIZED; if (!isValidWindow) return; const url = await UrlUtils.normalizeOrSearch(linkUrl, this.searchEngineUtils); this.showPreview(url, fromPanel, origin); } showPreview(linkUrl, fromPanel, origin) { const webviewId = `dialog-${this.getWebviewId()}`; const { previewContainer, previewWindow, webview, optionsContainer, progressBar } = this.renderer.createBaseElements(webviewId, linkUrl); if (fromPanel === undefined && this.webviews.size !== 0) { fromPanel = Array.from(this.webviews.values()).at(-1).fromPanel; } const activeWebview = this.getActiveWebview(); const tabId = !fromPanel && activeWebview ? Number(activeWebview.tab_id) : null; this.webviews.set(webviewId, { divContainer: previewContainer, webview: webview, fromPanel: fromPanel, tabId: tabId, pointerdownListener: null, pointerdownAttached: false }); if (!fromPanel) { const clearWebviews = closedTabId => { if (tabId === closedTabId) { this.webviews.forEach((view, key) => view.tabCloseListener === clearWebviews && this.removePreview(key)); chrome.tabs.onRemoved.removeListener(clearWebviews); } }; this.webviews.get(webviewId).tabCloseListener = clearWebviews; chrome.tabs.onRemoved.addListener(clearWebviews); this._tabListeners ??= new Set(); this._tabListeners.add(clearWebviews); } previewWindow.setAttribute('class', 'preview-window'); this.renderer.applyInitialSizing(previewWindow, this.webviews.size); optionsContainer.setAttribute('class', 'options-container'); let pageTitle = linkUrl; const fadeDuration = TIMING_CONFIG.fade; let timeout; let showingOptions = false; optionsContainer.textContent = IMAGE_CONFIG.preview + pageTitle; optionsContainer.addEventListener('mouseover', () => { if (!showingOptions) { optionsContainer.classList.add('fade-out'); setTimeout(() => { optionsContainer.innerHTML = ''; this.showWebviewOptions(webviewId, optionsContainer); optionsContainer.classList.remove('fade-out'); showingOptions = true; const siteUrl = webview.src; let btn = document.querySelector('.options-button'); let inp = document.querySelector('.url-input'); let len = inp.value.length; if (siteUrl.includes('youtube.com')) { if (this.rootBrowser.classList.contains('normal')) { if (len < 26) inp.style.marginRight = '-10.5vw'; else inp.style.marginRight = '-4vw'; } else { if (len < 26) el.style.marginRight = '-15.5vw'; else inp.style.marginRight = '-11.70vw'; } } if (siteUrl.includes('earth.google.com')) { inp.style.marginRight = '.3vw'; } }, fadeDuration); } clearTimeout(timeout); }); optionsContainer.addEventListener('mouseleave', () => { timeout = setTimeout(() => { optionsContainer.classList.add('fade-out'); setTimeout(() => { optionsContainer.textContent = IMAGE_CONFIG.preview + pageTitle; optionsContainer.classList.remove('fade-out'); showingOptions = false; }, fadeDuration); }, TIMING_CONFIG.optionsHideDelay); }); let currentPageUrl = ''; let titleFetched = false; webview.id = webviewId; webview.tab_id = `${webviewId}tabId`; webview.setAttribute('src', linkUrl); currentPageUrl = linkUrl; titleFetched = false; let isLoading = false; webview.addEventListener('loadstart', () => { webview.style.backgroundColor = 'var(--colorBorder)'; progressBar.start(); if (showUrlInput) { const input = document.getElementById(`input-${webview.id}`); if (input !== null) { input.value = webview.src; } } isLoading = true; webview.focus(); }); webview.addEventListener('loadcommit', () => { titleFetched = false; progressBar.clear(true); }); webview.addEventListener('loadstop', () => { progressBar.clear(true); const expectedSrc = webview.src; setTimeout(() => { let title = ''; try { if (webview.getTitle) { title = webview.getTitle(); } if (!title) { webview.executeScript({ code: 'document.title' }, (results) => { if (!results || !results[0]) return; const resolvedTitle = results[0]; if (webview.src === expectedSrc && resolvedTitle) { pageTitle = resolvedTitle; titleFetched = true; if (!showingOptions) { optionsContainer.textContent = IMAGE_CONFIG.preview + pageTitle; } } }); } else { if (webview.src === expectedSrc) { pageTitle = title; titleFetched = true; if (!showingOptions) { optionsContainer.textContent = IMAGE_CONFIG.preview + pageTitle; } } } } catch (e) { console.error('Title fetch failed:', e); } }, TIMING_CONFIG.titleFetchDelay); }); previewContainer.setAttribute('class', 'preview-container'); const pointerX = origin?.x ?? window.innerWidth / 2; const pointerY = origin?.y ?? window.innerHeight / 2; previewContainer.dataset.pointerX = String(pointerX); previewContainer.dataset.pointerY = String(pointerY); const stopEvent = event => { event.preventDefault(); event.stopPropagation(); if (showUrlInput && event.target.id === `input-${webviewId}`) { const inputElement = event.target; const offsetX = event.clientX - inputElement.getBoundingClientRect().left; this.#canvasContext.font = window.getComputedStyle(inputElement).font; const text = inputElement.value; let low = 0, high = text.length; while (low < high) { const mid = (low + high) >> 1; const width = this.#canvasContext.measureText(text.slice(0, mid)).width; if (width < offsetX) low = mid + 1; else high = mid; } const cursorPosition = low; inputElement.focus({ preventScroll: true }); inputElement.setSelectionRange(cursorPosition, cursorPosition); } }; if (fromPanel) { const boundStopEvent = stopEvent.bind(this); this.addListener(document.body, 'pointerdown', boundStopEvent); this.webviews.get(webviewId).pointerdownListener = boundStopEvent; } previewContainer.addEventListener('click', event => { if (event.target === previewContainer) { this.removePreview(webviewId); } }); this.renderer.attachStructure({ previewContainer, previewWindow, optionsContainer, progressBar, webview }); this.renderer.mount(previewContainer, fromPanel, this.rootBrowser); this.renderer.runOpenAnimation( previewWindow, previewContainer, pointerX, pointerY, this.setAnchoredTransformVars.bind(this), TIMING_CONFIG ); } setAnchoredTransformVars(previewWindow, viewportX, viewportY, s0 = 0.1) { const rect = previewWindow.getBoundingClientRect(); const dx = viewportX - rect.left; const dy = viewportY - rect.top; const t0x = (1 - s0) * dx; const t0y = (1 - s0) * dy; previewWindow.style.setProperty('--s0', String(s0)); previewWindow.style.setProperty('--tx0', `${t0x}px`); previewWindow.style.setProperty('--ty0', `${t0y}px`); return { t0x, t0y, s0 }; } showWebviewOptions(webviewId, thisElement) { let inputId = `input-${webviewId}`; let data = this.webviews.get(webviewId); let webview = data ? data.webview : undefined; if (webview && document.getElementById(inputId) === null) { let input = null; if (showUrlInput) { input = document.createElement('input'); input.value = webview.src; input.id = inputId; input.setAttribute('class', 'url-input'); input.addEventListener('keydown', async event => { if (event.key === 'Enter') { let value = input.value; const resolvedUrl = await UrlUtils.normalizeOrSearch(value, this.searchEngineUtils); webview.src = resolvedUrl; } }); } const fragment = document.createDocumentFragment(); const buttons = [ { content: this.iconUtils.back, action: () => webview.back(), cls: 'back-button', tooltip: 'Back' }, { content: this.iconUtils.forward, action: () => webview.forward(), cls: 'forward-button', tooltip: 'Forward' }, { content: this.iconUtils.reload, action: () => webview.reload(), cls: 'reload-button', tooltip: 'Reload page' }, { content: this.iconUtils.readerView, action: this.showReaderView.bind(this, webview), cls: 'reader-button', tooltip: 'Toggle Reader View' }, { content: this.iconUtils.newTab, action: () => showUrlInput ? this.openNewTab(inputId, true) : this.openNewTabFromWebview(webview, true), cls: 'newtab-button', tooltip: 'Open in new tab' }, { content: this.iconUtils.backgroundTab, action: () => showUrlInput ? this.openNewTab(inputId, false) : this.openNewTabFromWebview(webview, false), cls: 'background-button', tooltip: 'Open in background tab' }, { content: this.iconUtils.toggleInput, action: () => this.toggleInput(), cls: 'toggle-input', tooltip: 'Toggle url-input' }, { content: this.iconUtils.closeBtn, action: () => this.removePreview(webviewId), cls: 'close-button', tooltip: 'Close preview' } ]; buttons.forEach(button => fragment.appendChild( this.createOptionsButton( button.content, button.action, button.cls || '', button.tooltip ) ) ); if (input) fragment.appendChild(input); thisElement.append(fragment); } } createOptionsButton(content, clickListenerCallback, cls = '', tooltip = '') { const button = document.createElement('button'); button.className = `options-button ${cls}`.trim(); button.addEventListener('click', clickListenerCallback); if (tooltip) { button.dataset.tooltip = tooltip; } if (typeof content === 'string') { button.innerHTML = content; } else { button.appendChild(content); } return button; } getWebviewId() { const timestamp = Date.now(); const randomPart = Math.random().toString(36).substring(2, 11); return `${timestamp}-${randomPart}`; } showReaderView(webview) { const previewWindow = webview.parentElement; if (webview.src.includes(this.READER_VIEW_URL)) { webview.src = webview.src.replace(this.READER_VIEW_URL, ''); previewWindow.classList.remove('reader-open'); } else { webview.src = this.READER_VIEW_URL + webview.src; previewWindow.classList.add('reader-open'); } } async openNewTab(inputId, active) { const input = document.getElementById(inputId).value; const url = await UrlUtils.normalizeOrSearch(input, this.searchEngineUtils); chrome.tabs.create({ url, active }); } openNewTabFromWebview(webview, active) { chrome.tabs.create({ url: webview.src, active }); } cleanupAll() { this.removeAllListeners(); if (this._tabListeners) { for (const fn of this._tabListeners) { chrome.tabs.onRemoved.removeListener(fn); } this._tabListeners.clear(); } this.webviews.clear(); webview.remove(); container.remove(); this.webviews.delete(webviewId); if (this.lastWebviewId === webviewId) { this.lastWebviewId = Array.from(this.webviews.keys()).at(-1)?? null; } } } class PreviewRenderer { constructor(context) { this.ctx = context; } createBaseElements(webviewId, linkUrl) { const previewContainer = document.createElement('div'); const previewWindow = document.createElement('div'); const webview = document.createElement('webview'); const optionsContainer = document.createElement('div'); const progressBar = new ProgressBar(webviewId); previewWindow.className = 'preview-window'; optionsContainer.className = 'options-container'; previewContainer.className = 'preview-container'; webview.id = webviewId; webview.tab_id = `${webviewId}tabId`; webview.setAttribute('src', linkUrl); return { previewContainer, previewWindow, webview, optionsContainer, progressBar }; } attachStructure({ previewContainer, previewWindow, optionsContainer, progressBar, webview }) { previewWindow.appendChild(optionsContainer); previewWindow.appendChild(progressBar.element); previewWindow.appendChild(webview); previewContainer.appendChild(previewWindow); } mount(previewContainer, fromPanel, rootBrowser) { (fromPanel ? (rootBrowser || document.querySelector('#browser')) : document.querySelector('.active.visible.webpageview') ).appendChild(previewContainer); } async applyInitialSizing(previewWindow, stackIndex) { previewWindow.style.width = PREVIEW_CONFIG.width * stackIndex + '%'; previewWindow.style.height = PREVIEW_CONFIG.height * stackIndex + '%'; previewWindow.style.visibility = 'hidden'; } runOpenAnimation(previewWindow, previewContainer, pointerX, pointerY, setAnchoredTransformVars, durations) { requestAnimationFrame(() => { const t = setAnchoredTransformVars(previewWindow, pointerX, pointerY); Object.assign(previewWindow.style, { transform: `translate(${t.t0x}px, ${t.t0y}px) scale(${t.s0})`, opacity: '0', visibility: 'visible' }); requestAnimationFrame(() => { previewWindow.getBoundingClientRect(); requestAnimationFrame(() => { previewContainer.classList.add('is-open'); }); }); requestAnimationFrame(() => { previewContainer.classList.add('is-open'); setTimeout(() => { previewWindow.classList.add('animating-open'); const onOpenEnd = e => { if (e.animationName === 'preview-window-open-anchored') { previewWindow.classList.remove('animating-open'); previewWindow.style.removeProperty('transform'); previewWindow.style.removeProperty('opacity'); previewWindow.removeEventListener('animationend', onOpenEnd); } }; previewWindow.addEventListener('animationend', onOpenEnd); }, durations.fadeDelay); }); }); } } class UrlUtils { static VALID_PREFIXES = [ 'http://', 'https://', 'file://', 'vivaldi://', 'chrome://', 'chrome-extension://', 'data:', 'blob:' ]; static BLOCKED_SCHEMES = [ 'javascript:', 'vbscript:' ]; static isValid(url) { if (!url || typeof url !== 'string') return false; const trimmed = url.trim().toLowerCase(); if (this.BLOCKED_SCHEMES.some(s => trimmed.startsWith(s))) { return false; } if (trimmed.startsWith('about:')) return true; return this.VALID_PREFIXES.some(prefix => trimmed.startsWith(prefix)); } static async normalizeOrSearch(input, searchEngineUtils) { if (this.isValid(input)) { return input; } const searchRequest = await vivaldi.searchEngines.getSearchRequest( searchEngineUtils.defaultSearchId, input ); return searchRequest.url; } } class WebsiteInjectionUtils { constructor(getWebviewConfig, openPreview, iconConfig) { this.iconConfig = JSON.stringify(iconConfig); chrome.webNavigation.onCompleted.addListener(navigationDetails => { const { webview, fromPanel } = getWebviewConfig(navigationDetails); webview && this.injectCode(webview, fromPanel); }); chrome.runtime.onMessage.addListener(message => { if (message.url) { openPreview(message.url, message.fromPanel, message.origin); } }); } injectCode(webview, fromPanel) { const handler = WebsiteLinkInteractionHandler.toString(); const instantiationCode = ` if (window.__dialogHandlerInitialized) return; window.__dialogHandlerInitialized = true; window.__previewInjectedCleanupRun = () => { window.__previewInjectedCleanup?.forEach(fn => fn()); window.__previewInjectedCleanup?.clear?.(); }; window.addEventListener('beforeunload', () => { window.__previewInjectedCleanupRun?.(); }); window.addEventListener('pagehide', () => { window.__previewInjectedCleanupRun?.(); }); new (${handler})(${fromPanel}, ${this.iconConfig}); `; try { webview.executeScript({ code: instantiationCode }, () => { if (chrome.runtime.lastError) { console.debug('Preview mod: Script injection failed:', chrome.runtime.lastError.message); } }); } catch (error) { console.debug('Preview mod: Failed to execute script:', error); } } } class WebsiteLinkInteractionHandler { constructor(fromPanel, config) { window.__previewInjectedCleanup ??= new Set(); this.fromPanel = fromPanel; this.config = config; this.icon = null; this.timers = { showIcon: null, showPreview: null, hideIcon: null }; this.boundHideIcon = this.#hideLinkIcon.bind(this); this.#initialize(); } #initialize() { this.#setupMouseHandling(); if (this.config.linkIcon) { this.#setupIconHandling(); } } #setupMouseHandling() { let holdTimerForMiddleClick; const pointerDownHandler = event => { if (event.ctrlKey && event.altKey && [0, 1].includes(event.button)) { this.#callPreview(event); } else if (event.button === 1) { const link = this.#getLinkElement(event); if (!link) return; const px = event.clientX; const py = event.clientY; const href = link.href; holdTimerForMiddleClick = setTimeout(() => { this.#sendPreviewMessage(href, px, py); }, TIMING_CONFIG.middleClickDelay); } }; const pointerUpHandler = event => { if (event.button === 1) clearTimeout(holdTimerForMiddleClick); }; document.addEventListener('pointerdown', pointerDownHandler); document.addEventListener('pointerup', pointerUpHandler); window.__previewInjectedCleanup ??= new Set(); window.__previewInjectedCleanup.add(() => { document.removeEventListener('pointerdown', pointerDownHandler); document.removeEventListener('pointerup', pointerUpHandler); }); } #setupIconHandling() { this.#createIcon(); this.#createIconStyle(); document.addEventListener( 'mouseover', this.debounce(event => { const link = this.#getLinkElement(event); if (!link) return; clearTimeout(this.timers.hideIcon); requestAnimationFrame(() => { const rect = link.getBoundingClientRect(); Object.assign(this.icon.style, { display: 'block', left: `${rect.right + 5}px`, top: `${rect.top + window.scrollY}px` }); }); this.icon.dataset.targetUrl = link.href; this.currentLinkEl = link; link.addEventListener('mouseleave', this.boundHideIcon); }, this.config.showIconDelay) ); } #createIcon() { const icon = document.createElement('div'); icon.className = `link-icon ${this.config.linkIcon}`; icon.style.display = 'none'; const getLinkCenter = () => { const el = this.currentLinkEl; if (el) { const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; } return { x: Math.round(window.innerWidth / 2), y: Math.round(window.innerHeight / 2) }; }; if (this.config.linkIconInteractionOnHover) { icon.addEventListener('mouseenter', () => { this.timers.showPreview = setTimeout(() => { const { x, y } = getLinkCenter(); this.#sendPreviewMessage(this.icon.dataset.targetUrl, x, y); }, this.config.showPreviewOnHoverDelay); }); icon.addEventListener('mouseleave', () => clearTimeout(this.timers.showPreview)); } else { icon.addEventListener('click', () => { const { x, y } = getLinkCenter(); this.#sendPreviewMessage(this.icon.dataset.targetUrl, x, y); }); icon.addEventListener('mouseenter', () => clearTimeout(this.timers.hideIcon)); this.boundHideIcon = this.#hideLinkIcon.bind(this); icon.addEventListener('mouseleave', this.boundHideIcon); } this.icon = icon; document.body.appendChild(this.icon); } #hideLinkIcon() { this.timers.hideIcon = setTimeout( () => { this.icon.style.display = 'none'; clearTimeout(this.timers.showIcon); }, this.config.linkIconInteractionOnHover ? 300 : 600 ); } #getLinkElement(event) { return event.target.closest('a[href]:not([href="#"])'); } #sendPreviewMessage(url, x, y) { chrome.runtime.sendMessage({ url, fromPanel: this.fromPanel, origin: { x, y } }); } #callPreview(event) { let link = this.#getLinkElement(event); if (link) { event.preventDefault(); this.#sendPreviewMessage(link.href, event.clientX, event.clientY); } } #createIconStyle() { const style = document.createElement('style'); style.textContent = ` .link-icon { position: absolute; box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); cursor: pointer; z-index: 9999; transition: opacity 0.2s ease; } .link-icon:hover { opacity: 0.9; } `; document.head.appendChild(style); } debounce(fn, delay) { let timer = null; return (...args) => { clearTimeout(timer); timer = setTimeout(fn.bind(this, ...args), delay); }; } } class SearchEngineUtils { constructor(openLinkCallback, searchCallback, config = {}) { this.openLinkCallback = openLinkCallback; this.searchCallback = searchCallback; this.linkMenuTitle = config.linkMenuTitle; this.searchMenuTitle = config.searchMenuTitle; this.selectSearchMenuTitle = config.selectSearchMenuTitle; this.createdContextMenuMap = new Map(); this.searchEngineCollection = []; this.defaultSearchId = null; this.privateSearchId = null; this.LINK_ID = 'preview-window-link'; this.SEARCH_ID = 'search-preview-window'; this.SELECT_SEARCH_ID = 'select-search-preview-window'; this.#initialize(); } async #initialize() { this.#createContextMenuOption(); this.#updateSearchEnginesAndContextMenu(); vivaldi.searchEngines.onTemplateUrlsChanged.addListener(() => { this.#removeContextMenuSelectSearch(); this.#updateSearchEnginesAndContextMenu(); }); } #createContextMenuOption() { chrome.contextMenus.create({ id: this.LINK_ID, title: `${this.linkMenuTitle}`, contexts: ['link'] }); chrome.contextMenus.create({ id: this.SEARCH_ID, title: `${this.searchMenuTitle}`, contexts: ['selection'] }); chrome.contextMenus.create({ id: this.SELECT_SEARCH_ID, title: `${this.selectSearchMenuTitle}`, contexts: ['selection'] }); chrome.contextMenus.onClicked.addListener(itemInfo => { const { menuItemId, parentMenuItemId, linkUrl, selectionText } = itemInfo; if (menuItemId === this.LINK_ID) { this.openLinkCallback(linkUrl); } else if (menuItemId === this.SEARCH_ID) { const engineId = window.incognito ? this.privateSearchId : this.defaultSearchId; this.searchCallback(engineId, selectionText); } else if (parentMenuItemId === this.SELECT_SEARCH_ID) { const engineId = menuItemId.substr(parentMenuItemId.length); this.searchCallback(engineId, selectionText); } }); } async #updateSearchEnginesAndContextMenu() { const searchEngines = await vivaldi.searchEngines.getTemplateUrls(); this.searchEngineCollection = searchEngines.templateUrls; this.defaultSearchId = searchEngines.defaultSearch; this.privateSearchId = searchEngines.defaultPrivate; this.#createContextMenuSelectSearch(); } #removeContextMenuSelectSearch() { this.createdContextMenuMap.forEach((_, engineId) => { const menuId = this.SELECT_SEARCH_ID + engineId; chrome.contextMenus.remove(menuId); }); this.createdContextMenuMap.clear(); } #createContextMenuSelectSearch() { this.searchEngineCollection.forEach(engine => { if (!this.createdContextMenuMap.has(engine.guid)) { chrome.contextMenus.create({ id: this.SELECT_SEARCH_ID + engine.guid, parentId: this.SELECT_SEARCH_ID, title: engine.name, contexts: ['selection'] }); this.createdContextMenuMap.set(engine.guid, true); } }); } } class ProgressBar { static CLEAR_DELAY = 250; constructor(webviewId) { this.webviewId = webviewId; this.progress = 0; this.element = this.#createProgressBar(webviewId); this._raf = null; } #createProgressBar(webviewId) { const el = document.createElement('div'); el.className = 'progress-bar'; el.id = `progressBar-${webviewId}`; return el; } start() { this.element.style.visibility = 'visible'; this.element.classList.remove('is-complete'); this.progress = 0; this.element.style.width = '0%'; this.#animateTo(85); } #animateTo(target) { cancelAnimationFrame(this._raf); const step = () => { this.progress += (target - this.progress) * TIMING_CONFIG.progressEasing; this.element.style.width = `${this.progress.toFixed(2)}%`; if (this.progress < target - 0.5) { this._raf = requestAnimationFrame(step); } }; this._raf = requestAnimationFrame(step); } clear(loadStop = false) { cancelAnimationFrame(this._raf); this.element.classList.add('is-complete'); if (loadStop) { this.element.style.width = '100%'; setTimeout(() => { this.progress = 0; this.element.style.visibility = 'hidden'; this.element.style.width = '0%'; }, ProgressBar.CLEAR_DELAY); } } destroy() { cancelAnimationFrame(this._raf); this._raf = null; } } class IconUtils { static SVG = { readerView: '', newTab: '', backgroundTab: '', toggleInput: '', closeBtn: '', }; static VIVALDI_BUTTONS = [ { name: 'back', buttonName: 'Back', fallback: '' }, { name: 'forward', buttonName: 'Forward', fallback: '' }, { name: 'reload', buttonName: 'Reload', fallback: '' } ]; #initialized = false; #iconMap = new Map(); constructor() { this.#initializeStaticIcons(); } #initializeStaticIcons() { Object.entries(IconUtils.SVG).forEach(([key, value]) => { this.#iconMap.set(key, value); }); } #initializeVivaldiIcons() { if (this.#initialized) return; IconUtils.VIVALDI_BUTTONS.forEach(button => { this.#iconMap.set(button.name, this.#getVivaldiButton(button.buttonName, button.fallback)); }); this.#initialized = true; } #getVivaldiButton(buttonName, fallbackSVG) { const svg = document.querySelector(`.button-toolbar [data-name="${buttonName}"] svg`); return svg ? svg.cloneNode(true).outerHTML : fallbackSVG; } getIcon(name) { if (!this.#initialized && IconUtils.VIVALDI_BUTTONS.some(btn => btn.name === name)) { this.#initializeVivaldiIcons(); } return this.#iconMap.get(name) || ''; } get back() { return this.getIcon('back'); } get forward() { return this.getIcon('forward'); } get reload() { return this.getIcon('reload'); } get readerView() { return this.getIcon('readerView'); } get newTab() { return this.getIcon('newTab'); } get backgroundTab() { return this.getIcon('backgroundTab'); } get toggleInput() { return this.getIcon('toggleInput'); } get closeBtn() { return this.getIcon('closeBtn'); } } })();