Files

309 lines
15 KiB
JavaScript

/*
* tonalmine: per-user appearance (mode, contrast, size, color palette).
*
* Redmine loads this file in <head>, so the saved choice is applied before the page renders
* (no flash of the default colors). The picker is added to the profile menu and the mobile
* flyout. The choice is stored per browser in localStorage. With the redmine_tonalmine plugin,
* the plugin calls Tonalmine.configure() right after this file: it adds the administrator's
* default, and logged-in users' choices are saved on their account instead.
*/
(function () {
// Ids must match PALETTES in tools/palettes/build_palettes.mjs and
// redmine_tonalmine/lib/redmine_tonalmine/appearance.rb (the palettes build checks both).
var PALETTES = [
['purple', 'Purple', '#6750A4'],
['blue', 'Blue', '#0061A4'],
['green', 'Green', '#386A20'],
['orange', 'Orange', '#8B5000']
];
var MODES = [['auto', 'Auto'], ['light', 'Light'], ['dark', 'Dark']];
var CONTRASTS = [['standard', 'Standard'], ['medium', 'Medium'], ['high', 'High']];
var SIZES = [['compact', 'Compact'], ['normal', 'Normal'], ['large', 'Large']];
var KEY = 'tonalmine';
var CUSTOM_SEL = ':root[data-palette="custom"]';
var DEFAULT_KEYS = ['mode', 'contrast', 'size', 'palette', 'seed', 'css'];
var root = document.documentElement;
function load() {
try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) { return {}; }
}
function apply(prefs) {
if (prefs.palette && prefs.palette !== PALETTES[0][0]) root.setAttribute('data-palette', prefs.palette);
else root.removeAttribute('data-palette');
if (prefs.mode === 'light' || prefs.mode === 'dark') root.setAttribute('data-mode', prefs.mode);
else root.removeAttribute('data-mode');
if (prefs.contrast === 'medium' || prefs.contrast === 'high') root.setAttribute('data-contrast', prefs.contrast);
else root.removeAttribute('data-contrast');
if (prefs.size === 'compact' || prefs.size === 'large') root.setAttribute('data-size', prefs.size);
else root.removeAttribute('data-size');
// The custom palette's rules are generated once (see withScheme) and kept with the prefs.
if (prefs.palette === 'custom' && prefs.css) {
var style = document.getElementById('nm-custom-palette');
if (!style) {
style = document.createElement('style');
style.id = 'nm-custom-palette';
document.head.appendChild(style);
}
style.textContent = prefs.css;
}
}
// The scheme generator (scheme.js, ~90 KB) is only loaded when someone picks a custom color.
// Redmine serves theme assets under digested names only, so its URL comes from the CSS
// variable --nm-scheme-js (Redmine rewrites url() in CSS to the digested path).
var schemeScript;
function withScheme(done) {
if (window.tonalmineScheme) return done(window.tonalmineScheme);
if (!schemeScript) {
var url = getComputedStyle(root).getPropertyValue('--nm-scheme-js').match(/url\(["']?([^"')]+)/)[1];
schemeScript = document.createElement('script');
schemeScript.src = url;
document.head.appendChild(schemeScript);
}
schemeScript.addEventListener('load', function () { done(window.tonalmineScheme); });
}
// own: the parts this user changed. defaults: the administrator's default (plugin only).
// The page shows the defaults overridden by own, part by part.
var own = load();
var defaults = {};
var url = null; // set when the user's choice is saved on their account (plugin, logged in)
var configured = false;
var draft = null; // the default being edited on the Display settings tab
// palette, seed and css form one part: a custom palette brings its own generated CSS.
function merge(base, top) {
var src = top.palette ? top : base;
return { mode: top.mode || base.mode, contrast: top.contrast || base.contrast, size: top.size || base.size,
palette: src.palette, seed: src.seed, css: src.css };
}
function effective() { return merge(defaults, own); }
function isEmpty(prefs) { return !Object.keys(prefs).length; }
function save() {
if (!url) {
try { localStorage.setItem(KEY, JSON.stringify(own)); } catch (e) { /* private mode: applies to this page only */ }
return;
}
var token = document.querySelector('meta[name="csrf-token"]');
fetch(url, {
method: 'PUT',
credentials: 'same-origin',
keepalive: true, // finish even if the user navigates away right after picking
// X-Requested-With: an expired session answers 401 instead of redirecting to the login page.
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': token ? token.content : '' },
body: JSON.stringify(own)
}).then(function (response) {
if (response.status !== 204) throw new Error('HTTP ' + response.status);
}).catch(function (error) { console.warn('tonalmine: appearance not saved', error); });
}
// Called by the redmine_tonalmine plugin from <head>, right after this file.
window.Tonalmine = {
configure: function (config) {
defaults = config.defaults || {};
if (config.user) {
url = config.url;
var local = own;
own = config.user;
// A choice made in this browser before the plugin was installed moves to the account once.
if (isEmpty(own) && !isEmpty(local)) {
own = local;
save();
}
// Logged-in choices live on the account only, so the next person on this browser doesn't inherit them.
try { localStorage.removeItem(KEY); } catch (e) { /* nothing stored */ }
}
configured = true;
apply(effective());
}
};
apply(effective());
function button(attr, value, label, pressed, extra) {
return '<button type="button" ' + attr + '="' + value + '" aria-pressed="' + pressed + '"' + (extra || '') + '>' + label + '</button>';
}
// isDefault: the administrator's picker on the Display settings tab (no title, no reset).
function pickerHtml(prefs, isDefault) {
var mode = prefs.mode || 'auto';
var contrast = prefs.contrast || 'standard';
var size = prefs.size || 'normal';
var palette = prefs.palette || PALETTES[0][0];
return '<div class="nm-appearance' + (isDefault ? ' nm-appearance--default" data-nm-default' : '"') + '>' +
(isDefault ? '' : '<div class="nm-appearance__title">Appearance</div>') +
'<div class="nm-segmented" role="group" aria-label="Mode">' +
MODES.map(function (m) { return button('data-nm-mode', m[0], m[1], m[0] === mode); }).join('') +
'</div>' +
'<div class="nm-appearance__title">Contrast</div>' +
'<div class="nm-segmented" role="group" aria-label="Contrast">' +
CONTRASTS.map(function (c) { return button('data-nm-contrast', c[0], c[1], c[0] === contrast); }).join('') +
'</div>' +
'<div class="nm-appearance__title">Size</div>' +
'<div class="nm-segmented" role="group" aria-label="Size">' +
SIZES.map(function (s) { return button('data-nm-size', s[0], s[1], s[0] === size); }).join('') +
'</div>' +
'<div class="nm-swatches" role="group" aria-label="Color">' +
PALETTES.map(function (p) {
return button('data-nm-palette', p[0], '', p[0] === palette,
' class="nm-swatch" title="' + p[1] + '" aria-label="' + p[1] + '" style="--nm-swatch: ' + p[2] + '"');
}).join('') +
'<label class="nm-swatch nm-swatch--custom no-css" data-nm-palette="custom" aria-pressed="' + (palette === 'custom') + '"' +
(prefs.seed ? ' style="--nm-swatch: ' + prefs.seed + '"' : '') + ' title="Custom color">' +
'<input type="color" data-nm-custom value="' + (prefs.seed || PALETTES[0][2].toLowerCase()) + '" aria-label="Custom color"></label>' +
'</div>' +
(isDefault ? '' : '<button type="button" class="nm-appearance__reset" data-nm-reset' + (isEmpty(own) ? ' hidden' : '') + '>Reset to default</button>') +
'</div>';
}
function sync() {
document.querySelectorAll('.nm-appearance').forEach(function (picker) {
var prefs = picker.hasAttribute('data-nm-default') ? draft : effective();
picker.querySelectorAll('[data-nm-mode]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-mode') === (prefs.mode || 'auto')));
});
picker.querySelectorAll('[data-nm-contrast]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-contrast') === (prefs.contrast || 'standard')));
});
picker.querySelectorAll('[data-nm-size]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-size') === (prefs.size || 'normal')));
});
picker.querySelectorAll('[data-nm-palette]').forEach(function (b) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-nm-palette') === (prefs.palette || PALETTES[0][0])));
});
if (prefs.seed) {
picker.querySelectorAll('[data-nm-custom]').forEach(function (input) {
input.value = prefs.seed;
input.parentNode.style.setProperty('--nm-swatch', prefs.seed);
});
}
var reset = picker.querySelector('[data-nm-reset]');
if (reset) reset.hidden = isEmpty(own);
});
document.querySelectorAll('[data-nm-default-input]').forEach(function (input) {
input.value = draft[input.getAttribute('data-nm-default-input')] || '';
});
}
// persist: save the user's choice (the default is saved with the settings form instead).
function changed(isDefault, persist) {
if (isDefault) apply(draft); // preview
else {
if (persist) save();
apply(effective());
}
sync();
}
document.addEventListener('click', function (event) {
var target = event.target.closest('[data-nm-mode], [data-nm-contrast], [data-nm-size], [data-nm-palette], [data-nm-reset]');
if (!target) return;
var isDefault = !!target.closest('[data-nm-default]');
var prefs = isDefault ? draft : own;
if (target.hasAttribute('data-nm-reset')) own = {};
else if (target.hasAttribute('data-nm-mode')) prefs.mode = target.getAttribute('data-nm-mode');
else if (target.hasAttribute('data-nm-contrast')) prefs.contrast = target.getAttribute('data-nm-contrast');
else if (target.hasAttribute('data-nm-size')) prefs.size = target.getAttribute('data-nm-size');
// The custom swatch opens the color dialog; it only selects the palette once a color was picked.
else if (target.getAttribute('data-nm-palette') !== 'custom' || prefs.css) prefs.palette = target.getAttribute('data-nm-palette');
changed(isDefault, true);
});
document.addEventListener('input', function (event) {
if (!event.target.matches('[data-nm-custom]')) return;
var isDefault = !!event.target.closest('[data-nm-default]');
var seed = event.target.value;
withScheme(function (scheme) {
var prefs = isDefault ? draft : own;
prefs.palette = 'custom';
prefs.seed = seed;
prefs.css = scheme.paletteCss(CUSTOM_SEL, seed);
// While the user drags, only localStorage keeps up; the account gets the final color on change.
changed(isDefault, !url);
});
});
document.addEventListener('change', function (event) {
if (!url || !event.target.matches('[data-nm-custom]') || event.target.closest('[data-nm-default]')) return;
withScheme(save); // queued behind the input event's pending update
});
// Display settings tab (admins, plugin installed): edit the default appearance below the theme
// select. The hidden inputs are saved by the settings form as Setting.plugin_redmine_tonalmine.
function defaultPicker(select) {
draft = merge({}, defaults);
// One labelled form row per group (mode, contrast, size, color), each in its own default picker
// wrapper so sync() and the click handlers treat it like the whole picker.
var picker = document.createElement('div');
picker.innerHTML = pickerHtml(draft, true);
var groups = picker.querySelectorAll('[role="group"]');
var rows = ['Default appearance', 'Contrast', 'Size', 'Color'].map(function (label, i) {
var row = document.createElement('p');
row.innerHTML = '<label>' + label + '</label><div class="nm-appearance nm-appearance--default" data-nm-default></div>';
row.lastChild.appendChild(groups[i]);
return row;
});
rows[3].insertAdjacentHTML('beforeend', DEFAULT_KEYS.map(function (key) {
return '<input type="hidden" name="settings[plugin_redmine_tonalmine][' + key + ']" data-nm-default-input="' + key + '">';
}).join(''));
select.closest('p').after(rows[0], rows[1], rows[2], rows[3]);
// Only shown, and only posted, while Tonalmine is selected; otherwise the saved default stays.
function toggle() {
var on = select.value === 'tonalmine';
rows.forEach(function (row) { row.style.display = on ? '' : 'none'; });
rows[3].querySelectorAll('[data-nm-default-input]').forEach(function (input) { input.disabled = !on; });
}
select.addEventListener('change', toggle);
toggle();
sync();
}
// Calendar/Gantt hover cards: Redmine renders "<a>Tracker #id</a>: subject<br><br>" followed by
// "<strong>Label</strong>: value<br>" lines. Regroup them on first hover into a title and a
// label/value grid (nodes are moved, not re-parsed, so links and avatars stay intact).
function structureTip(tip) {
var lines = [[]];
Array.prototype.slice.call(tip.childNodes).forEach(function (node) {
if (node.nodeName === 'BR') lines.push([]);
else if (node.nodeType !== 3 || node.nodeValue.trim()) lines[lines.length - 1].push(node);
});
var title = document.createElement('span');
var fields = document.createElement('span');
title.className = 'nm-tip__title';
fields.className = 'nm-tip__fields';
lines.filter(function (line) { return line.length; }).forEach(function (line, i) {
var head = line[0];
var value = document.createElement('span');
line.slice(1).forEach(function (node) { value.appendChild(node); });
if (value.firstChild && value.firstChild.nodeType === 3) {
value.firstChild.nodeValue = value.firstChild.nodeValue.replace(/^\s*:\s*/, '');
}
var target = i > 0 && head.nodeName === 'STRONG' ? fields : title;
target.appendChild(head);
target.appendChild(value);
});
tip.replaceChildren(title, fields);
tip.classList.add('nm-tip');
}
document.addEventListener('mouseover', function (event) {
var tip = event.target.closest && event.target.closest('div.tooltip');
tip = tip && tip.querySelector(':scope > span.tip:not(.nm-tip)');
if (tip) structureTip(tip);
});
document.addEventListener('DOMContentLoaded', function () {
var menu = document.querySelector('#account .dropdown-content .dropdown-divider');
if (menu) menu.insertAdjacentHTML('afterend', pickerHtml(effective()) + '<div class="dropdown-divider"></div>');
var flyout = document.querySelector('.js-flyout-menu');
if (flyout) flyout.insertAdjacentHTML('beforeend', pickerHtml(effective()));
var select = document.getElementById('settings_ui_theme');
if (select && configured) defaultPicker(select);
});
})();