prefs.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. /* global prefs: true, contextMenus, FIREFOX_NO_DOM_STORAGE */
  2. 'use strict';
  3. // eslint-disable-next-line no-var
  4. var prefs = new function Prefs() {
  5. const defaults = {
  6. 'openEditInWindow': false, // new editor opens in a own browser window
  7. 'windowPosition': {}, // detached window position
  8. 'show-badge': true, // display text on popup menu icon
  9. 'disableAll': false, // boss key
  10. 'exposeIframes': false, // Add 'stylus-iframe' attribute to HTML element in all iframes
  11. 'newStyleAsUsercss': false, // create new style in usercss format
  12. // checkbox in style config dialog
  13. 'config.autosave': true,
  14. 'popup.breadcrumbs': true, // display 'New style' links as URL breadcrumbs
  15. 'popup.breadcrumbs.usePath': false, // use URL path for 'this URL'
  16. 'popup.enabledFirst': true, // display enabled styles before disabled styles
  17. 'popup.stylesFirst': true, // display enabled styles before disabled styles
  18. 'popup.borders': false, // add white borders on the sides
  19. 'manage.onlyEnabled': false, // display only enabled styles
  20. 'manage.onlyLocal': false, // display only styles created locally
  21. 'manage.onlyUsercss': false, // display only usercss styles
  22. 'manage.onlyEnabled.invert': false, // display only disabled styles
  23. 'manage.onlyLocal.invert': false, // display only externally installed styles
  24. 'manage.onlyUsercss.invert': false, // display only non-usercss (standard) styles
  25. // UI element state: expanded/collapsed
  26. 'manage.backup.expanded': true,
  27. 'manage.filters.expanded': true,
  28. 'manage.options.expanded': true,
  29. // the new compact layout doesn't look good on Android yet
  30. 'manage.newUI': !navigator.appVersion.includes('Android'),
  31. 'manage.newUI.favicons': false, // show favicons for the sites in applies-to
  32. 'manage.newUI.faviconsGray': true, // gray out favicons
  33. 'manage.newUI.targets': 3, // max number of applies-to targets visible: 0 = none
  34. 'editor.options': {}, // CodeMirror.defaults.*
  35. 'editor.options.expanded': true, // UI element state: expanded/collapsed
  36. 'editor.lint.expanded': true, // UI element state: expanded/collapsed
  37. 'editor.lineWrapping': true, // word wrap
  38. 'editor.smartIndent': true, // 'smart' indent
  39. 'editor.indentWithTabs': false, // smart indent with tabs
  40. 'editor.tabSize': 4, // tab width, in spaces
  41. 'editor.keyMap': navigator.appVersion.indexOf('Windows') > 0 ? 'sublime' : 'default',
  42. 'editor.theme': 'default', // CSS theme
  43. 'editor.beautify': { // CSS beautifier
  44. selector_separator_newline: true,
  45. newline_before_open_brace: false,
  46. newline_after_open_brace: true,
  47. newline_between_properties: true,
  48. newline_before_close_brace: true,
  49. newline_between_rules: false,
  50. end_with_newline: false,
  51. indent_conditional: true,
  52. },
  53. 'editor.lintDelay': 500, // lint gutter marker update delay, ms
  54. 'editor.linter': 'csslint', // 'csslint' or 'stylelint' or ''
  55. 'editor.lintReportDelay': 4500, // lint report update delay, ms
  56. 'editor.matchHighlight': 'token', // token = token/word under cursor even if nothing is selected
  57. // selection = only when something is selected
  58. // '' (empty string) = disabled
  59. 'editor.autoCloseBrackets': false, // auto-add a closing pair when typing an opening one of ()[]{}''""
  60. 'editor.autocompleteOnTyping': false, // show autocomplete dropdown on typing a word token
  61. 'editor.contextDelete': contextDeleteMissing(), // "Delete" item in context menu
  62. 'editor.appliesToLineWidget': true, // show applies-to line widget on the editor
  63. // show CSS colors as clickable colored rectangles
  64. 'editor.colorpicker': true,
  65. // #DEAD or #beef
  66. 'editor.colorpicker.hexUppercase': false,
  67. // default hotkey
  68. 'editor.colorpicker.hotkey': '',
  69. // last color
  70. 'editor.colorpicker.color': '',
  71. 'iconset': 0, // 0 = dark-themed icon
  72. // 1 = light-themed icon
  73. 'badgeDisabled': '#8B0000', // badge background color when disabled
  74. 'badgeNormal': '#006666', // badge background color
  75. 'popupWidth': 246, // popup width in pixels
  76. 'updateInterval': 24, // user-style automatic update interval, hours (0 = disable)
  77. };
  78. const values = deepCopy(defaults);
  79. const affectsIcon = [
  80. 'show-badge',
  81. 'disableAll',
  82. 'badgeDisabled',
  83. 'badgeNormal',
  84. 'iconset',
  85. ];
  86. const onChange = {
  87. any: new Set(),
  88. specific: new Map(),
  89. };
  90. // coalesce multiple pref changes in broadcast
  91. let broadcastPrefs = {};
  92. Object.defineProperty(this, 'readOnlyValues', {value: {}});
  93. Object.assign(Prefs.prototype, {
  94. get(key, defaultValue) {
  95. if (key in values) {
  96. return values[key];
  97. }
  98. if (defaultValue !== undefined) {
  99. return defaultValue;
  100. }
  101. if (key in defaults) {
  102. return defaults[key];
  103. }
  104. console.warn("No default preference for '%s'", key);
  105. },
  106. getAll() {
  107. return deepCopy(values);
  108. },
  109. set(key, value, {broadcast = true, sync = true, fromBroadcast} = {}) {
  110. const oldValue = values[key];
  111. switch (typeof defaults[key]) {
  112. case typeof value:
  113. break;
  114. case 'string':
  115. value = String(value);
  116. break;
  117. case 'number':
  118. value |= 0;
  119. break;
  120. case 'boolean':
  121. value = value === true || value === 'true';
  122. break;
  123. }
  124. values[key] = value;
  125. defineReadonlyProperty(this.readOnlyValues, key, value);
  126. const hasChanged = !equal(value, oldValue);
  127. if (!fromBroadcast) {
  128. if (BG && BG !== window) {
  129. BG.prefs.set(key, BG.deepCopy(value), {broadcast, sync});
  130. } else {
  131. localStorage[key] = typeof defaults[key] === 'object'
  132. ? JSON.stringify(value)
  133. : value;
  134. if (broadcast && hasChanged) {
  135. this.broadcast(key, value, {sync});
  136. }
  137. }
  138. }
  139. if (hasChanged) {
  140. const specific = onChange.specific.get(key);
  141. if (typeof specific === 'function') {
  142. specific(key, value);
  143. } else if (specific instanceof Set) {
  144. for (const listener of specific.values()) {
  145. listener(key, value);
  146. }
  147. }
  148. for (const listener of onChange.any.values()) {
  149. listener(key, value);
  150. }
  151. }
  152. },
  153. remove: key => this.set(key, undefined),
  154. reset: key => this.set(key, deepCopy(defaults[key])),
  155. broadcast(key, value, {sync = true} = {}) {
  156. broadcastPrefs[key] = value;
  157. debounce(doBroadcast);
  158. if (sync) {
  159. debounce(doSyncSet);
  160. }
  161. },
  162. subscribe(keys, listener) {
  163. // keys: string[] ids
  164. // or a falsy value to subscribe to everything
  165. // listener: function (key, value)
  166. if (keys) {
  167. for (const key of keys) {
  168. const existing = onChange.specific.get(key);
  169. if (!existing) {
  170. onChange.specific.set(key, listener);
  171. } else if (existing instanceof Set) {
  172. existing.add(listener);
  173. } else {
  174. onChange.specific.set(key, new Set([existing, listener]));
  175. }
  176. }
  177. } else {
  178. onChange.any.add(listener);
  179. }
  180. },
  181. });
  182. // Unlike sync, HTML5 localStorage is ready at browser startup
  183. // so we'll mirror the prefs to avoid using the wrong defaults
  184. // during the startup phase
  185. for (const key in defaults) {
  186. const defaultValue = defaults[key];
  187. let value = localStorage[key];
  188. if (typeof value === 'string') {
  189. switch (typeof defaultValue) {
  190. case 'boolean':
  191. value = value.toLowerCase() === 'true';
  192. break;
  193. case 'number':
  194. value |= 0;
  195. break;
  196. case 'object':
  197. value = tryJSONparse(value) || defaultValue;
  198. break;
  199. }
  200. } else if (FIREFOX_NO_DOM_STORAGE && BG) {
  201. value = BG.localStorage[key];
  202. value = value === undefined ? defaultValue : value;
  203. } else {
  204. value = defaultValue;
  205. }
  206. if (BG === window) {
  207. // when in bg page, .set() will write to localStorage
  208. this.set(key, value, {broadcast: false, sync: false});
  209. } else {
  210. values[key] = value;
  211. defineReadonlyProperty(this.readOnlyValues, key, value);
  212. }
  213. }
  214. if (!BG || BG === window) {
  215. affectsIcon.forEach(key => this.broadcast(key, values[key], {sync: false}));
  216. const importFromSync = (synced = {}) => {
  217. for (const key in defaults) {
  218. if (key in synced) {
  219. this.set(key, synced[key], {sync: false});
  220. }
  221. }
  222. };
  223. getSync().get('settings', ({settings} = {}) => importFromSync(settings));
  224. chrome.storage.onChanged.addListener((changes, area) => {
  225. if (area === 'sync' && 'settings' in changes) {
  226. const synced = changes.settings.newValue;
  227. if (synced) {
  228. importFromSync(synced);
  229. } else {
  230. // user manually deleted our settings, we'll recreate them
  231. getSync().set({'settings': values});
  232. }
  233. }
  234. });
  235. }
  236. // any access to chrome API takes time due to initialization of bindings
  237. window.addEventListener('load', function _() {
  238. window.removeEventListener('load', _);
  239. chrome.runtime.onMessage.addListener(msg => {
  240. if (msg.prefs) {
  241. for (const id in msg.prefs) {
  242. prefs.set(id, msg.prefs[id], {fromBroadcast: true});
  243. }
  244. }
  245. });
  246. });
  247. return;
  248. function doBroadcast() {
  249. if (BG && BG === window && !BG.dbExec.initialized) {
  250. window.addEventListener('storageReady', function _() {
  251. window.removeEventListener('storageReady', _);
  252. doBroadcast();
  253. });
  254. return;
  255. }
  256. const affects = {
  257. all: 'disableAll' in broadcastPrefs
  258. || 'exposeIframes' in broadcastPrefs,
  259. };
  260. if (!affects.all) {
  261. for (const key in broadcastPrefs) {
  262. affects.icon = affects.icon || affectsIcon.includes(key);
  263. affects.popup = affects.popup || key.startsWith('popup');
  264. affects.editor = affects.editor || key.startsWith('editor');
  265. affects.manager = affects.manager || key.startsWith('manage');
  266. }
  267. }
  268. notifyAllTabs({method: 'prefChanged', prefs: broadcastPrefs, affects});
  269. broadcastPrefs = {};
  270. }
  271. function doSyncSet() {
  272. getSync().set({'settings': values});
  273. }
  274. // Polyfill for Firefox < 53 https://bugzilla.mozilla.org/show_bug.cgi?id=1220494
  275. function getSync() {
  276. if ('sync' in chrome.storage && !chrome.runtime.id.includes('@temporary')) {
  277. return chrome.storage.sync;
  278. }
  279. const crappyStorage = {};
  280. return {
  281. get(key, callback) {
  282. callback(crappyStorage[key] || {});
  283. },
  284. set(source, callback) {
  285. for (const property in source) {
  286. if (source.hasOwnProperty(property)) {
  287. crappyStorage[property] = source[property];
  288. }
  289. }
  290. if (typeof callback === 'function') {
  291. callback();
  292. }
  293. }
  294. };
  295. }
  296. function defineReadonlyProperty(obj, key, value) {
  297. const copy = deepCopy(value);
  298. if (typeof copy === 'object') {
  299. Object.freeze(copy);
  300. }
  301. Object.defineProperty(obj, key, {value: copy, configurable: true});
  302. }
  303. function equal(a, b) {
  304. if (!a || !b || typeof a !== 'object' || typeof b !== 'object') {
  305. return a === b;
  306. }
  307. if (Object.keys(a).length !== Object.keys(b).length) {
  308. return false;
  309. }
  310. for (const k in a) {
  311. if (typeof a[k] === 'object') {
  312. if (!equal(a[k], b[k])) {
  313. return false;
  314. }
  315. } else if (a[k] !== b[k]) {
  316. return false;
  317. }
  318. }
  319. return true;
  320. }
  321. function contextDeleteMissing() {
  322. return CHROME && (
  323. // detect browsers without Delete by looking at the end of UA string
  324. /Vivaldi\/[\d.]+$/.test(navigator.userAgent) ||
  325. // Chrome and co.
  326. /Safari\/[\d.]+$/.test(navigator.userAgent) &&
  327. // skip forks with Flash as those are likely to have the menu e.g. CentBrowser
  328. !Array.from(navigator.plugins).some(p => p.name === 'Shockwave Flash')
  329. );
  330. }
  331. }();
  332. // Accepts an array of pref names (values are fetched via prefs.get)
  333. // and establishes a two-way connection between the document elements and the actual prefs
  334. function setupLivePrefs(
  335. IDs = Object.getOwnPropertyNames(prefs.readOnlyValues)
  336. .filter(id => $('#' + id))
  337. ) {
  338. const checkedProps = {};
  339. for (const id of IDs) {
  340. const element = $('#' + id);
  341. checkedProps[id] = element.type === 'checkbox' ? 'checked' : 'value';
  342. updateElement({id, element, force: true});
  343. element.addEventListener('change', onChange);
  344. }
  345. prefs.subscribe(IDs, (id, value) => updateElement({id, value}));
  346. function onChange() {
  347. const value = this[checkedProps[this.id]];
  348. if (prefs.get(this.id) !== value) {
  349. prefs.set(this.id, value);
  350. }
  351. }
  352. function updateElement({
  353. id,
  354. value = prefs.get(id),
  355. element = $('#' + id),
  356. force,
  357. }) {
  358. const prop = checkedProps[id];
  359. if (force || element[prop] !== value) {
  360. element[prop] = value;
  361. element.dispatchEvent(new Event('change', {bubbles: true, cancelable: true}));
  362. }
  363. }
  364. }