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.
This commit is contained in:
Horizontal42 2026-07-01 14:54:44 +03:00
parent 6efae0c706
commit 9aca062add
7 changed files with 178 additions and 0 deletions

View file

@ -16,6 +16,7 @@
<button id="toggle-sidebar" title="Documents (Ctrl+B)">☰</button> <button id="toggle-sidebar" title="Documents (Ctrl+B)">☰</button>
<input id="doc-title" spellcheck="false" autocomplete="off" /> <input id="doc-title" spellcheck="false" autocomplete="off" />
<span class="spacer" data-tauri-drag-region></span> <span class="spacer" data-tauri-drag-region></span>
<button id="open-search" title="Search all sheets (Ctrl+F)">🔍</button>
<button id="open-settings" title="Settings">⚙</button> <button id="open-settings" title="Settings">⚙</button>
<div id="win-controls"> <div id="win-controls">
<button id="win-min" title="Minimize">─</button> <button id="win-min" title="Minimize">─</button>
@ -79,6 +80,9 @@
<label class="row"><input id="set-autostart" type="checkbox" /> <label class="row"><input id="set-autostart" type="checkbox" />
<span data-i18n="autostart">Launch at startup</span> <span data-i18n="autostart">Launch at startup</span>
</label> </label>
<label class="row"><input id="set-alwaysontop" type="checkbox" />
<span data-i18n="alwaysontop">Always on top</span>
</label>
<label><span data-i18n="fontsize">Font size</span> <label><span data-i18n="fontsize">Font size</span>
<input id="set-fontsize" type="number" min="10" max="32" /> <input id="set-fontsize" type="number" min="10" max="32" />
</label> </label>
@ -107,6 +111,12 @@
</div> </div>
</div> </div>
</div> </div>
<div id="search-overlay" class="hidden">
<div class="search-box">
<input id="search-input" type="text" autocomplete="off" spellcheck="false" />
<div id="search-results"></div>
</div>
</div>
</div> </div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>

View file

@ -13,6 +13,7 @@
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-set-always-on-top",
"global-shortcut:allow-register", "global-shortcut:allow-register",
"global-shortcut:allow-unregister", "global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered", "global-shortcut:allow-is-registered",

View file

@ -12,6 +12,7 @@ const dict: Record<string, Record<string, string>> = {
language: "Language", language: "Language",
hotkey: "Global hotkey", hotkey: "Global hotkey",
autostart: "Launch at startup", autostart: "Launch at startup",
alwaysontop: "Always on top",
fontsize: "Font size", fontsize: "Font size",
extensions: "Extensions folder", extensions: "Extensions folder",
done: "Done", done: "Done",
@ -52,6 +53,8 @@ const dict: Record<string, Record<string, string>> = {
updateLater: "Later", updateLater: "Later",
updateInstalling: "Installing update…", updateInstalling: "Installing update…",
updateFailed: "Update failed", updateFailed: "Update failed",
searchPlaceholder: "Search all sheets…",
searchEmpty: "No matches",
}, },
ru: { ru: {
settings: "Настройки", settings: "Настройки",
@ -64,6 +67,7 @@ const dict: Record<string, Record<string, string>> = {
language: "Язык", language: "Язык",
hotkey: "Глобальный хоткей", hotkey: "Глобальный хоткей",
autostart: "Запуск при старте Windows", autostart: "Запуск при старте Windows",
alwaysontop: "Поверх всех окон",
fontsize: "Размер шрифта", fontsize: "Размер шрифта",
extensions: "Папка расширений", extensions: "Папка расширений",
done: "Готово", done: "Готово",
@ -104,6 +108,8 @@ const dict: Record<string, Record<string, string>> = {
updateLater: "Позже", updateLater: "Позже",
updateInstalling: "Установка обновления…", updateInstalling: "Установка обновления…",
updateFailed: "Не удалось обновить", updateFailed: "Не удалось обновить",
searchPlaceholder: "Поиск по всем листам…",
searchEmpty: "Ничего не найдено",
}, },
}; };

View file

