sections-editor-section.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. /* global $ */// dom.js
  2. /* global MozDocMapper trimCommentLabel */// util.js
  3. /* global cmFactory */
  4. /* global debounce tryRegExp */// toolbox.js
  5. /* global editor */
  6. /* global initBeautifyButton */// beautify.js
  7. /* global linterMan */
  8. /* global prefs */
  9. /* global t */// localization.js
  10. 'use strict';
  11. /* exported createSection */
  12. /**
  13. * @param {StyleSection} originalSection
  14. * @param {function():number} genId
  15. * @param {EditorScrollInfo} [si]
  16. * @returns {EditorSection}
  17. */
  18. function createSection(originalSection, genId, si) {
  19. const {dirty} = editor;
  20. const sectionId = genId();
  21. const el = t.template.section.cloneNode(true);
  22. const elLabel = $('.code-label', el);
  23. const cm = cmFactory.create(wrapper => {
  24. // making it tall during initial load so IntersectionObserver sees only one adjacent CM
  25. if (editor.ready !== true) {
  26. wrapper.style.height = si ? si.height : '100vh';
  27. }
  28. elLabel.after(wrapper);
  29. }, {
  30. value: originalSection.code,
  31. });
  32. el.CodeMirror = cm; // used by getAssociatedEditor
  33. editor.applyScrollInfo(cm, si);
  34. const changeListeners = new Set();
  35. const appliesToContainer = $('.applies-to-list', el);
  36. const appliesTo = [];
  37. MozDocMapper.forEachProp(originalSection, (type, value) =>
  38. insertApplyAfter({type, value}));
  39. if (!appliesTo.length) {
  40. insertApplyAfter({all: true});
  41. }
  42. let changeGeneration = cm.changeGeneration();
  43. let removed = false;
  44. registerEvents();
  45. updateRegexpTester();
  46. createResizeGrip(cm);
  47. /** @namespace EditorSection */
  48. const section = {
  49. id: sectionId,
  50. el,
  51. cm,
  52. appliesTo,
  53. getModel() {
  54. const items = appliesTo.map(a => !a.all && [a.type, a.value]);
  55. return MozDocMapper.toSection(items, {code: cm.getValue()});
  56. },
  57. remove() {
  58. linterMan.disableForEditor(cm);
  59. el.classList.add('removed');
  60. removed = true;
  61. appliesTo.forEach(a => a.remove());
  62. },
  63. render() {
  64. cm.refresh();
  65. },
  66. destroy() {
  67. cmFactory.destroy(cm);
  68. },
  69. restore() {
  70. linterMan.enableForEditor(cm);
  71. el.classList.remove('removed');
  72. removed = false;
  73. appliesTo.forEach(a => a.restore());
  74. cm.refresh();
  75. },
  76. onChange(fn) {
  77. changeListeners.add(fn);
  78. },
  79. off(fn) {
  80. changeListeners.delete(fn);
  81. },
  82. get removed() {
  83. return removed;
  84. },
  85. tocEntry: {
  86. label: '',
  87. get removed() {
  88. return removed;
  89. },
  90. },
  91. };
  92. prefs.subscribe('editor.toc.expanded', updateTocPrefToggled, {runNow: true});
  93. return section;
  94. function emitSectionChange(origin) {
  95. for (const fn of changeListeners) {
  96. fn(origin);
  97. }
  98. }
  99. function registerEvents() {
  100. cm.on('changes', () => {
  101. const newGeneration = cm.changeGeneration();
  102. dirty.modify(`section.${sectionId}.code`, changeGeneration, newGeneration);
  103. changeGeneration = newGeneration;
  104. emitSectionChange('code');
  105. });
  106. cm.display.wrapper.on('keydown', event => handleKeydown(cm, event), true);
  107. $('.test-regexp', el).onclick = () => updateRegexpTester(true);
  108. initBeautifyButton($('.beautify-section', el), [cm]);
  109. }
  110. function handleKeydown(cm, event) {
  111. if (event.shiftKey || event.altKey || event.metaKey) {
  112. return;
  113. }
  114. const {key} = event;
  115. const {line, ch} = cm.getCursor();
  116. switch (key) {
  117. case 'ArrowLeft':
  118. if (line || ch) {
  119. return;
  120. }
  121. // fallthrough
  122. case 'ArrowUp':
  123. cm = line === 0 && editor.prevEditor(cm, false);
  124. if (!cm) {
  125. return;
  126. }
  127. event.preventDefault();
  128. event.stopPropagation();
  129. cm.setCursor(cm.doc.size - 1, key === 'ArrowLeft' ? 1e20 : ch);
  130. break;
  131. case 'ArrowRight':
  132. if (line < cm.doc.size - 1 || ch < cm.getLine(line).length - 1) {
  133. return;
  134. }
  135. // fallthrough
  136. case 'ArrowDown':
  137. cm = line === cm.doc.size - 1 && editor.nextEditor(cm, false);
  138. if (!cm) {
  139. return;
  140. }
  141. event.preventDefault();
  142. event.stopPropagation();
  143. cm.setCursor(0, 0);
  144. break;
  145. }
  146. }
  147. async function updateRegexpTester(toggle) {
  148. const isLoaded = typeof regexpTester === 'object';
  149. if (toggle && !isLoaded) {
  150. await require(['/edit/regexp-tester']); /* global regexpTester */
  151. }
  152. if (toggle != null && isLoaded) {
  153. regexpTester.toggle(toggle);
  154. }
  155. const regexps = appliesTo.filter(a => a.type === 'regexp')
  156. .map(a => a.value);
  157. if (regexps.length) {
  158. el.classList.add('has-regexp');
  159. if (isLoaded) regexpTester.update(regexps);
  160. } else {
  161. el.classList.remove('has-regexp');
  162. if (isLoaded) regexpTester.toggle(false);
  163. }
  164. }
  165. function updateTocEntry(origin) {
  166. const te = section.tocEntry;
  167. let changed;
  168. if (origin === 'code' || !origin) {
  169. const label = getLabelFromComment();
  170. if (te.label !== label) {
  171. te.label = elLabel.dataset.text = label;
  172. changed = true;
  173. }
  174. }
  175. if (!te.label) {
  176. const target = appliesTo[0].all ? null : appliesTo[0].value;
  177. if (te.target !== target) {
  178. te.target = target;
  179. changed = true;
  180. }
  181. if (te.numTargets !== appliesTo.length) {
  182. te.numTargets = appliesTo.length;
  183. changed = true;
  184. }
  185. }
  186. if (changed) editor.updateToc([section]);
  187. }
  188. function updateTocEntryLazy(...args) {
  189. debounce(updateTocEntry, 0, ...args);
  190. }
  191. function updateTocFocus() {
  192. editor.updateToc({focus: true, 0: section});
  193. }
  194. function updateTocPrefToggled(key, val) {
  195. changeListeners[val ? 'add' : 'delete'](updateTocEntryLazy);
  196. (val ? el.on : el.off).call(el, 'focusin', updateTocFocus);
  197. if (val) {
  198. updateTocEntry();
  199. if (el.contains(document.activeElement)) {
  200. updateTocFocus();
  201. }
  202. }
  203. }
  204. function getLabelFromComment() {
  205. let cmt = '';
  206. let inCmt;
  207. cm.eachLine(({text}) => {
  208. let i = 0;
  209. if (!inCmt) {
  210. i = text.search(/\S/);
  211. if (i < 0) return;
  212. inCmt = text[i] === '/' && text[i + 1] === '*';
  213. if (!inCmt) return true;
  214. i += 2;
  215. }
  216. const j = text.indexOf('*/', i);
  217. cmt = trimCommentLabel(text.slice(i, j >= 0 ? j : text.length));
  218. return j >= 0 || cmt;
  219. });
  220. return cmt;
  221. }
  222. function insertApplyAfter(init, base) {
  223. const apply = createApply(init);
  224. appliesTo.splice(base ? appliesTo.indexOf(base) + 1 : appliesTo.length, 0, apply);
  225. appliesToContainer.insertBefore(apply.el, base ? base.el.nextSibling : null);
  226. dirty.add(apply, apply);
  227. if (appliesTo.length > 1 && appliesTo[0].all) {
  228. removeApply(appliesTo[0]);
  229. }
  230. emitSectionChange('apply');
  231. return apply;
  232. }
  233. function removeApply(apply) {
  234. const index = appliesTo.indexOf(apply);
  235. appliesTo.splice(index, 1);
  236. apply.remove();
  237. apply.el.remove();
  238. dirty.remove(apply, apply);
  239. if (!appliesTo.length) {
  240. insertApplyAfter({all: true});
  241. }
  242. emitSectionChange('apply');
  243. }
  244. function createApply({type = 'url', value, all = false}) {
  245. const applyId = genId();
  246. const dirtyPrefix = `section.${sectionId}.apply.${applyId}`;
  247. const el = all ? t.template.appliesToEverything.cloneNode(true) :
  248. t.template.appliesTo.cloneNode(true);
  249. const selectEl = !all && $('.applies-type', el);
  250. if (selectEl) {
  251. selectEl.value = type;
  252. selectEl.on('change', () => {
  253. const oldType = type;
  254. dirty.modify(`${dirtyPrefix}.type`, type, selectEl.value);
  255. type = selectEl.value;
  256. if (oldType === 'regexp' || type === 'regexp') {
  257. updateRegexpTester();
  258. }
  259. emitSectionChange('apply');
  260. validate();
  261. });
  262. }
  263. const valueEl = !all && $('.applies-value', el);
  264. if (valueEl) {
  265. valueEl.value = value;
  266. valueEl.on('input', () => {
  267. dirty.modify(`${dirtyPrefix}.value`, value, valueEl.value);
  268. value = valueEl.value;
  269. if (type === 'regexp') {
  270. updateRegexpTester();
  271. }
  272. emitSectionChange('apply');
  273. });
  274. valueEl.on('change', validate);
  275. }
  276. restore();
  277. const apply = {
  278. id: applyId,
  279. all,
  280. remove,
  281. restore,
  282. el,
  283. valueEl, // used by validator
  284. get type() {
  285. return type;
  286. },
  287. get value() {
  288. return value;
  289. },
  290. };
  291. const removeButton = $('.remove-applies-to', el);
  292. if (removeButton) {
  293. removeButton.on('click', e => {
  294. e.preventDefault();
  295. removeApply(apply);
  296. });
  297. }
  298. $('.add-applies-to', el).on('click', e => {
  299. e.preventDefault();
  300. const newApply = insertApplyAfter({type, value: ''}, apply);
  301. $('input', newApply.el).focus();
  302. });
  303. return apply;
  304. function validate() {
  305. if (type !== 'regexp' || tryRegExp(value)) {
  306. valueEl.setCustomValidity('');
  307. } else {
  308. valueEl.setCustomValidity(t('styleBadRegexp'));
  309. setTimeout(() => valueEl.reportValidity());
  310. }
  311. }
  312. function remove() {
  313. if (all) {
  314. return;
  315. }
  316. dirty.remove(`${dirtyPrefix}.type`, type);
  317. dirty.remove(`${dirtyPrefix}.value`, value);
  318. }
  319. function restore() {
  320. if (all) {
  321. return;
  322. }
  323. dirty.add(`${dirtyPrefix}.type`, type);
  324. dirty.add(`${dirtyPrefix}.value`, value);
  325. }
  326. }
  327. }
  328. function createResizeGrip(cm) {
  329. const wrapper = cm.display.wrapper;
  330. wrapper.classList.add('resize-grip-enabled');
  331. const resizeGrip = t.template.resizeGrip.cloneNode(true);
  332. wrapper.appendChild(resizeGrip);
  333. let lastClickTime = 0;
  334. let lastHeight;
  335. let lastY;
  336. resizeGrip.onmousedown = event => {
  337. lastHeight = wrapper.offsetHeight;
  338. lastY = event.clientY;
  339. if (event.button !== 0) {
  340. return;
  341. }
  342. event.preventDefault();
  343. if (Date.now() - lastClickTime < 500) {
  344. lastClickTime = 0;
  345. toggleSectionHeight(cm);
  346. return;
  347. }
  348. lastClickTime = Date.now();
  349. const minHeight = cm.defaultTextHeight() +
  350. /* .CodeMirror-lines padding */
  351. cm.display.lineDiv.offsetParent.offsetTop +
  352. /* borders */
  353. wrapper.offsetHeight - wrapper.clientHeight;
  354. wrapper.style.pointerEvents = 'none';
  355. document.body.style.cursor = 's-resize';
  356. document.on('mousemove', resize);
  357. document.on('mouseup', resizeStop);
  358. function resize(e) {
  359. const height = Math.max(minHeight, lastHeight + e.clientY - lastY);
  360. if (height !== lastHeight) {
  361. cm.setSize(null, height);
  362. lastHeight = height;
  363. lastY = e.clientY;
  364. }
  365. }
  366. function resizeStop() {
  367. document.off('mouseup', resizeStop);
  368. document.off('mousemove', resize);
  369. wrapper.style.pointerEvents = '';
  370. document.body.style.cursor = '';
  371. }
  372. };
  373. function toggleSectionHeight(cm) {
  374. if (cm.state.toggleHeightSaved) {
  375. // restore previous size
  376. cm.setSize(null, cm.state.toggleHeightSaved);
  377. cm.state.toggleHeightSaved = 0;
  378. } else {
  379. // maximize
  380. const wrapper = cm.display.wrapper;
  381. const allBounds = $('#sections').getBoundingClientRect();
  382. const pageExtrasHeight = allBounds.top + window.scrollY +
  383. parseFloat(getComputedStyle($('#sections')).paddingBottom);
  384. const sectionEl = wrapper.parentNode;
  385. const sectionExtrasHeight = sectionEl.clientHeight - wrapper.offsetHeight;
  386. cm.state.toggleHeightSaved = wrapper.clientHeight;
  387. cm.setSize(null, window.innerHeight - sectionExtrasHeight - pageExtrasHeight);
  388. const bounds = sectionEl.getBoundingClientRect();
  389. if (bounds.top < 0 || bounds.bottom > window.innerHeight) {
  390. window.scrollBy(0, bounds.top);
  391. }
  392. }
  393. }
  394. }