1// @ts-check 2 3import { app } from "./app.js"; 4import { config } from "./config.js"; 5import { settingsStore } from "../lib/settings_store.js"; 6import { $ } from "../lib/util.js"; 7 8/** 9 * A setting the user turns on or off. 10 * 11 * It is a boolean going in, but SettingsStore writes `true` as `1` and parses 12 * the digit back out on read, so it comes back as a number. A setting that was 13 * never written falls back to its default, which is a real boolean. Both are 14 * truthy or falsy as intended; only a strict comparison would go wrong. 15 * 16 * @typedef {boolean | number} StoredFlag 17 */ 18 19/** 20 * What each setting holds. 21 * 22 * @typedef {object} SettingsValues 23 * @property {number} count How many times the user has visited. 24 * @property {StoredFlag} hideDisabled 25 * @property {StoredFlag} hideIntro 26 * @property {number} news When the changelog was last read, as a Unix timestamp. 27 * @property {StoredFlag} manualUpdate 28 * @property {number} schema The offline database's schema version. 29 * @property {StoredFlag} analyticsConsent Written as 1 or 0 rather than deleted, 30 * so that consent that was refused is remembered. 31 * @property {string} theme `"auto"`, `"dark"` or `"default"`. 32 * @property {number} spaceScroll How far space scrolls, as a fraction of the viewport. 33 * @property {number | string} spaceTimeout How long after typing space stops 34 * scrolling, in seconds. Not an integer, so it comes back as a string. 35 * @property {StoredFlag} noDocSpecificIcon 36 * @property {StoredFlag} autoLatestVersion 37 * @property {number} version The build the user last saw. 38 * @property {StoredFlag} fastScroll 39 * @property {StoredFlag} arrowScroll 40 * @property {StoredFlag} noAutofocus 41 * @property {StoredFlag} autoInstall 42 * @property {number} dark Legacy; replaced by `theme`. 43 * @property {string} docs The enabled slugs, separated by `/`. 44 * @property {string} tips The tips already shown, separated by `/`. 45 * @property {string} layout The layout classes, separated by spaces. 46 * @property {number} size The sidebar's width, in pixels. 47 */ 48 49/** 50 * The user's preferences, stored in localStorage. 51 * 52 * `PREFERENCE_KEYS` are the ones the user controls and that a backup carries; 53 * `INTERNAL_KEYS` are the app's own bookkeeping and stay out of backups. 54 */ 55export class Settings { 56 static PREFERENCE_KEYS = [ 57 "hideDisabled", 58 "hideIntro", 59 "manualUpdate", 60 "fastScroll", 61 "arrowScroll", 62 "analyticsConsent", 63 "docs", 64 "dark", // legacy 65 "theme", 66 "layout", 67 "size", 68 "tips", 69 "noAutofocus", 70 "autoInstall", 71 "autoLatestVersion", 72 "spaceScroll", 73 "spaceTimeout", 74 "noDocSpecificIcon", 75 ]; 76 77 static INTERNAL_KEYS = ["count", "schema", "version", "news"]; 78 79 static LAYOUTS = [ 80 "_max-width", 81 "_sidebar-hidden", 82 "_native-scrollbars", 83 "_text-justify-hyphenate", 84 ]; 85 86 /** @type {Record<string, string | number | boolean>} */ 87 static defaults = { 88 count: 0, 89 hideDisabled: false, 90 hideIntro: false, 91 news: 0, 92 manualUpdate: false, 93 schema: 1, 94 analyticsConsent: false, 95 theme: "auto", 96 spaceScroll: 1, 97 spaceTimeout: 0.5, 98 noDocSpecificIcon: false, 99 autoLatestVersion: false, 100 }; 101 102 /** Opens the store and starts following the system colour scheme. */ 103 constructor() { 104 this.store = settingsStore; 105 this.cache = {}; 106 this.autoSupported = 107 window.matchMedia("(prefers-color-scheme)").media !== "not all"; 108 if (this.autoSupported) { 109 this.darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); 110 this.darkModeQuery.addListener(() => this.setTheme(this.get("theme"))); 111 } 112 } 113 114 /** 115 * Reads a setting, falling back to its default. Cached after the first read. 116 * 117 * @template {keyof SettingsValues} K 118 * @param {K} key 119 * @returns {SettingsValues[K]} 120 */ 121 get(key) { 122 // The store and the defaults are both keyed by name but hold mixed types, 123 // so the value is narrowed to the one this key stands for on the way out. 124 const cache = /** @type {Record<string, unknown>} */ (this.cache); 125 const cast = (/** @type {unknown} */ value) => 126 /** @type {SettingsValues[K]} */ (value); 127 128 let left; 129 if (cache.hasOwnProperty(key)) { 130 return cast(cache[key]); 131 } 132 cache[key] = 133 (left = this.store.get(key)) != null ? left : Settings.defaults[key]; 134 if (key === "theme" && cache[key] === "auto" && !this.darkModeQuery) { 135 return cast((cache[key] = "default")); 136 } else { 137 return cast(cache[key]); 138 } 139 } 140 141 /** 142 * @template {keyof SettingsValues} K 143 * @param {K} key 144 * @param {SettingsValues[K]} value 145 */ 146 set(key, value) { 147 this.store.set(key, /** @type {string | number | boolean} */ (value)); 148 delete (/** @type {Record<string, unknown>} */ (this.cache))[key]; 149 if (key === "theme") { 150 this.setTheme(/** @type {string} */ (value)); 151 } 152 } 153 154 /** @param {string} key */ 155 del(key) { 156 this.store.del(key); 157 delete (/** @type {Record<string, unknown>} */ (this.cache))[key]; 158 } 159 160 /** @returns {boolean | undefined} Whether the user has ever chosen a set of docs. */ 161 hasDocs() { 162 try { 163 return !!this.store.get("docs"); 164 } catch (error) {} 165 } 166 167 /** @returns {string[]} The enabled doc slugs, or the defaults. */ 168 getDocs() { 169 return ( 170 /** @type {string | undefined} */ (this.store.get("docs"))?.split("/") || 171 config.default_docs 172 ); 173 } 174 175 /** @param {string[]} docs */ 176 setDocs(docs) { 177 this.set("docs", docs.join("/")); 178 } 179 180 /** @returns {string[]} The tips the user has already been shown. */ 181 getTips() { 182 return /** @type {string | undefined} */ (this.store.get("tips"))?.split("/") || []; 183 } 184 185 /** @param {string[]} tips */ 186 setTips(tips) { 187 this.set("tips", tips.join("/")); 188 } 189 190 /** 191 * Applies a layout class and remembers it. 192 * 193 * @param {string} name One of `LAYOUTS`. 194 * @param {boolean} enable 195 */ 196 setLayout(name, enable) { 197 this.toggleLayout(name, enable); 198 199 const layout = /** @type {string} */ ( 200 this.store.get("layout") || "" 201 ).split(" "); 202 $.arrayDelete(layout, ""); 203 204 if (enable) { 205 if (!layout.includes(name)) { 206 layout.push(name); 207 } 208 } else { 209 $.arrayDelete(layout, name); 210 } 211 212 if (layout.length > 0) { 213 this.set("layout", layout.join(" ")); 214 } else { 215 this.del("layout"); 216 } 217 } 218 219 /** 220 * @param {string} name 221 * @returns {boolean} 222 */ 223 hasLayout(name) { 224 const layout = /** @type {string} */ ( 225 this.store.get("layout") || "" 226 ).split(" "); 227 return layout.includes(name); 228 } 229 230 /** @param {number} value The sidebar width, in pixels. */ 231 setSize(value) { 232 this.set("size", value); 233 } 234 235 /** @returns {Record<string, string>} Every stored setting, unparsed. */ 236 dump() { 237 return this.store.dump(); 238 } 239 240 /** @returns {Record<string, string>} The user's preferences, without the app's own bookkeeping. */ 241 export() { 242 const data = this.dump(); 243 for (var key of Settings.INTERNAL_KEYS) { 244 delete data[key]; 245 } 246 return data; 247 } 248 249 /** 250 * Replaces the user's preferences with `data`, dropping any it omits. 251 * 252 * @param {Record<string, unknown>} data 253 */ 254 import(data) { 255 let key, value; 256 const object = this.export(); 257 for (key in object) { 258 value = object[key]; 259 if (!data.hasOwnProperty(key)) { 260 this.del(key); 261 } 262 } 263 for (key in data) { 264 value = data[key]; 265 if (Settings.PREFERENCE_KEYS.includes(key)) { 266 this.set(key, value); 267 } 268 } 269 } 270 271 /** Clears every setting. */ 272 reset() { 273 this.store.reset(); 274 this.cache = {}; 275 } 276 277 /** Applies the stored theme and layout to the document. Runs before the first paint. */ 278 initLayout() { 279 if (this.get("dark") === 1) { 280 this.set("theme", "dark"); 281 this.del("dark"); 282 } 283 this.setTheme(this.get("theme")); 284 for (var layout of Settings.LAYOUTS) { 285 this.toggleLayout(layout, this.hasLayout(layout)); 286 } 287 this.initSidebarWidth(); 288 } 289 290 /** @param {string} theme `"auto"`, `"dark"` or `"default"`. */ 291 setTheme(theme) { 292 if (theme === "auto") { 293 theme = this.darkModeQuery.matches ? "dark" : "default"; 294 } 295 const { classList } = document.documentElement; 296 classList.remove("_theme-default", "_theme-dark"); 297 classList.add("_theme-" + theme); 298 this.updateColorMeta(); 299 } 300 301 /** Points the `theme-color` meta at the header colour of the current theme. */ 302 updateColorMeta() { 303 const color = getComputedStyle(document.documentElement)
304 .getPropertyValue("--headerBackground") 305 .trim(); 306 $("meta[name=theme-color]").setAttribute("content", color); 307 } 308 309 /** 310 * @param {string} layout 311 * @param {boolean} enable 312 */ 313 toggleLayout(layout, enable) { 314 const { classList } = document.body; 315 // sidebar is always shown for settings; its state is updated in SettingsView 316 if (layout !== "_sidebar-hidden" || !app.router?.isSettings) { 317 classList.toggle(layout, enable); 318 } 319 classList.toggle("_overlay-scrollbars", $.overlayScrollbarsEnabled()); 320 } 321 322 /** Applies the stored sidebar width. */ 323 initSidebarWidth() { 324 const size = this.get("size"); 325 if (size) { 326 document.documentElement.style.setProperty("--sidebarWidth", size + "px"); 327 } 328 } 329};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.