Created
February 12, 2026 04:33
-
-
Save nickyonge/afe8c5260b57521ace664f41ffe21516 to your computer and use it in GitHub Desktop.
Decap CMS Collection Title Color Display
This file contains hidden or 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
| /** DECAP COLLECTION SUMMARY COLOR DISPLAY | |
| * | |
| * Replaces a color string in your Decap CMS Collection's title text | |
| * with a visual indicator of that color, including a color swatch, | |
| * text output, and/or updating the collection item's background color. | |
| * | |
| * | |
| * MARK: Setup | |
| * Using marks for VSCode minimap navigation | |
| * | |
| * INSTALLATION | |
| * | |
| * 1) Load the script into your HTML | |
| * Drop/load this script the directory with Decap's index.html, and add | |
| <script src="decapColorDisplay.js"></script> | |
| * to index.html's body | |
| * | |
| * 2) Check your config.yml Collections settings | |
| * For this to work, the title of your Collection must have a | |
| * CSS-recognizable color in it. The easiest way to do this is | |
| * to have a "color" widget in your collection's fields, preferably | |
| * required and with a default value, and to specify it in your | |
| * collection's "summary". Eg, an example config.yml: | |
| config.yml | |
| collections: | |
| - name: 'demo' | |
| label: 'Demo' | |
| create: true | |
| folder: 'content/demo/' | |
| summary: "{{title}} {{color}}" | |
| fields: | |
| - { label: "Title", name: "title", widget: "string" } | |
| - { label: "Color", name: 'color', widget: 'color', default: '#beeeef'} | |
| * Ultimately though, so long as a CSS-valid color appears anywhere | |
| * in the text, it can be converted to a swatch. This is why it's useful | |
| * to set "preferColorOrder" in config to -1, keep your "color" field | |
| * required, give it a default value, and ensure that "{{color}}" is at | |
| * the END of your "summary", so that there will always be a color to catch. | |
| * | |
| * In the above example, by defining summary as "{{title}} {{color}}", the | |
| * color is always rendered last. Then, with "preferOrderColor" as -1, whatever | |
| * value is defined in the color widget will ALWAYS be the detected color. | |
| * | |
| * Also note: if you set "allowInput:true" in your color widget, users can | |
| * type their own manual text into the color field. In this case, you should | |
| * set "validColors" in config to "BASIC", "CSS", or "FULL", so that colors | |
| * like "red" or "rebeccapurple" can be validly identified. | |
| * | |
| * Collections docs: https://decapcms.org/docs/configuration-options/#collections | |
| * Color widget: https://decapcms.org/docs/widgets/#Color | |
| * | |
| * 3) Adjust the configuration settings in this script to your liking. See below. | |
| * | |
| * CONFIGURATION | |
| * Adjust the settings in the config parameters below as needed. | |
| * | |
| * First, define the behaviour config: | |
| * - validColors: What constitutes a "color"? Options are #ff0000 hex codes, | |
| * rgb()/rgba()/hsl()/hsla() values, common color names like "red", or special | |
| * color keywords like "transparent" or "currentcolor". | |
| * Typically "CSS" is ideal, but choose what's best for your setup. | |
| * - preferColorOrder: When searching for colors, what order should the search be | |
| * performed in? 0 is front to back, -1 is back to front, other positive numbers | |
| * are specific values within the summary title | |
| * | |
| * Next, set the appearance display flags: | |
| * - displayColorBackground: Show the color on the background of the collection item? | |
| * - displayColorSwatch: Display a color swatch on the collection item itself? | |
| * - displayColorText: Show the color's text value on the collection item (and in the swatch, if it's rendered)? | |
| * | |
| * Last, update specific appearance settings: | |
| * | |
| * For the background, | |
| * - backgroundUsesGradient: Should the background color, if used, be drawn as a gradient? | |
| * - swatchFadesBackgroundColor: If using both swatch and background colors, should the BG color be faded? | |
| * | |
| * For the swatch, | |
| * - swatchRightJustify: Should the swatch, if used, be right-justified? | |
| * - removeColorTextFromCollection: Should the detected color text be removed from the collection title's text? | |
| * - swatchWidth: Width of the swatch, if it's used | |
| * - swatchMaxWidth: Max width of the swatch, if it's used | |
| * - swatchHeight: Height of the swatch, if it's used | |
| * | |
| * For the text, | |
| * - textSize: Size of the color text, if it's used | |
| * - textColor: How to apply color to text, if it's used. Generally "AUTO" works best | |
| * - textShadow: Should a drop shadow render behind the text? | |
| * | |
| */ | |
| (() => { | |
| 'use strict'; | |
| // MARK: Config | |
| /* ======================================== */ | |
| /* ============= CONFIG ============= */ | |
| /* ======================================== */ | |
| /** Is this script enabled? */ | |
| const enabled = true; | |
| /* ===== Behaviour Config ===== */ | |
| /** | |
| * What type of colors are considered valid? | |
| * - `"HEX"`: only hex-code colors, like `#F0F`, `#F0F8`, `#FF00FF`. or `#FF00FF88` | |
| * - `"NAME"`: only named color keywords like `"red"` and `"darkseagreen"` | |
| * - `"BASIC"`: hex-code colors like `#FF00FF`, *and* named color keywords like `"red"` and `"darkseagreen"`, but *not* RGB/HSL | |
| * - `"DATA"`: hex colors *and* `rgb()`, `rgba()`, `hsl()`, and `hsla()` colors, but *not* any named color keywords | |
| * - `"CSS"`: hex, RGB/HSL, and color keywords like `"red"` and `"darkseagreen"`, and common technical keywords `"transparent"`, `"currentcolor"`, and `"accentcolor"` | |
| * - `"FULL"`: all CSS-functional colors in `"CSS"`, plus technical keywords like `"activetext"`, `"buttonface"`, `"canvas"`, `"mark"`, etc | |
| * @type {"HEX"|"NAME"|"BASIC"|"DATA"|"CSS"|"FULL"} | |
| */ | |
| const validColors = "CSS"; | |
| /** | |
| * Which order of color found in the summary to prefer? | |
| * - `-1`: last found color, closest to the end of the string | |
| * - `0`: first found color | |
| * - `>= 1`: zero-inclusive Nth found color | |
| * (eg, `2` would return the 3rd found color). If there are fewer | |
| * colors found than specified, returns the last found color. | |
| * - `< -1`: invalid | |
| */ | |
| const preferColorOrder = -1; | |
| /* ===== Appearance Config ===== */ | |
| // --- Main Display Flags --- | |
| /** Update the background color of each collection item? */ | |
| const displayColorBackground = true; | |
| /** Display a color swatch for item? */ | |
| const displayColorSwatch = false; | |
| /** Display the color text on the item? Typically meant to appear within the swatch, but it can display even if {@linkcode displayColorSwatch} is `false` */ | |
| const displayColorText = true; | |
| // --- Background Apperance --- | |
| /** Should the background color use a gradient? */ | |
| const backgroundUsesGradient = true; | |
| /** If both the swatch and background color are rendered, should the background color be slightly faded? */ | |
| const swatchFadesBackgroundColor = true; | |
| // --- Swatch Apperance --- | |
| /** Right-justify the swatch in the collection bar? */ | |
| const swatchRightJustify = true; | |
| /** Remove the color text from the collection's summary text? */ | |
| const removeColorTextFromCollection = true; | |
| /** CSS `width` of the swatch, in any valid CSS unit */ | |
| const swatchWidth = '200px'; | |
| /** CSS `max-width` of the swatch, in any valid CSS unit (ignored if blank or null) */ | |
| const swatchMaxWidth = '100%'; | |
| /** CSS `height` of the swatch, in any valid CSS unit. | |
| * - 28px is a satisfyingly large swatch | |
| * - 22.5px is the default height of the container */ | |
| const swatchHeight = '28px'; | |
| // --- Text Apperance --- | |
| /** If {@linkcode displayColorText} is `true`, what size should the text be? Adjust this for {@linkcode swatchHeight} changes. */ | |
| const textSize = '9pt'; | |
| /** If {@linkcode displayColorText} is `true`, should the text adjust to the general brightness of the color? */ | |
| /** | |
| * What type of colors are considered valid? | |
| * - `"DEFAULT"`: Don't modify the CSS text color value | |
| * - `"AUTO"`: Automatically use light or dark text depending on the detected color | |
| * - `"LIGHT"`: Use a light text color | |
| * - `"DARK"`: Use a dark text color | |
| * - `"COLOR"`: Match the detected color | |
| * | |
| * @type {"DEFAULT"|"AUTO"|"LIGHT"|"DARK"|"COLOR"} | |
| */ | |
| const textColor = 'AUTO'; | |
| /** Apply a shadow on the text? It automatically detects light or dark. Mainly useful if textColor is COLOR, and there's no BG/swatch */ | |
| const textShadow = false; | |
| /* ===== End Config ===== */ | |
| // MARK: Internal | |
| /* ===== Internal Params ===== */ | |
| /** regex for finding only `#RGB`, `#RGBA`, `#RRGGBB`, and `#RRGGBBAA` hex colors in a string */ | |
| const _COLOR_REGEX_HEX = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})(?![0-9a-f])/gi; | |
| /** regex for finding color hex codes *and* `rgb()`, `rgba()`, `hsl()`, and `hsla()` colors in a string */ | |
| const _COLOR_REGEX_DATA = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])|\b(?:rgba?|hsla?)\(\s*[^)]*\s*\)/gi; | |
| /** All CSS color keyword strings, from `"aliceblue"` to `"yellowgreen"` */ | |
| const _CSS_COLOR_NAMES = new Set(["aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", "floralwhite", "forestgreen", "fuchsia", "gainsboro", "ghostwhite", "gold", "goldenrod", "gray", "green", "greenyellow", "grey", "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "purple", "rebeccapurple", "red", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver", "skyblue", "slateblue", "slategray", "slategrey", "snow", "springgreen", "steelblue", "tan", "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white", "whitesmoke", "yellow", "yellowgreen"]); | |
| /** Relatively common special CSS color keywords: `"transparent"`, `"currentcolor"`, and `"accentcolor"` */ | |
| const _CSS_COLOR_KEYWORDS_COMMON = new Set(['transparent', 'currentcolor', 'accentcolor']); | |
| /** All special CSS color keywords, including {@link _CSS_COLOR_KEYWORDS_COMMON the common ones}, and also: `"accentcolortext"`, `"activetext"`, `"buttonborder"`, `"buttonface"`, `"buttontext"`, `"canvas"`, `"canvastext"`, `"field"`, `"fieldtext"`, `"graytext"`, `"highlight"`, `"highlighttext"`, `"linktext"`, `"mark"`, `"marktext"`, `"selecteditem"`, `"selecteditemtext"`, and `"visitedtext"`. */ | |
| const _CSS_COLOR_KEYWORDS_COMPREHENSIVE = new Set([..._CSS_COLOR_KEYWORDS_COMMON, 'accentcolortext', 'activetext', 'buttonborder', 'buttonface', 'buttontext', 'canvas', 'canvastext', 'field', 'fieldtext', 'graytext', 'highlight', 'highlighttext', 'linktext', 'mark', 'marktext', 'selecteditem', 'selecteditemtext', 'visitedtext']); | |
| /** Regex expression to search colors with, determined by {@linkcode validColors} */ | |
| const _COLOR_REGEX = | |
| // @ts-ignore ignore "const value mismatch" cuz users are allowed to customize their configs, TYPESCRIPT >:c | |
| (validColors === 'DATA' || validColors === 'CSS' || validColors === 'FULL') ? | |
| _COLOR_REGEX_DATA : _COLOR_REGEX_HEX; | |
| /** Set of CSS-valid color keywords, determined by {@linkcode validColors} */ | |
| const _CSS_COLORS = | |
| // @ts-ignore | |
| validColors === 'FULL' ? | |
| new Set([..._CSS_COLOR_NAMES, ..._CSS_COLOR_KEYWORDS_COMPREHENSIVE]) : | |
| // @ts-ignore | |
| validColors === 'CSS' ? | |
| new Set([..._CSS_COLOR_NAMES, ..._CSS_COLOR_KEYWORDS_COMMON]) : | |
| new Set([..._CSS_COLOR_NAMES]); | |
| /** | |
| * Searches for a CSS-valid color within the given string. | |
| * Returns a `[string,number]` tuple from the given string, | |
| * where the first param | |
| * @param {string} colorString Color string to search | |
| * @param {number} [order=0] Order of color to return. Default `0` | |
| * - `0`: return the | |
| * @returns {[string, number]} | |
| */ | |
| function cssColorFromString(colorString, order = 0) { | |
| // validity checks | |
| if (colorString == null || colorString.trim() === '') { return null; } | |
| if (order < -1) { console.warn(`Invalid order ${order}, must be -1 or greater`); return null; } | |
| // prep to lower | |
| colorString = colorString.toLowerCase(); | |
| /** colors stored with their index in the entire string @type {[string, number][]} */ | |
| const colors = []; | |
| let index = 0; | |
| // keywords | |
| switch (validColors) { | |
| case 'NAME': | |
| case 'BASIC': | |
| case 'CSS': | |
| case 'FULL': | |
| const words = colorString.match(/[A-Za-z]+/g) ?? []; | |
| if (words.length === 0) { return null; } | |
| for (const word of words) { | |
| if (_CSS_COLORS.has(word)) { | |
| if (isValidCssColor(word)) { | |
| index += colorString.slice(index).indexOf(word); | |
| colors.push([word, index]); | |
| } | |
| } | |
| } | |
| index = 0; | |
| break; | |
| } | |
| // hex / rgb[a] / hsl[a] | |
| switch (validColors) { | |
| case 'NAME': | |
| // skip any hex/rgba/hsla values | |
| break; | |
| default: | |
| const matches = colorString.match(_COLOR_REGEX); | |
| if (matches && matches.length > 0) { | |
| for (const match of matches) { | |
| if (isValidCssColor(match)) { | |
| index += colorString.slice(index).indexOf(match); | |
| colors.push([match, index]); | |
| } | |
| } | |
| } | |
| break; | |
| } | |
| switch (colors.length) { | |
| case 0: | |
| return null; | |
| case 1: | |
| return colors[0]; | |
| } | |
| // sort by index | |
| colors.sort((a, b) => a[1] - b[1]); | |
| switch (order) { | |
| case -1: | |
| return colors.at(-1) | |
| case 0: | |
| return colors[0]; | |
| default: | |
| if (order >= colors.length) { | |
| console.warn(`Invalid order, can't get the ${order}+1 Nth color, only ${colors.length} colors found, returning last color`); | |
| return colors.at(-1); | |
| } | |
| return colors[order]; | |
| } | |
| } | |
| /** | |
| * Creates a gradient that fades from 0% opacity. Returns the `"linear-gradient()"` string, or `null` if creation failed | |
| * @param {Element} element Element to apply the background gradient to | |
| * @param {string} [colorString] Color to to the background gradient. If blank or undefined, the color will be read from {@linkcode element} | |
| * @param {Number} [startPercent = 0] Percent, from `0` to `100`, that the gradient will *begin* at. Default `0` | |
| * @param {Number} [endPercent = 100] Percent, from `0` to `100`, that the gradient will *end* at. Default `100` | |
| * @param {string} [direction='90deg'] Direction of the gradient. Default `"90deg"` | |
| * @param {boolean} [useAlpha=true] Use the given color's alpha? If `true`, fades from `0` to the color's given opacity. If `false`, fades from `0` to `1` opacity. Default `true` | |
| * @returns {string} | |
| */ | |
| function createFadeGradient(element, colorString, startPercent = 0, endPercent = 100, direction = '90deg', useAlpha = true) { | |
| // validity checks, | |
| if (element == null) { return null; } | |
| // check if colorString is valid | |
| if (colorString == null || colorString.trim() === '') { | |
| // not valid, read from element | |
| colorString = getComputedStyle(element).backgroundColor; | |
| } | |
| const rgba = getColorRGBA(colorString, useAlpha); | |
| return `linear-gradient(${direction}, ${rgbaToColor(rgba, 0)} ${startPercent}%, ${rgbaToColor(rgba)} ${endPercent}%)`; | |
| } | |
| /** @typedef {{r:Number, g:Number, b:Number, a:Number}} rgba */ | |
| /** | |
| * Gets a given color as an object with `rgba.r`, `.g`, `.b`, and `.a` numeric values. | |
| * | |
| * The `r`, `g`, and `b` values are numbers 0-255, and `a` is 0.0 to 1.0 | |
| * @param {string} colorString Color to parse | |
| * @param {boolean} [useAlpha=true] Preserve any alpha value found in the color? If `false`, set `a` to `1`. Default `true` | |
| * @returns {rgba} | |
| */ | |
| function getColorRGBA(colorString, useAlpha = true) { | |
| if (!isValidCssColor(colorString)) { return null; } | |
| // create temporary span element, apply its background color | |
| const tempSpan = document.createElement('span'); | |
| tempSpan.style.cssText = `display:none;background-color:${colorString}`; | |
| document.body.append(tempSpan); | |
| // get the computer background color from the temporary element and remove it | |
| const bgColor = tempSpan.style.backgroundColor && getComputedStyle(tempSpan).backgroundColor; | |
| tempSpan.remove(); | |
| if (bgColor == null || bgColor.trim() === '') { return null; } | |
| // extract regex colors from bg string, parse to RGB | |
| const regexMatch = bgColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?/); | |
| if (!regexMatch) { return null; } | |
| const [r, g, b, a = 1] = regexMatch.slice(1).map(Number); | |
| const alpha = useAlpha ? isNaN(a) ? 1 : a : 1; | |
| return { r, g, b, a: alpha }; | |
| } | |
| /** | |
| * Converts an `rgba` object to a CSS-valid string | |
| * @param {rgba} rgba Object to convert | |
| * @param {number} [forceAlphaTo=null] If non-null and if {@linkcode rgbOnly} is `false`, overrides the returned `a` value to this | |
| * @param {boolean} [rgbOnly=false] If `true`, returns `rgb()`. If `false`, returns `rgba()`. Default `false` | |
| * @returns {string} | |
| */ | |
| function rgbaToColor(rgba, forceAlphaTo = null, rgbOnly = false) { | |
| return `rgb${rgbOnly ? '' : 'a'}(${rgba.r},${rgba.g},${rgba.b}${rgbOnly ? '' : `,${(forceAlphaTo != null && !isNaN(forceAlphaTo)) ? forceAlphaTo : rgba.a}`})`; | |
| } | |
| /** checks if the given color is valid css color @param {string} color @returns {boolean} */ | |
| function isValidCssColor(color) { | |
| if (color == null || color.trim() === '') { return false; } | |
| try { | |
| return typeof CSS !== 'undefined' && CSS.supports && CSS.supports('color', color); | |
| } catch { | |
| if (!CSS.supports) { | |
| console.warn(`Cannot check if CSS supports color ${color}, 'CSS.supports' unavaialble`); | |
| } | |
| return false; | |
| } | |
| } | |
| /** | |
| * Is the given color "dark" (`true`, closer to black) or "light" (`false`, closer to white) | |
| * @param {string} color Color to inspect | |
| * @param {number} [brightnessCutoff=120] Overall luminance value below which a color is considered "dark". Default `120` | |
| * @returns {boolean} */ | |
| function isDark(color, brightnessCutoff = 120) { | |
| const tempDiv = /** @type {HTMLElement} */ (document.body.appendChild(document.createElement('div'))); | |
| tempDiv.style.cssText = `position:fixed;left:-9999px;top:-9999px;background-color:${color}`; | |
| const regexMatch = getComputedStyle(tempDiv).backgroundColor.match(/[\d.]+/g); | |
| tempDiv.remove(); | |
| const [r, g, b, a = 1] = regexMatch.map(Number); | |
| if (a === 0) { return false; } | |
| // see: https://en.wikipedia.org/wiki/Relative_luminance | |
| return ((0.2126 * r) + (0.7152 * g) + (0.0722 * b)) < brightnessCutoff; | |
| } | |
| /** creates an inline color swatch CSS object @param {string} color @returns {HTMLSpanElement} */ | |
| function makeSwatch(color) { | |
| const wrap = document.createElement('span'); | |
| wrap.style.display = 'flex'; | |
| wrap.style.marginLeft = swatchRightJustify ? 'auto' : '0.69em'; | |
| wrap.style.whiteSpace = 'nowrap'; | |
| wrap.style.flex = '0 0 auto'; | |
| wrap.style.justifySelf = 'flex-end'; | |
| const box = document.createElement('span'); | |
| box.setAttribute('aria-hidden', 'true'); | |
| box.style.width = swatchWidth; | |
| if (swatchMaxWidth != null && | |
| // @ts-ignore Ignore the const assignment to swatchMaxWidth =_= | |
| swatchMaxWidth !== '') { | |
| box.style.maxWidth = swatchMaxWidth; | |
| } | |
| box.style.height = swatchHeight; | |
| box.style.borderRadius = '2.13px'; | |
| if (displayColorSwatch) { | |
| box.style.backgroundColor = color; | |
| box.style.border = `1px solid rgba(${isDark(color) ? '213,213,213,0.213' : '0,0,0,0.069'})`; | |
| } | |
| box.style.boxSizing = 'border-box'; | |
| box.style.display = 'flex'; | |
| box.style.justifyContent = 'flex-end'; | |
| box.style.alignItems = 'center'; | |
| box.style.alignSelf = 'center'; | |
| // in-swatch text | |
| if (displayColorText) { | |
| const lightTextColor = 'rgba(252, 251, 253, 0.69)'; | |
| const darkTextColor = 'rgba(2, 1, 3, 0.5)'; | |
| const lightTextShadow = '#F2F1F3'; | |
| const darkTextShadow = '#222123'; | |
| let shadow = darkTextShadow; | |
| switch (textColor) { | |
| case 'COLOR': | |
| box.style.color = color; | |
| if (textShadow) { shadow = isDark(color, 180) ? lightTextShadow : darkTextShadow; } | |
| break; | |
| case 'AUTO': | |
| const darkText = isDark(color, 180); | |
| box.style.color = darkText ? lightTextColor : darkTextColor; | |
| if (textShadow) { shadow = darkText ? darkTextShadow : lightTextShadow; } | |
| break; | |
| case 'LIGHT': | |
| box.style.color = lightTextColor; | |
| if (textShadow) { shadow = darkTextShadow; } | |
| break; | |
| case 'DARK': | |
| box.style.color = darkTextColor; | |
| if (textShadow) { shadow = lightTextShadow; } | |
| break; | |
| default: | |
| // default, do nothing | |
| break; | |
| } | |
| if (textShadow) { | |
| box.style.textShadow = `0 0 1px ${shadow}69, 0 0 2px ${shadow}FF, 0 0.5px 3px ${shadow}FF, 0 2px 16px ${shadow}FF`; | |
| } | |
| box.style.padding = '1px 5px'; | |
| box.style.whiteSpace = 'nowrap'; | |
| box.style.fontSize = textSize; | |
| box.innerText = color; | |
| if (displayColorSwatch) { | |
| box.style.overflow = 'hidden'; | |
| } | |
| } | |
| wrap.appendChild(box); | |
| return wrap; | |
| } | |
| /** | |
| * lerp two rgba objects, or an rgba object to target number | |
| * @param {rgba} cA | |
| * @param {rgba|number} cB | |
| * @param {number} t | |
| * @param {boolean} [alsoLerpAlpha=false] | |
| * @returns {rgba} | |
| */ | |
| function lerpColor(cA, cB, t, alsoLerpAlpha = false) { | |
| if (typeof cB === 'number') { | |
| cA.r = lerpNumber(cA.r, cB, t); | |
| cA.g = lerpNumber(cA.g, cB, t); | |
| cA.b = lerpNumber(cA.b, cB, t); | |
| if (alsoLerpAlpha) { | |
| cA.a = lerpNumber(cA.a, cB, t); | |
| } | |
| } else { | |
| cA.r = lerpNumber(cA.r, cB.r, t); | |
| cA.g = lerpNumber(cA.g, cB.g, t); | |
| cA.b = lerpNumber(cA.b, cB.b, t); | |
| if (alsoLerpAlpha) { | |
| cA.a = lerpNumber(cA.a, cB.a, t); | |
| } | |
| } | |
| return cA; | |
| } | |
| /** | |
| * lerp two numbers | |
| * @param {number} a | |
| * @param {number} b | |
| * @param {number} t | |
| * @returns {number} | |
| */ | |
| function lerpNumber(a, b, t) { return a + (b - a) * t; } | |
| /** find color text in titles, apply visual swatches @param {Document} [root=document] */ | |
| function colorizeCollection(root = document) { | |
| if (!enabled) { return; } | |
| // search for appropriate headers | |
| const candidates = root.querySelectorAll('h2'); | |
| candidates.forEach((h2) => { | |
| if (!h2) { return; } | |
| if (h2.dataset.decapColorized === '1') { return; } | |
| const parentElement = h2.parentElement; | |
| if (!parentElement || parentElement.tagName?.toLowerCase() !== 'a') { return; } | |
| const icons = h2.querySelector('div'); | |
| if (!icons) { return; } | |
| const textNode = Array.from(h2.childNodes).find((n) => n.nodeType === Node.TEXT_NODE); | |
| if (!textNode) { return; } | |
| const rawNode = (textNode.nodeValue || '').trimEnd(); | |
| const match = cssColorFromString(rawNode, preferColorOrder); | |
| if (!match || !match[0] || match[0].trim() === '') { return; } | |
| const color = match[0]; | |
| const prefix = match[1] > 0 ? rawNode.slice(0, match[1]) : ''; | |
| const suffix = rawNode.slice(prefix.length + color.length); | |
| textNode.nodeValue = prefix.trim() + (removeColorTextFromCollection ? '' : ` ${color}`) + suffix.trim(); | |
| if (displayColorSwatch || displayColorText) { | |
| const swatchElement = makeSwatch(color); | |
| if (swatchRightJustify) { | |
| const prevDisplay = getComputedStyle(h2).display; | |
| if (prevDisplay && prevDisplay !== 'flex') { | |
| h2.style.display = 'flex'; | |
| h2.style.alignItems = 'center'; | |
| h2.style.gap = '0.5em'; | |
| } | |
| h2.insertBefore(swatchElement, icons); | |
| icons.style.flex = '0 0 auto'; | |
| } else { | |
| h2.style.justifyContent = 'flex-start'; | |
| h2.insertBefore(swatchElement, icons); | |
| } | |
| } | |
| if (displayColorBackground) { | |
| const fade = displayColorSwatch && swatchFadesBackgroundColor; | |
| if (backgroundUsesGradient) { | |
| const gradient = createFadeGradient(parentElement, color, fade ? 0 : 10, fade ? 200 : 80); | |
| parentElement.style.backgroundImage = gradient; | |
| } else { | |
| const rgba = fade ? lerpColor(getColorRGBA(color), 255, 0.82) : getColorRGBA(color); | |
| parentElement.style.backgroundColor = rgbaToColor(rgba); | |
| } | |
| } | |
| // failsafe to ensure colorization only happens once | |
| h2.dataset.decapColorized = '1'; | |
| }); | |
| } | |
| colorizeCollection(); | |
| // watch for dom or url changes | |
| const mutationObservert = new MutationObserver(() => colorizeCollection()); | |
| mutationObservert.observe(document.documentElement, { childList: true, subtree: true }); | |
| window.addEventListener('hashchange', () => { colorizeCollection(); }); | |
| })(); | |
| // MARK: License | |
| /** | |
| * LICENSE INFO | |
| * | |
| * This script was written by Nick Yonge, 2026, and is released | |
| * under the terms of The Unlicense: | |
| * | |
| * This is free and unencumbered software released into the public domain. | |
| * | |
| * Anyone is free to copy, modify, publish, use, compile, sell, or | |
| * distribute this software, either in source code form or as a compiled | |
| * binary, for any purpose, commercial or non-commercial, and by any | |
| * means. | |
| * | |
| * In jurisdictions that recognize copyright laws, the author or authors | |
| * of this software dedicate any and all copyright interest in the | |
| * software to the public domain. We make this dedication for the benefit | |
| * of the public at large and to the detriment of our heirs and | |
| * successors. We intend this dedication to be an overt act of | |
| * relinquishment in perpetuity of all present and future rights to this | |
| * software under copyright law. | |
| * | |
| * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | |
| * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF | |
| * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. | |
| * IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR | |
| * OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, | |
| * ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR | |
| * OTHER DEALINGS IN THE SOFTWARE. | |
| * | |
| * For more information, please refer to <https://unlicense.org/> | |
| */ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment