Last active
February 20, 2025 10:24
-
-
Save baraqkamsani/b1bfc1e24b3c45c7967606c6b06ae9bd to your computer and use it in GitHub Desktop.
Myndex APCA with colorjs color-picker
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(function () { | |
if (window.location.hostname !== 'www.myndex.com') { | |
alert('' | |
+ 'Please use this bookmarklet/script on any of the following pages:' | |
+ '\n • https://www.myndex.com/APCA' | |
+ '\n • https://www.myndex.com/BPCA' | |
+ '\n • https://www.myndex.com/SAPC' | |
); | |
throw new Error(); | |
} | |
const | |
isPageAPCA = window.location.pathname.startsWith('/APCA') | |
, isPageBPCA = window.location.pathname.startsWith('/BPCA') | |
, isPageSAPC = window.location.pathname.startsWith('/SAPC') | |
, MYNDEX_tableSamples = document.getElementById('tableSamples') | |
, MYNDEX_contrastResultTable = document.getElementById('contrastResultTable') | |
, MYNDEX_inputTXT = document.getElementById('inputTXT') | |
, MYNDEX_inputBG = document.getElementById('inputBG') | |
, scriptId = '_scriptId' | |
, styleId = '_styleId' | |
, colorPickerIdTXT = 'colorPickerIdTXT' | |
, colorPickerIdBG = 'colorPickerIdBG' | |
, lightnessInputIdTXT = 'lightnessInputIdTXT' | |
, lightnessInputIdBG = 'lightnessInputIdBG' | |
// For .cloneNode (faster) | |
, emptyDiv = document.createElement('div') | |
, emptyButton = document.createElement('button') | |
; | |
if (!MYNDEX_tableSamples | |
|| !MYNDEX_contrastResultTable | |
|| !MYNDEX_inputTXT | |
|| !MYNDEX_inputBG | |
) throw new Error(); | |
let script = document.getElementById(scriptId); | |
if (script != null) console.log('Color picker script tag already created.'); | |
else { | |
script = document.createElement('script'); | |
script.id = scriptId; | |
script.type = 'module'; | |
script.textContent = /* language=javascript */ '' | |
+ 'import Color from "https://colorjs.io/dist/color.js";' | |
+ 'import "https://elements.colorjs.io/src/color-picker/color-picker.js";' | |
+ 'window.Color = Color;' | |
; | |
document.head.appendChild(script); | |
} | |
let style = document.getElementById(styleId); | |
if (style != null) console.log('Color picker style tag already created.'); | |
else { | |
style = document.createElement('style'); | |
style.id = styleId; | |
style.textContent = /* language=css */ '' | |
+ 'html { --slider-height: 1.125rem }' | |
+ 'color-picker { margin-block: 0; display: block }' | |
+ 'color-picker::part(swatch){ font-size: 1.75rem; width: 49.5cqw; min-block-size: 3.5rem }' | |
+ 'color-picker::part(color-space) { font-size: 0.875rem; }' | |
+ 'color-picker::part(sliders) { font-size: 0.875rem; gap: 0 }' | |
// width is 1000px because of the following CSS in the page: | |
// #tableSamples.conformScore { width: 1024px } /* https://www.myndex.com/APCA/ */ | |
// #tableSamples.conformScore { width: 100% } /* https://www.myndex.com/SAPC/ */ | |
+ '.color-picker-wrapper { display: flex; justify-content: space-between; width:1000px; container-type: inline-size; margin-left: 12px }' | |
+ '.color-picker-wrapper--SAPC{ width:100%; margin-inline: auto }' | |
+ '.chroma-button { font-size: 1rem; padding: 0.375rem; cursor: pointer; color:#30a; background-color:#def; border: 2px solid #30a }' | |
+ '.chroma-button-wrapper { display: flex; width:1000px; justify-content: space-around }' | |
+ '.chroma-button-wrapper--SAPC { margin-block: 0.5rem; width:100%; }' | |
+ '.lightness-input { font-size: 1rem; padding: 0.375rem; border: 2px solid #104; box-shadow: none; border-radius: 0; width: 25cqw; background-color: #FFD !important; color: #104 !important; }' | |
+ '.lightness-input::placeholder { color: #104 !important; }' | |
+ '.lightness-controls { margin-top: 1rem; display: flex; gap: 0.25rem; align-items: center; }' | |
+ '.lightness-controls-wrapper { display: flex; width:1000px; justify-content: space-around }' | |
+ '.lightness-controls-wrapper--SAPC { width:100%; }' | |
; | |
document.head.appendChild(style); | |
} | |
// ----- Utility Functions --------------------------------------------------- | |
/** | |
* Format <input id=color> in <color-picker> to preferred coords. | |
* @param color {import('colorjs.io').default} | |
* @see https://colorjs.io/docs/output | |
*/ | |
function colorToStringPreferred(color) { | |
return color.toString({ format: { | |
name: 'oklch', | |
coords: [ | |
'<percentage>', // or <number> | |
'<percentage>', // or <number> | |
'<number>' // or <angle> | |
] | |
}}); | |
} | |
/** | |
* Hotfix for color-slider after calling colorToStringPreferred() | |
* Without the reset, sliders may stop working. | |
* @param color {import('colorjs.io').default} | |
*/ | |
function colorToStringReset(color) { | |
return color.toString({ format: { name: 'oklch' } }); | |
} | |
/** | |
* @param element {HTMLElement} | |
* @param className {string} | |
*/ | |
function setClassName(element, className) { | |
if (isPageAPCA) element.className = `${className} ${className}--APCA`; | |
if (isPageBPCA) element.className = `${className} ${className}--BPCA`; | |
if (isPageSAPC) element.className = `${className} ${className}--SAPC`; | |
} | |
let | |
colorPickerTXT = document.getElementById(colorPickerIdTXT) | |
, colorPickerInputTXT | |
, colorPickerBG = document.getElementById(colorPickerIdBG) | |
, colorPickerInputBG | |
; | |
if (!!colorPickerTXT && !!colorPickerBG) { | |
console.log('<color-picker> web components already created.') | |
throw new Error(); | |
} | |
// ----- Button and Input Functions ------------------------------------------ | |
/** | |
* @param colorPickerInput {HTMLInputElement} | |
* @param allHues {boolean|undefined} undefined sets chroma to 0. | |
*/ | |
function setChroma(colorPickerInput, allHues = undefined) { | |
const color = new window.Color(colorPickerInput.value); | |
if (allHues == null) { | |
color.c = 0 | |
colorPickerInput.value = colorToStringPreferred(color); | |
colorPickerInput.dispatchEvent(new Event('input', { bubbles: true })); | |
colorToStringReset(color); | |
return | |
} | |
const inGamutSRGB = allHues === true | |
? color => { | |
for (color.h = 0; color.h <= 360; color.h++) | |
if (!color.inGamut('SRGB')) | |
return false; | |
return true; | |
} | |
: color => color.inGamut('SRGB'); | |
// Start from the highest upper-bound chroma. | |
color.c = allHues === true | |
? 0.15 | |
: 0.4; | |
const { h: startingHue } = color; | |
while (!inGamutSRGB(color)) | |
color.c = Math.round(color.c * 10000 - 1 ) / 10000; | |
color.h = startingHue; | |
colorPickerInput.value = colorToStringPreferred(color); | |
colorPickerInput.dispatchEvent(new Event('input', { bubbles: true })); | |
colorToStringReset(color); | |
} | |
/** | |
* @param isBackground {boolean} | |
*/ | |
async function setLightness(isBackground = false) { | |
const targetInput = isBackground ? colorPickerInputBG : colorPickerInputTXT; | |
const referenceInput = isBackground ? colorPickerInputTXT : colorPickerInputBG; | |
// Get initial OKLCH value | |
const targetColor = new window.Color(targetInput.value); | |
const referenceColor = new window.Color(referenceInput.value); | |
const { l: referenceLightness } = referenceColor; | |
// Backup the original lightness in case we can't reach the targetLc | |
const { l: originalLightness } = targetColor; | |
// Set initial lightness and increment | |
targetColor.l = referenceLightness; | |
const increment = referenceLightness > 0.5 ? -0.001 : 0.001; | |
// Get target Lc from input or prompt | |
const inputId = isBackground ? lightnessInputIdBG : lightnessInputIdTXT; | |
let targetLc = parseFloat(document.getElementById(inputId).value); | |
if (isNaN(targetLc)) { | |
targetLc = parseFloat(prompt( | |
'Enter target Lc value for ' | |
+ isBackground ? 'background' : 'text' | |
+ ' (e.g. 87.5):' | |
)); | |
if (isNaN(targetLc)) { | |
alert("Please enter a valid number for target Lc"); | |
return; | |
} | |
} | |
const update = async () => await new Promise(resolve => { | |
const colorString = colorToStringPreferred(targetColor); | |
targetInput.value = colorString; | |
targetInput.setAttribute('value', colorString); | |
targetInput.dispatchEvent(new Event('input', { bubbles: true })); | |
setTimeout(resolve, 2.5); | |
}) | |
const regex = /-?\d+\.?\d*/; | |
for (let i = 0; i < 65535 /* Maximum Iterations */; i++) { | |
await update(); | |
const contrastText = MYNDEX_contrastResultTable.textContent; | |
const lcMatch = contrastText.match(regex); | |
if (!lcMatch) { | |
console.error("Could not parse contrast value, current text:", contrastText); | |
continue; | |
} | |
const currentLc = Math.abs(parseFloat(lcMatch[0])); | |
const differenceLc = targetLc - currentLc; | |
if (differenceLc < 0) { | |
// Happy path. | |
colorToStringReset(targetColor); | |
return; | |
} | |
if (targetColor.l > 1.00 || targetColor.l < 0.00) { | |
// Overshot. This can happen when the reference lightness | |
// is neither bright nor dark, but the targetLc is very high. | |
targetColor.l = originalLightness; | |
await update(); | |
colorToStringReset(targetColor); | |
const reference = isBackground ? 'text' : 'background'; | |
const DecreaseOrIncrease = targetColor.l > 1.00 ? 'Reduce' : 'Increase'; | |
alert( | |
'Could not reach Target Lc.\n' | |
+ DecreaseOrIncrease | |
+ ' your ' | |
+ reference | |
+ ' lightness and try again.' | |
); | |
return; | |
} | |
let l = 0; | |
if (differenceLc > 50) l += increment * (differenceLc * 5); | |
else if (differenceLc > 40) l += increment * (differenceLc * 4); | |
else if (differenceLc > 30) l += increment * (differenceLc * 3); | |
else if (differenceLc > 20) l += increment * (differenceLc * 2); | |
else if (differenceLc > 2) l += increment * differenceLc; | |
else l += increment; | |
targetColor.l += l; | |
} | |
console.error("Maximum iterations reached without finding target contrast"); | |
colorToStringReset(targetColor); | |
} | |
// ----- Create Elements ------------------------------------------------------------------------- | |
const createButton = (text, onClick, className = 'chroma-button') => { | |
/** @type {HTMLButtonElement} */ | |
const button = emptyButton.cloneNode(false); | |
button.textContent = text; | |
button.addEventListener('click', onClick); | |
setClassName(button, className); | |
return button; | |
}; | |
const createInput = (id, placeholder, onClick) => { | |
const input = document.createElement('input'); | |
input.id = id; | |
input.type = 'number'; | |
input.placeholder = placeholder; | |
setClassName(input, 'lightness-input'); | |
input.addEventListener('keydown', e => { | |
if ([ 'Enter' ].includes(e.key)) onClick(); | |
}); | |
return input; | |
}; | |
const createColorPicker = (id, color, myndexInput) => { | |
const picker = document.createElement('color-picker'); | |
picker.id = id; | |
picker.setAttribute('space', 'oklch'); | |
picker.setAttribute('color', color); | |
picker.shadowRoot | |
.querySelector('color-swatch') | |
.shadowRoot | |
.querySelector('div#swatch') | |
.remove(); | |
picker.addEventListener('colorchange', (e) => { | |
try { | |
const color = new window.Color(e.target.color); | |
myndexInput.value = color.toString({ format: 'hex' }); | |
myndexInput.dispatchEvent(new Event('blur')); | |
} catch (err) { | |
console.error('Color conversion error:', err); | |
} | |
}); | |
return picker; | |
} | |
setTimeout(() => { | |
// ----- Color Picker ------------------------ | |
try { | |
colorPickerTXT = createColorPicker(colorPickerIdTXT, 'oklch(40.0% 49.6% 265.2)', MYNDEX_inputTXT); | |
colorPickerBG = createColorPicker(colorPickerIdBG, 'oklch(91.74% 6.9% 95.36)', MYNDEX_inputBG); | |
colorPickerInputTXT = colorPickerTXT.shadowRoot.querySelector('input#color'); | |
colorPickerInputBG = colorPickerBG.shadowRoot.querySelector('input#color'); | |
} catch (e) { | |
console.error(e); | |
alert('Please retry this script'); | |
} | |
const colorPickerWrapper = emptyDiv.cloneNode(false); | |
setClassName(colorPickerWrapper, 'color-picker-wrapper'); | |
colorPickerWrapper.appendChild(colorPickerTXT); | |
colorPickerWrapper.appendChild(colorPickerBG); | |
MYNDEX_tableSamples.parentNode.insertBefore(colorPickerWrapper, MYNDEX_tableSamples); | |
// ----- Chroma Buttons ---------------------- | |
const css = 'display: flex; flex-wrap: wrap; justify-content: space-evenly; gap: 0.25rem;' | |
const chromaButtonWrapperInsideTXT = emptyDiv.cloneNode(false); | |
[ | |
createButton('Max Chroma (Current Hue)', () => setChroma(colorPickerInputTXT, false)), | |
createButton('Max Chroma (All Hues)', () => setChroma(colorPickerInputTXT, true)), | |
createButton('Chroma 0', () => setChroma(colorPickerInputTXT)), | |
] | |
.forEach(button => chromaButtonWrapperInsideTXT.appendChild(button)); | |
chromaButtonWrapperInsideTXT.setAttribute('style', css); | |
const chromaButtonWrapperInsideBG = emptyDiv.cloneNode(false); | |
[ | |
createButton('Max Chroma (Current Hue)', () => setChroma(colorPickerInputBG, false)), | |
createButton('Max Chroma (All Hues)', () => setChroma(colorPickerInputBG, true)), | |
createButton('Chroma 0', () => setChroma(colorPickerInputBG)), | |
] | |
.forEach(button => chromaButtonWrapperInsideBG.appendChild(button)); | |
chromaButtonWrapperInsideBG.setAttribute('style', css); | |
const chromaButtonWrapper = emptyDiv.cloneNode(false); | |
setClassName(chromaButtonWrapper, 'chroma-button-wrapper'); | |
chromaButtonWrapper.appendChild(chromaButtonWrapperInsideTXT); | |
chromaButtonWrapper.appendChild(chromaButtonWrapperInsideBG); | |
MYNDEX_tableSamples.parentNode.insertBefore(chromaButtonWrapper, colorPickerWrapper); | |
// ----- Lightness Controls ------------------ | |
const | |
lightnessControlsWrapper = emptyDiv.cloneNode(false) | |
, lightnessControlsTXT = emptyDiv.cloneNode(false) | |
, lightnessControlsBG = emptyDiv.cloneNode(false) | |
, lightnessInputTXT = createInput(lightnessInputIdTXT, 'Target Lc', () => setLightness(false)) | |
, lightnessInputBG = createInput(lightnessInputIdBG, 'Target Lc', () => setLightness(true)) | |
, lightnessButtonTXT = createButton('Adjust lightness to Target Lc', () => setLightness(false)) | |
, lightnessButtonBG = createButton('Adjust lightness to Target Lc', () => setLightness(true)) | |
; | |
setClassName(lightnessControlsWrapper, 'lightness-controls-wrapper'); | |
setClassName(lightnessControlsTXT, 'lightness-controls'); | |
setClassName(lightnessControlsBG, 'lightness-controls'); | |
lightnessControlsTXT.appendChild(lightnessInputTXT); | |
lightnessControlsTXT.appendChild(lightnessButtonTXT); | |
lightnessControlsBG.appendChild(lightnessInputBG); | |
lightnessControlsBG.appendChild(lightnessButtonBG); | |
lightnessControlsWrapper.appendChild(lightnessControlsTXT); | |
lightnessControlsWrapper.appendChild(lightnessControlsBG); | |
MYNDEX_tableSamples.parentNode.insertBefore(lightnessControlsWrapper, chromaButtonWrapper); | |
}, 500); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This script adds the color-picker web component from colorjs into Myndex tools:
As of 2025-02-12, you can use it on any of the following pages:
Usage
Browser Console
Copy and paste the script into the browser console.
Bookmarklet
Paste the script into a bookmarklet generator.
I use bookmarkleter with the following options:
Note:
The produced Lc value is still based on the color-picker's values converted to hex.
In other words, if you have a text
oklch(100% 0 0)
and backgroundoklch(0% 0 0)
,this script converts it to
#fff
and#000
before updating the input field from Myndex.Therefore, it's best to stay within the sRGB gamut.
The color-picker will indicate when you are outside of it (PP+):
Tip:
If you need finer control over the LCh values,
select the value from its input field and hold Ctrl+Up or Ctrl+Down: