Paperchan Toolkit

Adds a bunch of new features to Paperchan.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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