base.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  1. /* global $ $$ $create setupLivePrefs waitForSelector */// dom.js
  2. /* global API */// msg.js
  3. /* global CODEMIRROR_THEMES */
  4. /* global CodeMirror */
  5. /* global MozDocMapper */// sections-util.js
  6. /* global initBeautifyButton */// beautify.js
  7. /* global prefs */
  8. /* global t */// localization.js
  9. /* global
  10. FIREFOX
  11. debounce
  12. getOwnTab
  13. sessionStore
  14. tryJSONparse
  15. tryURL
  16. */// toolbox.js
  17. /* global EventEmitter */
  18. 'use strict';
  19. /**
  20. * @type Editor
  21. * @namespace Editor
  22. */
  23. const editor = Object.assign(EventEmitter(), {
  24. style: null,
  25. dirty: DirtyReporter(),
  26. isUsercss: false,
  27. isWindowed: false,
  28. lazyKeymaps: {
  29. emacs: '/vendor/codemirror/keymap/emacs',
  30. vim: '/vendor/codemirror/keymap/vim',
  31. },
  32. livePreview: null,
  33. /** @type {'customName'|'name'} */
  34. nameTarget: 'name',
  35. previewDelay: 200, // Chrome devtools uses 200
  36. scrollInfo: null,
  37. onStyleUpdated() {
  38. document.documentElement.classList.toggle('is-new-style', !editor.style.id);
  39. },
  40. updateTitle(isDirty = editor.dirty.isDirty()) {
  41. const {customName, name} = editor.style;
  42. document.title = `${
  43. isDirty ? '* ' : ''
  44. }${
  45. customName || name || t('styleMissingName')
  46. } - Stylus`; // the suffix enables external utilities to process our windows e.g. pin on top
  47. },
  48. });
  49. //#region pre-init
  50. const baseInit = (() => {
  51. const domReady = waitForSelector('#sections');
  52. return {
  53. domReady,
  54. ready: Promise.all([
  55. domReady,
  56. loadStyle(),
  57. prefs.ready.then(() =>
  58. Promise.all([
  59. loadTheme(),
  60. loadKeymaps(),
  61. ])),
  62. ]),
  63. };
  64. /** Preloads vim/emacs keymap only if it's the active one, otherwise will load later */
  65. function loadKeymaps() {
  66. const km = prefs.get('editor.keyMap');
  67. return /emacs/i.test(km) && require([editor.lazyKeymaps.emacs]) ||
  68. /vim/i.test(km) && require([editor.lazyKeymaps.vim]);
  69. }
  70. async function loadStyle() {
  71. const params = new URLSearchParams(location.search);
  72. const id = Number(params.get('id'));
  73. const style = id && await API.styles.get(id) || {
  74. name: params.get('domain') ||
  75. tryURL(params.get('url-prefix')).hostname ||
  76. '',
  77. enabled: true,
  78. sections: [
  79. MozDocMapper.toSection([...params], {code: ''}),
  80. ],
  81. };
  82. // switching the mode here to show the correct page ASAP, usually before DOMContentLoaded
  83. editor.isUsercss = Boolean(style.usercssData || !style.id && prefs.get('newStyleAsUsercss'));
  84. editor.style = style;
  85. editor.onStyleUpdated();
  86. editor.updateTitle(false);
  87. document.documentElement.classList.toggle('usercss', editor.isUsercss);
  88. sessionStore.justEditedStyleId = style.id || '';
  89. // no such style so let's clear the invalid URL parameters
  90. if (!style.id) history.replaceState({}, '', location.pathname);
  91. }
  92. /** Preloads the theme so CodeMirror can use the correct metrics in its first render */
  93. async function loadTheme() {
  94. const theme = prefs.get('editor.theme');
  95. if (!CODEMIRROR_THEMES.includes(theme)) {
  96. prefs.set('editor.theme', 'default');
  97. return;
  98. }
  99. if (theme !== 'default') {
  100. const el = $('#cm-theme');
  101. const el2 = await require([`/vendor/codemirror/theme/${theme}.css`]);
  102. el2.id = el.id;
  103. el.remove();
  104. // FF containers take more time to load CSS
  105. for (let retry = 0; !el2.sheet && ++retry <= 10;) {
  106. await new Promise(requestAnimationFrame);
  107. }
  108. }
  109. }
  110. })();
  111. //#endregion
  112. //#region init layout/resize
  113. // baseInit.domReady.then(() => {
  114. // let headerHeight;
  115. // detectLayout(true);
  116. // window.on('resize', () => detectLayout());
  117. // function detectLayout(now) {
  118. // const compact = window.innerWidth <= 850;
  119. // if (compact) {
  120. // document.body.classList.add('compact-layout');
  121. // if (!editor.isUsercss) {
  122. // if (now) fixedHeader();
  123. // else debounce(fixedHeader, 250);
  124. // window.on('scroll', fixedHeader, {passive: true});
  125. // }
  126. // } else {
  127. // document.body.classList.remove('compact-layout', 'fixed-header');
  128. // window.off('scroll', fixedHeader);
  129. // }
  130. // for (const el of $$('details[data-pref]')) {
  131. // el.open = compact ? false : prefs.get(el.dataset.pref);
  132. // }
  133. // }
  134. // function fixedHeader() {
  135. // const headerFixed = $('.fixed-header');
  136. // if (!headerFixed) headerHeight = $('#header').clientHeight;
  137. // const scrollPoint = headerHeight - 43;
  138. // if (window.scrollY >= scrollPoint && !headerFixed) {
  139. // $('body').style.setProperty('--fixed-padding', ` ${headerHeight}px`);
  140. // $('body').classList.add('fixed-header');
  141. // } else if (window.scrollY < scrollPoint && headerFixed) {
  142. // $('body').classList.remove('fixed-header');
  143. // }
  144. // }
  145. // });
  146. //#endregion
  147. //#region init header
  148. baseInit.ready.then(() => {
  149. initBeautifyButton($('#beautify'));
  150. initKeymapElement();
  151. initNameArea();
  152. initThemeElement();
  153. setupLivePrefs();
  154. require(Object.values(editor.lazyKeymaps), () => {
  155. initKeymapElement();
  156. prefs.subscribe('editor.keyMap', showHotkeyInTooltip, {runNow: true});
  157. window.on('showHotkeyInTooltip', showHotkeyInTooltip);
  158. });
  159. function findKeyForCommand(command, map) {
  160. if (typeof map === 'string') map = CodeMirror.keyMap[map];
  161. let key = Object.keys(map).find(k => map[k] === command);
  162. if (key) {
  163. return key;
  164. }
  165. for (const ft of Array.isArray(map.fallthrough) ? map.fallthrough : [map.fallthrough]) {
  166. key = ft && findKeyForCommand(command, ft);
  167. if (key) {
  168. return key;
  169. }
  170. }
  171. return '';
  172. }
  173. function initNameArea() {
  174. const nameEl = $('#name');
  175. const resetEl = $('#reset-name');
  176. const isCustomName = editor.style.updateUrl || editor.isUsercss;
  177. editor.nameTarget = isCustomName ? 'customName' : 'name';
  178. nameEl.placeholder = t(editor.isUsercss ? 'usercssEditorNamePlaceholder' : 'styleMissingName');
  179. nameEl.title = isCustomName ? t('customNameHint') : '';
  180. nameEl.on('input', () => {
  181. editor.updateName(true);
  182. resetEl.hidden = false;
  183. });
  184. resetEl.hidden = !editor.style.customName;
  185. resetEl.onclick = () => {
  186. const {style} = editor;
  187. nameEl.focus();
  188. nameEl.select();
  189. // trying to make it undoable via Ctrl-Z
  190. if (!document.execCommand('insertText', false, style.name)) {
  191. nameEl.value = style.name;
  192. editor.updateName(true);
  193. }
  194. style.customName = null; // to delete it from db
  195. resetEl.hidden = true;
  196. };
  197. const enabledEl = $('#enabled');
  198. enabledEl.onchange = () => editor.updateEnabledness(enabledEl.checked);
  199. }
  200. function initThemeElement() {
  201. $('#editor.theme').append(...[
  202. $create('option', {value: 'default'}, t('defaultTheme')),
  203. ...CODEMIRROR_THEMES.map(s => $create('option', s)),
  204. ]);
  205. // move the theme after built-in CSS so that its same-specificity selectors win
  206. document.head.appendChild($('#cm-theme'));
  207. }
  208. function initKeymapElement() {
  209. // move 'pc' or 'mac' prefix to the end of the displayed label
  210. const maps = Object.keys(CodeMirror.keyMap)
  211. .map(name => ({
  212. value: name,
  213. name: name.replace(/^(pc|mac)(.+)/, (s, arch, baseName) =>
  214. baseName.toLowerCase() + '-' + (arch === 'mac' ? 'Mac' : 'PC')),
  215. }))
  216. .sort((a, b) => a.name < b.name && -1 || a.name > b.name && 1);
  217. const fragment = document.createDocumentFragment();
  218. let bin = fragment;
  219. let groupName;
  220. // group suffixed maps in <optgroup>
  221. maps.forEach(({value, name}, i) => {
  222. groupName = !name.includes('-') ? name : groupName;
  223. const groupWithNext = maps[i + 1] && maps[i + 1].name.startsWith(groupName);
  224. if (groupWithNext) {
  225. if (bin === fragment) {
  226. bin = fragment.appendChild($create('optgroup', {label: name.split('-')[0]}));
  227. }
  228. }
  229. const el = bin.appendChild($create('option', {value}, name));
  230. if (value === prefs.defaults['editor.keyMap']) {
  231. el.dataset.default = '';
  232. el.title = t('defaultTheme');
  233. }
  234. if (!groupWithNext) bin = fragment;
  235. });
  236. const selector = $('#editor.keyMap');
  237. selector.textContent = '';
  238. selector.appendChild(fragment);
  239. selector.value = prefs.get('editor.keyMap');
  240. }
  241. function showHotkeyInTooltip(_, mapName = prefs.get('editor.keyMap')) {
  242. const extraKeys = CodeMirror.defaults.extraKeys;
  243. for (const el of $$('[data-hotkey-tooltip]')) {
  244. if (el._hotkeyTooltipKeyMap !== mapName) {
  245. el._hotkeyTooltipKeyMap = mapName;
  246. const title = el._hotkeyTooltipTitle = el._hotkeyTooltipTitle || el.title;
  247. const cmd = el.dataset.hotkeyTooltip;
  248. const key = cmd[0] === '=' ? cmd.slice(1) :
  249. findKeyForCommand(cmd, mapName) ||
  250. extraKeys && findKeyForCommand(cmd, extraKeys);
  251. const newTitle = title + (title && key ? '\n' : '') + (key || '');
  252. if (el.title !== newTitle) el.title = newTitle;
  253. }
  254. }
  255. }
  256. });
  257. //#endregion
  258. //#region init windowed mode
  259. (() => {
  260. let ownTabId;
  261. if (chrome.windows) {
  262. initWindowedMode();
  263. const pos = tryJSONparse(sessionStore.windowPos);
  264. delete sessionStore.windowPos;
  265. // resize the window on 'undo close'
  266. if (pos && pos.left != null) {
  267. chrome.windows.update(chrome.windows.WINDOW_ID_CURRENT, pos);
  268. }
  269. }
  270. getOwnTab().then(async tab => {
  271. ownTabId = tab.id;
  272. // use browser history back when 'back to manage' is clicked
  273. if (sessionStore['manageStylesHistory' + ownTabId] === location.href) {
  274. await baseInit.domReady;
  275. $('#cancel-button').onclick = event => {
  276. event.stopPropagation();
  277. event.preventDefault();
  278. history.back();
  279. };
  280. }
  281. });
  282. async function initWindowedMode() {
  283. chrome.tabs.onAttached.addListener(onTabAttached);
  284. const isSimple = (await browser.windows.getCurrent()).type === 'popup';
  285. if (isSimple) require(['/edit/embedded-popup']);
  286. editor.isWindowed = isSimple || (
  287. history.length === 1 &&
  288. await prefs.ready && prefs.get('openEditInWindow') &&
  289. (await browser.windows.getAll()).length > 1 &&
  290. (await browser.tabs.query({currentWindow: true})).length === 1
  291. );
  292. }
  293. async function onTabAttached(tabId, info) {
  294. if (tabId !== ownTabId) {
  295. return;
  296. }
  297. if (info.newPosition !== 0) {
  298. prefs.set('openEditInWindow', false);
  299. return;
  300. }
  301. const win = await browser.windows.get(info.newWindowId, {populate: true});
  302. // If there's only one tab in this window, it's been dragged to new window
  303. const openEditInWindow = win.tabs.length === 1;
  304. // FF-only because Chrome retardedly resets the size during dragging
  305. if (openEditInWindow && FIREFOX) {
  306. chrome.windows.update(info.newWindowId, prefs.get('windowPosition'));
  307. }
  308. prefs.set('openEditInWindow', openEditInWindow);
  309. }
  310. })();
  311. //#endregion
  312. //#region internals
  313. /** @returns DirtyReporter */
  314. function DirtyReporter() {
  315. const data = new Map();
  316. const listeners = new Set();
  317. const notifyChange = wasDirty => {
  318. if (wasDirty !== (data.size > 0)) {
  319. listeners.forEach(cb => cb());
  320. }
  321. };
  322. /** @namespace DirtyReporter */
  323. return {
  324. add(obj, value) {
  325. const wasDirty = data.size > 0;
  326. const saved = data.get(obj);
  327. if (!saved) {
  328. data.set(obj, {type: 'add', newValue: value});
  329. } else if (saved.type === 'remove') {
  330. if (saved.savedValue === value) {
  331. data.delete(obj);
  332. } else {
  333. saved.newValue = value;
  334. saved.type = 'modify';
  335. }
  336. }
  337. notifyChange(wasDirty);
  338. },
  339. clear(obj) {
  340. const wasDirty = data.size > 0;
  341. if (obj === undefined) {
  342. data.clear();
  343. } else {
  344. data.delete(obj);
  345. }
  346. notifyChange(wasDirty);
  347. },
  348. has(key) {
  349. return data.has(key);
  350. },
  351. isDirty() {
  352. return data.size > 0;
  353. },
  354. modify(obj, oldValue, newValue) {
  355. const wasDirty = data.size > 0;
  356. const saved = data.get(obj);
  357. if (!saved) {
  358. if (oldValue !== newValue) {
  359. data.set(obj, {type: 'modify', savedValue: oldValue, newValue});
  360. }
  361. } else if (saved.type === 'modify') {
  362. if (saved.savedValue === newValue) {
  363. data.delete(obj);
  364. } else {
  365. saved.newValue = newValue;
  366. }
  367. } else if (saved.type === 'add') {
  368. saved.newValue = newValue;
  369. }
  370. notifyChange(wasDirty);
  371. },
  372. onChange(cb, add = true) {
  373. listeners[add ? 'add' : 'delete'](cb);
  374. },
  375. remove(obj, value) {
  376. const wasDirty = data.size > 0;
  377. const saved = data.get(obj);
  378. if (!saved) {
  379. data.set(obj, {type: 'remove', savedValue: value});
  380. } else if (saved.type === 'add') {
  381. data.delete(obj);
  382. } else if (saved.type === 'modify') {
  383. saved.type = 'remove';
  384. }
  385. notifyChange(wasDirty);
  386. },
  387. };
  388. }
  389. //#endregion