Custom Cursor Overlay (Upload, Angle, Tip, Size)

Upload your own cursor image, set the tip, adjust angle & size, with live preview. Safari Userscripts compatible.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Custom Cursor Overlay (Upload, Angle, Tip, Size)
// @namespace    https://greasyfork.org/users/your-name
// @version      1.1.0
// @description  Upload your own cursor image, set the tip, adjust angle & size, with live preview. Safari Userscripts compatible.
// @author       You
// @match        *://*/*
// @run-at       document-start
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function() {
    'use strict';

    /********************************************************************
     * Storage abstraction (GM_* if available, fallback to localStorage)
     ********************************************************************/
    const STORAGE_PREFIX = 'customCursorOverlay_';

    function canUseGM() {
        return typeof GM_getValue === 'function' && typeof GM_setValue === 'function';
    }

    function setStored(key, value) {
        const fullKey = STORAGE_PREFIX + key;
        if (canUseGM()) {
            try { GM_setValue(fullKey, value); return; } catch (e) {}
        }
        try { localStorage.setItem(fullKey, JSON.stringify(value)); } catch (e) {}
    }

    function getStored(key, defaultValue) {
        const fullKey = STORAGE_PREFIX + key;
        if (canUseGM()) {
            try {
                const v = GM_getValue(fullKey);
                return (typeof v === 'undefined') ? defaultValue : v;
            } catch (e) {}
        }
        try {
            const raw = localStorage.getItem(fullKey);
            if (raw === null) return defaultValue;
            return JSON.parse(raw);
        } catch (e) {
            return defaultValue;
        }
    }

    /********************************************************************
     * State
     ********************************************************************/
    const state = {
        dataUrl: getStored('dataUrl', null),
        tipX:    getStored('tipX', 0),
        tipY:    getStored('tipY', 0),
        angle:   getStored('angle', 0),
        scale:   getStored('scale', 1.0),
        imgNaturalWidth:  getStored('imgNaturalWidth', 32),
        imgNaturalHeight: getStored('imgNaturalHeight', 32)
    };

    /********************************************************************
     * DOM helpers
     ********************************************************************/
    function createEl(tag, props, children) {
        const el = document.createElement(tag);
        if (props) {
            Object.entries(props).forEach(([k, v]) => {
                if (k === 'style') Object.assign(el.style, v);
                else if (k === 'class') el.className = v;
                else if (k === 'dataset') Object.entries(v).forEach(([dk, dv]) => el.dataset[dk] = dv);
                else el[k] = v;
            });
        }
        if (children) children.forEach(c => c && el.appendChild(c));
        return el;
    }

    /********************************************************************
     * Inject styles
     ********************************************************************/
    function injectStyles() {
        const css = `
            html, body, * { cursor: none !important; }

            .ccov-settings-toggle {
                position: fixed; bottom: 12px; right: 12px;
                width: 32px; height: 32px; border-radius: 50%;
                background: rgba(20,20,20,0.9); color: #fff;
                display: flex; align-items: center; justify-content: center;
                font-size: 18px; z-index: 2147483646;
                box-shadow: 0 2px 8px rgba(0,0,0,0.4);
                user-select: none;
            }

            .ccov-panel {
                position: fixed; bottom: 56px; right: 12px;
                width: 320px; background: rgba(12,12,12,0.96);
                color: #eee; border-radius: 12px;
                box-shadow: 0 10px 30px rgba(0,0,0,0.6);
                padding: 12px; z-index: 2147483646;
                backdrop-filter: blur(18px);
                border: 1px solid rgba(255,255,255,0.06);
            }

            .ccov-preview-container {
                position: relative; width: 100%; height: 140px;
                background: radial-gradient(circle at 20% 20%, #222 0, #111 45%, #050505 100%);
                border-radius: 10px; margin-top: 6px; overflow: hidden;
                border: 1px solid rgba(255,255,255,0.04);
            }

            .ccov-cursor-overlay {
                position: fixed; left: 0; top: 0;
                z-index: 2147483647; pointer-events: none;
                image-rendering: pixelated;
                transform-origin: 0 0;
            }
        `;
        const style = document.createElement('style');
        style.textContent = css;
        document.documentElement.appendChild(style);
    }

    /********************************************************************
     * Cursor overlay
     ********************************************************************/
    let cursorEl = null;
    let lastX = null, lastY = null;

    function ensureCursorElement() {
        if (!cursorEl) {
            cursorEl = createEl('img', { className: 'ccov-cursor-overlay', draggable: false });
            document.documentElement.appendChild(cursorEl);
        }
        return cursorEl;
    }

    function updateCursorFromState() {
        if (!state.dataUrl) return;
        const el = ensureCursorElement();
        el.src = state.dataUrl;
        el.style.transformOrigin = `${state.tipX}px ${state.tipY}px`;
        el.style.transform = `rotate(${state.angle}deg) scale(${state.scale})`;
    }

    function updateCursorPosition(x, y) {
        lastX = x; lastY = y;
        if (!cursorEl || !state.dataUrl) return;
        cursorEl.style.left = (x - state.tipX * state.scale) + 'px';
        cursorEl.style.top  = (y - state.tipY * state.scale) + 'px';
    }

    function installPointerListeners() {
        window.addEventListener('pointermove', e => updateCursorPosition(e.clientX, e.clientY), { passive: true });
        window.addEventListener('mousemove', e => updateCursorPosition(e.clientX, e.clientY), { passive: true });
    }

    /********************************************************************
     * Settings UI + Preview
     ********************************************************************/
    let panelEl, previewCanvas, previewCtx, sliderEl, sliderValueEl, sizeSlider, sizeValue;

    const previewImg = new Image();
    previewImg.onload = function() {
        state.imgNaturalWidth = previewImg.naturalWidth;
        state.imgNaturalHeight = previewImg.naturalHeight;
        setStored('imgNaturalWidth', state.imgNaturalWidth);
        setStored('imgNaturalHeight', state.imgNaturalHeight);
        redrawPreview();
        updateCursorFromState();
    };

    function openPanel() {
        if (panelEl) return panelEl.style.display = 'block';

        const toggle = createEl('div', { className: 'ccov-settings-toggle' }, [document.createTextNode('⚙︎')]);
        toggle.onclick = () => panelEl.style.display = panelEl.style.display === 'none' ? 'block' : 'none';
        document.documentElement.appendChild(toggle);

        panelEl = createEl('div', { className: 'ccov-panel' });

        /******** Header ********/
        const header = createEl('div', { style: { display: 'flex', justifyContent: 'space-between' } }, [
            createEl('div', { style: { fontSize: '13px', opacity: 0.8 } }, [document.createTextNode('Custom Cursor Overlay')]),
            createEl('div', { style: { cursor: 'pointer' } }, [document.createTextNode('✕')])
        ]);
        header.lastChild.onclick = () => panelEl.style.display = 'none';
        panelEl.appendChild(header);

        /******** File Upload ********/
        panelEl.appendChild(createEl('div', { style: { fontSize: '12px', opacity: 0.7, marginTop: '6px' } }, [
            document.createTextNode('Cursor image')
        ]));

        const fileInput = createEl('input', { type: 'file', accept: 'image/*', style: { width: '100%' } });
        fileInput.onchange = e => {
            const file = e.target.files?.[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = ev => {
                state.dataUrl = ev.target.result;
                setStored('dataUrl', state.dataUrl);
                previewImg.src = state.dataUrl;
                state.tipX = 0; state.tipY = 0;
                setStored('tipX', 0); setStored('tipY', 0);
            };
            reader.readAsDataURL(file);
        };
        panelEl.appendChild(fileInput);

        /******** Preview ********/
        const previewContainer = createEl('div', { className: 'ccov-preview-container' });
        previewCanvas = createEl('canvas');
        previewContainer.appendChild(previewCanvas);
        panelEl.appendChild(previewContainer);

        previewCtx = previewCanvas.getContext('2d');
        resizePreviewCanvas();
        window.addEventListener('resize', resizePreviewCanvas);

        previewCanvas.onclick = e => {
            if (!state.dataUrl) return;
            const rect = previewCanvas.getBoundingClientRect();
            const cx = e.clientX - rect.left;
            const cy = e.clientY - rect.top;
            const info = computePreviewScale();
            const imgX = (cx - info.offsetX) / (info.scale * state.scale);
            const imgY = (cy - info.offsetY) / (info.scale * state.scale);
            state.tipX = Math.max(0, Math.min(state.imgNaturalWidth, imgX));
            state.tipY = Math.max(0, Math.min(state.imgNaturalHeight, imgY));
            setStored('tipX', state.tipX);
            setStored('tipY', state.tipY);
            redrawPreview();
            updateCursorFromState();
        };

        /******** Angle Presets ********/
        panelEl.appendChild(createEl('div', { style: { fontSize: '12px', opacity: 0.7, marginTop: '6px' } }, [
            document.createTextNode('Angle presets')
        ]));

        const angRow = createEl('div', { style: { display: 'flex', gap: '4px', marginTop: '4px' } });
        [0,45,90,135,180].forEach(a => {
            const btn = createEl('button', { style: { flex: 1, padding: '4px', fontSize: '11px' } }, [
                document.createTextNode(a + '°')
            ]);
            btn.onclick = () => {
                state.angle = a;
                setStored('angle', a);
                sliderEl.value = a;
                sliderValueEl.textContent = a + '°';
                redrawPreview();
                updateCursorFromState();
            };
            angRow.appendChild(btn);
        });
        panelEl.appendChild(angRow);

        /******** Angle Slider ********/
        panelEl.appendChild(createEl('div', { style: { fontSize: '12px', opacity: 0.7, marginTop: '6px' } }, [
            document.createTextNode('Custom angle')
        ]));

        sliderEl = createEl('input', { type: 'range', min: '0', max: '360', step: '1', value: state.angle });
        sliderValueEl = createEl('div', { style: { fontSize: '11px', width: '40px', textAlign: 'right' } }, [
            document.createTextNode(state.angle + '°')
        ]);

        const sliderRow = createEl('div', { style: { display: 'flex', gap: '8px', marginTop: '4px' } }, [
            sliderEl, sliderValueEl
        ]);

        sliderEl.oninput = () => {
            state.angle = parseInt(sliderEl.value);
            setStored('angle', state.angle);
            sliderValueEl.textContent = state.angle + '°';
            redrawPreview();
            updateCursorFromState();
        };

        panelEl.appendChild(sliderRow);

        /******** Size Slider ********/
        panelEl.appendChild(createEl('div', { style: { fontSize: '12px', opacity: 0.7, marginTop: '6px' } }, [
            document.createTextNode('Cursor size')
        ]));

        sizeSlider = createEl('input', { type: 'range', min: '0.1', max: '3.0', step: '0.01', value: state.scale });
        sizeValue = createEl('div', { style: { fontSize: '11px', width: '40px', textAlign: 'right' } }, [
            document.createTextNode(Math.round(state.scale * 100) + '%')
        ]);

        const sizeRow = createEl('div', { style: { display: 'flex', gap: '8px', marginTop: '4px' } }, [
            sizeSlider, sizeValue
        ]);

        sizeSlider.oninput = () => {
            state.scale = parseFloat(sizeSlider.value);
            setStored('scale', state.scale);
            sizeValue.textContent = Math.round(state.scale * 100) + '%';
            redrawPreview();
            updateCursorFromState();
        };

        panelEl.appendChild(sizeRow);

        /******** Save Button ********/
        const saveBtn = createEl('button', { style: { marginTop: '10px', padding: '6px 12px', fontSize: '11px' } }, [
            document.createTextNode('Save & Apply')
        ]);
        saveBtn.onclick = () => {
            updateCursorFromState();
            if (lastX !== null) updateCursorPosition(lastX, lastY);
        };
        panelEl.appendChild(saveBtn);

        document.documentElement.appendChild(panelEl);

        if (state.dataUrl) previewImg.src = state.dataUrl;
        redrawPreview();
    }

    /********************************************************************
     * Preview Rendering
     ********************************************************************/
    function resizePreviewCanvas() {
        if (!previewCanvas) return;
        const rect = previewCanvas.getBoundingClientRect();
        const dpr = window.devicePixelRatio || 1;
        previewCanvas.width = rect.width * dpr;
        previewCanvas.height = rect.height * dpr;
        previewCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
        redrawPreview();
    }

    function computePreviewScale() {
        const width = previewCanvas.clientWidth;
        const height = previewCanvas.clientHeight;
        const imgW = state.imgNaturalWidth;
        const imgH = state.imgNaturalHeight;

        const scale = Math.min(width * 0.8 / imgW, height * 0.8 / imgH);
        const drawW = imgW * scale * state.scale;
        const drawH = imgH * scale * state.scale;
        const offsetX = (width - drawW) / 2;
        const offsetY = (height - drawH) / 2;

        return { scale, offsetX, offsetY, drawW, drawH };
    }

    function redrawPreview() {
        if (!previewCtx) return;
        const width = previewCanvas.clientWidth;
        const height = previewCanvas.clientHeight;

        previewCtx.clearRect(0, 0, width, height);

        if (!state.dataUrl || !previewImg.complete) return;

        const info = computePreviewScale();
        const tipXScaled = info.offsetX + state.tipX * info.scale * state.scale;
        const tipYScaled = info.offsetY + state.tipY * info.scale * state.scale;

        previewCtx.save();
        previewCtx.translate(tipXScaled, tipYScaled);
        previewCtx.rotate(state.angle * Math.PI / 180);
        previewCtx.drawImage(
            previewImg,
            -state.tipX * info.scale * state.scale,
            -state.tipY * info.scale * state.scale,
            state.imgNaturalWidth * info.scale * state.scale,
            state.imgNaturalHeight * info.scale * state.scale
        );
        previewCtx.restore();

        previewCtx.save();
        previewCtx.fillStyle = '#f97316';
        previewCtx.strokeStyle = '#000';
        previewCtx.lineWidth = 1.5;
        previewCtx.beginPath();
        previewCtx.arc(tipXScaled, tipYScaled, 4, 0, Math.PI * 2);
        previewCtx.fill();
        previewCtx.stroke();
        previewCtx.restore();
    }

    /********************************************************************
     * Init
     ********************************************************************/
    function init() {
        injectStyles();
        installPointerListeners();

        if (state.dataUrl) {
            ensureCursorElement();
            previewImg.src = state.dataUrl;
            updateCursorFromState();
        }

        if (document.readyState === 'complete' || document.readyState === 'interactive') {
            openPanel();
        } else {
            window.addEventListener('DOMContentLoaded', openPanel, { once: true });
        }
    }

    init();
})();