ParaTranz diff

ParaTranz enhanced

当前为 2024-12-23 提交的版本,查看 最新版本

// ==UserScript==
// @name         ParaTranz diff
// @namespace    https://paratranz.cn/users/44232
// @version      0.7.0
// @description  ParaTranz enhanced
// @author       ooo
// @match        http*://paratranz.cn/*
// @icon         https://paratranz.cn/favicon.png
// @require      https://cdnjs.cloudflare.com/ajax/libs/medium-zoom/1.1.0/medium-zoom.min.js
// @license      MIT
// ==/UserScript==

(async function() {
    'use strict';

    // #region 主要功能

    // #region 自动跳过空白页 initSkip
    function initSkip() {
        waitForElems('.string-list .empty-sign').then(() => {
            if (location.search.match(/(\?|&)page=\d+/g)) {
                document.querySelector('.pagination .page-item a')?.click();
            }
        });
    }
    // #endregion

    // #region 添加快捷键 addHotkeys
    function addHotkeys() {
        document.addEventListener('keydown', (event) => {
            if (event.ctrlKey && event.shiftKey && event.key === 'V') {
                event.preventDefault();
                mockInput(document.querySelector('.editor-core .original')?.textContent);
            }
        });
    }
    // #endregion

    // #region 更多搜索高亮 markSearchParams
    let markSearchParams = () => console.log('PZdiff: no search');
    function updMark() {
        const params = new URLSearchParams(location.search);
        const text = params.get('text');
        const original = params.get('original');
        const translation = params.get('translation');
        const context = params.get('context');

        if (text) {
            markSearchParams = () => {
                markNorm('.editor-core .original', text);
                return markEditing(text);
            }
        } else if (original) {
            markSearchParams = () => {
                markNorm('.editor-core .original', original);
            }
        } else if (translation) {
            markSearchParams = () => {
                return markEditing(translation);
            }
        } else if (context) {
            markSearchParams = () => {
                markNorm('.context', context);
            }
        } else {
            markSearchParams = () => console.log('PZdiff: no search');
        }
    }
    let dropLastMark = updMark();

    function markNorm(selector, toMark) {
        const container = document.querySelector(selector);
        if (!container) return;

        let toMarkPattern = toMark;
        if (document.querySelector('.sidebar .custom-checkbox').__vue__.$data.localChecked) { // 忽略大小写
            toMarkPattern = new RegExp(`(${toMark.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig');
        }

        const HTML = container.innerHTML;
        const currentMark = `<mark class="PZS">${toMark}</mark>`;
        if (HTML.includes(currentMark)) return;
        container.innerHTML = HTML.replaceAll('<mark class="PZS">', '').replace(/(?<=>|^)([^<]*?)(?=<|$)/g, (match) => {
            if (typeof toMarkPattern === 'string') {
                return match.replaceAll(toMarkPattern, currentMark);
            } else {
                return match.replace(toMarkPattern, '<mark class="PZS">$1</mark>');
            }
        });
    }

    function markEditing(toMark) {
        const textarea = document.querySelector('textarea.translation');
        if (!textarea) return;
        const lastOverlay = document.getElementById('PZSoverlay');
        if (lastOverlay) return;

        const overlay = document.createElement('div');
        overlay.id = 'PZSoverlay';
        overlay.className = textarea.className;
        const textareaStyle = window.getComputedStyle(textarea);
        for (let i = 0; i < textareaStyle.length; i++) {
            const property = textareaStyle[i];
            overlay.style[property] = textareaStyle.getPropertyValue(property);
        }
        overlay.style.position = 'absolute';
        overlay.style.pointerEvents = 'none';
        overlay.style.setProperty('background', 'transparent', 'important');
        overlay.style['-webkit-text-fill-color'] = 'transparent';
        overlay.style['overflow-y'] = 'hidden';
        overlay.style.resize = 'none';

        textarea.parentNode.appendChild(overlay);

        const updOverlay = () => {
            let toMarkPattern = toMark.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('\\n', '<br>');
            if (document.querySelector('.sidebar .custom-checkbox').__vue__.$data.localChecked) { // 忽略大小写
                toMarkPattern = new RegExp(`(${toMark.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'ig');
            }
            overlay.innerText = textarea.value;
            if (typeof toMarkPattern === 'string') {
                overlay.innerHTML = overlay.innerHTML.replaceAll(toMarkPattern, `<mark class="PZS" style="-webkit-text-fill-color:${
                    window.getComputedStyle(textarea).getPropertyValue('-webkit-text-fill-color')
                };opacity:.5">${toMarkPattern}</mark>`);
            } else {
                overlay.innerHTML = overlay.innerHTML.replace(toMarkPattern, `<mark class="PZS" style="-webkit-text-fill-color:${
                    window.getComputedStyle(textarea).getPropertyValue('-webkit-text-fill-color')
                };opacity:.5">$1</mark>`);
            }
            overlay.style.top = textarea.offsetTop + 'px';
            overlay.style.left = textarea.offsetLeft + 'px';
            overlay.style.width = textarea.offsetWidth + 'px';
            overlay.style.height = textarea.offsetHeight + 'px';
        };

        updOverlay();

        textarea.addEventListener('input', updOverlay);

        const observer = new MutationObserver(updOverlay);
        observer.observe(textarea, { attributes: true, childList: true, subtree: true });

        window.addEventListener('resize', updOverlay);

        const cancelOverlay = () => {
            observer.disconnect();
            textarea.removeEventListener('input', updOverlay);
            window.removeEventListener('resize', updOverlay);
        }
        return cancelOverlay;
    }
    // #endregion

    // #region 高亮上下文 markContext(originTxt)
    function markContext(originTxt) {
        const contextBox = document.querySelector('.context');
        if (!contextBox) return;

        const context = contextBox.innerHTML.replaceAll(/<a.*?>(.*?)<\/a>/g, '$1').replaceAll(/<(\/?)(li|b|u|h\d|span)>/g, '&lt;$1$2&gt;');
        originTxt = originTxt.replaceAll('<br>', '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
        if (contextBox.querySelector('#PZmark')?.textContent === originTxt) return;
        contextBox.innerHTML = context.replace('<mark id="PZmark" class="mark">', '').replace(originTxt, `<mark id="PZmark" class="mark">${originTxt}</mark>`);
    }
    // #endregion

    // #region 修复原文排版崩坏和<<>> fixOrigin(originElem)
    function fixOrigin(originElem) {
        originElem.innerHTML = originElem.innerHTML
        .replaceAll('<abbr title="noun.>" data-value=">">&gt;</abbr>', '&gt;')
        .replaceAll(/<var>(&lt;&lt;[^<]*?&gt;)<\/var>&gt;/g, '<var class="PZvar">$1&gt;</var>')
        .replaceAll('<i class="lf" <abbr="" title="noun.>" data-value=">">&gt;&gt;', '')
        .replaceAll('<i class="<abbr" title="noun.“”" data-value="“”">"lf<abbr title="noun.“”" data-value="“”">"</abbr>&gt;&gt;', '')
        .replaceAll('<i class="<abbr" title="noun.“”" data-value="“”">"lf<abbr title="noun.“”" data-value="“”">"</abbr>&gt;', '');
    }
    // #endregion

    // #region 修复 Ctrl 唤起菜单的<<>> fixTagSelect
    const insertTag = debounce((tag) => {
        const textarea = document.querySelector('textarea.translation');
        const startPos = textarea.selectionStart;
        const endPos = textarea.selectionEnd;
        const currentText = textarea.value;

        const before = currentText.slice(0, startPos);
        const after = currentText.slice(endPos);

        mockInput(before.slice(0, Math.max(before.length - tag.length + 1, 0)) + tag + after);

        textarea.selectionStart = startPos + 1;
        textarea.selectionEnd = endPos + 1;
    })

    function fixTagSelect() {
        const tags = document.querySelectorAll('.list-group-item.tag');
        let activeTag;
        const modifiedTags = [];
        if (tags[0]) {
            for (const tag of tags) {
                tag.innerHTML = tag.innerHTML.trim();
                if (tag.innerHTML.startsWith('&lt;&lt;') && !tag.innerHTML.endsWith('&gt;&gt;')) {
                    tag.innerHTML += '&gt;';
                    modifiedTags.push(tag);
                }
            }
            activeTag = document.querySelector('.list-group-item.tag.active');
            document.addEventListener('keyup', handler);
        }

        function handler(event) {
            if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
                activeTag = document.querySelector('.list-group-item.tag.active');
            }
            if (event.key === 'Enter') {
                event.preventDefault();
                if (!activeTag) return;
                if (!modifiedTags.includes(activeTag)) return;
                insertTag(activeTag?.textContent);
                document.removeEventListener('keyup', handler);
            }
        }
    }
    // #endregion

    // #region 将填充原文移到右边,增加填充原文并保存 tweakButtons
    function tweakButtons() {
        const copyButton = document.querySelector('button.btn-secondary:has(.fa-clone)');
        const rightButtons = document.querySelector('.right .btn-group');

        if (rightButtons) {
            if (copyButton) {
                rightButtons.insertBefore(copyButton, rightButtons.firstChild);
            }
            if (document.querySelector('#PZpaste')) return;
            const pasteSave = document.createElement('button');
            rightButtons.appendChild(pasteSave);
            pasteSave.id = 'PZpaste';
            pasteSave.type = 'button';
            pasteSave.classList.add('btn', 'btn-secondary');
            pasteSave.title = '填充原文并保存';
            pasteSave.innerHTML = '<i aria-hidden="true" class="far fa-save"></i>';
            pasteSave.addEventListener('click', async () => {
                await mockInput(document.querySelector('.editor-core .original')?.textContent);
                document.querySelector('.right .btn-primary')?.click();
            });
        }
    }
    // #endregion

    // #region 缩略对比差异中过长无差异文本 extractDiff
    function extractDiff() {
        document.querySelectorAll('.diff-wrapper:not(.PZedited)').forEach(wrapper => {
            wrapper.childNodes.forEach(node => {
                if (node.nodeType !== Node.TEXT_NODE || node.length < 200) return;

                const text = node.cloneNode();
                const expand = document.createElement('span');
                expand.textContent = `${node.textContent.slice(0, 100)} ... ${node.textContent.slice(-100)}`;
                expand.style.cursor = 'pointer';
                expand.style.background = 'linear-gradient(to right, transparent, #dcc8ff, transparent)';
                expand.style.borderRadius = '2px';

                let time = 0;
                const start = () => time = Date.now();
                const end = () => {
                    if (Date.now() - time > 500) return;
                    expand.after(text);
                    expand.remove();
                }

                expand.addEventListener('mousedown', start);
                expand.addEventListener('mouseup', end);
                expand.addEventListener('mouseleave', () => time = 0);
                expand.addEventListener('touchstart', start);
                expand.addEventListener('touchend', end);
                expand.addEventListener('touchcancel', () => time = 0);

                node.after(expand);
                node.remove();
            });
            wrapper.classList.add('PZedited');
        });
    }
    // #endregion

    // #region 点击对比差异绿色文字粘贴其中文本 clickDiff
    function clickDiff() {
        const addeds = document.querySelectorAll('.diff.added:not(.PZPedited)');
        for (const added of addeds) {
            added.classList.add('PZPedited');
            const text = added.textContent.replaceAll('\\n', '\n');
            added.style.cursor = 'pointer';
            added.addEventListener('click', () => {
                mockInsert(text);
            });
        }
    }
    // #endregion

    // #region 初始化自动编辑 initAuto
    function initAuto() {
        waitForElems('.nav-item.user-info').then((bannerL) => {
            const banner = bannerL[0];
            let harvesting = false;
            let translationPattern, skipPattern, interval;
            banner.insertAdjacentHTML('afterend', `<li class="nav-item"><a id="PZpp" href="javascript:;" target="_self" class="nav-link" role="button">PP收割机</a></li>`);
            document.querySelector('#PZpp').addEventListener('click', async (e) => {
                if (location.pathname.split('/')[3] !== 'strings') return;
                harvesting = !harvesting;
                if (harvesting) {
                    e.target.style.color = '#dc3545';
                    translationPattern = prompt(`请确认译文模板代码,字符串用'包裹;常用代码:
    original(原文)
    document.querySelector('textarea.translation')?.value(现有译文)
    document.querySelectorAll('.translation-memory .translation')?.[0].textContent(第1条翻译建议)`, 'original');
                    if (translationPattern === null) return cancel();
                    skipPattern = prompt(`请确认跳过条件代码,多个条件用逻辑运算符相连;常用代码:
    original.match(/^(\s|\n|<<.*?>>|<.*?>)*/gm)[0] !== original(跳过并非只包含标签)
    document.querySelector('textarea.translation')?.value(现有译文)
    document.querySelector('.context').textContent(上下文内容)`, '');
                    if (skipPattern === null) return cancel();
                    if (skipPattern === '') skipPattern = 'false';
                    interval = prompt('请确认每次操作时间间隔(单位:ms)', '100');
                    if (interval === null) return cancel();
                    function cancel() {
                        harvesting = false;
                        e.target.style.color = '';
                    }
                } else {
                    e.target.style.color = '';
                    return 0;
                }

                const hideAlert = document.createElement('style');
                document.head.appendChild(hideAlert);
                hideAlert.innerHTML = '.alert-success.alert-global{display:none}';
                const checkboxs = Array.from(document.querySelectorAll('.right .custom-checkbox')).slice(0, 2);
                const checkboxValues = checkboxs.map(e => e.__vue__.$data.localChecked);
                checkboxs.forEach(e => e.__vue__.$data.localChecked = true);

                await (function harvest(time, skipInfo) {
                    return new Promise(async (resolve) => {
                        await sleep(time);
                        if (!harvesting) return resolve(0);
                        if (skipInfo) {
                            const skipWaiting = location.search.match(/(?<=(\?|&)page=)\d+/g) !== skipInfo[1]
                                       && document.querySelector('.editor-core .original') === skipInfo[0];
                            if (skipWaiting) {
                                return resolve(harvest(time, skipInfo));
                            }
                        }
                        const original = document.querySelector('.editor-core .original')?.textContent;
                        const nextButton = document.querySelectorAll('.navigation .btn-secondary')[1];
                        if (!original || !nextButton) {
                            console.log('%cWAITING...', 'background: #007BFF; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                            return resolve(harvest(interval));
                        }

                        const translation = eval(translationPattern);
                        if (!translation) {
                            console.log('%cWAITING...', 'background: #007BFF; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                            return resolve(harvest(interval));
                        }
                        if (eval(skipPattern)) {
                            console.log('%cSKIP!', 'background: #ffc107; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                            if (nextString()) return resolve(0);
                            return resolve(harvest(interval/2, [
                                document.querySelector('.editor-core .original'),
                                location.search.match(/(?<=(\?|&)page=)\d+/g)
                            ]));
                        }

                        await mockInput(translation);
                        const translateButton = document.querySelector('.right .btn-primary');
                        if (!translateButton) {
                            if (nextButton) {
                                console.log('%cSKIP!', 'background: #ffc107; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                                if (nextString()) return resolve(0);
                                console.log(original)
                                return resolve(harvest(interval/2, [
                                    document.querySelector('.editor-core .original'),
                                    location.search.match(/(?<=(\?|&)page=)\d+/g)
                                ]));
                            }
                        } else {
                            console.log('%cCLICK!', 'background: #20C997; color: #282828; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                            translateButton.click();
                            return resolve(harvest(interval));
                        }

                        function nextString() {
                            if (nextButton.disabled) {
                                console.log('%cTHE END!', 'background: #DE065B; color: white; font-weight: 900; padding: 0 5px; font-size: 12px; border-radius: 2px');
                                harvesting = false;
                                e.target.style.color = '';
                                return true;
                            }
                            nextButton.click();
                            return false;
                        }
                    });
                })(interval);

                hideAlert.remove();
                checkboxs.forEach((e, i) => { e.__vue__.$data.localChecked = checkboxValues[i] });

            });
        });
    }
    // #endregion

    // #endregion

    addHotkeys();
    initAuto();

    let lastPath = location.pathname;
    function actByPath() {
        lastPath = location.pathname;
        if (location.pathname.split('/').pop() === 'strings') {

            initSkip();
            let original;

            let observer = new MutationObserver(() => {

                original = document.querySelector('.editor-core .original');
                if (!original) return;

                observer.disconnect();
                markContext(original.textContent);
                fixOrigin(original);
                tweakButtons();
                fixTagSelect();
                markSearchParams();
                clickDiff();
                extractDiff();

                observer.observe(document.getElementsByTagName('body')[0], {
                    childList: true,
                    subtree: true,
                });
            });

            observer.observe(document.getElementsByTagName('body')[0], {
                childList: true,
                subtree: true,
            });

        } else if (location.pathname.split('/').at(-2) === 'issues') {
            waitForElems('.text-content p img').then((imgs) => {
                imgs.forEach(mediumZoom);
            });
        } else if (location.pathname.split('/').pop() === 'history') {
            let observer = new MutationObserver(() => {

                observer.disconnect();
                extractDiff();

                observer.observe(document.getElementsByTagName('body')[0], {
                    childList: true,
                    subtree: true,
                });
            });
            observer.observe(document.getElementsByTagName('body')[0], {
                childList: true,
                subtree: true,
            });
        }
    }
    actByPath();
    document.querySelector('main').__vue__.$router.afterHooks.push(()=>{
        dropLastMark?.();
        dropLastMark = updMark();
        if (lastPath === location.pathname) return;
        actByPath();
    });

    // #region utils
    function waitForElems(selector) {
        return new Promise(resolve => {
            if (document.querySelector(selector)) {
                return resolve(document.querySelectorAll(selector));
            }

            const observer = new MutationObserver(() => {
                if (document.querySelector(selector)) {
                    resolve(document.querySelectorAll(selector));
                    observer.disconnect();
                }
            });

            observer.observe(document.body, {
                childList: true,
                subtree: true
            });
        });
    }

    function sleep(delay) {
        return new Promise((resolve) => setTimeout(resolve, delay));
    }

    function mockInput(text) {
        return new Promise((resolve) => {
            const textarea = document.querySelector('textarea.translation');
            if (!textarea) return;
            textarea.value = text;
            textarea.dispatchEvent(new Event('input', {
                bubbles: true,
                cancelable: true,
            }));
            return resolve(0);
        })
    }

    function mockInsert(text) {
        const textarea = document.querySelector('textarea.translation');
        if (!textarea) return;
        const startPos = textarea.selectionStart;
        const endPos = textarea.selectionEnd;
        const currentText = textarea.value;

        const before = currentText.slice(0, startPos);
        const after = currentText.slice(endPos);

        mockInput(before + text + after);

        textarea.selectionStart = startPos + text.length;
        textarea.selectionEnd = endPos + text.length;
    }

    function debounce(func, timeout = 300) {
        let called = false;
        return (...args) => {
          if (!called) {
            func.apply(this, args);
            called = true;
            setTimeout(() => {
              called = false;
            }, timeout);
          }
        };
    }
    // #endregion

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址