Trello Colors

A userscript that allows for unlimited color options on Trello labels.

// ==UserScript==
// @name         Trello Colors
// @namespace    http://tampermonkey.net/
// @version      3.0
// @description  A userscript that allows for unlimited color options on Trello labels.
// @author       branbarh
// @match        *://*.trello.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

/*

  Potential future updates:
   - Support for custom colors on "Cover" cards

*/

//  ======================================= MAIN UPDATE LOOP =======================================

window.addEventListener("load", () => {

  // Handle CSS rules:
  handleCSSRules();

  // Periodically check for new unchecked labels and tooltips, and handle label input changes:
  setInterval(() => {
    handleLabelInput();
    handleLabels();
    handleTooltips();
  }, 0);

});

// ===================================== CSS & LABEL FUNCTIONS =====================================

function handleCSSRules() {

  // Append a style element to the head of the document, to allow for pseudo elements and animations to be hidden later:
  const styleElm = document.createElement("style");
  document.head.appendChild(styleElm);

  const styleSheet = styleElm.sheet;

  // Increase the padding between the labels and the card contentent:
  styleSheet.insertRule(`div[data-testid=trello-card] > div > div:first-child { padding-top: 3px !important; padding-bottom: 4px !important; }`);
  styleSheet.insertRule(`div[data-testid=trello-card] > div > div:first-child:empty { padding-top: 2px !important; padding-bottom: 0px !important; }`);

}

function handleLabels() {

  // Get all unchecked labels:
  const labels = [
    // Card front labels (i.e., the labels shown on the front of each card):
    ...document.querySelectorAll("div[data-testid=trello-card] > div > div:first-child span[data-testid]:not(.trelloColors_checked)"),
    // Card quick-edit front labels (i.e., the labels shown when you right click a card):
    ...document.querySelectorAll("div[data-testid=quick-card-editor-card-front] > div > div:first-child span[data-testid]:not(.trelloColors_checked)"),
    // Card back labels (i.e., the labels shown on the back of each card when you click to open it, under the "Labels" section):
    ...document.querySelectorAll(".js-card-back-labels-container div > span[data-testid=card-label]:not(.trelloColors_checked):not([type])"),
    // Popover label selector and suggested labels (i.e., the labels shown in the popover menu that appears when you chose to select labels for a card):
    ...document.querySelectorAll("[data-testid=labels-popover-labels-screen] [data-testid=card-label]:not(.trelloColors_checked)")
  ];

  // Loop through the unchecked labels and update them to display their custom color (if any):
  labels.forEach(label => {

    // Get the label title from the aria text:
    const labelTitle = cleanQuotes(label.ariaLabel?.split(": ")[2].slice(1, -1));

    // Mark this label as checked:
    label.classList.add("trelloColors_checked");
    
    // Return if the label is invalid; inform the user that they should open an issue on GitHub:
    if (labelTitle === undefined)
      return console.error("Invalid label. Please open an issue on GitHub (https://github.com/branbarh/Trello-Colors-Extension/issues) and outline the steps to reproduce this error.", label);

    // Return if the label does not have a custom color associated with it:
    if (labelTitle.indexOf("#") !== 0 || labelTitle.indexOf(":") < labelTitle.indexOf("#")) {
      label.style.removeProperty("background-color");
      label.style.removeProperty("color");
      return;
    }

    // Get the custom color associated with the label:
    const hex = labelTitle.split(":")[0];
    const shadedHex = hexToMouseoverColor(hex);

    // Determine if the label has its text showing:
    const isBackLabel = label?.parentNode?.parentNode?.parentNode?.parentNode?.classList?.contains("js-card-back-labels-container");
    const isPopoverLabel = label?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.dataset?.testid === "labels-popover-labels-screen";
    const isSuggestedLabel = label?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.parentNode?.dataset?.testid === "labels-popover-suggested-labels";
    const isFrontLabelTextShowing = document.getElementById("trello-root").classList.contains("body-card-label-text") || document.getElementById("trello-root").classList.contains("body-card-label-text-on");
    
    // Back labels, popover labels, and suggested labels always have their text showing; front labels will only have their text showing when expanded (i.e., when isFrontLabelTextShowing is true):
    const isLabelTextShowing = isBackLabel || isPopoverLabel || isSuggestedLabel || isFrontLabelTextShowing;

    // Get the label text color:
    const labelTextColor = hexToLabelTextColor(hex);

    // Update the relevant label styles and data:
    label.style.backgroundColor = hex;
    label.style.color = isLabelTextShowing ? labelTextColor : "#00000000";
    label.innerText = labelTitle.substring(hex.length + 1);

    // Update the "--label-text-color" CSS variable to color match the colorblind pattern with the label's inner text:
    label.style.setProperty("--label-text-color", labelTextColor);

    // Add mouseover/mouseout event listeners to shade the label color on hover:
    label.dataset.defaultcolor = hex;
    label.dataset.shadedcolor = shadedHex;
    label.addEventListener("mouseover", mouseoverLabel);
    label.addEventListener("mouseout", mouseoutLabel);

  });

}

