mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 22:41:58 +03:00
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>
This commit is contained in:
parent
203c4b4efd
commit
18d9c84805
2 changed files with 392 additions and 73 deletions
98
benchmark.ts
Normal file
98
benchmark.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
class MockClassList {
|
||||
classes: Set<string> = 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)}%`);
|
||||
367
src/main.ts
367
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 $ = <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 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<HTMLElement>(".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<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 = "";
|
||||
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> {
|
|||
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<void> {
|
|||
// ---------- 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 = $<HTMLInputElement>("#search-input");
|
||||
searchInput.placeholder = t("searchPlaceholder");
|
||||
|
|
@ -291,7 +376,10 @@ function applySettings(): void {
|
|||
});
|
||||
editor?.refresh();
|
||||
syncTitleField();
|
||||
$<HTMLElement>("#sidebar").classList.toggle("hidden", !settings.sidebarVisible);
|
||||
$<HTMLElement>("#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 = $<HTMLButtonElement>("#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<HTMLButtonElement>("#settings-tabs .tab");
|
||||
const tabPanels = document.querySelectorAll<HTMLElement>(".settings-tabpanel");
|
||||
const tabs = document.querySelectorAll<HTMLButtonElement>(
|
||||
"#settings-tabs .tab",
|
||||
);
|
||||
const tabPanels =
|
||||
document.querySelectorAll<HTMLElement>(".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<string, string> = {
|
||||
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<boolean> {
|
||||
async function registerHotkey(
|
||||
old: string | null,
|
||||
combo: string,
|
||||
): Promise<boolean> {
|
||||
if (!isTauri()) return true;
|
||||
try {
|
||||
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> {
|
||||
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<void> {
|
||||
|
|
@ -575,7 +726,7 @@ async function refreshRates(force = false): Promise<void> {
|
|||
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<void> {
|
|||
// ---------- 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<string, number> = {};
|
||||
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> {
|
||||
const dates = new Set<string>();
|
||||
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<void> {
|
|||
}
|
||||
}
|
||||
}
|
||||
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<unknown> = 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<void> {
|
||||
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<void> {
|
|||
|
||||
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<void> {
|
|||
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<void> {
|
||||
|
|
@ -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 = "";
|
||||
$<HTMLInputElement>("#set-resultswidth").value = String(settings.resultsWidth);
|
||||
$<HTMLInputElement>("#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<HTMLButtonElement>("[data-action]");
|
||||
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>(
|
||||
"[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<boolean>("write_text_file", { contents: content, isSum });
|
||||
const saved = await invoke<boolean>("write_text_file", {
|
||||
contents: content,
|
||||
isSum,
|
||||
});
|
||||
if (saved) {
|
||||
toast(t("saved"));
|
||||
}
|
||||
|
|
@ -1040,11 +1253,15 @@ async function bindLifecycle(): Promise<void> {
|
|||
// 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<void> {
|
||||
|
||||
applySettings(); // sets the language before bindSettingsUI renders dynamic labels
|
||||
bindSettingsUI();
|
||||
renderDocList();
|
||||
|
|
@ -1078,7 +1295,11 @@ async function checkUpdate(manual = false): Promise<void> {
|
|||
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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue