TikTok Clickable Plox

Hace clicables con rueda del raton videos relacionados (miniatura y título) en TikTok Desktop y añade toggle para abrir en nueva pestaña al darles click normal.

// ==UserScript==
// @name         TikTok Clickable Plox
// @namespace    tiktok-clickable-plox
// @description  Hace clicables con rueda del raton videos relacionados (miniatura y título) en TikTok Desktop y añade toggle para abrir en nueva pestaña al darles click normal.
// @version      0.0.2
// @author       Alplox
// @match        https://www.tiktok.com/*
// @icon         https://www.tiktok.com/favicon.ico
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @run-at       document-end
// @license      MIT
// @homepageURL  https://github.com/Alplox/TikTok-Clickable-Plox
// @supportURL   https://github.com/Alplox/TikTok-Clickable-Plox/issues
// ==/UserScript==

(function () {
    'use strict';

    // --- CONFIGURACIÓN DEL ESTADO ---
    const RELATED_KEY = 'tiktok_open_related_new_tab';
    const SHOW_FULL_URL_KEY = 'tiktok_show_full_url_in_bio';

    // Establecido a 'false' para que esté DESACTIVADO por defecto
    // Si el valor guardado es 'false', se usa. Si no existe, se usa 'false' por defecto.
    let openRelatedInNewTab = GM_getValue(RELATED_KEY, false);
    let showFullUrl = GM_getValue(SHOW_FULL_URL_KEY, false);

    let menuRelatedId = null;
    let menuFullUrlId = null;

    // --- MENÚS ---
    function updateMenus() {
        if (typeof GM_unregisterMenuCommand === 'function') {
            if (menuRelatedId !== null) GM_unregisterMenuCommand(menuRelatedId);
            if (menuFullUrlId !== null) GM_unregisterMenuCommand(menuFullUrlId);
        }

        menuRelatedId = GM_registerMenuCommand(
            `${openRelatedInNewTab ? '✅ Activado' : '❌ Desactivado (por defecto)'} abrir relacionados en nueva pestaña`,
            () => {
                const wasActive = openRelatedInNewTab; // Guarda el estado actual
                openRelatedInNewTab = !openRelatedInNewTab;

                // 1. Guardar el nuevo estado
                GM_setValue(RELATED_KEY, openRelatedInNewTab);

                // 2. Aplicar/retirar estilo dinámico que hace clickables los enlaces
                setClickableStyle(openRelatedInNewTab);

                // 3. Si se DESACTIVA, eliminar handlers y forzar un re-scan para limpiar marcas/atributos
                if (wasActive && !openRelatedInNewTab) {
                    // quitar handlers/flags y restaurar anchors
                    detachHandlers(document);
                    // re-scan para elementos hijos nuevos si fuera necesario
                    scan(document);
                    updateMenus();
                    return;
                }

                // 4. Si se ACTIVA, hacer un re-scan para añadir handlers a los elementos ya existentes
                updateMenus();
                scan(document);
            }
        );

        menuFullUrlId = GM_registerMenuCommand(
            `${showFullUrl ? '✅ Mostrando' : '❌ Ocultando (por defecto)'} URL completa en biografía`,
            () => {
                showFullUrl = !showFullUrl;
                GM_setValue(SHOW_FULL_URL_KEY, showFullUrl);
                applyFullUrlStyle(document);
                updateMenus();
            }
        );
    }

    // --- ESTILOS GENERALES ---
    // Reglas base (siempre aplicadas). NOTA: la regla que forcea "pointer-events:auto" para enlaces
    // se maneja dinámicamente en setClickableStyle() para poder desactivar sin recargar.
    GM_addStyle(`
    [data-e2e="user-post-item"], [data-e2e="video-item"], [data-e2e="video-card"],
    a[href*="/video/"], a[href*="/@"] {
      user-select: text !important;
    }
    .tiktok-overlay, .overlay, [data-e2e="video-overlay"] {
      pointer-events: none !important;
    }
    `);

    // Elemento <style> que se añade/remueve cuando activamos/desactivamos el comportamiento "clickable"
    let clickableStyleEl = null;
    function setClickableStyle(enabled) {
        try {
            if (enabled) {
                if (clickableStyleEl) return;
                clickableStyleEl = document.createElement('style');
                clickableStyleEl.id = 'tiktok-clickable-plox-clickable-style';
                clickableStyleEl.textContent = `
                    a[class*="--LinkNonClickable"], a[role="link"] {
                        pointer-events: auto !important;
                        cursor: pointer !important;
                    }
                `;
                (document.head || document.documentElement).appendChild(clickableStyleEl);
            } else {
                if (!clickableStyleEl) {
                    const ex = document.getElementById('tiktok-clickable-plox-clickable-style');
                    if (ex) { ex.remove(); clickableStyleEl = null; }
                    return;
                }
                clickableStyleEl.remove();
                clickableStyleEl = null;
            }
        } catch { }
    }

    // --- SELECTORES ---
    const cardSelector = `
    [data-e2e="video-item"],
    [data-e2e="user-post-item"],
    article,
    div[class*="--DivItemContainer"],
    div[class*="--DivCoverContainer"]
    `;

    // --- UTILS ---
    function normalizeUrl(s) {
        if (!s) return null;
        s = String(s).trim();
        if (!/^https?:\/\//i.test(s)) s = 'https://' + s;
        try { return new URL(s).href; } catch { return null; }
    }

    function abs(href) {
        if (!href) return null;
        href = href.trim();
        if (href.startsWith('//')) href = location.protocol + href;
        if (href.startsWith('/')) href = location.origin + href;
        if (!/^https?:\/\//i.test(href)) href = 'https://' + href;
        try { return new URL(href).href; } catch { return href; }
    }

    // --- UNWRAP LINK ---
    function extractFromLinkHref(href) {
        if (!href) return null;
        try {
            const u = new URL(href, location.origin);
            const target = u.searchParams.get('target') || u.searchParams.get('u') || u.searchParams.get('url');
            if (target) return normalizeUrl(decodeURIComponent(target)) || normalizeUrl(target);

            if (u.pathname.startsWith('/link/')) {
                const seg = u.pathname.split('/').slice(2).join('/') || '';
                try {
                    const decoded = decodeURIComponent(seg);
                    if (decoded && (decoded.includes('.') || decoded.startsWith('http'))) return normalizeUrl(decoded);
                } catch { }
                try {
                    const b64 = seg.replace(/_/g, '/').replace(/-/g, '+');
                    const padded = b64 + '='.repeat((4 - b64.length % 4) % 4);
                    const dec = atob(padded);
                    if (dec && (dec.includes('.') || dec.startsWith('http'))) return normalizeUrl(dec);
                } catch { }
            }

            for (const p of ['redirect', 'r']) {
                const v = u.searchParams.get(p);
                if (v) {
                    try { return normalizeUrl(decodeURIComponent(v)); } catch { }
                    return normalizeUrl(v);
                }
            }
        } catch { }
        return null;
    }

    function fixAnchor(a) {
        try {
            if (!a || a.dataset?.tiktokUnwrapped) return;
            const raw = a.getAttribute('href');
            if (!raw || !raw.includes('/link')) return;
            const resolved = extractFromLinkHref(raw) ||
                normalizeUrl(a.dataset?.href || a.dataset?.url || a.dataset?.link);
            if (resolved) {
                a.href = resolved;
                a.target ||= '_blank';
                a.rel ||= 'noopener noreferrer';
                a.dataset.tiktokUnwrapped = '1';
            }
        } catch { }
    }

    // --- SHOW FULL URL IN BIO ---
    function applyFullUrlStyle(root = document) {
        const bios = root.querySelectorAll('div[class*="--DivShareLinks"] a[data-e2e="user-link"]');
        bios.forEach(bio => {
            bio.style.maxWidth = showFullUrl ? 'none' : '';
        });
    }

    // --- OPEN RELATED HANDLERS ---
    function getHrefFromCard(card) {
        if (!card) return null;
        const preferred = card.querySelector('a[class*="--LinkNonClickable"][href], a[href*="/video/"], a[href*="/@"]');
        if (preferred?.getAttribute('href')) return abs(preferred.getAttribute('href'));
        const a = card.querySelector('a[href]');
        if (a?.getAttribute('href')) return abs(a.getAttribute('href'));

        const vid = card.querySelector('[data-video-id], [data-post-id], [data-id]');
        if (vid) {
            const id = vid.dataset.videoId || vid.dataset.postId || vid.dataset.id;
            if (id) {
                const userA = card.querySelector('a[href*="/@"]');
                if (userA) return abs(userA.getAttribute('href')).replace(/\/$/, '') + '/video/' + id;
                return 'https://www.tiktok.com/video/' + id;
            }
        }
        return null;
    }
    function attachHandlers(card) {
        if (!card || card.__tiktok_attached) return;

        const url = getHrefFromCard(card);
        if (!url) return;

        // Marcar SOLO después de confirmar que hay URL y que se añaden handlers
        card.__tiktok_attached = true;
        card.dataset.tiktokAttached = '1';

        card.style.cursor = 'pointer';

        // Handler para pointerdown: previene la accion nativa solo si está ACTIVO
        const pointerDownHandler = e => {
            if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;

            const shouldOpenInNewTab = GM_getValue('tiktok_open_related_new_tab', false);

            // Si está DESACTIVADO, salimos y permitimos el evento nativo.
            if (!shouldOpenInNewTab) return;

            // Si está ACTIVO: Bloqueamos la propagación AQUI para evitar que TikTok procese el 'pointerdown'/'mousedown'.
            const path = e.composedPath?.() || e.path || [];
            for (const el of path) {
                if (el === card) continue;
                if (el instanceof HTMLElement && el.closest('button,a,input,textarea,select,[role="button"],[role="link"]')) return;
            }

            // Anulación agresiva en pointerdown
            e.stopImmediatePropagation();
            e.preventDefault();
        };

        // Handler para click: abre la nueva pestaña
        const clickHandler = e => {
            if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;

            const shouldOpenInNewTab = GM_getValue('tiktok_open_related_new_tab', false);

            // Si está DESACTIVADO, salimos (el evento ya no fue bloqueado en pointerdown).
            if (!shouldOpenInNewTab) return;

            // Si el click no fue ya bloqueado, lo hacemos ahora (redundante pero seguro)
            e.preventDefault();
            e.stopPropagation();

            // Abrir nueva pestaña
            try {
                window.open(url, '_blank', 'noopener,noreferrer');
            } catch {
                const t = document.createElement('a');
                t.href = url; t.target = '_blank'; t.rel = 'noopener noreferrer';
                document.body.appendChild(t); t.click(); t.remove();
            }
        };

        // Adjuntar handlers en la fase de captura (true)
        card.addEventListener('pointerdown', pointerDownHandler, true);
        card.addEventListener('click', clickHandler, true);

        // Almacenar handlers para un hipotético detach futuro (si la recarga falla)
        card.__tiktok_handler = { down: pointerDownHandler, click: clickHandler };
    }

    // --- NEW: DETACH HANDLERS ---
    // Elimina listeners añadidos por attachHandlers para poder volver al comportamiento nativo sin recargar
    function detachHandlers(root = document) {
        try {
            // 1) intentar selector rápido (marcados)
            const marked = Array.from(root.querySelectorAll('[data-tiktok-attached]'));
            // 2) además inspeccionar todos los nodos para capturar handlers añadidos antes de la marca
            const all = Array.from(root.getElementsByTagName('*'));
            const candidates = new Set([...marked, ...all]);
            candidates.forEach(el => {
                if (!el) return;
                // detectar handlers guardados como propiedad o marca
                const hasHandler = !!el.__tiktok_handler || !!el.__tiktok_attached || el.dataset?.tiktokAttached === '1';
                if (!hasHandler) return;
                const h = el.__tiktok_handler;
                try {
                    if (h?.down) el.removeEventListener('pointerdown', h.down, true);
                    if (h?.click) el.removeEventListener('click', h.click, true);
                } catch { }
                // limpiar propiedades / atributos
                try { delete el.__tiktok_handler; } catch { }
                try { delete el.__tiktok_attached; } catch { }
                try { el.removeAttribute && el.removeAttribute('data-tiktok-attached'); } catch { }
                try { el.dataset && (el.dataset.tiktokAttached = undefined); } catch { }
                // Restaurar cursor por si se cambió
                try { el.style && (el.style.cursor = ''); } catch { }
            });

            // Además, restaurar target/rel en anchors internos para evitar que el middle-click abra nueva pestaña
            try { restoreAnchorTargets(root); } catch { }

        } catch (err) {
            // no hacer nada si falla el proceso de limpieza
            try { console.warn('detachHandlers error', err); } catch { }
        }
    }

    // --- RESTAURAR TARGETS EN ANCLAS DE TARJETAS ---
    // Quita target="_blank" (y rel 'noopener noreferrer' si fue añadido) en enlaces internos de tarjetas relacionadas
    function restoreAnchorTargets(root = document) {
        try {
            // Selecciona anclas dentro de las tarjetas (incluye variantes por si las tarjetas no coinciden exactamente con cardSelector)
            const anchors = Array.from(root.querySelectorAll(`${cardSelector} a[href], a[href*="/video/"], a[href*="/@"]`));
            anchors.forEach(a => {
                if (!a || !a.getAttribute) return;
                const href = a.getAttribute('href') || '';
                // considerar rutas relativas o internas que tienden a abrir en la misma SPA
                const isInternal = href.startsWith('/') || href.includes(location.hostname) || /\/video\/|\/@/.test(href);
                if (!isInternal) return;
                // Sólo quitar target si está explícitamente en _blank (no tocar si el site lo necesita distinto)
                try {
                    if (a.target === '_blank') {
                        a.removeAttribute('target');
                    }
                    // quitar rel añadido por nosotros si es exactamente noopener noreferrer (pero no tocar otros valores)
                    const rel = a.getAttribute('rel') || '';
                    if (rel.split(/\s+/).sort().join(' ') === 'noopener noreferrer') {
                        a.removeAttribute('rel');
                    }
                } catch { }
            });
        } catch (err) {
            try { console.warn('restoreAnchorTargets error', err); } catch { }
        }
    }

    // --- ESCANEO GENERAL ---
    function scan(root = document) {
        root.querySelectorAll('a[href*="/link"], a[href*="tiktok.com/link"]').forEach(fixAnchor);
        root.querySelectorAll(cardSelector).forEach(attachHandlers);
        root.querySelectorAll('a[class*="--LinkNonClickable"]').forEach(a => {
            const c = a.closest(cardSelector) || a.parentElement;
            if (c) attachHandlers(c);
        });
        applyFullUrlStyle(root);
    }

    // --- OBSERVADOR ---
    const mo = new MutationObserver(muts => {
        for (const m of muts) {
            if (m.addedNodes?.length) m.addedNodes.forEach(n => { if (n.nodeType === 1) scan(n); });
            if (m.type === 'attributes' && m.target?.matches?.('a[href]')) fixAnchor(m.target);
        }
    });

    // --- INICIO ---
    function start() {
        scan(document);
        mo.observe(document, {
            childList: true,
            subtree: true,
            attributes: true,
            attributeFilter: ['href', 'class']
        });
    }

    updateMenus();
    // Aplicar estado de estilo según el valor guardado al arrancar
    setClickableStyle(openRelatedInNewTab);
    if (document.readyState === 'loading') {
        window.addEventListener('DOMContentLoaded', start);
    } else {
        start();
    }

})();