⚡ Unnecessary DOM writes in drag mousemove event (#98)

* perf: Optimize DOM writes in drag mousemove event

Optimizes the drag-and-drop `mousemove` event handler by caching the previously hovered element. The `drag-over` class is now only removed from `prevOver` and added to the new hover target, instead of looping through all `cachedItems` to remove the class on every move. This eliminates layout thrashing and high CPU usage during drag operations.

Co-authored-by: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>

* drop stray benchmark script

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
Horizontal42 2026-08-19 16:44:07 +03:00 • committed by GitHub
parent 203c4b4efd
commit 70492159e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

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