diff options
author | Ben Gamari <ben@smart-cactus.org> | 2021-02-06 18:30:35 -0500 |
---|---|---|
committer | Ben Gamari <ben@smart-cactus.org> | 2021-02-06 18:30:35 -0500 |
commit | b995bfe84f9766e23ff78d7ccd520ec7d8cdbebc (patch) | |
tree | 3e7f15ac3b0abe417797ec89275aa1209f6ca297 /haddock-api/resources/html/js-src/details-helper.tsx | |
parent | 9f597b6647a53624eaf501a34bfb4d8d15425929 (diff) | |
parent | 010f0320dff64e3f86091ba4691bc69ce6999647 (diff) |
Merge branch 'wip/ghc-head-merge' into ghc-head
Diffstat (limited to 'haddock-api/resources/html/js-src/details-helper.tsx')
-rw-r--r-- | haddock-api/resources/html/js-src/details-helper.tsx | 464 |
1 files changed, 464 insertions, 0 deletions
diff --git a/haddock-api/resources/html/js-src/details-helper.tsx b/haddock-api/resources/html/js-src/details-helper.tsx new file mode 100644 index 00000000..871b5417 --- /dev/null +++ b/haddock-api/resources/html/js-src/details-helper.tsx @@ -0,0 +1,464 @@ +// 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 + ); +} |