@ -111,6 +111,74 @@ function newDoc(content = ""): void {
switchDoc(id); 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 = $<HTMLInputElement>("#search-input");
$("#search-overlay").classList.remove("hidden");
input.value = "";
renderSearchResults("", []);
input.focus();
}
function closeSearch(): void {
$("#search-overlay").classList.add("hidden");
}
// ---------- settings // ---------- settings
function applySettings(): void { function applySettings(): void {
@ -121,6 +189,7 @@ function applySettings(): void {
document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`); document.documentElement.style.setProperty("--editor-font-size", `${settings.fontSize}px`);
$("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`); $("#editor-wrap").style.setProperty("--results-width", `${settings.resultsWidth}%`);
setLang(settings.language); setLang(settings.language);
$<HTMLInputElement>("#search-input").placeholder = t("searchPlaceholder");
engine.updateSettings({ engine.updateSettings({
precision: settings.precision, precision: settings.precision,
groupSeparator: settings.groupSeparator, groupSeparator: settings.groupSeparator,
@ -139,6 +208,7 @@ function bindSettingsUI(): void {
const langSel = $<HTMLSelectElement>("#set-lang"); const langSel = $<HTMLSelectElement>("#set-lang");
const hotkey = $<HTMLInputElement>("#set-hotkey"); const hotkey = $<HTMLInputElement>("#set-hotkey");
const autostart = $<HTMLInputElement>("#set-autostart"); const autostart = $<HTMLInputElement>("#set-autostart");
const alwaysOnTop = $<HTMLInputElement>("#set-alwaysontop");
const fontSize = $<HTMLInputElement>("#set-fontsize"); const fontSize = $<HTMLInputElement>("#set-fontsize");
const resultsWidth = $<HTMLInputElement>("#set-resultswidth"); const resultsWidth = $<HTMLInputElement>("#set-resultswidth");
@ -149,6 +219,7 @@ function bindSettingsUI(): void {
langSel.value = settings.language; langSel.value = settings.language;
hotkey.value = settings.hotkey; hotkey.value = settings.hotkey;
autostart.checked = settings.autostart; autostart.checked = settings.autostart;
alwaysOnTop.checked = settings.alwaysOnTop;
fontSize.value = String(settings.fontSize); fontSize.value = String(settings.fontSize);
resultsWidth.value = String(settings.resultsWidth); resultsWidth.value = String(settings.resultsWidth);
@ -220,6 +291,11 @@ function bindSettingsUI(): void {
await applyAutostart(settings.autostart); await applyAutostart(settings.autostart);
void saveSettings(settings); void saveSettings(settings);
}); });
alwaysOnTop.addEventListener("change", async () => {
settings.alwaysOnTop = alwaysOnTop.checked;
await applyAlwaysOnTop(settings.alwaysOnTop);
void saveSettings(settings);
});
const binDays = $<HTMLInputElement>("#set-bindays"); const binDays = $<HTMLInputElement>("#set-bindays");
binDays.value = String(settings.deletedRetentionDays); binDays.value = String(settings.deletedRetentionDays);
@ -339,6 +415,12 @@ 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) => console.warn("always-on-top failed", e));
}
async function refreshRates(force = false): Promise<void> { async function refreshRates(force = false): Promise<void> {
const info = $("#rates-info"); const info = $("#rates-info");
info.classList.add("spin"); info.classList.add("spin");
@ -526,6 +608,22 @@ async function boot(): Promise<void> {
} }
}); });
$("#open-search").addEventListener("click", () => openSearch());
const searchInput = $<HTMLInputElement>("#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<HTMLElement>(".search-item")?.click();
}
});
$("#search-overlay").addEventListener("mousedown", (e) => {
if (e.target === $("#search-overlay")) closeSearch();
});
if (isTauri()) { if (isTauri()) {
const { getCurrentWindow } = await import("@tauri-apps/api/window"); const { getCurrentWindow } = await import("@tauri-apps/api/window");
const win = getCurrentWindow(); const win = getCurrentWindow();
@ -587,6 +685,10 @@ async function boot(): Promise<void> {
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b") { if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "b") {
e.preventDefault(); e.preventDefault();
$("#toggle-sidebar").click(); $("#toggle-sidebar").click();
}
if (e.ctrlKey && !e.shiftKey && e.key.toLowerCase() === "f") {
e.preventDefault();
openSearch();
} }
if (e.ctrlKey && e.shiftKey && e.code === "KeyC") { if (e.ctrlKey && e.shiftKey && e.code === "KeyC") {
e.preventDefault(); e.preventDefault();
@ -637,6 +739,7 @@ async function boot(): Promise<void> {
await registerHotkey(null, settings.hotkey); await registerHotkey(null, settings.hotkey);
if (settings.autostart) await applyAutostart(true); if (settings.autostart) await applyAutostart(true);
if (settings.alwaysOnTop) await applyAlwaysOnTop(true);
void onAppQuit(() => flushAppData()); void onAppQuit(() => flushAppData());
void refreshRates(); void refreshRates();
setInterval(() => void refreshRates(), 60 * 60 * 1000); setInterval(() => void refreshRates(), 60 * 60 * 1000);

View file

@ -29,6 +29,8 @@ export interface SettingsData {
dataDir: string; dataDir: string;
/** how long deleted sheets stay in backups/deleted */ /** how long deleted sheets stay in backups/deleted */
deletedRetentionDays: number; deletedRetentionDays: number;
/** keep the window above others */
alwaysOnTop: boolean;
} }
export const defaultSettingsData: SettingsData = { export const defaultSettingsData: SettingsData = {
@ -44,6 +46,7 @@ export const defaultSettingsData: SettingsData = {
resultsWidth: 42, resultsWidth: 42,
dataDir: "", dataDir: "",
deletedRetentionDays: 14, deletedRetentionDays: 14,
alwaysOnTop: false,
}; };
/** "" -> undefined for the Rust side (default app data dir) */ /** "" -> undefined for the Rust side (default app data dir) */

View file

@ -433,6 +433,50 @@ body {
.modal-btns button:hover { background: var(--hover); } .modal-btns button:hover { background: var(--hover); }
.modal-btns button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } .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 */ /* autocomplete */
.cm-tooltip.cm-tooltip-autocomplete { .cm-tooltip.cm-tooltip-autocomplete {
background: var(--bg-soft); background: var(--bg-soft);

View file

@ -252,6 +252,17 @@ export class SumEditor {
this.view.focus(); 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 { private evaluate(text: string): void {
this.results = this.engine.evaluateDocument(text); this.results = this.engine.evaluateDocument(text);
this.view.dispatch({ effects: setResults.of(this.results) }); this.view.dispatch({ effects: setResults.of(this.results) });