dom.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608
  1. /* global FIREFOX debounce */// toolbox.js
  2. /* global prefs */
  3. /* global t */// localization.js
  4. 'use strict';
  5. /* exported
  6. $createLink
  7. $isTextInput
  8. $remove
  9. $$remove
  10. animateElement
  11. getEventKeyName
  12. messageBoxProxy
  13. moveFocus
  14. scrollElementIntoView
  15. setupLivePrefs
  16. showSpinner
  17. toggleDataset
  18. waitForSheet
  19. */
  20. Object.assign(EventTarget.prototype, {
  21. on: addEventListener,
  22. off: removeEventListener,
  23. });
  24. //#region Exports
  25. // Makes the focus outline appear on keyboard tabbing, but not on mouse clicks.
  26. const focusAccessibility = {
  27. // last event's focusedViaClick
  28. lastFocusedViaClick: false,
  29. // to avoid a full layout recalc due to changes on body/root
  30. // we modify the closest focusable element (like input or button or anything with tabindex=0)
  31. closest(el) {
  32. let labelSeen;
  33. for (; el; el = el.parentElement) {
  34. if (el.localName === 'label' && el.control && !labelSeen) {
  35. el = el.control;
  36. labelSeen = true;
  37. }
  38. if (el.tabIndex >= 0) return el;
  39. }
  40. },
  41. };
  42. /**
  43. * Autoloads message-box.js
  44. * @alias messageBox
  45. */
  46. window.messageBoxProxy = new Proxy({}, {
  47. get(_, name) {
  48. return async (...args) => {
  49. await require([
  50. '/js/dlg/message-box', /* global messageBox */
  51. '/js/dlg/message-box.css',
  52. ]);
  53. window.messageBoxProxy = messageBox;
  54. return messageBox[name](...args);
  55. };
  56. },
  57. });
  58. function $(selector, base = document) {
  59. // we have ids with . like #manage.onlyEnabled which looks like #id.class
  60. // so since getElementById is superfast we'll try it anyway
  61. const byId = selector.startsWith('#') && document.getElementById(selector.slice(1));
  62. return byId || base.querySelector(selector);
  63. }
  64. function $$(selector, base = document) {
  65. return [...base.querySelectorAll(selector)];
  66. }
  67. function $isTextInput(el = {}) {
  68. return el.localName === 'textarea' ||
  69. el.localName === 'input' && /^(text|search|number)$/.test(el.type);
  70. }
  71. function $remove(selector, base = document) {
  72. const el = selector && typeof selector === 'string' ? $(selector, base) : selector;
  73. if (el) {
  74. el.remove();
  75. }
  76. }
  77. function $$remove(selector, base = document) {
  78. for (const el of base.querySelectorAll(selector)) {
  79. el.remove();
  80. }
  81. }
  82. /*
  83. $create('tag#id.class.class', ?[children])
  84. $create('tag#id.class.class', ?textContentOrChildNode)
  85. $create('tag#id.class.class', {properties}, ?[children])
  86. $create('tag#id.class.class', {properties}, ?textContentOrChildNode)
  87. tag is 'div' by default, #id and .class are optional
  88. $create([children])
  89. $create({propertiesAndOptions})
  90. $create({propertiesAndOptions}, ?[children])
  91. tag: string, default 'div'
  92. appendChild: element/string or an array of elements/strings
  93. dataset: object
  94. any DOM property: assigned as is
  95. tag may include namespace like 'ns:tag'
  96. */
  97. function $create(selector = 'div', properties, children) {
  98. let ns, tag, opt;
  99. if (typeof selector === 'string') {
  100. if (Array.isArray(properties) ||
  101. properties instanceof Node ||
  102. typeof properties !== 'object') {
  103. opt = {};
  104. children = properties;
  105. } else {
  106. opt = properties || {};
  107. children = children || opt.appendChild;
  108. }
  109. const idStart = (selector.indexOf('#') + 1 || selector.length + 1) - 1;
  110. const classStart = (selector.indexOf('.') + 1 || selector.length + 1) - 1;
  111. const id = selector.slice(idStart + 1, classStart);
  112. if (id) {
  113. opt.id = id;
  114. }
  115. const cls = selector.slice(classStart + 1);
  116. if (cls) {
  117. opt[selector.includes(':') ? 'class' : 'className'] =
  118. cls.includes('.') ? cls.replace(/\./g, ' ') : cls;
  119. }
  120. tag = selector.slice(0, Math.min(idStart, classStart));
  121. } else if (Array.isArray(selector)) {
  122. tag = 'div';
  123. opt = {};
  124. children = selector;
  125. } else {
  126. opt = selector;
  127. tag = opt.tag;
  128. children = opt.appendChild || properties;
  129. }
  130. if (tag && tag.includes(':')) {
  131. [ns, tag] = tag.split(':');
  132. if (ns === 'SVG' || ns === 'svg') {
  133. ns = 'http://www.w3.org/2000/svg';
  134. }
  135. }
  136. const element = ns ? document.createElementNS(ns, tag) :
  137. tag === 'fragment' ? document.createDocumentFragment() :
  138. document.createElement(tag || 'div');
  139. for (const child of Array.isArray(children) ? children : [children]) {
  140. if (child) {
  141. element.appendChild(child instanceof Node ? child : document.createTextNode(child));
  142. }
  143. }
  144. for (const [key, val] of Object.entries(opt)) {
  145. switch (key) {
  146. case 'dataset':
  147. Object.assign(element.dataset, val);
  148. break;
  149. case 'attributes':
  150. Object.entries(val).forEach(attr => element.setAttribute(...attr));
  151. break;
  152. case 'style': {
  153. const t = typeof val;
  154. if (t === 'string') element.style.cssText = val;
  155. if (t === 'object') Object.assign(element.style, val);
  156. break;
  157. }
  158. case 'tag':
  159. case 'appendChild':
  160. break;
  161. default: {
  162. if (ns) {
  163. const i = key.indexOf(':') + 1;
  164. const attrNS = i && `http://www.w3.org/1999/${key.slice(0, i - 1)}`;
  165. element.setAttributeNS(attrNS || null, key, val);
  166. } else {
  167. element[key] = val;
  168. }
  169. }
  170. }
  171. }
  172. return element;
  173. }
  174. function $createLink(href = '', content) {
  175. const opt = {
  176. tag: 'a',
  177. target: '_blank',
  178. rel: 'noopener',
  179. };
  180. if (typeof href === 'object') {
  181. Object.assign(opt, href);
  182. } else {
  183. opt.href = href;
  184. }
  185. opt.appendChild = opt.appendChild || content;
  186. return $create(opt);
  187. }
  188. /**
  189. * @param {HTMLElement} el
  190. * @param {string} [cls] - class name that defines or starts an animation
  191. * @param [removeExtraClasses] - class names to remove at animation end in the *same* paint frame,
  192. * which is needed in e.g. Firefox as it may call resolve() in the next frame
  193. * @returns {Promise<void>}
  194. */
  195. function animateElement(el, cls = 'highlight', ...removeExtraClasses) {
  196. return !el ? Promise.resolve(el) : new Promise(resolve => {
  197. let onDone = () => {
  198. el.classList.remove(cls, ...removeExtraClasses);
  199. onDone = null;
  200. resolve();
  201. };
  202. requestAnimationFrame(() => {
  203. if (onDone) {
  204. const style = getComputedStyle(el);
  205. if (style.animationName === 'none' || !parseFloat(style.animationDuration)) {
  206. el.off('animationend', onDone);
  207. onDone();
  208. }
  209. }
  210. });
  211. el.on('animationend', onDone, {once: true});
  212. el.classList.add(cls);
  213. });
  214. }
  215. function getEventKeyName(e, letterAsCode) {
  216. const mods =
  217. (e.shiftKey ? 'Shift-' : '') +
  218. (e.ctrlKey ? 'Ctrl-' : '') +
  219. (e.altKey ? 'Alt-' : '') +
  220. (e.metaKey ? 'Meta-' : '');
  221. return `${
  222. mods === e.key + '-' ? '' : mods
  223. }${
  224. e.key
  225. ? e.key.length === 1 && letterAsCode ? e.code : e.key
  226. : 'Mouse' + ('LMR'[e.button] || e.button)
  227. }`;
  228. }
  229. /**
  230. * Switches to the next/previous keyboard-focusable element.
  231. * Doesn't check `visibility` or `display` via getComputedStyle for simplicity.
  232. * @param {HTMLElement} rootElement
  233. * @param {Number} step - for exmaple 1 or -1 (or 0 to focus the first focusable el in the box)
  234. * @returns {HTMLElement|false|undefined} -
  235. * HTMLElement: focus changed,
  236. * false: focus unchanged,
  237. * undefined: nothing to focus
  238. */
  239. function moveFocus(rootElement, step) {
  240. const elements = [...rootElement.getElementsByTagName('*')];
  241. const activeEl = document.activeElement;
  242. const activeIndex = step ? Math.max(step < 0 ? 0 : -1, elements.indexOf(activeEl)) : -1;
  243. const num = elements.length;
  244. if (!step) step = 1;
  245. for (let i = 1; i <= num; i++) {
  246. const el = elements[(activeIndex + i * step + num) % num];
  247. if (!el.disabled && el.tabIndex >= 0) {
  248. el.focus();
  249. return activeEl !== el && el;
  250. }
  251. }
  252. }
  253. function onDOMready() {
  254. return document.readyState !== 'loading'
  255. ? Promise.resolve()
  256. : new Promise(resolve => document.on('DOMContentLoaded', resolve, {once: true}));
  257. }
  258. function scrollElementIntoView(element, {invalidMarginRatio = 0} = {}) {
  259. // align to the top/bottom of the visible area if wasn't visible
  260. if (!element.parentNode) return;
  261. const {top, height} = element.getBoundingClientRect();
  262. const {top: parentTop, bottom: parentBottom} = element.parentNode.getBoundingClientRect();
  263. const windowHeight = window.innerHeight;
  264. if (top < Math.max(parentTop, windowHeight * invalidMarginRatio) ||
  265. top > Math.min(parentBottom, windowHeight) - height - windowHeight * invalidMarginRatio) {
  266. window.scrollBy(0, top - windowHeight / 2 + height);
  267. }
  268. }
  269. /**
  270. * Accepts an array of pref names (values are fetched via prefs.get)
  271. * and establishes a two-way connection between the document elements and the actual prefs
  272. */
  273. function setupLivePrefs(ids = prefs.knownKeys.filter(id => $(`#${CSS.escape(id)}, [name=${CSS.escape(id)}]`))) {
  274. let forceUpdate = true;
  275. prefs.subscribe(ids, updateElement, {runNow: true});
  276. forceUpdate = false;
  277. for (const id of ids) {
  278. const elements = $$(`#${CSS.escape(id)}, [name=${CSS.escape(id)}]`);
  279. for (const element of elements) {
  280. element.addEventListener('change', onChange);
  281. }
  282. }
  283. function onChange() {
  284. if (!this.checkValidity()) {
  285. return;
  286. }
  287. if (this.type === 'radio' && !this.checked) {
  288. return;
  289. }
  290. prefs.set(this.id || this.name, getValue(this));
  291. }
  292. function getValue(el) {
  293. const type = el.dataset.valueType || el.type;
  294. return type === 'checkbox' ? el.checked :
  295. // https://stackoverflow.com/questions/18062069/why-does-valueasnumber-return-nan-as-a-value
  296. // valueAsNumber is not applicable for input[text/radio] or select
  297. type === 'number' ? Number(el.value) :
  298. el.value;
  299. }
  300. function isSame(el, oldValue, value) {
  301. return oldValue === value ||
  302. typeof value === 'boolean' &&
  303. el.tagName === 'SELECT' &&
  304. oldValue === `${value}` ||
  305. el.type === 'radio' && (oldValue === value) === el.checked;
  306. }
  307. function updateElement(id, value) {
  308. const els = $$(`#${CSS.escape(id)}, [name=${CSS.escape(id)}]`);
  309. if (!els.length) {
  310. // FIXME: why do we unsub all ids when a single id is missing from the page
  311. prefs.unsubscribe(ids, updateElement);
  312. return;
  313. }
  314. for (const el of els) {
  315. const oldValue = getValue(el);
  316. if (!isSame(el, oldValue, value) || forceUpdate) {
  317. if (el.type === 'radio') {
  318. el.checked = value === oldValue;
  319. } else if (el.type === 'checkbox') {
  320. el.checked = value;
  321. } else {
  322. el.value = value;
  323. }
  324. el.dispatchEvent(new Event('change', {bubbles: true}));
  325. }
  326. }
  327. }
  328. }
  329. /** @param {string|Node} parent - selector or DOM node */
  330. async function showSpinner(parent) {
  331. await require(['/spinner.css']);
  332. parent = parent instanceof Node ? parent : $(parent);
  333. parent.appendChild($create('.lds-spinner',
  334. new Array(12).fill($create('div')).map(e => e.cloneNode())));
  335. }
  336. function toggleDataset(el, prop, state) {
  337. const wasEnabled = el.dataset[prop] != null; // avoids mutating DOM unnecessarily
  338. if (state) {
  339. if (!wasEnabled) el.dataset[prop] = '';
  340. } else {
  341. if (wasEnabled) delete el.dataset[prop];
  342. }
  343. }
  344. /**
  345. * @param {string} selector - beware of $ quirks with `#dotted.id` that won't work with $$
  346. * @param {Object} [opt]
  347. * @param {function(HTMLElement, HTMLElement[]):boolean} [opt.recur] - called on each match
  348. with (firstMatchingElement, allMatchingElements) parameters until stopOnDomReady,
  349. you can also return `false` to disconnect the observer
  350. * @param {boolean} [opt.stopOnDomReady] - stop observing on DOM ready
  351. * @returns {Promise<HTMLElement>} - resolves on first match
  352. */
  353. function waitForSelector(selector, {recur, stopOnDomReady = true} = {}) {
  354. let el = $(selector);
  355. let elems, isResolved;
  356. return el && (!recur || recur(el, (elems = $$(selector))) === false)
  357. ? Promise.resolve(el)
  358. : new Promise(resolve => {
  359. const mo = new MutationObserver(() => {
  360. if (!el) el = $(selector);
  361. if (!el) return;
  362. if (!recur ||
  363. callRecur() === false ||
  364. stopOnDomReady && document.readyState === 'complete') {
  365. mo.disconnect();
  366. }
  367. if (!isResolved) {
  368. isResolved = true;
  369. resolve(el);
  370. }
  371. });
  372. mo.observe(document, {childList: true, subtree: true});
  373. });
  374. function callRecur() {
  375. const all = $$(selector); // simpler and faster than analyzing each node in `mutations`
  376. const added = !elems ? all : all.filter(el => !elems.includes(el));
  377. if (added.length) {
  378. elems = all;
  379. return recur(added[0], added);
  380. }
  381. }
  382. }
  383. /**
  384. * Forcing layout while the main stylesheet is still loading breaks page appearance
  385. * so we'll wait until it loads (0-1 frames in Chrome, Firefox occasionally needs 2-3).
  386. */
  387. async function waitForSheet({
  388. href = location.pathname.replace('.html', '.css'),
  389. maxFrames = FIREFOX ? 10 : 1,
  390. } = {}) {
  391. const el = $(`link[href$="${href}"]`);
  392. for (let i = 0; i < maxFrames && !el.sheet; i++) {
  393. await new Promise(requestAnimationFrame);
  394. }
  395. }
  396. //#endregion
  397. //#region Internals
  398. (() => {
  399. const Collapsible = {
  400. bindEvents(_, elems) {
  401. const prefKeys = [];
  402. for (const el of elems) {
  403. prefKeys.push(el.dataset.pref);
  404. ($('h2', el) || el).on('click', Collapsible.saveOnClick);
  405. }
  406. prefs.subscribe(prefKeys, Collapsible.updateOnPrefChange, {runNow: true});
  407. },
  408. canSave(el) {
  409. return !el.matches('.compact-layout .ignore-pref-if-compact');
  410. },
  411. async saveOnClick(event) {
  412. if (event.target.closest('.intercepts-click')) {
  413. event.preventDefault();
  414. } else {
  415. const el = event.target.closest('details');
  416. await new Promise(setTimeout);
  417. if (Collapsible.canSave(el)) {
  418. prefs.set(el.dataset.pref, el.open);
  419. }
  420. }
  421. },
  422. updateOnPrefChange(key, value) {
  423. const el = $(`details[data-pref="${key}"]`);
  424. if (el.open !== value && Collapsible.canSave(el)) {
  425. el.open = value;
  426. }
  427. },
  428. };
  429. window.on('mousedown', suppressFocusRingOnClick, {passive: true});
  430. window.on('keydown', keepFocusRingOnTabbing, {passive: true});
  431. if (!/^Win\d+/.test(navigator.platform)) {
  432. document.documentElement.classList.add('non-windows');
  433. }
  434. // set language for a) CSS :lang pseudo and b) hyphenation
  435. document.documentElement.setAttribute('lang', chrome.i18n.getUILanguage());
  436. document.on('keypress', clickDummyLinkOnEnter);
  437. document.on('wheel', changeFocusedInputOnWheel, {capture: true, passive: false});
  438. document.on('click', showTooltipNote);
  439. Promise.resolve().then(async () => {
  440. if (!chrome.app) addFaviconFF();
  441. await prefs.ready;
  442. waitForSelector('details[data-pref]', {recur: Collapsible.bindEvents});
  443. });
  444. onDOMready().then(() => {
  445. splitLongTooltips();
  446. debounce(addTooltipsToEllipsized, 500);
  447. window.on('resize', () => debounce(addTooltipsToEllipsized, 100));
  448. });
  449. window.on('load', () => {
  450. const {sheet} = $('link[href^="global.css"]');
  451. for (let i = 0, rule; (rule = sheet.cssRules[i]); i++) {
  452. if (/#\\1\s?transition-suppressor/.test(rule.selectorText)) {
  453. sheet.deleteRule(i);
  454. break;
  455. }
  456. }
  457. }, {once: true});
  458. function addFaviconFF() {
  459. const iconset = ['', 'light/'][prefs.get('iconset')] || '';
  460. for (const size of [38, 32, 19, 16]) {
  461. document.head.appendChild($create('link', {
  462. rel: 'icon',
  463. href: `/images/icon/${iconset}${size}.png`,
  464. sizes: size + 'x' + size,
  465. }));
  466. }
  467. }
  468. function changeFocusedInputOnWheel(event) {
  469. const el = document.activeElement;
  470. if (!el || el !== event.target && !el.contains(event.target)) {
  471. return;
  472. }
  473. const isSelect = el.tagName === 'SELECT';
  474. if (isSelect || el.tagName === 'INPUT' && el.type === 'range') {
  475. const key = isSelect ? 'selectedIndex' : 'valueAsNumber';
  476. const old = el[key];
  477. const rawVal = old + Math.sign(event.deltaY) * (el.step || 1);
  478. el[key] = Math.max(el.min || 0, Math.min(el.max || el.length - 1, rawVal));
  479. if (el[key] !== old) {
  480. el.dispatchEvent(new Event('change', {bubbles: true}));
  481. }
  482. event.preventDefault();
  483. }
  484. event.stopImmediatePropagation();
  485. }
  486. /** Displays a full text tooltip on buttons with ellipsis overflow and no inherent title */
  487. function addTooltipsToEllipsized() {
  488. for (const btn of document.getElementsByTagName('button')) {
  489. if (btn.title && !btn.titleIsForEllipsis) {
  490. continue;
  491. }
  492. const width = btn.offsetWidth;
  493. if (!width || btn.preresizeClientWidth === width) {
  494. continue;
  495. }
  496. btn.preresizeClientWidth = width;
  497. if (btn.scrollWidth > width) {
  498. const text = btn.textContent;
  499. btn.title = text.includes('\u00AD') ? text.replace(/\u00AD/g, '') : text;
  500. btn.titleIsForEllipsis = true;
  501. } else if (btn.title) {
  502. btn.title = '';
  503. }
  504. }
  505. }
  506. function clickDummyLinkOnEnter(e) {
  507. if (getEventKeyName(e) === 'Enter') {
  508. const a = e.target.closest('a');
  509. const isDummy = a && !a.href && a.tabIndex === 0;
  510. if (isDummy) a.dispatchEvent(new MouseEvent('click', {bubbles: true}));
  511. }
  512. }
  513. function keepFocusRingOnTabbing(event) {
  514. if (event.key === 'Tab' && !event.ctrlKey && !event.altKey && !event.metaKey) {
  515. focusAccessibility.lastFocusedViaClick = false;
  516. setTimeout(() => {
  517. let el = document.activeElement;
  518. if (el) {
  519. el = el.closest('[data-focused-via-click]');
  520. if (el) delete el.dataset.focusedViaClick;
  521. }
  522. });
  523. }
  524. }
  525. function suppressFocusRingOnClick({target}) {
  526. const el = focusAccessibility.closest(target);
  527. if (el) {
  528. focusAccessibility.lastFocusedViaClick = true;
  529. if (el.dataset.focusedViaClick === undefined) {
  530. el.dataset.focusedViaClick = '';
  531. }
  532. }
  533. }
  534. function showTooltipNote(event) {
  535. const el = event.target.closest('[data-cmd=note]');
  536. if (el) {
  537. event.preventDefault();
  538. window.messageBoxProxy.show({
  539. className: 'note center-dialog',
  540. contents: el.dataset.title || el.title,
  541. buttons: [t('confirmClose')],
  542. });
  543. }
  544. }
  545. function splitLongTooltips() {
  546. for (const el of $$('[title]')) {
  547. el.dataset.title = el.title;
  548. el.title = el.title.replace(/<\/?\w+>/g, ''); // strip html tags
  549. if (el.title.length < 50) {
  550. continue;
  551. }
  552. const newTitle = el.title
  553. .split('\n')
  554. .map(s => s.replace(/([^.][.。?!]|.{50,60},)\s+/g, '$1\n'))
  555. .map(s => s.replace(/(.{50,80}(?=.{40,}))\s+/g, '$1\n'))
  556. .join('\n');
  557. if (newTitle !== el.title) el.title = newTitle;
  558. }
  559. }
  560. })();
  561. //#endregion