Adds a bunch of new features to Paperchan.
// ==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();
}
})();