NYTimes Strands Drawing Overlay

Adds a togglable drawing canvas overlay to the NYTimes.com Strands game.

// ==UserScript==
// @name       NYTimes Strands Drawing Overlay
// @namespace  http://mathemaniac.org/
// @version    1.0.0
// @description  Adds a togglable drawing canvas overlay to the NYTimes.com Strands game.
// @match      https://www.nytimes.com/games/strands
// @copyright  2025, Sebastian Paaske Tørholm
// @grant      none
// @license    MIT
// ==/UserScript==

(function () {
    'use strict';

    // --- Configuration ---
    const DEFAULT_BRUSH_SIZE = 30;
    const MIN_BRUSH_SIZE = 1;
    const MAX_BRUSH_SIZE = 60;
    const COLORS = ['#FF0000', '#0000FF', '#00FF00', '#FFFF00', '#000000']; // Red, Blue, Green, Yellow, Black
    const DEFAULT_COLOR = COLORS[0]; // Red
    const OVERLAY_OPACITY = 0.4; // 40%
    const CANVAS_BACKGROUND_COLOR = 'rgba(200, 200, 200, 0.3)'; // Slight gray tinge

    // --- Utility Functions ---
    const log = (...args) => console.log('[StrandsDrawingOverlay]', ...args);

    // --- State Management ---
    let isDrawing = false;
    let lastX = 0;
    let lastY = 0;
    let currentColor = DEFAULT_COLOR;
    let currentBrushSize = DEFAULT_BRUSH_SIZE;
    let isOverlayVisible = false;
    let dpr = window.devicePixelRatio || 1; // Store Device Pixel Ratio

    // --- DOM Elements ---
    let gameBoardContainer = null;
    let canvasOverlay = null;
    let ctx = null;
    let controlsContainer = null;
    let toggleButton = null;
    let colorButtons = [];
    let cursorPreview = null;

    // --- Storage ---
    const getGameDate = () => {
        const dateElement = document.getElementById('portal-game-date');
        return dateElement ? dateElement.textContent.trim() : new Date().toDateString();
    };

    const getStorageKey = (suffix) => `strands_drawing_${getGameDate()}_${suffix}`;

    const saveCanvasState = () => {
        if (!canvasOverlay) return;
        try {
            const dataURL = canvasOverlay.toDataURL();
            localStorage.setItem(getStorageKey('canvas'), dataURL);
            log('Canvas state saved.');
        } catch (e) {
            console.error('Failed to save canvas state:', e);
        }
    };

    const loadCanvasState = () => {
        if (!canvasOverlay || !ctx) return;
        const dataURL = localStorage.getItem(getStorageKey('canvas'));
        if (dataURL) {
            const img = new Image();
            img.onload = () => {
                ctx.clearRect(0, 0, canvasOverlay.width, canvasOverlay.height);
                ctx.drawImage(img, 0, 0);
                log('Canvas state loaded.');
            };
            img.src = dataURL;
        }
    };

    const saveSettings = () => {
        localStorage.setItem(getStorageKey('color'), currentColor);
        localStorage.setItem(getStorageKey('brushSize'), currentBrushSize.toString());
        log('Settings saved.');
    };

    const loadSettings = () => {
        const storedColor = localStorage.getItem(getStorageKey('color'));
        const storedBrushSize = localStorage.getItem(getStorageKey('brushSize'));

        if (storedColor && COLORS.includes(storedColor)) {
            currentColor = storedColor;
        }
        if (storedBrushSize) {
            const size = parseInt(storedBrushSize, 10);
            if (!isNaN(size) && size >= MIN_BRUSH_SIZE && size <= MAX_BRUSH_SIZE) {
                currentBrushSize = size;
            }
        }
        log('Settings loaded.');
    };

    // --- Canvas Logic ---
    const startDrawing = (e) => {
        isDrawing = true;
        [lastX, lastY] = getCanvasCoordinates(e); // Use canvas-specific coordinates
        lastX *= dpr;
        lastY *= dpr;
    };

    // Set canvas internal resolution to CSS size * DPR.
    // Keep context transform as default (no scaling).
    // Draw using CSS pixel coordinates directly.
    // This ensures lineWidth is interpreted correctly in CSS pixels.
    const draw = (e) => {
        if (!isDrawing || !isOverlayVisible) return;
        let [currentX, currentY] = getCanvasCoordinates(e); // Use canvas-specific coordinates

        ctx.globalCompositeOperation = 'source-over';
        ctx.lineJoin = 'round';
        ctx.lineCap = 'round';
        ctx.strokeStyle = currentColor;
        ctx.lineWidth = currentBrushSize * dpr;
        ctx.globalAlpha = 1;

        currentX *= dpr;
        currentY *= dpr;

        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(currentX, currentY);
        ctx.stroke();

        [lastX, lastY] = [currentX, currentY];
    };

    const stopDrawing = () => {
        if (isDrawing) {
            isDrawing = false;
            saveCanvasState();
        }
    };

    // --- Coordinate Functions ---
    // For drawing on the canvas (relative to canvas)
    const getCanvasCoordinates = (e) => {
        const rect = canvasOverlay.getBoundingClientRect();
        let clientX, clientY;
        if (e.type.includes('touch')) {
            clientX = e.touches[0].clientX;
            clientY = e.touches[0].clientY;
        } else {
            clientX = e.clientX;
            clientY = e.clientY;
        }
        // Return coordinates relative to the canvas top-left in CSS pixels
        return [(clientX - rect.left), (clientY - rect.top)];
    };

    // --- UI Creation ---
    const createCanvasOverlay = () => {
        if (canvasOverlay) return;

        canvasOverlay = document.createElement('canvas');
        canvasOverlay.id = 'strands-drawing-overlay';
        canvasOverlay.style.position = 'absolute';
        canvasOverlay.style.top = '0';
        canvasOverlay.style.left = '0';
        canvasOverlay.style.width = '100%';
        canvasOverlay.style.height = '100%';
        canvasOverlay.style.pointerEvents = 'none';
        canvasOverlay.style.opacity = OVERLAY_OPACITY.toString();
        canvasOverlay.style.zIndex = '1000';
        canvasOverlay.style.backgroundColor = 'transparent';
        canvasOverlay.style.boxSizing = 'border-box';
        canvasOverlay.style.cursor = 'none';

        ctx = canvasOverlay.getContext('2d');
        if (!ctx) {
            console.error('Could not get 2D context for the drawing canvas.');
            canvasOverlay = null;
            return;
        }

        // Event listeners for drawing
        const handleEvent = (handler) => (e) => {
            if (isOverlayVisible) {
                handler(e);
                // Aggressively prevent default and stop propagation for drawing events
                e.preventDefault();
                e.stopPropagation();
            }
        };

        canvasOverlay.addEventListener('mousedown', handleEvent(startDrawing));
        canvasOverlay.addEventListener('mousemove', handleEvent(draw));
        canvasOverlay.addEventListener('mouseup', handleEvent(stopDrawing));
        canvasOverlay.addEventListener('mouseout', handleEvent(stopDrawing));
        canvasOverlay.addEventListener('touchstart', handleEvent(startDrawing), { passive: false });
        canvasOverlay.addEventListener('touchmove', handleEvent(draw), { passive: false });
        canvasOverlay.addEventListener('touchend', handleEvent(stopDrawing), { passive: false });
        canvasOverlay.addEventListener('touchcancel', handleEvent(stopDrawing), { passive: false });

        gameBoardContainer.style.position = 'relative';
        gameBoardContainer.appendChild(canvasOverlay);
        log('Canvas overlay created.');
    };

    // --- Cursor Preview Logic ---
    const createCursorPreview = () => {
        if (cursorPreview) return;

        cursorPreview = document.createElement('div');
        cursorPreview.id = 'strands-drawing-cursor-preview';
        cursorPreview.style.position = 'absolute';
        cursorPreview.style.pointerEvents = 'none';
        cursorPreview.style.zIndex = '1001'; // Above canvas, below controls
        cursorPreview.style.borderRadius = '50%';
        cursorPreview.style.transform = 'translate(-50%, -50%)';
        cursorPreview.style.display = 'none';
        cursorPreview.style.boxSizing = 'border-box';

        updateCursorPreviewStyle();
        gameBoardContainer.appendChild(cursorPreview);

        const handleMove = (e) => {
            if (isOverlayVisible) {
                const [x, y] = getCanvasCoordinates(e);
                const rect = gameBoardContainer.getBoundingClientRect();
                const isInBounds = x >= 0 && x <= rect.width && y >= 0 && y <= rect.height;

                if (isInBounds) {
                    cursorPreview.style.display = 'block';
                    cursorPreview.style.left = `${x}px`;
                    cursorPreview.style.top = `${y}px`;
                } else {
                    cursorPreview.style.display = 'none';
                }
            }
        };
        
        canvasOverlay.addEventListener('mousemove', handleMove);
        canvasOverlay.addEventListener('touchmove', (e) => { handleMove(e); e.preventDefault(); }, { passive: false });
        canvasOverlay.addEventListener('mouseleave', () => {
             if (isOverlayVisible) cursorPreview.style.display = 'none';
        });
        canvasOverlay.addEventListener('touchcancel', () => {
             if (isOverlayVisible) cursorPreview.style.display = 'none';
        });
        canvasOverlay.addEventListener('touchend', () => {
             if (isOverlayVisible) cursorPreview.style.display = 'none';
        });
    };

    const updateCursorPreviewStyle = () => {
        if (!cursorPreview) return;
        cursorPreview.style.width = `${currentBrushSize}px`;
        cursorPreview.style.height = `${currentBrushSize}px`;
        cursorPreview.style.border = `2px solid ${currentColor}`;
        cursorPreview.style.backgroundColor = `${currentColor}`;
        cursorPreview.style.opacity = '0.7';
    };

    const createControls = () => {
        if (controlsContainer) return;

        controlsContainer = document.createElement('div');
        controlsContainer.id = 'strands-drawing-controls';
        controlsContainer.style.position = 'absolute';
        controlsContainer.style.top = '10px';
        controlsContainer.style.right = '-130px';
        controlsContainer.style.zIndex = '1002';
        controlsContainer.style.display = 'none';
        controlsContainer.style.fontFamily = 'Arial, sans-serif';
        controlsContainer.style.fontSize = '14px';
        controlsContainer.style.minWidth = '100px';
        controlsContainer.style.backgroundColor = 'rgba(255, 255, 255, 0.95)'; // Slightly more opaque
        controlsContainer.style.border = '1px solid #aaa';
        controlsContainer.style.borderRadius = '5px';
        controlsContainer.style.padding = '8px';
        controlsContainer.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';

        // Redesigned Color Picker
        const colorPickerContainer = document.createElement('div');
        colorPickerContainer.id = 'drawing-color-picker';
        colorPickerContainer.style.display = 'flex';
        colorPickerContainer.style.flexDirection = 'column';
        colorPickerContainer.style.gap = '5px';
        colorPickerContainer.style.alignItems = 'center';

        colorButtons = [];

        COLORS.forEach(color => {
            const colorButton = document.createElement('button');
            // --- Ensure button type ---
            colorButton.type = 'button';
            colorButton.className = 'drawing-color-button';
            colorButton.setAttribute('data-color', color);
            colorButton.style.width = '25px';
            colorButton.style.height = '25px';
            colorButton.style.border = '2px solid transparent';
            colorButton.style.borderRadius = '4px';
            colorButton.style.backgroundColor = color;
            colorButton.style.cursor = 'pointer';
            colorButton.style.padding = '0';
            colorButton.style.boxSizing = 'border-box';

            if (color === currentColor) {
                colorButton.style.border = '2px solid #000';
            }

            // --- Robust click handler ---
            colorButton.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                currentColor = color;
                colorButtons.forEach(btn => {
                    if (btn.getAttribute('data-color') === color) {
                        btn.style.border = '2px solid #000';
                    } else {
                        btn.style.border = '2px solid transparent';
                    }
                });
                saveSettings();
                updateCursorPreviewStyle();
            });

            colorButtons.push(colorButton);
            colorPickerContainer.appendChild(colorButton);
        });

        // Brush Size Slider
        const sizeSliderContainer = document.createElement('div');
        sizeSliderContainer.style.marginTop = '10px';
        sizeSliderContainer.style.display = 'flex';
        sizeSliderContainer.style.flexDirection = 'column';
        sizeSliderContainer.style.alignItems = 'center';
        sizeSliderContainer.style.width = '90px';

        const sizeLabel = document.createElement('span');
        sizeLabel.id = 'drawing-brush-size-label';
        sizeLabel.textContent = currentBrushSize;
        sizeLabel.style.fontSize = '12px';
        sizeLabel.style.marginBottom = '3px';

        const sizeSlider = document.createElement('input');
        sizeSlider.type = 'range';
        sizeSlider.id = 'drawing-brush-size';
        sizeSlider.min = MIN_BRUSH_SIZE;
        sizeSlider.max = MAX_BRUSH_SIZE;
        sizeSlider.value = currentBrushSize;
        sizeSlider.style.writingMode = 'bt-lr';
        sizeSlider.style.webkitAppearance = 'slider-vertical';
        sizeSlider.style.width = '90px';
        sizeSlider.style.height = '70px';
        sizeSlider.style.cursor = 'pointer';
        sizeSlider.addEventListener('input', () => {
            currentBrushSize = parseInt(sizeSlider.value, 10);
            sizeLabel.textContent = currentBrushSize;
            saveSettings();
            updateCursorPreviewStyle();
        });

        sizeSliderContainer.appendChild(sizeLabel);
        sizeSliderContainer.appendChild(sizeSlider);

        // Clear Button
        const clearButton = document.createElement('button');
        // --- Explicitly set type and prevent all actions ---
        clearButton.type = 'button';
        clearButton.textContent = '🗑️';
        clearButton.title = 'Clear Canvas';
        clearButton.style.display = 'block';
        clearButton.style.marginTop = '10px';
        clearButton.style.padding = '5px';
        clearButton.style.fontSize = '16px';
        clearButton.style.cursor = 'pointer';
        clearButton.style.border = '1px solid #ccc';
        clearButton.style.borderRadius = '4px';
        clearButton.style.backgroundColor = '#f9f9f9';
        clearButton.addEventListener('click', (event) => {
            // --- Aggressive prevention ---
            event.preventDefault();
            event.stopPropagation();
            if (event.target !== clearButton) return; // Extra check
            if (ctx && canvasOverlay) {
                log("Clearing canvas...");
                ctx.clearRect(0, 0, canvasOverlay.width, canvasOverlay.height);
                saveCanvasState();
            }
        }, true); // Use capture phase

        // Hide Button
        const hideButton = document.createElement('button');
        hideButton.type = 'button';
        hideButton.textContent = '✕';
        hideButton.title = 'Hide overlay';
        hideButton.style.display = 'block';
        hideButton.style.marginTop = '5px';
        hideButton.style.padding = '5px';
        hideButton.style.fontSize = '16px';
        hideButton.style.cursor = 'pointer';
        hideButton.style.border = '1px solid #ccc';
        hideButton.style.borderRadius = '4px';
        hideButton.style.backgroundColor = '#f9f9f9';
        hideButton.addEventListener('click', (event) => {
             // --- Aggressive prevention ---
             event.preventDefault();
             event.stopPropagation();
             if (event.target !== hideButton) return; // Extra check
             log("Hiding overlay...");
             toggleOverlay();
        }, true); // Use capture phase

        controlsContainer.appendChild(colorPickerContainer);
        controlsContainer.appendChild(sizeSliderContainer);
        controlsContainer.appendChild(clearButton);
        controlsContainer.appendChild(hideButton);

        gameBoardContainer.appendChild(controlsContainer);
        log('Controls created.');
    };

    // --- Canvas Size Update ---
    const updateCanvasSize = () => {
        if (!canvasOverlay || !gameBoardContainer) return;

        const containerRect = gameBoardContainer.getBoundingClientRect();
        dpr = window.devicePixelRatio || 1;

        // Set internal resolution (width/height attrs) based on CSS size * DPR
        // This makes the canvas render crisply without needing ctx.scale()
        canvasOverlay.width = Math.floor(containerRect.width * dpr);
        canvasOverlay.height = Math.floor(containerRect.height * dpr);

        // Ensure CSS size matches the container exactly
        canvasOverlay.style.width = `${containerRect.width}px`;
        canvasOverlay.style.height = `${containerRect.height}px`;

        // Reset context transform to identity
        // This ensures drawing commands use the CSS pixel coordinate system directly.
        ctx.setTransform(1, 0, 0, 1, 0, 0);

        log(`Canvas updated: Display(${containerRect.width}x${containerRect.height}), Internal(${canvasOverlay.width}x${canvasOverlay.height}), DPR(${dpr})`);
    };

    // --- Main Logic ---
    const toggleOverlay = () => {
        if (!canvasOverlay || !controlsContainer || !cursorPreview) return;

        isOverlayVisible = !isOverlayVisible;

        if (isOverlayVisible) {
            canvasOverlay.style.pointerEvents = 'auto';
            canvasOverlay.style.backgroundColor = CANVAS_BACKGROUND_COLOR;
            canvasOverlay.style.display = 'block';
            controlsContainer.style.display = 'block';
            toggleButton.setAttribute('aria-label', 'Hide drawing notes overlay');
            cursorPreview.style.display = 'none'; // Start hidden, shown on move
        } else {
            canvasOverlay.style.pointerEvents = 'none';
            canvasOverlay.style.backgroundColor = 'transparent';
            canvasOverlay.style.display = 'none';
            controlsContainer.style.display = 'none';
            toggleButton.setAttribute('aria-label', 'Show drawing notes overlay');
            cursorPreview.style.display = 'none';
        }
        log(`Overlay ${isOverlayVisible ? 'shown' : 'hidden'}.`);
    };

    const createToggleButton = () => {
        if (toggleButton) return;

        const toolbar = document.querySelector('.pz-module.pz-flex-row.pz-game-toolbar-content');
        if (!toolbar) {
            log('Toolbar not found, retrying...');
            setTimeout(createToggleButton, 1000);
            return;
        }

        toggleButton = document.createElement('button');
        toggleButton.type = 'button';
        toggleButton.className = 'ToolbarItem-module_toolbar_item__xrBr_ ToolbarItem-module_toolbar_itemDesktop__jFTZJ';
        toggleButton.id = 'drawing-overlay-toggle';
        toggleButton.setAttribute('aria-label', 'Show drawing notes overlay');

        const iconWrapper = document.createElement('div');
        iconWrapper.className = 'Icon-module_iconWrapper__ZfKPm';

        const svgIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svgIcon.setAttribute('aria-hidden', 'true');
        svgIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        svgIcon.setAttribute('height', '24');
        svgIcon.setAttribute('viewBox', '0 0 24 24');
        svgIcon.setAttribute('width', '24');
        svgIcon.setAttribute('class', 'game-icon');
        svgIcon.setAttribute('data-testid', 'icon-drawing-overlay');
        svgIcon.innerHTML = `
            <path fill="var(--text)" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 5.63l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83a.9959.9959 0 0 0 0-1.41z"/>
            <path fill="none" d="M0 0h24v24H0z"/>
        `;

        iconWrapper.appendChild(svgIcon);
        toggleButton.appendChild(iconWrapper);

        // --- Robust click handler for toggle button ---
        toggleButton.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            log("Toggle button clicked.");
            toggleOverlay();
        }, true); // Use capture phase

        const helpButton = document.getElementById('help-button');
        if (helpButton && helpButton.parentNode) {
            helpButton.parentNode.parentNode.insertBefore(toggleButton, helpButton.parentNode);
        } else {
            const toolbarSection = toolbar.querySelector('section');
            if (toolbarSection) {
                toolbarSection.appendChild(toggleButton);
            } else {
                toolbar.appendChild(toggleButton);
            }
        }
        log('Toggle button created.');
    };

    const init = () => {
        log('Initializing...');

        gameBoardContainer = document.querySelector("form[data-testid='strands-board'] > div:first-child > div:first-child");
        if (!gameBoardContainer) {
            log('Game board container not found, retrying...');
            setTimeout(init, 1000);
            return;
        }

        loadSettings();
        createCanvasOverlay();
        createControls();
        createToggleButton();
        createCursorPreview();

        if (canvasOverlay) {
            setTimeout(() => {
                 updateCanvasSize();
                 loadCanvasState();
                 if (colorButtons.length > 0) {
                     colorButtons.forEach(btn => {
                         if (btn.getAttribute('data-color') === currentColor) {
                             btn.style.border = '2px solid #000';
                         } else {
                             btn.style.border = '2px solid transparent';
                         }
                     });
                 }
                 updateCursorPreviewStyle();

                 window.addEventListener('resize', () => {
                     clearTimeout(window.strandsResizeTimeout);
                     window.strandsResizeTimeout = setTimeout(updateCanvasSize, 100);
                 });
                 const resizeObserver = new ResizeObserver(() => {
                     clearTimeout(window.strandsResizeObserverTimeout);
                     window.strandsResizeObserverTimeout = setTimeout(updateCanvasSize, 100);
                 });
                 resizeObserver.observe(gameBoardContainer);
            }, 100);
        }
    };

    // --- Keyboard Shortcut ---
    document.addEventListener('keydown', (e) => {
        if (e.key === 't' && e.ctrlKey === false && e.altKey === false && e.shiftKey === false) {
             if (document.activeElement.tagName !== 'INPUT' && document.activeElement.tagName !== 'TEXTAREA') {
                 e.preventDefault();
                 log("T key pressed, toggling overlay.");
                 toggleOverlay();
             }
        }
    });

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();