Last active
November 12, 2024 08:12
-
-
Save schlomo/d68c66b6cf9e5d8a258e22c9bf31bf3f to your computer and use it in GitHub Desktop.
Click to dial UserScript to add phone icon to every phone number for easy dialing or copying the phone number
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Click to Dial Phone Icon | |
// @namespace https://schlomo.schapiro.org/ | |
// @version 2024.11.12.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 | |
(?:\+|0) # Start with + or 0 | |
(?: | |
# Separators, digits, and special dial characters w , * #, | |
# dial code must end on digit or # | |
[().\sw,*#\/-]*[\d#] | |
# 10-50 long to exclude typical dates, times and ranges but allow DTMF codes | |
){10,50} | |
(?!\d) # 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 ID_SUFFIX = Math.random().toString(36).substring(2, 8); | |
const PREFIX = 'click-to-dial'; | |
const PHONE_ICON_ELEMENT = `${PREFIX}-icon-${ID_SUFFIX}`; | |
const PHONE_NUMBER_CLASS = `${PREFIX}-number-${ID_SUFFIX}`; | |
const NO_PHONE_ICON_CLASS = `${PREFIX}-no-icon-${ID_SUFFIX}`; | |
const STYLE_ID = `${PREFIX}-style-${ID_SUFFIX}`; | |
const hasDebug = window.location.search.includes('debug') || | |
window.location.hash.includes('debug'); | |
const debug = hasDebug | |
? (...args) => console.log('[Click to Dial]', ...args) | |
: () => { }; | |
let processCount = 0; | |
// Define the phone icon element | |
class PhoneIcon extends HTMLElement { | |
constructor(phoneNumber) { | |
super(); | |
this.phoneNumber = phoneNumber; | |
this.textContent = 'β'; | |
this.title = `Call ${phoneNumber}`; | |
this.addEventListener('click', e => { | |
e.preventDefault(); | |
window.location.href = 'tel:' + this.phoneNumber; | |
}); | |
this.addEventListener('contextmenu', e => { | |
e.preventDefault(); | |
navigator.clipboard.writeText(this.phoneNumber) | |
.then(() => showCopiedTooltip(e, this.phoneNumber)) | |
.catch(err => { | |
console.error('Copy failed:', err); | |
showCopiedTooltip(e, 'Copy failed - browser denied clipboard access', true); | |
}); | |
}); | |
} | |
} | |
function injectStyles() { | |
if (document.getElementById(STYLE_ID)) return; | |
const style = document.createElement('style'); | |
style.id = STYLE_ID; | |
style.textContent = ` | |
${PHONE_ICON_ELEMENT} { | |
margin-right: 0.3em; | |
text-decoration: none !important; | |
color: inherit !important; | |
cursor: pointer !important; | |
} | |
@media print { | |
${PHONE_ICON_ELEMENT} { | |
display: none !important; | |
} | |
} | |
`; | |
document.head.appendChild(style); | |
} | |
function showCopiedTooltip(event, phoneNumberOrErrorMessage, isError = false) { | |
const tooltip = document.createElement('div'); | |
tooltip.className = NO_PHONE_ICON_CLASS; | |
tooltip.textContent = isError | |
? phoneNumberOrErrorMessage | |
: `Phone number ${phoneNumberOrErrorMessage} copied!`; | |
tooltip.style.cssText = ` | |
position: fixed; | |
left: ${event.clientX + 10}px; | |
top: ${event.clientY + 10}px; | |
background: ${isError ? '#d32f2f' : '#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 cleanNumber = phoneNumber.replace(/[^\d+]/g, '').replace('+490', '+49'); | |
return new PhoneIcon(cleanNumber); | |
} | |
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...'); | |
// Register the custom element | |
if (!customElements.get(PHONE_ICON_ELEMENT)) { | |
customElements.define(PHONE_ICON_ELEMENT, PhoneIcon); | |
} | |
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(); | |
} | |
})(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using only javascript, I want to add a hovering popup over any phone number in the +49 123 3475 3457 format that has a clickable link with tel:+4912334753457 and a phone icon to click on | |
it should match phone numbers with and without blanks, and with leading +49 (or country code) or starting with 0. Assume phone numbers are between 10 and 20 digits long, including the + | |
make the regex more precise to not match on non-phonenumbers, e.g. number sequences within other context. | |
the code should be useable as a userscript | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { PHONE_6546546_REGEX } from './click-to-dial.user.js'; | |
console.log('REGEX: ', PHONE_6546546_REGEX); | |
const testCases = { | |
shouldMatch: [ | |
'+1 (555) 123-4567', | |
'+44 20 7123 4567', | |
'0800 123 4567', | |
'012723476582734', | |
'0123456789012345678901234', | |
'+1.555.123.4567', | |
'+1/555/123/4567', | |
'+1-555-123-4567', | |
'+1 555 123 4567', | |
'+49 (0)30 123456', | |
'+86 10 6988 8888', | |
'+44.20.7123.4567', | |
'0044 20 7123 4567', | |
'+33 1 23 45 67 89', | |
'+81-3-1234-5678', | |
'+1 555 123 4567 w123', | |
'0800 123 4567w99', | |
'+44 20 7123 4567w1234w12', | |
'+1 (555)/123.4567', | |
'+44 (0)20-7123.4567', | |
'01234567890', | |
'+12345678901', | |
'0123456789012', | |
'+491234567952,,12345674359#,,,,*123449#' // DTMF | |
], | |
shouldNotMatch: [ | |
'31.12.2024', | |
'12/31/2024', | |
'2024-12-31', | |
'2024.01.31', | |
'31122024', | |
'15:09', | |
'15:09:30', | |
'15.09', | |
'15.09.30', | |
'1509', | |
'23.59.59', | |
'31.12.2024 15:09', | |
'2024-12-31T15:09:30', | |
'01/11/2024 15:09', | |
'15:09 UTC', | |
'31.03.-02.04.2025', | |
'31.03.2025 16:40 - 18:10', | |
'16:40 - 18:10', | |
'123456789', | |
'1234567', | |
'12345678901234567890123456', | |
'+123 abc 4567', | |
'abcd123456789' | |
] | |
}; | |
console.log('Running comprehensive tests with regex pattern...\n'); | |
let finalResult = true; | |
console.log("Should match:"); | |
testCases.shouldMatch.forEach(input => { | |
const matches = input.match(PHONE_6546546_REGEX); | |
const result = matches !== null && matches[0] === input; | |
finalResult = finalResult && result; | |
console.log(`${result ? 'β' : 'π'} ${input} ${result ? '' : '(' + matches + ')'}`); | |
if (!result) { | |
//throw new Error(`Test failed: ${input} should match but did not.`); | |
} | |
}); | |
console.log("\nShould not match:"); | |
testCases.shouldNotMatch.forEach(input => { | |
const matches = input.match(PHONE_6546546_REGEX); | |
const result = matches === null; | |
finalResult = finalResult && result; | |
console.log(`${result ? 'β' : 'π'} ${input} ${result ? '' : '(' + matches + ')'}`); | |
if (!result) { | |
//throw new Error(`Test failed: ${input} should not match but did.`); | |
} | |
}); | |
console.log(`\n${finalResult ? 'β ' : 'β'} Final result: ${finalResult ? 'All tests passed successfully.' : 'Some tests failed.'}`); | |
// Run the tests | |
process.exitCode = finalResult ? 0 : 1; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment