PageSourceSearch

https://spec-zone.ru/assets/app/settings.js

js spec-zone.ru collected 2026-10-05 04:51:35 UTC 9,323 bytes, 329 lines download raw bytes

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.