420 lines
12 KiB
JavaScript
420 lines
12 KiB
JavaScript
var MAX_NAME_LENGTH = 40;
|
|
|
|
var SHIPPED = [
|
|
{
|
|
id: "moon",
|
|
name: "Moon",
|
|
scheme: "dark",
|
|
accent: "#82aaff",
|
|
secondary: "#b172b0",
|
|
shipped: true
|
|
},
|
|
{
|
|
id: "moon-rose",
|
|
name: "Moon Rose",
|
|
scheme: "dark",
|
|
accent: "#ff757f",
|
|
secondary: "#c099ff",
|
|
shipped: true
|
|
},
|
|
{
|
|
id: "day",
|
|
name: "Day",
|
|
scheme: "light",
|
|
accent: "#2e7de9",
|
|
secondary: "#9854f1",
|
|
shipped: true
|
|
}
|
|
];
|
|
|
|
// The curated accents. `gnome` is the nearest member of GNOME's own
|
|
// accent-color enum, which is a fixed list of nine we do not get to extend. It
|
|
// is what libadwaita applications -- Files, Papers, Loupe -- are told to use, so
|
|
// choosing an accent here recolors them too instead of leaving them in GNOME
|
|
// blue. Nearest by hue, not by name: "rose" maps to red rather than pink
|
|
// because it is the red role in this palette. adwaita-accent-contract reads
|
|
// this table and fails when a member is missing or is not in GNOME's enum.
|
|
var CURATED = {
|
|
blue: {
|
|
dark: "#82aaff", darkSecondary: "#b172b0",
|
|
light: "#2e7de9", lightSecondary: "#9854f1",
|
|
label: "Prism blue",
|
|
gnome: "blue"
|
|
},
|
|
orchid: {
|
|
dark: "#c099ff", darkSecondary: "#fca7ea",
|
|
light: "#7847bd", lightSecondary: "#9854f1",
|
|
label: "Orchid",
|
|
gnome: "purple"
|
|
},
|
|
teal: {
|
|
dark: "#86e1fc", darkSecondary: "#82aaff",
|
|
light: "#007197", lightSecondary: "#2e7de9",
|
|
label: "Teal",
|
|
gnome: "teal"
|
|
},
|
|
green: {
|
|
dark: "#c3e88d", darkSecondary: "#86e1fc",
|
|
light: "#587539", lightSecondary: "#007197",
|
|
label: "Green",
|
|
gnome: "green"
|
|
},
|
|
amber: {
|
|
dark: "#ffc777", darkSecondary: "#ff966c",
|
|
light: "#8c6c3e", lightSecondary: "#b15c00",
|
|
label: "Amber",
|
|
gnome: "yellow"
|
|
},
|
|
orange: {
|
|
dark: "#ff966c", darkSecondary: "#ff757f",
|
|
light: "#b15c00", lightSecondary: "#c64343",
|
|
label: "Orange",
|
|
gnome: "orange"
|
|
},
|
|
rose: {
|
|
dark: "#ff757f", darkSecondary: "#c099ff",
|
|
light: "#f52a65", lightSecondary: "#9854f1",
|
|
label: "Rose",
|
|
gnome: "red"
|
|
},
|
|
slate: {
|
|
dark: "#828bb8", darkSecondary: "#82aaff",
|
|
light: "#6172b0", lightSecondary: "#2e7de9",
|
|
label: "Slate",
|
|
gnome: "slate"
|
|
}
|
|
};
|
|
|
|
function copyProfile(profile) {
|
|
return {
|
|
id: profile.id,
|
|
name: profile.name,
|
|
scheme: profile.scheme,
|
|
accent: profile.accent,
|
|
secondary: profile.secondary,
|
|
shipped: profile.shipped === true
|
|
};
|
|
}
|
|
|
|
function shippedProfiles() {
|
|
return SHIPPED.map(copyProfile);
|
|
}
|
|
|
|
function curatedAccents() {
|
|
var result = {};
|
|
Object.keys(CURATED).forEach(function(name) {
|
|
result[name] = Object.assign({}, CURATED[name]);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function isScheme(value) {
|
|
return value === "dark" || value === "light";
|
|
}
|
|
|
|
function normalizedColor(value) {
|
|
var color = String(value || "").trim().toLowerCase();
|
|
return /^#[0-9a-f]{6}$/.test(color) ? color : null;
|
|
}
|
|
|
|
function normalizeStoredProfile(value) {
|
|
if (!value || typeof value !== "object" || value.shipped === true)
|
|
return null;
|
|
|
|
var id = String(value.id || "").trim();
|
|
var name = String(value.name || "").trim();
|
|
var accent = normalizedColor(value.accent);
|
|
var secondary = normalizedColor(value.secondary);
|
|
if (!/^custom-[a-z0-9][a-z0-9-]{0,56}$/.test(id)
|
|
|| name.length === 0 || name.length > MAX_NAME_LENGTH
|
|
|| !isScheme(value.scheme) || !accent || !secondary)
|
|
return null;
|
|
|
|
return {
|
|
id: id,
|
|
name: name,
|
|
scheme: value.scheme,
|
|
accent: accent,
|
|
secondary: secondary,
|
|
shipped: false
|
|
};
|
|
}
|
|
|
|
function validCustomProfiles(values) {
|
|
if (!Array.isArray(values))
|
|
return [];
|
|
|
|
var ids = {};
|
|
var names = {};
|
|
SHIPPED.forEach(function(profile) {
|
|
ids[profile.id] = true;
|
|
names[profile.name.toLowerCase()] = true;
|
|
});
|
|
|
|
var result = [];
|
|
values.forEach(function(value) {
|
|
var profile = normalizeStoredProfile(value);
|
|
if (!profile)
|
|
return;
|
|
var foldedName = profile.name.toLowerCase();
|
|
if (ids[profile.id] || names[foldedName])
|
|
return;
|
|
ids[profile.id] = true;
|
|
names[foldedName] = true;
|
|
result.push(profile);
|
|
});
|
|
return result;
|
|
}
|
|
|
|
function profileCatalog(values) {
|
|
return shippedProfiles().concat(validCustomProfiles(values));
|
|
}
|
|
|
|
function boundedName(value) {
|
|
var name = String(value || "").trim();
|
|
if (!name)
|
|
name = "Custom theme";
|
|
return name.slice(0, MAX_NAME_LENGTH);
|
|
}
|
|
|
|
function uniqueName(value, profiles) {
|
|
var requested = boundedName(value);
|
|
var names = {};
|
|
profiles.forEach(function(profile) {
|
|
names[profile.name.toLowerCase()] = true;
|
|
});
|
|
if (!names[requested.toLowerCase()])
|
|
return requested;
|
|
|
|
for (var suffix = 2; suffix < 10000; suffix++) {
|
|
var ending = " " + suffix;
|
|
var candidate = requested.slice(0, MAX_NAME_LENGTH - ending.length) + ending;
|
|
if (!names[candidate.toLowerCase()])
|
|
return candidate;
|
|
}
|
|
return requested.slice(0, MAX_NAME_LENGTH - 6) + " 10000";
|
|
}
|
|
|
|
function slug(value) {
|
|
var result = String(value || "").toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 48)
|
|
.replace(/-+$/g, "");
|
|
return result || "theme";
|
|
}
|
|
|
|
function uniqueId(name, profiles) {
|
|
var base = "custom-" + slug(name);
|
|
var ids = {};
|
|
profiles.forEach(function(profile) { ids[profile.id] = true; });
|
|
if (!ids[base])
|
|
return base;
|
|
for (var suffix = 2; suffix < 10000; suffix++) {
|
|
var candidate = base.slice(0, 57 - String(suffix).length) + "-" + suffix;
|
|
if (!ids[candidate])
|
|
return candidate;
|
|
}
|
|
return base.slice(0, 52) + "-10000";
|
|
}
|
|
|
|
function createCustomProfile(values, input) {
|
|
var customs = validCustomProfiles(values);
|
|
var catalog = shippedProfiles().concat(customs);
|
|
var scheme = input && input.scheme;
|
|
var accent = normalizedColor(input && input.accent);
|
|
var secondary = normalizedColor(input && input.secondary);
|
|
if (!isScheme(scheme) || !accent || !secondary)
|
|
return { profiles: customs, profile: null };
|
|
|
|
var name = uniqueName(input && input.name, catalog);
|
|
var profile = {
|
|
id: uniqueId(name, catalog),
|
|
name: name,
|
|
scheme: scheme,
|
|
accent: accent,
|
|
secondary: secondary,
|
|
shipped: false
|
|
};
|
|
return { profiles: customs.concat([profile]), profile: profile };
|
|
}
|
|
|
|
function findProfile(values, id) {
|
|
var catalog = profileCatalog(values);
|
|
for (var index = 0; index < catalog.length; index++) {
|
|
if (catalog[index].id === id)
|
|
return catalog[index];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function editProfile(values, selected, changes) {
|
|
var customs = validCustomProfiles(values);
|
|
if (!selected || typeof selected !== "object")
|
|
return { profiles: customs, profile: null };
|
|
|
|
var accent = normalizedColor(changes && changes.accent !== undefined
|
|
? changes.accent : selected.accent);
|
|
var secondary = normalizedColor(changes && changes.secondary !== undefined
|
|
? changes.secondary : selected.secondary);
|
|
var scheme = changes && changes.scheme !== undefined ? changes.scheme : selected.scheme;
|
|
if (!isScheme(scheme) || !accent || !secondary)
|
|
return { profiles: customs, profile: null };
|
|
|
|
if (selected.shipped === true) {
|
|
return createCustomProfile(customs, {
|
|
name: selected.name + " custom",
|
|
scheme: scheme,
|
|
accent: accent,
|
|
secondary: secondary
|
|
});
|
|
}
|
|
|
|
var stored = normalizeStoredProfile(selected);
|
|
if (!stored)
|
|
return { profiles: customs, profile: null };
|
|
|
|
var updated = Object.assign({}, stored, {
|
|
scheme: scheme,
|
|
accent: accent,
|
|
secondary: secondary
|
|
});
|
|
var found = false;
|
|
var next = customs.map(function(profile) {
|
|
if (profile.id !== updated.id)
|
|
return profile;
|
|
found = true;
|
|
return updated;
|
|
});
|
|
if (!found)
|
|
next.push(updated);
|
|
return { profiles: next, profile: updated };
|
|
}
|
|
|
|
function deleteProfile(values, id) {
|
|
var customs = validCustomProfiles(values);
|
|
for (var shippedIndex = 0; shippedIndex < SHIPPED.length; shippedIndex++) {
|
|
if (SHIPPED[shippedIndex].id === id)
|
|
return { profiles: customs, removed: false };
|
|
}
|
|
var next = customs.filter(function(profile) { return profile.id !== id; });
|
|
return { profiles: next, removed: next.length !== customs.length };
|
|
}
|
|
|
|
function curatedPair(name, scheme) {
|
|
var entry = CURATED[name] || CURATED.blue;
|
|
if (scheme === "light")
|
|
return { accent: entry.light, secondary: entry.lightSecondary };
|
|
return { accent: entry.dark, secondary: entry.darkSecondary };
|
|
}
|
|
|
|
function matchingShippedProfile(scheme, accent, secondary) {
|
|
var first = normalizedColor(accent);
|
|
var second = normalizedColor(secondary);
|
|
for (var index = 0; index < SHIPPED.length; index++) {
|
|
var profile = SHIPPED[index];
|
|
if (profile.scheme === scheme && profile.accent === first && profile.secondary === second)
|
|
return copyProfile(profile);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function curatedNameForProfile(profile) {
|
|
if (!profile || !isScheme(profile.scheme))
|
|
return "";
|
|
var accent = normalizedColor(profile.accent);
|
|
var secondary = normalizedColor(profile.secondary);
|
|
var names = Object.keys(CURATED);
|
|
for (var index = 0; index < names.length; index++) {
|
|
var name = names[index];
|
|
var pair = curatedPair(name, profile.scheme);
|
|
if (pair.accent === accent && pair.secondary === secondary)
|
|
return name;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function clamp(value, minimum, maximum) {
|
|
return Math.max(minimum, Math.min(maximum, Number(value)));
|
|
}
|
|
|
|
function channelHex(value) {
|
|
var text = Math.round(value).toString(16);
|
|
return text.length < 2 ? "0" + text : text;
|
|
}
|
|
|
|
function hsvToHex(hue, saturation, value) {
|
|
var h = Number(hue);
|
|
var s = Number(saturation);
|
|
var v = Number(value);
|
|
if (!isFinite(h) || !isFinite(s) || !isFinite(v))
|
|
return null;
|
|
h = ((h % 360) + 360) % 360;
|
|
s = clamp(s, 0, 100) / 100;
|
|
v = clamp(v, 0, 100) / 100;
|
|
|
|
var chroma = v * s;
|
|
var section = h / 60;
|
|
var x = chroma * (1 - Math.abs(section % 2 - 1));
|
|
var red = 0;
|
|
var green = 0;
|
|
var blue = 0;
|
|
if (section < 1) { red = chroma; green = x; }
|
|
else if (section < 2) { red = x; green = chroma; }
|
|
else if (section < 3) { green = chroma; blue = x; }
|
|
else if (section < 4) { green = x; blue = chroma; }
|
|
else if (section < 5) { red = x; blue = chroma; }
|
|
else { red = chroma; blue = x; }
|
|
var match = v - chroma;
|
|
return "#" + channelHex((red + match) * 255)
|
|
+ channelHex((green + match) * 255)
|
|
+ channelHex((blue + match) * 255);
|
|
}
|
|
|
|
function hexToHsv(value) {
|
|
var color = normalizedColor(value);
|
|
if (!color)
|
|
return null;
|
|
var red = parseInt(color.slice(1, 3), 16) / 255;
|
|
var green = parseInt(color.slice(3, 5), 16) / 255;
|
|
var blue = parseInt(color.slice(5, 7), 16) / 255;
|
|
var maximum = Math.max(red, green, blue);
|
|
var minimum = Math.min(red, green, blue);
|
|
var delta = maximum - minimum;
|
|
var hue = 0;
|
|
if (delta !== 0) {
|
|
if (maximum === red)
|
|
hue = 60 * (((green - blue) / delta) % 6);
|
|
else if (maximum === green)
|
|
hue = 60 * ((blue - red) / delta + 2);
|
|
else
|
|
hue = 60 * ((red - green) / delta + 4);
|
|
}
|
|
if (hue < 0)
|
|
hue += 360;
|
|
return {
|
|
h: Math.round(hue),
|
|
s: Math.round((maximum === 0 ? 0 : delta / maximum) * 100),
|
|
v: Math.round(maximum * 100)
|
|
};
|
|
}
|
|
|
|
if (typeof module !== "undefined") {
|
|
module.exports = {
|
|
MAX_NAME_LENGTH: MAX_NAME_LENGTH,
|
|
shippedProfiles: shippedProfiles,
|
|
curatedAccents: curatedAccents,
|
|
validCustomProfiles: validCustomProfiles,
|
|
profileCatalog: profileCatalog,
|
|
createCustomProfile: createCustomProfile,
|
|
findProfile: findProfile,
|
|
editProfile: editProfile,
|
|
deleteProfile: deleteProfile,
|
|
curatedPair: curatedPair,
|
|
matchingShippedProfile: matchingShippedProfile,
|
|
curatedNameForProfile: curatedNameForProfile,
|
|
hsvToHex: hsvToHex,
|
|
hexToHsv: hexToHsv
|
|
};
|
|
}
|