diff --git a/scripts/previewWidth.test.ts b/scripts/previewWidth.test.ts index 2161db39..b6440865 100644 --- a/scripts/previewWidth.test.ts +++ b/scripts/previewWidth.test.ts @@ -51,9 +51,14 @@ test('the dedicated full-width preference takes priority over its legacy key', ( test('settings load, persist, and reset the preview width through one normalizer', () => { assert.match(settingsSource, /previewMaxWidth = \$state\(DEFAULT_PREVIEW_MAX_WIDTH\)/); - assert.match(settingsSource, /localStorage\.getItem\('preview\.maxWidth'\)/); + // Persistence moved from one all-fields effect to one entry per localStorage + // key (so that a change in one window stops rewriting every other key from a + // stale snapshot). The preview width still round-trips under the same key and + // through the same normalizer; only the plumbing that reads and writes it is + // now shared. See scripts/settingsPersistence.test.ts. + assert.match(settingsSource, /key: 'preview\.maxWidth'/); + assert.match(settingsSource, /read: \(s\) => String\(s\.previewMaxWidth\)/); assert.match(settingsSource, /normalizePreviewMaxWidth\(savedPreviewMaxWidth\)/); - assert.match(settingsSource, /localStorage\.setItem\('preview\.maxWidth', String\(this\.previewMaxWidth\)\)/); assert.match(settingsSource, /resetPreviewMaxWidth\(\)[\s\S]*this\.previewMaxWidth = DEFAULT_PREVIEW_MAX_WIDTH/); }); diff --git a/scripts/settingsPersistence.test.ts b/scripts/settingsPersistence.test.ts new file mode 100644 index 00000000..df68bc86 --- /dev/null +++ b/scripts/settingsPersistence.test.ts @@ -0,0 +1,515 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +/* + * `settings.svelte.ts` is a runes module, so it cannot be imported the way the + * other suites import plain utils. Node's test runner gives every file its own + * process, so shimming the runes and the browser globals here cannot leak into + * any other suite. + * + * The shims are deliberately dumb: `$state` is identity and `$effect` runs its + * callback once and records it. That is enough to assert the *shape* of the + * persistence layer (how many effects exist, which fields each one reads), + * which is the property that actually decides the multi-window behaviour. No + * assertion below depends on the shims re-running anything. + */ +type EffectFn = () => void | (() => void); + +const runes = globalThis as unknown as { + $state: unknown; + $derived: unknown; + $effect: unknown; +}; + +const registeredEffects: EffectFn[] = []; + +const identity = (value: unknown) => value; +const stateRune = Object.assign(identity, { raw: identity, snapshot: identity }); +const effectRune = Object.assign( + (fn: EffectFn) => { + registeredEffects.push(fn); + fn(); + }, + { + root: (fn: () => void) => { + fn(); + return () => {}; + }, + pre: (fn: EffectFn) => { + registeredEffects.push(fn); + fn(); + }, + tracking: () => false, + }, +); + +runes.$state = stateRune; +runes.$derived = Object.assign(identity, { by: (fn: () => unknown) => fn() }); +runes.$effect = effectRune; + +const storageBacking = new Map(); +const storageListeners: ((event: unknown) => void)[] = []; + +const localStorageShim = { + getItem: (key: string) => (storageBacking.has(key) ? storageBacking.get(key)! : null), + setItem: (key: string, value: string) => { + storageBacking.set(key, String(value)); + }, + removeItem: (key: string) => { + storageBacking.delete(key); + }, + clear: () => storageBacking.clear(), +}; + +const windowShim = { + __TAURI_INTERNALS__: { invoke: async () => 'macos' }, + addEventListener: (type: string, fn: (event: unknown) => void) => { + if (type === 'storage') storageListeners.push(fn); + }, + removeEventListener: (type: string, fn: (event: unknown) => void) => { + if (type !== 'storage') return; + const index = storageListeners.indexOf(fn); + if (index >= 0) storageListeners.splice(index, 1); + }, +}; + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageShim, configurable: true }); +Object.defineProperty(globalThis, 'window', { value: windowShim, configurable: true }); +Object.defineProperty(globalThis, 'navigator', { value: { language: 'en-US' }, configurable: true }); + +const settingsModule = await import('../src/lib/stores/settings.svelte.js'); +const { + CODE_FONT_SIZE_RANGE, + EDITOR_FONT_SIZE_RANGE, + EDITOR_MAX_WIDTH_RANGE, + PREVIEW_FONT_SIZE_RANGE, + SettingsStore, + clampToRange, + createSettingsPersistence, + detectSystemLanguage, + isWithinRange, + parseStoredNumber, + resolveLanguageTag, + stepWithinRange, + writeStoredSetting, +} = settingsModule; + +type PersistedEntry = ReturnType[number]; + +const storeSource = readFileSync(new URL('../src/lib/stores/settings.svelte.ts', import.meta.url), 'utf8'); +const componentSource = readFileSync(new URL('../src/lib/components/Settings.svelte', import.meta.url), 'utf8'); + +function resetStorage(seed: Record = {}) { + storageBacking.clear(); + for (const [key, value] of Object.entries(seed)) storageBacking.set(key, value); +} + +/** + * A stand-in store whose property reads are recorded. Values are plausible so + * that the entries' normalizers behave, but only the *set of property names* + * touched matters. + */ +function createRecordingStore(): { proxy: InstanceType; reads: Set } { + const reads = new Set(); + resetStorage(); + const real = new SettingsStore(); + const proxy = new Proxy(real as Record, { + get(target, property, receiver) { + if (typeof property === 'string') reads.add(property); + return Reflect.get(target, property, receiver); + }, + }) as InstanceType; + return { proxy, reads }; +} + +/* + * --------------------------------------------------------------------------- + * Task 1 — one localStorage entry per store field. + * --------------------------------------------------------------------------- + */ + +test('each persisted entry reads exactly one store field', () => { + const entries = createSettingsPersistence(); + const { proxy, reads } = createRecordingStore(); + + for (const entry of entries) { + reads.clear(); + entry.read(proxy); + assert.equal( + reads.size, + 1, + `entry "${entry.key}" reads ${reads.size} fields (${[...reads].join(', ')}); it must read exactly one`, + ); + } +}); + +test('no store field is read by two persisted entries', () => { + // Svelte's dependency tracking *is* "which properties did this effect read". + // With one effect per entry, a field read by two entries would mean changing + // it rewrites two keys — the multi-window clobbering bug in miniature. This + // asserts the dependency graph is a bijection, not an approximation of one. + const entries = createSettingsPersistence(); + const { proxy, reads } = createRecordingStore(); + const owner = new Map(); + + for (const entry of entries) { + reads.clear(); + entry.read(proxy); + for (const field of reads) { + const previous = owner.get(field); + assert.equal( + previous, + undefined, + `field "${field}" is read by both "${previous}" and "${entry.key}"`, + ); + owner.set(field, entry.key); + } + } + + assert.equal(owner.size, entries.length); +}); + +test('persisted keys are unique and cover the documented settings surface', () => { + const entries = createSettingsPersistence(); + const keys = entries.map((entry) => entry.key); + assert.equal(new Set(keys).size, keys.length, 'duplicate localStorage key'); + for (const key of ['editor.fontSize', 'editor.language', 'preview.maxWidth', 'editor.preZenState']) { + assert.ok(keys.includes(key), `missing persisted key ${key}`); + } +}); + +test('persistence installs one effect per key plus the storage listener', () => { + // The regression this guards: a single effect that read every field, so any + // one change rewrote all ~30 keys from a stale snapshot. + resetStorage(); + storageListeners.length = 0; + registeredEffects.length = 0; + + const store = new SettingsStore(); + assert.ok(store instanceof SettingsStore); + + const entryCount = createSettingsPersistence().length; + assert.equal(registeredEffects.length, entryCount + 1); + assert.equal(storageListeners.length, 1); +}); + +test('changing one setting rewrites only that setting key', () => { + const entries = createSettingsPersistence(); + resetStorage(); + const store = new SettingsStore(); + + // Snapshot what each entry's effect last wrote, then re-run only the effects + // whose single dependency actually changed — which is what Svelte does. + const lastWritten = new Map(entries.map((entry) => [entry.key, entry.read(store)])); + const flush = () => { + const written: string[] = []; + for (const entry of entries) { + const next = entry.read(store); + if (lastWritten.get(entry.key) === next) continue; + lastWritten.set(entry.key, next); + if (writeStoredSetting(entry.key, next)) written.push(entry.key); + } + return written; + }; + + flush(); + store.minimap = !store.minimap; + assert.deepEqual(flush(), ['editor.minimap']); + + store.editorFontSize = 30; + store.language = 'ja'; + assert.deepEqual(flush().sort(), ['editor.fontSize', 'editor.language']); +}); + +test('a second window no longer clobbers what the first window just changed', () => { + // Window A and window B are separate webviews with separate store instances + // over one shared localStorage. + const entries = createSettingsPersistence(); + resetStorage(); + + const windowA = new SettingsStore(); + const windowB = new SettingsStore(); // holds its own construction-time snapshot + + // A changes font size and language and persists just those two keys. + windowA.editorFontSize = 30; + windowA.language = 'ja'; + for (const entry of entries) writeStoredSetting(entry.key, entry.read(windowA)); + + // B, still unaware, flips an unrelated toggle and persists only its own key. + windowB.minimap = !windowB.minimap; + const minimapEntry = entries.find((entry) => entry.key === 'editor.minimap') as PersistedEntry; + writeStoredSetting(minimapEntry.key, minimapEntry.read(windowB)); + + assert.equal(localStorageShim.getItem('editor.fontSize'), '30'); + assert.equal(localStorageShim.getItem('editor.language'), 'ja'); + + // And a fresh window (a restart) sees A's changes intact. + const restarted = new SettingsStore(); + assert.equal(restarted.editorFontSize, 30); + assert.equal(restarted.language, 'ja'); +}); + +test('storage events fold another window changes into this instance', () => { + resetStorage(); + storageListeners.length = 0; + const store = new SettingsStore(); + assert.equal(storageListeners.length, 1); + const onStorage = storageListeners[0]; + + // Another window wrote the value; localStorage already reflects it when the + // event is delivered here. + localStorageShim.setItem('editor.fontSize', '30'); + onStorage({ key: 'editor.fontSize', newValue: '30', storageArea: localStorageShim }); + assert.equal(store.editorFontSize, 30); + + localStorageShim.setItem('editor.language', 'ja'); + onStorage({ key: 'editor.language', newValue: 'ja', storageArea: localStorageShim }); + assert.equal(store.language, 'ja'); +}); + +test('a value that arrived from localStorage is not written back', () => { + // The anti-echo property. No flag is involved: the write-back is dropped + // because compare-and-set finds the value already stored. + const entries = createSettingsPersistence(); + resetStorage(); + storageListeners.length = 0; + const store = new SettingsStore(); + const onStorage = storageListeners[0]; + const entry = entries.find((item) => item.key === 'editor.fontSize') as PersistedEntry; + + localStorageShim.setItem('editor.fontSize', '30'); + onStorage({ key: 'editor.fontSize', newValue: '30', storageArea: localStorageShim }); + + // This is the follow-up effect run that a naive implementation would use to + // echo the value straight back to the other window. + assert.equal(writeStoredSetting(entry.key, entry.read(store)), false); +}); + +test('writeStoredSetting is compare-and-set and removes on null', () => { + resetStorage(); + assert.equal(writeStoredSetting('demo.key', 'a'), true); + assert.equal(writeStoredSetting('demo.key', 'a'), false); + assert.equal(writeStoredSetting('demo.key', 'b'), true); + assert.equal(writeStoredSetting('demo.key', null), true); + assert.equal(localStorageShim.getItem('demo.key'), null); + assert.equal(writeStoredSetting('demo.key', null), false); +}); + +test('a cleared localStorage is re-read rather than half-applied', () => { + resetStorage({ 'editor.fontSize': '30' }); + storageListeners.length = 0; + const store = new SettingsStore(); + assert.equal(store.editorFontSize, 30); + + storageBacking.clear(); + storageListeners[0]({ key: null, newValue: null, storageArea: localStorageShim }); + assert.equal(store.editorFontSize, EDITOR_FONT_SIZE_RANGE.default); +}); + +/* + * --------------------------------------------------------------------------- + * Task 2 — UI bounds and persisted bounds are the same constants. + * --------------------------------------------------------------------------- + */ + +test('font size ranges reach the maximum the UI already offers', () => { + assert.equal(EDITOR_FONT_SIZE_RANGE.max, 48); + assert.equal(PREVIEW_FONT_SIZE_RANGE.max, 48); + assert.equal(CODE_FONT_SIZE_RANGE.max, 48); + assert.equal(EDITOR_FONT_SIZE_RANGE.min, 10); + assert.equal(PREVIEW_FONT_SIZE_RANGE.min, 12); + assert.equal(CODE_FONT_SIZE_RANGE.min, 10); + assert.deepEqual( + { min: EDITOR_MAX_WIDTH_RANGE.min, max: EDITOR_MAX_WIDTH_RANGE.max, default: EDITOR_MAX_WIDTH_RANGE.default }, + { min: 20, max: 500, default: 80 }, + ); +}); + +test('a size the UI allows survives a restart', () => { + // Regression: 30 used to be clamped back to 24 (editor/code) or 28 (preview) + // on load, so the value silently shrank on the next launch. + resetStorage({ + 'editor.fontSize': '30', + 'preview.fontSize': '40', + 'preview.codeFontSize': '48', + 'editor.maxWidth': '500', + }); + const store = new SettingsStore(); + assert.equal(store.editorFontSize, 30); + assert.equal(store.previewFontSize, 40); + assert.equal(store.codeFontSize, 48); + assert.equal(store.editorMaxWidth, 500); +}); + +test('values outside the shared range are still clamped on load', () => { + resetStorage({ 'editor.fontSize': '900', 'preview.fontSize': '2', 'editor.maxWidth': '5000' }); + const store = new SettingsStore(); + assert.equal(store.editorFontSize, EDITOR_FONT_SIZE_RANGE.max); + assert.equal(store.previewFontSize, PREVIEW_FONT_SIZE_RANGE.min); + assert.equal(store.editorMaxWidth, EDITOR_MAX_WIDTH_RANGE.max); +}); + +test('the settings UI renders its bounds from the shared range constants', () => { + for (const range of ['EDITOR_FONT_SIZE_RANGE', 'PREVIEW_FONT_SIZE_RANGE', 'CODE_FONT_SIZE_RANGE', 'EDITOR_MAX_WIDTH_RANGE']) { + assert.match(componentSource, new RegExp(`min=\\{${range}\\.min\\}`), `${range} min not wired to the UI`); + assert.match(componentSource, new RegExp(`max=\\{${range}\\.max\\}`), `${range} max not wired to the UI`); + } + assert.doesNotMatch(componentSource, /max="48"/, 'hard-coded UI bound left behind'); +}); + +/* + * --------------------------------------------------------------------------- + * Task 3 — typed input is validated before it reaches the store. + * --------------------------------------------------------------------------- + */ + +test('an empty or unparseable field falls back to the default, never to null', () => { + assert.equal(parseStoredNumber('', EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.default); + assert.equal(parseStoredNumber(' ', EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.default); + assert.equal(parseStoredNumber(null, EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.default); + assert.equal(parseStoredNumber('null', EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.default); + assert.equal(parseStoredNumber('abc', EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.default); + assert.equal(clampToRange(null, EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.default); + assert.equal(clampToRange(Number.NaN, EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.default); +}); + +test('a half-typed value is neither accepted nor clamped mid-keystroke', () => { + // Typing "18" passes through "1". Clamping there would jump the field to 10 + // and fight the user, so in-range is the acceptance test while typing. + assert.equal(isWithinRange(1, EDITOR_FONT_SIZE_RANGE), false); + assert.equal(isWithinRange(18, EDITOR_FONT_SIZE_RANGE), true); + assert.equal(isWithinRange(4, EDITOR_MAX_WIDTH_RANGE), false); + assert.equal(isWithinRange(40, EDITOR_MAX_WIDTH_RANGE), true); + assert.equal(isWithinRange(Number.NaN, EDITOR_FONT_SIZE_RANGE), false); + // ...and committing settles it inside the range. + assert.equal(clampToRange(1, EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.min); + assert.equal(clampToRange(4, EDITOR_MAX_WIDTH_RANGE), EDITOR_MAX_WIDTH_RANGE.min); +}); + +test('spin buttons clamp with the same rules as typing', () => { + assert.equal(stepWithinRange(EDITOR_FONT_SIZE_RANGE.max, 1, EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.max); + assert.equal(stepWithinRange(EDITOR_FONT_SIZE_RANGE.min, -1, EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.min); + assert.equal(stepWithinRange(47, 1, EDITOR_FONT_SIZE_RANGE), 48); + assert.equal(stepWithinRange(490, 10, EDITOR_MAX_WIDTH_RANGE), 500); + // A corrupt current value cannot escape the range either. + assert.equal(stepWithinRange(Number.NaN, 1, EDITOR_FONT_SIZE_RANGE), EDITOR_FONT_SIZE_RANGE.default + 1); +}); + +test('numeric setting inputs are one-way bound and commit through the clamp', () => { + const numericInputIds = ['editor-font-size', 'editor-max-width', 'preview-font-size', 'code-font-size']; + for (const id of numericInputIds) { + const block = componentSource.slice(componentSource.indexOf(`id="${id}"`)); + const input = block.slice(0, block.indexOf('/>')); + assert.doesNotMatch(input, /bind:value/, `${id} still uses a two-way number binding`); + assert.match(input, /oninput=\{\(e\) => handleNumberInput\(/, `${id} does not validate input`); + assert.match(input, /onchange=\{\(e\) => commitNumberInput\(/, `${id} does not clamp on change`); + assert.match(input, /onblur=\{\(e\) => commitNumberInput\(/, `${id} does not clamp on blur`); + assert.match(input, /onkeydown=\{\(e\) => handleNumberKeydown\(/, `${id} does not clamp on Enter`); + } + assert.match(componentSource, /function commitNumberInput[\s\S]{0,400}input\.value = String\(next\)/); +}); + +/* + * --------------------------------------------------------------------------- + * Task 4 — language tag resolution. + * --------------------------------------------------------------------------- + */ + +test('Norwegian BCP-47 tags resolve to the Norwegian catalogue entry', () => { + // `no` is the macrolanguage; browsers report nb/nn. + assert.equal(resolveLanguageTag('nb'), 'no'); + assert.equal(resolveLanguageTag('nb-NO'), 'no'); + assert.equal(resolveLanguageTag('nn'), 'no'); + assert.equal(resolveLanguageTag('nn-NO'), 'no'); + assert.equal(resolveLanguageTag('no'), 'no'); +}); + +test('Dutch is not swallowed by the Norwegian rule', () => { + assert.equal(resolveLanguageTag('nl'), 'nl'); + assert.equal(resolveLanguageTag('nl-NL'), 'nl'); + assert.equal(resolveLanguageTag('nl-BE'), 'nl'); +}); + +test('Traditional Chinese is detected from the script subtag, not just the region', () => { + assert.equal(resolveLanguageTag('zh-Hant'), 'zh-TW'); + assert.equal(resolveLanguageTag('zh-Hant-TW'), 'zh-TW'); + assert.equal(resolveLanguageTag('zh-Hant-HK'), 'zh-TW'); + assert.equal(resolveLanguageTag('zh-TW'), 'zh-TW'); + assert.equal(resolveLanguageTag('zh-HK'), 'zh-TW'); + assert.equal(resolveLanguageTag('zh-MO'), 'zh-TW'); + assert.equal(resolveLanguageTag('zh-Hans'), 'zh-CN'); + assert.equal(resolveLanguageTag('zh-Hans-CN'), 'zh-CN'); + assert.equal(resolveLanguageTag('zh'), 'zh-CN'); + assert.equal(resolveLanguageTag('zh-CN'), 'zh-CN'); +}); + +test('regional variants and unsupported tags resolve predictably', () => { + assert.equal(resolveLanguageTag('pt-BR'), 'pt-BR'); + assert.equal(resolveLanguageTag('pt'), 'pt'); + assert.equal(resolveLanguageTag('pt-PT'), 'pt'); + assert.equal(resolveLanguageTag('en_GB'), 'en'); + assert.equal(resolveLanguageTag('ar'), null); + assert.equal(resolveLanguageTag('nog'), null); // used to match startsWith('no') + assert.equal(resolveLanguageTag(''), null); + assert.equal(resolveLanguageTag(null), null); + assert.equal(resolveLanguageTag(undefined), null); +}); + +test('system detection falls back to English for unsupported locales', () => { + Object.defineProperty(globalThis, 'navigator', { value: { language: 'ar-EG' }, configurable: true }); + assert.equal(detectSystemLanguage(), 'en'); + Object.defineProperty(globalThis, 'navigator', { value: { language: 'nb-NO' }, configurable: true }); + assert.equal(detectSystemLanguage(), 'no'); + Object.defineProperty(globalThis, 'navigator', { value: { language: 'en-US' }, configurable: true }); +}); + +test('a stored language is validated against the catalogue', () => { + resetStorage({ 'editor.language': 'klingon' }); + Object.defineProperty(globalThis, 'navigator', { value: { language: 'ja-JP' }, configurable: true }); + assert.equal(new SettingsStore().language, 'en'); // unchanged default, not the bogus code + + resetStorage(); + assert.equal(new SettingsStore().language, 'ja'); // nothing stored -> detect + Object.defineProperty(globalThis, 'navigator', { value: { language: 'en-US' }, configurable: true }); +}); + +/* + * --------------------------------------------------------------------------- + * Task 5 — the settings dialog restores focus to whatever opened it. + * --------------------------------------------------------------------------- + */ + +test('the open effect neither reads the app version nor re-enters itself', () => { + const effectStart = componentSource.indexOf('$effect(() => {\n\t\tif (show) {'); + assert.ok(effectStart > 0, 'settings open effect not found'); + const effectBody = componentSource.slice(effectStart, componentSource.indexOf('\n\t});', effectStart)); + + // Reading a state it also writes is what made the effect re-run and + // re-capture the focus target from inside the dialog. + assert.doesNotMatch(effectBody, /appVersion/, 'open effect still depends on appVersion'); + assert.match(componentSource, /function ensureAppVersion\(\)/); + assert.match(componentSource, /let versionRequested = false;/); + + // `loaded` and `previousActiveElement` are read and written by this effect, + // so they must not be reactive. + assert.match(componentSource, /\n\tlet loaded = false;/); + assert.match(componentSource, /\n\tlet previousActiveElement: HTMLElement \| null = null;/); +}); + +/* + * --------------------------------------------------------------------------- + * Structural guards on the store itself. + * --------------------------------------------------------------------------- + */ + +test('the store listens for cross-window storage events', () => { + assert.match(storeSource, /addEventListener\('storage', onStorage\)/); + assert.match(storeSource, /removeEventListener\('storage', onStorage\)/); +}); + +test('the store has no single effect that writes every key', () => { + const setItemCalls = storeSource.match(/localStorage\.setItem\(/g) ?? []; + assert.equal(setItemCalls.length, 1, 'localStorage writes must funnel through writeStoredSetting'); + assert.match(storeSource, /function writeStoredSetting[\s\S]{0,600}if \(current === value\) return false;/); +}); diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index 27bedb99..2f8c11ed 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -2,7 +2,20 @@ import { invoke } from '@tauri-apps/api/core'; import { getVersion } from '@tauri-apps/api/app'; import { openUrl } from '@tauri-apps/plugin-opener'; - import { settings, DEFAULT_FONTS, type OSType } from '../stores/settings.svelte.js'; + import { + settings, + clampToRange, + isWithinRange, + parseStoredNumber, + stepWithinRange, + CODE_FONT_SIZE_RANGE, + DEFAULT_FONTS, + EDITOR_FONT_SIZE_RANGE, + EDITOR_MAX_WIDTH_RANGE, + PREVIEW_FONT_SIZE_RANGE, + type NumericSettingRange, + type OSType, + } from '../stores/settings.svelte.js'; import { updateStore } from '../stores/update.svelte.js'; import { fade, scale, fly } from 'svelte/transition'; import { t, getSupportedLanguages } from '../utils/i18n.js'; @@ -29,6 +42,58 @@ function updatePreviewMaxWidth(value: unknown) { settings.previewMaxWidth = normalizePreviewMaxWidth(value); } + + /** + * Keystroke handler for a numeric setting input. + * + * `min`/`max` on `` only drive validation styling and + * the spin buttons; they do not stop typing, and Svelte's `bind:value` + * assigns `null` for an empty field — which used to reach the store and + * render as `font-size: nullpx` before being persisted as the string + * "null". So the binding is one-way and every commit goes through the + * shared range. + * + * While typing we accept only values that already fall inside the range and + * ignore everything else, rather than clamping eagerly: clamping on each + * keystroke would turn the intermediate "1" of "18" into "10" and leave the + * caret stranded. Out-of-range or empty input is settled on commit instead. + */ + function handleNumberInput(event: Event, range: NumericSettingRange, assign: (value: number) => void) { + const input = event.currentTarget as HTMLInputElement; + if (input.value.trim() === '') return; + const parsed = Number(input.value); + if (!isWithinRange(parsed, range)) return; + assign(Math.round(parsed)); + } + + /** + * Commit handler (blur / Enter). Clamps, falls back to the range default for + * an empty or unparseable field, and writes the result back to the DOM — + * the element needs that explicitly, because when the clamped value equals + * the value already in the store there is no state change for Svelte to + * re-render from and the input would keep showing the rejected text. + */ + function commitNumberInput(event: Event, range: NumericSettingRange, assign: (value: number) => void) { + const input = event.currentTarget as HTMLInputElement; + const next = parseStoredNumber(input.value, range); + assign(next); + input.value = String(next); + } + + function handleNumberKeydown(event: KeyboardEvent, range: NumericSettingRange, assign: (value: number) => void) { + if (event.key !== 'Enter') return; + commitNumberInput(event, range, assign); + } + + /** Spin buttons share the clamping rules with typing and with persistence. */ + function stepSetting(current: number, delta: number, range: NumericSettingRange, assign: (value: number) => void) { + assign(stepWithinRange(current, delta, range)); + } + + const setEditorFontSize = (value: number) => { settings.editorFontSize = clampToRange(value, EDITOR_FONT_SIZE_RANGE); }; + const setEditorMaxWidth = (value: number) => { settings.editorMaxWidth = clampToRange(value, EDITOR_MAX_WIDTH_RANGE); }; + const setPreviewFontSize = (value: number) => { settings.previewFontSize = clampToRange(value, PREVIEW_FONT_SIZE_RANGE); }; + const setCodeFontSize = (value: number) => { settings.codeFontSize = clampToRange(value, CODE_FONT_SIZE_RANGE); }; const highlightColors = [ { value: 'default', color: 'var(--color-accent-fg)' }, { value: 'yellow', color: '#ffd000' }, @@ -84,9 +149,14 @@ ]; let systemFonts = $state([]); - let loaded = $state(false); + // Plain variables on purpose: neither is rendered, and both are read *and* + // written by the open-effect below. As `$state` they made that effect + // re-enter itself — `loadFonts()` reads `loaded` synchronously and sets it + // after its await, which re-ran the effect and clobbered the saved + // `previousActiveElement` with a control inside the dialog. + let loaded = false; + let previousActiveElement: HTMLElement | null = null; let settingsModal = $state(); - let previousActiveElement = $state(null); let appVersion = $state(''); let osType = $state('unknown'); let defaultFonts = $derived(DEFAULT_FONTS[osType] || DEFAULT_FONTS.unknown); @@ -498,14 +568,25 @@ } } + // Guarded by a plain (non-reactive) flag rather than by `if (!appVersion)`. + // The old form made the open-effect below *read* `appVersion` and the + // resolved promise *write* it, so the whole effect re-ran once the version + // arrived — and the re-run re-captured `previousActiveElement`, which by then + // was a button inside the dialog. Closing settings then returned focus into + // the dialog instead of to the control that opened it. + let versionRequested = false; + function ensureAppVersion() { + if (versionRequested) return; + versionRequested = true; + getVersion() + .then((v) => (appVersion = v)) + .catch(console.error); + } + $effect(() => { if (show) { loadFonts(); - if (!appVersion) { - getVersion() - .then((v) => (appVersion = v)) - .catch(console.error); - } + ensureAppVersion(); loadVscodeThemes(); previousActiveElement = document.activeElement as HTMLElement; setTimeout(() => { @@ -742,7 +823,7 @@

{t('settings.editorSettings', settings.language)}

@@ -773,17 +854,29 @@
- - -
- px + px · {EDITOR_FONT_SIZE_RANGE.min}–{EDITOR_FONT_SIZE_RANGE.max} · {t('settings.default', settings.language)} {EDITOR_FONT_SIZE_RANGE.default}
@@ -791,17 +884,30 @@
- - -
- chars + chars · {EDITOR_MAX_WIDTH_RANGE.min}–{EDITOR_MAX_WIDTH_RANGE.max} · {t('settings.default', settings.language)} {EDITOR_MAX_WIDTH_RANGE.default}
@@ -911,7 +1017,7 @@

{t('settings.previewSettings', settings.language)}

- - - px + px · {PREVIEW_FONT_SIZE_RANGE.min}–{PREVIEW_FONT_SIZE_RANGE.max} · {t('settings.default', settings.language)} {PREVIEW_FONT_SIZE_RANGE.default} @@ -1012,17 +1130,29 @@
- - -
- px + px · {CODE_FONT_SIZE_RANGE.min}–{CODE_FONT_SIZE_RANGE.max} · {t('settings.default', settings.language)} {CODE_FONT_SIZE_RANGE.default}
diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index 1196ee3b..99399d52 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -81,40 +81,79 @@ export const SUPPORTED_LANGUAGES: { code: LanguageCode; name: string; nativeName { code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文' }, ]; -function detectSystemLanguage(): LanguageCode { - if (typeof navigator !== 'undefined') { - const browserLang = navigator.language.toLowerCase(); - if (browserLang.startsWith('zh')) { - if (browserLang === 'zh-tw' || browserLang === 'zh-hk') return 'zh-TW'; - return 'zh-CN'; - } - if (browserLang.startsWith('ja')) return 'ja'; - if (browserLang.startsWith('ko')) return 'ko'; - if (browserLang.startsWith('ru')) return 'ru'; - if (browserLang.startsWith('es')) return 'es'; - if (browserLang.startsWith('fr')) return 'fr'; - if (browserLang.startsWith('de')) return 'de'; - if (browserLang.startsWith('pt')) { - if (browserLang === 'pt-br') return 'pt-BR'; - return 'pt'; - } - if (browserLang.startsWith('it')) return 'it'; - if (browserLang.startsWith('pl')) return 'pl'; - if (browserLang.startsWith('nl')) return 'nl'; - if (browserLang.startsWith('sv')) return 'sv'; - if (browserLang.startsWith('vi')) return 'vi'; - if (browserLang.startsWith('ro')) return 'ro'; - if (browserLang.startsWith('hu')) return 'hu'; - if (browserLang.startsWith('cs')) return 'cs'; - if (browserLang.startsWith('sk')) return 'sk'; - if (browserLang.startsWith('el')) return 'el'; - if (browserLang.startsWith('fi')) return 'fi'; - if (browserLang.startsWith('da')) return 'da'; - if (browserLang.startsWith('no')) return 'no'; - if (browserLang.startsWith('id')) return 'id'; - if (browserLang.startsWith('tr')) return 'tr'; - } - return 'en'; +export const SUPPORTED_LANGUAGE_CODES: readonly LanguageCode[] = SUPPORTED_LANGUAGES.map((entry) => entry.code); + +export function isSupportedLanguage(value: unknown): value is LanguageCode { + return typeof value === 'string' && (SUPPORTED_LANGUAGE_CODES as readonly string[]).includes(value); +} + +/** + * Maps a BCP-47 *primary language subtag* to one of our catalogue entries. + * + * Matching is on the exact primary subtag rather than a `startsWith()` prefix: + * prefix matching silently confuses distinct languages that share leading + * letters (`nl` Dutch vs. `nn`/`nb` Norwegian), and it also matches unrelated + * tags such as `nog` (Nogai). + */ +const LANGUAGE_BY_PRIMARY_SUBTAG: Readonly> = { + cs: 'cs', + da: 'da', + de: 'de', + el: 'el', + en: 'en', + es: 'es', + fi: 'fi', + fr: 'fr', + hu: 'hu', + id: 'id', + it: 'it', + ja: 'ja', + ko: 'ko', + nl: 'nl', + // Our catalogue is keyed by the `no` macrolanguage, but BCP-47 tags in the + // wild are `nb` (Bokmål) or `nn` (Nynorsk); browsers essentially never + // report a bare `no`. Accept all three. + nb: 'no', + nn: 'no', + no: 'no', + pl: 'pl', + ro: 'ro', + ru: 'ru', + sk: 'sk', + sv: 'sv', + tr: 'tr', + vi: 'vi', +}; + +/** + * Resolves a BCP-47 language tag to a supported catalogue code, or `null` when + * we have no translation for it. Pure so it can be unit-tested directly. + */ +export function resolveLanguageTag(tag: string | null | undefined): LanguageCode | null { + if (typeof tag !== 'string') return null; + const subtags = tag.trim().toLowerCase().split(/[-_]/).filter(Boolean); + const primary = subtags[0]; + if (!primary) return null; + const rest = subtags.slice(1); + + if (primary === 'zh') { + // The script subtag is authoritative when present: `zh-Hant` and + // `zh-Hant-TW` are Traditional even without a Traditional region, and + // `zh-Hans-HK` is Simplified despite the Hong Kong region. + if (rest.includes('hant')) return 'zh-TW'; + if (rest.includes('hans')) return 'zh-CN'; + if (rest.some((subtag) => subtag === 'tw' || subtag === 'hk' || subtag === 'mo')) return 'zh-TW'; + return 'zh-CN'; + } + if (primary === 'pt') { + return rest.includes('br') ? 'pt-BR' : 'pt'; + } + return LANGUAGE_BY_PRIMARY_SUBTAG[primary] ?? null; +} + +export function detectSystemLanguage(): LanguageCode { + if (typeof navigator === 'undefined') return 'en'; + return resolveLanguageTag(navigator.language) ?? 'en'; } export interface DefaultFonts { @@ -146,6 +185,172 @@ export const DEFAULT_FONTS: Record = { }, }; +/** + * The allowed range of a numeric setting. Single source of truth: the settings + * UI renders `min`/`max`/`step` from it and every write path (typing, spin + * buttons, reset, load from localStorage) clamps against the same object, so + * the UI can never offer a value that persistence would silently shrink. + */ +export interface NumericSettingRange { + readonly min: number; + readonly max: number; + readonly step: number; + readonly default: number; +} + +// Upper bound is 48 across all three font sizes: the spin buttons already let +// users reach 48, so profiles in the wild contain sizes above the old clamp of +// 24/28. Aligning persistence up to the UI keeps those settings; aligning the +// UI down to persistence would shrink an existing user's editor on upgrade. +export const EDITOR_FONT_SIZE_RANGE: NumericSettingRange = { min: 10, max: 48, step: 1, default: 14 }; +export const PREVIEW_FONT_SIZE_RANGE: NumericSettingRange = { min: 12, max: 48, step: 1, default: 16 }; +export const CODE_FONT_SIZE_RANGE: NumericSettingRange = { min: 10, max: 48, step: 1, default: 14 }; +export const EDITOR_MAX_WIDTH_RANGE: NumericSettingRange = { min: 20, max: 500, step: 10, default: 80 }; +export const TOC_WIDTH_RANGE: NumericSettingRange = { min: 180, max: 420, step: 1, default: 240 }; + +/** True when `value` is a finite number the user is allowed to commit as-is. */ +export function isWithinRange(value: unknown, range: NumericSettingRange): boolean { + return typeof value === 'number' && Number.isFinite(value) && value >= range.min && value <= range.max; +} + +/** + * Coerces anything to an in-range integer, falling back to the range default. + * + * "No value" (null/undefined/empty) is the default rather than the minimum: + * `Number(null)` is 0, so clamping it blindly would turn a cleared input into + * the smallest legal font size instead of the one the user started with. + */ +export function clampToRange(value: unknown, range: NumericSettingRange): number { + if (value === null || value === undefined || value === '') return range.default; + const parsed = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(parsed)) return range.default; + return Math.min(range.max, Math.max(range.min, Math.round(parsed))); +} + +/** Parses a persisted string. Missing/blank/garbage all fall back to the default. */ +export function parseStoredNumber(raw: string | null, range: NumericSettingRange): number { + if (raw === null || raw.trim() === '') return range.default; + return clampToRange(raw, range); +} + +/** Spin-button helper: nudge by `delta` steps and clamp with the same rules. */ +export function stepWithinRange(current: unknown, delta: number, range: NumericSettingRange): number { + return clampToRange(clampToRange(current, range) + delta, range); +} + +function parseStoredStringList(value: string | null): string[] | null { + if (value === null) return null; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : null; + } catch { + return null; + } +} + +function parseStoredRecord(value: string | null): Record | null { + if (value === null) return null; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null; + } catch { + return null; + } +} + +/** + * One persisted localStorage entry. + * + * `read` must touch **exactly one** field of the target. That is not a style + * rule: `read` runs inside the entry's own `$effect`, so whatever it reads + * becomes that effect's dependency set, and every dependency is a field whose + * change rewrites this key. A `read` that touched two fields would resurrect + * the multi-window bug this shape exists to fix. + */ +export interface PersistedSetting { + readonly key: string; + /** Serializes this entry's single field. `null` means "remove the key". */ + readonly read: (target: T) => string | null; + /** Applies a stored (or remotely changed) raw value back onto that field. */ + readonly load: (target: T, raw: string | null) => void; +} + +/** + * Compare-and-set write. Returns true when localStorage actually changed. + * + * This is the whole anti-echo mechanism for cross-window sync, and it is why no + * "I am currently applying a remote change" flag is involved. Such a flag + * cannot work here: Svelte flushes effects asynchronously, so a flag raised and + * lowered synchronously inside a `storage` handler is long gone by the time the + * dependent effect runs — and holding it up until the next flush would also + * swallow genuine local edits made in the same tick. + * + * Reading before writing needs no timing assumption at all. A value that + * arrived *from* localStorage is by definition already equal to what is stored, + * so the write-back is dropped at its source and the + * `storage` -> state -> effect -> write -> `storage` loop terminates after one + * hop. It also makes redundant writes free in the ordinary single-window case. + */ +export function writeStoredSetting(key: string, value: string | null): boolean { + if (typeof localStorage === 'undefined') return false; + const current = localStorage.getItem(key); + if (value === null) { + if (current === null) return false; + localStorage.removeItem(key); + return true; + } + if (current === value) return false; + localStorage.setItem(key, value); + return true; +} + +/** Applies everything currently in localStorage onto `target`. */ +export function loadPersistedSettings(target: T, entries: readonly PersistedSetting[]): void { + if (typeof localStorage === 'undefined') return; + for (const entry of entries) { + entry.load(target, localStorage.getItem(entry.key)); + } +} + +/** + * Wires `entries` up to localStorage. Must be called inside an effect root. + * + * One effect per key, instead of one effect reading every field: a change to + * setting A then rewrites only A's key. Markpad opens several windows, each a + * separate webview with its own store instance, all sharing one localStorage — + * with a single all-fields effect, any change in window B rewrote all ~30 keys + * from B's construction-time snapshot and threw away whatever window A had just + * changed. + * + * The `storage` listener closes the other half: localStorage fires it in every + * *other* same-origin document, so each window folds its siblings' changes into + * its own state instead of drifting until the next restart. + */ +export function installPersistedSettings(target: T, entries: readonly PersistedSetting[]): void { + for (const entry of entries) { + $effect(() => { + writeStoredSetting(entry.key, entry.read(target)); + }); + } + + $effect(() => { + if (typeof window === 'undefined') return; + const entriesByKey = new Map(entries.map((entry) => [entry.key, entry])); + const onStorage = (event: StorageEvent) => { + if (event.storageArea && typeof localStorage !== 'undefined' && event.storageArea !== localStorage) return; + // A null key means the whole store was cleared; re-read everything. + if (event.key === null) { + loadPersistedSettings(target, entries); + return; + } + const entry = entriesByKey.get(event.key); + if (entry) entry.load(target, event.newValue); + }; + window.addEventListener('storage', onStorage); + return () => window.removeEventListener('storage', onStorage); + }); +} + export class SettingsStore { minimap = $state(false); wordWrap = $state('on'); @@ -202,201 +407,33 @@ export class SettingsStore { confirmBeforeSave = $state(false); constructor() { - if (typeof localStorage !== 'undefined') { - const savedMinimap = localStorage.getItem('editor.minimap'); - const savedWordWrap = localStorage.getItem('editor.wordWrap'); - const savedLineNumbers = localStorage.getItem('editor.lineNumbers'); - const savedVimMode = localStorage.getItem('editor.vimMode'); - const savedStatusBar = localStorage.getItem('editor.statusBar'); - - const savedWordCount = localStorage.getItem('editor.wordCount'); - const savedRenderLineHighlight = localStorage.getItem('editor.renderLineHighlight'); - const savedShowTabs = localStorage.getItem('editor.showTabs'); - const savedRestoreStateOnReopen = localStorage.getItem('editor.restoreStateOnReopen'); - const savedZenMode = localStorage.getItem('editor.zenMode'); - const savedPreZenState = localStorage.getItem('editor.preZenState'); - const savedOccurrencesHighlight = localStorage.getItem('editor.occurrencesHighlight'); - const savedShowWhitespace = localStorage.getItem('editor.showWhitespace'); - const savedShowToc = localStorage.getItem('editor.showToc'); - const savedHighlightColor = localStorage.getItem('editor.highlightColor'); - const savedStartInEditor = localStorage.getItem('editor.startInEditor'); - const savedNewFileDefaultMode = localStorage.getItem('editor.newFileDefaultMode'); - const savedShowRecentFiles = localStorage.getItem('editor.showRecentFiles'); - const savedEditorMaxWidth = localStorage.getItem('editor.maxWidth'); - const savedPreviewMaxWidth = localStorage.getItem('preview.maxWidth'); - const savedPinnedToc = localStorage.getItem('editor.pinnedToc'); - const savedTocSide = localStorage.getItem('editor.tocSide'); - const savedTocWidth = localStorage.getItem('editor.tocWidth'); - const savedImageDirectory = localStorage.getItem('editor.imageDirectory'); - const savedMacosImageScaling = localStorage.getItem('editor.macosImageScaling'); - const savedLanguage = localStorage.getItem('editor.language'); - const savedEditorToolbarOrder = localStorage.getItem('editor.toolbarOrder'); - const savedEditorToolbarHidden = localStorage.getItem('editor.toolbarHidden'); - const savedShowEditorToolbar = localStorage.getItem('editor.showEditorToolbar'); - const savedTitlebarToolbarOrder = localStorage.getItem('titlebar.toolbarOrder'); - const savedTitlebarToolbarHidden = localStorage.getItem('titlebar.toolbarHidden'); - const savedTitlebarToolbarPlacement = localStorage.getItem('titlebar.toolbarPlacement'); - - const savedEditorFont = localStorage.getItem('editor.font'); - const savedEditorFontSize = localStorage.getItem('editor.fontSize'); - const savedPreviewFont = localStorage.getItem('preview.font'); - const savedPreviewFontSize = localStorage.getItem('preview.fontSize'); - const savedCodeFont = localStorage.getItem('preview.codeFont'); - const savedCodeFontSize = localStorage.getItem('preview.codeFontSize'); - - const savedAutoSave = localStorage.getItem('editor.autoSave'); - const savedConfirmBeforeSave = localStorage.getItem('editor.confirmBeforeSave'); - if (savedAutoSave !== null) this.autoSave = savedAutoSave === 'true'; - if (savedConfirmBeforeSave !== null) this.confirmBeforeSave = savedConfirmBeforeSave === 'true'; - - const parseFontSize = (value: string | null, fallback: number, min: number, max: number) => { - if (value === null) return fallback; - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed)) return fallback; - return Math.min(max, Math.max(min, parsed)); - }; - const parseStringList = (value: string | null) => { - if (value === null) return null; - try { - const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : null; - } catch { - return null; - } - }; - const parseRecord = (value: string | null) => { - if (value === null) return null; - try { - const parsed = JSON.parse(value); - return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null; - } catch { - return null; - } - }; - - if (savedMinimap !== null) this.minimap = savedMinimap === 'true'; - if (savedWordWrap !== null) this.wordWrap = savedWordWrap; - if (savedLineNumbers !== null) this.lineNumbers = savedLineNumbers; - if (savedVimMode !== null) this.vimMode = savedVimMode === 'true'; - if (savedStatusBar !== null) this.statusBar = savedStatusBar === 'true'; - - if (savedWordCount !== null) this.wordCount = savedWordCount === 'true'; - if (savedRenderLineHighlight !== null) this.renderLineHighlight = savedRenderLineHighlight; - if (savedShowTabs !== null) this.showTabs = savedShowTabs === 'true'; - if (savedRestoreStateOnReopen !== null) this.restoreStateOnReopen = savedRestoreStateOnReopen === 'true'; - if (savedZenMode !== null) this.zenMode = savedZenMode === 'true'; - if (savedOccurrencesHighlight !== null) this.occurrencesHighlight = savedOccurrencesHighlight === 'true'; - if (savedShowWhitespace !== null) this.showWhitespace = savedShowWhitespace === 'true'; - if (savedShowToc !== null) this.showToc = savedShowToc === 'true'; - if (savedHighlightColor !== null) this.highlightColor = savedHighlightColor; - if (savedStartInEditor !== null) this.startInEditor = savedStartInEditor === 'true'; - if (savedNewFileDefaultMode !== null) this.newFileDefaultMode = savedNewFileDefaultMode === 'true'; - if (savedShowRecentFiles !== null) this.showRecentFiles = savedShowRecentFiles === 'true'; - if (savedEditorMaxWidth !== null) this.editorMaxWidth = parseFontSize(savedEditorMaxWidth, 80, 20, 500); - this.previewMaxWidth = normalizePreviewMaxWidth(savedPreviewMaxWidth); - if (savedPinnedToc !== null) this.pinnedToc = savedPinnedToc === 'true'; - if (savedTocSide !== null) this.tocSide = savedTocSide as 'left' | 'right'; - if (savedTocWidth !== null) this.tocWidth = parseFontSize(savedTocWidth, 240, 180, 420); - if (savedImageDirectory !== null) this.imageDirectory = savedImageDirectory; - if (savedMacosImageScaling !== null) this.macosImageScaling = savedMacosImageScaling === 'true'; - if (savedShowEditorToolbar !== null) this.showEditorToolbar = savedShowEditorToolbar === 'true'; - this.editorToolbarOrder = normalizeEditorToolbarOrder(parseStringList(savedEditorToolbarOrder)); - this.editorToolbarHidden = normalizeEditorToolbarHidden(parseStringList(savedEditorToolbarHidden)); - this.titlebarToolbarOrder = normalizeTitlebarToolbarOrder(parseStringList(savedTitlebarToolbarOrder)); - this.titlebarToolbarHidden = normalizeTitlebarToolbarHidden(parseStringList(savedTitlebarToolbarHidden)); - this.titlebarToolbarPlacement = normalizeTitlebarToolbarPlacement(parseRecord(savedTitlebarToolbarPlacement)); - if (savedLanguage !== null) { - const lang = savedLanguage as LanguageCode; - const supportedCodes: LanguageCode[] = ['en', 'ja', 'zh-CN', 'zh-TW', 'ko', 'ru', 'es', 'fr', 'de', 'pt-BR', 'it', 'pl', 'nl', 'sv', 'vi', 'pt', 'ro', 'hu', 'cs', 'sk', 'el', 'fi', 'da', 'no', 'id', 'tr']; - if (supportedCodes.includes(lang)) { - this.language = lang; - } - } else { - this.language = detectSystemLanguage(); - } - if (savedPreZenState !== null) { - try { - this.preZenState = JSON.parse(savedPreZenState); - } catch (e) { - console.error('Failed to parse preZenState', e); - } - } - - this.initOSType().then(() => { - const defaults = DEFAULT_FONTS[this.osType]; - - if (savedEditorFont !== null) { - this.editorFont = savedEditorFont; - } else { - this.editorFont = defaults.editorFont; - } - this.editorFontSize = parseFontSize(savedEditorFontSize, 14, 10, 24); - - if (savedPreviewFont !== null) { - this.previewFont = savedPreviewFont; - } else { - this.previewFont = defaults.previewFont; - } - this.previewFontSize = parseFontSize(savedPreviewFontSize, 16, 12, 28); + if (typeof localStorage === 'undefined') return; + + const entries = createSettingsPersistence(); + + // Whether a font *family* was ever chosen has to be sampled before the + // write effects run, because those effects seed every key with the + // current value; asking localStorage later would always find something. + const hasStoredFontFamily = { + editorFont: localStorage.getItem('editor.font') !== null, + previewFont: localStorage.getItem('preview.font') !== null, + codeFont: localStorage.getItem('preview.codeFont') !== null, + }; + + loadPersistedSettings(this, entries); + + // Font families default per OS, and the OS is only known once the + // backend answers. Re-apply just the ones the user never picked. + this.initOSType().then(() => { + const defaults = DEFAULT_FONTS[this.osType]; + if (!hasStoredFontFamily.editorFont) this.editorFont = defaults.editorFont; + if (!hasStoredFontFamily.previewFont) this.previewFont = defaults.previewFont; + if (!hasStoredFontFamily.codeFont) this.codeFont = defaults.codeFont; + }); - if (savedCodeFont !== null) { - this.codeFont = savedCodeFont; - } else { - this.codeFont = defaults.codeFont; - } - this.codeFontSize = parseFontSize(savedCodeFontSize, 14, 10, 24); - }); - - $effect.root(() => { - $effect(() => { - localStorage.setItem('editor.minimap', String(this.minimap)); - localStorage.setItem('editor.wordWrap', this.wordWrap); - localStorage.setItem('editor.lineNumbers', this.lineNumbers); - localStorage.setItem('editor.vimMode', String(this.vimMode)); - localStorage.setItem('editor.statusBar', String(this.statusBar)); - - localStorage.setItem('editor.wordCount', String(this.wordCount)); - localStorage.setItem('editor.renderLineHighlight', this.renderLineHighlight); - localStorage.setItem('editor.showTabs', String(this.showTabs)); - localStorage.setItem('editor.restoreStateOnReopen', String(this.restoreStateOnReopen)); - localStorage.setItem('editor.zenMode', String(this.zenMode)); - localStorage.setItem('editor.occurrencesHighlight', String(this.occurrencesHighlight)); - localStorage.setItem('editor.showWhitespace', String(this.showWhitespace)); - localStorage.setItem('editor.showToc', String(this.showToc)); - localStorage.setItem('editor.highlightColor', this.highlightColor); - localStorage.setItem('editor.startInEditor', String(this.startInEditor)); - localStorage.setItem('editor.newFileDefaultMode', String(this.newFileDefaultMode)); - localStorage.setItem('editor.showRecentFiles', String(this.showRecentFiles)); - localStorage.setItem('editor.maxWidth', String(this.editorMaxWidth)); - localStorage.setItem('preview.maxWidth', String(this.previewMaxWidth)); - localStorage.setItem('editor.pinnedToc', String(this.pinnedToc)); - localStorage.setItem('editor.tocSide', this.tocSide); - localStorage.setItem('editor.tocWidth', String(this.tocWidth)); - localStorage.setItem('editor.imageDirectory', this.imageDirectory); - localStorage.setItem('editor.macosImageScaling', String(this.macosImageScaling)); - localStorage.setItem('editor.language', this.language); - localStorage.setItem('editor.showEditorToolbar', String(this.showEditorToolbar)); - localStorage.setItem('editor.toolbarOrder', JSON.stringify(normalizeEditorToolbarOrder(this.editorToolbarOrder))); - localStorage.setItem('editor.toolbarHidden', JSON.stringify(normalizeEditorToolbarHidden(this.editorToolbarHidden))); - localStorage.setItem('titlebar.toolbarOrder', JSON.stringify(normalizeTitlebarToolbarOrder(this.titlebarToolbarOrder))); - localStorage.setItem('titlebar.toolbarHidden', JSON.stringify(normalizeTitlebarToolbarHidden(this.titlebarToolbarHidden))); - localStorage.setItem('titlebar.toolbarPlacement', JSON.stringify(normalizeTitlebarToolbarPlacement(this.titlebarToolbarPlacement))); - localStorage.setItem('editor.font', this.editorFont); - localStorage.setItem('editor.fontSize', String(this.editorFontSize)); - localStorage.setItem('preview.font', this.previewFont); - localStorage.setItem('preview.fontSize', String(this.previewFontSize)); - localStorage.setItem('preview.codeFont', this.codeFont); - localStorage.setItem('preview.codeFontSize', String(this.codeFontSize)); - localStorage.setItem('editor.autoSave', String(this.autoSave)); - localStorage.setItem('editor.confirmBeforeSave', String(this.confirmBeforeSave)); - if (this.preZenState) { - localStorage.setItem('editor.preZenState', JSON.stringify(this.preZenState)); - } else { - localStorage.removeItem('editor.preZenState'); - } - }); - }); - } + $effect.root(() => { + installPersistedSettings(this, entries); + }); } toggleMinimap() { @@ -504,7 +541,7 @@ export class SettingsStore { } setTocWidth(width: number) { - this.tocWidth = Math.min(420, Math.max(180, Math.round(width))); + this.tocWidth = clampToRange(width, TOC_WIDTH_RANGE); } toggleMacosImageScaling() { @@ -588,7 +625,7 @@ export class SettingsStore { } resetEditorMaxWidth() { - this.editorMaxWidth = 80; + this.editorMaxWidth = EDITOR_MAX_WIDTH_RANGE.default; } resetPreviewMaxWidth() { @@ -608,16 +645,164 @@ export class SettingsStore { resetEditorFont() { const defaults = DEFAULT_FONTS[this.osType]; this.editorFont = defaults.editorFont; - this.editorFontSize = 14; + this.editorFontSize = EDITOR_FONT_SIZE_RANGE.default; } resetPreviewFont() { const defaults = DEFAULT_FONTS[this.osType]; this.previewFont = defaults.previewFont; - this.previewFontSize = 16; + this.previewFontSize = PREVIEW_FONT_SIZE_RANGE.default; this.codeFont = defaults.codeFont; - this.codeFontSize = 14; + this.codeFontSize = CODE_FONT_SIZE_RANGE.default; } } +function booleanSetting( + key: string, + read: (target: SettingsStore) => boolean, + load: (target: SettingsStore, value: boolean) => void, +): PersistedSetting { + return { + key, + read: (target) => String(read(target)), + load: (target, raw) => { + if (raw !== null) load(target, raw === 'true'); + }, + }; +} + +function stringSetting( + key: string, + read: (target: SettingsStore) => string, + load: (target: SettingsStore, value: string) => void, +): PersistedSetting { + return { + key, + read, + load: (target, raw) => { + if (raw !== null) load(target, raw); + }, + }; +} + +function numberSetting( + key: string, + range: NumericSettingRange, + read: (target: SettingsStore) => number, + load: (target: SettingsStore, value: number) => void, +): PersistedSetting { + return { + key, + read: (target) => String(read(target)), + load: (target, raw) => load(target, parseStoredNumber(raw, range)), + }; +} + +/** + * The complete localStorage contract of {@link SettingsStore}: one entry per + * key, and — by construction — one store field per entry. + * + * Adding a setting means adding one entry here; load and save then cannot drift + * apart, and cross-window sync comes for free because + * {@link installPersistedSettings} reuses `load` for incoming `storage` events. + */ +export function createSettingsPersistence(): PersistedSetting[] { + return [ + booleanSetting('editor.minimap', (s) => s.minimap, (s, v) => { s.minimap = v; }), + stringSetting('editor.wordWrap', (s) => s.wordWrap, (s, v) => { s.wordWrap = v; }), + stringSetting('editor.lineNumbers', (s) => s.lineNumbers, (s, v) => { s.lineNumbers = v; }), + booleanSetting('editor.vimMode', (s) => s.vimMode, (s, v) => { s.vimMode = v; }), + booleanSetting('editor.statusBar', (s) => s.statusBar, (s, v) => { s.statusBar = v; }), + booleanSetting('editor.wordCount', (s) => s.wordCount, (s, v) => { s.wordCount = v; }), + stringSetting('editor.renderLineHighlight', (s) => s.renderLineHighlight, (s, v) => { s.renderLineHighlight = v; }), + booleanSetting('editor.showTabs', (s) => s.showTabs, (s, v) => { s.showTabs = v; }), + booleanSetting('editor.restoreStateOnReopen', (s) => s.restoreStateOnReopen, (s, v) => { s.restoreStateOnReopen = v; }), + booleanSetting('editor.zenMode', (s) => s.zenMode, (s, v) => { s.zenMode = v; }), + booleanSetting('editor.occurrencesHighlight', (s) => s.occurrencesHighlight, (s, v) => { s.occurrencesHighlight = v; }), + booleanSetting('editor.showWhitespace', (s) => s.showWhitespace, (s, v) => { s.showWhitespace = v; }), + booleanSetting('editor.showToc', (s) => s.showToc, (s, v) => { s.showToc = v; }), + stringSetting('editor.highlightColor', (s) => s.highlightColor, (s, v) => { s.highlightColor = v; }), + booleanSetting('editor.startInEditor', (s) => s.startInEditor, (s, v) => { s.startInEditor = v; }), + booleanSetting('editor.newFileDefaultMode', (s) => s.newFileDefaultMode, (s, v) => { s.newFileDefaultMode = v; }), + booleanSetting('editor.showRecentFiles', (s) => s.showRecentFiles, (s, v) => { s.showRecentFiles = v; }), + numberSetting('editor.maxWidth', EDITOR_MAX_WIDTH_RANGE, (s) => s.editorMaxWidth, (s, v) => { s.editorMaxWidth = v; }), + { + key: 'preview.maxWidth', + read: (s) => String(s.previewMaxWidth), + load: (s, savedPreviewMaxWidth) => { s.previewMaxWidth = normalizePreviewMaxWidth(savedPreviewMaxWidth); }, + }, + booleanSetting('editor.pinnedToc', (s) => s.pinnedToc, (s, v) => { s.pinnedToc = v; }), + { + key: 'editor.tocSide', + read: (s) => s.tocSide, + load: (s, raw) => { + if (raw === 'left' || raw === 'right') s.tocSide = raw; + }, + }, + numberSetting('editor.tocWidth', TOC_WIDTH_RANGE, (s) => s.tocWidth, (s, v) => { s.tocWidth = v; }), + stringSetting('editor.imageDirectory', (s) => s.imageDirectory, (s, v) => { s.imageDirectory = v; }), + booleanSetting('editor.macosImageScaling', (s) => s.macosImageScaling, (s, v) => { s.macosImageScaling = v; }), + { + key: 'editor.language', + read: (s) => s.language, + load: (s, raw) => { + if (raw === null) { + s.language = detectSystemLanguage(); + } else if (isSupportedLanguage(raw)) { + s.language = raw; + } + }, + }, + booleanSetting('editor.showEditorToolbar', (s) => s.showEditorToolbar, (s, v) => { s.showEditorToolbar = v; }), + { + key: 'editor.toolbarOrder', + read: (s) => JSON.stringify(normalizeEditorToolbarOrder(s.editorToolbarOrder)), + load: (s, raw) => { s.editorToolbarOrder = normalizeEditorToolbarOrder(parseStoredStringList(raw)); }, + }, + { + key: 'editor.toolbarHidden', + read: (s) => JSON.stringify(normalizeEditorToolbarHidden(s.editorToolbarHidden)), + load: (s, raw) => { s.editorToolbarHidden = normalizeEditorToolbarHidden(parseStoredStringList(raw)); }, + }, + { + key: 'titlebar.toolbarOrder', + read: (s) => JSON.stringify(normalizeTitlebarToolbarOrder(s.titlebarToolbarOrder)), + load: (s, raw) => { s.titlebarToolbarOrder = normalizeTitlebarToolbarOrder(parseStoredStringList(raw)); }, + }, + { + key: 'titlebar.toolbarHidden', + read: (s) => JSON.stringify(normalizeTitlebarToolbarHidden(s.titlebarToolbarHidden)), + load: (s, raw) => { s.titlebarToolbarHidden = normalizeTitlebarToolbarHidden(parseStoredStringList(raw)); }, + }, + { + key: 'titlebar.toolbarPlacement', + read: (s) => JSON.stringify(normalizeTitlebarToolbarPlacement(s.titlebarToolbarPlacement)), + load: (s, raw) => { s.titlebarToolbarPlacement = normalizeTitlebarToolbarPlacement(parseStoredRecord(raw)); }, + }, + stringSetting('editor.font', (s) => s.editorFont, (s, v) => { s.editorFont = v; }), + numberSetting('editor.fontSize', EDITOR_FONT_SIZE_RANGE, (s) => s.editorFontSize, (s, v) => { s.editorFontSize = v; }), + stringSetting('preview.font', (s) => s.previewFont, (s, v) => { s.previewFont = v; }), + numberSetting('preview.fontSize', PREVIEW_FONT_SIZE_RANGE, (s) => s.previewFontSize, (s, v) => { s.previewFontSize = v; }), + stringSetting('preview.codeFont', (s) => s.codeFont, (s, v) => { s.codeFont = v; }), + numberSetting('preview.codeFontSize', CODE_FONT_SIZE_RANGE, (s) => s.codeFontSize, (s, v) => { s.codeFontSize = v; }), + booleanSetting('editor.autoSave', (s) => s.autoSave, (s, v) => { s.autoSave = v; }), + booleanSetting('editor.confirmBeforeSave', (s) => s.confirmBeforeSave, (s, v) => { s.confirmBeforeSave = v; }), + { + key: 'editor.preZenState', + read: (s) => (s.preZenState ? JSON.stringify(s.preZenState) : null), + load: (s, raw) => { + if (raw === null) { + s.preZenState = null; + return; + } + try { + s.preZenState = JSON.parse(raw); + } catch (e) { + console.error('Failed to parse preZenState', e); + } + }, + }, + ]; +} + export const settings = new SettingsStore();