moz-section-widget.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. /* global $ $create messageBoxProxy */// dom.js
  2. /* global CodeMirror */
  3. /* global MozSectionFinder */
  4. /* global colorMimicry */
  5. /* global editor */
  6. /* global msg */
  7. /* global prefs */
  8. /* global t */// localization.js
  9. /* global tryCatch */// toolbox.js
  10. 'use strict';
  11. /* exported MozSectionWidget */
  12. function MozSectionWidget(cm, finder = MozSectionFinder(cm)) {
  13. let TPL, EVENTS, CLICK_ROUTE;
  14. const KEY = 'MozSectionWidget';
  15. const C_CONTAINER = '.applies-to';
  16. const C_LABEL = 'label';
  17. const C_LIST = '.applies-to-list';
  18. const C_ITEM = '.applies-to-item';
  19. const C_TYPE = '.applies-type';
  20. const C_VALUE = '.applies-value';
  21. /** @returns {MarkedFunc} */
  22. const getFuncFor = el => el.closest(C_ITEM)[KEY];
  23. /** @returns {MarkedFunc[]} */
  24. const getFuncsFor = el => el.closest(C_LIST)[KEY];
  25. /** @returns {MozSection} */
  26. const getSectionFor = el => el.closest(C_CONTAINER)[KEY];
  27. const {cmpPos} = CodeMirror;
  28. let enabled = false;
  29. let funcHeight = 0;
  30. /** @type {HTMLStyleElement} */
  31. let actualStyle;
  32. return {
  33. toggle(enable) {
  34. if (Boolean(enable) !== enabled) {
  35. (enable ? init : destroy)();
  36. }
  37. },
  38. };
  39. function init() {
  40. enabled = true;
  41. TPL = {
  42. container:
  43. $create('div' + C_CONTAINER, [
  44. $create(C_LABEL, t('appliesLabel')),
  45. $create('ul' + C_LIST),
  46. ]),
  47. listItem:
  48. t.template.appliesTo.cloneNode(true),
  49. appliesToEverything:
  50. $create('li.applies-to-everything', t('appliesToEverything')),
  51. };
  52. $(C_VALUE, TPL.listItem).after(
  53. $create('button.test-regexp', t('styleRegexpTestButton')));
  54. CLICK_ROUTE = {
  55. '.test-regexp': showRegExpTester,
  56. /**
  57. * @param {HTMLElement} elItem
  58. * @param {MarkedFunc} func
  59. */
  60. '.remove-applies-to'(elItem, func) {
  61. const funcs = getFuncsFor(elItem);
  62. if (funcs.length < 2) {
  63. messageBoxProxy.show({
  64. contents: t('appliesRemoveError'),
  65. buttons: [t('confirmClose')],
  66. });
  67. return;
  68. }
  69. const i = funcs.indexOf(func);
  70. const next = funcs[i + 1];
  71. const from = i ? funcs[i - 1].item.find(1) : func.item.find(-1);
  72. const to = next ? next.item.find(-1) : func.item.find(1);
  73. cm.replaceRange(i && next ? ', ' : '', from, to);
  74. },
  75. /**
  76. * @param {HTMLElement} elItem
  77. * @param {MarkedFunc} func
  78. */
  79. '.add-applies-to'(elItem, func) {
  80. const pos = func.item.find(1);
  81. cm.replaceRange(`, ${func.typeText}("")`, pos, pos);
  82. },
  83. };
  84. EVENTS = {
  85. onchange({target: el}) {
  86. EVENTS.oninput({target: el.closest(C_TYPE) || el});
  87. },
  88. oninput({target: el}) {
  89. const part =
  90. el.matches(C_VALUE) && 'value' ||
  91. el.matches(C_TYPE) && 'type';
  92. if (!part) return;
  93. const func = getFuncFor(el);
  94. const pos = func[part].find();
  95. if (part === 'type' && el.value !== func.typeText) {
  96. func.typeText = func.item[KEY].dataset.type = el.value;
  97. }
  98. if (part === 'value' && func === getFuncsFor(el)[0]) {
  99. const sec = getSectionFor(el);
  100. sec.tocEntry.target = el.value;
  101. if (!sec.tocEntry.label) editor.updateToc([sec]);
  102. }
  103. cm.replaceRange(toDoubleslash(el.value), pos.from, pos.to, finder.IGNORE_ORIGIN);
  104. },
  105. onclick(event) {
  106. const {target} = event;
  107. for (const selector in CLICK_ROUTE) {
  108. const routed = target.closest(selector);
  109. if (routed) {
  110. const elItem = routed.closest(C_ITEM);
  111. CLICK_ROUTE[selector](elItem, elItem[KEY], event);
  112. return;
  113. }
  114. }
  115. },
  116. };
  117. actualStyle = $create('style');
  118. cm.on('optionChange', onCmOption);
  119. msg.onExtension(onRuntimeMessage);
  120. if (finder.sections.length) {
  121. update(finder.sections, []);
  122. }
  123. finder.on(update);
  124. updateWidgetStyle(); // updating in this paint frame to avoid FOUC for dark themes
  125. cm.display.wrapper.style.setProperty('--cm-bar-width', cm.display.barWidth + 'px');
  126. }
  127. function destroy() {
  128. enabled = false;
  129. cm.off('optionChange', onCmOption);
  130. msg.off(onRuntimeMessage);
  131. actualStyle.remove();
  132. actualStyle = null;
  133. cm.operation(() => finder.sections.forEach(killWidget));
  134. finder.off(update);
  135. }
  136. function onCmOption(cm, option) {
  137. if (option === 'theme') {
  138. updateWidgetStyle();
  139. }
  140. }
  141. function onRuntimeMessage(msg) {
  142. if (msg.reason === 'editPreview' && !$(`#stylus-${msg.style.id}`)) {
  143. // no style element with this id means the style doesn't apply to the editor URL
  144. return;
  145. }
  146. if (msg.style || msg.styles ||
  147. msg.prefs && 'disableAll' in msg.prefs ||
  148. msg.method === 'styleDeleted') {
  149. requestAnimationFrame(updateWidgetStyle);
  150. }
  151. }
  152. function updateWidgetStyle() {
  153. funcHeight = 0;
  154. if (prefs.get('editor.theme') !== 'default' &&
  155. !tryCatch(() => $('#cm-theme').sheet.cssRules)) {
  156. requestAnimationFrame(updateWidgetStyle);
  157. return;
  158. }
  159. const MIN_LUMA = .05;
  160. const MIN_LUMA_DIFF = .4;
  161. const color = {
  162. wrapper: colorMimicry(cm.display.wrapper),
  163. gutter: colorMimicry(cm.display.gutters, {
  164. bg: 'backgroundColor',
  165. border: 'borderRightColor',
  166. }),
  167. line: colorMimicry('.CodeMirror-linenumber', null, cm.display.lineDiv),
  168. comment: colorMimicry('span.cm-comment', null, cm.display.lineDiv),
  169. };
  170. const hasBorder =
  171. color.gutter.style.borderRightWidth !== '0px' &&
  172. !/transparent|\b0\)/g.test(color.gutter.style.borderRightColor);
  173. const diff = {
  174. wrapper: Math.abs(color.gutter.bgLuma - color.wrapper.foreLuma),
  175. border: hasBorder ? Math.abs(color.gutter.bgLuma - color.gutter.borderLuma) : 0,
  176. line: Math.abs(color.gutter.bgLuma - color.line.foreLuma),
  177. };
  178. const preferLine = diff.line > diff.wrapper || diff.line > MIN_LUMA_DIFF;
  179. const fore = preferLine ? color.line.fore : color.wrapper.fore;
  180. const border = fore.replace(/[\d.]+(?=\))/, MIN_LUMA_DIFF / 2);
  181. const borderStyleForced = `1px ${hasBorder ? color.gutter.style.borderRightStyle : 'solid'} ${border}`;
  182. actualStyle.textContent = `
  183. ${C_CONTAINER} {
  184. background-color: ${color.gutter.bg};
  185. border-top: ${borderStyleForced};
  186. border-bottom: ${borderStyleForced};
  187. }
  188. ${C_CONTAINER} ${C_LABEL} {
  189. color: ${fore};
  190. }
  191. ${C_CONTAINER} input,
  192. ${C_CONTAINER} button,
  193. ${C_CONTAINER} select {
  194. background: rgba(255, 255, 255, ${
  195. Math.max(MIN_LUMA, Math.pow(Math.max(0, color.gutter.bgLuma - MIN_LUMA * 2), 2)).toFixed(2)
  196. });
  197. border: ${borderStyleForced};
  198. transition: none;
  199. color: ${fore};
  200. }
  201. ${C_CONTAINER} .svg-icon.select-arrow {
  202. fill: ${fore};
  203. transition: none;
  204. }
  205. `;
  206. document.documentElement.appendChild(actualStyle);
  207. }
  208. /**
  209. * @param {MozSection[]} added
  210. * @param {MozSection[]} removed
  211. * @param {number} cutAt
  212. */
  213. function update(added, removed, cutAt = finder.sections.indexOf(added[0])) {
  214. const isDelayed = added.isDelayed && (cm.startOperation(), true);
  215. const toDelay = [];
  216. const t0 = performance.now();
  217. let {viewFrom, viewTo} = cm.display;
  218. for (const sec of added) {
  219. const i = removed.findIndex(isReusableWidget, sec);
  220. const old = removed[i];
  221. if (isDelayed || old || sec.end.line >= viewFrom && sec.start.line < viewTo) {
  222. renderWidget(sec, old);
  223. viewTo -= (sec.funcs.length || 1) * 1.25;
  224. if (old) removed[i] = null;
  225. if (performance.now() - t0 > 50) {
  226. toDelay.push(...added.slice(added.indexOf(sec) + 1));
  227. break;
  228. }
  229. } else {
  230. toDelay.push(sec);
  231. }
  232. }
  233. // renumber
  234. for (let i = Math.max(0, cutAt), {sections} = finder, sec; (sec = sections[i++]);) {
  235. if (!toDelay.includes(sec)) {
  236. const data = $(C_LABEL, sec.widget.node).dataset;
  237. if (data.index !== `${i}`) data.index = `${i}`;
  238. }
  239. }
  240. if (toDelay.length) {
  241. toDelay.isDelayed = true;
  242. setTimeout(update, 0, toDelay, removed);
  243. } else {
  244. removed.forEach(killWidget);
  245. }
  246. if (isDelayed) cm.endOperation();
  247. }
  248. /** @this {MozSection} */
  249. function isReusableWidget(r) {
  250. return r &&
  251. r.widget &&
  252. r.widget.line.parent &&
  253. r.start &&
  254. !cmpPos(r.start, this.start);
  255. }
  256. function renderWidget(sec, old) {
  257. let widget = old && old.widget;
  258. const height = Math.round(funcHeight * (sec.funcs.length || 1)) || undefined;
  259. const node = renderContainer(sec, widget);
  260. if (widget) {
  261. widget.node = node;
  262. if (height && height !== widget.height) {
  263. widget.height = height;
  264. widget.changed();
  265. }
  266. } else {
  267. widget = cm.addLineWidget(sec.start.line, node, {
  268. coverGutter: true,
  269. noHScroll: true,
  270. above: true,
  271. height,
  272. });
  273. widget.on('redraw', () => {
  274. const value = cm.display.barWidth + 'px';
  275. if (widget[KEY] !== value) {
  276. widget[KEY] = value;
  277. node.style.setProperty('--cm-bar-width', value);
  278. }
  279. });
  280. }
  281. if (!funcHeight) {
  282. funcHeight = node.offsetHeight / (sec.funcs.length || 1);
  283. }
  284. setProp(sec, 'widget', widget);
  285. return widget;
  286. }
  287. /**
  288. * @param {MozSection} sec
  289. * @param {LineWidget} oldWidget
  290. * @returns {Node}
  291. */
  292. function renderContainer(sec, oldWidget) {
  293. const container = oldWidget ? oldWidget.node : TPL.container.cloneNode(true);
  294. const elList = $(C_LIST, container);
  295. const {funcs} = sec;
  296. const oldItems = elList[KEY] || false;
  297. const items = funcs.map((f, i) => renderFunc(f, oldItems[i]));
  298. let slot = elList.firstChild;
  299. for (const {item} of items) {
  300. const el = item[KEY];
  301. if (el !== slot) {
  302. elList.insertBefore(el, slot);
  303. if (slot) slot.remove();
  304. slot = el;
  305. }
  306. slot = slot.nextSibling;
  307. }
  308. for (let i = funcs.length; oldItems && i < oldItems.length; i++) {
  309. killFunc(oldItems[i]);
  310. if (slot) {
  311. const el = slot.nextSibling;
  312. slot.remove();
  313. slot = el;
  314. }
  315. }
  316. if (!funcs.length && (!oldItems || oldItems.length)) {
  317. TPL.appliesToEverything.cloneNode(true);
  318. }
  319. setProp(sec, 'widgetFuncs', items);
  320. elList[KEY] = items;
  321. container[KEY] = sec;
  322. container.classList.toggle('error', !sec.funcs.length);
  323. return Object.assign(container, EVENTS);
  324. }
  325. /**
  326. * @param {MozSectionFunc} func
  327. * @param {MarkedFunc} old
  328. * @returns {MarkedFunc}
  329. */
  330. function renderFunc(func, old = {}) {
  331. const {
  332. type,
  333. value,
  334. isQuoted = false,
  335. start,
  336. start: {line},
  337. typeEnd = {line, ch: start.ch + type.length},
  338. valuePos = {line, ch: typeEnd.ch + 1 + Boolean(isQuoted)},
  339. valueEnd = {line, ch: valuePos.ch + value.length},
  340. end = {line, ch: valueEnd.ch + Boolean(isQuoted) + 1},
  341. } = func;
  342. const el = (old.item || {})[KEY] || TPL.listItem.cloneNode(true);
  343. /** @namespace MarkedFunc */
  344. const res = el[KEY] = {
  345. typeText: type,
  346. item: markFuncPart(start, end, old.item, el),
  347. type: markFuncPart(start, typeEnd, old.type, $(C_TYPE, el), type, toLowerCase),
  348. value: markFuncPart(valuePos, valueEnd, old.value, $(C_VALUE, el), value, fromDoubleslash),
  349. };
  350. if (el.dataset.type !== type) {
  351. el.dataset.type = type;
  352. }
  353. return res;
  354. }
  355. /**
  356. * @param {CodeMirror.Pos} start
  357. * @param {CodeMirror.Pos} end
  358. * @param {TextMarker} marker
  359. * @param {HTMLElement} el
  360. * @param {string} [text]
  361. * @param {function} [textTransform]
  362. * @returns {TextMarker}
  363. */
  364. function markFuncPart(start, end, marker, el, text, textTransform) {
  365. if (marker) {
  366. const pos = marker.find();
  367. if (!pos ||
  368. cmpPos(pos.from, start) ||
  369. cmpPos(pos.to, end) ||
  370. text != null && text !== cm.getRange(start, end)) {
  371. marker.clear();
  372. marker = null;
  373. }
  374. }
  375. if (!marker) {
  376. marker = cm.markText(start, end, {
  377. clearWhenEmpty: false,
  378. inclusiveLeft: true,
  379. inclusiveRight: true,
  380. [KEY]: el,
  381. });
  382. }
  383. if (text != null) {
  384. text = textTransform(text);
  385. if (el.value !== text) el.value = text;
  386. }
  387. return marker;
  388. }
  389. /** @type {MozSection} sec */
  390. function killWidget(sec) {
  391. const w = sec && sec.widget;
  392. if (w) {
  393. w.clear();
  394. w.node[KEY].widgetFuncs.forEach(killFunc);
  395. }
  396. }
  397. /** @type {MarkedFunc} f */
  398. function killFunc(f) {
  399. f.item.clear();
  400. f.type.clear();
  401. f.value.clear();
  402. }
  403. async function showRegExpTester(el) {
  404. /* global regexpTester */
  405. await require(['/edit/regexp-tester']);
  406. const reFuncs = getFuncsFor(el).filter(f => f.typeText === 'regexp');
  407. regexpTester.toggle(true);
  408. regexpTester.update(reFuncs.map(f => fromDoubleslash(f.value[KEY].value)));
  409. }
  410. function fromDoubleslash(s) {
  411. return /([^\\]|^)\\([^\\]|$)/.test(s) ? s : s.replace(/\\\\/g, '\\');
  412. }
  413. function toDoubleslash(s) {
  414. return fromDoubleslash(s).replace(/\\/g, '\\\\');
  415. }
  416. function toLowerCase(s) {
  417. return s.toLowerCase();
  418. }
  419. /** Adds a non-enumerable property so it won't be seen by deepEqual */
  420. function setProp(obj, name, value) {
  421. return Object.defineProperty(obj, name, {value, configurable: true});
  422. }
  423. }