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

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
})();