function handleTooltips() {

  // Get the current unchecked tooltip, if any:
  const tooltip = document.querySelector(".atlaskit-portal-container [role=tooltip]:not(.trelloColors_checked)");

  // Return if the tooltip does not exist or is not for a label:
  if (!tooltip || tooltip.innerText.split(": ")[0] !== "Color" || tooltip.innerText.split(": ")[1]?.split(", ")[1] !== "title")
    return;

  // Get the label title from the inner text:
  const tooltipTitle = cleanQuotes(tooltip.innerText?.split(": ")[2].slice(1, -1));

  // Mark this tooltip as checked:
  tooltip.classList.add("trelloColors_checked");

  // Return if the tooltip is invalid; inform the user that they should open an issue on GitHub:
  if (tooltipTitle === undefined)
    return console.error("Invalid tooltip. Please open an issue on GitHub (https://github.com/branbarh/Trello-Colors-Extension/issues) and outline the steps to reproduce this error.", tooltip);

  // Return if the tooltip does not have a custom color associated with it:
  if (tooltipTitle.indexOf("#") !== 0 || tooltipTitle.indexOf(":") < tooltipTitle.indexOf("#"))
    return;

  // Update the tooltip to hide the custom color:
  const hex = tooltipTitle.split(":")[0];
  const title = tooltipTitle.substring(hex.length + 1);
  tooltip.innerText = `Custom color: ${hex}, title: “${title}”`;

}

function handleLabelInput() {

  // Get the label input and the save button:
  const input = document.querySelector("section.js-react-root > div > div > input.nch-textfield__input:not(.trelloColors_eventAdded)");
  const saveButton = document.querySelector("section.js-react-root > div > div[class] > button");

  // Return if there is no label input currently being displayed to the user:
  if (!input)
    return;

  // Mark the label input as checked:
  input.classList.add("trelloColors_eventAdded");
  
  // Update the label preview when the input changes:
  input.addEventListener("input", () => {
    updateLabelPreview(input);
  });
  updateLabelPreview(input);

  // Uncheck all labels when the current label is saved, as all instances of this label will need to be updated in accordance with the new changes:
  input.addEventListener("keydown", (e) => {
    if (e.keyCode === 13)
      uncheckAllLabels();
  });
  saveButton.addEventListener("click", uncheckAllLabels);

}

function updateLabelPreview(input) {

  // Get the label preview element:
  const label = document.querySelector("section.js-react-root span[data-testid='card-label']");

  // If there is no custom color, remove any custom, previously set properties:
  if (input.value.indexOf("#") !== 0 || input.value.indexOf(":") < input.value.indexOf("#")) {
    label.style.removeProperty("background-color");
    label.style.removeProperty("color");
    label.innerText = input.value;
    return;
  }

  // Otherwise, a custom color has been provided; update the label preview to display the custom color:
  const hex = input.value.split(":")[0];

  // Update the label preview properties:
  label.style.backgroundColor = hex;
  label.style.color = hexToLabelTextColor(hex);
  label.innerText = input.value.substring(hex.length + 1);

}

//  =================================== EVENT LISTENER FUNCTIONS ===================================

function mouseoverLabel() {

  // Get a reference to the label:
  const label = this;

  // Update the label's background color:
  label.style.backgroundColor = label.dataset.shadedcolor;

}

function mouseoutLabel() {

  // Get a reference to the label:
  const label = this;

  // Update the label's background color:
  label.style.backgroundColor = label.dataset.defaultcolor;

}

// ======================================== COLOR FUNCTIONS ========================================

