Paperchan Toolkit

Adds a bunch of new features to Paperchan.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==UserScript==
// @name         Paperchan Toolkit
// @namespace    http://paperchan.club/
// @version      1.7
// @description  Adds a bunch of new features to Paperchan.
// @author       You
// @match        *://paperchan.club/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    let layers = [];
    let activeLayerIndex = 0;
    let layerIdCounter = 1;
    let layersContainer, eventCatcher, mainCanvasElement;
    let currentTool = 'draw';
    let trackRAF = null;

    const drawingCommands = new Set([
        'arc', 'arcTo', 'beginPath', 'bezierCurveTo', 'clearRect', 'clip',
        'closePath', 'drawImage', 'fill', 'fillRect', 'fillText', 'lineTo',
        'moveTo', 'putImageData', 'quadraticCurveTo', 'rect', 'restore',
        'save', 'stroke', 'strokeRect', 'strokeText', 'getImageData', 'setLineDash'
    ]);

    function syncState(source, dest) {
        const props = ['strokeStyle', 'fillStyle', 'lineWidth', 'lineCap', 'lineJoin', 'globalAlpha', 'globalCompositeOperation', 'font', 'textAlign', 'textBaseline'];
        for (let p of props) {
            if (dest[p] !== source[p]) dest[p] = source[p];
        }
    }

    function resetWorkspace() {
        layers = [];
        activeLayerIndex = 0;
        layerIdCounter = 1;
        currentTool = 'draw';

        if (layersContainer && layersContainer.parentNode) layersContainer.parentNode.removeChild(layersContainer);
        if (eventCatcher && eventCatcher.parentNode) eventCatcher.parentNode.removeChild(eventCatcher);
        const oldUi = document.getElementById('custom-paperchan-ui');
        if (oldUi) oldUi.parentNode.removeChild(oldUi);

        layersContainer = null;
        eventCatcher = null;

        if (trackRAF) {
            cancelAnimationFrame(trackRAF);
            trackRAF = null;
        }
    }

    const origGetContext = HTMLCanvasElement.prototype.getContext;
    HTMLCanvasElement.prototype.getContext = function(type, ...args) {
        const ctx = origGetContext.call(this, type, ...args);

        if (type === '2d' && !this.dataset.isLayer && !this.dataset.isHelper) {
            if (this.width > 200 || this.id === 'canvas' || this.id === 'board' || this.className.includes('board')) {
                if (this._ctxProxy) return this._ctxProxy;

                if (mainCanvasElement && mainCanvasElement !== this) {
                    resetWorkspace();
                }

                this.dataset.isMainCanvas = 'true';
                mainCanvasElement = this;

                const proxy = new Proxy(ctx, {
                    get(target, prop) {
                        if (prop === 'canvas') return target.canvas;

                        const val = target[prop];
                        if (typeof val === 'function') {
                            return function(...fnArgs) {
                                if (drawingCommands.has(prop) && layers.length > 0) {
                                    const activeCtx = layers[activeLayerIndex].ctx;
                                    if (activeCtx) {
                                        syncState(target, activeCtx);

                                        let isEraseProp = false;
                                        let prevGCO, prevStroke, prevFill;

                                        if (currentTool === 'erase') {
                                            const paintCommands = ['stroke', 'fill', 'fillRect', 'strokeRect', 'fillText', 'strokeText', 'drawImage'];
                                            if (paintCommands.includes(prop)) {
                                                isEraseProp = true;
                                                prevGCO = activeCtx.globalCompositeOperation;
                                                prevStroke = activeCtx.strokeStyle;
                                                prevFill = activeCtx.fillStyle;

                                                activeCtx.globalCompositeOperation = 'destination-out';
                                                activeCtx.strokeStyle = 'rgba(0,0,0,1)';
                                                activeCtx.fillStyle = 'rgba(0,0,0,1)';
                                            }
                                        }

                                        const result = activeCtx[prop](...fnArgs);

                                        if (isEraseProp) {
                                            activeCtx.globalCompositeOperation = prevGCO;
                                            activeCtx.strokeStyle = prevStroke;
                                            activeCtx.fillStyle = prevFill;
                                        }

                                        return result;
                                    }
                                }
                                return val.apply(target, fnArgs);
                            };
                        }
                        return val;
                    },
                    set(target, prop, value) {
                        target[prop] = value;
                        if (layers.length > 0) {
                            const activeCtx = layers[activeLayerIndex].ctx;
                            if (activeCtx) activeCtx[prop] = value;
                        }
                        return true;
                    }
                });
                this._ctxProxy = proxy;

                const checkDom = setInterval(() => {
                    if (mainCanvasElement !== this) {
                        clearInterval(checkDom);
                        return;
                    }

                    if (document.body && this.offsetParent && document.contains(this)) {
                        clearInterval(checkDom);
                        if (!this.dataset.uiAttached) {
                            this.dataset.uiAttached = 'true';
                            setupUI(this);
                        }
                    }
                }, 100);

                return proxy;
            }
        }
        return ctx;
    };

    function mergeLayers(canvas) {
        if (canvas !== mainCanvasElement) return;
        if (layers.length === 0) return;

        const ctx = origGetContext.call(canvas, '2d');
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        for (let i = layers.length - 1; i >= 0; i--) {
            const layer = layers[i];
            if (layer.visible) {
                ctx.globalAlpha = layer.opacity || 1;
                ctx.drawImage(layer.canvas, 0, 0);
            }
        }
        ctx.globalAlpha = 1;
    }

    const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
    HTMLCanvasElement.prototype.toDataURL = function(...args) {
        if (this.dataset.isMainCanvas) mergeLayers(this);
        return originalToDataURL.apply(this, args);
    };

    const originalToBlob = HTMLCanvasElement.prototype.toBlob;
    if (originalToBlob) {
        HTMLCanvasElement.prototype.toBlob = function(...args) {
            if (this.dataset.isMainCanvas) mergeLayers(this);
            return originalToBlob.apply(this, args);
        };
    }

    function hexToRgba(colorStr) {
        const c = document.createElement('canvas');
        c.width = 1; c.height = 1;
        c.dataset.isHelper = 'true';
        const ctx = c.getContext('2d');
        ctx.fillStyle = colorStr;
        ctx.fillRect(0,0,1,1);
        return ctx.getImageData(0,0,1,1).data;
    }

    function floodFill(ctx, startX, startY, fillColor, tolerance = 35) {
        const w = ctx.canvas.width;
        const h = ctx.canvas.height;
        if (startX < 0 || startX >= w || startY < 0 || startY >= h) return;

        const imgData = ctx.getImageData(0, 0, w, h);
        const data = imgData.data;
        const startPos = (startY * w + startX) * 4;

        const startR = data[startPos], startG = data[startPos+1], startB = data[startPos+2], startA = data[startPos+3];

        if (Math.abs(startR - fillColor[0]) <= tolerance &&
            Math.abs(startG - fillColor[1]) <= tolerance &&
            Math.abs(startB - fillColor[2]) <= tolerance &&
            Math.abs(startA - fillColor[3]) <= tolerance) {
            return;
        }

        const matchStart = (pos) => {
            return Math.abs(data[pos] - startR) <= tolerance &&
                   Math.abs(data[pos+1] - startG) <= tolerance &&
                   Math.abs(data[pos+2] - startB) <= tolerance &&
                   Math.abs(data[pos+3] - startA) <= tolerance;
        };

        const colorPixel = (pos) => {
            data[pos] = fillColor[0]; data[pos+1] = fillColor[1];
            data[pos+2] = fillColor[2]; data[pos+3] = fillColor[3];
        };

        const stack = [[startX, startY]];

        while(stack.length > 0) {
            const [x, y] = stack.pop();
            let currentY = y;
            let pos = (currentY * w + x) * 4;

            while(currentY >= 0 && matchStart(pos)) { currentY--; pos -= w * 4; }
            currentY++; pos += w * 4;

            let reachLeft = false, reachRight = false;
            while(currentY < h && matchStart(pos)) {
                colorPixel(pos);
                if (x > 0) {
                    if (matchStart(pos - 4)) {
                        if (!reachLeft) { stack.push([x - 1, currentY]); reachLeft = true; }
                    } else if (reachLeft) reachLeft = false;
                }
                if (x < w - 1) {
                    if (matchStart(pos + 4)) {
                        if (!reachRight) { stack.push([x + 1, currentY]); reachRight = true; }
                    } else if (reachRight) reachRight = false;
                }
                currentY++; pos += w * 4;
            }
        }
        ctx.putImageData(imgData, 0, 0);
    }

    function handleFill(e) {
        if (e.button !== 0 || currentTool !== 'fill') return;

        e.preventDefault();
        e.stopPropagation();

        const rect = eventCatcher.getBoundingClientRect();
        const scaleX = mainCanvasElement.width / rect.width;
        const scaleY = mainCanvasElement.height / rect.height;

        const startX = Math.floor((e.clientX - rect.left) * scaleX);
        const startY = Math.floor((e.clientY - rect.top) * scaleY);

        const activeCtx = layers[activeLayerIndex].ctx;
        const fillColor = hexToRgba(activeCtx.strokeStyle || '#000000');

        floodFill(activeCtx, startX, startY, fillColor, 35);
    }

    function createLayer() {
        const canvas = document.createElement('canvas');
        canvas.width = mainCanvasElement.width;
        canvas.height = mainCanvasElement.height;
        canvas.style.position = 'absolute';
        canvas.style.top = '0';
        canvas.style.left = '0';
        canvas.style.width = '100%';
        canvas.style.height = '100%';
        canvas.dataset.isLayer = 'true';

        layersContainer.appendChild(canvas);

        const layerId = layerIdCounter++;
        const layer = {
            id: layerId,
            name: 'Layer ' + layerId,
            canvas: canvas,
            ctx: canvas.getContext('2d'),
            visible: true
        };
        layers.unshift(layer);
        activeLayerIndex = 0;
        renderLayerList();
    }

    function moveLayer(index, direction) {
        const newIndex = index + direction;
        if (newIndex < 0 || newIndex >= layers.length) return;

        const temp = layers[index];
        layers[index] = layers[newIndex];
        layers[newIndex] = temp;

        if (activeLayerIndex === index) {
            activeLayerIndex = newIndex;
        } else if (activeLayerIndex === newIndex) {
            activeLayerIndex = index;
        }

        for (let i = layers.length - 1; i >= 0; i--) {
            layersContainer.appendChild(layers[i].canvas);
        }

        renderLayerList();
    }

    function updateActiveLayerUI() {
        document.querySelectorAll('.pc-layer-item').forEach((item, i) => {
            item.classList.toggle('active', i === activeLayerIndex);
        });
    }

    function renderLayerList() {
        const list = document.getElementById('pc-layer-list');
        if (!list) return;
        list.innerHTML = '';

        layers.forEach((layer, index) => {
            const item = document.createElement('div');
            item.className = 'pc-layer-item ' + (index === activeLayerIndex ? 'active' : '');

            const visBtn = document.createElement('button');
            visBtn.type = 'button';
            visBtn.className = 'pc-btn';
            visBtn.textContent = layer.visible ? '👁' : '—';
            visBtn.onclick = (e) => {
                e.stopPropagation();
                layer.visible = !layer.visible;
                layer.canvas.style.visibility = layer.visible ? 'visible' : 'hidden';
                visBtn.textContent = layer.visible ? '👁' : '—';
            };

            const nameInput = document.createElement('input');
            nameInput.type = 'text';
            nameInput.value = layer.name;
            nameInput.className = 'pc-layer-name-input';
            nameInput.oninput = (e) => {
                layer.name = e.target.value;
            };
            nameInput.onfocus = () => {
                if (activeLayerIndex !== index) {
                    activeLayerIndex = index;
                    updateActiveLayerUI();
                }
            };

            const upBtn = document.createElement('button');
            upBtn.type = 'button';
            upBtn.className = 'pc-btn';
            upBtn.textContent = '↑';
            upBtn.title = 'Move Up';
            upBtn.style.padding = '2px 5px';
            upBtn.onclick = (e) => {
                e.stopPropagation();
                moveLayer(index, -1);
            };

            const downBtn = document.createElement('button');
            downBtn.type = 'button';
            downBtn.className = 'pc-btn';
            downBtn.textContent = '↓';
            downBtn.title = 'Move Down';
            downBtn.style.padding = '2px 5px';
            downBtn.onclick = (e) => {
                e.stopPropagation();
                moveLayer(index, 1);
            };

            const delBtn = document.createElement('button');
            delBtn.type = 'button';
            delBtn.className = 'pc-btn';
            delBtn.style.borderColor = 'red';
            delBtn.style.color = 'red';
            delBtn.textContent = 'X';
            delBtn.title = 'Delete Layer';
            delBtn.onclick = (e) => {
                e.stopPropagation();
                if (layers.length <= 1) return;
                layer.canvas.remove();
                layers.splice(index, 1);
                if (activeLayerIndex === index) {
                    activeLayerIndex = Math.max(0, index - 1);
                } else if (activeLayerIndex > index) {
                    activeLayerIndex--;
                }
                renderLayerList();
            };

            item.onclick = () => {
                if (activeLayerIndex !== index) {
                    activeLayerIndex = index;
                    updateActiveLayerUI();
                }
            };

            item.appendChild(visBtn);
            item.appendChild(nameInput);
            item.appendChild(upBtn);
            item.appendChild(downBtn);
            item.appendChild(delBtn);
            list.appendChild(item);
        });
    }

    function injectCopyButtons() {
        const articles = document.querySelectorAll('.thread article');
        articles.forEach(article => {
            if (article.dataset.copyBtnInjected) return;

            const img = article.querySelector('img');
            if (!img) return;

            article.style.position = 'relative';

            const btn = document.createElement('button');
            btn.textContent = 'Remix';
            btn.className = 'pc-copy-layer-btn';

            btn.onclick = (e) => {
                e.preventDefault();
                if (!mainCanvasElement || !layersContainer) {
                    alert("Toolkit failed to load. Please hard-refresh the page with CTRL + SHIFT + R and try again.");
                    return;
                }

                createLayer();
                const activeCtx = layers[activeLayerIndex].ctx;
                activeCtx.drawImage(img, 0, 0, mainCanvasElement.width, mainCanvasElement.height);

                const originalText = btn.textContent;
                btn.textContent = 'Copied!';
                setTimeout(() => { btn.textContent = originalText; }, 1500);
            };

            article.appendChild(btn);
            article.dataset.copyBtnInjected = 'true';
        });
    }

    function setupUI(canvas) {
        layersContainer = document.createElement('div');
        layersContainer.style.position = 'absolute';
        layersContainer.style.pointerEvents = 'none';
        layersContainer.style.zIndex = '50';
        document.body.appendChild(layersContainer);

        eventCatcher = document.createElement('div');
        eventCatcher.style.position = 'absolute';
        eventCatcher.style.pointerEvents = 'none';
        eventCatcher.style.zIndex = '51';
        document.body.appendChild(eventCatcher);

        function trackCanvasPosition() {
            if (canvas && canvas.offsetParent) {
                const rect = canvas.getBoundingClientRect();

                if (rect.width > 0 && rect.height > 0) {
                    const t = (rect.top + window.scrollY) + 'px';
                    const l = (rect.left + window.scrollX) + 'px';
                    const w = rect.width + 'px';
                    const h = rect.height + 'px';

                    if (layersContainer.dataset.top !== t || layersContainer.dataset.left !== l || layersContainer.dataset.width !== w || layersContainer.dataset.height !== h) {
                        layersContainer.style.top = eventCatcher.style.top = t;
                        layersContainer.style.left = eventCatcher.style.left = l;
                        layersContainer.style.width = eventCatcher.style.width = w;
                        layersContainer.style.height = eventCatcher.style.height = h;

                        layersContainer.dataset.top = t;
                        layersContainer.dataset.left = l;
                        layersContainer.dataset.width = w;
                        layersContainer.dataset.height = h;
                    }
                }
            }
            trackRAF = requestAnimationFrame(trackCanvasPosition);
        }
        trackCanvasPosition();

        const style = document.createElement('style');
        style.textContent = `
            #custom-paperchan-ui {
                background: transparent;
                color: inherit;
                font-family: inherit;
                max-width: 400px;
                box-sizing: border-box;
                margin-top: 10px;
            }
            .pc-btn {
                background: transparent;
                color: inherit;
                border: 1px solid currentColor;
                padding: 4px 8px;
                cursor: pointer;
                font-family: inherit;
                font-size: 0.9em;
            }
            .pc-btn:hover {
                background: rgba(127,127,127,0.1);
            }
            .pc-layer-item {
                display: flex;
                align-items: center;
                gap: 6px;
                padding: 6px;
                border: 1px solid transparent;
                cursor: pointer;
                margin-bottom: 2px;
            }
            .pc-layer-item.active {
                border-color: currentColor;
                background: rgba(127,127,127,0.1);
            }
            .pc-layer-name-input {
                background: transparent;
                color: inherit;
                border: none;
                border-bottom: 1px dashed transparent;
                flex-grow: 1;
                font-family: inherit;
                font-size: inherit;
                outline: none;
                min-width: 50px;
                padding: 2px 4px;
                margin: 0 2px;
            }
            .pc-layer-name-input:focus, .pc-layer-name-input:hover {
                border-bottom-color: currentColor;
            }
        `;
        document.head.appendChild(style);

        const uiContainer = document.createElement('div');
        uiContainer.id = 'custom-paperchan-ui';
        uiContainer.innerHTML = `
            <div style="display: flex; gap: 15px; margin-bottom: 8px; align-items: center; flex-wrap: wrap;">
                <strong>Tool:</strong>
                <label style="cursor: pointer;"><input type="radio" name="pc-tool" value="draw" checked /> Draw</label>
                <label style="cursor: pointer;"><input type="radio" name="pc-tool" value="erase" /> Erase</label>
                <label style="cursor: pointer;"><input type="radio" name="pc-tool" value="fill" /> Fill</label>
            </div>
            <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px;">
                <strong>Layers</strong>
                <button class="pc-btn" id="pc-add-layer">+ New Layer</button>
            </div>
            <div id="pc-layer-list" style="max-height: 150px; overflow-y: auto; border: 1px solid currentColor; padding: 5px;"></div>
        `;

        canvas.parentNode.insertBefore(uiContainer, canvas.nextSibling);

        createLayer();

        document.getElementById('pc-add-layer').addEventListener('click', createLayer);

        document.querySelectorAll('input[name="pc-tool"]').forEach(radio => {
            radio.addEventListener('change', (e) => {
                currentTool = e.target.value;
                if (currentTool === 'fill') {
                    eventCatcher.style.pointerEvents = 'auto';
                } else {
                    eventCatcher.style.pointerEvents = 'none';
                }
            });
        });

        eventCatcher.addEventListener('pointerdown', handleFill);
    }

    function initSeamlessThreadToggle() {
        if (document.getElementById('pc-seamless-btn')) return;

        const seamlessStyle = document.createElement('style');
        seamlessStyle.textContent = `
            body.pc-seamless-mode .thread p {
                display: none !important;
            }
            body.pc-seamless-mode .thread article {
                margin: 0 !important;
                padding: 0 !important;
                border: 0 !important;
            }
            body.pc-seamless-mode .thread article img {
                display: block !important;
                margin: 0 !important;
                padding: 0 !important;
                border: 0 !important;
            }
            #pc-seamless-btn {
                position: fixed;
                bottom: 20px;
                right: 20px;
                z-index: 9999;
                background: transparent;
                color: inherit;
                border: 1px solid currentColor;
                padding: 8px 12px;
                cursor: pointer;
                font-family: inherit;
                font-size: 14px;
                display: none;
            }
            #pc-seamless-btn:hover {
                background: rgba(127,127,127,0.1);
            }
            body[data-is-thread="true"] #pc-seamless-btn {
                display: block;
            }
            .pc-copy-layer-btn {
                position: absolute;
                bottom: 10px;
                right: 10px;
                padding: 8px 12px;
                background: transparent;
                color: inherit;
                border: 1px solid currentColor;
                cursor: pointer;
                font-family: inherit;
                font-size: 14px;
                z-index: 10;
            }
            .pc-copy-layer-btn:hover {
                background: rgba(127,127,127,0.1);
            }
            body.pc-seamless-mode .pc-copy-layer-btn {
                display: none !important;
            }
        `;
        document.head.appendChild(seamlessStyle);

        const toggleBtn = document.createElement('button');
        toggleBtn.id = 'pc-seamless-btn';
        toggleBtn.textContent = 'Hide Metadata';
        document.body.appendChild(toggleBtn);

        toggleBtn.addEventListener('click', () => {
            const isSeamless = document.body.classList.toggle('pc-seamless-mode');
            toggleBtn.textContent = isSeamless ? 'Show Metadata' : 'Hide Metadata';
        });

        const checkThreadURL = () => {
            if (window.location.href.includes('/thread/')) {
                document.body.setAttribute('data-is-thread', 'true');
            } else {
                document.body.removeAttribute('data-is-thread');
            }
        };

        checkThreadURL();
        injectCopyButtons();

        let lastUrl = window.location.href;
        const domObserver = new MutationObserver(() => {
            if (window.location.href !== lastUrl) {
                lastUrl = window.location.href;
                checkThreadURL();
            }
            injectCopyButtons();
        });
        domObserver.observe(document.body, { childList: true, subtree: true });
    }

    document.addEventListener('DOMContentLoaded', initSeamlessThreadToggle);
    if (document.readyState === 'interactive' || document.readyState === 'complete') {
        initSeamlessThreadToggle();
    }
})();