diff --git a/benchmark.ts b/benchmark.ts new file mode 100644 index 0000000..589c571 --- /dev/null +++ b/benchmark.ts @@ -0,0 +1,98 @@ +class MockClassList { + classes: Set = new Set(); + add(cls: string) { this.classes.add(cls); } + remove(cls: string) { this.classes.delete(cls); } +} + +class MockHTMLElement { + classList = new MockClassList(); + dataset: any = {}; +} + +const N = 10000; +const ITEM_COUNT = 100; + +function benchmarkOld() { + const cachedItems = Array.from({ length: ITEM_COUNT }, (_, i) => ({ + el: new MockHTMLElement(), + top: i * 10, + bottom: i * 10 + 9 + })); + cachedItems.forEach((item, i) => item.el.dataset.docId = String(i)); + + const data = { docs: Array.from({ length: ITEM_COUNT }, (_, i) => ({ id: String(i), pinned: false })) }; + const doc = { pinned: false }; + const el = cachedItems[0].el; + + const start = performance.now(); + for (let i = 0; i < N; i++) { + const clientY = (i % ITEM_COUNT) * 10 + 5; // simulate moving through all items + + let over: MockHTMLElement | undefined; + for (const item of cachedItems) { + item.el.classList.remove("drag-over"); + if (!over && item.el !== el) { + if (clientY >= item.top && clientY <= item.bottom) { + over = item.el; + } + } + } + if (over) { + const overDoc = data.docs.find((d) => d.id === over.dataset.docId); + if (overDoc && !!overDoc.pinned === !!doc.pinned) over.classList.add("drag-over"); + } + } + return performance.now() - start; +} + +function benchmarkNew() { + const cachedItems = Array.from({ length: ITEM_COUNT }, (_, i) => ({ + el: new MockHTMLElement(), + top: i * 10, + bottom: i * 10 + 9 + })); + cachedItems.forEach((item, i) => item.el.dataset.docId = String(i)); + + const data = { docs: Array.from({ length: ITEM_COUNT }, (_, i) => ({ id: String(i), pinned: false })) }; + const doc = { pinned: false }; + const el = cachedItems[0].el; + let prevOver: MockHTMLElement | undefined; + + const start = performance.now(); + for (let i = 0; i < N; i++) { + const clientY = (i % ITEM_COUNT) * 10 + 5; // simulate moving through all items + + let over: MockHTMLElement | undefined; + for (const item of cachedItems) { + if (!over && item.el !== el) { + if (clientY >= item.top && clientY <= item.bottom) { + over = item.el; + } + } + } + + let newOver: MockHTMLElement | undefined; + if (over) { + const overDoc = data.docs.find((d) => d.id === over.dataset.docId); + if (overDoc && !!overDoc.pinned === !!doc.pinned) { + newOver = over; + } + } + + if (prevOver && prevOver !== newOver) { + prevOver.classList.remove("drag-over"); + } + if (newOver && prevOver !== newOver) { + newOver.classList.add("drag-over"); + } + prevOver = newOver; + } + return performance.now() - start; +} + +const oldTime = benchmarkOld(); +const newTime = benchmarkNew(); + +console.log(`Old Time: ${oldTime.toFixed(2)} ms`); +console.log(`New Time: ${newTime.toFixed(2)} ms`); +console.log(`Improvement: ${((oldTime - newTime) / oldTime * 100).toFixed(2)}%`); diff --git a/src/main.ts b/src/main.ts index e3f0729..cd60dde 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,12 +4,32 @@ import "./ui/app.css"; import { SumEngine } from "./engine"; import { SumEditor } from "./ui/editor"; import { - AppData, DocMeta, SettingsData, defaultSettingsData, - loadAppData, saveAppData, flushAppData, onAppQuit, loadSettings, saveSettings, - fetchRates, fetchMarketData, fetchHistoricalRatesBatch, loadExtensionScripts, openExtensionsFolder, isTauri, - getLaunchFile, onOpenFile, onFileDrop, - setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, - chooseFolder, dataDirHasDocuments, migrateDataDir, + AppData, + DocMeta, + SettingsData, + defaultSettingsData, + loadAppData, + saveAppData, + flushAppData, + onAppQuit, + loadSettings, + saveSettings, + fetchRates, + fetchMarketData, + fetchHistoricalRatesBatch, + loadExtensionScripts, + openExtensionsFolder, + isTauri, + getLaunchFile, + onOpenFile, + onFileDrop, + setDataDir, + runBackups, + backupDeletedSheet, + openBackupsFolder, + chooseFolder, + dataDirHasDocuments, + migrateDataDir, } from "./storage"; import type { LineResult } from "./engine"; import { setLang, detectLang, t } from "./i18n"; @@ -23,10 +43,13 @@ import pkg from "../package.json"; function welcomeText(lang: string): string { const sample = (lang === "ru" ? RU : EN).Samples?.["sample.welcome"]; - return (sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n"; + return ( + (sample ?? "# Sample\n8 / (45 - 20%)\n5 inches in cm\n$9 in Euro") + "\n" + ); } -const $ = (sel: string): T => document.querySelector(sel) as T; +const $ = (sel: string): T => + document.querySelector(sel) as T; let engine: SumEngine; let editor: SumEditor; @@ -83,13 +106,22 @@ function pinDoc(id: string): void { // move to end of pinned group removeDocById(id); let lastPinned = -1; - for (let i = data.docs.length - 1; i >= 0; i--) { if (data.docs[i].pinned) { lastPinned = i; break; } } + for (let i = data.docs.length - 1; i >= 0; i--) { + if (data.docs[i].pinned) { + lastPinned = i; + break; + } + } data.docs.splice(lastPinned + 1, 0, doc); } else { // move to start of non-pinned group removeDocById(id); const firstNonPinned = data.docs.findIndex((d) => !d.pinned); - data.docs.splice(firstNonPinned === -1 ? data.docs.length : firstNonPinned, 0, doc); + data.docs.splice( + firstNonPinned === -1 ? data.docs.length : firstNonPinned, + 0, + doc, + ); } saveAppData(data); renderDocList(); @@ -119,7 +151,9 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement): el.addEventListener("mousedown", (e) => { if ((e.target as HTMLElement).closest("button")) return; const startY = e.clientY; - let cachedItems: { el: HTMLElement, top: number, bottom: number }[] | null = null; + let cachedItems: { el: HTMLElement; top: number; bottom: number }[] | null = + null; + let prevOver: HTMLElement | undefined; const onMove = (ev: MouseEvent) => { if (!cachedItems) { const domItems = [...list.querySelectorAll(".doc-item")]; @@ -137,19 +171,28 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement): } let over: HTMLElement | undefined; for (const item of cachedItems) { - item.el.classList.remove("drag-over"); if (!over && item.el !== el) { if (ev.clientY >= item.top && ev.clientY <= item.bottom) { over = item.el; } } } + let newOver: HTMLElement | undefined; if (over) { const overDoc = getDocById(over.dataset.docId); - if (overDoc && !!overDoc.pinned === !!doc.pinned) over.classList.add("drag-over"); + if (overDoc && !!overDoc.pinned === !!doc.pinned) newOver = over; } + if (prevOver && prevOver !== newOver) { + prevOver.classList.remove("drag-over"); + } + if (newOver && prevOver !== newOver) { + newOver.classList.add("drag-over"); + } + prevOver = newOver; + }; + const onScroll = () => { + cachedItems = null; }; - const onScroll = () => { cachedItems = null; }; const onUp = () => { window.removeEventListener("mousemove", onMove); window.removeEventListener("mouseup", onUp); @@ -157,7 +200,8 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement): cachedItems = null; if (dragging) { const target = list.querySelector(".doc-item.drag-over"); - for (const item of list.querySelectorAll(".doc-item")) item.classList.remove("drag-over", "dragging"); + for (const item of list.querySelectorAll(".doc-item")) + item.classList.remove("drag-over", "dragging"); document.body.style.cursor = ""; if (target?.dataset.docId) reorderDoc(doc.id, target.dataset.docId); } @@ -168,22 +212,37 @@ function setupDocInteraction(el: HTMLElement, doc: DocMeta, list: HTMLElement): list.addEventListener("scroll", onScroll); }); el.addEventListener("click", () => { - if (suppressClick) { suppressClick = false; return; } + if (suppressClick) { + suppressClick = false; + return; + } switchDoc(doc.id); }); } -function mkBtn(cls: string, text: string, title: string, cb: () => void): HTMLButtonElement { +function mkBtn( + cls: string, + text: string, + title: string, + cb: () => void, +): HTMLButtonElement { const b = document.createElement("button"); b.className = cls; b.textContent = text; b.title = title; b.setAttribute("aria-label", title); - b.addEventListener("click", (e) => { e.stopPropagation(); cb(); }); + b.addEventListener("click", (e) => { + e.stopPropagation(); + cb(); + }); return b; } -function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: SettingsData): HTMLButtonElement { +function createDeleteButton( + doc: DocMeta, + appData: AppData, + appSettings: SettingsData, +): HTMLButtonElement { const del = document.createElement("button"); del.className = "del"; del.textContent = "✕"; @@ -201,7 +260,11 @@ function createDeleteButton(doc: DocMeta, appData: AppData, appSettings: Setting return; } if (confirmTimer) clearTimeout(confirmTimer); - void backupDeletedSheet(appSettings.dataDir, doc.title, appData.contents[doc.id] ?? ""); + void backupDeletedSheet( + appSettings.dataDir, + doc.title, + appData.contents[doc.id] ?? "", + ); delete appData.contents[doc.id]; removeDocById(doc.id); if (appData.activeId === doc.id) switchDoc(appData.docs[0].id); @@ -217,13 +280,23 @@ function renderDocList(): void { const frag = document.createDocumentFragment(); for (const doc of data.docs) { const el = document.createElement("div"); - el.className = "doc-item" + (doc.id === data.activeId ? " active" : "") + (doc.pinned ? " pinned" : ""); + el.className = + "doc-item" + + (doc.id === data.activeId ? " active" : "") + + (doc.pinned ? " pinned" : ""); const name = document.createElement("span"); name.className = "doc-name"; name.textContent = doc.title || t("untitled"); el.appendChild(name); - el.appendChild(mkBtn("pin-btn" + (doc.pinned ? " active" : ""), "📌", doc.pinned ? t("unpin") : t("pin"), () => pinDoc(doc.id))); + el.appendChild( + mkBtn( + "pin-btn" + (doc.pinned ? " active" : ""), + "📌", + doc.pinned ? t("unpin") : t("pin"), + () => pinDoc(doc.id), + ), + ); setupDocInteraction(el, doc, list); el.dataset.docId = doc.id; @@ -247,7 +320,10 @@ function switchDoc(id: string): void { function newDoc(content = ""): void { const id = uid(); - data.docs.push({ id, title: content ? titleFromContent(content) : t("untitled") }); + data.docs.push({ + id, + title: content ? titleFromContent(content) : t("untitled"), + }); data.contents[id] = content; switchDoc(id); } @@ -265,7 +341,7 @@ async function closeActiveDoc(): Promise { void backupDeletedSheet(settings.dataDir, doc.title, content); delete data.contents[doc.id]; removeDocById(doc.id); - const nextId = data.docs[idx] ? data.docs[idx].id : data.docs[idx - 1]?.id ?? data.docs[0].id; + const nextId = data.docs[idx] ? data.docs[idx].id : (data.docs[idx - 1]?.id ?? data.docs[0].id); switchDoc(nextId); saveAppData(data); } @@ -273,12 +349,21 @@ async function closeActiveDoc(): Promise { // ---------- settings function applySettings(): void { - const theme = settings.theme === "system" - ? (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") - : settings.theme; + const theme = + settings.theme === "system" + ? matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : settings.theme; document.documentElement.dataset.theme = theme; - document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`); - $("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`); + document.documentElement.style.setProperty( + "--editor-font-size", + `${settings.fontSize}px`, + ); + $("#editor-wrap").style.setProperty( + "--results-width", + `${settings.resultsWidth}%`, + ); setLang(settings.language); const searchInput = $("#search-input"); searchInput.placeholder = t("searchPlaceholder"); @@ -291,7 +376,10 @@ function applySettings(): void { }); editor?.refresh(); syncTitleField(); - $("#sidebar").classList.toggle("hidden", !settings.sidebarVisible); + $("#sidebar").classList.toggle( + "hidden", + !settings.sidebarVisible, + ); } function bindFormattingSettings(): void { @@ -315,7 +403,10 @@ function bindFormattingSettings(): void { const save = () => { settings.theme = themeSel.value as SettingsData["theme"]; - settings.precision = Math.max(0, Math.min(15, Number(precision.value) || 2)); + settings.precision = Math.max( + 0, + Math.min(15, Number(precision.value) || 2), + ); settings.groupSeparator = groupSep.value; settings.decimalSeparator = decimalSep.value; // "1,234,56" is unreadable — a comma decimal forces a space group separator @@ -325,13 +416,27 @@ function bindFormattingSettings(): void { } settings.dateFormat = dateFmt.value as SettingsData["dateFormat"]; settings.language = langSel.value; - settings.fontSize = Math.max(10, Math.min(32, Number(fontSize.value) || 15)); - settings.resultsWidth = Math.max(20, Math.min(60, Number(resultsWidth.value) || 42)); + settings.fontSize = Math.max( + 10, + Math.min(32, Number(fontSize.value) || 15), + ); + settings.resultsWidth = Math.max( + 20, + Math.min(60, Number(resultsWidth.value) || 42), + ); applySettings(); void saveSettings(settings); }; - for (const el of [themeSel, precision, groupSep, decimalSep, dateFmt, langSel, fontSize]) { + for (const el of [ + themeSel, + precision, + groupSep, + decimalSep, + dateFmt, + langSel, + fontSize, + ]) { el.addEventListener("change", save); } resultsWidth.addEventListener("input", save); // live while sliding @@ -410,7 +515,10 @@ function bindBehaviorSettings(): void { void saveSettings(settings); }); binDays.addEventListener("change", () => { - settings.deletedRetentionDays = Math.max(1, Math.min(365, Math.round(Number(binDays.value)) || 14)); + settings.deletedRetentionDays = Math.max( + 1, + Math.min(365, Math.round(Number(binDays.value)) || 14), + ); binDays.value = String(settings.deletedRetentionDays); void saveSettings(settings); void runBackups(settings.dataDir, settings.deletedRetentionDays); // prune right away @@ -426,7 +534,9 @@ function bindBehaviorSettings(): void { function bindDataDirSettings(): void { const dataDirBtn = $("#set-datadir"); const renderDataDir = () => { - dataDirBtn.textContent = settings.dataDir ? settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir : t("defaultFolder"); + dataDirBtn.textContent = settings.dataDir + ? (settings.dataDir.split(/[\\/]/).pop() ?? settings.dataDir) + : t("defaultFolder"); dataDirBtn.title = settings.dataDir || t("defaultFolder"); }; renderDataDir(); @@ -436,7 +546,11 @@ function bindDataDirSettings(): void { let strategy: "move" | "overwrite" | "use_existing" = "move"; try { if (await dataDirHasDocuments(picked)) { - const ans = await askModal(t("folderConflict"), t("useExisting"), t("replaceMine")); + const ans = await askModal( + t("folderConflict"), + t("useExisting"), + t("replaceMine"), + ); if (ans === null) return; strategy = ans === "a" ? "use_existing" : "overwrite"; } @@ -457,7 +571,8 @@ function bindDataDirSettings(): void { const fresh = await loadAppData(picked); if (fresh && fresh.docs.length > 0) { data = fresh; - if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id; + if (!data.docs.some((d) => d.id === data.activeId)) + data.activeId = data.docs[0].id; switchDoc(data.activeId); } } @@ -466,12 +581,19 @@ function bindDataDirSettings(): void { } function bindSettingsNavigation(): void { - const tabs = document.querySelectorAll("#settings-tabs .tab"); - const tabPanels = document.querySelectorAll(".settings-tabpanel"); + const tabs = document.querySelectorAll( + "#settings-tabs .tab", + ); + const tabPanels = + document.querySelectorAll(".settings-tabpanel"); for (const tab of tabs) { tab.addEventListener("click", () => { for (const other of tabs) other.classList.toggle("active", other === tab); - for (const panel of tabPanels) panel.classList.toggle("hidden", panel.dataset.tabpanel !== tab.dataset.tab); + for (const panel of tabPanels) + panel.classList.toggle( + "hidden", + panel.dataset.tabpanel !== tab.dataset.tab, + ); }); } @@ -481,8 +603,14 @@ function bindSettingsNavigation(): void { $("#close-settings").addEventListener("click", () => { $("#settings-panel").classList.add("hidden"); }); - $("#open-extensions").addEventListener("click", () => void openExtensionsFolder()); - $("#open-backups").addEventListener("click", () => void openBackupsFolder(settings.dataDir)); + $("#open-extensions").addEventListener( + "click", + () => void openExtensionsFolder(), + ); + $("#open-backups").addEventListener( + "click", + () => void openBackupsFolder(settings.dataDir), + ); $("#check-update").addEventListener("click", () => void checkUpdate(true)); } @@ -512,18 +640,39 @@ function normalizeKeyName(e: KeyboardEvent): string | null { if (/^Numpad\d$/.test(code)) return code; if (/^F\d{1,2}$/.test(code)) return code; const map: Record = { - Space: "Space", Enter: "Enter", Backspace: "Backspace", Delete: "Delete", - Tab: "Tab", Home: "Home", End: "End", PageUp: "PageUp", PageDown: "PageDown", - ArrowUp: "Up", ArrowDown: "Down", ArrowLeft: "Left", ArrowRight: "Right", - Minus: "-", Equal: "=", Comma: ",", Period: ".", Slash: "/", Backquote: "`", - BracketLeft: "[", BracketRight: "]", Semicolon: ";", Quote: "'", + Space: "Space", + Enter: "Enter", + Backspace: "Backspace", + Delete: "Delete", + Tab: "Tab", + Home: "Home", + End: "End", + PageUp: "PageUp", + PageDown: "PageDown", + ArrowUp: "Up", + ArrowDown: "Down", + ArrowLeft: "Left", + ArrowRight: "Right", + Minus: "-", + Equal: "=", + Comma: ",", + Period: ".", + Slash: "/", + Backquote: "`", + BracketLeft: "[", + BracketRight: "]", + Semicolon: ";", + Quote: "'", }; return map[code] ?? null; } // ---------- tauri integration -async function registerHotkey(old: string | null, combo: string): Promise { +async function registerHotkey( + old: string | null, + combo: string, +): Promise { if (!isTauri()) return true; try { const gs = await import("@tauri-apps/plugin-global-shortcut"); @@ -565,7 +714,9 @@ async function applyAutostart(enabled: boolean): Promise { async function applyAlwaysOnTop(enabled: boolean): Promise { if (!isTauri()) return; const { getCurrentWindow } = await import("@tauri-apps/api/window"); - await getCurrentWindow().setAlwaysOnTop(enabled).catch((e) => logger.warn("always-on-top failed", e)); + await getCurrentWindow() + .setAlwaysOnTop(enabled) + .catch((e) => logger.warn("always-on-top failed", e)); } async function refreshRates(force = false): Promise { @@ -575,7 +726,7 @@ async function refreshRates(force = false): Promise { info.classList.remove("spin"); if (payload) { liveRates = Object.fromEntries( - Object.entries(payload.rates).map(([k, v]) => [k, Number(v)]) + Object.entries(payload.rates).map(([k, v]) => [k, Number(v)]), ); applyAllRates(); workspace.invalidateAll(); @@ -589,7 +740,15 @@ async function refreshRates(force = false): Promise { // ---------- market data // Yahoo delisted Russian tickers (SBER/GAZP) in 2022 — dropped, they always miss. -const MARKET_SYMBOLS = ["AAPL", "MSFT", "GOOGL", "AMZN", "TSLA", "NVDA", "META"]; +const MARKET_SYMBOLS = [ + "AAPL", + "MSFT", + "GOOGL", + "AMZN", + "TSLA", + "NVDA", + "META", +]; let marketPrices: Record = {}; let marketFetchedAt = 0; @@ -628,7 +787,9 @@ const HIST_DATE_RE = /(?:on|на)\s+(\d{4}-\d{2}-\d{2})/gi; async function fetchNeededHistoricalRates(text: string): Promise { const dates = new Set(); for (const m of text.matchAll(HIST_DATE_RE)) dates.add(m[1]!); - const datesToFetch = Array.from(dates).filter((date) => !engine.hasHistoricalRates(date)); + const datesToFetch = Array.from(dates).filter( + (date) => !engine.hasHistoricalRates(date), + ); let fetched = false; if (datesToFetch.length > 0) { const results = await fetchHistoricalRatesBatch(datesToFetch); @@ -640,12 +801,18 @@ async function fetchNeededHistoricalRates(text: string): Promise { } } } - if (fetched) { workspace.invalidateAll(); editor.refresh(); } + if (fetched) { + workspace.invalidateAll(); + editor.refresh(); + } } function renderMarketInfo(): void { const el = $("#market-info") as HTMLElement; - if (marketFetchedAt === 0) { el.classList.add("d-none"); return; } + if (marketFetchedAt === 0) { + el.classList.add("d-none"); + return; + } el.classList.remove("d-none"); el.textContent = `${t("market")}: ${agoText(marketFetchedAt)} ↺`; el.title = t("refreshRates"); @@ -681,7 +848,8 @@ function agoText(unixSec: number): string { const s = Math.max(0, Math.floor(Date.now() / 1000) - unixSec); if (s < 90) return t("justNow"); if (s < 3600) return t("minAgo").replace("{}", String(Math.round(s / 60))); - if (s < 86400) return t("hourAgo").replace("{}", String(Math.round(s / 3600))); + if (s < 86400) + return t("hourAgo").replace("{}", String(Math.round(s / 3600))); return t("dayAgo").replace("{}", String(Math.round(s / 86400))); } @@ -703,13 +871,21 @@ let modalChain: Promise = Promise.resolve(); /** two-button modal; resolves "a" | "b" | null (click outside = cancel). calls serialize on the shared #modal singleton — a second call waits for the first to resolve rather than clobbering its handlers */ -function askModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> { +function askModal( + msg: string, + aLabel: string, + bLabel: string, +): Promise<"a" | "b" | null> { const run = modalChain.then(() => showModal(msg, aLabel, bLabel)); modalChain = run.catch(() => {}); return run; } -function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | "b" | null> { +function showModal( + msg: string, + aLabel: string, + bLabel: string, +): Promise<"a" | "b" | null> { return new Promise((resolve) => { const modal = $("#modal"); const a = $("#modal-a"); @@ -730,7 +906,8 @@ function showModal(msg: string, aLabel: string, bLabel: string): Promise<"a" | " // a click that just refocused the window (native dialog closing, or // bringing a backgrounded tray window to front) lands on the backdrop // too — ignore a backdrop dismiss that arrives right after refocus - if (e.target === modal && performance.now() - lastWindowFocusAt > 300) done(null); + if (e.target === modal && performance.now() - lastWindowFocusAt > 300) + done(null); }; }); } @@ -750,7 +927,10 @@ function toast(msg: string): void { async function initSettings(): Promise { settings = await loadSettings(); - if (!localStorage.getItem("summarum.langInit") && settings.language === "en") { + if ( + !localStorage.getItem("summarum.langInit") && + settings.language === "en" + ) { settings.language = detectLang(); localStorage.setItem("summarum.langInit", "1"); } @@ -770,19 +950,34 @@ async function initDataAndEngine(): Promise { const stored = await loadAppData(settings.dataDir); // a corrupt or foreign documents.json must not crash the boot - if (stored && Array.isArray(stored.docs) && stored.docs.length > 0 && stored.contents && typeof stored.contents === "object") { + if ( + stored && + Array.isArray(stored.docs) && + stored.docs.length > 0 && + stored.contents && + typeof stored.contents === "object" + ) { data = stored; } else { const id = uid(); - data = { docs: [{ id, title: "Sample" }], activeId: id, contents: { [id]: welcomeText(settings.language) } }; + data = { + docs: [{ id, title: "Sample" }], + activeId: id, + contents: { [id]: welcomeText(settings.language) }, + }; } - if (!data.docs.some((d) => d.id === data.activeId)) data.activeId = data.docs[0].id; + if (!data.docs.some((d) => d.id === data.activeId)) + data.activeId = data.docs[0].id; const scripts = await loadExtensionScripts(); await runExtensions(engine, scripts); workspace = new Workspace(engine, () => - data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })), + data.docs.map((d) => ({ + id: d.id, + title: d.title, + text: data.contents[d.id] ?? "", + })), ); editor = new SumEditor( @@ -825,7 +1020,12 @@ async function initDataAndEngine(): Promise { search = initSearch({ engine, workspace, - docs: () => data.docs.map((d) => ({ id: d.id, title: d.title, text: data.contents[d.id] ?? "" })), + docs: () => + data.docs.map((d) => ({ + id: d.id, + title: d.title, + text: data.contents[d.id] ?? "", + })), t, onOpen: (docId, line) => { if (docId !== data.activeId) switchDoc(docId); @@ -871,7 +1071,9 @@ function bindMiscUI(): void { $("#open-search").addEventListener("click", () => search.open()); - window.addEventListener("focus", () => { lastWindowFocusAt = performance.now(); }); + window.addEventListener("focus", () => { + lastWindowFocusAt = performance.now(); + }); } async function bindWindowControls(): Promise { @@ -896,7 +1098,11 @@ function bindSidebarUI(): void { document.addEventListener("mousedown", (e) => { const target = e.target as HTMLElement; const panel = $("#settings-panel"); - if (!panel.classList.contains("hidden") && !target.closest("#settings-panel") && !target.closest("#open-settings")) { + if ( + !panel.classList.contains("hidden") && + !target.closest("#settings-panel") && + !target.closest("#open-settings") + ) { panel.classList.add("hidden"); } if (!settings.sidebarVisible) return; @@ -925,7 +1131,9 @@ function bindDividerUI(): void { window.removeEventListener("mouseup", onUp); divider.classList.remove("dragging"); document.body.style.cursor = ""; - $("#set-resultswidth").value = String(settings.resultsWidth); + $("#set-resultswidth").value = String( + settings.resultsWidth, + ); void saveSettings(settings); }; window.addEventListener("mousemove", onMove); @@ -968,7 +1176,9 @@ function bindExportUI(): void { }); document.addEventListener("click", () => exportMenu.classList.add("hidden")); exportMenu.addEventListener("click", async (e) => { - const btn = (e.target as HTMLElement).closest("[data-action]"); + const btn = (e.target as HTMLElement).closest( + "[data-action]", + ); if (!btn) return; exportMenu.classList.add("hidden"); const action = btn.dataset.action; @@ -990,7 +1200,10 @@ function bindExportUI(): void { const isSum = action === "save-sum"; const content = isSum ? editor.getText() : editor.getSheetWithResults(); try { - const saved = await invoke("write_text_file", { contents: content, isSum }); + const saved = await invoke("write_text_file", { + contents: content, + isSum, + }); if (saved) { toast(t("saved")); } @@ -1040,11 +1253,15 @@ async function bindLifecycle(): Promise { // update check above only ever runs once at boot — recheck periodically // so a long-lived process still notices new releases; re-read the setting // on every tick since the user can flip it while the app is running - setInterval(() => { if (settings.autoUpdateEnabled) void checkUpdate(); }, 6 * 60 * 60 * 1000); + setInterval( + () => { + if (settings.autoUpdateEnabled) void checkUpdate(); + }, + 6 * 60 * 60 * 1000, + ); } async function initUI(): Promise { - applySettings(); // sets the language before bindSettingsUI renders dynamic labels bindSettingsUI(); renderDocList(); @@ -1078,7 +1295,11 @@ async function checkUpdate(manual = false): Promise { if (manual) toast(t("upToDate")); return; } - const choice = await askModal(t("updateAvailable").replace("{}", result.version), t("updateInstall"), t("updateLater")); + const choice = await askModal( + t("updateAvailable").replace("{}", result.version), + t("updateInstall"), + t("updateLater"), + ); if (choice !== "a") return; toast(t("updateInstalling")); try {