function hexToRGB(hex) {

  // Get the number of hex digits per RGB channel (should always be either 1 (e.g., #000) or 2 (e.g., #000000)):
  const digitsPerChannel = Math.floor((hex.length - 1) / 3);

  // Get the value for each RGB channel:
  const RGB = {
    r: parseInt(hex.substring(1 + 0 * digitsPerChannel, 1 + 1 * digitsPerChannel), 16),
    g: parseInt(hex.substring(1 + 1 * digitsPerChannel, 1 + 2 * digitsPerChannel), 16),
    b: parseInt(hex.substring(1 + 2 * digitsPerChannel, 1 + 3 * digitsPerChannel), 16)
  };

  // Fix the values for each RGB channel if only 1 hex digit was provided per channel:
  if (digitsPerChannel === 1) {
    RGB.r *= 17;
    RGB.g *= 17;
    RGB.b *= 17;
  }

  // Return the RGB values:
  return RGB;

}

function RGBToHex(RGB) {

  // Convert each RBG channel to hex:
  const hexChannels = {
    r: RGB.r.toString(16).padStart(2, "0"),
    g: RGB.g.toString(16).padStart(2, "0"),
    b: RGB.b.toString(16).padStart(2, "0")
  };

  // Form the hex string:
  const hex = `#${hexChannels.r}${hexChannels.g}${hexChannels.b}`;

  // Return the resulting hex:
  return hex;

}

function shadeDirection(hex) {

  // Convert the hex to RGB:
  const RGB = hexToRGB(hex);

  // Determine if the color is light or dark based on whether or not it exceeds the threshold (equal to ceil(2 * 16^2 / 3)):
  const threshold = 171;
  const hexShadeDirection = (RGB.r + RGB.g + RGB.b) / 3 >= threshold ? "light" : "dark";

  // Return the calculated shade direction:
  return hexShadeDirection;

}

function hexToLabelTextColor(hex) {

  // If the color is light, a dark text color should be used; if the color is dark, a light text color should be used:
  const hexShadeDirection = shadeDirection(hex);
  const labelTextColor = hexShadeDirection === "light" ? "#1d2125" : "#ffffffe0";

  // Return the calculated label text color:
  return labelTextColor;

}

function hexToMouseoverColor(hex) {

  // Note: I found "0" to be the best constant to use when calling shadeHex; this may change in the future, so I've left it in the code for now.

  // If the color is light, a light mouseover color should be used; if the color is dark, a dark mouseover color should be used:
  const hexShadeDirection = shadeDirection(hex);
  const mouseoverColor = hexShadeDirection === "light" ? shadeHex(hex, 0, 1.16) : shadeHex(hex, 0, 0.6);

  // Return the calculated mouseover color:
  return mouseoverColor;

}

function shadeHex(hex, constant, scalar) {

  // Convert the hex to RGB:
  const RGB = hexToRGB(hex);

  // Shade each color channel:
  const shadedRGB = {
    r: Math.min(Math.max(Math.round((RGB.r + constant) * scalar), 0), 255),
    g: Math.min(Math.max(Math.round((RGB.g + constant) * scalar), 0), 255),
    b: Math.min(Math.max(Math.round((RGB.b + constant) * scalar), 0), 255)
  };

  // Convert back to hex:
  const shadedHex = RGBToHex(shadedRGB);

  // Return the original hex if something went wrong:
  if (shadedHex.includes("NaN"))
    return hex;

  // Otherwise, return the shaded hex:
  return shadedHex;

}

// ======================================= UTILITY FUNCTIONS =======================================

function cleanQuotes(str) {

  // Remove any extraneous quotes:
  const quoteChars = "«»".split("");
  quoteChars.forEach(q => (str = str?.split(q).join("")));

  // Return the cleaned string:
  return str;

}

function uncheckAllLabels() {

  // Delay unchecking all labels by a frame to ensure that the card front labels have updated:
  setTimeout(() => {

    // Get all checked labels:
    const checkedLabels = [...document.getElementsByClassName("trelloColors_checked")];

    // Uncheck all checked labels and remove any event listeners:
    checkedLabels.forEach(label => {
      label.classList.remove("trelloColors_checked");
      delete label.dataset.defaultcolor;
      delete label.dataset.shadedcolor;
      label.removeEventListener("mouseover", mouseoverLabel);
      label.removeEventListener("mouseout", mouseoutLabel);
    });

  }, 0);

}