// This file implements the UI and logic for collapsing and expanding
// instance lists ("details").
//
// A configuration ('GlobalConfig') controlled by the UI is persisted
// in local storage in the user's browser. The configuration includes:
//
// * a global default state ('defaultInstanceState') for all instance
// lists. The possible values for the global default are "collapsed"
// and "expanded".
//
// * a global boolean option ('rememberToggles') to remember which
// specific instance lists are not in the default state (e.g. which
// instance lists are expanded when the default is "collapsed").
//
// * a local / per-page record of which specific instance lists are
// not in the default state, when the global option
// ('rememberToggles') to remember this info is enabled.
//
// The UI consists of an Instances menu with buttons for expanding and
// collapsing all instance lists in the current module, a checkbox for
// setting the global default state, and a checkbox to enable
// remembering which instance lists are not in the global default
// state. Also, each instance list on each module page has buttons for
// collapsing and expanding.
//
// The logic of the UI is as follows:
//
// * setting the global default state erases any record of which
// specific instances are in the non-default state, and collapses or
// expands all instance lists on the current page to be in the
// global default state.
//
// * changing boolean option for remembering which specific instance
// lists are not in the default state erases any existing record of
// which instances are not in the default state across all pages,
// and updates the record for the current page when the option is
// set to true. No collapsing or expanding is done.
//
// * toggling the collapse/expand state of a specific instance list
// causes the state of that specific instance list to be recorded in
// the persisted configuration iff the new state of that specific
// instance list is different from the global default state, and the
// option to remember instance list states is enabled. There are two
// ways to toggle the collapse/expand state of a specific instance,
// by clicking its collapse/expand button, and by clicking the
// "collapse all" or "expand all" button in the Instances menu.
//
// This file also implements an association between elements (with
// class "details-toggle" and "details-toggle-control") that can be
// clicked to expand/collapse <details> elements, and the details
// elements themselves. Note that this covers both <details> elements
// that list instances -- what the above explained UI and logic is
// concerned with -- and details about individual instances themselves
// -- which the above is not concerend with. The association includes
// adding event listeners that change CSS classes back and forth
// between "expander" and "collapser"; these classes determine whether
// an element is adorned with a right arrow ("expander") or a down
// arrow ("collapser"). I don't understand why we don't directly use
// the the HTML <summary> element type to allow the <details> elements
// to be directly clickable.
import preact = require("preact");
const { h, Component } = preact;
enum DefaultState { Closed, Open }
interface GlobalConfig {
defaultInstanceState: DefaultState
rememberToggles: boolean
}
// Hackage domain-wide config
const globalConfig: GlobalConfig = {
defaultInstanceState: DefaultState.Open,
rememberToggles: true,
};
class PreferencesButton extends Component<any, any> {
render(props: { title: string, onClick: () => void }) {
function onClick(e: Event) {
e.preventDefault();
props.onClick();
}
return <li><a href="#" onClick={onClick}>{props.title}</a></li>;
}
}
function addPreferencesButton(action: () => void) {
const pageMenu = document.querySelector('#page-menu') as HTMLUListElement;
const dummy = document.createElement('li');
pageMenu.insertBefore(dummy, pageMenu.firstChild);
preact.render(<PreferencesButton onClick={action} title="Instances" />, pageMenu, dummy);
}
type PreferencesProps = {
showHideTrigger: (action: () => void) => void
}
type PreferencesState = {
isVisible: boolean
}
class Preferences extends Component<PreferencesProps, PreferencesState> {
componentWillMount() {
document.addEventListener('mousedown', this.hide.bind(this));
document.addEventListener('keydown', (e) => {
if (this.state.isVisible) {
if (e.key === 'Escape') {
this.hide();
}
}
})
}
hide() {
this.setState({ isVisible: false });
}
show() {
if (!this.state.isVisible) {
this.setState({ isVisible: true });
}
}
toggleVisibility() {
if (this.state.isVisible) {
this.hide();
} else {
this.show();
}
}
componentDidMount() {
this.props.showHideTrigger(this.toggleVisibility.bind(this));
}
render(props: PreferencesProps, state: PreferencesState) {
const stopPropagation = (e: Event) => { e.stopPropagation(); };
return <div id="preferences" class={state.isVisible ? '' : 'hidden'}>
<div id="preferences-menu" class="dropdown-menu" onMouseDown={stopPropagation}>
<PreferencesMenu />
</div>
</div>;
}
}
function storeGlobalConfig() {
const json = JSON.stringify(globalConfig);
try {
// https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem#Exceptions.
localStorage.setItem('global', json);
} catch (e) {}
}
var globalConfigLoaded: boolean = false;
function loadGlobalConfig() {
if (globalConfigLoaded) { return; }
globalConfigLoaded = true;
const global = localStorage.getItem('global');
if (!global) { return; }
try {
const globalConfig_ = JSON.parse(global);
globalConfig.defaultInstanceState = globalConfig_.defaultInstanceState;
globalConfig.rememberToggles = globalConfig_.rememberToggles;
} catch(e) {
// Gracefully handle errors related to changed config format.
if (e instanceof SyntaxError || e instanceof TypeError) {
localStorage.removeItem('global');
} else {
throw e;
}
}
}
function setDefaultInstanceState(s: DefaultState) {
return (e: Event) => {
globalConfig.defaultInstanceState = s;
putInstanceListsInDefaultState();
storeGlobalConfig();
clearLocalStorage();
storeLocalConfig();
}
}
function setRememberToggles(e: Event) {
const checked: boolean = (e as any).target.checked;
globalConfig.rememberToggles = checked;
storeGlobalConfig();
clearLocalStorage();
storeLocalConfig();
}
// Click event consumer for "default collapse" instance menu check box.
function defaultCollapseOnClick(e: Event) {
const us = document.getElementById('default-collapse-instances') as HTMLInputElement;
if (us !== null) {
if (us.checked) {
setDefaultInstanceState(DefaultState.Closed)(e);
} else {
setDefaultInstanceState(DefaultState.Open)(e);
}
}
}
// Instances menu.
function PreferencesMenu() {
loadGlobalConfig();
return <div>
<div>
<button type="button"
onClick={expandAllInstances}>
Expand All Instances
</button>
<button type="button"
onClick={collapseAllInstances}>
Collapse All Instances
</button>
</div>
<div>
<input type="checkbox"
id="default-collapse-instances"
name="default-instance-state"
checked={globalConfig.defaultInstanceState===DefaultState.Closed}
onClick={defaultCollapseOnClick}></input>
<span>Collapse All Instances By Default</span>
</div>
<div>
<input type="checkbox"
id="remember-toggles"
name="remember-toggles"
checked={globalConfig.rememberToggles}
onClick={setRememberToggles}></input>
<label for="remember-toggles">Remember Manually Collapsed/Expanded Instances</label>
</div>
</div>;
}
interface HTMLDetailsElement extends HTMLElement {
open: boolean
}
interface DetailsInfo {
element: HTMLDetailsElement
// Here 'toggles' is the list of all elements of class
// 'details-toggle-control' that control toggling 'element'. I
// believe this list is always length zero or one.
toggles: HTMLElement[]
}
// Mapping from <details> elements to their info.
const detailsRegistry: { [id: string]: DetailsInfo } = {};
function lookupDetailsRegistry(id: string): DetailsInfo {
const info = detailsRegistry[id];
if (info == undefined) { throw new Error(`could not find <details> element with id '${id}'`); }
return info;
}
// Return true iff instance lists are open by default.
function getDefaultOpenSetting(): boolean {
return globalConfig.defaultInstanceState == DefaultState.Open;
}
// Event handler for "toggle" events, which are triggered when a
// <details> element's "open" property changes. We don't deal with
// any config stuff here, because we only change configs in response
// to mouse clicks. In contrast, for example, this event is triggred
// automatically once for every <details> element when the user clicks
// the "collapse all elements" button.
function onToggleEvent(ev: Event) {
const element = ev.target as HTMLDetailsElement;
const id = element.id;
const info = lookupDetailsRegistry(id);
const isOpen = info.element.open;
// Update the CSS of the toggle element users can click on to toggle
// 'element'. The "collapser" and "expander" classes control what
// kind of arrow appears next to the 'toggle' element.
for (const toggle of info.toggles) {
if (toggle.classList.contains('details-toggle-control')) {
toggle.classList.add(isOpen ? 'collapser' : 'expander');
toggle.classList.remove(isOpen ? 'expander' : 'collapser');
}
}
}
function gatherDetailsElements() {
const els: HTMLDetailsElement[] = Array.prototype.slice.call(document.getElementsByTagName('details'));
for (const el of els) {
if (typeof el.id == "string" && el.id.length > 0) {
detailsRegistry[el.id] = {
element: el,
toggles: [] // Populated later by 'initCollapseToggles'.
};
el.addEventListener('toggle', onToggleEvent);
}
}
}
// Return the id of the <details> element that the given 'toggle'
// element toggles.
function getDataDetailsId(toggle: Element): string {
const id = toggle.getAttribute('data-details-id');
if (!id) { throw new Error("element with class " + toggle + " has no 'data-details-id' attribute!"); }
return id;
}
// Toggle the "open" state of a <details> element when that element's
// toggle element is clicked.
function toggleDetails(toggle: Element) {
const id = getDataDetailsId(toggle);
const {element} = lookupDetailsRegistry(id);
element.open = !element.open;
}
// Prefix for local keys used with local storage. Idea is that other
// modules could also use local storage with a different prefix and we
// wouldn't step on each other's toes.
//
// NOTE: we're using the browser's "local storage" API via the
// 'localStorage' object to store both "local" (to the current Haddock
// page) and "global" (across all Haddock pages) configuration. Be
// aware of these two different uses of the term "local".
const localStoragePrefix: string = "local-details-config:";
// Local storage key for the current page.
function localStorageKey(): string {
return localStoragePrefix + document.location.pathname;
}
// Clear all local storage related to instance list configs.
function clearLocalStorage() {
const keysToDelete: string[] = [];
for (var i = 0; i < localStorage.length; ++i) {
const key = localStorage.key(i);
if (key !== null && key.startsWith(localStoragePrefix)) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => {
localStorage.removeItem(key);
});
}
// Compute and save the set of instance list ids that aren't in the
// default state.
function storeLocalConfig() {
if (!globalConfig.rememberToggles) return;
const instanceListToggles: HTMLElement[] =
// Restrict to 'details-toggle' elements for "instances"
// *plural*. These are the toggles that control instance lists and
// not the list of methods for individual instances.
Array.prototype.slice.call(document.getElementsByClassName(
'instances details-toggle details-toggle-control'));
const nonDefaultInstanceListIds: string[] = [];
instanceListToggles.forEach(toggle => {
const id = getDataDetailsId(toggle);
const details = document.getElementById(id) as HTMLDetailsElement;
if (details.open != getDefaultOpenSetting()) {
nonDefaultInstanceListIds.push(id);
}
});
const json = JSON.stringify(nonDefaultInstanceListIds);
try {
// https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem#Exceptions.
localStorage.setItem(localStorageKey(), json);
} catch (e) {}
}
function putInstanceListsInDefaultState() {
switch (globalConfig.defaultInstanceState) {
case DefaultState.Closed: _collapseAllInstances(true); break;
case DefaultState.Open: _collapseAllInstances(false); break;
default: break;
}
}
// Expand and collapse instance lists according to global and local
// config.
function restoreToggled() {
loadGlobalConfig();
putInstanceListsInDefaultState();
if (!globalConfig.rememberToggles) { return; }
const local = localStorage.getItem(localStorageKey());
if (!local) { return; }
try {
const nonDefaultInstanceListIds: string[] = JSON.parse(local);
nonDefaultInstanceListIds.forEach(id => {
const info = lookupDetailsRegistry(id);
info.element.open = ! getDefaultOpenSetting();
});
} catch(e) {
// Gracefully handle errors related to changed config format.
if (e instanceof SyntaxError || e instanceof TypeError) {
localStorage.removeItem(localStorageKey());
} else {
throw e;
}
}
}
// Handler for clicking on the "toggle" element that toggles the
// <details> element with id given by the 'data-details-id' property
// of the "toggle" element.
function onToggleClick(ev: MouseEvent) {
ev.preventDefault();
const toggle = ev.currentTarget as HTMLElement;
toggleDetails(toggle);
storeLocalConfig();
}
// Set event handlers on elements responsible for expanding and
// collapsing <details> elements.
//
// This applies to all 'details-toggle's, not just to to top-level
// 'details-toggle's that control instance lists.
function initCollapseToggles() {
const toggles: HTMLElement[] = Array.prototype.slice.call(document.getElementsByClassName('details-toggle'));
toggles.forEach(toggle => {
const id = getDataDetailsId(toggle);
const info = lookupDetailsRegistry(id);
info.toggles.push(toggle);
toggle.addEventListener('click', onToggleClick);
if (toggle.classList.contains('details-toggle-control')) {
toggle.classList.add(info.element.open ? 'collapser' : 'expander');
}
});
}
// Collapse or expand all instances.
function _collapseAllInstances(collapse: boolean) {
const ilists = document.getElementsByClassName('subs instances');
[].forEach.call(ilists, function (ilist : Element) {
const toggleType = collapse ? 'collapser' : 'expander';
const toggle = ilist.getElementsByClassName('instances ' + toggleType)[0];
if (toggle) {
toggleDetails(toggle);
}
});
}
function collapseAllInstances() {
_collapseAllInstances(true);
storeLocalConfig();
}
function expandAllInstances() {
_collapseAllInstances(false);
storeLocalConfig();
}
export function init(showHide?: (action: () => void) => void) {
gatherDetailsElements();
initCollapseToggles();
restoreToggled();
preact.render(
<Preferences showHideTrigger={showHide || addPreferencesButton} />,
document.body
);
}