您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Add phone icon to phone numbers for dialing (click) and copying (context menu / right click)
当前为
// ==UserScript== // @name Click to Dial Phone Icon // @namespace https://schlomo.schapiro.org/ // @version 2024.11.05.01 // @description Add phone icon to phone numbers for dialing (click) and copying (context menu / right click) // @author Schlomo Schapiro // @match *://*/* // @grant none // @run-at document-start // @homepageURL https://schlomo.schapiro.org/ // @icon https://gist.githubusercontent.com/schlomo/d68c66b6cf9e5d8a258e22c9bf31bf3f/raw/~click-to-dial.svg // ==/UserScript== /** This script adds a phone icon before every phone number found. Click to dial via your local soft phone (whathappens is the same as clicking on a tel: link), right click to copy the phone number to the clipboard. My motivation for writing this was to have a solution that I can trust because the code is simple enough to read. And to find out how well I can write this with the help of AI 😁 Add debug to URL query or hash to activate debug output in console Copyright 2024 Schlomo Schapiro Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ const PHONE_6546546_REGEX = new RegExp(String.raw` (?<!\d) # Negative lookbehind: not preceded by a digit (?! # Negative lookahead for dates/times (?: # Date patterns (DD/MM/YYYY, YYYY-MM-DD, etc) (?:\d{1,4}[-./]\d{1,2}[-./]\d{1,4}) | # Time patterns (HH:MM, HH:MM:SS) (?:\d{1,2}[:.](?:\d{2}|\d{2}[:]\d{2})) ) ) # Phone number pattern (?: (?:\+|0) # Must start with + or 0 (?: [().\s-\/]*\d # Digits with optional separators | w # 'w' character for wait/pause ){9,25} # Length: 9-25 digits ) (?!\d) # Negative lookahead: not followed by a digit `.replace(/\s+#.+/g, '') // Remove comments .replace(/\s+/g, ''), // Remove whitespace 'g' // Global flag ); // export for testing if (typeof module !== 'undefined' && module.exports) { module.exports = { PHONE_6546546_REGEX }; } (() => { // Exit early if not running in a browser environment if (typeof window === 'undefined' || typeof document === 'undefined') { return; } const hasDebug = window.location.search.includes('debug') || window.location.hash.includes('debug'); const debug = hasDebug ? (...args) => console.log('[Click to Dial]', ...args) : () => { }; const ID_SUFFIX = Math.random().toString(36).substring(2, 8); const PREFIX = 'click-to-dial'; const PHONE_ICON_CLASS = `${PREFIX}-icon-${ID_SUFFIX}`; const PHONE_NUMBER_CLASS = `${PREFIX}-number-${ID_SUFFIX}`; const NO_PHONE_ICON_CLASS = `${PREFIX}-no-icon-${ID_SUFFIX}`; let processCount = 0; function injectStyles() { const styleId = `${PREFIX}-style-${ID_SUFFIX}`; if (document.getElementById(styleId)) return; const style = document.createElement('style'); style.id = styleId; style.textContent = ` .${PHONE_ICON_CLASS} { display: inline-block; margin-right: 0.3em; text-decoration: none !important; cursor: pointer; } `; document.head.appendChild(style); } function showCopiedTooltip(event, phoneNumber) { const tooltip = document.createElement('div'); tooltip.className = NO_PHONE_ICON_CLASS; tooltip.textContent = `Phone number ${phoneNumber} copied!`; tooltip.style.cssText = ` position: fixed; left: ${event.clientX + 10}px; top: ${event.clientY + 10}px; background: #333; color: white; padding: 5px 10px; border-radius: 4px; font-size: 12px; z-index: 10000; pointer-events: none; opacity: 0; transition: opacity 0.2s; `; document.body.appendChild(tooltip); requestAnimationFrame(() => { tooltip.style.opacity = '1'; setTimeout(() => { tooltip.style.opacity = '0'; setTimeout(() => tooltip.remove(), 200); }, 1000); }); } function createPhoneIcon(phoneNumber) { const icon = document.createElement('a'); icon.className = PHONE_ICON_CLASS; const cleanNumber = phoneNumber.replace(/[^\d+]/g, '').replace('+490', '+49'); icon.href = 'tel:' + cleanNumber; icon.textContent = '☎'; icon.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); window.location.href = icon.href; return false; }); icon.addEventListener('contextmenu', (event) => { event.preventDefault(); event.stopPropagation(); navigator.clipboard.writeText(cleanNumber).then(() => showCopiedTooltip(event, cleanNumber)); return false; }); return icon; } function isEditable(element) { if (!element) return false; if (element.getAttribute('contenteditable') === 'false') return false; return element.isContentEditable || (element.tagName === 'INPUT' && !['button', 'submit', 'reset', 'hidden'].includes(element.type?.toLowerCase())) || element.tagName === 'TEXTAREA' || (element.getAttribute('role') === 'textbox' && element.getAttribute('contenteditable') !== 'false') || element.getAttribute('contenteditable') === 'true' || element.classList?.contains('ql-editor') || element.classList?.contains('cke_editable') || element.classList?.contains('tox-edit-area') || element.classList?.contains('kix-page-content-wrapper') || element.classList?.contains('waffle-content-pane'); } function processTextNode(node) { PHONE_6546546_REGEX.lastIndex = 0; // reset regex to always match from the beginning const matches = Array.from(node.textContent.matchAll(PHONE_6546546_REGEX)); if (!matches.length) return 0; /* debug('Found numbers: ', matches.map(m => m[0]).join(', ') ); */ const fragment = document.createDocumentFragment(); let lastIndex = 0; matches.forEach(match => { if (match.index > lastIndex) { fragment.appendChild(document.createTextNode(node.textContent.slice(lastIndex, match.index))); } const span = document.createElement('span'); span.className = PHONE_NUMBER_CLASS; const icon = createPhoneIcon(match[0]); span.appendChild(icon); span.appendChild(document.createTextNode(match[0])); fragment.appendChild(span); lastIndex = match.index + match[0].length; }); if (lastIndex < node.textContent.length) { fragment.appendChild(document.createTextNode(node.textContent.slice(lastIndex))); } node.parentNode.replaceChild(fragment, node); return matches.length; } function traverseDOM(node) { let nodesProcessed = 0; let phoneNumbersFound = 0; // Get all text nodes const textNodes = document.evaluate( './/text()', node, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null ); // Process each text node for (let i = 0; i < textNodes.snapshotLength; i++) { const textNode = textNodes.snapshotItem(i); const parent = textNode.parentElement; // Skip empty or very short text nodes if (!textNode.textContent || textNode.textContent.trim().length < 5) { continue; } // Skip if already processed or in excluded content if (!parent || parent.closest('script, style') || isEditable(parent.closest('[contenteditable], textarea, input')) || // Check editability of closest editable ancestor isEditable(parent) || parent.classList?.contains(PHONE_NUMBER_CLASS) || parent.classList?.contains(NO_PHONE_ICON_CLASS) || textNode.parentNode.closest(`.${PHONE_NUMBER_CLASS}`)) { continue; } nodesProcessed++; phoneNumbersFound += processTextNode(textNode); } return { nodesProcessed, phoneNumbersFound }; } function processPhoneNumbers() { const startTime = performance.now(); const { nodesProcessed, phoneNumbersFound } = traverseDOM(document.body); if (phoneNumbersFound > 0) { const duration = performance.now() - startTime; debug( `Phone number processing #${++processCount}:`, `${phoneNumbersFound} numbers in ${nodesProcessed} nodes, ${duration.toFixed(1)}ms` ); } } function initialize() { if (!document.body) { debug('Body not ready, waiting...'); requestAnimationFrame(initialize); return; } debug('Initializing...'); injectStyles(); // Initial processing with slight delay to let the page settle requestAnimationFrame(processPhoneNumbers); // Set up observer for dynamic content const observer = new MutationObserver(mutations => { // Early exit if no actual changes if (!mutations.length) return; // Check if we have any relevant changes const hasRelevantChanges = mutations.some(mutation => { // Quick check for added nodes if (mutation.addedNodes.length > 0) { // Only process if added nodes contain text or elements return Array.from(mutation.addedNodes).some(node => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && !['SCRIPT', 'STYLE', 'META', 'LINK'].includes(node.tagName)) ); } // Check attribute changes only on elements that could matter if (mutation.type === 'attributes') { const target = mutation.target; return target.nodeType === Node.ELEMENT_NODE && !['SCRIPT', 'STYLE', 'META', 'LINK'].includes(target.tagName) && ['contenteditable', 'role', 'class'].includes(mutation.attributeName); } return false; }); if (hasRelevantChanges) { // Debounce multiple rapid changes if (!initialize.pendingUpdate) { initialize.pendingUpdate = true; requestAnimationFrame(() => { processPhoneNumbers(); initialize.pendingUpdate = false; }); } } }); try { observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['contenteditable', 'role', 'class'] }); } catch (e) { debug('Failed to set up observer:', e); } } // Start initialization process depending on document state if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initialize); } else { initialize(); } })();