From 9aca062addff9de8005ccb986d636cefbbaedba7 Mon Sep 17 00:00:00 2001 From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:54:44 +0300 Subject: [PATCH] add search across all sheets and always-on-top toggle Ctrl+F opens an overlay that searches every document's lines, not just the active one, and jumps to the hit on click. Always-on-top is a plain window pin, off by default, toggleable in settings. --- index.html | 10 +++ src-tauri/capabilities/default.json | 1 + src/i18n.ts | 6 ++ src/main.ts | 103 ++++++++++++++++++++++++++++ src/storage.ts | 3 + src/ui/app.css | 44 ++++++++++++ src/ui/editor.ts | 11 +++ 7 files changed, 178 insertions(+) diff --git a/index.html b/index.html index 7832af7..d5d77ba 100644 --- a/index.html +++ b/index.html @@ -16,6 +16,7 @@ +
@@ -79,6 +80,9 @@ + @@ -107,6 +111,12 @@
+ diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 62f4383..39aa864 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "core:window:allow-close", "core:window:allow-minimize", "core:window:allow-start-dragging", + "core:window:allow-set-always-on-top", "global-shortcut:allow-register", "global-shortcut:allow-unregister", "global-shortcut:allow-is-registered", diff --git a/src/i18n.ts b/src/i18n.ts index 1df80f9..d1f812d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -12,6 +12,7 @@ const dict: Record> = { language: "Language", hotkey: "Global hotkey", autostart: "Launch at startup", + alwaysontop: "Always on top", fontsize: "Font size", extensions: "Extensions folder", done: "Done", @@ -52,6 +53,8 @@ const dict: Record> = { updateLater: "Later", updateInstalling: "Installing update…", updateFailed: "Update failed", + searchPlaceholder: "Search all sheets…", + searchEmpty: "No matches", }, ru: { settings: "Настройки", @@ -64,6 +67,7 @@ const dict: Record> = { language: "Язык", hotkey: "Глобальный хоткей", autostart: "Запуск при старте Windows", + alwaysontop: "Поверх всех окон", fontsize: "Размер шрифта", extensions: "Папка расширений", done: "Готово", @@ -104,6 +108,8 @@ const dict: Record> = { updateLater: "Позже", updateInstalling: "Установка обновления…", updateFailed: "Не удалось обновить", + searchPlaceholder: "Поиск по всем листам…", + searchEmpty: "Ничего не найдено", }, }; diff --git a/src/main.ts b/src/main.ts index 4a277de..230faf8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -111,6 +111,74 @@ function newDoc(content = ""): void { switchDoc(id); } +// ---------- search all sheets + +interface SearchHit { + docId: string; + docTitle: string; + line: number; + text: string; +} + +function searchAllSheets(query: string): SearchHit[] { + const q = query.trim().toLowerCase(); + if (!q) return []; + const hits: SearchHit[] = []; + for (const doc of data.docs) { + const lines = (data.contents[doc.id] ?? "").split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().includes(q)) { + hits.push({ docId: doc.id, docTitle: doc.title, line: i + 1, text: lines[i] }); + if (hits.length >= 200) return hits; + } + } + } + return hits; +} + +function renderSearchResults(query: string, hits: SearchHit[]): void { + const el = $("#search-results"); + el.replaceChildren(); + if (query.trim() && hits.length === 0) { + const empty = document.createElement("div"); + empty.className = "search-empty"; + empty.textContent = t("searchEmpty"); + el.appendChild(empty); + return; + } + const frag = document.createDocumentFragment(); + for (const hit of hits) { + const item = document.createElement("div"); + item.className = "search-item"; + const docEl = document.createElement("div"); + docEl.className = "doc"; + docEl.textContent = hit.docTitle; + const lineEl = document.createElement("div"); + lineEl.className = "line"; + lineEl.textContent = hit.text; + item.append(docEl, lineEl); + item.addEventListener("click", () => { + if (hit.docId !== data.activeId) switchDoc(hit.docId); + editor.goToLine(hit.line); + closeSearch(); + }); + frag.appendChild(item); + } + el.appendChild(frag); +} + +function openSearch(): void { + const input = $("#search-input"); + $("#search-overlay").classList.remove("hidden"); + input.value = ""; + renderSearchResults("", []); + input.focus(); +} + +function closeSearch(): void { + $("#search-overlay").classList.add("hidden"); +} + // ---------- settings function applySettings(): void { @@ -121,6 +189,7 @@ function applySettings(): void { document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`); $("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`); setLang(settings.language); + $("#search-input").placeholder = t("searchPlaceholder"); engine.updateSettings({ precision: settings.precision, groupSeparator: settings.groupSeparator, @@ -139,6 +208,7 @@ function bindSettingsUI(): void { const langSel = $("#set-lang"); const hotkey = $("#set-hotkey"); const autostart = $("#set-autostart"); + const alwaysOnTop = $("#set-alwaysontop"); const fontSize = $("#set-fontsize"); const resultsWidth = $("#set-resultswidth"); @@ -149,6 +219,7 @@ function bindSettingsUI(): void { langSel.value = settings.language; hotkey.value = settings.hotkey; autostart.checked = settings.autostart; + alwaysOnTop.checked = settings.alwaysOnTop; fontSize.value = String(settings.fontSize); resultsWidth.value = String(settings.resultsWidth); @@ -220,6 +291,11 @@ function bindSettingsUI(): void { await applyAutostart(settings.autostart); void saveSettings(settings); }); + alwaysOnTop.addEventListener("change", async () => { + settings.alwaysOnTop = alwaysOnTop.checked; + await applyAlwaysOnTop(settings.alwaysOnTop); + void saveSettings(settings); + }); const binDays = $("#set-bindays"); binDays.value = String(settings.deletedRetentionDays); @@ -339,6 +415,12 @@ 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) => console.warn("always-on-top failed", e)); +} + async function refreshRates(force = false): Promise { const info = $("#rates-info"); info.classList.add("spin"); @@ -526,6 +608,22 @@ async function boot(): Promise { } }); + $("#open-search").addEventListener("click", () => openSearch()); + const searchInput = $("#search-input"); + searchInput.addEventListener("input", () => { + renderSearchResults(searchInput.value, searchAllSheets(searchInput.value)); + }); + searchInput.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + closeSearch(); + } else if (e.key === "Enter") { + $("#search-results").querySelector(".search-item")?.click(); + } + }); + $("#search-overlay").addEventListener("mousedown", (e) => { + if (e.target === $("#search-overlay")) closeSearch(); + }); + if (isTauri()) { const { getCurrentWindow } = await import("@tauri-apps/api/window"); const win = getCurrentWindow(); @@ -587,6 +685,10 @@ async function boot(): Promise { if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b") { e.preventDefault(); $("#toggle-sidebar").click(); + } + if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "f") { + e.preventDefault(); + openSearch(); } if (e.ctrlKey && e.shiftKey && e.code === "KeyC") { e.preventDefault(); @@ -637,6 +739,7 @@ async function boot(): Promise { await registerHotkey(null, settings.hotkey); if (settings.autostart) await applyAutostart(true); + if (settings.alwaysOnTop) await applyAlwaysOnTop(true); void onAppQuit(() => flushAppData()); void refreshRates(); setInterval(() => void refreshRates(), 60 * 60 * 1000); diff --git a/src/storage.ts b/src/storage.ts index 36c73cc..fad1231 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -29,6 +29,8 @@ export interface SettingsData { dataDir: string; /** how long deleted sheets stay in backups/deleted */ deletedRetentionDays: number; + /** keep the window above others */ + alwaysOnTop: boolean; } export const defaultSettingsData: SettingsData = { @@ -44,6 +46,7 @@ export const defaultSettingsData: SettingsData = { resultsWidth: 42, dataDir: "", deletedRetentionDays: 14, + alwaysOnTop: false, }; /** "" -> undefined for the Rust side (default app data dir) */ diff --git a/src/ui/app.css b/src/ui/app.css index cbb89a8..cb95b84 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -433,6 +433,50 @@ body { .modal-btns button:hover { background: var(--hover); } .modal-btns button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } +/* ---------- search-all-sheets overlay */ +#search-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12%; + z-index: 200; +} +#search-overlay.hidden { display: none; } +.search-box { + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35); + width: min(440px, 90vw); + max-height: 60vh; + display: flex; + flex-direction: column; + overflow: hidden; +} +#search-input { + border: none; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--fg); + padding: 12px 14px; + font-size: 14px; + outline: none; +} +#search-results { overflow-y: auto; padding: 6px; } +.search-item { + padding: 7px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; +} +.search-item:hover { background: var(--hover); } +.search-item .doc { color: var(--fg-muted); font-size: 11px; margin-bottom: 2px; } +.search-item .line { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.search-empty { padding: 10px; color: var(--fg-muted); font-size: 13px; } + /* autocomplete */ .cm-tooltip.cm-tooltip-autocomplete { background: var(--bg-soft); diff --git a/src/ui/editor.ts b/src/ui/editor.ts index 9d21961..4c0e91f 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -252,6 +252,17 @@ export class SumEditor { this.view.focus(); } + /** put the caret on line n (1-based) and scroll it into view */ + goToLine(n: number): void { + const doc = this.view.state.doc; + const line = doc.line(Math.min(Math.max(n, 1), doc.lines)); + this.view.dispatch({ + selection: { anchor: line.from, head: line.to }, + scrollIntoView: true, + }); + this.view.focus(); + } + private evaluate(text: string): void { this.results = this.engine.evaluateDocument(text); this.view.dispatch({ effects: setResults.of(this.results) });