colorjs.io
Version:
Color space agnostic color manipulation library
541 lines (432 loc) • 12.5 kB
JavaScript
/**
* Color notebook: Interactive color examples
* Idea credit: chroma.js
* Author: Lea Verou
*/
let $ = Bliss, $$ = $.$;
import Color, {util} from "../color.js";
const supportsP3 = window.CSS && CSS.supports("color", "color(display-p3 0 1 0)");
const outputSpace = supportsP3? "p3" : "srgb";
const codes = new WeakMap();
Prism.hooks.add("before-sanity-check", env => {
if ($(".token", env.element)) {
// Already highlighted, abort
env.code = "";
}
});
Prism.hooks.add("complete", env => {
let pre = env.element.closest("pre");
if (pre?.notebook?.initialized) {
pre.notebook.eval();
}
});
export default class Notebook {
constructor (pre) {
this.pre = pre;
this.pre.notebook = this;
this.initialCode = this.pre.textContent;
Notebook.all.add(this);
Notebook.intersectionObserver.observe(this.pre);
}
get edited () {
return this.initialCode !== this.code;
}
init () {
if (this.initialized) {
return false;
}
this.wrapper = $.create("div", {
className: "cn-wrapper",
around: this.pre,
contents: {className: "cn-results"}
});
// Create Prism Live instance if not already present
if (typeof Prism !== "undefined" && Prism.Live && !this.pre.live) {
this.pre.live = new Prism.Live(this.pre);
}
this.sandbox = $.create("iframe", {
srcdoc: `<script type=module>
import Color from "/color.js";
window.runLine = function (line, env) {
let doc = {
documentElement: document.documentElement,
head: document.head,
querySelector: parent.document.querySelector.bind(parent.document),
querySelectorAll: parent.document.querySelector.bind(parent.documentAll)
};
{
let parent, window, location, self;
let document = doc;
return eval(line);
}
}
</script>
<style>:root {--color-red: hsl(0 80% 50%); --color-green: hsl(90 50% 45%); --color-blue: hsl(210 80% 55%)}</style>`,
// sandbox: "allow-scripts allow-same-origin",
inside: document.body,
hidden: true
});
this.initialized = true;
Notebook.intersectionObserver.unobserve(this.pre);
this.eval();
return true;
}
async reloadSandbox () {
if (this.sandbox.contentWindow?.document.readyState === "complete") {
this.sandbox.classList.remove("ready", "dirty");
this.sandbox.contentWindow.location.reload();
}
await new Promise(r => this.sandbox.addEventListener("load", r, {once: true}));
let win = this.sandbox.contentWindow;
if (win.document.readyState !== "complete") {
await new Promise(r => win.addEventListener("load", r, {once: true}));
}
this.sandbox.classList.add("ready");
return win;
}
async eval () {
let pre = this.pre;
if ($(".cn-evaluated.token", pre)) {
// Already evaluated
return;
}
this.code = this.pre.textContent;
// Create a clone so we can take advantage of Prism's parsing to tweak the code
// Bonus: Comment this out to debug what's going on!
let originalPre = pre;
pre = pre.cloneNode(true);
// Remove comments
$$(".comment", pre).forEach(comment => comment.remove());
// Replace variable declarations with property creation on env
// This is so we can evaluate line by line, because eval() in strict mode has its own scope
let variables = new Set();
let nextVariable;
walk(pre, (node) => {
let text = node.textContent.trim();
let parent = node.parentNode;
let inRoot = parent.matches("code");
if (!text) {
// Whitespace node
return;
}
if (nextVariable && (inRoot || parent.matches(".token.constant"))) {
// Variables with ALL_CAPS are highlighted as constants
variables.add(text);
nextVariable = false;
}
else if (parent.matches(".token.keyword") && (text === "var" || text === "let")) {
nextVariable = true; // next token is the variable name
node.textContent = "";
}
if ((inRoot || parent.matches(".token.function, .token.template-string .interpolation, .token.constant")) && variables.has(text)) {
// node.textContent = "env." + text;
node.textContent = node.textContent.replace(text, "env.$&");
}
}, NodeFilter.SHOW_TEXT);
let value = pre.textContent.trim().replace(/\s+$/m, "");
if (codes.get(pre) === value) {
// We've already evaluated this
return;
}
codes.set(pre, value);
let varNodes = new Set();
let semicolons = [];
let line = 0;
let varLines = [];
// Wrap the variables so we can find them easily later
walk(originalPre, (node) => {
let text = node.textContent.trim();
let parent = node.parentNode;
let inRoot = parent.matches("code");
if (inRoot && variables.has(text)) {
// TODO get whitespace outside
node.line = line;
varNodes.add(node);
}
else if (parent.matches(".token.punctuation") && text === ";") {
semicolons.push(parent);
line++;
}
}, NodeFilter.SHOW_TEXT);
for (let node of varNodes) {
let wholeText = node.textContent;
let text = wholeText.trim();
let line = node.line;
varLines[line] = varLines[line] || new Set();
if (text !== wholeText) {
// There is whitespace
let start = wholeText.indexOf(text);
let end = start + text.length;
if (start > 0) {
// Whitespace in the beginning
node.splitText(start);
node = node.nextSibling;
}
if (end < wholeText.length) {
// Whitespace in the end
node.splitText(text.length);
}
}
let wrappedNode = $.create("span", {
className: "variable",
"data-varname": text,
"data-line": line,
textContent: node.textContent
});
node.replaceWith(wrappedNode);
// Associate variable nodes with lines so we know which line is relevant
varLines[line].add(wrappedNode);
}
// Split by semicolon
let lines = value.trim().split(/\s*;\s*/);
// Remove last line if empty
if (["", "\u200b"].includes(lines[lines.length - 1])) {
lines.pop();
}
if (!this.sandbox.matches(".ready:not(.dirty)")) {
await this.reloadSandbox();
}
this.sandbox.classList.add("dirty");
let win = this.sandbox.contentWindow;
let env = {};
let wrapper = originalPre.closest(".cn-wrapper");
let results = $(".cn-results", wrapper);
// CLear previous results
results.textContent = "";
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
let isLastLine = i === lines.length - 1;
let ret;
try {
ret = win.runLine(line, env);
}
catch (e) {
ret = e;
}
if (!(ret instanceof win.Error)) {
// Update variables in the current line
let lineVars = varLines[i];
if (lineVars && lineVars.size > 0) {
for (let node of lineVars) {
let variable = node.textContent;
let value = env[variable];
if (value instanceof win.Color) {
try {
node.style.setProperty("--color", value.to(outputSpace));
node.classList.add(lightOrDark(value));
}
catch (e) {}
}
// TODO do something nice with other types :)
}
}
}
let result;
try {
result = serialize(ret, undefined, win);
}
catch (e) {
}
if (result) {
results.append(result);
// Make result line up with its line if there's space
let semicolon = semicolons[i];
if (!semicolon && isLastLine) {
// Last line often doesn't have a semicolon
semicolon = originalPre.lastElementChild.lastElementChild;
}
if (semicolon) {
let offset = semicolon.offsetTop - result.offsetTop
// Prevent overly tall results (e.g. long arrays of colors)
// to make the entire code area super tall
- Math.max(0, result.offsetHeight - 30);
if (offset > 5) {
result.style.marginTop = offset + "px";
}
}
}
}
// Add a class to the first token to mark that we've evaluated this
// so that we don't do it again unless the contents are overwritten
let firstToken = $("code .token", originalPre);
if (firstToken) {
firstToken.classList.add("cn-evaluated");
}
// Clean up after ourselves
await this.reloadSandbox();
}
destroy () {
this.sandbox.remove();
Notebook.intersectionObserver.disconnect(this.pre);
this.wrapper = this.sandbox = this.pre = null;
Notebook.all.delete(this);
}
static create (pre) {
if (pre.notebook) {
return pre.notebook;
}
return new Notebook(pre);
}
}
export function walk(pre, callback, filter) {
let walker = document.createTreeWalker(pre, filter);
let node;
while (node = walker.nextNode()) {
let ret = callback(node);
if (ret !== undefined) {
return ret;
}
}
}
export function serialize(ret, color, win = window) {
var color, element;
let Color = win.Color;
if (ret === undefined) {
return;
}
if (ret instanceof win.Error) {
if (win.Error.message.indexOf("Cannot use import statement")) {
return "";
}
return $.create({
className: "cn-error",
textContent: ret.name,
title: ret + ". Click to see error in the console.",
onclick: _ => console.error(ret)
});
}
let template = {
title: "Click to see value in the console",
events: {
click: _ => console.log(ret)
}
};
if (ret instanceof Color) {
color = ret;
element = $.create({
...template,
textContent: ret.toString({precision: 3, inGamut: false})
});
}
else if (typeof ret === "function" && ret.rangeArgs) {
// Range function?
return $.create({
...template,
className: "cn-value cn-range",
style: {
"--stops": Color.steps(ret, {steps: 5, maxDeltaE: 4}).map(color => {
if (!CSS.supports("color", color)) {
return color.to(outputSpace);
}
return color;
})
}
});
}
else if (Array.isArray(ret)) {
let colors = ret.map(c => serialize(c, undefined, win));
if (ret.length > 2 && ret[0] instanceof Color) {
// Don't print out color if too many
colors.forEach(c => {
c.textContent = "";
c.title = c.dataset.title;
});
}
let contents = ["["];
colors.forEach(c => contents.push(c, ","));
contents.pop(); // get rid of last comma
contents.push("]");
return $.create({
...template,
className: "cn-value cn-array",
contents
});
}
else if (typeof ret === "number") {
element = $.create({
...template,
className: "cn-number",
textContent: util.toPrecision(ret, 3) + ""
});
}
else if (typeof ret === "boolean") {
element = $.create({
...template,
className: "cn-boolean",
textContent: ret
});
}
else if (util.isString(ret)) {
element = $.create({
...template,
className: "cn-string",
textContent: `"${ret}"`
});
}
else if (ret && typeof ret === "object") {
let keys = Object.keys(ret);
element = $.create({
...template,
className: "cn-object",
textContent: `Object {${keys.slice(0, 3).join(", ") + (keys.length > 3? ", ..." : "")}}`
});
}
element.classList.add("cn-value");
if (color instanceof Color) {
if (!element) {
element = $.create({className: "void"});
}
element.classList.add("cn-color", lightOrDark(color));
let str = element.dataset.title = color.toString({inGamut: false});
let outOfGamut = [];
if (!color.inGamut()) {
outOfGamut.push(color.space.name);
}
if (outputSpace !== color.spaceId && !color.inGamut(outputSpace)) {
outOfGamut.push(Color.space(outputSpace).name);
}
if (outOfGamut.length > 0) {
element.classList.add("out-of-gamut");
element.title = outOfGamut.map(s => `out of ${s} gamut`).join(", ");
}
$.set(element, {
style: {
"--color": color.to(outputSpace)
},
properties: {
color
}
});
}
return element;
}
function lightOrDark(color) {
return color.luminance > .5 || color.alpha < .5? "light" : "dark";
}
Notebook.all = new Set();
Notebook.intersectionObserver = new IntersectionObserver(entries => {
for (let entry of entries) {
if (entry.intersectionRatio === 0) {
// IntersectionObserver callback fires immediately for no reason
// so we need to guard against this
continue;
}
let pre = entry.target;
pre?.notebook.init();
}
});
export function initAll(container = document) {
let pres = $$(".language-js, .language-javascript", container).flatMap(el => {
let ret = $$("pre > code", el);
let ancestor = el.closest("pre > code");
if (ancestor) {
ret.push(ancestor);
}
return ret.filter(code => !code.matches(".cn-ignore, .cn-ignore *")
&& !code.matches('[class*="language-"]:not(.language-js):not(.language-javascript)'));
}).map(code => code.parentNode);
for (let pre of pres) {
Notebook.create(pre);
}
}
initAll();