UNPKG

@ltcode/eyedropper

Version:

High-performance EyeDropper library for picking pixel colors from images/canvas with optimized magnifier. Canvas2D willReadFrequently optimized.

635 lines (562 loc) 24.3 kB
// @ltcode/eyedropper // Color picker library for selecting pixel color from images/canvas on the web /** * EyeDropper - Color picker library for selecting pixel color from images/canvas on the web * * Manual usage (with canvas): * const eyedropper = new EyeDropper(); * eyedropper.open(canvas).then(color => console.log(color.hex)); * * Automated usage (with image URL): * const eyedropper = new EyeDropper(); * eyedropper.openFromImageUrl('image.jpg').then(color => console.log(color.hex)); * * ReactJS usage example: * import EyeDropper from '@ltcode/eyedropper'; * const ref = useRef(); * const pickColor = async () => { * const eyedropper = new EyeDropper(); * const color = await eyedropper.open(ref.current); * // color.hex, color.rgb * }; * <canvas ref={ref} ... /> * * Note: Only call .open() in browser/client-side code (not SSR). */ class EyeDropper { /** * Utility to draw an <img> element onto a <canvas> (for React usage) * @param {HTMLImageElement} img - The image element * @param {HTMLCanvasElement} canvas - The canvas element * @param {Object} [options] - { cover: boolean } (if true, image will cover canvas, else fit) */ static drawImageToCanvas(img, canvas, options = {}) { if (!img || !canvas || !img.naturalWidth || !img.naturalHeight) return; const ctx = canvas.getContext('2d'); const { width: canvasW, height: canvasH } = canvas; const { naturalWidth: imgW, naturalHeight: imgH } = img; ctx.clearRect(0, 0, canvasW, canvasH); let dx, dy, dw, dh; if (options.cover) { // Cover logic (center crop) - optimized calculations const ratio = Math.max(canvasW / imgW, canvasH / imgH); dw = imgW * ratio; dh = imgH * ratio; dx = (canvasW - dw) * 0.5; // Faster than division by 2 dy = (canvasH - dh) * 0.5; } else { // Fit logic - optimized calculations const ratio = Math.min(canvasW / imgW, canvasH / imgH); dw = imgW * ratio; dh = imgH * ratio; dx = (canvasW - dw) * 0.5; dy = (canvasH - dh) * 0.5; } ctx.drawImage(img, dx, dy, dw, dh); } /** * @param {Object} options - Global customization options (can be overridden in open) * @param {Object} [options.magnifier] - Magnifier customization (size, border, color, etc) * @param {Object} [options.preview] - Preview customization (style, HTML, etc) * @param {Object} [options.overlay] - Overlay customization (color, opacity, etc) * @param {Function} [options.renderPreview] - Custom function to render the preview * @param {Function} [options.onMove] - Callback on mouse move * @param {Function} [options.onPick] - Callback on color pick */ constructor(options = {}) { this.options = options; } /** * Opens the color picker over an existing canvas. * @param {HTMLCanvasElement|CanvasRenderingContext2D} canvasOrContext - Canvas or 2D context for color picking * @param {Object} [options] - Options to override those from the constructor * @returns {Promise<{hex: string, rgb: [number, number, number]}>} */ open(canvasOrContext, options = {}) { // Ensure running in browser (not SSR) if (typeof window === 'undefined' || typeof document === 'undefined') { throw new Error('EyeDropper can only be used in the browser environment.'); } this.options = { ...this.options, ...options }; return new Promise((resolve) => { this._resolve = resolve; this._createUI(canvasOrContext); }); } _createUI(canvasOrContext) { this._removeUI(); // Discover the canvas let canvas; if (canvasOrContext instanceof HTMLCanvasElement) { canvas = canvasOrContext; } else if (canvasOrContext && typeof canvasOrContext.getImageData === 'function') { canvas = canvasOrContext.canvas; } else { throw new Error('You must provide a valid canvas or 2D context to EyeDropper.'); } this._canvas = canvas; // Optimize canvas context for frequent getImageData operations const ctx = this._canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) { throw new Error('Failed to get 2D context from canvas.'); } // Overlay container this._container = document.createElement('div'); this._container.className = 'eyedropper-overlay'; this._container.id = 'eyedropper-overlay'; Object.assign(this._container.style, { position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh', background: (this.options.overlay && this.options.overlay.background) || 'rgba(0,0,0,0.0)', zIndex: (this.options.overlay && this.options.overlay.zIndex) || 99999, pointerEvents: 'none', ...(this.options.overlay && this.options.overlay.style), }); document.body.appendChild(this._container); // Magnifier const magOpt = this.options.magnifier || {}; this._magnifier = document.createElement('div'); this._magnifier.className = 'eyedropper-magnifier'; this._magnifier.id = 'eyedropper-magnifier'; Object.assign(this._magnifier.style, { position: 'absolute', pointerEvents: 'none', width: magOpt.width || '80px', height: magOpt.height || '80px', border: magOpt.border || '2px solid #333', borderRadius: magOpt.borderRadius || '50%', overflow: magOpt.overflow || 'hidden', boxShadow: magOpt.boxShadow || '0 2px 8px #0008', zIndex: magOpt.zIndex || 100000, display: 'none', background: magOpt.background || '#fff', ...magOpt.style, }); this._container.appendChild(this._magnifier); // Cache the canvas context and dimensions for performance this._canvasCache = { ctx: ctx, width: this._canvas.width, height: this._canvas.height, scaleX: this._canvas.width / this._canvas.getBoundingClientRect().width, scaleY: this._canvas.height / this._canvas.getBoundingClientRect().height, magW: parseInt(magOpt.width || '80'), magH: parseInt(magOpt.height || '80') }; // Create crosshair element once (as child of magnifier) this._crosshair = document.createElement('div'); this._crosshair.className = 'eyedropper-crosshair'; this._crosshair.id = 'eyedropper-crosshair'; Object.assign(this._crosshair.style, { position: 'absolute', pointerEvents: 'none', width: '6px', height: '6px', border: '1px solid #000', backgroundColor: 'transparent', left: '50%', top: '50%', transform: 'translate(-50%, -50%)', zIndex: '1', boxSizing: 'border-box' }); this._magnifier.appendChild(this._crosshair); // Preview const prevOpt = this.options.preview || {}; this._preview = document.createElement('div'); this._preview.className = 'eyedropper-preview'; this._preview.id = 'eyedropper-preview'; Object.assign(this._preview.style, { position: 'fixed', left: '0px', top: '0px', padding: prevOpt.padding || '10px 18px', background: prevOpt.background || '#fff', borderRadius: prevOpt.borderRadius || '8px', boxShadow: prevOpt.boxShadow || '0 2px 8px #0002', fontFamily: prevOpt.fontFamily || 'monospace', fontSize: prevOpt.fontSize || '1.1em', display: 'none', // Initially hidden until hover alignItems: prevOpt.alignItems || 'center', gap: prevOpt.gap || '12px', zIndex: prevOpt.zIndex || 100001, pointerEvents: 'none', minWidth: prevOpt.minWidth || '80px', ...prevOpt.style, }); // Create color preview elements this._previewColor = document.createElement('div'); this._previewColor.className = 'eyedropper-preview-color'; this._previewColor.id = 'eyedropper-preview-color'; Object.assign(this._previewColor.style, { width: '20px', height: '20px', borderRadius: '3px', border: '1px solid #ccc', flexShrink: '0' }); this._colorDisplay = document.createElement('span'); this._colorDisplay.className = 'eyedropper-color-display'; this._colorDisplay.id = 'eyedropper-color-display'; this._colorDisplay.textContent = ''; this._preview.appendChild(this._previewColor); this._preview.appendChild(this._colorDisplay); this._container.appendChild(this._preview); // Initialize last pixel data for when mouse leaves this._lastPixel = null; // Events with throttled mouse move for better performance this._canvas.addEventListener('mousemove', this._onMouseMoveBound = this._throttledMouseMove.bind(this), { passive: true }); this._canvas.addEventListener('mouseleave', this._onMouseLeaveBound = this._onMouseLeave.bind(this), { passive: true }); this._canvas.addEventListener('mouseenter', this._onMouseEnterBound = this._onMouseEnter.bind(this), { passive: true }); this._canvas.addEventListener('click', this._onClickBound = this._onClick.bind(this)); } // Throttled mouse move for better performance _throttledMouseMove(e) { if (!this._mouseThrottle) { this._mouseThrottle = true; requestAnimationFrame(() => { this._onMouseMove(e); this._mouseThrottle = false; }); } } _onMouseEnter(e) { // Hide cursor and show magnifier on mouse enter (preview will be shown when mouse moves) this._canvas.style.cursor = 'none'; this._magnifier.style.display = 'block'; } _onMouseMove(e) { const rect = this._canvas.getBoundingClientRect(); const x = Math.floor(e.clientX - rect.left); const y = Math.floor(e.clientY - rect.top); // Simple getImageData call - optimized by willReadFrequently context const imageData = this._canvasCache.ctx.getImageData(x, y, 1, 1); const [r, g, b] = imageData.data; // Update preview with current color const hexColor = this._rgbToHex(r, g, b); this._previewColor.style.backgroundColor = `rgb(${r}, ${g}, ${b})`; this._colorDisplay.textContent = hexColor; // Show preview now that we have valid data this._preview.style.display = 'flex'; // Update magnifier position and content this._updateMagnifier(x, y, e, [r, g, b], hexColor); } _updateMagnifier(x, y, e, rgb, hex) { // Update magnifier position this._magnifier.style.left = `${e.clientX - this._canvasCache.magW / 2}px`; this._magnifier.style.top = `${e.clientY - this._canvasCache.magH / 2}px`; // Draw magnifier content this._drawMagnifier(x, y); // Position preview below magnifier const previewRect = { w: this._preview.offsetWidth, h: this._preview.offsetHeight }; const px = e.clientX - this._canvasCache.magW / 2; const py = e.clientY + this._canvasCache.magH / 2 + 8; this._preview.style.left = `${px + this._canvasCache.magW / 2 - previewRect.w / 2}px`; this._preview.style.top = `${py}px`; // Store last pixel data this._lastPixel = { x: x, y: y, clientX: e.clientX, clientY: e.clientY, hex: hex, rgb: rgb }; } _onMouseLeave() { // Keep magnifier and preview visible showing last selected pixel if (this._lastPixel && this._canvasCache && this._lastPixel.hex && this._lastPixel.rgb) { // Keep magnifier at last position using cached dimensions this._magnifier.style.left = `${this._lastPixel.clientX - this._canvasCache.magW / 2}px`; this._magnifier.style.top = `${this._lastPixel.clientY - this._canvasCache.magH / 2}px`; this._drawMagnifier(this._lastPixel.x, this._lastPixel.y); // Keep preview with last pixel data using proper elements if (typeof this.options.renderPreview === 'function') { this._preview.innerHTML = this.options.renderPreview(this._lastPixel); } else { // Update the preview elements properly with valid data this._previewColor.style.backgroundColor = `rgb(${this._lastPixel.rgb[0]}, ${this._lastPixel.rgb[1]}, ${this._lastPixel.rgb[2]})`; this._colorDisplay.textContent = this._lastPixel.hex; this._preview.style.display = 'flex'; } // Position preview below magnifier const previewRect = { w: this._preview.offsetWidth, h: this._preview.offsetHeight }; const px = this._lastPixel.clientX - this._canvasCache.magW / 2; const py = this._lastPixel.clientY + this._canvasCache.magH / 2 + 8; this._preview.style.left = `${px + this._canvasCache.magW / 2 - previewRect.w / 2}px`; this._preview.style.top = `${py}px`; } else { // If no valid last pixel, hide elements and restore cursor this._canvas.style.cursor = 'default'; this._magnifier.style.display = 'none'; this._preview.style.display = 'none'; } } _onClick(e) { // Use the pixel data we already have from the last mouse move if (this._lastPixel) { const { hex, rgb, x, y } = this._lastPixel; if (typeof this.options.onPick === 'function') { this.options.onPick({ hex, rgb, x, y, event: e }); } this._resolve({ hex, rgb }); } else { // Fallback: get pixel data if somehow we don't have it const position = this._currentPosition || (() => { const rect = this._canvas.getBoundingClientRect(); return { x: Math.floor((e.clientX - rect.left) * (this._canvasCache?.scaleX || (this._canvas.width / rect.width))), y: Math.floor((e.clientY - rect.top) * (this._canvasCache?.scaleY || (this._canvas.height / rect.height))), }; })(); // Simple getImageData call - optimized by willReadFrequently context const imageData = this._canvasCache.ctx.getImageData(position.x, position.y, 1, 1); const [r, g, b] = imageData.data; const hex = this._rgbToHex(r, g, b); const rgb = [r, g, b]; if (typeof this.options.onPick === 'function') { this.options.onPick({ hex, rgb, x: position.x, y: position.y, event: e }); } this._resolve({ hex, rgb }); } this._removeUI(); } _drawMagnifier(x, y) { // Cache magnifier options on first use if (!this._magnifierCache) { this._magnifierCache = { size: (this.options.magnifier && this.options.magnifier.size) || 20, zoom: (this.options.magnifier && this.options.magnifier.zoom) || 4 }; } const { size, zoom } = this._magnifierCache; // Create or reuse magnifier canvas if (!this._magCanvas) { this._magCanvas = document.createElement('canvas'); this._magCanvas.className = 'eyedropper-magnifier-canvas'; this._magCanvas.id = 'eyedropper-magnifier-canvas'; // Insert canvas before crosshair to maintain proper z-order this._magnifier.insertBefore(this._magCanvas, this._crosshair); } // Only resize if dimensions changed const newWidth = size * zoom; const newHeight = size * zoom; if (this._magCanvas.width !== newWidth || this._magCanvas.height !== newHeight) { this._magCanvas.width = newWidth; this._magCanvas.height = newHeight; } // Use cached context with willReadFrequently optimization if (!this._magCtx) { this._magCtx = this._magCanvas.getContext('2d', { willReadFrequently: true }); this._magCtx.imageSmoothingEnabled = false; } this._magCtx.drawImage( this._canvas, x - size / 2, y - size / 2, size, size, 0, 0, newWidth, newHeight ); } _rgbToHex(r, g, b) { // Faster bitwise conversion without array creation return '#' + ((1 << 24) + (r << 16) + (g << 8) + b) .toString(16) .slice(1); } _removeUI() { if (this._container && this._container.parentNode) { this._container.parentNode.removeChild(this._container); } if (this._canvas) { // Restore cursor this._canvas.style.cursor = 'default'; this._canvas.removeEventListener('mousemove', this._onMouseMoveBound); this._canvas.removeEventListener('mouseleave', this._onMouseLeaveBound); this._canvas.removeEventListener('mouseenter', this._onMouseEnterBound); this._canvas.removeEventListener('click', this._onClickBound); } // Clear all cached references and data this._container = null; this._canvas = null; this._magnifier = null; this._crosshair = null; this._preview = null; this._lastPixel = null; this._currentPosition = null; this._canvasCache = null; this._magnifierCache = null; this._magCanvas = null; this._magCtx = null; this._mouseThrottle = false; this._imageDataCache = null; } /** * Automated method: pass only image URL, library handles canvas creation and setup * @param {string} imageUrl - URL of the image to load * @param {Object} [options] - Options to override constructor options * @param {Object} [canvasOptions] - Canvas configuration { width, height, position } * @returns {Promise<{hex: string, rgb: [number, number, number]}>} */ openFromImageUrl(imageUrl, options = {}, canvasOptions = {}) { // Ensure running in browser (not SSR) if (typeof window === 'undefined' || typeof document === 'undefined') { throw new Error('EyeDropper can only be used in the browser environment.'); } return new Promise((resolve, reject) => { // Calculate position for loading and canvas const canvasTop = canvasOptions.position?.top || '50%'; const canvasLeft = canvasOptions.position?.left || '50%'; const canvasWidth = canvasOptions.width || 400; // Default fallback const canvasHeight = canvasOptions.height || 300; // Default fallback // Create loading overlay positioned where canvas will appear const loadingOverlay = this._createLoadingOverlay(canvasTop, canvasLeft, canvasWidth, canvasHeight); document.body.appendChild(loadingOverlay); // Create temporary image element const img = new Image(); img.crossOrigin = 'anonymous'; // For CORS images img.onload = () => { try { // Remove loading overlay if (loadingOverlay.parentNode) { loadingOverlay.parentNode.removeChild(loadingOverlay); } // Create temporary canvas const canvas = document.createElement('canvas'); canvas.className = 'eyedropper-main-canvas'; canvas.id = 'eyedropper-main-canvas'; const finalCanvasWidth = canvasOptions.width || img.naturalWidth; const finalCanvasHeight = canvasOptions.height || img.naturalHeight; canvas.width = finalCanvasWidth; canvas.height = finalCanvasHeight; canvas.style.position = 'fixed'; canvas.style.top = canvasTop; canvas.style.left = canvasLeft; canvas.style.transform = 'translate(-50%, -50%)'; canvas.style.zIndex = '99998'; canvas.style.border = '2px solid #333'; canvas.style.boxShadow = '0 4px 12px rgba(0,0,0,0.3)'; canvas.style.background = '#fff'; // Draw image to canvas EyeDropper.drawImageToCanvas(img, canvas, { cover: canvasOptions.cover }); // Add canvas to body temporarily document.body.appendChild(canvas); // Open eyedropper on this canvas this.open(canvas, options).then((color) => { // Clean up: remove temporary canvas if (canvas.parentNode) { canvas.parentNode.removeChild(canvas); } resolve(color); }).catch(reject); } catch (error) { // Remove loading overlay on error if (loadingOverlay.parentNode) { loadingOverlay.parentNode.removeChild(loadingOverlay); } reject(error); } }; img.onerror = () => { // Remove loading overlay on error if (loadingOverlay.parentNode) { loadingOverlay.parentNode.removeChild(loadingOverlay); } reject(new Error(`Failed to load image from URL: ${imageUrl}`)); }; img.src = imageUrl; }); } /** * Creates a loading overlay with skeleton/spinner positioned where canvas will appear * @param {string} top - Top position (e.g., '50%') * @param {string} left - Left position (e.g., '50%') * @param {number} width - Expected canvas width * @param {number} height - Expected canvas height * @returns {HTMLElement} Loading overlay element */ _createLoadingOverlay(top = '50%', left = '50%', width = 400, height = 300) { const overlay = document.createElement('div'); overlay.className = 'eyedropper-loading-overlay'; overlay.id = 'eyedropper-loading-overlay'; Object.assign(overlay.style, { position: 'fixed', top: top, left: left, transform: 'translate(-50%, -50%)', width: `${width}px`, height: `${height}px`, background: '#fff', border: '2px solid #333', borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: '99998', fontFamily: 'Arial, sans-serif' }); const loadingContent = document.createElement('div'); loadingContent.className = 'eyedropper-loading-content'; loadingContent.id = 'eyedropper-loading-content'; Object.assign(loadingContent.style, { textAlign: 'center' }); // Skeleton loader animation (proportional to canvas size) const skeleton = document.createElement('div'); skeleton.className = 'eyedropper-loading-skeleton'; skeleton.id = 'eyedropper-loading-skeleton'; const skeletonWidth = Math.min(width * 0.7, 200); const skeletonHeight = Math.min(height * 0.5, 120); Object.assign(skeleton.style, { width: `${skeletonWidth}px`, height: `${skeletonHeight}px`, background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)', backgroundSize: '200% 100%', animation: 'skeleton-loading 1.5s infinite', borderRadius: '8px', marginBottom: '16px', margin: '0 auto 16px auto' }); const text = document.createElement('div'); text.className = 'eyedropper-loading-text'; text.id = 'eyedropper-loading-text'; text.textContent = 'Loading image...'; Object.assign(text.style, { color: '#666', fontSize: '14px', fontWeight: '500' }); // Add CSS animation for skeleton if (!document.getElementById('skeleton-animation-style')) { const style = document.createElement('style'); style.id = 'skeleton-animation-style'; style.textContent = ` @keyframes skeleton-loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } `; document.head.appendChild(style); } loadingContent.appendChild(skeleton); loadingContent.appendChild(text); overlay.appendChild(loadingContent); return overlay; } attachToImage(imgElement) { // Initialize eyedropper on an <img> element // Example usage: eyedropper.attachToImage(document.querySelector('img')) // Basic implementation will be provided in future versions throw new Error('Method not implemented. Coming soon!'); } } // Export for ES Modules export default EyeDropper; // Export for CommonJS (require) if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = EyeDropper; module.exports.default = EyeDropper; }