Click to Dial Phone Icon

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

当前为 2024-11-01 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Click to Dial Phone Icon
  3. // @namespace https://schlomo.schapiro.org/
  4. // @version 2024.11.01.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 soft phone (what
  17. happens is the same as clicking on a tel: link), right click to copy the phone number to the clipboard.
  18.  
  19. My motivation for writing this was to have a solution that I can trust because the code is simple enough to read.
  20.  
  21. Copyright 2024 Schlomo Schapiro
  22.  
  23. Licensed under the Apache License, Version 2.0 (the "License");
  24. you may not use this file except in compliance with the License.
  25. You may obtain a copy of the License at
  26.  
  27. http://www.apache.org/licenses/LICENSE-2.0
  28.  
  29. Unless required by applicable law or agreed to in writing, software
  30. distributed under the License is distributed on an "AS IS" BASIS,
  31. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  32. See the License for the specific language governing permissions and
  33. limitations under the License.
  34.  
  35. */
  36.  
  37. (() => {
  38.  
  39. // add debug to URL query or hash to activate debug output in console
  40. const hasDebug = window.location.search.includes('debug') ||
  41. window.location.hash.includes('debug');
  42. const debug = hasDebug
  43. ? (...args) => console.log('[Click to Dial]', ...args)
  44. : () => { };
  45.  
  46. const ID_SUFFIX = Math.random().toString(36).substring(2, 8);
  47. const PREFIX = 'click-to-dial';
  48. const PHONE_ICON_CLASS = `${PREFIX}-icon-${ID_SUFFIX}`;
  49. const PHONE_NUMBER_CLASS = `${PREFIX}-number-${ID_SUFFIX}`;
  50. const NO_PHONE_ICON_CLASS = `${PREFIX}-no-icon-${ID_SUFFIX}`;
  51. const PHONE_REGEX = new RegExp(String.raw`
  52. (?<!\d) # Negative lookbehind: not preceded by a digit
  53. (?! # Negative lookahead: don't match timestamps
  54. (?:
  55. \d{1,2} # Hours: 1-2 digits
  56. [:.] # Separator (colon or dot)
  57. (?:
  58. \d{2} # Minutes only (HH:MM)
  59. | # OR
  60. \d{2}[:.]\d{2} # Minutes and seconds (HH:MM:SS)
  61. )
  62. )
  63. )
  64. (?:\+|0) # Must start with + or 0
  65. (?:
  66. [().\s-\/]*\d # Digits with optional separators
  67. | # OR
  68. w # 'w' character for wait/pause
  69. ){9,25} # Length: 9-25 digits
  70. (?!\d) # Negative lookahead: not followed by a digit
  71. `.replace(/\s+#.+/g, '') // Remove comments
  72. .replace(/\s+/g, ''), // Remove whitespace
  73. 'g' // Global flag
  74. );
  75.  
  76. let processCount = 0;
  77.  
  78. function injectStyles() {
  79. const styleId = `${PREFIX}-style-${ID_SUFFIX}`;
  80. if (document.getElementById(styleId)) return;
  81.  
  82. const style = document.createElement('style');
  83. style.id = styleId;
  84. style.textContent = `
  85. .${PHONE_ICON_CLASS} {
  86. display: inline-block;
  87. margin-right: 0.3em;
  88. text-decoration: none !important;
  89. cursor: pointer;
  90. }
  91. `;
  92. document.head.appendChild(style);
  93. }
  94.  
  95. function showCopiedTooltip(event, phoneNumber) {
  96. const tooltip = document.createElement('div');
  97. tooltip.className = NO_PHONE_ICON_CLASS;
  98. tooltip.textContent = `Phone number ${phoneNumber} copied!`;
  99. tooltip.style.cssText = `
  100. position: fixed;
  101. left: ${event.clientX + 10}px;
  102. top: ${event.clientY + 10}px;
  103. background: #333;
  104. color: white;
  105. padding: 5px 10px;
  106. border-radius: 4px;
  107. font-size: 12px;
  108. z-index: 10000;
  109. pointer-events: none;
  110. opacity: 0;
  111. transition: opacity 0.2s;
  112. `;
  113. document.body.appendChild(tooltip);
  114. requestAnimationFrame(() => {
  115. tooltip.style.opacity = '1';
  116. setTimeout(() => {
  117. tooltip.style.opacity = '0';
  118. setTimeout(() => tooltip.remove(), 200);
  119. }, 1000);
  120. });
  121. }
  122.  
  123. function createPhoneIcon(phoneNumber) {
  124. const icon = document.createElement('a');
  125. icon.className = PHONE_ICON_CLASS;
  126. const cleanNumber = phoneNumber.replace(/[^\d+]/g, '').replace('+490', '+49');
  127. icon.href = 'tel:' + cleanNumber;
  128. icon.textContent = '☎';
  129.  
  130. icon.addEventListener('click', (event) => {
  131. event.preventDefault();
  132. event.stopPropagation();
  133. window.location.href = icon.href;
  134. return false;
  135. });
  136.  
  137. icon.addEventListener('contextmenu', (event) => {
  138. event.preventDefault();
  139. event.stopPropagation();
  140. navigator.clipboard.writeText(cleanNumber).then(() => showCopiedTooltip(event, cleanNumber));
  141. return false;
  142. });
  143.  
  144. return icon;
  145. }
  146.  
  147. function isEditable(element) {
  148. if (!element) return false;
  149. if (element.getAttribute('contenteditable') === 'false') return false;
  150.  
  151. return element.isContentEditable ||
  152. (element.tagName === 'INPUT' && !['button', 'submit', 'reset', 'hidden'].includes(element.type?.toLowerCase())) ||
  153. element.tagName === 'TEXTAREA' ||
  154. (element.getAttribute('role') === 'textbox' && element.getAttribute('contenteditable') !== 'false') ||
  155. element.getAttribute('contenteditable') === 'true' ||
  156. element.classList?.contains('ql-editor') ||
  157. element.classList?.contains('cke_editable') ||
  158. element.classList?.contains('tox-edit-area') ||
  159. element.classList?.contains('kix-page-content-wrapper') ||
  160. element.classList?.contains('waffle-content-pane');
  161. }
  162.  
  163. function processTextNode(node) {
  164. const matches = Array.from(node.textContent.matchAll(PHONE_REGEX));
  165. if (!matches.length) return 0;
  166.  
  167. debug('Found numbers: ',
  168. matches.map(m => m[0]).join(', ')
  169. );
  170.  
  171. const fragment = document.createDocumentFragment();
  172. let lastIndex = 0;
  173.  
  174. matches.forEach(match => {
  175. if (match.index > lastIndex) {
  176. fragment.appendChild(document.createTextNode(node.textContent.slice(lastIndex, match.index)));
  177. }
  178.  
  179. const span = document.createElement('span');
  180. span.className = PHONE_NUMBER_CLASS;
  181.  
  182. const icon = createPhoneIcon(match[0]);
  183. span.appendChild(icon);
  184. span.appendChild(document.createTextNode(match[0]));
  185. fragment.appendChild(span);
  186.  
  187. lastIndex = match.index + match[0].length;
  188. });
  189.  
  190. if (lastIndex < node.textContent.length) {
  191. fragment.appendChild(document.createTextNode(node.textContent.slice(lastIndex)));
  192. }
  193.  
  194. node.parentNode.replaceChild(fragment, node);
  195. return matches.length;
  196. }
  197.  
  198. function traverseDOM(node) {
  199. let nodesProcessed = 0;
  200. let phoneNumbersFound = 0;
  201.  
  202. function traverse(node) {
  203. if (node.nodeType === Node.TEXT_NODE &&
  204. !node.parentElement.classList.contains(PHONE_NUMBER_CLASS) &&
  205. !node.parentElement.querySelector(`.${PHONE_ICON_CLASS}`) &&
  206. !node.parentElement.classList.contains(NO_PHONE_ICON_CLASS)) { // Exclude elements with the tooltip class
  207. nodesProcessed++;
  208. phoneNumbersFound += processTextNode(node);
  209. } else if (node.nodeType === Node.ELEMENT_NODE && !isEditable(node)) {
  210. for (let child = node.firstChild; child; child = child.nextSibling) {
  211. traverse(child);
  212. }
  213. }
  214. }
  215.  
  216. traverse(node);
  217.  
  218. return { nodesProcessed, phoneNumbersFound };
  219. }
  220.  
  221. function processPhoneNumbers() {
  222. const startTime = performance.now();
  223. const { nodesProcessed, phoneNumbersFound } = traverseDOM(document.body);
  224.  
  225. if (phoneNumbersFound > 0) {
  226. const duration = performance.now() - startTime;
  227. debug(
  228. `Phone number processing #${++processCount}:`,
  229. `${phoneNumbersFound} numbers in ${nodesProcessed} nodes, ${duration.toFixed(1)}ms`
  230. );
  231. }
  232. }
  233.  
  234. function debounce(func, wait) {
  235. let timeout;
  236. return function (...args) {
  237. clearTimeout(timeout);
  238. timeout = setTimeout(() => func.apply(this, args), wait);
  239. };
  240. }
  241.  
  242. document.addEventListener('DOMContentLoaded', () => {
  243. injectStyles();
  244. const debouncedProcessPhoneNumbers = debounce(processPhoneNumbers, 300);
  245.  
  246. debouncedProcessPhoneNumbers();
  247.  
  248. new MutationObserver(mutations => {
  249. if (mutations.some(m =>
  250. m.type === 'childList' ||
  251. (m.type === 'attributes' && ['contenteditable', 'role', 'class'].includes(m.attributeName))
  252. )) {
  253. debouncedProcessPhoneNumbers();
  254. }
  255. }).observe(document.body, {
  256. childList: true,
  257. subtree: true,
  258. attributes: true,
  259. attributeFilter: ['contenteditable', 'role', 'class']
  260. });
  261.  
  262. document.addEventListener('focus', debouncedProcessPhoneNumbers, true);
  263.  
  264. // Reprocess periodically to catch any missed phone numbers
  265. setInterval(debouncedProcessPhoneNumbers, 5000);
  266. });
  267. })();
  268.