prefs.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. /* global API msg */// msg.js
  2. /* global debounce deepMerge */// toolbox.js - not used in content scripts
  3. 'use strict';
  4. (() => {
  5. if (window.INJECTED === 1) return;
  6. const STORAGE_KEY = 'settings';
  7. const clone = typeof deepMerge === 'function'
  8. ? deepMerge
  9. : val =>
  10. typeof val === 'object' && val
  11. ? JSON.parse(JSON.stringify(val))
  12. : val;
  13. /**
  14. * @type PrefsValues
  15. * @namespace PrefsValues
  16. */
  17. const defaults = {
  18. 'openEditInWindow': false, // new editor opens in a own browser window
  19. 'openEditInWindow.popup': false, // new editor opens in a simplified browser window without omnibox
  20. 'windowPosition': {}, // detached window position
  21. 'show-badge': true, // display text on popup menu icon
  22. 'disableAll': false, // boss key
  23. 'exposeIframes': false, // Add 'stylus-iframe' attribute to HTML element in all iframes
  24. 'newStyleAsUsercss': false, // create new style in usercss format
  25. 'styleViaXhr': false, // early style injection to avoid FOUC
  26. 'patchCsp': false, // add data: and popular image hosting sites to strict CSP
  27. // checkbox in style config dialog
  28. 'config.autosave': true,
  29. 'schemeSwitcher.enabled': 'never',
  30. 'schemeSwitcher.nightStart': '18:00',
  31. 'schemeSwitcher.nightEnd': '06:00',
  32. 'popup.breadcrumbs': true, // display 'New style' links as URL breadcrumbs
  33. 'popup.breadcrumbs.usePath': false, // use URL path for 'this URL'
  34. 'popup.enabledFirst': true, // display enabled styles before disabled styles
  35. 'popup.stylesFirst': true, // display enabled styles before disabled styles
  36. 'popup.autoResort': false, // auto resort styles after toggling
  37. 'popup.borders': false, // add white borders on the sides
  38. 'popup.findStylesInline': true, // use the inline style search
  39. /** @type {'n' | 'u' | 't' | 'w' | 'r'} see IndexEntry */
  40. 'popup.findSort': 'u', // the inline style search sort order
  41. 'manage.onlyEnabled': false, // display only enabled styles
  42. 'manage.onlyLocal': false, // display only styles created locally
  43. 'manage.onlyUsercss': false, // display only usercss styles
  44. 'manage.onlyEnabled.invert': false, // display only disabled styles
  45. 'manage.onlyLocal.invert': false, // display only externally installed styles
  46. 'manage.onlyUsercss.invert': false, // display only non-usercss (standard) styles
  47. // UI element state: expanded/collapsed
  48. 'manage.actions.expanded': true,
  49. 'manage.backup.expanded': true,
  50. 'manage.filters.expanded': true,
  51. // the new compact layout doesn't look good on Android yet
  52. 'manage.newUI': !navigator.appVersion.includes('Android'),
  53. 'manage.newUI.favicons': false, // show favicons for the sites in applies-to
  54. 'manage.newUI.faviconsGray': true, // gray out favicons
  55. 'manage.newUI.targets': 3, // max number of applies-to targets visible: 0 = none
  56. 'manage.newUI.sort': 'title,asc',
  57. 'editor.options': {}, // CodeMirror.defaults.*
  58. 'editor.toc.expanded': true, // UI element state: expanded/collapsed
  59. 'editor.options.expanded': true, // UI element state: expanded/collapsed
  60. 'editor.lint.expanded': true, // UI element state: expanded/collapsed
  61. 'editor.publish.expanded': true, // UI element state expanded/collapsed
  62. 'editor.lineWrapping': true, // word wrap
  63. 'editor.smartIndent': true, // 'smart' indent
  64. 'editor.indentWithTabs': false, // smart indent with tabs
  65. 'editor.tabSize': 4, // tab width, in spaces
  66. 'editor.keyMap': navigator.appVersion.indexOf('Windows') > 0 ? 'sublime' : 'default',
  67. 'editor.theme': 'default', // CSS theme
  68. // CSS beautifier
  69. 'editor.beautify': {
  70. selector_separator_newline: true,
  71. newline_before_open_brace: false,
  72. newline_after_open_brace: true,
  73. newline_between_properties: true,
  74. newline_before_close_brace: true,
  75. newline_between_rules: false,
  76. preserve_newlines: true,
  77. end_with_newline: false,
  78. indent_conditional: true,
  79. },
  80. 'editor.beautify.hotkey': '',
  81. 'editor.lintDelay': 300, // lint gutter marker update delay, ms
  82. 'editor.linter': 'csslint', // 'csslint' or 'stylelint' or ''
  83. 'editor.lintReportDelay': 500, // lint report update delay, ms
  84. 'editor.matchHighlight': 'token', // token = token/word under cursor even if nothing is selected
  85. // selection = only when something is selected
  86. // '' (empty string) = disabled
  87. 'editor.autoCloseBrackets': true, // auto-add a closing pair when typing an opening one of ()[]{}''""
  88. 'editor.autocompleteOnTyping': false, // show autocomplete dropdown on typing a word token
  89. // "Delete" item in context menu for browsers that don't have it
  90. 'editor.contextDelete': null,
  91. 'editor.selectByTokens': true,
  92. 'editor.appliesToLineWidget': true, // show applies-to line widget on the editor
  93. 'editor.livePreview': true,
  94. // show CSS colors as clickable colored rectangles
  95. 'editor.colorpicker': true,
  96. // #DEAD or #beef
  97. 'editor.colorpicker.hexUppercase': false,
  98. // default hotkey
  99. 'editor.colorpicker.hotkey': '',
  100. // last color
  101. 'editor.colorpicker.color': '',
  102. 'editor.colorpicker.maxHeight': 300,
  103. // Firefox-only chrome.commands.update
  104. 'hotkey._execute_browser_action': '',
  105. 'hotkey.openManage': '',
  106. 'hotkey.styleDisableAll': '',
  107. 'sync.enabled': 'none',
  108. 'iconset': 0, // 0 = dark-themed icon
  109. // 1 = light-themed icon
  110. 'badgeDisabled': '#8B0000', // badge background color when disabled
  111. 'badgeNormal': '#006666', // badge background color
  112. 'popupWidth': 246, // popup width in pixels
  113. 'updateInterval': 24, // user-style automatic update interval, hours (0 = disable)
  114. };
  115. const knownKeys = Object.keys(defaults);
  116. /** @type {PrefsValues} */
  117. const values = clone(defaults);
  118. const onChange = {
  119. any: new Set(),
  120. specific: {},
  121. };
  122. // API fails in the active tab during Chrome startup as it loads the tab before bg
  123. /** @type {Promise|boolean} will be `true` to avoid wasting a microtask tick on each `await` */
  124. let ready = (msg.isBg ? readStorage() : API.prefs.getValues().catch(readStorage))
  125. .then(data => {
  126. setAll(data);
  127. ready = true;
  128. });
  129. chrome.storage.onChanged.addListener(async (changes, area) => {
  130. const data = area === 'sync' && changes[STORAGE_KEY];
  131. if (data) {
  132. if (ready.then) await ready;
  133. setAll(data.newValue);
  134. }
  135. });
  136. const prefs = window.prefs = {
  137. STORAGE_KEY,
  138. knownKeys,
  139. ready,
  140. /** @type {PrefsValues} */
  141. defaults: new Proxy({}, {
  142. get: (_, key) => clone(defaults[key]),
  143. }),
  144. /** @type {PrefsValues} */
  145. get values() {
  146. return clone(values);
  147. },
  148. __defaults: defaults, // direct reference, be careful!
  149. __values: values, // direct reference, be careful!
  150. get(key) {
  151. const res = values[key];
  152. if (res !== undefined || isKnown(key)) {
  153. return clone(res);
  154. }
  155. },
  156. set(key, val, isSynced) {
  157. if (!isKnown(key)) return;
  158. const oldValue = values[key];
  159. const type = typeof defaults[key];
  160. if (type !== typeof val) {
  161. if (type === 'string') val = String(val);
  162. if (type === 'number') val = Number(val) || 0;
  163. if (type === 'boolean') val = val === 'true' || val !== 'false' && Boolean(val);
  164. }
  165. if (val !== oldValue && !simpleDeepEqual(val, oldValue)) {
  166. values[key] = val;
  167. emitChange(key, val, isSynced);
  168. }
  169. },
  170. reset(key) {
  171. prefs.set(key, clone(defaults[key]));
  172. },
  173. /**
  174. * @param {?string|string[]} keys - pref ids or a falsy value to subscribe to everything
  175. * @param {function(key:string?, value:any?)} fn
  176. * @param {Object} [opts]
  177. * @param {boolean} [opts.runNow] - when truthy, the listener is called immediately:
  178. * 1) if `keys` is an array of keys, each `key` will be fired separately with a real `value`
  179. * 2) if `keys` is falsy, no key/value will be provided
  180. */
  181. async subscribe(keys, fn, {runNow} = {}) {
  182. const toRun = [];
  183. if (keys) {
  184. const uniqKeys = new Set(Array.isArray(keys) ? keys : [keys]);
  185. for (const key of uniqKeys) {
  186. if (!isKnown(key)) continue;
  187. const listeners = onChange.specific[key] ||
  188. (onChange.specific[key] = new Set());
  189. listeners.add(fn);
  190. if (runNow) toRun.push({fn, key});
  191. }
  192. } else {
  193. onChange.any.add(fn);
  194. if (runNow) toRun.push({fn});
  195. }
  196. if (toRun.length) {
  197. if (ready.then) await ready;
  198. toRun.forEach(({fn, key}) => fn(key, values[key]));
  199. }
  200. },
  201. subscribeMany(data, opts) {
  202. for (const [k, fn] of Object.entries(data)) {
  203. prefs.subscribe(k, fn, opts);
  204. }
  205. },
  206. unsubscribe(keys, fn) {
  207. if (keys) {
  208. for (const key of keys) {
  209. const listeners = onChange.specific[key];
  210. if (listeners) {
  211. listeners.delete(fn);
  212. if (!listeners.size) {
  213. delete onChange.specific[key];
  214. }
  215. }
  216. }
  217. } else {
  218. onChange.all.remove(fn);
  219. }
  220. },
  221. };
  222. function isKnown(key) {
  223. const res = knownKeys.includes(key);
  224. if (!res) console.warn('Unknown preference "%s"', key);
  225. return res;
  226. }
  227. function setAll(settings) {
  228. for (const [key, value] of Object.entries(settings || {})) {
  229. prefs.set(key, value, true);
  230. }
  231. }
  232. function emitChange(key, value, isSynced) {
  233. for (const fn of onChange.specific[key] || []) {
  234. fn(key, value);
  235. }
  236. for (const fn of onChange.any) {
  237. fn(key, value);
  238. }
  239. if (!isSynced) {
  240. /* browser.storage is slow and can randomly lose values if the tab was closed immediately
  241. so we're sending the value to the background script which will save it to the storage;
  242. the extra bonus is that invokeAPI is immediate in extension tabs */
  243. if (msg.isBg) {
  244. debounce(updateStorage);
  245. } else {
  246. API.prefs.set(key, value);
  247. }
  248. }
  249. }
  250. async function readStorage() {
  251. return (await browser.storage.sync.get(STORAGE_KEY))[STORAGE_KEY];
  252. }
  253. function updateStorage() {
  254. return browser.storage.sync.set({[STORAGE_KEY]: values});
  255. }
  256. function simpleDeepEqual(a, b) {
  257. return !a || !b || typeof a !== 'object' || typeof b !== 'object' ? a === b :
  258. Object.keys(a).length === Object.keys(b).length &&
  259. Object.keys(a).every(key => b.hasOwnProperty(key) && simpleDeepEqual(a[key], b[key]));
  260. }
  261. })();