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();
    }
})();