Click-to-Dial

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

目前為 2024-10-31 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Click-to-Dial
// @namespace    https://schlomo.schapiro.org/
// @version      1.0
// @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 (what
happens 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.

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 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}`;
    const PHONE_REGEX = new RegExp(String.raw`
        (?<!\d)                     # Negative lookbehind: not preceded by a digit
        
        (?!                         # Negative lookahead: don't match timestamps
            (?:
                \d{1,2}             # Hours: 1-2 digits
                [:.]                # Separator (colon or dot)
                (?:
                    \d{2}           # Minutes only (HH:MM)
                    |               # OR
                    \d{2}[:.]\d{2}  # Minutes and seconds (HH:MM:SS)
                )
            )
        )
        
        (?:\+|0)                    # Must start with + or 0
        
        (?:
            [().\s-\/]*\d           # Digits with optional separators
            |                       # OR
            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
    );

    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; // Add the dynamically generated class to the tooltip
        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) {
        const matches = Array.from(node.textContent.matchAll(PHONE_REGEX));
        if (!matches.length) return 0;

        console.log('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;

        function traverse(node) {
            if (node.nodeType === Node.TEXT_NODE &&
                !node.parentElement.classList.contains(PHONE_NUMBER_CLASS) &&
                !node.parentElement.querySelector(`.${PHONE_ICON_CLASS}`) &&
                !node.parentElement.classList.contains(NO_PHONE_ICON_CLASS)) { // Exclude elements with the tooltip class
                nodesProcessed++;
                phoneNumbersFound += processTextNode(node);
            } else if (node.nodeType === Node.ELEMENT_NODE && !isEditable(node)) {
                for (let child = node.firstChild; child; child = child.nextSibling) {
                    traverse(child);
                }
            }
        }

        traverse(node);

        return { nodesProcessed, phoneNumbersFound };
    }

    function processPhoneNumbers() {
        const startTime = performance.now();
        const { nodesProcessed, phoneNumbersFound } = traverseDOM(document.body);

        if (phoneNumbersFound > 0) {
            const duration = performance.now() - startTime;
            console.log(
                `Phone number processing #${++processCount}:`,
                `${phoneNumbersFound} numbers in ${nodesProcessed} nodes, ${duration.toFixed(1)}ms`
            );
        }

        // Reprocess to catch any missed phone numbers
        if (phoneNumbersFound > 0) {
            setTimeout(() => processPhoneNumbers(), 100);
        }
    }

    function debounce(func, wait) {
        let timeout;
        return function (...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    document.addEventListener('DOMContentLoaded', () => {
        injectStyles();
        const debouncedProcessPhoneNumbers = debounce(processPhoneNumbers, 300);

        debouncedProcessPhoneNumbers();

        new MutationObserver(mutations => {
            if (mutations.some(m =>
                m.type === 'childList' ||
                (m.type === 'attributes' && ['contenteditable', 'role', 'class'].includes(m.attributeName))
            )) {
                debouncedProcessPhoneNumbers();
            }
        }).observe(document.body, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['contenteditable', 'role', 'class']
        });

        document.addEventListener('focus', debouncedProcessPhoneNumbers, true);

        // Reprocess periodically to catch any missed phone numbers
        setInterval(debouncedProcessPhoneNumbers, 5000);
    });
})();