// ==UserScript==
// @name Straw Tools+
// @namespace https://straw.page/
// @version 1.0
// @description enhanced toolbox for straw.page picasso (eyedrop and eraser and more)
// @match https://*.straw.page/*
// @run-at document-idle
// @grant none
// @license GPL-3.0-only
// ==/UserScript==
(function () {
'use strict';
// --- CURSOR HELPERS (add/replace this block) ---
let cursorStyleEl;
function ensureCursorStyleEl() {
if (!cursorStyleEl) {
cursorStyleEl = document.createElement('style');
cursorStyleEl.id = 'sp-cursor-css';
document.head.appendChild(cursorStyleEl);
}
return cursorStyleEl;
}
// Brush: clean double-outline ring (no fill)
function makeBrushCursorDataUrl(d) {
const r = (d / 2).toFixed(2);
const strokeBlack = Math.max(0.7, d * 0.10);
const strokeWhite = strokeBlack * 1.6;
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='${d}' height='${d}'>` +
`<circle cx='${r}' cy='${r}' r='${(d / 2 - strokeWhite / 2).toFixed(2)}' fill='none' stroke='white' stroke-width='${strokeWhite}'/>` +
`<circle cx='${r}' cy='${r}' r='${(d / 2 - strokeBlack / 2).toFixed(2)}' fill='none' stroke='black' stroke-width='${strokeBlack}'/>` +
`</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
// Eraser: same ring but dashed to subtly differentiate
function makeEraserCursorDataUrl(d) {
const r = (d / 2).toFixed(2);
const strokeBlack = Math.max(0.7, d * 0.10);
const strokeWhite = strokeBlack * 1.6;
const dash = Math.max(2, Math.round(d * 0.35)); // dash length scales with size
const gap = Math.max(2, Math.round(d * 0.25)); // gap length scales with size
const dashAttr = `stroke-dasharray='${dash} ${gap}'`;
const svg =
`<svg xmlns='http://www.w3.org/2000/svg' width='${d}' height='${d}'>` +
`<circle cx='${r}' cy='${r}' r='${(d / 2 - strokeWhite / 2).toFixed(2)}' fill='none' stroke='white' stroke-width='${strokeWhite}' ${dashAttr}/>` +
`<circle cx='${r}' cy='${r}' r='${(d / 2 - strokeBlack / 2).toFixed(2)}' fill='none' stroke='black' stroke-width='${strokeBlack}' ${dashAttr}/>` +
`</svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
}
function applyCursors(brushDiam, eraserDiam) {
ensureCursorStyleEl();
const bUrl = makeBrushCursorDataUrl(brushDiam);
const eUrl = makeEraserCursorDataUrl(eraserDiam);
const bHot = `${(brushDiam / 2 | 0)} ${(brushDiam / 2 | 0)}`;
const eHot = `${(eraserDiam / 2 | 0)} ${(eraserDiam / 2 | 0)}`;
cursorStyleEl.textContent =
`.picasso canvas.brush-cursor{cursor:url("${bUrl}") ${bHot}, crosshair !important;}` +
`.picasso canvas.eraser-cursor{cursor:url("${eUrl}") ${eHot}, crosshair !important;}`;
}
// --- sync helper ---
const rgbStrToHex = (rgb) => {
const m = rgb && rgb.match(/\d+/g);
if (!m) return null;
const [r, g, b] = m.map(n => parseInt(n, 10));
const h = n => n.toString(16).padStart(2, '0');
return `#${h(r)}${h(g)}${h(b)}`;
};
const applyColor = (rgbOrHex) => {
const swatch = document.querySelector('.canvCol');
const hex = rgbOrHex.startsWith('#') ? rgbOrHex : rgbStrToHex(rgbOrHex);
if (!hex) return;
if (swatch) { swatch.style.background = rgbOrHex; swatch.setAttribute('data-color', hex); }
window.__spForcedColor = hex;
const colpick = document.querySelector('#colpick');
if (colpick) {
colpick.value = hex;
colpick.dispatchEvent(new Event('input', { bubbles: true }));
colpick.dispatchEvent(new Event('change', { bubbles: true }));
}
};
// --- watch the floating Straw color picker ---
(function watchFloatingPicker() {
const attach = () => {
const hl = document.querySelector('.colorPicker .colorHighlight');
if (!hl) return false;
// initial
const rgb = getComputedStyle(hl).backgroundColor;
if (rgb) applyColor(rgb);
new MutationObserver(() => {
const rgb2 = getComputedStyle(hl).backgroundColor;
if (rgb2) applyColor(rgb2);
}).observe(hl, { attributes: true, attributeFilter: ['style'] });
const hue = document.querySelector('.colorPicker .hueSlider');
if (hue) hue.addEventListener('input', () => {
const rgb3 = getComputedStyle(hl).backgroundColor;
if (rgb3) applyColor(rgb3);
});
return true;
};
if (!attach()) {
const mo = new MutationObserver(() => { if (attach()) mo.disconnect(); });
mo.observe(document.documentElement, { childList: true, subtree: true });
}
})();
// --- Hook stroke() so we can set color at the last moment ---
(function installStrokeHookOnce() {
const P = CanvasRenderingContext2D.prototype;
if (P.__spStrokeHooked) return;
P.__spStrokeHooked = true;
const orig = P.stroke;
// global forced color (kept in sync with eyedropper AND built-in picker)
window.__spForcedColor = null;
P.stroke = function (...args) {
const prev = this.strokeStyle;
if (window.__spForcedColor) this.strokeStyle = window.__spForcedColor;
const r = orig.apply(this, args);
this.strokeStyle = prev;
return r;
};
// Cover OffscreenCanvas too (harmless if absent)
if (self.OffscreenCanvas && self.OffscreenCanvasRenderingContext2D) {
const P2 = OffscreenCanvasRenderingContext2D.prototype;
if (!P2.__spStrokeHooked) {
P2.__spStrokeHooked = true;
const orig2 = P2.stroke;
P2.stroke = function (...args) {
const prev = this.strokeStyle;
if (window.__spForcedColor) this.strokeStyle = window.__spForcedColor;
const r = orig2.apply(this, args);
this.strokeStyle = prev;
return r;
};
}
}
})();
// --- Enhance the UI once it appears ---
let mo;
const addStyles = () => {
if (document.getElementById('sp-tools-css')) return;
const s = document.createElement('style');
s.id = 'sp-tools-css';
// inside addStyles()
s.textContent = `
.toolbox .tool.sp-temp { outline-style: dashed; }
.toolbox .tool{border:0;background:#f6f6f6;padding:6px 10px;margin-left:6px;border-radius:8px;cursor:pointer;font:inherit;display:inline-flex;align-items:center;justify-content:center;}
.toolbox .tool i{font-size:16px;line-height:1;vertical-align:middle;}
.toolbox .tool.active{outline:2px solid #333;}
.picasso canvas.eyedropper-cursor{cursor:crosshair!important;}
/* cursors are injected dynamically by applyCursors(); these are just fallbacks */
.picasso canvas.brush-cursor{cursor:crosshair!important;}
.picasso canvas.eraser-cursor{cursor:crosshair!important;}
.toolbox .fa-undo,
.toolbox .canvCol,
.toolbox #colpick,
.toolbox .canvSizing input[type=range]{
cursor: pointer;
}
`;
document.head.appendChild(s);
};
function enhanceOnce() {
const toolbox = document.querySelector('.innerCanvas .toolbox');
const canvas = document.querySelector('.picasso canvas');
const colorInput = document.querySelector('#colpick');
if (!toolbox || !canvas || !colorInput) return false;
if (canvas.dataset.spEnhanced === '1') return true;
canvas.dataset.spEnhanced = '1';
addStyles();
// Buttons (only once)
if (!toolbox.querySelector('[data-sp-tool]')) {
const mk = (name, title, iconClass) => {
const b = document.createElement('button');
b.className = 'tool';
b.dataset.spTool = name;
b.title = title;
b.innerHTML = `<i class="${iconClass}"></i>`;
return b;
};
toolbox.append(
mk('brush', 'Brush (B)', 'fas fa-paint-brush'),
mk('eraser', 'Eraser (E)', 'fas fa-eraser'),
mk('eyedropper', 'Eyedropper (I / hold Alt)', 'fas fa-eye-dropper')
);
const infoBtn = document.createElement('button');
infoBtn.className = 'tool';
infoBtn.dataset.spTool = 'info';
infoBtn.title = 'straw-tools+, made by boni\nhttps://boni.straw.page/';
infoBtn.innerHTML = `<i class="fas fa-info-circle"></i>`;
toolbox.appendChild(infoBtn);
// Optional: make it clickable to open the link directly
infoBtn.addEventListener('click', (e) => {
window.open('https://greasyfork.org/en/scripts/553255-straw-tools', '_blank');
e.stopPropagation();
e.preventDefault();
});
// Enable clicking our injected tool buttons
toolbox.addEventListener('click', (ev) => {
const btn = ev.target.closest('[data-sp-tool]');
if (!btn) return;
const name = btn.dataset.spTool; // "brush" | "eraser" | "eyedropper"
setTool(name);
ev.stopPropagation(); // don't let site re-toggle something else
ev.preventDefault();
});
}
const ctx = canvas.getContext('2d', { willReadFrequently: true });
// find the size slider
const sizeSlider = document.querySelector('.canvSizing input[type=range]');
// map slider -> lineWidth directly
const sliderToLineWidth = v => parseFloat(v) || 4;
function refreshCursors() {
const d = Math.max(8, Math.round(sliderToLineWidth(sizeSlider?.value ?? 4)));
applyCursors(d, d); // brush and eraser use same diameter
}
// init + react to changes
refreshCursors();
sizeSlider?.addEventListener('input', refreshCursors);
sizeSlider?.addEventListener('change', refreshCursors);
const swatch = document.querySelector('.canvCol');
const setSwatch = v => { if (swatch) { swatch.style.background = v; swatch.setAttribute('data-color', v); } };
let tool = 'brush', altSampling = false, erasing = false, swallowNextClick = false;
const setTool = (t) => {
tool = t;
if (tool !== 'eraser' && ctx.globalCompositeOperation !== 'source-over') {
ctx.globalCompositeOperation = 'source-over';
}
toolbox.querySelectorAll('[data-sp-tool]').forEach(b => b.classList.remove('active'));
toolbox.querySelector(`[data-sp-tool="${tool}"]`)?.classList.add('active');
if (tool === 'eraser') {
refreshCursors();
canvas.classList.add('eraser-cursor');
canvas.classList.remove('brush-cursor');
} else if (tool === 'brush') {
refreshCursors();
canvas.classList.add('brush-cursor');
canvas.classList.remove('eraser-cursor');
} else {
canvas.classList.remove('brush-cursor');
canvas.classList.remove('eraser-cursor');
}
canvas.classList.toggle('eyedropper-cursor', tool === 'eyedropper' || altSampling);
};
// Visual highlight only (doesn't change tool or composite op)
function setActiveVisual(name) {
const all = toolbox.querySelectorAll('[data-sp-tool]');
all.forEach(b => b.classList.remove('active', 'sp-temp'));
const b = toolbox.querySelector(`[data-sp-tool="${name}"]`);
if (b) b.classList.add('active', 'sp-temp'); // sp-temp = temporary highlight (Alt)
}
function restoreActiveVisual() {
const all = toolbox.querySelectorAll('[data-sp-tool]');
all.forEach(b => b.classList.remove('active', 'sp-temp'));
toolbox.querySelector(`[data-sp-tool="${tool}"]`)?.classList.add('active');
}
setTool('brush');
setSwatch(colorInput.value);
// Keep forced color IN SYNC with built-in picker changes
const syncFromPicker = (hex) => {
setSwatch(hex);
window.__spForcedColor = hex; // now strokes follow the picker as well
};
colorInput.addEventListener('input', e => syncFromPicker(e.target.value));
colorInput.addEventListener('change', e => syncFromPicker(e.target.value));
// Also watch swatch attribute/style (in case site sets presets without updating #colpick first)
if (swatch) {
new MutationObserver(() => {
const hex = swatch.getAttribute('data-color');
if (hex && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) {
window.__spForcedColor = hex;
}
}).observe(swatch, { attributes: true, attributeFilter: ['data-color', 'style'] });
}
const fireColorEvents = (hex) => {
colorInput.value = hex;
setSwatch(hex);
colorInput.dispatchEvent(new Event('input', { bubbles: true }));
colorInput.dispatchEvent(new Event('change', { bubbles: true }));
colorInput.blur?.();
};
const toHex = (r, g, b) => `#${[r, g, b].map(n => n.toString(16).padStart(2, '0')).join('')}`;
const getXY = (evt) => {
const r = canvas.getBoundingClientRect(), p = evt.touches ? evt.touches[0] : evt;
return { x: (p.clientX - r.left) * (canvas.width / r.width), y: (p.clientY - r.top) * (canvas.height / r.height) };
};
const sampleAt = (x, y) => {
const d = ctx.getImageData(Math.floor(x), Math.floor(y), 1, 1).data;
const hex = toHex(d[0], d[1], d[2]);
// update UI + force strokes to use it
fireColorEvents(hex);
window.__spForcedColor = hex;
};
// --- Tap vs Hold E behavior ---
let eKeyDownAt = 0;
let ePrevToolForHold = null;
const E_HOLD_MS = 300; // ≥ this = temporary hold, < this = tap toggle (stay on eraser)
// small helper to avoid hijacking while typing/sliding
function allowToolHotkeys(ev) {
const ae = document.activeElement;
const tag = (ae && ae.tagName) || '';
const isRange = tag === 'INPUT' && ae.type === 'range';
const isTyping = (tag === 'INPUT' && !isRange) || tag === 'TEXTAREA' || ae?.isContentEditable;
if (isRange) ae.blur();
return !isTyping;
}
window.addEventListener('keydown', (e) => {
if (!allowToolHotkeys(e)) return;
// E = start timing; switch to eraser immediately
if ((e.key === 'e' || e.key === 'E') && !e.repeat) {
eKeyDownAt = performance.now();
ePrevToolForHold = tool; // remember the tool to snap back to on a long hold
setTool('eraser');
}
// explicit tool keys
if (e.key === 'b' || e.key === 'B') setTool('brush');
if (e.key === 'i' || e.key === 'I') setTool('eyedropper');
// size keys: [ / ]
if (e.key === '[' || e.key === ']') {
if (!sizeSlider) return;
const step = parseFloat(sizeSlider.step || '1') || 1;
const min = parseFloat(sizeSlider.min || '1') || 1;
const max = parseFloat(sizeSlider.max || '64') || 64;
let val = parseFloat(sizeSlider.value || String(min)) || min;
val += (e.key === ']') ? step : -step;
val = Math.max(min, Math.min(max, val));
sizeSlider.value = String(val);
sizeSlider.dispatchEvent(new Event('input', { bubbles: true }));
sizeSlider.dispatchEvent(new Event('change', { bubbles: true }));
refreshCursors();
e.preventDefault();
}
// ALT = temporary eyedropper (unchanged)
if (e.altKey && !altSampling) {
altSampling = true;
canvas.classList.add('eyedropper-cursor');
canvas.classList.remove('brush-cursor', 'eraser-cursor');
setActiveVisual('eyedropper');
}
if (e.key === 'L' && e.shiftKey) {
window.__spForcedColor = null;
console.log('[color forcing cleared]');
}
}, { capture: true });
window.addEventListener('keyup', (e) => {
if (e.key === 'e' || e.key === 'E') {
const heldFor = performance.now() - (eKeyDownAt || performance.now());
const wasHold = heldFor >= E_HOLD_MS;
if (wasHold && ePrevToolForHold && ePrevToolForHold !== 'eraser') {
// long hold → snap back to the previous tool
setTool(ePrevToolForHold);
}
// quick tap (< E_HOLD_MS) → stay on eraser
eKeyDownAt = 0;
ePrevToolForHold = null;
}
// release ALT -> restore real tool + visuals (unchanged)
if (!e.altKey && altSampling) {
altSampling = false;
canvas.classList.remove('eyedropper-cursor');
restoreActiveVisual();
if (tool === 'brush') canvas.classList.add('brush-cursor');
if (tool === 'eraser') canvas.classList.add('eraser-cursor');
}
}, { capture: true });
// pointer (capture) so we run before site handlers
function onDown(e) {
if (tool === 'eyedropper' || altSampling) {
const { x, y } = getXY(e);
sampleAt(x, y); // sets __spForcedColor
swallowNextClick = true; // avoid a dot from the click/tap that picked
// auto-switch back to brush if user explicitly chose the eyedropper button
if (tool === 'eyedropper') {
// switch on the next frame to avoid racing site handlers
requestAnimationFrame(() => {
setTool('brush');
altSampling = false;
canvas.classList.remove('eyedropper-cursor');
});
}
e.stopImmediatePropagation?.();
e.preventDefault();
return;
}
if (tool === 'eraser') {
erasing = true;
ctx.globalCompositeOperation = 'destination-out';
return;
}
}
function onUp() {
if (erasing) {
swallowNextClick = true;
requestAnimationFrame(() => { ctx.globalCompositeOperation = 'source-over'; erasing = false; });
}
}
function onClick(e) {
if (swallowNextClick) {
swallowNextClick = false;
e.stopImmediatePropagation?.();
e.preventDefault();
}
}
canvas.addEventListener('mousedown', onDown, { capture: true, passive: false });
canvas.addEventListener('touchstart', onDown, { capture: true, passive: false });
window.addEventListener('mouseup', onUp, { capture: true, passive: false });
window.addEventListener('touchend', onUp, { capture: true, passive: false });
canvas.addEventListener('click', onClick, { capture: true, passive: false });
window.addEventListener('pointercancel', onUp, { capture: true, passive: false });
return true;
}
function start() {
const ok = enhanceOnce();
if (ok && mo) { mo.disconnect(); mo = null; return; }
if (!mo) {
mo = new MutationObserver(() => { if (enhanceOnce()) { mo.disconnect(); mo = null; } });
mo.observe(document.documentElement, { childList: true, subtree: true });
}
}
if (document.readyState === 'complete') start();
else window.addEventListener('load', start, { once: true });
window.addEventListener('pageshow', (e) => { if (e.persisted) start(); });
})();