diff --git a/src/main.js b/src/main.js index a1f6487..881e37d 100644 --- a/src/main.js +++ b/src/main.js @@ -3,7 +3,7 @@ import * as fitCurve from '/fit-curve.js'; import { Bezier } from "/bezier.js"; import { Quadtree } from './quadtree.js'; import { createNewFileDialog, showNewFileDialog, closeDialog } from './newfile.js'; -import { titleCase, getMousePositionFraction, getKeyframesSurrounding, invertPixels, lerpColor, lerp, camelToWords, generateWaveform, floodFillRegion, getShapeAtPoint } from './utils.js'; +import { titleCase, getMousePositionFraction, getKeyframesSurrounding, invertPixels, lerpColor, lerp, camelToWords, generateWaveform, floodFillRegion, getShapeAtPoint, hslToRgb, drawCheckerboardBackground, hexToHsl, hsvToRgb, hexToHsv, rgbToHex, clamp } from './utils.js'; const { writeTextFile: writeTextFile, readTextFile: readTextFile, writeFile: writeFile, readFile: readFile }= window.__TAURI__.fs; const { open: openFileDialog, @@ -3149,39 +3149,230 @@ function toolbar() { let tools_break = document.createElement("div") tools_break.className = "horiz_break" tools_scroller.appendChild(tools_break) - let fillColor = document.createElement("input") - let strokeColor = document.createElement("input") + let fillColor = document.createElement("div") + let strokeColor = document.createElement("div") fillColor.className = "color-field" strokeColor.className = "color-field" + fillColor.style.setProperty('--color', "#ff0000"); + strokeColor.style.setProperty('--color', "#000000"); + fillColor.type="color" fillColor.value = "#ff0000" strokeColor.value = "#000000" context.fillStyle = fillColor.value context.strokeStyle = strokeColor.value + let evtListener; + let padding = 10 + let gradwidth = 25 + let ccwidth = 300 + let mainSize = ccwidth - (3*padding + gradwidth) fillColor.addEventListener('click', e => { - Coloris({ - el: ".color-field", - selectInput: true, - focusInput: true, - theme: 'default', - swatches: context.swatches, - defaultColor: '#ff0000', - onChange: (color) => { - context.fillStyle = color; + // Coloris({ + // el: ".color-field", + // selectInput: true, + // focusInput: true, + // theme: 'default', + // swatches: context.swatches, + // defaultColor: '#ff0000', + // onChange: (color) => { + // context.fillStyle = color; + // } + // }) + let colorCvs = document.querySelector("#color-cvs") + if (colorCvs==null) { + console.log('creating new one') + colorCvs = document.createElement("canvas") + colorCvs.id = "color-cvs" + document.body.appendChild(colorCvs) + colorCvs.width = ccwidth + colorCvs.height = 500 + colorCvs.style.width = "300px" + colorCvs.style.height = "500px" + colorCvs.style.position = "absolute" + colorCvs.style.left = '500px' + colorCvs.style.top = '500px' + colorCvs.style.boxShadow = "0 2px 2px rgba(0,0,0,0.2)" + colorCvs.style.cursor = "crosshair" + colorCvs.currentColor = "#00ffba88" + colorCvs.draw = function() { + const darkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + let ctx = colorCvs.getContext('2d') + if (darkMode) { + ctx.fillStyle = "#333" + } else { + ctx.fillStyle = "#ccc" //TODO + } + ctx.fillRect(0,0,colorCvs.width, colorCvs.height) + + // draw current color + drawCheckerboardBackground(ctx, padding, padding, colorCvs.width - 2*padding, 50, 10) + ctx.fillStyle = colorCvs.currentColor //TODO + ctx.fillRect(padding,padding,colorCvs.width-2*padding, 50) + + // Draw main gradient + let mainGradient = ctx.createImageData(mainSize, mainSize) + let data = mainGradient.data + let {h, s, v} = hexToHsv(colorCvs.currentColor) + for (let i=0; i { + colorCvs.clickedMainGradient = false + colorCvs.clickedHueGradient = false + colorCvs.clickedAlphaGradient = false + let mouse = getMousePos(colorCvs, e) + let {h, s, v} = hexToHsv(colorCvs.currentColor) + if (mouse.x > padding && mouse.x < padding + mainSize && + mouse.y > 2*padding + 50 && mouse.y < 2*padding + 50 + mainSize) { + // we clicked in the main gradient + let x = (mouse.x - padding) / mainSize + let y = (mouse.y - (2*padding + 50)) / mainSize + let rgb = hsvToRgb(h, x, 1-y) + let alpha = colorCvs.currentColor.slice(7,9) || 'ff' + colorCvs.currentColor = rgbToHex(rgb.r, rgb.g, rgb.b) + alpha + colorCvs.clickedMainGradient = true + } else if (mouse.x > padding && mouse.x < padding + mainSize && + mouse.y > 3*padding + 50+ mainSize && mouse.y < 3*padding + 50 + mainSize + gradwidth + ) { + // we clicked in the hue gradient + let x = (mouse.x - padding) / mainSize + let rgb = hsvToRgb(x, s, v) + let alpha = colorCvs.currentColor.slice(7,9) || 'ff' + colorCvs.currentColor = rgbToHex(rgb.r, rgb.g, rgb.b) + alpha + colorCvs.clickedHueGradient = true + } else if (mouse.x > colorCvs.width - (padding + gradwidth) && mouse.x < colorCvs.width - padding && + mouse.y > 2 * padding + 50 && mouse.y < 2 * padding + 50 + mainSize + ) { + // we clicked in the alpha gradient + let y = 1 - ((mouse.y - (2 * padding + 50)) / mainSize) + let alpha = Math.round(y*255).toString(16) + colorCvs.currentColor = `${colorCvs.currentColor.slice(0,7)}${alpha}` + colorCvs.clickedAlphaGradient = true + } + fillColor.style.setProperty('--color', colorCvs.currentColor); + colorCvs.draw() + }) + + window.addEventListener('mouseup', (e) => { + let mouse = getMousePos(colorCvs, e) + colorCvs.clickedMainGradient = false + colorCvs.clickedHueGradient = false + colorCvs.clickedAlphaGradient = false + if (e.target != colorCvs) { + colorCvs.style.display = 'none' + window.removeEventListener('mousemove', evtListener) + } + }) + } else { + colorCvs.style.display = "block" + } + evtListener = window.addEventListener('mousemove', (e) => { + let mouse = getMousePos(colorCvs, e) + let {h, s, v} = hexToHsv(colorCvs.currentColor) + if (colorCvs.clickedMainGradient) { + let x = clamp((mouse.x - padding) / mainSize) + let y = clamp((mouse.y - (2*padding + 50)) / mainSize) + let rgb = hsvToRgb(h, x, 1-y) + let alpha = colorCvs.currentColor.slice(7,9) || 'ff' + colorCvs.currentColor = rgbToHex(rgb.r, rgb.g, rgb.b) + alpha + colorCvs.draw() + } else if (colorCvs.clickedHueGradient) { + let x = clamp((mouse.x - padding) / mainSize) + let rgb = hsvToRgb(x, s, v) + let alpha = colorCvs.currentColor.slice(7,9) || 'ff' + colorCvs.currentColor = rgbToHex(rgb.r, rgb.g, rgb.b) + alpha + colorCvs.draw() + } else if (colorCvs.clickedAlphaGradient) { + let y = clamp(1 - ((mouse.y - (2 * padding + 50)) / mainSize)) + let alpha = Math.round(y * 255).toString(16).padStart(2, '0'); + colorCvs.currentColor = `${colorCvs.currentColor.slice(0,7)}${alpha}` + colorCvs.draw() + } + console.log(colorCvs.currentColor) + fillColor.style.setProperty('--color', colorCvs.currentColor); }) + // Get mouse coordinates relative to the viewport + const mouseX = e.clientX + window.scrollX; + const mouseY = e.clientY + window.scrollY; + + const divWidth = colorCvs.offsetWidth; + const divHeight = colorCvs.offsetHeight; + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + // Default position to the mouse cursor + let left = mouseX; + let top = mouseY; + + // If the window is narrower than twice the width, center horizontally + if (windowWidth < divWidth * 2) { + left = (windowWidth - divWidth) / 2; + } else { + // If it would overflow on the right side, position it to the left of the cursor + if (left + divWidth > windowWidth) { + left = mouseX - divWidth; + } + } + + // If the window is shorter than twice the height, center vertically + if (windowHeight < divHeight * 2) { + top = (windowHeight - divHeight) / 2; + } else { + // If it would overflow at the bottom, position it above the cursor + if (top + divHeight > windowHeight) { + top = mouseY - divHeight; + } + } + + colorCvs.style.left = `${left}px`; + colorCvs.style.top = `${top}px`; + colorCvs.draw() + e.preventDefault() }) strokeColor.addEventListener('click', e => { - Coloris({ - el: ".color-field", - selectInput: true, - focusInput: true, - theme: 'default', - swatches: context.swatches, - defaultColor: '#000000', - onChange: (color) => { - context.strokeStyle = color; - } - }) + // Coloris({ + // el: ".color-field", + // selectInput: true, + // focusInput: true, + // theme: 'default', + // swatches: context.swatches, + // defaultColor: '#000000', + // onChange: (color) => { + // context.strokeStyle = color; + // } + // }) }) // Fill and stroke colors use the same set of swatches fillColor.addEventListener("change", e => { @@ -3357,7 +3548,7 @@ function splitPane(div, percent, horiz, newPane=undefined) { event.currentTarget.setAttribute("dragging", false) // event.currentTarget.style.userSelect = 'auto'; }) - Coloris({el: ".color-field"}) + // Coloris({el: ".color-field"}) updateAll() updateUI() updateLayers() diff --git a/src/styles.css b/src/styles.css index 3a160cf..b10d062 100644 --- a/src/styles.css +++ b/src/styles.css @@ -80,7 +80,7 @@ button { color: #0f0f0f; background-color: #ffffff; transition: border-color 0.25s; - box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2); + box-shadow: 0 4px 4px rgba(0, 0, 0, 0.2); box-sizing: border-box; min-height: var(--lineheight); } @@ -278,9 +278,35 @@ button { background-color: #999; } -.clr-field { +.color-field { + position: relative; + display: flex; + align-items: center; + padding-left: 10px; width: 100%; + height: calc(2 * var(--lineheight)); + --color: red; /* CSS variable for the pseudo-element color */ } + +.color-field::before { + content: "Color:"; + font-size: 16px; + color: black; + margin-right: 10px; +} + +.color-field::after { + content: ""; + flex-grow: 1; + height: 100%; + border-radius: 5px; + background: + linear-gradient(to right, var(--color), var(--color)), + repeating-conic-gradient(#B0B0B0 0% 25%, #E0E0E0 0% 50%) + 50% / 20px 20px, + linear-gradient(to right, white, white); +} + .clr-field button { width: 50% !important; /* height: 100% !important; */ @@ -582,6 +608,9 @@ button { #popupMenu li:not(:last-child) { border-bottom: 1px solid #444; } + .color-field::before { + color: #eee; + } .layers { background-color: #222222; } diff --git a/src/utils.js b/src/utils.js index c206f2a..a4ae897 100644 --- a/src/utils.js +++ b/src/utils.js @@ -104,11 +104,6 @@ function lerpColor(color1, color2, t) { return { r, g, b }; }; - // Convert RGB to hex color - const rgbToHex = (r, g, b) => { - return `#${(1 << 24 | (r << 16) | (g << 8) | b).toString(16).slice(1).toUpperCase()}`; - }; - // Get RGB values of both colors const start = hexToRgb(color1); const end = hexToRgb(color2); @@ -356,11 +351,201 @@ function intToHexColor(value) { return '#' + value.toString(16).padStart(6, '0').toUpperCase(); } -// Helper function to convert RGB to hex (for sampling) -function rgbToHex(r, g, b) { - return '#' + (1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1).toUpperCase(); +function hslToRgb(h, s, l) { + // Ensure that the input values are within the expected range [0, 1] + h = h % 1; // Hue wraps around at 1 + s = Math.min(Math.max(s, 0), 1); // Saturation should be between 0 and 1 + l = Math.min(Math.max(l, 0), 1); // Lightness should be between 0 and 1 + + // Handle case where saturation is 0 (the color is gray) + if (s === 0) { + const gray = Math.round(l * 255); // All RGB values are equal to the lightness value + return { r: gray, g: gray, b: gray }; + } + + // Calculate temporary values + const temp2 = (l < 0.5) ? (l * (1 + s)) : (l + s - l * s); + const temp1 = 2 * l - temp2; + + // Pre-calculate hues at the different points to avoid repeating calculations + const r = hueToRgb(temp1, temp2, h + 1 / 3); + const g = hueToRgb(temp1, temp2, h); + const b = hueToRgb(temp1, temp2, h - 1 / 3); + + // Return RGB values in 0-255 range, rounding the result + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; } +function hueToRgb(t1, t2, t3) { + // Normalize hue to be between 0 and 1 + if (t3 < 0) t3 += 1; + if (t3 > 1) t3 -= 1; + + // Efficient calculation of RGB component + if (6 * t3 < 1) return t1 + (t2 - t1) * 6 * t3; + if (2 * t3 < 1) return t2; + if (3 * t3 < 2) return t1 + (t2 - t1) * (2 / 3 - t3) * 6; + return t1; +} + +function hsvToRgb(h, s, v) { + let r, g, b; + + if (s === 0) { + // If saturation is 0, the color is a shade of gray + r = g = b = v; // All channels are equal + } else { + // Calculate the hue sector (6 sectors, for each of the primary and secondary colors) + const i = Math.floor(h * 6); // The integer part of the hue value + const f = h * 6 - i; // The fractional part of the hue + const p = v * (1 - s); // The value at the lower boundary + const q = v * (1 - f * s); // Intermediate value + const t = v * (1 - (1 - f) * s); // Another intermediate value + + // Use the hue sector index (i) to determine which RGB component will be maximum + switch (i % 6) { + case 0: r = v; g = t; b = p; break; + case 1: r = q; g = v; b = p; break; + case 2: r = p; g = v; b = t; break; + case 3: r = p; g = q; b = v; break; + case 4: r = t; g = p; b = v; break; + case 5: r = v; g = p; b = q; break; + } + } + + // Return RGB values between 0 and 255 (scaled from 0-1 to 0-255) + return { + r: Math.round(r * 255), + g: Math.round(g * 255), + b: Math.round(b * 255) + }; +} + +let cachedPattern = null; // Cache the pattern + +function drawCheckerboardBackground(ctx, x, y, width, height, squareSize) { + // If the pattern is not cached, create and cache it + if (!cachedPattern) { + // Define two shades of gray for the checkerboard + const color1 = '#E0E0E0'; // Light gray + const color2 = '#B0B0B0'; // Dark gray + + // Create a 2x2 checkerboard pattern with four squares + const patternCanvas = document.createElement('canvas'); + const patternCtx = patternCanvas.getContext('2d'); + + // Set the pattern canvas size to 2x2 squares (width and height) + patternCanvas.width = 2 * squareSize; + patternCanvas.height = 2 * squareSize; + + // Fill the four squares to create the checkerboard pattern + patternCtx.fillStyle = color1; // Light gray for the first square + patternCtx.fillRect(0, 0, squareSize, squareSize); // Top-left square + + patternCtx.fillStyle = color2; // Dark gray for the second square + patternCtx.fillRect(squareSize, 0, squareSize, squareSize); // Top-right square + + patternCtx.fillStyle = color2; // Dark gray for the third square + patternCtx.fillRect(0, squareSize, squareSize, squareSize); // Bottom-left square + + patternCtx.fillStyle = color1; // Light gray for the fourth square + patternCtx.fillRect(squareSize, squareSize, squareSize, squareSize); // Bottom-right square + + // Cache the repeating pattern + cachedPattern = ctx.createPattern(patternCanvas, 'repeat'); + } + + // Set the cached pattern as the fill style for the rectangle + ctx.fillStyle = cachedPattern; + + // Draw the rectangle with the repeating checkerboard pattern + ctx.fillRect(x, y, width, height); +} + +function hexToHsl(hex) { + // Step 1: Convert hex to RGB + let r = parseInt(hex.substring(1, 3), 16) / 255; + let g = parseInt(hex.substring(3, 5), 16) / 255; + let b = parseInt(hex.substring(5, 7), 16) / 255; + + // Step 2: Find the maximum and minimum values of r, g, and b + let max = Math.max(r, g, b); + let min = Math.min(r, g, b); + + // Step 3: Calculate Lightness (L) + let l = (max + min) / 2; + + // Step 4: Calculate Saturation (S) + let s = 0; + if (max !== min) { + s = (l > 0.5) ? (max - min) / (2 - max - min) : (max - min) / (max + min); + } + + // Step 5: Calculate Hue (H) + let h = 0; + if (max === r) { + h = (g - b) / (max - min); + } else if (max === g) { + h = (b - r) / (max - min) + 2; + } else { + h = (r - g) / (max - min) + 4; + } + + h = (h / 6) % 1; // Normalize hue to be between 0 and 1 + + // Return HSL values with H, S, and L scaled to [0.0, 1.0] + return { h: h, s: s, l: l }; +} + +function hexToHsv(hex) { + // Step 1: Convert hex to RGB + let r = parseInt(hex.substring(1, 3), 16) / 255; + let g = parseInt(hex.substring(3, 5), 16) / 255; + let b = parseInt(hex.substring(5, 7), 16) / 255; + + // Step 2: Calculate Min and Max RGB values + let min = Math.min(r, g, b); + let max = Math.max(r, g, b); + let delta = max - min; + + // Step 3: Calculate Hue + let h = 0; + if (delta !== 0) { + if (max === r) { + h = (g - b) / delta; // Red is max + } else if (max === g) { + h = (b - r) / delta + 2; // Green is max + } else { + h = (r - g) / delta + 4; // Blue is max + } + h = (h / 6 + 1) % 1; // Normalize to [0, 1] + } + + // Step 4: Calculate Saturation + let s = 0; + if (max !== 0) { + s = delta / max; + } + + // Step 5: Calculate Value + let v = max; + + // Return HSV values, with H, S, and V between 0.0 and 1.0 + return { h: h, s: s, v: v }; +} + +const rgbToHex = (r, g, b) => { + return `#${(1 << 24 | (r << 16) | (g << 8) | b).toString(16).slice(1).toUpperCase()}`; +}; + +function clamp(n) { + // Clamps a value between 0 and 1 + return Math.min(Math.max(n,0),1) +} export { titleCase, @@ -372,5 +557,12 @@ export { camelToWords, generateWaveform, floodFillRegion, - getShapeAtPoint + getShapeAtPoint, + hslToRgb, + hsvToRgb, + hexToHsl, + hexToHsv, + rgbToHex, + drawCheckerboardBackground, + clamp }; \ No newline at end of file