/* global IITC, L, log -- eslint */
/**
* @file The options window: the list of options pages next to the page chosen, and a search over
* the titles and texts of all pages. A narrow window shows the list or the page, with a way back.
* The window is a dialog over the map, so a change shows on the map at once, or fills the pane
* of an app. The page shown follows changes of its own options made elsewhere, such as values arriving
* through sync
*/
// pages of IITC itself come first, the rest follow by title
const FIRST_PAGES = ['iitc', 'about'];
// texts of a block searched for a query; titles computed when drawn are left out
const SEARCHED = ['title', 'description', 'text'];
// longest text the search shows under a page
const FOUND_LENGTH = 80;
// height of the dialog, in pixels; a lower screen gets a lower dialog
const HEIGHT = 560;
const PANE_ID = 'options';
const LINK_ID = 'options-toolbox-link';
// the window's element and its dialog while it is open; the page and the query stay between openings
const view = {
root: null,
dialog: null,
pageId: undefined,
query: '',
pane: null,
paneAdded: false,
mapButton: null,
};
const el = (...args) => IITC.options._el(...args);
/**
* Ids of the registered pages in the order the window lists them
*
* @alias IITC.options._pageIds
* @private
* @returns {string[]}
*/
const pageIds = () => {
const pages = IITC.options._pages;
const rank = (id) => (FIRST_PAGES.includes(id) ? FIRST_PAGES.indexOf(id) : FIRST_PAGES.length);
return Object.keys(pages).sort((a, b) => rank(a) - rank(b) || String(pages[a].title ?? a).localeCompare(String(pages[b].title ?? b)));
};
const titleOf = (pageId) => String(IITC.options._pages[pageId]?.title ?? pageId);
// a block the page does not show; inside a repeat, `visible` as a function of the item counts as shown
const hidden = (block, inRepeat) => {
if (typeof block?.visible !== 'function') return block?.visible === false;
if (inRepeat) return false;
try {
return block.visible() === false;
} catch {
// drawing the page reports the error
return false;
}
};
// texts of the blocks shown, the ones inside a repeat too
const blockTexts = (blocks, inRepeat = false) =>
(blocks ?? []).flatMap((block) => {
if (hidden(block, inRepeat)) return [];
const own = SEARCHED.filter((name) => typeof block?.[name] === 'string').map((name) => block[name]);
return block?.type === 'repeat' ? [...own, ...blockTexts(block.blocks, true)] : own;
});
/**
* Finds the texts of a page that contain the query, ignoring case
*
* @memberof IITC.options
* @inner
* @private
* @returns {string[]|null} Texts of the page's blocks that match, empty when only the page title does;
* null when nothing on the page matches
*/
const search = (pageId, query) => {
const needle = query.trim().toLowerCase();
if (!needle) return [];
// the sections of extensions are searched with their titles
const sections = IITC.options._sections(pageId).flatMap((section) => [section.title, ...blockTexts(section.blocks)]);
const found = [...blockTexts(IITC.options._pages[pageId].blocks), ...sections].filter((text) => text.toLowerCase().includes(needle));
if (found.length) return found;
return titleOf(pageId).toLowerCase().includes(needle) ? [] : null;
};
const shorten = (text) => (text.length > FOUND_LENGTH ? `${text.slice(0, FOUND_LENGTH - 1)}…` : text);
const isOpen = () => view.root !== null && view.root.isConnected;
const part = (className) => view.root.querySelector(`.${className}`);
const content = () => part('iitc-options-content');
const navLink = (pageId) => [...view.root.querySelectorAll('.iitc-options-page-link')].find((link) => link.dataset.page === pageId);
const isHidden = (element) => element.ownerDocument.defaultView.getComputedStyle(element).display === 'none';
// the tooltip of a page in the list, telling its access key; none on a touch screen, where a tap that opens
// the page would show it
const accessKeyHint = (pageId) => {
const accesskey = IITC.options._pages[pageId]?.accesskey;
if (!accesskey || window.matchMedia?.('(hover: none)').matches) return undefined;
return `Access key [${accesskey}]`;
};
// draws the list again; the focus stays on the page it was on
const drawNav = () => {
const list = part('iitc-options-pages');
const focused = list.contains(document.activeElement) ? document.activeElement.dataset.page : undefined;
const entries = pageIds()
.map((pageId) => [pageId, search(pageId, view.query)])
.filter(([, found]) => found !== null)
.map(([pageId, found]) =>
el(
'li',
{},
el(
'button',
{
type: 'button',
className: 'iitc-options-page-link',
dataset: { page: pageId },
title: accessKeyHint(pageId),
'aria-current': pageId === view.pageId && 'page',
onclick: () => choose(pageId),
},
el('span', { className: 'iitc-options-page-title' }, titleOf(pageId)),
// what the search found on the page
found.length > 0 && el('span', { className: 'iitc-options-found' }, found.slice(0, 3).map(shorten).join(' · '))
)
)
);
list.replaceChildren(...entries);
if (!entries.length) list.append(el('li', { className: 'iitc-options-nothing' }, 'Nothing found'));
if (focused !== undefined) navLink(focused)?.focus();
};
const drawPage = () => {
part('iitc-options-page-heading').textContent = view.pageId ? titleOf(view.pageId) : '';
if (view.pageId) IITC.options._renderPage(view.pageId, content(), view.query);
else content().replaceChildren();
};
// shows a page; in a narrow window it takes the place of the list, and the focus moves to the way back
const choose = (pageId) => {
const another = pageId !== view.pageId;
view.pageId = pageId;
view.root.dataset.view = 'page';
view.root.querySelectorAll('.iitc-options-page-link').forEach((link) => {
if (link.dataset.page === pageId) link.setAttribute('aria-current', 'page');
else link.removeAttribute('aria-current');
});
drawPage();
if (another) {
// another page starts at its top, or at what the search found on it
content().scrollTop = 0;
content().querySelector('.iitc-options-match')?.scrollIntoView?.({ block: 'center' });
}
const nav = part('iitc-options-nav');
if (nav.contains(document.activeElement) && isHidden(nav)) part('iitc-options-back').focus();
};
// the window, showing the page in a narrow window too when `showPage` is set
const build = (showPage) => {
const searchInput = el('input', {
type: 'search',
className: 'iitc-options-search',
placeholder: 'Search',
value: view.query,
'aria-label': 'Search options',
});
// the list and the page show what the query finds
const searched = () => {
drawNav();
if (view.pageId) drawPage();
};
searchInput.addEventListener('input', () => {
view.query = searchInput.value;
searched();
});
// Escape clears the search first, and closes the window only from an empty field
searchInput.addEventListener('keydown', (event) => {
if (event.key !== 'Escape' || !searchInput.value) return;
event.stopPropagation();
searchInput.value = '';
view.query = '';
searched();
});
const back = el(
'button',
{
type: 'button',
className: 'iitc-options-back',
'aria-label': 'Back to the list of pages',
onclick: () => {
view.root.dataset.view = 'list';
(navLink(view.pageId) ?? searchInput).focus();
},
},
el('iitc-icon', {}, 'arrow_back')
);
view.root = el(
'div',
{ className: 'iitc-options', dataset: { view: showPage ? 'page' : 'list' } },
el('nav', { className: 'iitc-options-nav' }, searchInput, el('ul', { className: 'iitc-options-pages' })),
el(
'section',
{ className: 'iitc-options-detail' },
el('div', { className: 'iitc-options-detail-head' }, back, el('h2', { className: 'iitc-options-page-heading' })),
el('div', { className: 'iitc-options-content' })
)
);
drawNav();
drawPage();
return view.root;
};
// a page to show: the one chosen last while it is still registered, or the first
const currentPage = () => (IITC.options._pages[view.pageId] ? view.pageId : pageIds()[0]);
const openDialog = (showPage) => {
view.dialog = window.dialog({
id: 'iitc-options',
title: 'Options',
html: build(showPage),
width: 700,
height: Math.min(HEIGHT, window.innerHeight - 16),
closeCallback: () => {
view.root = null;
view.dialog = null;
},
});
// the dialog focuses the search as its first field; on a smartphone that would open the keyboard over the window
const search = part('iitc-options-search');
if (IITC.utils.isSmartphone() && document.activeElement === search) search.blur();
};
// no close button: the app leaves the pane by showing another
const openPane = (showPage) => {
view.pane = el('div', { id: 'iitc-options-pane', className: 'iitc-options-pane' }, build(showPage));
document.body.append(view.pane);
};
const closePane = () => {
view.pane?.remove();
view.pane = null;
view.root = null;
};
const display = (pageId) => {
const showPage = pageId !== undefined;
if (isOpen()) {
if (showPage) choose(pageId);
view.dialog?.dialog?.('moveToTop');
} else {
// a wide window always shows a page next to the list
view.pageId = pageId ?? currentPage();
if (window.currentPane === PANE_ID) {
openPane(showPage);
return;
}
// on a smartphone the dialog shows over the map pane
if (IITC.utils.isSmartphone() && window.currentPane !== 'map') window.show('map');
openDialog(showPage);
}
};
// the page open() asked for before it switched to the pane
let paneRequest;
const entry = () => {
if (!window.useAppPanes()) return 'button';
return IITC.options.get('iitc.optionsPane') ? 'pane' : 'toolbox';
};
/**
* Opens the options window, on a given page or on the page chosen last
*
* A toolbox button to an options page comes from the page's `shortcut`, which the user can turn off
* with the other shortcuts; `IITC.toolbox.addButton` is for a plugin's own dialogs that are not part
* of the options, such as lists and other views of map data. In an app with panes the window is a pane
* of the app, unless `iitc.optionsPane` is off
*
* @memberof IITC.options
* @param {string} [pageId] - Page to show
* @returns {boolean} False when no such page is registered
*
* @example
* // a toolbox button that opens the page
* IITC.options.register('my-plugin', { title: 'My plugin', shortcut: true, blocks });
* // the page opened from the plugin's own code, such as a link in its dialog
* IITC.options.open('my-plugin');
*/
const open = (pageId) => {
if (pageId !== undefined && !IITC.options._pages[pageId]) {
log.warn(`options: no page "${pageId}" to open`);
return false;
}
if (entry() === 'pane' && window.currentPane !== PANE_ID) {
// the window opens when the pane is shown
paneRequest = { pageId };
window.show(PANE_ID);
return true;
}
display(pageId);
return true;
};
/**
* Draws the page shown in the options window again, after the current task and after the click when
* the pointer is down, and updates the toolbox shortcut of the page. Changes of the page's own options and
* of the sections extensions add to it redraw it already; a page that also shows other state calls this when
* that state changes
*
* @memberof IITC.options
* @param {string} [pageId] - Redraws only if this page is shown; all shortcuts are updated without it
*/
const refresh = (pageId) => {
(pageId === undefined ? Object.keys(IITC.options._pages) : [pageId]).forEach(updateShortcut);
if (!isOpen() || (pageId !== undefined && pageId !== view.pageId)) return;
IITC.options._redrawLater(content());
};
// a change made on the page redraws it already; this follows changes made elsewhere, such as by sync, of the
// options the page and the sections extensions add to it declare. Options of other pages are left to
// refresh, so an open select is not closed by unrelated changes
const onOptionChanged = ({ key }) => {
if (isOpen() && IITC.options._pages[view.pageId] && IITC.options._definitions[key]?.pageId === view.pageId) IITC.options._redrawLater(content());
};
const onPageRegistered = ({ pageId }) => {
if (!isOpen()) return;
drawNav();
if (pageId !== view.pageId) return;
part('iitc-options-page-heading').textContent = titleOf(pageId);
IITC.options._redrawLater(content());
};
const shortcutId = (pageId) => `options-shortcut-${pageId}`;
// the shortcut arguments each button was drawn with, so a refresh that changes nothing leaves the toolbox alone
const drawnShortcuts = new Map();
// the arguments of a page's toolbox button, or undefined when the page has none
const shortcutOf = (pageId) => {
const page = IITC.options._pages[pageId];
if (!page?.shortcut || IITC.options.get('iitc.toolboxShortcuts') === false) return undefined;
let args = page.shortcut;
try {
if (typeof args === 'function') args = args();
} catch (e) {
log.warn(`options: shortcut of page "${pageId}" failed`, e);
return undefined;
}
args = { label: titleOf(pageId), ...(typeof args === 'object' ? args : {}) };
if (!page.accesskey) return args;
const hint = `[${page.accesskey}]`;
return { ...args, accesskey: page.accesskey, title: args.title ? `${args.title} ${hint}` : hint };
};
// the access keys of pages without a toolbox button, on links that are not seen but still take the key
const accessKeyLinks = () => {
let links = document.getElementById('iitc-options-access-keys');
if (!links) {
links = el('div', { id: 'iitc-options-access-keys', className: 'iitc-options-access-keys', 'aria-hidden': 'true' });
document.body.append(links);
}
return links;
};
// keeps the access key of a page working while the page has no toolbox button
const updateAccessKey = (pageId, hasButton) => {
const accesskey = IITC.options._pages[pageId]?.accesskey;
const link = document.getElementById(`options-access-key-${pageId}`);
if (hasButton || !accesskey) {
link?.remove();
return;
}
if (link) {
link.accessKey = accesskey;
return;
}
accessKeyLinks().append(el('a', { id: `options-access-key-${pageId}`, accessKey: accesskey, tabIndex: -1, onclick: () => open(pageId) }));
};
// adds, updates or removes the toolbox button of a page, as its declaration and iitc.toolboxShortcuts say;
// the access key of the page moves to a hidden link when there is no button
const updateShortcut = (pageId) => {
const args = IITC.toolbox ? shortcutOf(pageId) : undefined;
updateAccessKey(pageId, Boolean(args));
if (!IITC.toolbox) return;
const id = shortcutId(pageId);
const exists = Boolean(IITC.toolbox.buttons[id]);
if (!args) {
if (exists) IITC.toolbox.removeButton(id);
drawnShortcuts.delete(id);
return;
}
const drawn = JSON.stringify(args);
if (exists && drawnShortcuts.get(id) === drawn) return;
drawnShortcuts.set(id, drawn);
// added anew, since updateButton keeps arguments that a shortcut function no longer returns
if (exists) IITC.toolbox.removeButton(id);
IITC.toolbox.addButton({ ...args, id, action: () => open(pageId) });
};
const setMapButton = (wanted) => {
if (!wanted) {
view.mapButton?.remove();
view.mapButton = null;
return;
}
if (view.mapButton || !window.map || !globalThis.L?.Control) return;
const Button = L.Control.extend({
options: { position: 'topright' },
onAdd: () => {
// the pages, shown beside the button under a mouse; filled on the way in, so it has the pages of plugins
const menu = el('div', { className: 'iitc-control-menu' });
const fillMenu = () => {
// drawn anew, the item in focus would lose it
if (menu.contains(document.activeElement)) return;
menu.replaceChildren(
...pageIds().map((pageId) =>
el(
'a',
{
href: '#',
role: 'button',
title: accessKeyHint(pageId),
onclick: (event) => {
event.preventDefault();
open(pageId);
},
},
titleOf(pageId)
)
)
);
};
const container = el(
'div',
{ className: 'iitc-control-button iitc-options-button' },
el(
'a',
{
href: '#',
role: 'button',
title: 'IITC and plugin options',
'aria-label': 'Options',
onclick: (event) => {
event.preventDefault();
open();
},
},
el('iitc-icon', { fallback: '⚙' }, 'tune')
),
menu
);
for (const type of ['pointerenter', 'focusin']) container.addEventListener(type, fillMenu);
L.DomEvent.disableClickPropagation(container);
return container;
},
});
view.mapButton = new Button().addTo(window.map);
};
/**
* Shows the way to the window that `entry` names. An app cannot remove a pane, so it stays until
* IITC is reloaded and still opens the window
*
* @alias IITC.options._setupWindow
* @private
*/
const updateEntry = () => {
const mode = entry();
if (IITC.toolbox) {
const hasLink = Boolean(IITC.toolbox.buttons[LINK_ID]);
if (mode === 'toolbox' && !hasLink) {
IITC.toolbox.addButton({ id: LINK_ID, label: 'Options', title: 'IITC and plugin options', action: () => open() });
} else if (mode !== 'toolbox' && hasLink) {
IITC.toolbox.removeButton(LINK_ID);
}
}
setMapButton(mode === 'button');
if (mode === 'pane' && !view.paneAdded) {
window.app.addPane(PANE_ID, 'Options', 'tune');
view.paneAdded = true;
}
};
Object.assign(IITC.options, { open, refresh, _pageIds: pageIds, _setupWindow: updateEntry, _view: view });
// handlers return nothing: a false would stop the remaining hook handlers
window.addHook('optionChanged', (event) => {
onOptionChanged(event);
if (event.key === 'iitc.toolboxShortcuts') Object.keys(IITC.options._pages).forEach(updateShortcut);
if (event.key === 'iitc.optionsPane') updateEntry();
});
window.addHook('paneChanged', (pane) => {
if (pane === PANE_ID) {
const request = paneRequest;
paneRequest = undefined;
// a dialog left open from before the pane was chosen gives way to it
if (isOpen() && !view.pane) view.dialog?.dialog?.('close');
display(request?.pageId);
} else if (view.pane) {
closePane();
}
});
window.addHook('optionsPageRegistered', (event) => {
onPageRegistered(event);
updateShortcut(event.pageId);
});