Click to Dial Phone Icon

Add phone icon to phone numbers for dialing (click) and copying (context menu / right click)

  1. // ==UserScript==
  2. // @name Click to Dial Phone Icon
  3. // @namespace https://schlomo.schapiro.org/
  4. // @version 2024.11.12.01
  5. // @description Add phone icon to phone numbers for dialing (click) and copying (context menu / right click)
  6. // @author Schlomo Schapiro
  7. // @match *://*/*
  8. // @grant none
  9. // @run-at document-start
  10. // @homepageURL https://schlomo.schapiro.org/
  11. // @icon https://gist.githubusercontent.com/schlomo/d68c66b6cf9e5d8a258e22c9bf31bf3f/raw/~click-to-dial.svg
  12. // ==/UserScript==
  13.  
  14. /**
  15.  
  16. This script adds a phone icon before every phone number found. Click to dial via your local
  17. soft phone (whathappens is the same as clicking on a tel: link), right click to copy the
  18. phone number to the clipboard.
  19.  
  20. My motivation for writing this was to have a solution that I can trust because the code
  21. is simple enough to read. And to find out how well I can write this with the help of AI 😁
  22.  
  23. Add debug to URL query or hash to activate debug output in console
  24.  
  25. Copyright 2024 Schlomo Schapiro
  26.  
  27. Licensed under the Apache License, Version 2.0 (the "License");
  28. you may not use this file except in compliance with the License.
  29. You may obtain a copy of the License at
  30.  
  31. http://www.apache.org/licenses/LICENSE-2.0
  32.  
  33. Unless required by applicable law or agreed to in writing, software
  34. distributed under the License is distributed on an "AS IS" BASIS,
  35. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  36. See the License for the specific language governing permissions and
  37. limitations under the License.
  38.  
  39. */
  40.  
  41. const PHONE_6546546_REGEX = new RegExp(String.raw`
  42. (?<!\d) # Negative lookbehind: not preceded by a digit
  43.  
  44. (?:\+|0) # Start with + or 0
  45. (?:
  46. # Separators, digits, and special dial characters w , * #,
  47. # dial code must end on digit or #
  48. [().\sw,*#\/-]*[\d#]
  49. # 10-50 long to exclude typical dates, times and ranges but allow DTMF codes
  50. ){10,50}
  51. (?!\d) # Not followed by a digit
  52. `.replace(/\s+#.+/g, '') // Remove comments
  53. .replace(/\s+/g, ''), // Remove whitespace
  54. 'g' // Global flag
  55. );
  56.  
  57. // export for testing
  58. if (typeof module !== 'undefined' && module.exports) {
  59. module.exports = { PHONE_6546546_REGEX };
  60. }
  61.  
  62. (() => {
  63.  
  64. // Exit early if not running in a browser environment
  65. if (typeof window === 'undefined' || typeof document === 'undefined') {
  66. return;
  67. }
  68.  
  69. const ID_SUFFIX = Math.random().toString(36).substring(2, 8);
  70. const PREFIX = 'click-to-dial';
  71. const PHONE_ICON_ELEMENT = `${PREFIX}-icon-${ID_SUFFIX}`;
  72. const PHONE_NUMBER_CLASS = `${PREFIX}-number-${ID_SUFFIX}`;
  73. const NO_PHONE_ICON_CLASS = `${PREFIX}-no-icon-${ID_SUFFIX}`;
  74. const STYLE_ID = `${PREFIX}-style-${ID_SUFFIX}`;
  75.  
  76. const hasDebug = window.location.search.includes('debug') ||
  77. window.location.hash.includes('debug');
  78. const debug = hasDebug
  79. ? (...args) => console.log('[Click to Dial]', ...args)
  80. : () => { };
  81.  
  82. let processCount = 0;
  83.  
  84. // Define the phone icon element
  85. class PhoneIcon extends HTMLElement {
  86. constructor(phoneNumber) {
  87. super();
  88. this.phoneNumber = phoneNumber;
  89. this.textContent = '☎';
  90. this.title = `Call ${phoneNumber}`;
  91.  
  92. this.addEventListener('click', e => {
  93. e.preventDefault();
  94. window.location.href = 'tel:' + this.phoneNumber;
  95. });
  96.  
  97. this.addEventListener('contextmenu', e => {
  98. e.preventDefault();
  99. navigator.clipboard.writeText(this.phoneNumber)
  100. .then(() => showCopiedTooltip(e, this.phoneNumber))
  101. .catch(err => {
  102. console.error('Copy failed:', err);
  103. showCopiedTooltip(e, 'Copy failed - browser denied clipboard access', true);
  104. });
  105. });
  106. }
  107. }
  108.  
  109. function injectStyles() {
  110. if (document.getElementById(STYLE_ID)) return;
  111.  
  112. const style = document.createElement('style');
  113. style.id = STYLE_ID;
  114. style.textContent = `
  115. ${PHONE_ICON_ELEMENT} {
  116. margin-right: 0.3em;
  117. text-decoration: none !important;
  118. color: inherit !important;
  119. cursor: pointer !important;
  120. }
  121. @media print {
  122. ${PHONE_ICON_ELEMENT} {
  123. display: none !important;
  124. }
  125. }
  126. `;
  127. document.head.appendChild(style);
  128. }
  129.  
  130. function showCopiedTooltip(event, phoneNumberOrErrorMessage, isError = false) {
  131. const tooltip = document.createElement('div');
  132. tooltip.className = NO_PHONE_ICON_CLASS;
  133. tooltip.textContent = isError
  134. ? phoneNumberOrErrorMessage
  135. : `Phone number ${phoneNumberOrErrorMessage} copied!`;
  136. tooltip.style.cssText = `
  137. position: fixed;
  138. left: ${event.clientX + 10}px;
  139. top: ${event.clientY + 10}px;
  140. background: ${isError ? '#d32f2f' : '#333'};
  141. color: white;
  142. padding: 5px 10px;
  143. border-radius: 4px;
  144. font-size: 12px;
  145. z-index: 10000;
  146. pointer-events: none;
  147. opacity: 0;
  148. transition: opacity 0.2s;
  149. `;
  150. document.body.appendChild(tooltip);
  151. requestAnimationFrame(() => {
  152. tooltip.style.opacity = '1';
  153. setTimeout(() => {
  154. tooltip.style.opacity = '0';
  155. setTimeout(() => tooltip.remove(), 200);
  156. }, 1000);
  157. });
  158. }
  159.  
  160. function createPhoneIcon(phoneNumber) {
  161. const cleanNumber = phoneNumber.replace(/[^\d+]/g, '').replace('+490', '+49');
  162. return new PhoneIcon(cleanNumber);
  163. }
  164.  
  165. function isEditable(element) {
  166. if (!element) return false;
  167. if (element.getAttribute('contenteditable') === 'false') return false;
  168.  
  169. return element.isContentEditable ||
  170. (element.tagName === 'INPUT' && !['button', 'submit', 'reset', 'hidden'].includes(element.type?.toLowerCase())) ||
  171. element.tagName === 'TEXTAREA' ||
  172. (element.getAttribute('role') === 'textbox' && element.getAttribute('contenteditable') !== 'false') ||
  173. element.getAttribute('contenteditable') === 'true' ||
  174. element.classList?.contains('ql-editor') ||
  175. element.classList?.contains('cke_editable') ||
  176. element.classList?.contains('tox-edit-area') ||
  177. element.classList?.contains('kix-page-content-wrapper') ||
  178. element.classList?.contains('waffle-content-pane');
  179. }
  180.  
  181. function processTextNode(node) {
  182. PHONE_6546546_REGEX.lastIndex = 0; // reset regex to always match from the beginning
  183. const matches = Array.from(node.textContent.matchAll(PHONE_6546546_REGEX));
  184. if (!matches.length) return 0;
  185. /*
  186. debug('Found numbers: ',
  187. matches.map(m => m[0]).join(', ')
  188. );
  189. */
  190. const fragment = document.createDocumentFragment();
  191. let lastIndex = 0;
  192.  
  193. matches.forEach(match => {
  194. if (match.index > lastIndex) {
  195. fragment.appendChild(document.createTextNode(node.textContent.slice(lastIndex, match.index)));
  196. }
  197.  
  198. const span = document.createElement('span');
  199. span.className = PHONE_NUMBER_CLASS;
  200.  
  201. const icon = createPhoneIcon(match[0]);
  202. span.appendChild(icon);
  203. span.appendChild(document.createTextNode(match[0]));
  204. fragment.appendChild(span);
  205.  
  206. lastIndex = match.index + match[0].length;
  207. });
  208.  
  209. if (lastIndex < node.textContent.length) {
  210. fragment.appendChild(document.createTextNode(node.textContent.slice(lastIndex)));
  211. }
  212.  
  213. node.parentNode.replaceChild(fragment, node);
  214. return matches.length;
  215. }
  216.  
  217. function traverseDOM(node) {
  218. let nodesProcessed = 0;
  219. let phoneNumbersFound = 0;
  220.  
  221. // Get all text nodes
  222. const textNodes = document.evaluate(
  223. './/text()',
  224. node,
  225. null,
  226. XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
  227. null
  228. );
  229.  
  230. // Process each text node
  231. for (let i = 0; i < textNodes.snapshotLength; i++) {
  232. const textNode = textNodes.snapshotItem(i);
  233. const parent = textNode.parentElement;
  234.  
  235. // Skip empty or very short text nodes
  236. if (!textNode.textContent || textNode.textContent.trim().length < 5) {
  237. continue;
  238. }
  239.  
  240. // Skip if already processed or in excluded content
  241. if (!parent ||
  242. parent.closest('script, style') ||
  243. isEditable(parent.closest('[contenteditable], textarea, input')) || // Check editability of closest editable ancestor
  244. isEditable(parent) ||
  245. parent.classList?.contains(PHONE_NUMBER_CLASS) ||
  246. parent.classList?.contains(NO_PHONE_ICON_CLASS) ||
  247. textNode.parentNode.closest(`.${PHONE_NUMBER_CLASS}`)) {
  248. continue;
  249. }
  250.  
  251. nodesProcessed++;
  252. phoneNumbersFound += processTextNode(textNode);
  253. }
  254.  
  255. return { nodesProcessed, phoneNumbersFound };
  256. }
  257.  
  258. function processPhoneNumbers() {
  259. const startTime = performance.now();
  260. const { nodesProcessed, phoneNumbersFound } = traverseDOM(document.body);
  261.  
  262. if (phoneNumbersFound > 0) {
  263. const duration = performance.now() - startTime;
  264. debug(
  265. `Phone number processing #${++processCount}:`,
  266. `${phoneNumbersFound} numbers in ${nodesProcessed} nodes, ${duration.toFixed(1)}ms`
  267. );
  268. }
  269. }
  270.  
  271. function initialize() {
  272. if (!document.body) {
  273. debug('Body not ready, waiting...');
  274. requestAnimationFrame(initialize);
  275. return;
  276. }
  277.  
  278. debug('Initializing...');
  279.  
  280. // Register the custom element
  281. if (!customElements.get(PHONE_ICON_ELEMENT)) {
  282. customElements.define(PHONE_ICON_ELEMENT, PhoneIcon);
  283. }
  284.  
  285. injectStyles();
  286.  
  287. // Initial processing with slight delay to let the page settle
  288. requestAnimationFrame(processPhoneNumbers);
  289.  
  290. // Set up observer for dynamic content
  291. const observer = new MutationObserver(mutations => {
  292. // Early exit if no actual changes
  293. if (!mutations.length) return;
  294.  
  295. // Check if we have any relevant changes
  296. const hasRelevantChanges = mutations.some(mutation => {
  297. // Quick check for added nodes
  298. if (mutation.addedNodes.length > 0) {
  299. // Only process if added nodes contain text or elements
  300. return Array.from(mutation.addedNodes).some(node =>
  301. node.nodeType === Node.TEXT_NODE ||
  302. (node.nodeType === Node.ELEMENT_NODE &&
  303. !['SCRIPT', 'STYLE', 'META', 'LINK'].includes(node.tagName))
  304. );
  305. }
  306.  
  307. // Check attribute changes only on elements that could matter
  308. if (mutation.type === 'attributes') {
  309. const target = mutation.target;
  310. return target.nodeType === Node.ELEMENT_NODE &&
  311. !['SCRIPT', 'STYLE', 'META', 'LINK'].includes(target.tagName) &&
  312. ['contenteditable', 'role', 'class'].includes(mutation.attributeName);
  313. }
  314.  
  315. return false;
  316. });
  317.  
  318. if (hasRelevantChanges) {
  319. // Debounce multiple rapid changes
  320. if (!initialize.pendingUpdate) {
  321. initialize.pendingUpdate = true;
  322. requestAnimationFrame(() => {
  323. processPhoneNumbers();
  324. initialize.pendingUpdate = false;
  325. });
  326. }
  327. }
  328. });
  329.  
  330. try {
  331. observer.observe(document.body, {
  332. childList: true,
  333. subtree: true,
  334. attributes: true,
  335. attributeFilter: ['contenteditable', 'role', 'class']
  336. });
  337. } catch (e) {
  338. debug('Failed to set up observer:', e);
  339. }
  340. }
  341.  
  342. // Start initialization process depending on document state
  343. if (document.readyState === 'loading') {
  344. document.addEventListener('DOMContentLoaded', initialize);
  345. } else {
  346. initialize();
  347. }
  348. })();